diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e63f8ea..1243803 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,8 @@ jobs:
node-version: 22
- name: Build UI
run: cd ui && npm ci && npm run build
+ - name: UI unit tests
+ run: cd ui && npm test
- name: cargo test
run: cargo test --workspace --release
fmt:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04afe6a..8f53349 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## Unreleased
+
+- Web UI maps human librarium URLs (`/projects/…/doc.md`) to the entries
+ browser instead of sending unknown paths to the library root.
+
## 0.1.7 - 2026-05-11
Authentication, concurrency, and operator tooling release.
diff --git a/tests/test_webui_selenium.py b/tests/test_webui_selenium.py
index ef0d591..d4060a0 100644
--- a/tests/test_webui_selenium.py
+++ b/tests/test_webui_selenium.py
@@ -373,6 +373,43 @@ def test_spa_deep_link_not_404(selenium_driver, tabularium_base_url):
assert "404" not in selenium_driver.title.lower()
+def test_spa_librarium_abs_path_opens_document(selenium_driver, tabularium_base_url):
+ slug = uuid.uuid4().hex[:8]
+ name = f"pretty_{slug}"
+ mkdir(tabularium_base_url, name)
+ base = tabularium_base_url.rstrip("/")
+ marker = f"pretty-open-{slug}"
+ requests.post(
+ f"{base}/api/doc/{name}",
+ json={"name": "tracker.md", "content": f"# {marker}\n"},
+ timeout=15,
+ ).raise_for_status()
+ selenium_driver.get(f"{base}/{name}/tracker.md")
+ _wait_app_ready(selenium_driver)
+ _wait(selenium_driver).until(lambda d: "open=" in d.current_url)
+ assert f"/entries/{name}" in selenium_driver.current_url
+ _wait(selenium_driver).until(
+ EC.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, "[data-testid='preview-pane']"),
+ marker,
+ ),
+ )
+
+
+def test_spa_librarium_abs_path_opens_directory(
+ selenium_driver, tabularium_base_url, seed_dir: str,
+):
+ selenium_driver.get(f"{tabularium_base_url.rstrip('/')}/{seed_dir}")
+ _wait_app_ready(selenium_driver)
+ _wait(selenium_driver).until(
+ EC.text_to_be_present_in_element(
+ (By.CSS_SELECTOR, "[data-testid='entries-path']"),
+ f"/{seed_dir}",
+ ),
+ )
+ assert "open=" not in selenium_driver.current_url
+
+
# --- Group E — infrastructure ---
diff --git a/ui/eslint.config.js b/ui/eslint.config.js
index 5e6b472..094f19b 100644
--- a/ui/eslint.config.js
+++ b/ui/eslint.config.js
@@ -20,4 +20,10 @@ export default defineConfig([
globals: globals.browser,
},
},
+ {
+ files: ['**/*.test.ts'],
+ languageOptions: {
+ globals: globals.node,
+ },
+ },
])
diff --git a/ui/package.json b/ui/package.json
index 1643f8e..574a323 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "test": "node --experimental-strip-types --test src/features/entries/entriesPath.test.ts",
"lint": "eslint .",
"preview": "vite preview"
},
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 09f3c84..126d5ef 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,11 +1,28 @@
import { lazy, Suspense } from "react";
-import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
+import {
+ BrowserRouter,
+ Navigate,
+ Route,
+ Routes,
+ useLocation,
+} from "react-router-dom";
import { AppShell } from "./app/AppShell";
import { AuthShell } from "./app/AuthShell";
+import { entriesUrlFromLibrariumPathname } from "./features/entries/entriesPath";
import { EntriesView } from "./features/entries/EntriesView";
const StatsView = lazy(() => import("./features/stats/StatsView"));
+function LibrariumPathRedirect() {
+ const { pathname, hash } = useLocation();
+ return (
+
+ );
+}
+
export default function App() {
return (
@@ -22,7 +39,7 @@ export default function App() {
}
/>
- } />
+ } />
diff --git a/ui/src/features/entries/entriesPath.test.ts b/ui/src/features/entries/entriesPath.test.ts
new file mode 100644
index 0000000..05f43b1
--- /dev/null
+++ b/ui/src/features/entries/entriesPath.test.ts
@@ -0,0 +1,37 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { entriesUrlFromLibrariumPathname } from "./entriesPath.ts";
+
+test("opens a nested markdown path as preview, not entries root", () => {
+ assert.equal(
+ entriesUrlFromLibrariumPathname(
+ "/projects/turret/design/sas-architecture-tracker.md",
+ ),
+ "/entries/projects/turret/design?open=%2Fprojects%2Fturret%2Fdesign%2Fsas-architecture-tracker.md",
+ );
+});
+
+test("opens a root-level document", () => {
+ assert.equal(
+ entriesUrlFromLibrariumPathname("/notes.md"),
+ "/entries?open=%2Fnotes.md",
+ );
+});
+
+test("treats a path without a file suffix as a directory", () => {
+ assert.equal(
+ entriesUrlFromLibrariumPathname("/projects/turret/design"),
+ "/entries/projects/turret/design",
+ );
+});
+
+test("maps empty path to entries root", () => {
+ assert.equal(entriesUrlFromLibrariumPathname("/"), "/entries");
+});
+
+test("opens a hidden file as preview", () => {
+ assert.equal(
+ entriesUrlFromLibrariumPathname("/secrets/.gitignore"),
+ "/entries/secrets?open=%2Fsecrets%2F.gitignore",
+ );
+});
diff --git a/ui/src/features/entries/entriesPath.ts b/ui/src/features/entries/entriesPath.ts
index 6fd3986..79313dd 100644
--- a/ui/src/features/entries/entriesPath.ts
+++ b/ui/src/features/entries/entriesPath.ts
@@ -1,20 +1,14 @@
/**
* Browser routes for the entries browser — `/entries` + optional path segments.
* Optional `?open=` holds absolute document path for preview + refresh (Enginseer / Ferrum).
+ * Human links may omit `/entries` (`/projects/…/doc.md`); those map via
+ * `entriesUrlFromLibrariumPathname`.
*/
const PREFIX = "/entries";
-/** Librarium dir path from location (always a directory in the URL). */
-export function dirPathFromEntriesLocation(pathname: string): string {
- if (pathname === PREFIX || pathname === `${PREFIX}/`) {
- return "/";
- }
- if (!pathname.startsWith(`${PREFIX}/`)) {
- return "/";
- }
- const rest = pathname.slice(PREFIX.length + 1);
- const segments = rest
+function decodePathSegments(pathname: string): string[] {
+ return pathname
.split("/")
.filter(Boolean)
.map((s) => {
@@ -24,6 +18,31 @@ export function dirPathFromEntriesLocation(pathname: string): string {
return s;
}
});
+}
+
+function absFromPathname(pathname: string): string {
+ const segments = decodePathSegments(pathname);
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
+}
+
+/** Last segment looks like a file name (`notes.md`, `.gitignore`), not `.` / `..`. */
+function looksLikeDocumentName(name: string): boolean {
+ if (name === "." || name === "..") {
+ return false;
+ }
+ return name.includes(".");
+}
+
+/** Librarium dir path from location (always a directory in the URL). */
+export function dirPathFromEntriesLocation(pathname: string): string {
+ if (pathname === PREFIX || pathname === `${PREFIX}/`) {
+ return "/";
+ }
+ if (!pathname.startsWith(`${PREFIX}/`)) {
+ return "/";
+ }
+ const rest = pathname.slice(PREFIX.length + 1);
+ const segments = decodePathSegments(rest);
return `/${segments.join("/")}`;
}
@@ -51,3 +70,21 @@ export function withOpenDocQuery(
q.set("open", docAbsolutePath);
return `${path}?${q.toString()}`;
}
+
+/**
+ * Maps a human librarium URL (`/projects/…/doc.md`) to the entries browser route.
+ * Last path segment containing `.` is treated as a document; otherwise a directory.
+ */
+export function entriesUrlFromLibrariumPathname(pathname: string): string {
+ const abs = absFromPathname(pathname);
+ if (abs === "/") {
+ return PREFIX;
+ }
+ const slash = abs.lastIndexOf("/");
+ const name = abs.slice(slash + 1);
+ if (looksLikeDocumentName(name)) {
+ const dirPath = slash <= 0 ? "/" : abs.slice(0, slash);
+ return withOpenDocQuery(dirPath, abs);
+ }
+ return entriesPathForDir(abs);
+}
diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json
index af516fc..160e659 100644
--- a/ui/tsconfig.app.json
+++ b/ui/tsconfig.app.json
@@ -24,5 +24,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src"],
+ "exclude": ["src/**/*.test.ts"]
}