Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/src/routes/WorkspaceHome.onboard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 66 additions & 1 deletion frontend/src/routes/WorkspaceHome.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down Expand Up @@ -388,7 +408,52 @@ async function fetchWorkspaceHome(signal: AbortSignal): Promise<WorkspaceHomePay

<p v-if="loadState === 'error'" class="repo-state" role="alert">{{ loadError }}</p>

<section class="home-grid">
<section
v-if="isFreshWorkspace"
class="home-onboard"
data-smoke="home-onboard"
aria-label="Get started"
>
<div class="home-onboard-intro">
<span class="overline">First run</span>
<h2>Bring your first repository in</h2>
<p>
This workspace is empty. Create a fresh repository or import one you
already have — both land here with their <code>comtrya.cue</code>
config evaluated and ready to browse.
</p>
</div>

<div class="home-onboard-actions">
<RouterLink to="/new" class="home-onboard-card primary" data-smoke="onboard-create">
<span class="home-onboard-glyph" aria-hidden="true">+</span>
<span class="home-onboard-card-body">
<strong>Create a repository</strong>
<span>Start empty and push your first commit over git.</span>
</span>
</RouterLink>
<RouterLink to="/new" class="home-onboard-card" data-smoke="onboard-import">
<span class="home-onboard-glyph" aria-hidden="true">↧</span>
<span class="home-onboard-card-body">
<strong>Import from a URL</strong>
<span>Clone an existing repo into the forge by its remote URL.</span>
</span>
</RouterLink>
</div>

<ul class="home-onboard-tips" aria-label="Tips">
<li>
<kbd>{{ cmdLabel }}</kbd><kbd>K</kbd>
opens the command palette — jump to any repo, issue, or pull.
</li>
<li>
A <code>comtrya.cue</code> at a repo root declares its visibility,
default branch, enabled extensions, and projects.
</li>
</ul>
</section>

<section v-else class="home-grid">
<div class="home-spine" data-smoke="home-spine">
<div
v-if="uniqueActivityProjects.length > 0"
Expand Down
146 changes: 146 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,152 @@ kbd {
display: grid;
}

/* First-run onboarding — shown on the workspace home when no repos
exist yet. Editorial intro + two equal action cards + quiet tips. */
.shell-app .home-onboard {
display: grid;
gap: 28px;
max-width: 760px;
margin: 0 auto;
padding: 56px 0 40px;
}

.shell-app .home-onboard-intro {
display: grid;
gap: 10px;
}

.shell-app .home-onboard-intro h2 {
font-size: 28px;
letter-spacing: -0.02em;
line-height: 1.1;
}

.shell-app .home-onboard-intro p {
color: var(--fg-2);
max-width: 56ch;
line-height: 1.55;
}

.shell-app .home-onboard-intro code,
.shell-app .home-onboard-tips code {
font-family: var(--font-mono);
font-size: 0.88em;
color: var(--fg);
background: var(--surface);
border: 0.5px solid var(--line-2);
border-radius: var(--r-xs);
padding: 0.5px 5px;
}

.shell-app .home-onboard-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}

.shell-app .home-onboard-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px;
border: 0.5px solid var(--line-2);
border-radius: var(--r-md);
background: var(--surface);
color: var(--fg);
text-decoration: none;
transition: border-color 100ms ease, background 100ms ease, transform 100ms ease;
}

.shell-app .home-onboard-card:hover {
border-color: var(--line-3);
background: var(--surface-2);
transform: translateY(-1px);
}
Comment thread
rawkode marked this conversation as resolved.

.shell-app .home-onboard-card:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}

.shell-app .home-onboard-card.primary {
border-color: var(--accent-line);
background: var(--accent-soft);
}

.shell-app .home-onboard-glyph {
flex: none;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
border: 0.5px solid var(--line-2);
background: var(--surface-2);
font-family: var(--font-mono);
font-size: 18px;
line-height: 1;
color: var(--fg-2);
}

.shell-app .home-onboard-card.primary .home-onboard-glyph {
border-color: var(--accent-line);
color: var(--accent);
}

.shell-app .home-onboard-card-body {
display: grid;
gap: 4px;
min-width: 0;
}

.shell-app .home-onboard-card-body strong {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
}

.shell-app .home-onboard-card-body span {
color: var(--fg-3);
font-size: 13px;
line-height: 1.45;
}

.shell-app .home-onboard-tips {
list-style: none;
margin: 0;
padding: 18px 0 0;
border-top: 0.5px solid var(--line);
display: grid;
gap: 12px;
}

.shell-app .home-onboard-tips li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--fg-3);
font-size: 13px;
line-height: 1.5;
}

.shell-app .home-onboard-tips kbd {
border: 0.5px solid var(--line-2);
border-radius: var(--r-xs);
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg);
background: var(--surface);
}

@media (max-width: 720px) {
.shell-app .home-onboard-actions { grid-template-columns: 1fr; }
.shell-app .home-onboard { padding-top: 32px; }
}

.shell-app .activity-project-filter {
display: flex;
flex-wrap: wrap;
Expand Down
Loading