feat(web): search moves beside the brand in the sidebar header, with a ⌘K hover tooltip

Icon-only trigger in the vault header (Linear-style); a tiny search.ts
emitter asks Browser's palette to open — no plumbing through the shell.
Custom tooltip card (label + kbd chip, arrow) on hover/focus. Topbar search
and its centered styles removed. 47/47 e2e incl. header-trigger spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
Snow Lee
2026-07-19 11:24:07 -07:00
co-authored by Claude Fable 5
parent 75f2b0bfe9
commit 3f974d96e4
12 changed files with 119 additions and 23 deletions
@@ -88,6 +88,14 @@ test("back/forward walks file → folder → file", async ({ page }) => {
await expect(page.locator(".dl-title")).toContainText("notes");
});
test("header search button opens the palette", async ({ page }) => {
await login(page);
await wikiId(page);
await page.click("#search-btn");
await expect(page.locator("#palette")).toBeVisible();
await page.keyboard.press("Escape");
});
test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
@@ -12,6 +12,7 @@ import { urlForPath, urlForView, type Route } from "../router";
import { currentNavType, navigate, useLocationPath } from "../nav";
import { copyText } from "../util";
import { toast } from "../toast";
import { onSearchRequest } from "../search";
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
import { FileTree, ancestorsOf } from "../components/FileTree";
import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -141,6 +142,7 @@ export default function Browser(props: {
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => onSearchRequest(() => setPaletteOpen(true)), []);
const downloadRef = useRef<HTMLAnchorElement>(null);
const panel = props.panel ?? null;
@@ -348,9 +350,6 @@ export default function Browser(props: {
meta={meta}
actions={
<>
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
<Icon name="search" /> <span className="lbl">Search</span> <kbd>K</kbd>
</button>
{canShare && (
<button id="share-btn" className="btn icon-only" title="Share" aria-label="Share" onClick={shareNow}>
<Icon name="share" />
+1 -1
View File
@@ -75,7 +75,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// Top of the sidebar is the brand; project and account actions live in
// their own sections below (PropelAuth-style layout).
const vault = <VaultHeader name={brand} onHome={() => navigate("/")} />;
const vault = <VaultHeader name={brand} onHome={() => navigate("/")} search={!!current} />;
const accountBar = config.me ? (
<AccountBar
@@ -21,7 +21,7 @@ export default function VolumeApp({ config }: { config: ServerConfig }) {
apiBase="/api/"
route={route}
hub={false}
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} /> }}
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} search /> }}
/>
);
}
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { requestSearch } from "../search";
import {
Check,
ChevronDown,
@@ -118,8 +119,9 @@ export function VaultHeader(props: {
name: string;
onHome?: () => void; // hub: the project name doubles as a home link
showSignout?: boolean; // volume mode: sign-out stays in the header (no account bar)
search?: boolean; // icon-only ⌘K search trigger beside the brand
}) {
const { name, onHome, showSignout } = props;
const { name, onHome, showSignout, search } = props;
return (
<header id="vault">
<span id="vault-badge" aria-hidden="true">
@@ -141,6 +143,22 @@ export function VaultHeader(props: {
{name}
</span>
<div className="vault-actions">
{search && (
<button
id="search-btn"
className="icon-btn2 has-tip"
aria-label="Search"
onClick={() => {
requestSearch();
closeSidebarOnMobile();
}}
>
<Icon name="search" />
<span className="tip" role="tooltip">
Search <kbd>K</kbd>
</span>
</button>
)}
{showSignout && (
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
<Icon name="power" />
+14
View File
@@ -0,0 +1,14 @@
// Tiny cross-component signal: the sidebar's search button asks whichever
// surface owns the ⌘K palette (Browser) to open it — same in-repo-emitter
// spirit as nav.ts, no context plumbing through the shell.
type Listener = () => void;
const listeners = new Set<Listener>();
export function onSearchRequest(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
export function requestSearch(): void {
for (const l of listeners) l();
}
+19 -10
View File
@@ -217,17 +217,27 @@ button, input, a.btn { font-family: inherit; }
.btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
.btn .ico { width: 15px; height: 15px; }
.btn.ghost { color: var(--text-dim); }
/* The search control sits dead-center in the topbar, twice its natural
width, reading as an input field. */
#search-btn {
position: absolute; left: 50%; transform: translateX(-50%);
width: 260px; justify-content: flex-start;
/* Hover tooltip for icon-only controls (the header search): label + kbd
chip in a floating card below the button, arrow pointing up. */
.has-tip { position: relative; }
.has-tip .tip {
position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 7px; white-space: nowrap;
padding: 6px 9px; border-radius: 8px; border: 1px solid var(--border-2);
background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 500;
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
opacity: 0; visibility: hidden; transition: opacity .12s ease .15s; z-index: 40;
}
#search-btn kbd { margin-left: auto; }
#search-btn kbd {
.has-tip .tip::before {
content: ""; position: absolute; top: -4.5px; left: 50%;
width: 8px; height: 8px; transform: translateX(-50%) rotate(45deg);
background: var(--surface); border-left: 1px solid var(--border-2); border-top: 1px solid var(--border-2);
}
.has-tip:hover .tip, .has-tip:focus-visible .tip { opacity: 1; visibility: visible; }
.has-tip .tip kbd {
font: 11px var(--ui); color: var(--text-faint);
background: var(--hover); border: 1px solid var(--border-2); border-radius: 5px;
padding: 1px 5px; margin-left: 2px;
padding: 1px 5px;
}
#more-menu {
position: absolute; right: 12px; top: calc(100% - 4px); z-index: 80;
@@ -463,8 +473,7 @@ button, input, a.btn { font-family: inherit; }
.icon-btn { display: inline-flex; width: 44px; height: 44px; }
#content { padding: 24px 18px 70px; }
#topbar { padding: 0 8px; gap: 4px; }
#search-btn { position: static; transform: none; width: auto; justify-content: center; }
#search-btn kbd, .btn .lbl { display: none; }
.btn .lbl { display: none; }
#topbar .btn { min-width: 44px; min-height: 44px; padding: 0; justify-content: center; gap: 0; }
#topbar .btn .ico { width: 18px; height: 18px; }
#more-btn:not([hidden]) { display: inline-flex; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-XRsupwXA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CCQsgUR1.css">
<script type="module" crossorigin src="/assets/index-Ce0U9QAi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4kYunG_6.css">
</head>
<body>
<div id="root"></div>
+48
View File
@@ -220,6 +220,35 @@ h2 { font-size: clamp(26px, 3.2vw, 34px); margin: 0 0 12px; letter-spacing: -.02
.claude ul li::before { content: "✓"; position: absolute; left: 0; color: var(--brand); font-weight: 600; }
.claude ul b { color: var(--text); font-weight: 600; }
/* auto-share reply mock */
.autoshare { text-align: center; }
.autoshare .lede { margin-left: auto; margin-right: auto; }
.reply {
max-width: 660px; margin: 6px auto 0; text-align: left;
background: #101010; border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 0 0 1px rgba(245,166,35,.05), 0 24px 64px rgba(0,0,0,.55);
overflow: hidden;
}
.reply-line {
padding: 24px 26px; font: 15px/1.7 var(--mono); color: var(--text);
display: flex; gap: 13px; align-items: baseline;
}
.reply-dot { color: var(--brand); font-size: 11px; line-height: 1.7; }
.reply-path { color: #6fbfff; word-break: break-all; }
.reply-link {
color: var(--brand); text-decoration: none; cursor: pointer;
padding: 1px 4px; border-radius: 5px; transition: background .15s;
}
.reply-link:hover { background: rgba(245,166,35,.16); }
.reply-cap {
border-top: 1px solid #1e1e1e; background: #0c0c0c;
padding: 13px 26px; font: 12.5px/1.6 var(--mono); color: var(--text-faint);
}
.reply-cap code {
font: 11.5px var(--mono); background: var(--card);
border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; color: var(--text-dim);
}
/* open source / cloud */
.oss { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.oss-card {
@@ -345,6 +374,25 @@ every tool works content-addressed blobs append-only, no locks, offl
</div>
</section>
<section id="autoshare" class="autoshare">
<div class="wrap">
<p class="kicker">SHARE WITHOUT A SHARE STEP</p>
<h2>The link is born with the file.</h2>
<p class="lede">The moment your agent writes a file, the path in its reply is
already a clickable, members-only hub link. You didn't run a command, you didn't
copy a path — any teammate who's signed in opens it in one click, rendered and at
the latest version. It's gated to your project, so it's safe to paste in any
internal channel.</p>
<div class="reply" aria-label="Example agent reply with an auto-generated hub link">
<div class="reply-line">
<span class="reply-dot"></span>
<span>Deal page written to <span class="reply-path">shared/wiki/deals/motive.md</span> <span class="reply-link" role="link" tabindex="0" title="Opens for signed-in teammates on the project">🔗</span></span>
</div>
<div class="reply-cap">↑ your agent's own reply — the <span class="reply-link" style="cursor:default">🔗</span> is a live hub link it added automatically. No <code>bdrive share</code>, no copy-paste, nobody asking you to resend it.</div>
</div>
</div>
</section>
<section id="why">
<div class="wrap">
<p class="kicker">WHY BEARDRIVE</p>