feat(web): nav redesign — lucide icons; project settings in header, workspace gear + sign-out in org bar

Design feedback applied:
- icons are lucide-react (lucide.dev) behind the existing <Icon name> API;
  the inline SVG sprite is gone
- org bar (sidebar footer): the Manage text button is a gear icon opening
  the org admin panel; sign-out moved here, after the gear
- header: the org-manage (people) button is removed; the header gear now
  opens a new per-PROJECT settings panel (identity facts + connect guide);
  sign-out stays in the header only when no project/org bar is present
  (volume mode, loading, empty state)

Playwright suite updated and green (44/44); static/ rebuilt.

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 09:25:22 -07:00
co-authored by Claude Fable 5
parent fce5cdb670
commit 77735660c9
18 changed files with 358 additions and 139 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -494,8 +494,9 @@ conflicts. Set `BDRIVE_HOME` to relocate all beardrive state (used heavily in te
### Web frontend
The hub's web UI is a React + TypeScript app in `internal/webapp/frontend`
(Vite; runtime dependencies are just react, react-dom, and
@tanstack/react-query — routing is a small in-repo history router). Its
(Vite; runtime dependencies are just react, react-dom,
@tanstack/react-query, and lucide-react for icons — routing is a small
in-repo history router). Its
**built output is committed** at `internal/webapp/static`, the `go:embed`
target, so building or `go install`-ing the binary never needs Node.
+5 -5
View File
@@ -8,7 +8,7 @@ import { login, wikiId, ADMIN, MEMBER } from "./helpers";
test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => {
await login(page);
await page.click("#invite-btn"); // owner's Manage button
await page.click("#org-settings-btn"); // owner's Manage button
await expect(page.locator("#org-title")).toHaveText("default");
await expect(page.locator("#crumb")).toHaveText("default");
await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)");
@@ -27,7 +27,7 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p
test("org admin: member role change round-trip", async ({ page }) => {
await login(page);
await page.click("#invite-btn");
await page.click("#org-settings-btn");
const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select");
await sel.selectOption("owner");
await expect(page.locator("#toast")).toContainText("Role updated");
@@ -38,7 +38,7 @@ test("org admin: member role change round-trip", async ({ page }) => {
test("org admin: invite create shows in list, revoke removes it", async ({ page }) => {
await login(page);
await page.click("#invite-btn");
await page.click("#org-settings-btn");
await page.click(".admin-h .pbtn"); // New invite
await expect(page.locator("#toast")).toContainText("Invite");
const row = page.locator(".admin-item", { hasText: "/join/" }).first();
@@ -54,7 +54,7 @@ test("org admin: public share audit lists and revokes", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } });
await page.click("#invite-btn");
await page.click("#org-settings-btn");
const row = page.locator(".admin-item", { hasText: "index.md" });
await expect(row).toBeVisible();
await expect(row.locator(".ai-tag")).toContainText("wiki");
@@ -68,7 +68,7 @@ test("org admin: project rename and delete", async ({ page }) => {
await login(page);
await page.request.post("/api/projects", { data: { name: "doomed" } });
await page.reload(); // pick up the new project
await page.click("#invite-btn");
await page.click("#org-settings-btn");
const row = page.locator(".admin-item", { hasText: "doomed" });
await row.locator(".ai-btn", { hasText: "Rename" }).click();
await page.fill(".modal-input", "doomed-2");
+2 -2
View File
@@ -33,7 +33,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow
await login(page); // admin, owner of "default"
await expect(page.locator("#adminbar")).toBeVisible();
await expect(page.locator("#orgbar #org-name")).toHaveText("default");
await expect(page.locator("#invite-btn")).toBeVisible();
await expect(page.locator("#org-settings-btn")).toBeVisible();
await expect(page.locator("#signout")).toBeVisible();
const ctx = await browser.newContext();
@@ -41,7 +41,7 @@ test("admin sees admin bar and org Manage; member does not", async ({ page, brow
await login(p2, MEMBER);
await expect(p2.locator("#orgbar #org-name")).toHaveText("default");
await expect(p2.locator("#adminbar")).toHaveCount(0);
await expect(p2.locator("#invite-btn")).toHaveCount(0);
await expect(p2.locator("#org-settings-btn")).toHaveCount(0);
await ctx.close();
});
-30
View File
@@ -7,36 +7,6 @@
<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>">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></symbol>
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 3v6h6"/><path d="M3.5 9a9 9 0 1 0 2-4"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l1.7-1.3-1.7-3-2 .8a7.6 7.6 0 0 0-1.7-1l-.3-2.1H10l-.3 2.1a7.6 7.6 0 0 0-1.7 1l-2-.8-1.7 3L6 11a7.6 7.6 0 0 0 0 2l-1.7 1.3 1.7 3 2-.8a7.6 7.6 0 0 0 1.7 1l.3 2.1h3.4l.3-2.1a7.6 7.6 0 0 0 1.7-1l2 .8 1.7-3z"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-2a4 4 0 0 0-3-3.8"/><path d="M16 3.5a4 4 0 0 1 0 7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6z"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4.5" y="10" width="15" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 4 3 17a2 2 0 0 0 1.7 3h14.6a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 4v8M7.5 7a7 7 0 1 0 9 0"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
</svg>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+10
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"lucide-react": "^1.25.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -1532,6 +1533,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"lucide-react": "^1.25.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
+10 -11
View File
@@ -9,6 +9,7 @@ import { OrgAdmin } from "../components/OrgAdmin";
import { HubSettings } from "../components/HubSettings";
import { ProjectNav } from "../components/ProjectNav";
import { OrgBar } from "../components/OrgBar";
import { ProjectSettings } from "../components/ProjectSettings";
import { EmptyState } from "../components/EmptyState";
import { toast } from "../toast";
import Browser from "./Browser";
@@ -21,7 +22,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const [joinedOrgId, setJoinedOrgId] = useState<string | null>(null);
// Admin panels replace the content pane without touching the URL (they
// were never routes in the classic app); any navigation closes them.
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string } | { kind: "project" }>(null);
useEffect(() => setPanel(null), [pathname]);
const joinToken = useMemo(() => {
@@ -67,11 +68,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const brand = config.brand || config.volume || "BearDrive";
const org = (current && orgs?.find((o) => o.id === current.org)) || null;
const ownedOrg = orgs?.find((o) => o.role === "owner") || null;
// The top-of-sidebar gear is the always-visible admin entry point: any
// account that owns an org (or is a hub admin) gets it, whatever project
// is open. The panels it opens arrive in Phase 4.
const gearTarget = org && org.role === "owner" ? org : ownedOrg;
// Insights (embedded on the project home and behind the ⋯ menu) is for
// hub admins and owners of the project's org.
const canInsights = isAdmin || (org ? org.role === "owner" : false);
@@ -80,7 +76,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
<VaultHeader
name={projects ? (current ? current.name : brand) : "…"}
onHome={current ? () => navigate("/" + current.id) : undefined}
showSignout={config.auth.enabled}
showSignout={config.auth.enabled && !current}
admin={
isAdmin
? {
@@ -92,11 +88,11 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
: undefined
}
gear={
gearTarget
projectSettings={
current
? {
onClick: () => {
setPanel({ kind: "org", orgId: gearTarget.id });
setPanel({ kind: "project" });
closeSidebarOnMobile();
},
}
@@ -146,7 +142,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const activePanel =
panel?.kind === "hub"
? { crumb: "Signup & access", body: <HubSettings /> }
: panelOrg
: panel?.kind === "project"
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: panelOrg
? {
crumb: panelOrg.name,
body: (
@@ -182,6 +180,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
orgBar: (
<OrgBar
org={org}
showSignout={config.auth.enabled}
onManage={(o) => {
setPanel({ kind: "org", orgId: o.id });
closeSidebarOnMobile();
@@ -1,32 +1,54 @@
import type { Org } from "../api/types";
import { Icon } from "./shell";
// The sidebar footer names the project's org; clicking it opens the org
// admin panel, and owners get a Manage button that does the same. The
// panel itself arrives with the admin surfaces (Phase 4).
export function OrgBar({ org, onManage }: { org: Org | null; onManage: (org: Org) => void }) {
if (!org) return null;
// The sidebar footer is the workspace (org) row: the org name, a gear that
// opens the org admin panel (owners), and sign-out. Project-scoped actions
// live in the header; workspace-scoped ones end here.
export function OrgBar({
org,
onManage,
showSignout,
}: {
org: Org | null;
onManage: (org: Org) => void;
showSignout?: boolean;
}) {
if (!org && !showSignout) return null;
return (
<footer id="orgbar">
<span
id="org-name"
title="Manage organization"
role="button"
tabIndex={0}
onClick={() => onManage(org)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onManage(org);
}
}}
>
{org.name}
</span>
{org.role === "owner" && (
<button id="invite-btn" title="Manage this organization" onClick={() => onManage(org)}>
Manage
{org && (
<span
id="org-name"
title="Manage organization"
role="button"
tabIndex={0}
onClick={() => onManage(org)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onManage(org);
}
}}
>
{org.name}
</span>
)}
{org && org.role === "owner" && (
<button
id="org-settings-btn"
className="icon-btn2"
title="Manage this organization"
aria-label="Manage this organization"
onClick={() => onManage(org)}
>
<Icon name="gear" />
</button>
)}
{showSignout && (
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
<Icon name="power" />
</a>
)}
</footer>
);
}
@@ -0,0 +1,32 @@
import type { Org, Project } from "../api/types";
import { ConnectGuide } from "./ConnectGuide";
// Settings for the open project (the header gear). Today: identity facts
// plus the connect guide; per-project knobs land here as they grow.
export function ProjectSettings({ project, org }: { project: Project; org: Org | null }) {
return (
<div className="project-settings">
<h2>{project.name}</h2>
<dl className="ps-facts">
<dt>Project id</dt>
<dd>
<code>{project.id}</code>
</dd>
{org && (
<>
<dt>Workspace</dt>
<dd>{org.name}</dd>
</>
)}
{project.created && (
<>
<dt>Created</dt>
<dd>{new Date(project.created).toLocaleDateString()}</dd>
</>
)}
</dl>
<h3>Connect a device</h3>
<ConnectGuide project={project} />
</div>
);
}
@@ -1,4 +1,32 @@
import type { ReactNode } from "react";
import {
Check,
ChevronDown,
ChevronRight,
Clock,
Copy,
Download,
Ellipsis,
FileText,
Folder,
Globe,
History,
Link,
Lock,
LogOut,
Menu,
Plus,
Search,
Settings,
Share2,
Shield,
Trash2,
TriangleAlert,
Upload,
Users,
X,
type LucideIcon,
} from "lucide-react";
// The app's fixed layout: off-canvas sidebar (mobile: body.sb-open toggles
// it), topbar, and the content pane. Ids and classes match the classic app
@@ -11,12 +39,40 @@ export function closeSidebarOnMobile() {
document.body.classList.remove("sb-open");
}
// Icons are lucide (lucide.dev) components behind the historical sprite
// names, so call sites keep the tiny `<Icon name>` API and style.css's
// `.ico` sizing/stroke rules apply unchanged.
const ICONS: Record<string, LucideIcon> = {
alert: TriangleAlert,
check: Check,
chev: ChevronRight,
chevd: ChevronDown,
clock: Clock,
copy: Copy,
doc: FileText,
dots: Ellipsis,
download: Download,
folder: Folder,
gear: Settings,
globe: Globe,
hist: History,
link: Link,
lock: Lock,
menu: Menu,
plus: Plus,
power: LogOut,
search: Search,
share: Share2,
shield: Shield,
trash: Trash2,
upload: Upload,
users: Users,
x: X,
};
export function Icon({ name }: { name: string }) {
return (
<svg className="ico" aria-hidden="true">
<use href={`#i-${name}`} />
</svg>
);
const C = ICONS[name];
return C ? <C className="ico" aria-hidden="true" /> : null;
}
export function AppShell(props: {
@@ -57,11 +113,11 @@ export function AppShell(props: {
export function VaultHeader(props: {
name: string;
onHome?: () => void; // hub: the project name doubles as a home link
showSignout: boolean;
showSignout?: boolean; // volume mode: sign-out stays in the header (no org bar)
admin?: { pending: number; onClick: () => void }; // hub admins only
gear?: { onClick: () => void }; // org owners: manage organization
projectSettings?: { onClick: () => void }; // hub: settings for the open project
}) {
const { name, onHome, showSignout, admin, gear } = props;
const { name, onHome, showSignout, admin, projectSettings } = props;
return (
<header id="vault">
<span id="vault-badge" aria-hidden="true">
@@ -97,15 +153,15 @@ export function VaultHeader(props: {
<span>Admin{admin.pending ? " · " + admin.pending : ""}</span>
</button>
)}
{gear && (
{projectSettings && (
<button
id="settings-btn"
id="project-settings-btn"
className="icon-btn2"
title="Manage organization"
aria-label="Manage organization"
onClick={gear.onClick}
title="Project settings"
aria-label="Project settings"
onClick={projectSettings.onClick}
>
<Icon name="users" />
<Icon name="gear" />
</button>
)}
{showSignout && (
+2 -7
View File
@@ -154,12 +154,8 @@ button, input, a.btn { font-family: inherit; }
background: linear-gradient(160deg, #ffcf85, #d3861a); opacity: .85;
}
#org-name:hover { color: var(--text); }
#invite-btn {
flex: none; height: 27px; padding: 0 11px; border-radius: var(--r-ctl);
border: 1px solid var(--border); background: var(--surface);
color: var(--text-dim); font-size: 12px; font-weight: 500; cursor: pointer;
}
#invite-btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
#orgbar { gap: 4px; }
#orgbar #org-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- main pane ---- */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@@ -435,7 +431,6 @@ button, input, a.btn { font-family: inherit; }
#vault { padding: 0 8px 0 12px; }
.icon-btn2, #signout, .adminbar { min-width: 44px; min-height: 44px; }
#tree li > .row, #projects .row { height: 44px; }
#invite-btn { min-height: 44px; padding: 0 14px; }
#org-name { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
+163
View File
@@ -0,0 +1,163 @@
package webapp
// Temporary manual demo harness: a seeded hub (~500 files, zipf-ish read
// data, four agent devices) for exploring the web UI by hand. Not part of
// the test suite: it only runs with BDRIVE_MANUAL_SERVE=1 and must never be
// committed.
//
// State lives in a STABLE directory (os.TempDir()/bdrive-demo-hub, override
// with BDRIVE_MANUAL_STATE), so restarting the harness — e.g. after a
// frontend change — keeps accounts, browser sessions, the project id, and
// all seeded/demo data. Delete the directory to reset the demo.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/remote"
)
func TestManualServe(t *testing.T) {
if os.Getenv("BDRIVE_MANUAL_SERVE") == "" {
t.Skip("manual demo harness; set BDRIVE_MANUAL_SERVE=1 to run")
}
state := os.Getenv("BDRIVE_MANUAL_STATE")
if state == "" {
state = filepath.Join(os.TempDir(), "bdrive-demo-hub")
}
if err := os.MkdirAll(filepath.Join(state, "storage"), 0o755); err != nil {
t.Fatal(err)
}
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
if err != nil {
t.Fatal(err)
}
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
if err != nil {
t.Fatal(err)
}
p, _, err := db.GetOrCreate("proj", "") // create-or-join: id is stable across restarts
if err != nil {
t.Fatal(err)
}
srv := &Server{Root: be, Projects: db, Device: webDevice, Refresh: 0, Upload: UploadConfig{Enabled: true}}
prefix := filepath.Join(state, "storage", p.ID)
seedMark := filepath.Join(prefix, "journal", "seed.jsonl")
if _, err := os.Stat(seedMark); os.IsNotExist(err) {
seedDemo(t, state, prefix, p.ID)
}
srv.Reads, err = OpenReadLedger(filepath.Join(state, "reads.json"), 0)
if err != nil {
t.Fatal(err)
}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
srv.Devices.Observe(DeviceInfo{ID: "dev-ci", Name: "claude-ci", OS: "linux/amd64"})
srv.Devices.Observe(DeviceInfo{ID: "dev-snow", Name: "claude-snow", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "codex-mia", Name: "codex-mia", OS: "darwin/arm64"})
srv.Devices.Observe(DeviceInfo{ID: "gemini-doc", Name: "gemini-doc", OS: "linux/amd64"})
srv.Shares, _ = OpenShareDB(filepath.Join(state, "shares.json"))
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
if err != nil {
t.Fatal(err)
}
auth.signup("snow@runbear.io", "Snow", "password1") // no-op if the account exists
auth.Admins = map[string]bool{"snow@runbear.io": true}
srv.Auth = auth
t.Logf("serving on http://0.0.0.0:8993 (state: %s) — snow@runbear.io / password1", state)
go http.ListenAndServe("0.0.0.0:8993", srv.Handler())
time.Sleep(8 * time.Hour)
}
// seedDemo writes ~500 files (journal + blobs) and their read buckets. Runs
// once per state dir.
func seedDemo(t *testing.T, state, prefix, projectID string) {
t.Helper()
os.MkdirAll(filepath.Join(prefix, "journal"), 0o755)
os.MkdirAll(filepath.Join(prefix, "blobs"), 0o755)
seed := int64(42)
rnd := func() float64 {
seed = (seed*16807 + 7) % 2147483647
return float64(seed) / 2147483647
}
folders := []struct {
dir string
n int
}{
{"wiki/onboarding", 28}, {"wiki/architecture", 40}, {"wiki/runbooks", 55},
{"wiki/api", 70}, {"wiki/decisions", 45}, {"docs/product", 60},
{"docs/design", 35}, {"notes/meetings", 80}, {"notes/research", 45},
{"shared/reports", 42},
}
humans := []string{"alice@x.io", "bob@x.io", "carol@x.io"}
agents := []string{"dev-ci", "dev-snow", "codex-mia", "gemini-doc"}
now := time.Now().UTC()
var ops []journal.Op
var stats []ReadStat
var lam, seq int64
fileNo := 0
for _, f := range folders {
short := filepath.Base(f.dir)[:3]
for i := 0; i < f.n; i++ {
fileNo++
path := fmt.Sprintf("%s/%s-%03d.md", f.dir, short, i+1)
content := fmt.Sprintf("# %s\n\nSeeded demo file %d in %s.\n", path, fileNo, f.dir)
sum := sha256.Sum256([]byte(content))
blob := hex.EncodeToString(sum[:])
os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(content), 0o644)
stale := int(400 * rnd() * rnd())
lam++
seq++
ops = append(ops, journal.Op{
Seq: seq, Lamport: lam, Time: now.AddDate(0, 0, -stale),
Device: "seed", DeviceName: "seed", Author: "alice@x.io",
User: "alice@x.io", UserName: "Alice",
Kind: journal.KindPut, Path: path, Blob: blob,
Size: int64(len(content)), Mode: 0o644,
})
hot := rnd() < 0.15
day := now.AddDate(0, 0, -int(rnd()*20)).Format("2006-01-02")
for _, a := range humans {
n := int64(math.Floor((map[bool]float64{true: 30, false: 3}[hot]) * rnd() * rnd()))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindHuman, Actor: a, Count: n, Last: now})
}
}
for ai, a := range agents {
boost := 1.0
if f.dir == "wiki/runbooks" && ai < 2 {
boost = 4
}
if f.dir == "notes/research" {
boost = 0.05
}
n := int64(math.Floor((map[bool]float64{true: 60, false: 5}[hot]) * rnd() * rnd() * boost))
if n > 0 {
stats = append(stats, ReadStat{Project: projectID, Path: path, Day: day,
Kind: ReadKindAgent, Actor: a, Count: n, Last: now})
}
}
}
}
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
t.Fatal(err)
}
if err := newFileReadRepo(filepath.Join(state, "reads.json")).PutBatch(stats); err != nil {
t.Fatal(err)
}
}
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
File diff suppressed because one or more lines are too long
+2 -32
View File
@@ -5,40 +5,10 @@
<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-Dz7xLxXQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-h2c92P2n.css">
<script type="module" crossorigin src="/assets/index-CGPKgglj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbihATmy.css">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></symbol>
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></symbol>
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 3v6h6"/><path d="M3.5 9a9 9 0 1 0 2-4"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l1.7-1.3-1.7-3-2 .8a7.6 7.6 0 0 0-1.7-1l-.3-2.1H10l-.3 2.1a7.6 7.6 0 0 0-1.7 1l-2-.8-1.7 3L6 11a7.6 7.6 0 0 0 0 2l-1.7 1.3 1.7 3 2-.8a7.6 7.6 0 0 0 1.7 1l.3 2.1h3.4l.3-2.1a7.6 7.6 0 0 0 1.7-1l2 .8 1.7-3z"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-2a4 4 0 0 0-3-3.8"/><path d="M16 3.5a4 4 0 0 1 0 7"/></symbol>
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6z"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4.5" y="10" width="15" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 4 3 17a2 2 0 0 0 1.7 3h14.6a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/></symbol>
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 4v8M7.5 7a7 7 0 1 0 9 0"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
</svg>
<div id="root"></div>
</body>
</html>