Skip to content

Global search: top-nav magnifier morphs into the one palette surface - #171

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-6410-search-morph
Aug 21, 2026
Merged

TheGreatAxios merged 6 commits into
mainfrom
cl-6410-search-morph

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Implements CL-6410 — global search: the top-nav magnifier morphs into the one command-palette surface.

DESIGN.md's Search section fixes the invocation contract: exactly one search surface, reachable two ways that resolve to the same UI. This wires that contract into the shell and extends the existing @corbits/command-palette — no second search implementation.

What changed

  • StageSearch (apps/web/src/shell/stage-search.tsx) — the magnifier every route's top bar now carries, ahead of the page's own actions. Clicking it morphs the control in place into an inline bar: a width transition on react-ui's motion tokens (duration-standard / ease-spring, i.e. 200ms with the spring easing). Esc collapses it back to the magnifier and returns focus to it. Under prefers-reduced-motion the transition is not declared at all, so the swap is instant.
  • One open state — command-palette-open-store.ts holds open + query outside the React tree, because CommandPaletteProvider and StageTopBar are siblings in app.tsx's Shell. The morph and the palette can no longer disagree about whether search is open. The old command-palette-events.ts window event is gone; the context menu's Search item drives the same store (cut over, no legacy path left beside it).
  • Slug detail routing — detailPathForName (new in @corbits/command-palette) resolves a result's name to the slug-addressed detail routes PR Slug detail routes for agents, skills, plugins, routines #155 landed: agents, skills, and plugins now navigate to /agents/<slug> etc. A name that cannot name a URL resolves to the roster rather than a fabricated slug.
  • Plugins group — the bench's connected MCP servers, each already carrying the immutable slug its detail route is addressed by. Every other group the ticket lists (agents, skills, files, routines, workbenches, runs, actions, pages) already existed and was left alone.

Interaction decision worth a look

react-ui's CommandPalette is a modal dialog that owns the editable input once open. So the morphed inline bar 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 that bar be the input itself — flagged in docs/command-palette.md rather than worked around here.

Follow-ups (noted, not done)

  • Plugins covers connected servers only; gallery presets and catalog entries have no detail route yet.
  • Routines and Library results still open their roster deep links, which carry real content the slug placeholders do not yet.
  • The palette still has no scope-chip badge or footer prefix legend (pre-existing react-ui gap).

Testing

apps/web/test/global-search-morph.test.tsx covers the morph open/close, Esc restoring the icon, cmd+K and a click landing on one palette surface (asserting a single combobox in the document), a result navigating to /agents/research-analyst, and the reduced-motion path. packages/command-palette/src/detail-paths.test.ts covers the slug/roster resolution.

bun run check is green except workflows/assistant (2 failures in definition.test.ts / manager-tools-scenario.test.ts), which fail identically on a clean origin/main worktree — pre-existing, unrelated to this change.

https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review fixes pushed (rebased onto current main). Per item:

1 — motion classes were inert. Confirmed: react-ui's prebuilt dist/styles.css defines neither .duration-standard/.ease-spring nor --ease-spring, so only transition-[width] applied. The transition is now authored in app.css on .stage-search: transition: width var(--duration-standard) var(--ease-in-out). Took --ease-in-out per react-ui's own token doc ("symmetric in-out curve for morphs — something growing/shrinking in place") — and updated DESIGN.md's Motion section, which had named spring for this morph, with the rationale and the note that these are theme tokens, not utilities the product can name. The test now reads the authored rule out of app.css, asserts both tokens exist in the stylesheet the app actually imports, and asserts the element carries no inert motion utility.

2 — Esc/focus-return unreachable. Dead wrapper onKeyDown deleted. Focus now returns to the magnifier from an effect on the shared open state, so it fires however the palette closed (Escape in the dialog, overlay click, store). Tests drive Escape inside the palette input, including the cmd+K case where the magnifier was never focused — which is what proves it is our mechanism and not radix restoring the click target.

3 — slug fabrication. detailPathForName is gone. detailPath(rosterPath, { slug, id }) takes the entity's own minted slug and never derives one: valid slug → /agents/<slug>, otherwise → /agents/<id>, the roster deep link. The agent's real handle is recorded as the definitions list is fetched (useEntitySearch carries only id + title), skills pass their name (which is their slug — no double-slugify), plugins pass server.slug. Rival slugify cut over in this PR: create-agent-panel now uses @corbits/slug's slugify/isValidSlug and its local copy plus HANDLE_PATTERN are deleted — accent folding and the length cap now apply where handles are minted. Covered by a test with a "Café Crème Bot" handle landing on /agents/wfd_2, never /agents/cafe-creme-bot.

4 — mirror input was a decoy. Now a <span> styled as the field (ellipsis, muted when showing placeholder text). aria-hidden/tabIndex unnecessary and gone; nothing to click into or focus, so it can no longer swallow cmd+K.

5 — data-motion suppressed nothing. Attribute, the usePrefersReducedMotion call and the conditional class are gone, along with the test asserting them. A test asserts the global prefers-reduced-motion transition-duration collapse exists in the shipped stylesheet, since that is the mechanism now relied on.

6 — store lifecycle. The provider closes search when path changes or the bench switches (a first null→tenant resolve at boot is not a switch), and closing clears the query. Tested via a route change closing an open surface.

7 — stale comments. Both references to the deleted command-palette-events.ts rewritten.

8 — toggleCommandPalette could only open. Renamed to the honest openCommandPalette (plus closeCommandPalette); the cmd+K test now closes with Escape rather than pretending a second cmd+K works.

9 — rebase. Rebased onto origin/main (1a19da8b); no conflict materialised in shell/context-menu/items.tsx, and that file's only palette reference is the store API.

bun run check is green end to end on this branch — including workflows/assistant, whose two failures I flagged earlier were the pre-existing connections-tools pin, now fixed on main.

https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti

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.
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.
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.
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.
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.
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.
@TheGreatAxios
TheGreatAxios merged commit 0aa2ca4 into main Aug 21, 2026
0 of 2 checks passed
@TheGreatAxios
TheGreatAxios deleted the cl-6410-search-morph branch August 25, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant