mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
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:
@@ -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 →
|
||||
</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> — it'll walk you through setup.
|
||||
</p>
|
||||
</div>
|
||||
<span class="nudge-actions">
|
||||
<span class="nudge-cta">Connect agent →</span>
|
||||
<button
|
||||
class="dismiss-btn"
|
||||
type="button"
|
||||
aria-label="Dismiss banner"
|
||||
onclick={handleDismiss}
|
||||
>
|
||||
✕
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user