From 4af66f159f7e2fe7b6409b10e411fa8a264b15f0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:43:20 -0700 Subject: [PATCH 1/6] Add tests for the top-nav search morph The magnifier's morph into an inline input, Esc collapsing it back, cmd+K and a click landing on one palette surface, a result navigating to a slug-addressed detail route, and the reduced-motion path where the swap is instant. --- apps/web/test/global-search-morph.test.tsx | 298 ++++++++++++++++++ .../command-palette/src/detail-paths.test.ts | 22 ++ 2 files changed, 320 insertions(+) create mode 100644 apps/web/test/global-search-morph.test.tsx create mode 100644 packages/command-palette/src/detail-paths.test.ts diff --git a/apps/web/test/global-search-morph.test.tsx b/apps/web/test/global-search-morph.test.tsx new file mode 100644 index 000000000..e48edbb3e --- /dev/null +++ b/apps/web/test/global-search-morph.test.tsx @@ -0,0 +1,298 @@ +// CL-6410: the product's one search surface. DESIGN.md's Search section fixes +// how it is invoked from chrome: the top-nav magnifier morphs in place into an +// inline bar over ~200ms with the spring easing, Esc collapses it, and cmd+K +// reaches the identical palette — never a second search implementation. This +// suite covers the morph's open/close behaviour, both doors landing on one +// surface, a result navigating to a slug-addressed detail route, and the +// reduced-motion path. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ThemeProvider } from "@corbits/react-ui"; + +import { BenchProvider } from "../src/bench-context"; +import { CommandPaletteProvider } from "../src/command-palette-provider"; +import { setCommandPaletteOpen } from "../src/command-palette-open-store"; +import { NavigationProvider } from "../src/navigation"; +import { StageTopBar } from "../src/shell/stage-top-bar"; +import { TestQueryProvider } from "./test-query-provider"; + +const noop = () => undefined; +const realFetch = globalThis.fetch; +const realMatchMedia = window.matchMedia; + +const TENANT = "tnt_1"; + +const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; + +function stubMatchMedia(matching: Record): void { + window.matchMedia = ((media: string) => + ({ + media, + matches: matching[media] ?? false, + addEventListener: noop, + removeEventListener: noop, + }) as unknown as MediaQueryList) as typeof window.matchMedia; +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const definition = { + id: "wfd_1", + tenantId: TENANT, + name: "research-analyst", + description: "Answers research questions", + currentVersion: "1", + status: "deployed" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function stubShellFetch(): void { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = String(input); + if (path.includes("/api/me/principals")) + return Promise.resolve( + json({ + data: [ + { + principalId: "prn_1", + tenantId: TENANT, + tenantName: "Corbits Bench", + tenantSlug: "corbits-bench", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }), + ); + if (path.includes("/api/workbench-tenancies/kinds")) + return Promise.resolve(json({ workbenchTenantIds: [] })); + if (path.includes("/workflows/definitions")) + return Promise.resolve(json({ data: [definition], nextCursor: null })); + if (path.includes("/mcp-servers")) + return Promise.resolve(json({ data: [] })); + if (path.includes("/skills")) return Promise.resolve(json({ skills: [] })); + if (path.includes("/routines")) return Promise.resolve(json({ data: [] })); + return Promise.resolve(json({ data: [], nextCursor: null })); + }) as typeof fetch; +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + stubMatchMedia({}); + stubShellFetch(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + setCommandPaletteOpen(false); + globalThis.fetch = realFetch; + window.matchMedia = realMatchMedia; +}); + +async function settle(): Promise { + for (let i = 0; i < 25; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +async function render(node: React.ReactElement): Promise { + await act(async () => { + root.render(node); + }); + await settle(); +} + +function searchShell(): HTMLElement { + const shell = container.querySelector( + '[data-testid="stage-search"]', + ); + if (shell === null) throw new Error("the top nav renders no search control"); + return shell; +} + +function magnifier(): HTMLButtonElement { + const button = searchShell().querySelector( + 'button[aria-label="Search"]', + ); + if (button === null) throw new Error("no magnifier in the top nav"); + return button; +} + +function morphField(): HTMLInputElement | null { + return container.querySelector( + '[data-testid="stage-search-input"]', + ); +} + +function paletteInputs(): readonly HTMLInputElement[] { + return [...document.querySelectorAll('[role="combobox"]')]; +} + +function TopBarOnly() { + return ( + + + + ); +} + +function Harness({ + navigate = noop, +}: { + readonly navigate?: (to: string) => void; +}) { + return ( + + + + + + + + + + + ); +} + +describe("the top-nav search morph", () => { + test("the collapsed control is a magnifier and nothing else", async () => { + await render(); + expect(magnifier().getAttribute("aria-expanded")).toBe("false"); + expect(morphField()).toBeNull(); + }); + + test("clicking the magnifier morphs it in place into an inline input", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + + expect(morphField()).not.toBeNull(); + expect(magnifier().getAttribute("aria-expanded")).toBe("true"); + const shell = searchShell(); + expect(shell.dataset.expanded).toBe("true"); + // The morph is the shell's own width transition, on react-ui's motion + // tokens — 200ms, spring easing. + expect(shell.className).toContain("duration-standard"); + expect(shell.className).toContain("ease-spring"); + }); + + test("Escape collapses the input back to the magnifier", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + expect(morphField()).not.toBeNull(); + + await act(async () => { + searchShell().dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + + expect(morphField()).toBeNull(); + expect(magnifier().getAttribute("aria-expanded")).toBe("false"); + }); + + test("under prefers-reduced-motion the swap is instant, with no transition", async () => { + stubMatchMedia({ [REDUCED_MOTION]: true }); + await render(); + await act(async () => { + magnifier().click(); + }); + + const shell = searchShell(); + expect(morphField()).not.toBeNull(); + expect(shell.dataset.motion).toBe("instant"); + expect(shell.className).not.toContain("duration-standard"); + expect(shell.className).not.toContain("ease-spring"); + }); +}); + +describe("one search surface, two doors", () => { + test("cmd+K and the magnifier open the identical palette", async () => { + await render(); + expect(paletteInputs()).toHaveLength(0); + + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + }), + ); + }); + await settle(); + expect(paletteInputs()).toHaveLength(1); + const fromShortcut = paletteInputs()[0]; + expect(searchShell().dataset.expanded).toBe("true"); + + await act(async () => { + setCommandPaletteOpen(false); + }); + await settle(); + expect(paletteInputs()).toHaveLength(0); + + await act(async () => { + magnifier().click(); + }); + await settle(); + const fromClick = paletteInputs(); + expect(fromClick).toHaveLength(1); + expect(fromClick[0]?.getAttribute("aria-label")).toBe( + fromShortcut?.getAttribute("aria-label"), + ); + }); + + test("selecting a result navigates to that entity's slug detail route", async () => { + const navigated: string[] = []; + await render( navigated.push(to)} />); + + await act(async () => { + magnifier().click(); + }); + await settle(); + + const input = paletteInputs()[0]; + if (input === undefined) throw new Error("the palette rendered no input"); + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (setValue === undefined) throw new Error("no native value setter"); + await act(async () => { + setValue.call(input, "@"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await settle(); + + const result = [ + ...document.querySelectorAll('[role="option"]'), + ].find((option) => option.textContent?.includes("research-analyst")); + if (result === undefined) throw new Error("the agent never showed up"); + await act(async () => { + result.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigated).toContain("/agents/research-analyst"); + }); +}); diff --git a/packages/command-palette/src/detail-paths.test.ts b/packages/command-palette/src/detail-paths.test.ts new file mode 100644 index 000000000..d3b1010d1 --- /dev/null +++ b/packages/command-palette/src/detail-paths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { detailPathForName } from "./detail-paths"; + +describe("detailPathForName", () => { + test("a slug-shaped name addresses its own detail route", () => { + expect(detailPathForName("/agents", "weekly-digest")).toBe( + "/agents/weekly-digest", + ); + }); + + test("a display name is slugified into the detail path", () => { + expect(detailPathForName("/plugins", "Linear MCP")).toBe( + "/plugins/linear-mcp", + ); + }); + + test("a name with nothing sluggable in it falls back to the roster", () => { + expect(detailPathForName("/skills", "✨")).toBe("/skills"); + expect(detailPathForName("/skills", " ")).toBe("/skills"); + }); +}); From bf0afa8ad9b9e347fc290571a359e773b5febc39 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:43:32 -0700 Subject: [PATCH 2/6] Global search: magnifier morphs into the palette surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every route's top bar now carries the product's one search entry point: a magnifier that morphs in place into an inline bar over 200ms on react-ui's spring easing, Esc collapsing it back, and cmd+K opening the identical palette. Open state and query moved out of CommandPaletteProvider into a shared external store, since the palette and the top bar are siblings in the Shell — the morph and the palette can no longer disagree about whether search is open, and the context menu's Search item drives the same store instead of a window event, which is gone. Palette results now resolve agents, skills, and plugins to their slug-addressed detail routes (detailPathForName, falling back to the roster for a name that cannot name a URL), and a Plugins group lists the bench's connected MCP servers. --- apps/web/src/app.css | 51 +++++++++++++ apps/web/src/command-palette-events.ts | 9 --- apps/web/src/command-palette-open-store.ts | 61 ++++++++++++++++ apps/web/src/command-palette-provider.tsx | 75 ++++++++++++++------ apps/web/src/query-client.ts | 2 + apps/web/src/shell/context-menu/items.tsx | 4 +- apps/web/src/shell/stage-search.tsx | 75 ++++++++++++++++++++ apps/web/src/shell/stage-top-bar.tsx | 7 ++ bun.lock | 9 +-- packages/command-palette/package.json | 1 + packages/command-palette/src/detail-paths.ts | 12 ++++ packages/command-palette/src/index.ts | 2 + 12 files changed, 270 insertions(+), 38 deletions(-) delete mode 100644 apps/web/src/command-palette-events.ts create mode 100644 apps/web/src/command-palette-open-store.ts create mode 100644 apps/web/src/shell/stage-search.tsx create mode 100644 packages/command-palette/src/detail-paths.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 806a67256..65135b4e2 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -699,6 +699,57 @@ select:disabled, margin-left: auto; } +/* The one search entry point: a magnifier that morphs in place into the + palette's inline bar. Width is the animated property (see + stage-search.tsx for the motion tokens), so both states have to be the + same element — never a swap between two boxes. */ +.stage-search { + display: flex; + flex-shrink: 0; + align-items: center; + width: 1.9rem; + overflow: hidden; + border: 1px solid transparent; +} + +.stage-search[data-expanded="true"] { + width: 15rem; + max-width: 40vw; + border-color: var(--border); + background: var(--card); + padding-right: 0.4rem; +} + +.stage-search-button { + display: grid; + place-items: center; + flex-shrink: 0; + width: 1.9rem; + height: 1.9rem; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.stage-search-button:hover { + color: var(--foreground); +} + +.stage-search-field { + min-width: 0; + flex: 1; + border: 0; + background: transparent; + outline: none; + font-size: 0.78rem; + color: var(--foreground); +} + +.stage-search-field::placeholder { + color: var(--muted-foreground); +} + /* Breadcrumb trails live in the title slot, always top-left. */ .stage-crumbs { display: flex; diff --git a/apps/web/src/command-palette-events.ts b/apps/web/src/command-palette-events.ts deleted file mode 100644 index 751f43962..000000000 --- a/apps/web/src/command-palette-events.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** App-local event so the rail Search control can open the command palette - * without coupling rail.tsx to CommandPaletteProvider state. */ - -export const OPEN_COMMAND_PALETTE_EVENT = "workbench:open-command-palette"; - -export function requestOpenCommandPalette(): void { - if (typeof window === "undefined") return; - window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT)); -} diff --git a/apps/web/src/command-palette-open-store.ts b/apps/web/src/command-palette-open-store.ts new file mode 100644 index 000000000..d59f0a2c4 --- /dev/null +++ b/apps/web/src/command-palette-open-store.ts @@ -0,0 +1,61 @@ +// The state of the product's single search surface (DESIGN.md → Search), +// held outside the React tree because the surfaces that read and write it are +// siblings, not ancestors: `CommandPaletteProvider` renders the palette, +// `StageTopBar`'s magnifier morphs into it, and a context menu item opens it, +// and app.tsx's Shell mounts the first two side by side. One store, so the +// morph and the palette can never disagree about whether search is open, and +// so cmd+K, the magnifier, and a menu item all drive the same surface. + +import { useSyncExternalStore } from "react"; + +let open = false; +let query = ""; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function setCommandPaletteOpen(next: boolean): void { + if (open === next) return; + open = next; + // A closed palette keeps no query: reopening starts from the default view, + // never from a stale search someone abandoned. + if (!next) query = ""; + emit(); +} + +export function openCommandPalette(): void { + setCommandPaletteOpen(true); +} + +export function toggleCommandPalette(): void { + setCommandPaletteOpen(!open); +} + +export function setCommandPaletteQuery(next: string): void { + if (query === next) return; + query = next; + emit(); +} + +export function useCommandPaletteOpen(): boolean { + return useSyncExternalStore( + subscribe, + () => open, + () => false, + ); +} + +export function useCommandPaletteQuery(): string { + return useSyncExternalStore( + subscribe, + () => query, + () => "", + ); +} diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index b8d50e8d3..a64aeb46e 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -15,6 +15,7 @@ import { useQuery } from "@tanstack/react-query"; import { buildCommandPaletteGroups, buildStaticCommands, + detailPathForName, isBareScopeQuery, parsePaletteQuery, useEntitySearch, @@ -31,7 +32,13 @@ import { runActionCommand, type ActionCommandId, } from "./command-palette-actions"; -import { OPEN_COMMAND_PALETTE_EVENT } from "./command-palette-events"; +import { + setCommandPaletteOpen, + setCommandPaletteQuery, + toggleCommandPalette, + useCommandPaletteOpen, + useCommandPaletteQuery, +} from "./command-palette-open-store"; import { WORKBENCH_NOT_FOUND_EVENT } from "./workbench-not-found-event"; import { recentsStoreForBench } from "./command-palette-recents"; import { NAV_ROUTES } from "./routes"; @@ -41,6 +48,12 @@ import { useCloseCanvas, useOpenRoutineInCanvas, } from "./shell/canvas-availability"; +import { listMcpServers } from "@corbits/plugins-ui"; +import { + AGENTS_PATH_PREFIX, + PLUGINS_PATH_PREFIX, + SKILLS_PATH_PREFIX, +} from "./path-ids"; import { listRoutines, runRoutineNow, useTenantQuery } from "./routines-api"; import { listSkills } from "./skills-api"; import { meKeys, tenantKeys } from "./query-client"; @@ -76,8 +89,11 @@ export function CommandPaletteProvider({ }) { const { memberships, selectedTenantId, selectTenant } = useBench(); const queryClient = useQueryClient(); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); + // Open state and query live in the shared store, not in this component: + // the top nav's magnifier morphs into this very surface and has to read + // the same state (`command-palette-open-store`). + const open = useCommandPaletteOpen(); + const query = useCommandPaletteQuery(); const [recents, setRecents] = useState([]); const { cycleMode } = useTheme(); const closeCanvas = useCloseCanvas(); @@ -239,6 +255,16 @@ export function CommandPaletteProvider({ open && selectedTenantId !== null, () => listSkills(selectedTenantId ?? ""), ); + // Plugins, as far as this bench has any: its connected MCP servers, each + // already carrying the immutable slug `/plugins/` is addressed by. + // The gallery's presets and catalog entries are not connected things and + // have no detail route of their own yet — a follow-up, not a second + // search. + const mcpServersQuery = useTenantQuery( + tenantKeys.mcpServers(selectedTenantId ?? ""), + open && selectedTenantId !== null, + () => listMcpServers(selectedTenantId ?? ""), + ); const artifactsQuery = useAPIQuery( selectedTenantId === null || !open ? "" @@ -280,17 +306,7 @@ export function CommandPaletteProvider({ ] : undefined; - useCommandShortcut(() => setOpen((current) => !current)); - - useEffect(() => { - function onOpenRequest() { - setOpen(true); - } - window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpenRequest); - return () => { - window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpenRequest); - }; - }, []); + useCommandShortcut(toggleCommandPalette); const pageItems = useMemo( () => @@ -376,6 +392,18 @@ export function CommandPaletteProvider({ [skillsQuery], ); + const pluginItems = useMemo( + () => + mcpServersQuery.kind === "ready" + ? mcpServersQuery.data.map((server) => ({ + id: `entity:plugins:${server.slug}`, + title: server.name, + subtitle: "Connected plugin", + })) + : [], + [mcpServersQuery], + ); + const libraryItems = useMemo( () => artifactsQuery.kind === "ready" @@ -408,6 +436,7 @@ export function CommandPaletteProvider({ { id: "pages", heading: "Pages", kind: "pages", items: pageItems }, { id: "routines", heading: "Routines", items: routineItems }, { id: "skills", heading: "Skills", items: skillItems }, + { id: "plugins", heading: "Plugins", items: pluginItems }, { id: "library", heading: "Files", items: libraryItems }, { id: "people", @@ -422,6 +451,7 @@ export function CommandPaletteProvider({ pageItems, routineItems, skillItems, + pluginItems, libraryItems, agentItems, ], @@ -486,7 +516,7 @@ export function CommandPaletteProvider({ const agentId = id.slice("entity:agents:".length); const title = agentItems.find((item) => item.id === id)?.title ?? agentId; - navigate(`/agents/${encodeURIComponent(agentId)}`); + navigate(detailPathForName(AGENTS_PATH_PREFIX, title)); pushRecent({ kind: "agents", id, title, subtitle: "Agent" }); } else if (id.startsWith("entity:routines:")) { const routineId = id.slice("entity:routines:".length); @@ -498,8 +528,13 @@ export function CommandPaletteProvider({ const skillId = id.slice("entity:skills:".length); const title = skillItems.find((item) => item.id === id)?.title ?? skillId; - navigate(`/skills/${encodeURIComponent(skillId)}`); + navigate(detailPathForName(SKILLS_PATH_PREFIX, title)); pushRecent({ kind: "skills", id, title, subtitle: "Skill" }); + } else if (id.startsWith("entity:plugins:")) { + const slug = id.slice("entity:plugins:".length); + const title = pluginItems.find((item) => item.id === id)?.title ?? slug; + navigate(detailPathForName(PLUGINS_PATH_PREFIX, slug)); + pushRecent({ kind: "plugins", id, title, subtitle: "Plugin" }); } else if (id.startsWith("entity:library:")) { const artifactId = id.slice("entity:library:".length); const title = @@ -507,7 +542,7 @@ export function CommandPaletteProvider({ navigate(libraryArtifactPath(artifactId)); pushRecent({ kind: "library", id, title, subtitle: "Files" }); } - setOpen(false); + setCommandPaletteOpen(false); }, [ navigate, @@ -521,6 +556,7 @@ export function CommandPaletteProvider({ agentItems, routineItems, skillItems, + pluginItems, libraryItems, nextWorkbench, selectTenant, @@ -528,8 +564,7 @@ export function CommandPaletteProvider({ ); const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) setQuery(""); + setCommandPaletteOpen(nextOpen); }, []); return ( @@ -537,7 +572,7 @@ export function CommandPaletteProvider({ open={open} onOpenChange={handleOpenChange} query={query} - onQueryChange={setQuery} + onQueryChange={setCommandPaletteQuery} groups={groups} onSelect={handleSelect} loading={loading} diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 1057bc2fb..4d62c6bc6 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -88,6 +88,8 @@ export const tenantKeys = { ["tenant", tenantId, "approvals", "needs-you"] as const, routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const, skills: (tenantId: string) => ["tenant", tenantId, "skills"] as const, + mcpServers: (tenantId: string) => + ["tenant", tenantId, "mcp-servers"] as const, routineRuns: (tenantId: string, routineId: string) => ["tenant", tenantId, "routines", routineId, "runs"] as const, routineRunHistories: (tenantId: string) => diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index c71fbb832..940efe567 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -33,7 +33,7 @@ import { } from "../library-artifacts"; import { workbenchPath } from "../../workbench-path"; import { requestWorkbenchRename } from "../../workbench-rename-events"; -import { requestOpenCommandPalette } from "../../command-palette-events"; +import { openCommandPalette } from "../../command-palette-open-store"; import { runRoutineNow } from "../../routines-api"; import { SETTINGS_PATH } from "../../routes"; import type { ShellContextMenuTarget } from "./targets"; @@ -240,7 +240,7 @@ function shellMenu(actions: ShellContextMenuActions): ContextMenu { id: "search", label: "Search…", icon: , - onSelect: () => requestOpenCommandPalette(), + onSelect: () => openCommandPalette(), }), contextMenuItem({ id: "workbenches", diff --git a/apps/web/src/shell/stage-search.tsx b/apps/web/src/shell/stage-search.tsx new file mode 100644 index 000000000..d4cb5724c --- /dev/null +++ b/apps/web/src/shell/stage-search.tsx @@ -0,0 +1,75 @@ +// The one way into search from chrome (DESIGN.md → Search): a magnifier that +// morphs in place into an inline bar and hands the query to the command +// palette — the product's single search surface. There is no second search +// implementation behind this control; it opens the same palette cmd+K does, +// and its expanded/collapsed state IS the palette's open state +// (`command-palette-open-store`), so the two can never disagree. +// +// The palette itself is react-ui's modal `CommandPalette`, which owns the +// editable input once open. The bar this file expands into therefore mirrors +// the live query rather than accepting keystrokes: one editable search field +// in the product, with the morph showing where the overlay came from. An +// anchored, non-modal palette in react-ui would let this bar be the input +// itself — until then, mirroring is the honest shape. +// +// Motion is the shell's own width transition on react-ui's motion tokens +// (`--duration-standard`, `--ease-spring`). Under `prefers-reduced-motion` +// the transition is not declared at all, so the swap is instant. + +import { MagnifyingGlass } from "@corbits/icons"; +import { usePrefersReducedMotion } from "@corbits/react-ui"; +import { useRef } from "react"; + +import { + openCommandPalette, + setCommandPaletteOpen, + useCommandPaletteOpen, + useCommandPaletteQuery, +} from "../command-palette-open-store"; + +const MORPH_CLASS = "transition-[width] duration-standard ease-spring"; + +export function StageSearch() { + const expanded = useCommandPaletteOpen(); + const query = useCommandPaletteQuery(); + const reducedMotion = usePrefersReducedMotion(); + const buttonRef = useRef(null); + + return ( +
{ + if (event.key !== "Escape") return; + setCommandPaletteOpen(false); + buttonRef.current?.focus(); + }} + > + + {expanded ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/shell/stage-top-bar.tsx b/apps/web/src/shell/stage-top-bar.tsx index b801310cc..3ac1a9828 100644 --- a/apps/web/src/shell/stage-top-bar.tsx +++ b/apps/web/src/shell/stage-top-bar.tsx @@ -8,6 +8,11 @@ // carries an `href`, so the trail is deep-linkable and a plain click // navigates through the app's own `Link` instead of reloading the shell. // +// The bar also carries the product's one search entry point (`StageSearch`, +// DESIGN.md → Search) ahead of the page's own controls. It is shell chrome, +// not a page action: no page passes it, and no page can opt out — that is +// what makes "exactly one search surface" true of every route at once. +// // `@corbits/react-ui`'s `TopBarBreadcrumbs` renders bare ``, which // would drop the SPA out from under the click, so the trail lives here // until react-ui takes a link-render slot. @@ -16,6 +21,7 @@ import { Fragment, type ReactNode } from "react"; import { Link } from "../navigation"; import { Chip, type ChipTone } from "./chip"; +import { StageSearch } from "./stage-search"; export type StageCrumb = { readonly label: string; @@ -55,6 +61,7 @@ export function StageTopBar({ className="stage-top-bar-actions" data-testid="stage-top-bar-actions" > + {chip !== undefined ? {chip.label} : null} {actions} diff --git a/bun.lock b/bun.lock index 1824bac53..315f96366 100644 --- a/bun.lock +++ b/bun.lock @@ -498,6 +498,7 @@ "name": "@corbits/command-palette", "version": "0.0.1", "dependencies": { + "@corbits/slug": "workspace:*", "react": "^19.2.0", }, "devDependencies": { @@ -546,7 +547,7 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@intx/agent": "0.3.0", "@intx/types": "0.3.0", @@ -3269,8 +3270,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3293,10 +3292,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/command-palette/package.json b/packages/command-palette/package.json index 920178f1d..047e156c8 100644 --- a/packages/command-palette/package.json +++ b/packages/command-palette/package.json @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/slug": "workspace:*", "react": "^19.2.0" }, "devDependencies": { diff --git a/packages/command-palette/src/detail-paths.ts b/packages/command-palette/src/detail-paths.ts new file mode 100644 index 000000000..ef44fe022 --- /dev/null +++ b/packages/command-palette/src/detail-paths.ts @@ -0,0 +1,12 @@ +// Where a palette result lands. DESIGN.md's Detail Pages section addresses +// every browsable entity by slug (`/agents/`), so a result row has to +// resolve a name to that route rather than to an id-shaped path its roster +// would swallow. A name that cannot name a URL resolves to the roster +// instead — never a fabricated slug that could collide with a real one. + +import { isValidSlug, slugify } from "@corbits/slug"; + +export function detailPathForName(rosterPath: string, name: string): string { + const slug = slugify(name); + return isValidSlug(slug) ? `${rosterPath}/${slug}` : rosterPath; +} diff --git a/packages/command-palette/src/index.ts b/packages/command-palette/src/index.ts index 5f0c5d712..fa42d796f 100644 --- a/packages/command-palette/src/index.ts +++ b/packages/command-palette/src/index.ts @@ -43,6 +43,8 @@ export type { PaletteSource, } from "./command-groups"; +export { detailPathForName } from "./detail-paths"; + export { addRecentEntry, createRecentsStore, From 64f522dbab89b17e0d3d9d08d95d71ffcae7663d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 15:43:39 -0700 Subject: [PATCH 3/6] Update docs: how the one search surface is invoked Records the two doors onto the palette and the store they share, the Plugins group and its connected-servers-only scope, and the slug detail routing for agents, skills, and plugins. --- docs/command-palette.md | 52 ++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/command-palette.md b/docs/command-palette.md index 0d14d2653..a40d18107 100644 --- a/docs/command-palette.md +++ b/docs/command-palette.md @@ -1,8 +1,29 @@ # Command palette Cmd/Ctrl-K opens a global search-and-jump overlay: pages the app shell -already renders, plus workbenches, agents, workflow runs, routines, skills, and -Library artifacts, ranked and grouped, with full keyboard navigation. +already renders, plus workbenches, agents, workflow runs, routines, skills, +plugins, and Library artifacts, ranked and grouped, with full keyboard +navigation. + +## How it's invoked + +This palette is the product's only search surface (DESIGN.md → Search), and +it has exactly two doors: Cmd/Ctrl-K anywhere, and the magnifier the shell's +top bar carries on every route (`StageSearch` in +`apps/web/src/shell/stage-search.tsx`). Clicking the magnifier morphs it in +place into an inline bar — a width transition on react-ui's motion tokens +(`--duration-standard`, `--ease-spring`), not declared at all under +`prefers-reduced-motion`, where the swap is instant — and opens this same +overlay. Esc collapses the bar back to the magnifier. + +Both doors read and write one state, `command-palette-open-store.ts`: an +external store rather than component state, because the palette provider and +the top bar are siblings in `app.tsx`'s Shell, and a context-menu item opens +the palette too. Because react-ui's `CommandPalette` is a modal dialog that +owns the editable input once open, the morphed bar mirrors the live query +instead of accepting keystrokes — one editable search field in the product, +with the morph showing where the overlay came from. An anchored, non-modal +palette in react-ui would let that bar be the input itself. ## Where it lives @@ -35,19 +56,28 @@ with three responsibilities: `listWorkbenches`), agents (`listAgentDefinitions`), and workflow runs (`/api/me/workflows/runs`), fetches routines, skills, and Library artifacts as small per-bench catalogs (filtered client-side, the same way the static -route list already is), builds `@corbits/command-palette`'s static commands +route list already is), lists the bench's connected MCP servers as Plugins, +builds `@corbits/command-palette`'s static commands from `apps/web/src/routes.tsx`, and hands the assembled groups to react-ui's data-driven palette. No new endpoint beyond the ones the Routines, Skills, and Library pages already use, no domain logic in the app. ## Groups, in display order -Commands, Workbenches, Pages, Runs, Routines, Skills, Library, then -People & agents — matching the mock's `buildCmdkEntries` ordering. Selecting -a workbench, run, agent, routine, or skill result navigates to its real route -and records a recent entry (`recentsStoreForBench`, per-bench, local to the -browser); selecting a Library result opens the Library list, since a Library -item has no dedicated route of its own yet. +Commands, Workbenches, Pages, Runs, Routines, Skills, Plugins, Files, then +People & agents — the mock's `buildCmdkEntries` ordering, with Plugins (this +bench's connected MCP servers) added where the gallery sits in the rail. +Selecting a workbench, run, agent, routine, skill, or plugin result navigates +to its real route and records a recent entry (`recentsStoreForBench`, +per-bench, local to the browser); selecting a Library result opens the +Library list, since a Library item has no dedicated route of its own yet. + +Agents, skills, and plugins resolve to the slug-addressed detail routes +DESIGN.md's Detail Pages section defines (`/agents/`), through +`detailPathForName` — a name that cannot name a URL resolves to the roster +instead of a fabricated slug. Routines and Library results still open their +roster deep links, which carry real content the slug placeholders do not +yet; moving them over belongs with those detail pages. ## What is not wired yet @@ -56,3 +86,7 @@ small badge for the active `#`/`@`/`>`/`/` scope and a footer legend naming each prefix; react-ui's `CommandPalette` has no slot for either today. The placeholder text (`Search or jump to… (# workbenches · @ people · > actions · / pages)`) carries that information as plain text instead. + +**Plugins covers connected servers only.** The gallery's presets and catalog +entries are not connected things and have no detail route yet, so they are +not searchable — a follow-up on this group, never a second search surface. From 8600835ef6e93783fcf065c24b7f2ea229438535 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 16:23:08 -0700 Subject: [PATCH 4/6] Add tests for the search morph's real motion, focus and slug routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous morph test asserted Tailwind class names, which compile to nothing against react-ui's prebuilt stylesheet — it would have passed on a morph that never ran. It now reads the authored transition out of app.css and checks the tokens exist in the stylesheet the app imports, and asserts the element carries no inert motion utility. Escape is driven inside the palette dialog rather than dispatched at the collapsed shell, including the cmd+K case where the magnifier was never focused, so the focus return is actually exercised. New coverage: a route change closing the surface, the inline bar being text rather than a second input, and an entity whose handle is not a slug keeping its id deep link instead of a guessed slug. --- apps/web/test/global-search-morph.test.tsx | 256 +++++++++++++----- .../command-palette/src/detail-paths.test.ts | 24 +- 2 files changed, 203 insertions(+), 77 deletions(-) diff --git a/apps/web/test/global-search-morph.test.tsx b/apps/web/test/global-search-morph.test.tsx index e48edbb3e..996c1a3bc 100644 --- a/apps/web/test/global-search-morph.test.tsx +++ b/apps/web/test/global-search-morph.test.tsx @@ -1,12 +1,16 @@ // CL-6410: the product's one search surface. DESIGN.md's Search section fixes // how it is invoked from chrome: the top-nav magnifier morphs in place into an -// inline bar over ~200ms with the spring easing, Esc collapses it, and cmd+K -// reaches the identical palette — never a second search implementation. This -// suite covers the morph's open/close behaviour, both doors landing on one -// surface, a result navigating to a slug-addressed detail route, and the -// reduced-motion path. +// inline bar, Esc collapses it, and cmd+K reaches the identical palette — +// never a second search implementation. +// +// The motion assertions deliberately check the authored stylesheet and the +// tokens it consumes, not class names on the element: react-ui ships a +// prebuilt stylesheet, so a Tailwind motion utility (`duration-standard`, +// `ease-spring`) compiles to nothing here and a className assertion would +// green-light a morph that never runs. import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { ThemeProvider } from "@corbits/react-ui"; @@ -24,7 +28,19 @@ const realMatchMedia = window.matchMedia; const TENANT = "tnt_1"; -const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; +const appCss = readFileSync(new URL("../src/app.css", import.meta.url), "utf8"); +const reactUiCss = readFileSync( + new URL("../node_modules/@corbits/react-ui/dist/styles.css", import.meta.url), + "utf8", +); + +/** The declaration block of the rule that names `className`. */ +function ruleFor(css: string, className: string): string { + const selector = new RegExp(`\\.${className}\\s*[,{]`); + const block = css.split("}").find((candidate) => selector.test(candidate)); + if (block === undefined) throw new Error(`no rule for .${className}`); + return block.slice(block.indexOf("{")); +} function stubMatchMedia(matching: Record): void { window.matchMedia = ((media: string) => @@ -42,7 +58,7 @@ const json = (body: unknown, status = 200) => headers: { "content-type": "application/json" }, }); -const definition = { +const slugHandled = { id: "wfd_1", tenantId: TENANT, name: "research-analyst", @@ -53,6 +69,14 @@ const definition = { updatedAt: "2026-01-01T00:00:00.000Z", }; +/** A handle that is not a slug — minted before the rule tightened, or + * imported. Its detail route cannot be guessed at. */ +const unsluggedHandle = { + ...slugHandled, + id: "wfd_2", + name: "Café Crème Bot", +}; + function stubShellFetch(): void { globalThis.fetch = ((input: RequestInfo | URL) => { const path = String(input); @@ -76,7 +100,9 @@ function stubShellFetch(): void { if (path.includes("/api/workbench-tenancies/kinds")) return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/workflows/definitions")) - return Promise.resolve(json({ data: [definition], nextCursor: null })); + return Promise.resolve( + json({ data: [slugHandled, unsluggedHandle], nextCursor: null }), + ); if (path.includes("/mcp-servers")) return Promise.resolve(json({ data: [] })); if (path.includes("/skills")) return Promise.resolve(json({ skills: [] })); @@ -135,9 +161,9 @@ function magnifier(): HTMLButtonElement { return button; } -function morphField(): HTMLInputElement | null { - return container.querySelector( - '[data-testid="stage-search-input"]', +function morphField(): HTMLElement | null { + return container.querySelector( + '[data-testid="stage-search-field"]', ); } @@ -145,25 +171,60 @@ function paletteInputs(): readonly HTMLInputElement[] { return [...document.querySelectorAll('[role="combobox"]')]; } -function TopBarOnly() { - return ( - - - - ); +function paletteInput(): HTMLInputElement { + const input = paletteInputs()[0]; + if (input === undefined) throw new Error("the palette rendered no input"); + return input; +} + +async function typeInPalette(value: string): Promise { + const setValue = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (setValue === undefined) throw new Error("no native value setter"); + const input = paletteInput(); + await act(async () => { + setValue.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await settle(); +} + +async function pressEscapeInPalette(): Promise { + await act(async () => { + paletteInput().dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + await settle(); +} + +function resultRow(text: string): HTMLElement { + const row = [ + ...document.querySelectorAll('[role="option"]'), + ].find((option) => option.textContent?.includes(text)); + if (row === undefined) throw new Error(`no result row for ${text}`); + return row; } function Harness({ navigate = noop, + path = "/agents", }: { readonly navigate?: (to: string) => void; + readonly path?: string; }) { return ( - + @@ -174,56 +235,117 @@ function Harness({ describe("the top-nav search morph", () => { test("the collapsed control is a magnifier and nothing else", async () => { - await render(); + await render(); expect(magnifier().getAttribute("aria-expanded")).toBe("false"); expect(morphField()).toBeNull(); }); - test("clicking the magnifier morphs it in place into an inline input", async () => { - await render(); + test("clicking the magnifier morphs it in place into the inline bar", async () => { + await render(); await act(async () => { magnifier().click(); }); + await settle(); expect(morphField()).not.toBeNull(); expect(magnifier().getAttribute("aria-expanded")).toBe("true"); - const shell = searchShell(); - expect(shell.dataset.expanded).toBe("true"); - // The morph is the shell's own width transition, on react-ui's motion - // tokens — 200ms, spring easing. - expect(shell.className).toContain("duration-standard"); - expect(shell.className).toContain("ease-spring"); + expect(searchShell().dataset.expanded).toBe("true"); + }); + + test("the morph is a real transition: authored on the element, on tokens the shipped stylesheet defines", () => { + const rule = ruleFor(appCss, "stage-search"); + // One element whose width animates — a swap between two boxes could not + // transition at all. + expect(rule).toContain("transition: width var(--duration-standard)"); + // react-ui's documented curve for something growing in place; a spring's + // overshoot would jitter the whole top bar. + expect(rule).toContain("var(--ease-in-out)"); + // Both tokens have to exist in the prebuilt sheet the app actually + // imports, or the declaration silently resolves to nothing. + expect(reactUiCss).toContain("--duration-standard:"); + expect(reactUiCss).toContain("--ease-in-out:"); }); - test("Escape collapses the input back to the magnifier", async () => { - await render(); + test("the morph carries no Tailwind motion utility, which would be inert against the prebuilt stylesheet", async () => { + await render(); await act(async () => { magnifier().click(); }); - expect(morphField()).not.toBeNull(); + expect(searchShell().className).not.toContain("duration-"); + expect(searchShell().className).not.toContain("ease-"); + expect(searchShell().className).not.toContain("transition-"); + }); + test("reduced motion needs no per-element handling: the shipped stylesheet collapses every transition", () => { + const reducedMotionBlock = reactUiCss.slice( + reactUiCss.lastIndexOf("prefers-reduced-motion: reduce"), + ); + expect(reducedMotionBlock).toContain("transition-duration: 0.01ms"); + }); + + test("the inline bar shows the query instead of impersonating an input", async () => { + await render(); await act(async () => { - searchShell().dispatchEvent( - new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), - ); + magnifier().click(); }); + await settle(); + await typeInPalette("resea"); + + const field = morphField(); + expect(field?.tagName).toBe("SPAN"); + expect(field?.textContent).toBe("resea"); + // The palette owns the one editable search field in the product. + expect(paletteInputs()).toHaveLength(1); + }); +}); + +describe("collapsing back to the magnifier", () => { + test("Escape inside the palette collapses the morph and returns focus to the magnifier", async () => { + await render(); + await act(async () => { + magnifier().click(); + }); + await settle(); + expect(morphField()).not.toBeNull(); + + await pressEscapeInPalette(); expect(morphField()).toBeNull(); expect(magnifier().getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(magnifier()); + }); + + test("focus lands on the magnifier even when the palette was opened by cmd+K, which never focused it", async () => { + await render(); + await act(async () => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + }), + ); + }); + await settle(); + expect(document.activeElement).not.toBe(magnifier()); + + await pressEscapeInPalette(); + + expect(document.activeElement).toBe(magnifier()); }); - test("under prefers-reduced-motion the swap is instant, with no transition", async () => { - stubMatchMedia({ [REDUCED_MOTION]: true }); - await render(); + test("a route change closes the surface, so Back never leaves it standing", async () => { + await render(); await act(async () => { magnifier().click(); }); + await settle(); + expect(paletteInputs()).toHaveLength(1); - const shell = searchShell(); - expect(morphField()).not.toBeNull(); - expect(shell.dataset.motion).toBe("instant"); - expect(shell.className).not.toContain("duration-standard"); - expect(shell.className).not.toContain("ease-spring"); + await render(); + + expect(paletteInputs()).toHaveLength(0); + expect(morphField()).toBeNull(); }); }); @@ -243,27 +365,21 @@ describe("one search surface, two doors", () => { }); await settle(); expect(paletteInputs()).toHaveLength(1); - const fromShortcut = paletteInputs()[0]; + const fromShortcut = paletteInput().getAttribute("aria-label"); expect(searchShell().dataset.expanded).toBe("true"); - await act(async () => { - setCommandPaletteOpen(false); - }); - await settle(); + await pressEscapeInPalette(); expect(paletteInputs()).toHaveLength(0); await act(async () => { magnifier().click(); }); await settle(); - const fromClick = paletteInputs(); - expect(fromClick).toHaveLength(1); - expect(fromClick[0]?.getAttribute("aria-label")).toBe( - fromShortcut?.getAttribute("aria-label"), - ); + expect(paletteInputs()).toHaveLength(1); + expect(paletteInput().getAttribute("aria-label")).toBe(fromShortcut); }); - test("selecting a result navigates to that entity's slug detail route", async () => { + test("selecting a result navigates to that entity's own slug detail route", async () => { const navigated: string[] = []; await render( navigated.push(to)} />); @@ -271,28 +387,34 @@ describe("one search surface, two doors", () => { magnifier().click(); }); await settle(); + await typeInPalette("@"); - const input = paletteInputs()[0]; - if (input === undefined) throw new Error("the palette rendered no input"); - const setValue = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - if (setValue === undefined) throw new Error("no native value setter"); await act(async () => { - setValue.call(input, "@"); - input.dispatchEvent(new Event("input", { bubbles: true })); + resultRow("research-analyst").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(navigated).toContain("/agents/research-analyst"); + }); + + test("an entity whose handle is not a slug keeps its id deep link, never a guessed slug", async () => { + const navigated: string[] = []; + await render( navigated.push(to)} />); + + await act(async () => { + magnifier().click(); }); await settle(); + await typeInPalette("@"); - const result = [ - ...document.querySelectorAll('[role="option"]'), - ].find((option) => option.textContent?.includes("research-analyst")); - if (result === undefined) throw new Error("the agent never showed up"); await act(async () => { - result.dispatchEvent(new MouseEvent("click", { bubbles: true })); + resultRow("Café Crème Bot").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); }); - expect(navigated).toContain("/agents/research-analyst"); + expect(navigated).toContain("/agents/wfd_2"); + expect(navigated).not.toContain("/agents/cafe-creme-bot"); }); }); diff --git a/packages/command-palette/src/detail-paths.test.ts b/packages/command-palette/src/detail-paths.test.ts index d3b1010d1..914d085ad 100644 --- a/packages/command-palette/src/detail-paths.test.ts +++ b/packages/command-palette/src/detail-paths.test.ts @@ -1,22 +1,26 @@ import { describe, expect, test } from "bun:test"; -import { detailPathForName } from "./detail-paths"; +import { detailPath } from "./detail-paths"; -describe("detailPathForName", () => { - test("a slug-shaped name addresses its own detail route", () => { - expect(detailPathForName("/agents", "weekly-digest")).toBe( +describe("detailPath", () => { + test("an entity's own slug addresses its detail route", () => { + expect(detailPath("/agents", { slug: "weekly-digest", id: "wfd_1" })).toBe( "/agents/weekly-digest", ); }); - test("a display name is slugified into the detail path", () => { - expect(detailPathForName("/plugins", "Linear MCP")).toBe( - "/plugins/linear-mcp", + test("a slug is never derived from a display name", () => { + expect(detailPath("/agents", { slug: "Café Crème Bot", id: "wfd_2" })).toBe( + "/agents/wfd_2", + ); + expect(detailPath("/skills", { slug: "", id: "skill_1" })).toBe( + "/skills/skill_1", ); }); - test("a name with nothing sluggable in it falls back to the roster", () => { - expect(detailPathForName("/skills", "✨")).toBe("/skills"); - expect(detailPathForName("/skills", " ")).toBe("/skills"); + test("the id fallback survives a segment that needs escaping", () => { + expect(detailPath("/plugins", { slug: "Not A Slug", id: "a/b" })).toBe( + "/plugins/a%2Fb", + ); }); }); From 6352da2d90180e9d2771447f805b6ecde6c65ac9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 16:23:28 -0700 Subject: [PATCH 5/6] Search morph: real transition, real focus return, real slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the top-nav search morph. Motion: the transition is authored on .stage-search in app.css against --duration-standard and --ease-in-out. The Tailwind utilities it used before (duration-standard, ease-spring) exist only inside react-ui's own build — the app imports react-ui's prebuilt stylesheet, where neither the classes nor --ease-spring are defined, so only transition-[width] applied and the morph ran at the default 150ms ease. --ease-in-out is also the right curve: it is what react-ui's theme documents for something growing in place, and a spring's overshoot would drag the whole top bar with it. Focus: the wrapper keydown handler could never fire — the palette is a portaled modal that holds focus in its own input — so it is gone. Focus returns to the magnifier when the surface closes, whichever way it closed. Slugs are no longer derived: detailPath takes the entity's own minted slug (an agent's handle, a skill's name, a server's slug) and falls back to its opaque id, which every roster resolves as a deep link. Guessing a slug from a display title 404s the moment the two disagree — a renamed agent, an accent folded differently — and the create-agent panel's own rival slugify is now @corbits/slug, so a suggested handle can never be a shape the router refuses. Also: the inline bar is a span styled as a field, not a readOnly input that swallowed clicks and confused assistive tech; the reduced-motion hook and data-motion attribute are gone, since react-ui's stylesheet already collapses transition durations globally; search closes on route change and bench switch, so module state cannot outlive the scope it was opened in; the shortcut is named openCommandPalette, which is all it can honestly do while the open palette holds focus in a text field. --- apps/web/src/app.css | 20 ++++--- apps/web/src/command-palette-open-store.ts | 9 +++- apps/web/src/command-palette-provider.tsx | 50 ++++++++++++++--- apps/web/src/pages/create-agent-panel.tsx | 17 +++--- apps/web/src/shell/stage-search.tsx | 57 ++++++++++---------- apps/web/src/workbench-not-found-event.ts | 4 +- apps/web/src/workbench-rename-events.ts | 2 +- packages/command-palette/src/detail-paths.ts | 30 ++++++++--- packages/command-palette/src/index.ts | 3 +- 9 files changed, 126 insertions(+), 66 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 65135b4e2..f74f2768c 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -700,9 +700,14 @@ select:disabled, } /* The one search entry point: a magnifier that morphs in place into the - palette's inline bar. Width is the animated property (see - stage-search.tsx for the motion tokens), so both states have to be the - same element — never a swap between two boxes. */ + palette's inline bar. Width is the animated property, so both states have + to be the same element — never a swap between two boxes. The transition is + authored here rather than as Tailwind utilities: react-ui ships a prebuilt + stylesheet, and `duration-standard`/`ease-*` compile to classes only in + react-ui's own build, so a utility class would be inert here. `--ease-in-out` + is react-ui's documented curve for something growing in place — a spring's + overshoot would jitter the whole top bar. Reduced motion is already handled + by that stylesheet's global transition-duration collapse. */ .stage-search { display: flex; flex-shrink: 0; @@ -710,6 +715,7 @@ select:disabled, width: 1.9rem; overflow: hidden; border: 1px solid transparent; + transition: width var(--duration-standard) var(--ease-in-out); } .stage-search[data-expanded="true"] { @@ -739,14 +745,14 @@ select:disabled, .stage-search-field { min-width: 0; flex: 1; - border: 0; - background: transparent; - outline: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 0.78rem; color: var(--foreground); } -.stage-search-field::placeholder { +.stage-search-field[data-placeholder="true"] { color: var(--muted-foreground); } diff --git a/apps/web/src/command-palette-open-store.ts b/apps/web/src/command-palette-open-store.ts index d59f0a2c4..719a10c59 100644 --- a/apps/web/src/command-palette-open-store.ts +++ b/apps/web/src/command-palette-open-store.ts @@ -5,6 +5,11 @@ // and app.tsx's Shell mounts the first two side by side. One store, so the // morph and the palette can never disagree about whether search is open, and // so cmd+K, the magnifier, and a menu item all drive the same surface. +// +// Module state outlives a React remount, so search is scoped explicitly: +// `CommandPaletteProvider` closes it on a route change (a Back out of a +// result must not leave the overlay standing) and on a bench switch (whose +// results and query belonged to the bench being left). import { useSyncExternalStore } from "react"; @@ -34,8 +39,8 @@ export function openCommandPalette(): void { setCommandPaletteOpen(true); } -export function toggleCommandPalette(): void { - setCommandPaletteOpen(!open); +export function closeCommandPalette(): void { + setCommandPaletteOpen(false); } export function setCommandPaletteQuery(next: string): void { diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index a64aeb46e..76625c934 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -15,7 +15,7 @@ import { useQuery } from "@tanstack/react-query"; import { buildCommandPaletteGroups, buildStaticCommands, - detailPathForName, + detailPath, isBareScopeQuery, parsePaletteQuery, useEntitySearch, @@ -24,7 +24,7 @@ import { type RecentEntry, } from "@corbits/command-palette"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listAgentDefinitions } from "./agents-api"; import { @@ -33,9 +33,9 @@ import { type ActionCommandId, } from "./command-palette-actions"; import { + openCommandPalette, setCommandPaletteOpen, setCommandPaletteQuery, - toggleCommandPalette, useCommandPaletteOpen, useCommandPaletteQuery, } from "./command-palette-open-store"; @@ -164,9 +164,19 @@ export function CommandPaletteProvider({ })); }, [selectedTenantId, queryClient]); + // `useEntitySearch` carries an id and a title per result and nothing else, + // but `/agents/` needs the definition's own minted handle — which is + // exactly what the fetch just read. Recorded here as the list arrives so a + // selection resolves the real slug instead of guessing one back out of a + // display title. + const agentHandleById = useRef(new Map()); + const listAgentsForSearch = useCallback(async () => { if (selectedTenantId === null) return []; const definitions = await listAgentDefinitions(selectedTenantId); + for (const definition of definitions) { + agentHandleById.current.set(definition.id, definition.name); + } return definitions.map((definition) => ({ id: definition.id, name: definition.name, @@ -306,7 +316,25 @@ export function CommandPaletteProvider({ ] : undefined; - useCommandShortcut(toggleCommandPalette); + // cmd+K opens; it cannot also close, because react-ui's shortcut yields to + // text fields and an open palette holds focus in its own input. Escape and + // the overlay are the ways back out. + useCommandShortcut(openCommandPalette); + + // Search is scoped to where it was opened from. A route change (including + // browser Back out of a result) closes it, so the overlay never stands over + // content it was not opened from; a bench switch closes it too, dropping a + // query whose results belonged to the bench being left. A tenant resolving + // for the first time (null → a real bench, at boot) is not a switch. + const searchScope = useRef({ path, tenantId: selectedTenantId }); + useEffect(() => { + const previous = searchScope.current; + const routeChanged = previous.path !== path; + const benchSwitched = + previous.tenantId !== null && previous.tenantId !== selectedTenantId; + searchScope.current = { path, tenantId: selectedTenantId }; + if (routeChanged || benchSwitched) setCommandPaletteOpen(false); + }, [path, selectedTenantId]); const pageItems = useMemo( () => @@ -516,7 +544,12 @@ export function CommandPaletteProvider({ const agentId = id.slice("entity:agents:".length); const title = agentItems.find((item) => item.id === id)?.title ?? agentId; - navigate(detailPathForName(AGENTS_PATH_PREFIX, title)); + navigate( + detailPath(AGENTS_PATH_PREFIX, { + slug: agentHandleById.current.get(agentId) ?? "", + id: agentId, + }), + ); pushRecent({ kind: "agents", id, title, subtitle: "Agent" }); } else if (id.startsWith("entity:routines:")) { const routineId = id.slice("entity:routines:".length); @@ -528,12 +561,15 @@ export function CommandPaletteProvider({ const skillId = id.slice("entity:skills:".length); const title = skillItems.find((item) => item.id === id)?.title ?? skillId; - navigate(detailPathForName(SKILLS_PATH_PREFIX, title)); + // A skill's name is its slug: the Skills API keys every route on it. + navigate( + detailPath(SKILLS_PATH_PREFIX, { slug: skillId, id: skillId }), + ); pushRecent({ kind: "skills", id, title, subtitle: "Skill" }); } else if (id.startsWith("entity:plugins:")) { const slug = id.slice("entity:plugins:".length); const title = pluginItems.find((item) => item.id === id)?.title ?? slug; - navigate(detailPathForName(PLUGINS_PATH_PREFIX, slug)); + navigate(detailPath(PLUGINS_PATH_PREFIX, { slug, id: slug })); pushRecent({ kind: "plugins", id, title, subtitle: "Plugin" }); } else if (id.startsWith("entity:library:")) { const artifactId = id.slice("entity:library:".length); diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx index ca8f50480..79784627e 100644 --- a/apps/web/src/pages/create-agent-panel.tsx +++ b/apps/web/src/pages/create-agent-panel.tsx @@ -61,16 +61,15 @@ import { draftAgentDefinition, listCatalogModels, } from "../agents-api"; -import { AgentSkillsPicker } from "./agent-skills-picker"; +import { isValidSlug, slugify } from "@corbits/slug"; -const HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +import { AgentSkillsPicker } from "./agent-skills-picker"; -export function slugify(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} +// A handle IS the agent's slug — the immutable name `/agents/` is +// addressed by (DESIGN.md → Detail Pages). Minting and validating it through +// `@corbits/slug` rather than a local regex and a local slugify is what keeps +// that true: one implementation, so a handle suggested or accepted here can +// never be a shape the router refuses to resolve. function initialsFromName(name: string): string { const [first, second] = name.trim().split(/\s+/).filter(Boolean); @@ -207,7 +206,7 @@ function blockedReason( draftFailed: boolean, ): string | null { if (values.name.trim() === "") return "Add a name to continue."; - if (values.handle.trim() === "" || !HANDLE_PATTERN.test(values.handle)) { + if (!isValidSlug(values.handle.trim())) { return "Fix the handle below — lowercase letters, digits, and hyphens only."; } if (draftFailed && values.manualSystemPrompt.trim() === "") { diff --git a/apps/web/src/shell/stage-search.tsx b/apps/web/src/shell/stage-search.tsx index d4cb5724c..766149db8 100644 --- a/apps/web/src/shell/stage-search.tsx +++ b/apps/web/src/shell/stage-search.tsx @@ -6,46 +6,46 @@ // (`command-palette-open-store`), so the two can never disagree. // // The palette itself is react-ui's modal `CommandPalette`, which owns the -// editable input once open. The bar this file expands into therefore mirrors -// the live query rather than accepting keystrokes: one editable search field -// in the product, with the morph showing where the overlay came from. An -// anchored, non-modal palette in react-ui would let this bar be the input -// itself — until then, mirroring is the honest shape. +// editable input once open. The bar this expands into therefore *shows* the +// live query rather than pretending to accept one — a span styled as a +// field, never a second input a click could land in and a screen reader +// would have to explain. An anchored, non-modal palette in react-ui would +// let this bar be the input itself; until then, showing is the honest shape. // -// Motion is the shell's own width transition on react-ui's motion tokens -// (`--duration-standard`, `--ease-spring`). Under `prefers-reduced-motion` -// the transition is not declared at all, so the swap is instant. +// Motion is the width transition authored on `.stage-search` in app.css +// (react-ui's `--duration-standard` and `--ease-in-out`, the curve its +// theme documents for something growing in place). Reduced motion needs +// nothing here: react-ui's stylesheet already collapses every transition +// duration under `prefers-reduced-motion`, which makes the swap instant. import { MagnifyingGlass } from "@corbits/icons"; -import { usePrefersReducedMotion } from "@corbits/react-ui"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { openCommandPalette, - setCommandPaletteOpen, useCommandPaletteOpen, useCommandPaletteQuery, } from "../command-palette-open-store"; -const MORPH_CLASS = "transition-[width] duration-standard ease-spring"; - export function StageSearch() { const expanded = useCommandPaletteOpen(); const query = useCommandPaletteQuery(); - const reducedMotion = usePrefersReducedMotion(); const buttonRef = useRef(null); + const wasExpanded = useRef(false); + + // Whichever way the palette closed — Escape inside its dialog, a click on + // its overlay, the store — focus comes back to the control the morph came + // out of, instead of being dropped on the document. + useEffect(() => { + if (wasExpanded.current && !expanded) buttonRef.current?.focus(); + wasExpanded.current = expanded; + }, [expanded]); return (
{ - if (event.key !== "Escape") return; - setCommandPaletteOpen(false); - buttonRef.current?.focus(); - }} > {expanded ? ( - + data-testid="stage-search-field" + data-placeholder={query === ""} + > + {query === "" ? "Search or jump to…" : query} + ) : null}
); diff --git a/apps/web/src/workbench-not-found-event.ts b/apps/web/src/workbench-not-found-event.ts index 0098df871..3d384d212 100644 --- a/apps/web/src/workbench-not-found-event.ts +++ b/apps/web/src/workbench-not-found-event.ts @@ -1,8 +1,8 @@ /** App-local event so a workbench-level 404 (`chat-page.tsx`, driven by * `ChatWorkspace`'s `onWorkbenchNotFound`) can tell the command palette to * drop a stale Recents entry, without coupling the chat route to - * `CommandPaletteProvider` state — same pattern as - * `command-palette-events.ts`'s open-palette event. */ + * `CommandPaletteProvider` state — the chat route and that provider are + * siblings in the Shell. */ export const WORKBENCH_NOT_FOUND_EVENT = "workbench:workbench-not-found"; diff --git a/apps/web/src/workbench-rename-events.ts b/apps/web/src/workbench-rename-events.ts index 43a35a7cf..9defabcac 100644 --- a/apps/web/src/workbench-rename-events.ts +++ b/apps/web/src/workbench-rename-events.ts @@ -1,6 +1,6 @@ /** App-local event so the global context menu can trigger a workbench panel * row's own inline-rename input without either side owning the other's - * state, mirroring `command-palette-events.ts`. */ + * state. */ export const REQUEST_WORKBENCH_RENAME_EVENT = "workbench:request-workbench-rename"; diff --git a/packages/command-palette/src/detail-paths.ts b/packages/command-palette/src/detail-paths.ts index ef44fe022..420e9f2d6 100644 --- a/packages/command-palette/src/detail-paths.ts +++ b/packages/command-palette/src/detail-paths.ts @@ -1,12 +1,28 @@ // Where a palette result lands. DESIGN.md's Detail Pages section addresses // every browsable entity by slug (`/agents/`), so a result row has to -// resolve a name to that route rather than to an id-shaped path its roster -// would swallow. A name that cannot name a URL resolves to the roster -// instead — never a fabricated slug that could collide with a real one. +// resolve to that route when the entity carries a real slug. +// +// The slug is never derived here. A slug is minted once, at creation, and is +// immutable; guessing one from a display name produces a URL that 404s the +// moment the two disagree (an accent folded differently, a rename, a name +// that was never sluggable). An entity whose slug is not a slug — an import +// race, an external id, a handle minted before the rule tightened — falls +// back to its own opaque id, which every roster still resolves as a deep +// link into that row. -import { isValidSlug, slugify } from "@corbits/slug"; +import { isValidSlug } from "@corbits/slug"; -export function detailPathForName(rosterPath: string, name: string): string { - const slug = slugify(name); - return isValidSlug(slug) ? `${rosterPath}/${slug}` : rosterPath; +export type DetailAddressable = { + /** The entity's own minted slug, as the server returned it. */ + readonly slug: string; + /** The opaque id its roster deep link accepts. */ + readonly id: string; +}; + +export function detailPath( + rosterPath: string, + entity: DetailAddressable, +): string { + if (isValidSlug(entity.slug)) return `${rosterPath}/${entity.slug}`; + return `${rosterPath}/${encodeURIComponent(entity.id)}`; } diff --git a/packages/command-palette/src/index.ts b/packages/command-palette/src/index.ts index fa42d796f..fec52026e 100644 --- a/packages/command-palette/src/index.ts +++ b/packages/command-palette/src/index.ts @@ -43,7 +43,8 @@ export type { PaletteSource, } from "./command-groups"; -export { detailPathForName } from "./detail-paths"; +export { detailPath } from "./detail-paths"; +export type { DetailAddressable } from "./detail-paths"; export { addRecentEntry, From 28aabb7302cca147cbb35f217a096ada564f6e13 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 20 Aug 2026 16:23:37 -0700 Subject: [PATCH 6/6] Update docs: the morph curve, and how search is scoped DESIGN.md's Motion section named spring as the search morph's curve; in-place morphs take the in-out curve instead, and the section now says why and notes that these are theme tokens rather than utilities the product can name. docs/command-palette.md records the focus return, the route/bench scoping, open-not-toggle, and that a result's slug is the entity's own, never derived. --- DESIGN.md | 20 +++++++++++++++++--- docs/command-palette.md | 35 ++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f95c85e4f..943bd2388 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -99,8 +99,9 @@ as a document, and these are working surfaces. There is exactly one search surface in the product: the command-palette scope. It is reachable two ways that resolve to the same UI — cmd+K anywhere, or clicking the magnifier in the top nav, which morphs in place -into an inline search bar over about 200ms with the spring easing (see -Motion). Esc collapses it back to the magnifier. There is no page-local +into an inline search bar over about 200ms with the in-place morph easing +(see Motion). Esc collapses it back to the magnifier, with focus returning +to the magnifier itself. There is no page-local search input that duplicates palette scope; a page that needs scoped filtering builds it as a filter control, not a second "search." See `docs/command-palette.md` for the palette's scoring and result-group @@ -139,10 +140,23 @@ Durations run 150–300ms; entrances ease out, never linear or bouncy-in. Two named easings cover the system: - `spring` — `cubic-bezier(.2, .9, .3, 1.15)` — for things that pop into - place with a little overshoot (the search bar's morph). + place with a little overshoot. - `out` — `cubic-bezier(.2, .8, .3, 1)` — for straightforward entrances and exits with no overshoot. +Something that grows or shrinks _in place_ — the search bar's morph, a rail +resizing — takes `--ease-in-out` instead: an overshoot there does not read as +liveliness, it drags every neighbour in the row along with it. This +supersedes the earlier reading of `spring` as the search morph's curve +(CL-6410 review); the curves themselves are react-ui's, and its `theme.css` +documents `--ease-in-out` as the morph curve. + +These are tokens on `@corbits/react-ui`'s theme, not Tailwind utilities the +product can name: the app imports react-ui's _prebuilt_ stylesheet, so a +`duration-standard` or `ease-spring` class compiles to nothing here. Product +motion is authored as a real `transition` declaration reading +`var(--duration-*)` / `var(--ease-*)`. + Motion always encodes a state change — something entering, something transforming, focus moving — never plain decoration. If removing an animation wouldn't remove any information, it doesn't belong. Every diff --git a/docs/command-palette.md b/docs/command-palette.md index a40d18107..2ad8abf84 100644 --- a/docs/command-palette.md +++ b/docs/command-palette.md @@ -11,19 +11,29 @@ This palette is the product's only search surface (DESIGN.md → Search), and it has exactly two doors: Cmd/Ctrl-K anywhere, and the magnifier the shell's top bar carries on every route (`StageSearch` in `apps/web/src/shell/stage-search.tsx`). Clicking the magnifier morphs it in -place into an inline bar — a width transition on react-ui's motion tokens -(`--duration-standard`, `--ease-spring`), not declared at all under -`prefers-reduced-motion`, where the swap is instant — and opens this same -overlay. Esc collapses the bar back to the magnifier. +place into an inline bar — a width transition authored in `app.css` on +react-ui's `--duration-standard` and `--ease-in-out` (the curve its theme +documents for something growing in place; the app imports react-ui's +prebuilt stylesheet, where Tailwind motion utilities do not exist) — and +opens this same overlay. Escape collapses the bar back to the magnifier and +returns focus to it. Reduced motion needs nothing here: that stylesheet +already collapses every transition duration under +`prefers-reduced-motion`. Both doors read and write one state, `command-palette-open-store.ts`: an external store rather than component state, because the palette provider and the top bar are siblings in `app.tsx`'s Shell, and a context-menu item opens -the palette too. Because react-ui's `CommandPalette` is a modal dialog that -owns the editable input once open, the morphed bar mirrors the live query -instead of accepting keystrokes — one editable search field in the product, -with the morph showing where the overlay came from. An anchored, non-modal -palette in react-ui would let that bar be the input itself. +the palette too. That state outlives a remount, so it is scoped explicitly — +the provider closes search on a route change (a Back out of a result never +leaves the overlay standing) and on a bench switch. cmd+K opens and does not +toggle: react-ui's shortcut yields to text fields, and an open palette holds +focus in its own input, so Escape and the overlay are the ways back out. + +Because react-ui's `CommandPalette` is a modal dialog that owns the editable +input once open, the morphed bar _shows_ the live query as text rather than +rendering a second input a click could land in — one editable search field in +the product, with the morph showing where the overlay came from. An anchored, +non-modal palette in react-ui would let that bar be the input itself. ## Where it lives @@ -74,8 +84,11 @@ Library list, since a Library item has no dedicated route of its own yet. Agents, skills, and plugins resolve to the slug-addressed detail routes DESIGN.md's Detail Pages section defines (`/agents/`), through -`detailPathForName` — a name that cannot name a URL resolves to the roster -instead of a fabricated slug. Routines and Library results still open their +`detailPath`, which is handed the entity's _own_ minted slug — an agent's +handle, a skill's name, an MCP server's slug. A slug is never derived from a +display title: slugs are immutable and titles are not, so a guess 404s the +moment the two disagree. An entity whose slug is not a slug falls back to +its opaque id, which every roster still resolves as a deep link. Routines and Library results still open their roster deep links, which carry real content the slug placeholders do not yet; moving them over belongs with those detail pages.