feat(web): unify dashboard onboarding banners around needs_onboarding signal (TASK-1530) (#594)

IDEA-1516 Phase 3. The pre-IDEA-1516 design split workspace onboarding
guidance across two banners — OnboardingIdeaBanner (gated on the
retired IDEA-1 / BACK-1 / FEAT-1 seed-item pattern from PLAN-1496) and
OnboardingChecklist (gated on a totalItems === 0 heuristic that
predates the canonical needs_onboarding flag from TASK-1504). Both
fired competing CTAs on the same screen; neither read the canonical
signal.

Backend (internal/server/handlers_dashboard.go):
- Add `NeedsOnboarding bool json:"needs_onboarding"` to
  DashboardResponse, populated via the existing
  Store.WorkspaceHasUserCreatedItems EXISTS query (same predicate
  AgentBootstrap.NeedsOnboarding uses). Web reads it from the
  dashboard fetch the page already does — no second round-trip
  against the heavier bootstrap endpoint.

Frontend:
- Add `needs_onboarding: boolean` to TS DashboardResponse type
- Delete OnboardingIdeaBanner.svelte entirely (signal retired,
  no remaining consumers); the back-end onboarding_seed field
  stays for now per spec — separate cleanup
- Delete OnboardingChecklist.svelte; replace with
  OnboardingNudgeBanner.svelte — single message + "Connect agent →"
  CTA that opens the workspace's already-mounted
  ConnectWorkspaceModal. Dismissible, preserves the existing
  `pad-onboarding-dismissed-{wsSlug}` localStorage key so users who
  dismissed the old checklist don't get re-prompted
- Workspace +page.svelte: collapse the two banner blocks into one
  gated on `needsOnboarding && !onboardingDismissed`; reshow button
  follows the same signal. Drop the now-orphaned `.connect-card`
  CSS — its function is subsumed by the banner's CTA. Drop the
  unused OnboardingIdeaBanner / OnboardingChecklist imports and the
  `onboardingSeed` derived state

Smart-suppression deferred to a follow-up. The existing
api.workspaces.claimCode endpoint returns suppression info but
generates a real claim code as a side effect in the not-suppressed
case — calling it on every workspace page-load with needs_onboarding=true
is awkward. The CTA still opens the modal, which renders its own
suppression state correctly; users get the right experience with one
extra click on the rare suppressed case. A dedicated read-only
GET /workspaces/{ws}/connect-status endpoint is a separate piece
of work.
This commit is contained in:
xarmian
2026-05-19 13:17:57 -04:00
committed by GitHub
parent b969dd7557
commit e27f805ffc
6 changed files with 238 additions and 618 deletions
+21
View File
@@ -31,6 +31,15 @@ type DashboardResponse struct {
// workspace's agent loop is wired up and the banner stops nagging
// the user on this workspace.
HasAgentActivity bool `json:"has_agent_activity"`
// NeedsOnboarding is true when the workspace has zero items with
// source != 'template' — i.e. nothing beyond what the template
// seeded. Mirrors the canonical AgentBootstrap.NeedsOnboarding
// flag (PLAN-1496 / TASK-1504) so the web UI can render its
// onboarding nudge without making a second bootstrap call. Flips
// false the moment any user/agent-sourced item exists; the
// dashboard's onboarding banner uses this as its sole gating
// signal post IDEA-1516 / TASK-1530.
NeedsOnboarding bool `json:"needs_onboarding"`
// OnboardingSeed identifies the seeded onboarding entry point for
// the workspace (e.g. IDEA-1 for `startup`, BACK-1 for `scrum`,
// FEAT-1 for `product`) when present and untouched. The web UI's
@@ -366,6 +375,18 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
}
resp.HasAgentActivity = hasAgent
// needs_onboarding mirrors AgentBootstrap.NeedsOnboarding (TASK-1504):
// true when the workspace has zero items with source != 'template'.
// Web UI's onboarding nudge banner (TASK-1530) reads this from the
// dashboard fetch the page already does, so no second round-trip
// against the heavier bootstrap endpoint is needed. Predicate is the
// same EXISTS-backed store helper bootstrap uses.
hasUserItems, err := s.store.WorkspaceHasUserCreatedItems(workspaceID)
if err != nil {
return nil, err
}
resp.NeedsOnboarding = !hasUserItems
// Summary: items grouped by collection slug and status field
allItems, err := s.store.ListItems(workspaceID, models.ItemListParams{CollectionIDs: dashCollIDs, ItemIDs: dashItemIDs})
if err != nil {
@@ -1,368 +0,0 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils/clipboard';
interface Props {
wsSlug: string;
username?: string;
byCollection: Record<string, Record<string, number>>;
// Slugs of collections that actually exist in this workspace. Steps
// targeting missing collections (e.g. blank-template workspaces with
// no plans/tasks/docs) are filtered out so users don't get
// "Collection not found" links.
collectionSlugs?: string[];
ondismiss?: () => void;
}
let { wsSlug, username = '', byCollection, collectionSlugs, ondismiss }: Props = $props();
let collectionSet = $derived(new Set(collectionSlugs ?? []));
function collectionExists(slug: string): boolean {
// If collectionSlugs wasn't provided, fall back to "assume exists"
// for backward compat with callers that haven't been updated.
return collectionSlugs === undefined ? true : collectionSet.has(slug);
}
let copiedHint = $state<string | null>(null);
function collectionHasItems(slug: string): boolean {
const breakdown = byCollection[slug];
if (!breakdown) return false;
return Object.values(breakdown).reduce((sum, n) => sum + n, 0) > 0;
}
function collectionItemCount(slug: string): number {
const breakdown = byCollection[slug];
if (!breakdown) return 0;
return Object.values(breakdown).reduce((sum, n) => sum + n, 0);
}
interface Step {
title: string;
href: string;
done: boolean;
hint: string;
// Optional gate: if set, this step is only shown when the target
// collection exists in the workspace. The conventions step has no
// gate because the conventions collection is system-level and
// shipped by every template (including blank).
requiresCollection?: string;
}
let allSteps = $derived<Step[]>([
{
title: 'Add project conventions',
href: `/${username}/${wsSlug}/library`,
done: collectionHasItems('conventions'),
hint: '/pad what conventions should this project follow?'
},
{
title: 'Create your first plan',
href: `/${username}/${wsSlug}/plans`,
done: collectionHasItems('plans'),
hint: '/pad create a plan for what I\'m working on',
requiresCollection: 'plans'
},
{
title: 'Add a few tasks',
href: `/${username}/${wsSlug}/tasks`,
done: collectionItemCount('tasks') >= 3,
hint: '/pad break down my current work into tasks',
requiresCollection: 'tasks'
},
{
title: 'Write an architecture doc',
href: `/${username}/${wsSlug}/docs`,
done: collectionHasItems('docs'),
hint: '/pad document the architecture of this project',
requiresCollection: 'docs'
}
]);
let steps = $derived(allSteps.filter(s => !s.requiresCollection || collectionExists(s.requiresCollection)));
let completedCount = $derived(steps.filter((s) => s.done).length);
let progressPct = $derived(steps.length === 0 ? 0 : Math.round((completedCount / steps.length) * 100));
async function copyHint(text: string) {
const ok = await copyToClipboard(text);
if (ok) {
copiedHint = text;
setTimeout(() => { copiedHint = null; }, 1500);
}
}
</script>
<div class="onboarding">
<div class="onboarding-header">
<div class="header-text">
<h2>Set up your workspace</h2>
<p class="subtitle">Complete these steps to get the most out of Pad.</p>
</div>
{#if ondismiss}
<button class="dismiss-btn" onclick={ondismiss} title="Dismiss setup guide" aria-label="Dismiss setup guide">&times;</button>
{/if}
</div>
<div class="progress-section">
<span class="progress-label">{completedCount} of {steps.length} complete</span>
<div class="progress-track">
<div class="progress-fill" style:width="{progressPct}%"></div>
</div>
</div>
<ol class="step-list">
{#each steps as step (step.title)}
<li class="step" class:done={step.done}>
<div class="step-icon">
{#if step.done}
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
<circle cx="10" cy="10" r="10" />
<path d="M6 10.5l2.5 2.5 5.5-5.5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else}
<svg class="empty-icon" viewBox="0 0 20 20" width="20" height="20">
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
{/if}
</div>
<div class="step-body">
<a href={step.href} class="step-title">{step.title}</a>
{#if !step.done}
<span class="step-hint">
Try: <code>{step.hint}</code>
<button
class="copy-btn"
onclick={() => copyHint(step.hint)}
title="Copy to clipboard"
>
{#if copiedHint === step.hint}
Copied!
{:else}
Copy
{/if}
</button>
</span>
{/if}
</div>
</li>
{/each}
</ol>
<div class="onboarding-footer">
<p class="footer-instructions">
Install the Pad skill in your project with <code>pad agent install</code>, then paste a prompt above into Claude Code or your favorite AI agent.
</p>
<a href="/{username}/{wsSlug}/library" class="footer-link">Or browse the library for conventions and playbooks</a>
</div>
</div>
<style>
.onboarding {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-6);
max-width: 600px;
}
.onboarding-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.header-text {
margin-bottom: 0;
}
.onboarding-header h2 {
font-size: 1.2em;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-1) 0;
}
.subtitle {
font-size: 0.88em;
color: var(--text-muted);
margin: 0;
}
.dismiss-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.3em;
cursor: pointer;
padding: 0 var(--space-1);
line-height: 1;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.dismiss-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
/* Progress */
.progress-section {
margin-bottom: var(--space-5);
}
.progress-label {
display: block;
font-size: 0.82em;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.progress-track {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-green);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Steps */
.step-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.step {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius);
transition: background 0.1s;
}
.step:hover {
background: var(--bg-tertiary);
}
.step-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
margin-top: 1px;
}
.check-icon {
color: var(--accent-green);
}
.empty-icon {
color: var(--text-muted);
}
.step-body {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.step-title {
font-size: 0.92em;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.step-title:hover {
color: var(--accent-blue);
text-decoration: underline;
}
.done .step-title {
color: var(--text-muted);
}
.step-hint {
font-size: 0.8em;
color: var(--text-muted);
line-height: 1.5;
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.step-hint code {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.92em;
word-break: break-all;
}
.copy-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.88em;
cursor: pointer;
padding: 0 6px;
border-radius: 3px;
white-space: nowrap;
line-height: 1.6;
}
.copy-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
/* Footer */
.onboarding-footer {
margin-top: var(--space-5);
padding-top: var(--space-4);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.footer-link {
font-size: 0.85em;
color: var(--accent-blue);
text-decoration: none;
}
.footer-link:hover {
text-decoration: underline;
}
.footer-instructions {
font-size: 0.85em;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
.footer-instructions code {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.92em;
}
</style>
@@ -1,150 +0,0 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils/clipboard';
interface Props {
/** Workspace slug — used to link the seeded item into the right URL. */
wsSlug: string;
/** Workspace owner username — used to build the deep link. */
username: string;
/** The seeded primary-entry ref (e.g. "IDEA-1", "BACK-1", "FEAT-1").
* Drives the trigger phrase the user copies. The dashboard handler
* computes this per workspace based on the seeded item; the
* component just renders it. */
primaryRef: string;
/** Slug of the seeded item — used for the "Read it first" link. */
ideaSlug: string;
/** Collection slug the seeded item lives in (ideas / backlog /
* features). Used to build the deep-link path. */
collectionSlug: string;
}
let { wsSlug, username, primaryRef, ideaSlug, collectionSlug }: Props = $props();
let triggerPhrase = $derived(`use pad to get ${primaryRef}`);
let copied = $state(false);
async function copyTrigger() {
const ok = await copyToClipboard(triggerPhrase);
if (ok) {
copied = true;
setTimeout(() => {
copied = false;
}, 1500);
}
}
</script>
<div class="idea-banner">
<div class="idea-banner-icon" aria-hidden="true">💡</div>
<div class="idea-banner-body">
<h2>Your workspace has a starting point waiting.</h2>
<p>
Open a fresh agent session — Claude Code, Cursor, Codex, whatever you have —
and say:
</p>
<div class="trigger-row">
<code class="trigger-phrase">{triggerPhrase}</code>
<button class="copy-btn" type="button" onclick={copyTrigger} title="Copy to clipboard">
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="idea-banner-footnote">
{primaryRef} is a note from your future self to whoever's helping you set up.
The agent will read it and walk through your project with you, capturing
what you tell it — using your real work, not toy data.
<a href="/{username}/{wsSlug}/{collectionSlug}/{ideaSlug}">Read it first</a>
if you'd like to see what's there.
</p>
</div>
</div>
<style>
.idea-banner {
display: flex;
gap: var(--space-4);
align-items: flex-start;
background: color-mix(in srgb, var(--accent-blue) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--accent-blue) 30%, var(--border));
border-radius: var(--radius);
padding: var(--space-5);
max-width: 600px;
}
.idea-banner-icon {
font-size: 1.6em;
line-height: 1;
margin-top: 2px;
flex-shrink: 0;
}
.idea-banner-body {
flex: 1;
min-width: 0;
}
.idea-banner-body h2 {
font-size: 1.05em;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.idea-banner-body p {
font-size: 0.92em;
color: var(--text-secondary);
margin: 0 0 var(--space-3) 0;
line-height: 1.5;
}
.trigger-row {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.trigger-phrase {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
font-size: 0.95em;
color: var(--text-primary);
font-family: var(--font-mono, monospace);
user-select: all;
}
.copy-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
font-size: 0.85em;
cursor: pointer;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.copy-btn:hover {
background: var(--bg-primary);
border-color: var(--accent-blue);
}
.idea-banner-footnote {
font-size: 0.85em !important;
color: var(--text-muted) !important;
margin: 0 !important;
line-height: 1.5;
}
.idea-banner-footnote a {
color: var(--accent-blue);
text-decoration: none;
}
.idea-banner-footnote a:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,183 @@
<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 and type
<code>/pad onboard</code> to walk through setting it up.
</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>
+5
View File
@@ -751,6 +751,11 @@ export interface DashboardResponse {
// the underlying store query also matches). Drives the connect-agent
// banner's auto-hide.
has_agent_activity: boolean;
// needs_onboarding mirrors AgentBootstrap.NeedsOnboarding (PLAN-1496 /
// TASK-1504): true when the workspace has zero items with
// source != 'template'. Drives the post-IDEA-1516 onboarding nudge
// banner. Flips false the moment any user/agent-sourced item exists.
needs_onboarding: boolean;
// onboarding_seed identifies the seeded onboarding entry for the
// workspace (e.g. IDEA-1 for `startup`, BACK-1 for `scrum`,
// FEAT-1 for `product`). Present + active drives the
@@ -8,8 +8,7 @@
import { uiStore } from '$lib/stores/ui.svelte';
import { syncService } from '$lib/services/sync.svelte';
import { relativeTime } from '$lib/utils/markdown';
import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte';
import OnboardingIdeaBanner from '$lib/components/OnboardingIdeaBanner.svelte';
import OnboardingNudgeBanner from '$lib/components/OnboardingNudgeBanner.svelte';
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte';
import { collectionStore } from '$lib/stores/collections.svelte';
@@ -67,13 +66,13 @@
if (mem !== null) isOwner = mem.role === 'owner';
});
// The dashboard response carries an `onboarding_seed` field when the
// workspace has a seeded onboarding primary (IDEA-1 / BACK-1 / FEAT-1
// per template). The OnboardingIdeaBanner shows only when that seed
// is still active (status equals its initial value — agent has not
// yet engaged). The server computes `active` so the frontend doesn't
// need a per-collection "what's the initial status" map.
let onboardingSeed = $derived(dashboard?.onboarding_seed);
// Post IDEA-1516 / TASK-1530: the canonical onboarding signal is
// `dashboard.needs_onboarding` (mirrors AgentBootstrap.NeedsOnboarding
// from PLAN-1496 / TASK-1504). The old `onboarding_seed` field still
// rides on the dashboard response (its backend cleanup is out of
// scope) but no longer has a consumer in this page — the
// OnboardingIdeaBanner that read it was retired with this task.
let needsOnboarding = $derived(dashboard?.needs_onboarding ?? false);
// Sync dismissed state from localStorage when workspace changes
$effect(() => {
@@ -271,44 +270,31 @@
</div>
</header>
<!-- 2. Onboarding -->
<!-- Onboarding seed banner: shown only while the seeded primary entry
is still untouched (server returns active=true when its status
equals the schema initial value). Once the user engages the
agent and the status flips, the next dashboard poll returns
active=false and the banner disappears. The OnboardingChecklist
below remains gated on totalItems === 0 — its surface is empty
/ non-templated workspaces; this banner is the surface for
templated (seeded) workspaces. -->
{#if onboardingSeed?.active && !onboardingDismissed}
<!-- 2. Onboarding nudge (IDEA-1516 §3 / TASK-1530) -->
<!--
Single banner triggered by `dashboard.needs_onboarding`. The
pre-IDEA-1516 design split this into two banners — an
OnboardingIdeaBanner gated on the seeded primary entry
(IDEA-1 / BACK-1 / FEAT-1) plus an OnboardingChecklist gated
on totalItems === 0. Both were wired to retired signals
(seed-item pattern from PLAN-1496, item-count heuristic
predating needs_onboarding) and produced two competing CTAs
in the same screen. The new banner reads the canonical
AgentBootstrap.NeedsOnboarding signal (mirrored onto the
dashboard response by TASK-1530's backend change) and
delegates "Connect agent →" to the workspace's already-mounted
ConnectWorkspaceModal — same modal the Phase F auto-open hook
and ConnectBanner use.
-->
{#if needsOnboarding && !onboardingDismissed}
<div class="onboarding-wrapper">
<OnboardingIdeaBanner
<OnboardingNudgeBanner
{wsSlug}
{username}
primaryRef={onboardingSeed.ref}
ideaSlug={onboardingSeed.slug}
collectionSlug={onboardingSeed.collection_slug}
onconnect={() => (connectOpen = true)}
ondismiss={dismissOnboarding}
/>
</div>
{/if}
{#if totalItems === 0 && !onboardingDismissed}
<div class="onboarding-wrapper">
<OnboardingChecklist {wsSlug} {username} byCollection={dashboard.summary.by_collection} collectionSlugs={collections.map(c => c.slug)} ondismiss={dismissOnboarding} />
<button class="connect-card" type="button" onclick={() => (connectOpen = true)}>
<span class="connect-card-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
</span>
<span class="connect-card-body">
<span class="connect-card-title">Connect your local project</span>
<span class="connect-card-subtitle">Manage this workspace from your terminal with the pad CLI.</span>
</span>
<span class="connect-card-cta" aria-hidden="true">&rarr;</span>
</button>
</div>
{:else if totalItems === 0 && onboardingDismissed}
{:else if needsOnboarding && onboardingDismissed}
<div class="onboarding-reshow">
<button class="reshow-btn" onclick={showOnboarding}>Show setup guide</button>
</div>
@@ -649,63 +635,6 @@
flex-direction: column;
gap: var(--space-3);
}
/* Connect-your-local-project card — sibling under OnboardingChecklist. */
.connect-card {
display: flex;
align-items: center;
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-lg);
text-align: left;
cursor: pointer;
color: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.05s;
}
.connect-card:hover {
border-color: var(--accent-blue);
background: color-mix(in srgb, var(--accent-blue) 4%, var(--bg-secondary));
}
.connect-card:active {
transform: translateY(1px);
}
.connect-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius);
background: var(--bg-tertiary);
color: var(--accent-blue);
flex-shrink: 0;
}
.connect-card-body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.connect-card-title {
font-size: 0.95em;
font-weight: 600;
color: var(--text-primary);
}
.connect-card-subtitle {
font-size: 0.82em;
color: var(--text-muted);
}
.connect-card-cta {
font-size: 1.1em;
color: var(--text-muted);
flex-shrink: 0;
}
.connect-card:hover .connect-card-cta {
color: var(--accent-blue);
}
.onboarding-reshow {
margin-bottom: var(--space-4);
}