Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,21 @@ as a document, and these are working surfaces.

## Search

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 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
contract — this section only fixes how it's invoked from chrome.
Two separate surfaces, never merged, and neither opens the other (a
decision re-litigated more than once — see `docs/DECISIONS.md` → Search):

- **The magnifier in the stage top bar is a per-page filter.** It scopes to
whatever page it's on — Files filters files, Skills filters skills — and
never leaves that page. Clicking it morphs it in place into an inline
input over about 200ms with the in-place morph easing (see Motion); Esc
collapses it back, with focus returning to the magnifier. Where a page
already has its own filter, the magnifier drives that filter directly
rather than the page adding a second input. A page with nothing to filter
renders no magnifier at all.
- **`Cmd+K` opens the global command palette**, reachable from anywhere
(including a route with no stage top bar of its own) and rendered as its
own surface, never anchored to the magnifier. See `docs/command-palette.md`
for the palette's scoring and result-group contract.

## Color, Type & Icons

Expand Down
40 changes: 14 additions & 26 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -719,15 +719,18 @@ 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, 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. */
/* A page's per-page filter (DECISIONS.md → Search): a magnifier that morphs
in place into a plain text input scoped to that page. 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. This is
never the global command palette — see `command-palette-provider.tsx`,
mounted on its own. */
.stage-search {
display: flex;
flex-shrink: 0;
Expand Down Expand Up @@ -770,22 +773,7 @@ select:disabled,
color: var(--foreground);
}

.stage-search [data-slot="command-palette-inline"] {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}

.stage-search [data-slot="command-palette-inline-field"] {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 0.35rem;
}

.stage-search [data-slot="command-palette-input"] {
.stage-search-input {
min-width: 0;
flex: 1;
border: 0;
Expand All @@ -794,7 +782,7 @@ select:disabled,
color: var(--foreground);
}

.stage-search [data-slot="command-palette-input"]::placeholder {
.stage-search-input::placeholder {
color: var(--muted-foreground);
}

Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/command-palette-open-store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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.
// The state of the global command palette (DECISIONS.md → Search) — a
// separate surface from the stage top bar's per-page filter, which owns no
// state here at all. Held outside the React tree because the things that
// open it are siblings, not ancestors: `CommandPaletteProvider` renders the
// palette itself, `Cmd+K` opens it from anywhere via `useCommandShortcut`,
// and a context menu item opens it too. One store, so all three ways in can
// never disagree about whether the palette is open.
//
// Module state outlives a React remount, so search is scoped explicitly:
// `CommandPaletteProvider` closes it on a route change (a Back out of a
Expand Down
90 changes: 26 additions & 64 deletions apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
artifactKindLabel,
CommandPalette,
useCommandShortcut,
useTheme,
} from "@corbits/react-ui";
Expand All @@ -24,9 +25,7 @@ import {
} from "@corbits/command-palette";
import { useQueryClient } from "@tanstack/react-query";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
Expand All @@ -43,6 +42,7 @@ import {
import {
openCommandPalette,
setCommandPaletteOpen,
setCommandPaletteQuery,
useCommandPaletteOpen,
useCommandPaletteQuery,
} from "./command-palette-open-store";
Expand Down Expand Up @@ -71,44 +71,12 @@ const STATIC_COMMANDS = buildStaticCommands(
);

/**
* The data `StageSearch` renders — everything react-ui's `CommandPaletteInline`
* needs, computed once here rather than re-derived at the one place it is
* consumed. `StageSearch` owns the surface (the morphing bar, the input,
* the dropdown); this provider owns what fills it.
*/
export type CommandPaletteRenderProps = {
readonly groups: readonly CommandPaletteGroup[];
readonly onSelect: (id: string) => void;
readonly loading: boolean;
readonly error?: string;
readonly hasMore: boolean;
readonly onLoadMore?: () => void;
readonly footer: string;
};

/** `StageTopBar` mounts in isolation across the page test suite (no
* workbench, no query client, nothing search needs) — an inert palette
* that renders a magnifier with no results is the honest fallback there.
* The real app always mounts `CommandPaletteProvider` above `AppShell`
* (`app.tsx`), so production code never sees this default. */
const INERT_RENDER_PROPS: CommandPaletteRenderProps = {
groups: [],
onSelect: () => undefined,
loading: false,
hasMore: false,
footer: "",
};

const CommandPaletteRenderContext =
createContext<CommandPaletteRenderProps>(INERT_RENDER_PROPS);

/** Read by `StageSearch`, mounted anywhere under `CommandPaletteProvider`. */
export function useCommandPaletteRender(): CommandPaletteRenderProps {
return useContext(CommandPaletteRenderContext);
}

/**
* Wires the data-driven react-ui command palette into the app shell.
* Wires the data-driven react-ui command palette into the app shell, and
* renders it — the global surface `Cmd+K` (and a context-menu item) opens,
* as its own modal dialog (`CommandPalette`), never anchored to the stage
* top bar's per-page filter magnifier. Mounted once in `app.tsx`'s `Shell`,
* above `AppShell`, so it works from every route — including one that
* matches no page and renders no stage top bar of its own.
*
* Grouping, `#`/`@`/`>`/`/` scope parsing, and the Recents rule live in
* `@corbits/command-palette` (`buildCommandPaletteGroups`) — this file only
Expand All @@ -118,11 +86,6 @@ export function useCommandPaletteRender(): CommandPaletteRenderProps {
* `useEntitySearch` paging this provider already used; routines, skills and
* library artifacts are small per-bench catalogs fetched once and filtered
* client-side, the same way the static route list already is.
*
* This provider computes the data and hands it down through context; it
* renders no search surface itself. `StageSearch` (the top bar's magnifier)
* is the one place that data becomes UI — react-ui's `CommandPaletteInline`,
* anchored in place, never a centered dialog.
*/
export function CommandPaletteProvider({
path,
Expand All @@ -136,8 +99,8 @@ export function CommandPaletteProvider({
const { memberships, selectedTenantId, selectTenant } = useBench();
const queryClient = useQueryClient();
// 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`).
// Cmd+K and a context-menu item both open this surface from outside the
// React tree (`command-palette-open-store`).
const open = useCommandPaletteOpen();
const query = useCommandPaletteQuery();
const [recents, setRecents] = useState<readonly RecentEntry[]>([]);
Expand Down Expand Up @@ -645,24 +608,23 @@ export function CommandPaletteProvider({
],
);

const renderProps = useMemo<CommandPaletteRenderProps>(
() => ({
groups,
onSelect: handleSelect,
loading,
// `exactOptionalPropertyTypes` distinguishes an absent key from an
// explicit `undefined`, so the key only appears when there is an error.
...(error ? { error: "Search failed. Try again." } : {}),
hasMore,
onLoadMore: loadMore,
footer: "# workbenches · @ people · > actions · / pages",
}),
[groups, handleSelect, loading, error, hasMore, loadMore],
);

return (
<CommandPaletteRenderContext.Provider value={renderProps}>
<>
{children}
</CommandPaletteRenderContext.Provider>
<CommandPalette
open={open}
onOpenChange={setCommandPaletteOpen}
query={query}
onQueryChange={setCommandPaletteQuery}
groups={groups}
onSelect={handleSelect}
loading={loading}
{...(error ? { error: "Search failed. Try again." } : {})}
hasMore={hasMore}
onLoadMore={loadMore}
placeholder="Search or jump to…"
footer="# workbenches · @ people · > actions · / pages"
/>
</>
);
}
20 changes: 10 additions & 10 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
BulkActionBar,
Button,
LibrarySearchInput,
Menu,
MenuContent,
MenuItem,
Expand Down Expand Up @@ -316,9 +315,10 @@ function PreviewPane({
* Every control the page owns — the workbench lens, the name filter, sort,
* the rows/grid toggle, Upload — lives in `StageTopBar`'s action slot
* (DESIGN.md → Pages & Routing: the top nav owns the page's actions, and a
* page body never floats its own). The name filter is a filter control, not
* a second search: the product has exactly one search surface and it is the
* palette the top bar already carries (DESIGN.md → Search).
* page body never floats its own). The name filter drives the stage top
* bar's own magnifier (`filter` prop) rather than a second input — the
* magnifier IS this page's filter, never the global palette (DECISIONS.md
* → Search).
*/
export function LibraryPage({
artifacts,
Expand Down Expand Up @@ -440,6 +440,12 @@ export function LibraryPage({
? `${artifacts.length} files`
: artifactKindLabel(selectedSummary.kind)
}
filter={{
label: "Filter files",
placeholder: "Filter by name",
value: activeQuery,
onChange: setActiveQuery,
}}
actions={
<>
{selectedSummary !== null ? (
Expand Down Expand Up @@ -473,12 +479,6 @@ export function LibraryPage({
</Button>
</div>
) : null}
<LibrarySearchInput
label="Filter files"
placeholder="Filter by name"
value={activeQuery}
onChange={setActiveQuery}
/>
<Menu>
<MenuTrigger asChild>
<Button
Expand Down
31 changes: 11 additions & 20 deletions apps/web/src/pages/plugins-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,7 @@
// and mutations through `PluginSkillDetailPanel`, never forking
// `SkillsPage` itself.

import {
Button,
LibrarySearchInput,
PageShell,
RichEmptyState,
} from "@corbits/react-ui";
import { Button, PageShell, RichEmptyState } from "@corbits/react-ui";
import { WorkbenchLoadingState } from "@corbits/chat-ui";
import {
PluginsGallery,
Expand Down Expand Up @@ -214,21 +209,17 @@ export function PluginsRoute({
<div className="flex h-full min-h-0 flex-col">
<StageTopBar
crumbs={[{ label: "Plugins" }]}
filter={{
label: activeTab === "plugins" ? "Filter plugins" : "Filter skills",
value: galleryQuery,
onChange: setGalleryQuery,
}}
actions={
<>
<LibrarySearchInput
label={
activeTab === "plugins" ? "Search plugins" : "Search skills"
}
value={galleryQuery}
onChange={setGalleryQuery}
/>
{activeTab === "skills" ? (
<Button size="sm" onClick={() => setCreateSkillOpen(true)}>
<Plus /> New skill
</Button>
) : null}
</>
activeTab === "skills" ? (
<Button size="sm" onClick={() => setCreateSkillOpen(true)}>
<Plus /> New skill
</Button>
) : null
}
/>
<PageShell width="full" className="page-fill">
Expand Down
Loading
Loading