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:
xarmian
2026-04-29 09:20:29 -04:00
committed by GitHub
parent a03c96f9b0
commit a28767d323
4 changed files with 585 additions and 0 deletions
@@ -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)}>&#10005;</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 &rarr;
</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">&middot;</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}
+36
View File
@@ -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">&rarr;</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);