diff --git a/frontend/src/routes/WorkspaceHome.onboard.test.ts b/frontend/src/routes/WorkspaceHome.onboard.test.ts
new file mode 100644
index 00000000..ca84680c
--- /dev/null
+++ b/frontend/src/routes/WorkspaceHome.onboard.test.ts
@@ -0,0 +1,39 @@
+/**
+ * Tests for the WorkspaceHome first-run onboarding gate.
+ *
+ * WorkspaceHome swaps its normal activity/projects grid for a guided
+ * onboarding panel when the workspace has resolved with zero
+ * repositories. The predicate is mirrored here (matching the
+ * RepoTabs / AccountNav test convention) so it stays fast and
+ * dependency-free; any change to `isFreshWorkspace` in the SFC must
+ * be reflected here.
+ */
+
+import { describe, expect, test } from "bun:test";
+
+type LoadState = "loading" | "ready" | "error";
+
+// Mirrors `isFreshWorkspace` in WorkspaceHome.vue.
+function isFreshWorkspace(loadState: LoadState, repoCount: number): boolean {
+ return loadState === "ready" && repoCount === 0;
+}
+
+describe("WorkspaceHome onboarding gate", () => {
+ test("shows onboarding once a resolved workspace has no repos", () => {
+ expect(isFreshWorkspace("ready", 0)).toBe(true);
+ });
+
+ test("hides onboarding while the workspace is still loading", () => {
+ // Avoid flashing the first-run panel during a slow load.
+ expect(isFreshWorkspace("loading", 0)).toBe(false);
+ });
+
+ test("hides onboarding on a load error", () => {
+ expect(isFreshWorkspace("error", 0)).toBe(false);
+ });
+
+ test("hides onboarding as soon as any repository exists", () => {
+ expect(isFreshWorkspace("ready", 1)).toBe(false);
+ expect(isFreshWorkspace("ready", 12)).toBe(false);
+ });
+});
diff --git a/frontend/src/routes/WorkspaceHome.vue b/frontend/src/routes/WorkspaceHome.vue
index 317d012f..72a35aad 100644
--- a/frontend/src/routes/WorkspaceHome.vue
+++ b/frontend/src/routes/WorkspaceHome.vue
@@ -63,6 +63,26 @@ const workspace = computed(() => payload.value?.workspace ?? {
});
const repositories = computed(() => workspace.value.repositories);
const extensionCount = computed(() => payload.value?.extensionInstallations?.length ?? 0);
+
+/**
+ * A freshly-provisioned single-user forge has no repositories yet.
+ * Rather than render an empty header strip over an empty activity
+ * stream — which reads as "broken" on first run — the home swaps to
+ * a guided first-run panel: the two ways to get a repo into the forge
+ * (create empty / import by URL, both served by `/new`) plus the two
+ * navigation affordances a newcomer won't discover on their own
+ * (⌘K palette, the `comtrya.cue` config model). Only shown once the
+ * workspace summary has resolved so a slow load doesn't flash it.
+ */
+const isFreshWorkspace = computed(
+ () => loadState.value === "ready" && repositories.value.length === 0,
+);
+
+const isMac =
+ typeof navigator !== "undefined"
+ ? /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent || "")
+ : false;
+const cmdLabel = computed(() => (isMac ? "⌘" : "Ctrl"));
const extensionRuntime = computed(
() => payload.value?.instance?.capabilities?.extensionRuntime ? "enabled" : "disabled",
);
@@ -388,7 +408,52 @@ async function fetchWorkspaceHome(signal: AbortSignal): Promise
+ This workspace is empty. Create a fresh repository or import one you
+ already have — both land here with their comtrya.cue
+ config evaluated and ready to browse.
+
comtrya.cue at a repo root declares its visibility,
+ default branch, enabled extensions, and projects.
+