feat(web): launchpad render-mode for unset-up workspaces (TASK-1852) (#744)

* feat(web): launchpad render-mode for unset-up workspaces (TASK-1852)

While `needs_onboarding` is true, the workspace dashboard rendered as an
empty board with a nudge banner on top — which reads as broken, not new.
Replace that with a dedicated setup launchpad (OnboardingLaunchpad.svelte):
a three-step bridge that routes the user into the agent-driven onboard flow
— ① connect an agent (opens the existing ConnectWorkspaceModal), ② tell it
"set up my workspace" (NL-canonical, with per-surface shortcuts), ③ watch
the result appear live via the page's SSE feed.

It's a render-mode keyed on the existing flag, not a new route: while
needs_onboarding && !dismissed the launchpad renders instead of the board;
"Skip setup" falls through to the board with a reshow affordance; the flag
flips false on the first real item, swapping back to the normal dashboard.

The launchpad supersedes OnboardingNudgeBanner (TASK-1851) — it had no other
consumer, so it's deleted; its NL-canonical copy carries over into step ②.

Parent: PLAN-1847 (Phase 2, task B).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* refactor(web): drop unused wsSlug prop from OnboardingLaunchpad

Self-review cleanup: wsSlug was declared in Props and passed by the parent
but never used in the component (it opens the connect modal via callback).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
This commit is contained in:
xarmian
2026-06-21 11:44:40 -04:00
committed by GitHub
parent 1d62035f28
commit ac43e490e1
3 changed files with 282 additions and 193 deletions
@@ -0,0 +1,260 @@
<script lang="ts">
/**
* Setup launchpad — the render-mode the workspace dashboard switches to
* while `needs_onboarding` is true (PLAN-1847, Phase 2 / TASK-1852).
*
* A brand-new workspace has no user items, so the normal dashboard would
* render as an empty board that reads as "broken" rather than "new". This
* component replaces that board with a three-step bridge that routes the
* user into the agent-driven onboard flow:
* ① connect an agent ② tell it (in natural language) to set up
* ③ watch the result appear here live (via the page's SSE feed).
*
* The flag flips to false the moment any real item exists, at which point
* the parent swaps back to the normal dashboard — so this view is
* inherently transient.
*/
interface Props {
workspaceName: string;
/** Opens the workspace's ConnectWorkspaceModal (mounted by the parent). */
onconnect: () => void;
/** Skip setup for now — parent falls back to the normal (empty) board. */
ondismiss: () => void;
}
let { workspaceName, onconnect, ondismiss }: Props = $props();
// Per-surface shortcuts for step ②. Natural language is the canonical
// trigger (works on every surface, matches TASK-1849/1851); these are
// secondary conveniences shown for whichever agent the user connects.
const shortcuts = [
{ tool: 'Claude Code', cmd: '/pad onboard' },
{ tool: 'Codex', cmd: '$pad onboard' },
{ tool: 'Claude Desktop / MCP', cmd: 'the pad_onboard prompt' }
];
</script>
<div class="launchpad">
<header class="lp-header">
<span class="lp-icon" aria-hidden="true"></span>
<h1 class="lp-title">Let's set up {workspaceName}</h1>
<p class="lp-subtitle">
Pad works best when your AI agent sets it up for you — it'll ask a few
questions and adapt this workspace to your project.
</p>
</header>
<ol class="lp-steps">
<li class="lp-step">
<span class="lp-step-num" aria-hidden="true">1</span>
<div class="lp-step-body">
<h2 class="lp-step-title">Connect your agent</h2>
<p class="lp-step-text">
Add Pad to Claude Code, Cursor, Codex, or Claude Desktop — it takes a
few seconds.
</p>
<button class="lp-connect-btn" type="button" onclick={onconnect}>
Connect agent &rarr;
</button>
</div>
</li>
<li class="lp-step">
<span class="lp-step-num" aria-hidden="true">2</span>
<div class="lp-step-body">
<h2 class="lp-step-title">Tell it to set up</h2>
<p class="lp-step-text">Once connected, just say:</p>
<code class="lp-nl">set up my workspace</code>
<p class="lp-shortcuts">
Or use the shortcut for your agent:
{#each shortcuts as s, i (s.tool)}<span class="lp-shortcut"
><span class="lp-shortcut-tool">{s.tool}</span>
<code>{s.cmd}</code></span
>{#if i < shortcuts.length - 1}<span class="lp-sep"> · </span>{/if}{/each}
</p>
</div>
</li>
<li class="lp-step">
<span class="lp-step-num" aria-hidden="true">3</span>
<div class="lp-step-body">
<h2 class="lp-step-title">Watch it happen</h2>
<p class="lp-step-text">
Your collections and first items will appear right here as your agent
works — no need to refresh.
</p>
</div>
</li>
</ol>
<footer class="lp-footer">
<button class="lp-skip-btn" type="button" onclick={ondismiss}>
Prefer to look around first? Skip setup
</button>
</footer>
</div>
<style>
/* Mirrors OnboardingNudgeBanner / ConnectBanner visual language (same
tokens) so the setup surfaces feel like one system. */
.launchpad {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 640px;
margin: var(--space-5) auto;
padding: var(--space-5) var(--space-4);
}
.lp-header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-2);
}
.lp-icon {
font-size: 2em;
line-height: 1;
color: var(--accent-blue);
}
.lp-title {
margin: 0;
font-size: 1.5em;
font-weight: 700;
color: var(--text-primary);
}
.lp-subtitle {
margin: 0;
max-width: 460px;
font-size: 0.95em;
line-height: 1.5;
color: var(--text-secondary);
}
.lp-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.lp-step {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.lp-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
color: var(--accent-blue);
font-weight: 700;
font-size: 0.85em;
}
.lp-step-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lp-step-title {
margin: 0;
font-size: 1em;
font-weight: 600;
color: var(--text-primary);
}
.lp-step-text {
margin: 0;
font-size: 0.88em;
line-height: 1.45;
color: var(--text-secondary);
}
.lp-connect-btn {
align-self: flex-start;
margin-top: var(--space-1);
padding: var(--space-2) var(--space-4);
background: var(--accent-blue);
color: #fff;
border: none;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.88em;
cursor: pointer;
transition: filter 0.15s;
}
.lp-connect-btn:hover {
filter: brightness(1.08);
}
.lp-connect-btn:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
.lp-nl {
align-self: flex-start;
padding: var(--space-2) var(--space-3);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 0.95em;
color: var(--text-primary);
}
.lp-shortcuts {
margin: var(--space-1) 0 0;
font-size: 0.8em;
line-height: 1.6;
color: var(--text-muted);
}
.lp-shortcut-tool {
color: var(--text-secondary);
}
.lp-shortcuts code {
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: var(--radius-sm);
font-size: 0.95em;
color: var(--text-secondary);
}
.lp-sep {
color: var(--text-muted);
}
.lp-footer {
display: flex;
justify-content: center;
}
.lp-skip-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 0.85em;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.lp-skip-btn:hover {
color: var(--text-secondary);
}
@media (max-width: 480px) {
.launchpad {
gap: var(--space-4);
margin: var(--space-3) auto;
}
.lp-step {
padding: var(--space-3);
}
}
</style>
@@ -1,183 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
interface Props {
wsSlug: string;
/**
* Click handler that opens the workspace's ConnectWorkspaceModal.
* Wired by the parent so this banner stays unaware of how the
* modal is mounted (the workspace page already mounts a single
* ConnectWorkspaceModal instance — passing a handler avoids
* mounting a second one here).
*/
onconnect: () => void;
ondismiss?: () => void;
}
let { wsSlug, onconnect, ondismiss }: Props = $props();
// Dismissal persists in the existing localStorage key shape
// (`pad-onboarding-dismissed-{wsSlug}`) per IDEA-1516 §3 — users who
// dismissed the old OnboardingChecklist don't get re-prompted by the
// new banner. The parent (+page.svelte) owns the canonical dismiss
// state; this component just signals up via ondismiss.
function handleDismiss(event: MouseEvent) {
event.stopPropagation();
ondismiss?.();
if (browser) {
localStorage.setItem(`pad-onboarding-dismissed-${wsSlug}`, 'true');
}
}
function handleConnect() {
onconnect();
}
function handleKeydown(e: KeyboardEvent) {
// Banner-level Enter/Space activates the Connect CTA — but only
// when the keydown originates from the banner itself, not from a
// nested button (the dismiss X). Mirrors ConnectBanner.svelte's
// guard so dismissing via keyboard doesn't also fire connect.
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onconnect();
}
}
</script>
<div
class="nudge-banner"
role="button"
tabindex="0"
onclick={handleConnect}
onkeydown={handleKeydown}
>
<span class="nudge-icon" aria-hidden="true"></span>
<div class="nudge-body">
<span class="nudge-title">Set up your workspace</span>
<p class="nudge-text">
Your workspace is ready. Connect your agent, then just say
<code>set up my workspace</code> &mdash; it'll walk you through setup.
</p>
</div>
<span class="nudge-actions">
<span class="nudge-cta">Connect agent &rarr;</span>
<button
class="dismiss-btn"
type="button"
aria-label="Dismiss banner"
onclick={handleDismiss}
>
&#10005;
</button>
</span>
</div>
<style>
/* Mirrors ConnectBanner.svelte's visual language — same border, hover,
and focus treatment so the two banners feel like a single system. The
nudge-banner is taller because it carries a heading + body, whereas
ConnectBanner is a single-line nudge. */
.nudge-banner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
box-sizing: border-box;
text-align: left;
}
.nudge-banner:hover {
border-color: var(--accent-blue);
background: color-mix(in srgb, var(--accent-blue) 4%, var(--bg-secondary));
}
.nudge-banner:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
.nudge-icon {
font-size: 1.2em;
line-height: 1.3;
flex-shrink: 0;
color: var(--accent-blue);
}
.nudge-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nudge-title {
font-size: 0.92em;
font-weight: 600;
color: var(--text-primary);
}
.nudge-text {
margin: 0;
font-size: 0.85em;
color: var(--text-secondary);
line-height: 1.45;
}
.nudge-text code {
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: var(--radius-sm);
font-size: 0.95em;
}
.nudge-actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
padding-top: 2px;
}
.nudge-cta {
color: var(--accent-blue);
font-weight: 600;
font-size: 0.88em;
white-space: nowrap;
}
.dismiss-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
font-size: 0.85em;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.dismiss-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
@media (max-width: 480px) {
.nudge-banner {
flex-wrap: wrap;
gap: var(--space-2);
}
.nudge-actions {
width: 100%;
justify-content: space-between;
}
}
</style>
@@ -8,7 +8,7 @@
import { uiStore } from '$lib/stores/ui.svelte';
import { syncService } from '$lib/services/sync.svelte';
import { relativeTime } from '$lib/utils/markdown';
import OnboardingNudgeBanner from '$lib/components/OnboardingNudgeBanner.svelte';
import OnboardingLaunchpad from '$lib/components/OnboardingLaunchpad.svelte';
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte';
import { collectionStore } from '$lib/stores/collections.svelte';
@@ -254,6 +254,21 @@
</div>
</div>
{:else if dashboard}
{#if needsOnboarding && !onboardingDismissed}
<!--
Launchpad render-mode (PLAN-1847 Phase 2 / TASK-1852). While
needs_onboarding is true and not dismissed, the workspace renders
as a dedicated setup launchpad INSTEAD of the empty board (which
reads as broken, not new). Dismissing ("Skip setup") falls through
to the normal board with a reshow affordance. The flag flips to
false on the first real item, swapping back to the board.
-->
<OnboardingLaunchpad
workspaceName={workspaceStore.current?.name ?? wsSlug}
onconnect={() => (connectOpen = true)}
ondismiss={dismissOnboarding}
/>
{:else}
<!-- 1. Header -->
<header class="dash-header">
<div class="dash-header-left">
@@ -286,15 +301,11 @@
ConnectWorkspaceModal — same modal the Phase F auto-open hook
and ConnectBanner use.
-->
{#if needsOnboarding && !onboardingDismissed}
<div class="onboarding-wrapper">
<OnboardingNudgeBanner
{wsSlug}
onconnect={() => (connectOpen = true)}
ondismiss={dismissOnboarding}
/>
</div>
{:else if needsOnboarding && onboardingDismissed}
{#if needsOnboarding && onboardingDismissed}
<!--
Reshow affordance for users who skipped the launchpad. Clicking
restores the launchpad render-mode (clears the dismissed flag).
-->
<div class="onboarding-reshow">
<button class="reshow-btn" onclick={showOnboarding}>Show setup guide</button>
</div>
@@ -514,6 +525,7 @@
</div>
</section>
{/if}
{/if}
{:else}
<div class="loading">No dashboard data available.</div>
{/if}