mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750. Gives a user who created a workspace via the web UI a one-line copy-paste to connect that workspace to their local repo, exposed in the two zero-state surfaces where they'd look for it. Changes: - New `<ConnectWorkspaceModal>` Svelte 5 component (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no host-page coupling. Matches ShareDialog's modal pattern (overlay + centered modal, native, open = $bindable(), Escape closes). Props: serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed install — macOS/Linux/Windows/Docker, default tab from detected platform) and Step 2 (pad init --url ... --workspace ... snippet with a copy button on the full snippet). Footer links to docs + troubleshooting. - New web/src/lib/utils/platform.ts — tiny dependency-free OS detection helper. SSR-safe (defaults to "macos" with no navigator). - Mounted in the workspace landing page as a "Connect your local project" card directly under <OnboardingChecklist> in the empty- workspace .onboarding-wrapper. Modal itself is mounted unconditionally at the page root so it survives re-renders of the conditional empty state. - Mounted in TopBar.svelte's user menu (both desktop and mobile branches): "Connect a project..." entry between Theme/Cloud-support links and the Sign-out divider. Modal lives outside the dropdown so it doesn't unmount when the dropdown closes. Both gated on workspaceStore.current?.slug since the modal needs a workspace to interpolate. Docs URLs in the modal footer (getpad.dev/docs/install, getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in PLAN-859 will publish those pages and we'll wire the final URLs then. Test plan: - go build ./... && go test ./... clean - cd web && npm run build clean - make install clean, server restarted - Svelte MCP autofixer ran on all four touched files — no findings Parent: PLAN-859. Driving idea: IDEA-750. * fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1) Two findings from Codex review on PR #283: 1. macOS install command was `brew install xarmian/pad/pad`, but the actual tap is `PerpetualSoftware/tap/pad` (per README.md and skills/INSTALL.md). Users would have hit a failing install. 2. Footer links pointed at `getpad.dev/docs/install` and `getpad.dev/docs/connect-local-project` — pages TASK-863 will publish but don't exist yet. Until they do, point at the GitHub README's #installation and #getting-started anchors so clicks at least land somewhere useful instead of 404. The TASK-863 follow-up will swap these back to the dedicated docs URLs once the pages ship. * fix(web/connect-modal): use real install commands from README (Codex round 2) Round 2 caught that Linux/Windows/Docker commands were fabricated: - Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist - Docker used wrong volume mount (/root/.pad vs the image's /data) and didn't publish ports All four tabs now mirror the README's Installation section exactly: - macOS + Linux: brew install PerpetualSoftware/tap/pad - Windows: pointer to the GitHub releases page (no first-party one-liner) - Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
<script lang="ts">
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { defaultInstallTab, type InstallTab } from '$lib/utils/platform';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
serverUrl: string;
|
||||
workspaceSlug: string;
|
||||
workspaceName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
serverUrl,
|
||||
workspaceSlug,
|
||||
workspaceName = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Active install tab persists across open/close cycles — initialized
|
||||
// from the detected platform on first mount.
|
||||
let activeTab = $state<InstallTab>(defaultInstallTab());
|
||||
|
||||
// All commands mirror the README's Installation section. Homebrew works
|
||||
// on both macOS and Linux. Windows has no first-party one-liner — direct
|
||||
// users to the GitHub releases page. Docker matches the documented run
|
||||
// invocation (data volume at /data, port published to localhost).
|
||||
const installCommands: Record<InstallTab, string> = {
|
||||
macos: 'brew install PerpetualSoftware/tap/pad',
|
||||
linux: 'brew install PerpetualSoftware/tap/pad',
|
||||
windows: '# Download a Windows binary from\n# https://github.com/PerpetualSoftware/pad/releases',
|
||||
docker: 'docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad'
|
||||
};
|
||||
|
||||
const tabs: { id: InstallTab; label: string }[] = [
|
||||
{ id: 'macos', label: 'macOS' },
|
||||
{ id: 'linux', label: 'Linux' },
|
||||
{ id: 'windows', label: 'Windows' },
|
||||
{ id: 'docker', label: 'Docker' }
|
||||
];
|
||||
|
||||
let connectSnippet = $derived(
|
||||
`cd /path/to/your/project\npad init --url ${serverUrl} --workspace ${workspaceSlug}`
|
||||
);
|
||||
|
||||
async function handleCopy(text: string) {
|
||||
const success = await copyToClipboard(text);
|
||||
if (success) {
|
||||
toastStore.show('Copied to clipboard', 'success');
|
||||
} else {
|
||||
toastStore.show('Failed to copy', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: until TASK-863 (PLAN-859) publishes dedicated docs pages, the
|
||||
// "Other install options", "Documentation", and "Troubleshooting" links
|
||||
// fall back to anchors in the GitHub README, which has a complete
|
||||
// install + getting-started section. Once the docs page exists, swap
|
||||
// these for getpad.dev/docs/install and /docs/connect-local-project.
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={() => (open = false)}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>Connect this workspace to your local project</h2>
|
||||
<button class="close-btn" type="button" onclick={() => (open = false)}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="intro-copy">
|
||||
{#if workspaceName}
|
||||
Run <strong>{workspaceName}</strong> from your terminal with the pad CLI.
|
||||
{:else}
|
||||
Run this workspace from your terminal with the pad CLI.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Step 1 — Install pad -->
|
||||
<section class="step">
|
||||
<span class="section-label">Step 1 — Install pad</span>
|
||||
|
||||
<div class="tab-strip" role="tablist">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={activeTab === tab.id}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
type="button"
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="code-block">
|
||||
<pre>{installCommands[activeTab]}</pre>
|
||||
<button
|
||||
class="copy-btn-small"
|
||||
type="button"
|
||||
title="Copy command"
|
||||
onclick={() => handleCopy(installCommands[activeTab])}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="other-options"
|
||||
href="https://github.com/PerpetualSoftware/pad#installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Other install options →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Step 2 — Connect this workspace -->
|
||||
<section class="step">
|
||||
<span class="section-label">Step 2 — Connect this workspace</span>
|
||||
|
||||
<div class="code-block">
|
||||
<pre>{connectSnippet}</pre>
|
||||
<button
|
||||
class="copy-btn-small"
|
||||
type="button"
|
||||
title="Copy snippet"
|
||||
onclick={() => handleCopy(connectSnippet)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer links -->
|
||||
<div class="modal-footer-links">
|
||||
<a
|
||||
href="https://github.com/PerpetualSoftware/pad#getting-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
<span class="footer-sep">·</span>
|
||||
<a
|
||||
href="https://github.com/PerpetualSoftware/pad#installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Troubleshooting
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.intro-copy {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.intro-copy strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Tab strip — flat, bordered buttons. Active tab gets accent border + filled bg. */
|
||||
.tab-strip {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 12%, transparent);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Code block — monospace, padded, with copy button on the right. */
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-9) var(--space-3) var(--space-3);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82em;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.copy-btn-small {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-btn-small:hover {
|
||||
color: var(--accent-blue);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.other-options {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-3);
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.other-options:hover {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.modal-footer-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
.modal-footer-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.modal-footer-links a:hover {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-header h2 {
|
||||
white-space: normal;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,12 +10,14 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import PadLogo from '$lib/components/layout/PadLogo.svelte';
|
||||
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
|
||||
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
|
||||
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
|
||||
|
||||
let { mobile = false }: { mobile?: boolean } = $props();
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
let currentTheme = $state<'dark' | 'light'>('dark');
|
||||
let connectOpen = $state(false);
|
||||
|
||||
let currentSlug = $derived(workspaceStore.current?.slug ?? '');
|
||||
|
||||
@@ -872,6 +874,25 @@
|
||||
Status
|
||||
</a>
|
||||
{/if}
|
||||
<!--
|
||||
"Connect a project…" sits after the cloud-mode Support/Status
|
||||
block (when present) and just above the Sign-out divider —
|
||||
it's a CLI-onboarding action, semantically closer to
|
||||
Settings/Support than to account actions, but visually we
|
||||
want it adjacent to the divider so it reads as a discrete
|
||||
action rather than another link.
|
||||
-->
|
||||
{#if workspaceStore.current?.slug}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
closeUserMenu();
|
||||
connectOpen = true;
|
||||
}}
|
||||
>
|
||||
Connect a project…
|
||||
</button>
|
||||
{/if}
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item logout" onclick={handleLogout}>
|
||||
Sign out
|
||||
@@ -881,6 +902,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Modal lives OUTSIDE the dropdown so it doesn't unmount when the
|
||||
dropdown closes (closeUserMenu fires synchronously with opening
|
||||
the modal). Gated on workspaceStore.current?.slug since the
|
||||
modal needs a workspace to interpolate into the connect snippet.
|
||||
-->
|
||||
{#if workspaceStore.current?.slug}
|
||||
<ConnectWorkspaceModal
|
||||
bind:open={connectOpen}
|
||||
serverUrl={typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
workspaceSlug={workspaceStore.current.slug}
|
||||
workspaceName={workspaceStore.current.name}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
{:else}
|
||||
<!-- ── Mobile ─────────────────────────────────────────────────────────── -->
|
||||
@@ -959,6 +995,18 @@
|
||||
Status
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Connect a project — see desktop branch for placement rationale. -->
|
||||
{#if workspaceStore.current?.slug}
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
closeUserMenu();
|
||||
connectOpen = true;
|
||||
}}
|
||||
>
|
||||
Connect a project…
|
||||
</button>
|
||||
{/if}
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item logout" onclick={handleLogout}>
|
||||
Sign out
|
||||
@@ -967,6 +1015,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Same Connect modal pattern as the desktop branch — see notes above. -->
|
||||
{#if workspaceStore.current?.slug}
|
||||
<ConnectWorkspaceModal
|
||||
bind:open={connectOpen}
|
||||
serverUrl={typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
workspaceSlug={workspaceStore.current.slug}
|
||||
workspaceName={workspaceStore.current.name}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* OS detection helpers. Used by ConnectWorkspaceModal to pick a sensible
|
||||
* default install tab based on the user's machine. SSR returns "macos" by
|
||||
* default since that's the most common dev environment for Pad's audience.
|
||||
*/
|
||||
|
||||
export type Platform = 'macos' | 'linux' | 'windows' | 'other';
|
||||
export type InstallTab = 'macos' | 'linux' | 'windows' | 'docker';
|
||||
|
||||
/**
|
||||
* Best-effort platform detection from `navigator.userAgent` with
|
||||
* `navigator.platform` as a fallback signal. Pure function — safe to call
|
||||
* during render. Returns "macos" on the server (no `navigator`).
|
||||
*/
|
||||
export function detectPlatform(): Platform {
|
||||
if (typeof navigator === 'undefined') return 'macos';
|
||||
|
||||
const ua = (navigator.userAgent || '').toLowerCase();
|
||||
const plat = ((navigator as Navigator & { platform?: string }).platform || '').toLowerCase();
|
||||
const haystack = ua + ' ' + plat;
|
||||
|
||||
if (haystack.includes('mac')) return 'macos';
|
||||
if (haystack.includes('win')) return 'windows';
|
||||
if (haystack.includes('linux') || haystack.includes('x11')) return 'linux';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the detected platform to the install tab we want to show first.
|
||||
* "other" maps to "macos" since that's the most common dev case.
|
||||
*/
|
||||
export function defaultInstallTab(): InstallTab {
|
||||
const p = detectPlatform();
|
||||
if (p === 'other') return 'macos';
|
||||
return p;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { syncService } from '$lib/services/sync.svelte';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte';
|
||||
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import type { DashboardResponse, Collection } from '$lib/types';
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
let collections = $state<Collection[]>([]);
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let onboardingDismissed = $state(false);
|
||||
let connectOpen = $state(false);
|
||||
|
||||
// Sync dismissed state from localStorage when workspace changes
|
||||
$effect(() => {
|
||||
@@ -201,6 +203,19 @@
|
||||
{#if totalItems === 0 && !onboardingDismissed}
|
||||
<div class="onboarding-wrapper">
|
||||
<OnboardingChecklist {wsSlug} {username} byCollection={dashboard.summary.by_collection} 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">→</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if totalItems === 0 && onboardingDismissed}
|
||||
<div class="onboarding-reshow">
|
||||
@@ -421,6 +436,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Mount the connect modal unconditionally at the page root so it
|
||||
survives re-renders of the conditional onboarding block above —
|
||||
closing the modal must not be entangled with that branch's state.
|
||||
-->
|
||||
<ConnectWorkspaceModal
|
||||
bind:open={connectOpen}
|
||||
serverUrl={typeof window !== 'undefined' ? window.location.origin : ''}
|
||||
workspaceSlug={wsSlug}
|
||||
workspaceName={workspaceStore.current?.name ?? ''}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* ── Layout ─────────────────────────────────────────────────────────── */
|
||||
.dashboard {
|
||||
@@ -495,6 +522,66 @@
|
||||
/* ── Onboarding ─────────────────────────────────────────────────────── */
|
||||
.onboarding-wrapper {
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user