feat(web): Installation and Settings are real routes; every page owns a URL

/<pid>/install and /<pid>/settings join /insights and /history as view
routes — deep links, reload, and back/forward work; the sidebar menu
navigates instead of toggling panel state. Rule recorded in CLAUDE.md:
new surfaces are view routes, never URL-less panels (org/hub admin panels
are the legacy exceptions). 46/46 e2e incl. deep-link 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 10:51:25 -07:00
co-authored by Claude Fable 5
parent e894f4ced9
commit bacb528de6
6 changed files with 60 additions and 42 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+10 -5
View File
@@ -164,21 +164,26 @@ test("insights scopes to the selected folder via the ⋯ menu", async ({ page })
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
});
test("project menu: Dashboard, Installation, Settings", async ({ page }) => {
test("project menu pages each own a URL: Dashboard, Installation, Settings", async ({ page }) => {
await login(page);
await wikiId(page);
const pid = await wikiId(page);
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/insights`);
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await page.click("#nav-install");
await page.waitForURL(`/${pid}/install`);
await expect(page.locator("#crumb")).toHaveText("Installation");
await expect(page.locator("#nav-install")).toHaveClass(/active/);
await page.click("#nav-settings");
await page.waitForURL(`/${pid}/settings`);
await expect(page.locator("#crumb")).toHaveText("Project settings");
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
// Regression: from a panel, Dashboard must work even when the URL is
// already /insights (same-path navigation can't rely on route change).
await page.click("#nav-dashboard");
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
await page.waitForURL(`/${pid}/insights`);
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
// Deep link + reload land on the page, like any URL.
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
await expect(page.locator("#nav-settings")).toHaveClass(/active/);
});
+30 -18
View File
@@ -23,7 +23,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 } | { kind: "project" } | { kind: "install" }>(null);
const [panel, setPanel] = useState<null | { kind: "hub" } | { kind: "org"; orgId: string }>(null);
useEffect(() => setPanel(null), [pathname]);
const joinToken = useMemo(() => {
@@ -141,11 +141,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const activePanel =
panel?.kind === "hub"
? { crumb: "Signup & access", body: <HubSettings /> }
: panel?.kind === "project"
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: panel?.kind === "install"
? { crumb: "Installation", body: <div className="onboard"><ConnectGuide project={current} /></div> }
: panelOrg
: panelOrg
? {
crumb: panelOrg.name,
body: (
@@ -159,6 +155,20 @@ export default function HubApp({ config }: { config: ServerConfig }) {
}
: null;
const routePage =
route.view === "settings"
? { crumb: "Project settings", body: <ProjectSettings project={current} org={org} /> }
: route.view === "install"
? {
crumb: "Installation",
body: (
<div className="onboard">
<ConnectGuide project={current} />
</div>
),
}
: null;
// Landing ("/") and unknown project ids both resolve to a real project
// URL; replace so back/forward never bounces through the redirect.
if (route.project !== current.id) {
@@ -182,28 +192,30 @@ export default function HubApp({ config }: { config: ServerConfig }) {
projects={projects}
currentId={current.id}
menu={{
active:
panel?.kind === "project"
? "settings"
: panel?.kind === "install"
active: panel
? null
: route.view === "insights"
? "dashboard"
: route.view === "install"
? "install"
: !panel && route.view === "insights"
? "dashboard"
: route.view === "settings"
? "settings"
: null,
// Each page is a URL; explicitly close overlay panels because
// same-path navigation doesn't change pathname.
onDashboard: () => {
// Explicitly close any panel: navigating to the SAME url
// (already on /insights) doesn't change pathname, so the
// route-change effect can't do it.
setPanel(null);
navigate(urlForView("insights", current.id));
closeSidebarOnMobile();
},
onInstall: () => {
setPanel({ kind: "install" });
setPanel(null);
navigate(urlForView("install", current.id));
closeSidebarOnMobile();
},
onSettings: () => {
setPanel({ kind: "project" });
setPanel(null);
navigate(urlForView("settings", current.id));
closeSidebarOnMobile();
},
}}
@@ -211,7 +223,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
),
orgBar: accountBar,
}}
panel={activePanel}
panel={activePanel || routePage}
onClosePanel={() => setPanel(null)}
/>
);
+12 -11
View File
@@ -16,16 +16,21 @@ export function decodePath(p: string): string {
// Special views are RESTful routes under the project — the first segment
// after the project id is reserved when it names a view:
// /<project-id>/insights the Insights dashboard
// /<project-id>/insights[/<path>] the Insights dashboard (optionally scoped)
// /<project-id>/history[/<path>] change feed (project / subtree / file)
// (Root-level files literally named "insights" or "history" lose the URL
// shortcut and remain reachable through the tree.)
export const VIEW_ROUTES = new Set(["insights", "history"]);
// /<project-id>/install connect-a-device guide
// /<project-id>/settings project settings
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
// view routes here, not ephemeral panel state. (Root-level files literally
// named like a view lose the URL shortcut and remain reachable via the tree.)
export const VIEW_ROUTES = new Set(["insights", "history", "install", "settings"]);
export type ViewName = "insights" | "history" | "install" | "settings";
export interface Route {
project?: string;
path: string;
view?: "insights" | "history";
view?: ViewName;
viewTarget?: string;
}
@@ -38,7 +43,7 @@ export function parseRoute(pathname: string, mode: "volume" | "hub"): Route {
const seg = r.path.indexOf("/");
const head = seg === -1 ? r.path : r.path.slice(0, seg);
if (VIEW_ROUTES.has(head)) {
r.view = head as "insights" | "history";
r.view = head as ViewName;
r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
r.path = "";
}
@@ -53,11 +58,7 @@ export function urlForPath(path: string, projectId?: string): string {
}
// The URL for a special view of a project.
export function urlForView(
view: "insights" | "history",
projectId?: string,
target?: string,
): string {
export function urlForView(view: ViewName, projectId?: string, target?: string): string {
let s = (projectId ? "/" + projectId : "") + "/" + view;
if (target) s += "/" + encodePath(target.replace(/\/+$/, ""));
return s;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<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-DDdhKsd5.js"></script>
<script type="module" crossorigin src="/assets/index-B9q7i7UX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5jjBsTc.css">
</head>
<body>