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
20 changes: 17 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,63 @@ 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. */
.stage-search {
display: flex;
flex-shrink: 0;
align-items: center;
width: 1.9rem;
overflow: hidden;
border: 1px solid transparent;
transition: width var(--duration-standard) var(--ease-in-out);
}

.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;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
color: var(--foreground);
}

.stage-search-field[data-placeholder="true"] {
color: var(--muted-foreground);
}

/* Breadcrumb trails live in the title slot, always top-left. */
.stage-crumbs {
display: flex;
Expand Down
9 changes: 0 additions & 9 deletions apps/web/src/command-palette-events.ts

This file was deleted.

66 changes: 66 additions & 0 deletions apps/web/src/command-palette-open-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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.
//
// 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";

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 closeCommandPalette(): void {
setCommandPaletteOpen(false);
}

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,
() => "",
);
}
Loading
Loading