diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx
index 19057f59..1f1b7746 100644
--- a/frontend/packages/swarm-ui/src/shell/Shell.tsx
+++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx
@@ -35,6 +35,7 @@ import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
+import { isFullBleedRoute, isWideBodyRoute } from "./routes.js";
import { useDynamicTabs } from "./useDynamicTabs.js";
import { useSwarmName } from "./useSwarmName.js";
import "./Shell.css";
@@ -58,38 +59,6 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/components", label: "components", accent: "var(--blue)" },
];
-// Routes that opt out of `.shell-body`'s 60em readable-line-length cap
-// — mara screenshotted the issue report's 8-column table getting
-// clipped; that width is deliberate for the rest of the UI's cards/
-// forms, but too narrow for a wide table. `.shell-body-wide` (Shell.css) is a
-// modifier alongside the base class, not a replacement, so every other
-// route's layout is untouched. `/agents` joined for the same reason —
-// its own table view (plus the list+detail split) hits the same cap.
-const WIDE_BODY_ROUTES = new Set([
- "/issues",
- "/agents",
- "/review-runs",
- "/review-tryout",
-]);
-
-// `/agents/:name/terminal` (and a subagent's, under
-// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills
-// the whole viewport below the header bar, edge to edge (`shell-full` /
-// `shell-body-full` in Shell.css). It can't join the `Set` above — it's a
-// parameterized path, one literal string can't match every agent name.
-const FULL_BLEED_ROUTE_PATTERN =
- /^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
-
-// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
-// body below the header.
-function isFullBleedRoute(location: string): boolean {
- return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
-}
-
-function isWideBodyRoute(location: string): boolean {
- return WIDE_BODY_ROUTES.has(location);
-}
-
// Static fallback — matches `index.html`'s `
` default, so a page
// never flashes something else before the fetch below resolves, and an
// operator who never set `services.hyperhive.swarm.name` sees the exact
diff --git a/frontend/packages/swarm-ui/src/shell/routes.ts b/frontend/packages/swarm-ui/src/shell/routes.ts
new file mode 100644
index 00000000..e897e126
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/shell/routes.ts
@@ -0,0 +1,35 @@
+// Which layout a route gets inside the shell: the readable-width cap, the
+// wide body, or full-bleed. Kept apart from `Shell.tsx` so it is plain
+// functions of a path, with no DOM or CSS behind it, and can be tested.
+
+// Routes that opt out of `.shell-body`'s 60em readable-line-length cap
+// — mara screenshotted the issue report's 8-column table getting
+// clipped; that width is deliberate for the rest of the UI's cards/
+// forms, but too narrow for a wide table. `.shell-body-wide` (Shell.css) is a
+// modifier alongside the base class, not a replacement, so every other
+// route's layout is untouched. `/agents` joined for the same reason —
+// its own table view (plus the list+detail split) hits the same cap.
+const WIDE_BODY_ROUTES = new Set([
+ "/issues",
+ "/agents",
+ "/review-runs",
+ "/review-tryout",
+]);
+
+// `/agents/:name/terminal` (and a subagent's, under
+// `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills
+// the whole viewport below the header bar, edge to edge (`shell-full` /
+// `shell-body-full` in Shell.css). It can't join the `Set` above — it's a
+// parameterized path, one literal string can't match every agent name.
+const FULL_BLEED_ROUTE_PATTERN =
+ /^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
+
+// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
+// body below the header.
+export function isFullBleedRoute(location: string): boolean {
+ return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
+}
+
+export function isWideBodyRoute(location: string): boolean {
+ return WIDE_BODY_ROUTES.has(location);
+}
diff --git a/frontend/packages/swarm-ui/test/routes.test.mjs b/frontend/packages/swarm-ui/test/routes.test.mjs
new file mode 100644
index 00000000..ce10142c
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/routes.test.mjs
@@ -0,0 +1,42 @@
+// The shell's per-route layout: which paths are full-bleed, which get the
+// wide body. Run by `npm test`.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { isFullBleedRoute, isWideBodyRoute } from "../src/shell/routes.ts";
+
+test("the start page is full-bleed", () => {
+ assert.equal(isFullBleedRoute("/"), true);
+});
+
+test("the hive directory moved off / and is not full-bleed", () => {
+ assert.equal(isFullBleedRoute("/hives"), false);
+ assert.equal(isWideBodyRoute("/hives"), false);
+});
+
+test("an agent's terminal and a subagent's are full-bleed", () => {
+ assert.equal(isFullBleedRoute("/agents/iris/terminal"), true);
+ assert.equal(
+ isFullBleedRoute("/agents/iris/subagents/scout-1/terminal"),
+ true,
+ );
+});
+
+test("the agents page and other routes are not full-bleed", () => {
+ for (const path of ["/agents", "/agents/iris", "/jobs", "/issues"]) {
+ assert.equal(isFullBleedRoute(path), false, path);
+ }
+});
+
+test("a terminal path with extra segments is not full-bleed", () => {
+ assert.equal(isFullBleedRoute("/agents/iris/terminal/extra"), false);
+ assert.equal(isFullBleedRoute("/agents//terminal"), false);
+});
+
+test("the wide-body routes are exactly the table pages", () => {
+ for (const path of ["/issues", "/agents", "/review-runs", "/review-tryout"]) {
+ assert.equal(isWideBodyRoute(path), true, path);
+ }
+ for (const path of ["/", "/jobs", "/schedules", "/components"]) {
+ assert.equal(isWideBodyRoute(path), false, path);
+ }
+});