Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 21 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ import {
type VelaLoginStatus,
} from './providers/daemon';
import { AMR_LOGIN_STATUS_EVENT } from './components/amrLoginPolling';
import { CollabDemoView } from './collab/CollabDemoView';
import { CommunityView } from './components/CommunityView';
import { seedHomeComposerPrompt } from './components/HomeView';
import { goBack, navigate, useRoute } from './router';
import {
fetchDaemonConfig,
Expand Down Expand Up @@ -975,7 +978,11 @@ function AppInner() {
// banner keys off `privacyDecisionAt`. They may coexist on the
// first launch; the banner sits above the modal layer so it
// stays actionable regardless of the active view.
if (!next.onboardingCompleted) {
// Explicit deep-link entries (collab demo, community gallery) shouldn't be
// hijacked by the first-run onboarding redirect, so they stay reachable.
const path = window.location.pathname;
const exemptFromOnboarding = path.startsWith('/collab-demo') || path.startsWith('/community');
if (!next.onboardingCompleted && !exemptFromOnboarding) {
navigate({ kind: 'home', view: 'onboarding' }, { replace: true });
}
setDaemonConfigLoaded(true);
Expand Down Expand Up @@ -2228,6 +2235,19 @@ function AppInner() {
appMain = <MarketplaceView />;
} else if (route.kind === 'marketplace-detail') {
appMain = <PluginDetailView pluginId={route.pluginId} />;
} else if (route.kind === 'collab-demo') {
appMain = <CollabDemoView projectId={route.projectId} />;
} else if (route.kind === 'community') {
appMain = (
<CommunityView
onRemixTemplate={({ prompt }) => {
// Seed the Home composer with the template's starting prompt, then hand
// the user into Home to review + send it (instead of dropping the pick).
seedHomeComposerPrompt(prompt);
navigate({ kind: 'home', view: 'home' });
}}
/>
);
} else if (route.kind === 'design-system-create') {
appMain = (
<DesignSystemCreationFlow
Expand Down
159 changes: 159 additions & 0 deletions apps/web/src/collab/CollabDemoView.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
.view {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px;
display: flex;
flex-direction: column;
gap: 20px;
}

.banner {
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text, #1f2937);
background: color-mix(in srgb, #f59e0b 14%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
}

.picker,
.session {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface, #fff);
}

.picker {
flex-direction: row;
align-items: flex-end;
}

.field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
font-size: 12px;
color: var(--color-muted, #6b7280);
}

.row,
.actions {
display: flex;
align-items: center;
gap: 12px;
}

.label {
min-width: 108px;
font-size: 12px;
font-weight: 600;
color: var(--color-muted, #6b7280);
}

.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
padding: 2px 6px;
border-radius: 6px;
background: var(--color-code-bg, #f3f4f6);
}

.count {
font-size: 12px;
color: var(--color-muted, #6b7280);
}

.view input,
.view select {
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
font-size: 13px;
}

.view button {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface, #fff);
font-size: 13px;
cursor: pointer;
transition: background 0.14s cubic-bezier(0.23, 1, 0.32, 1);
}

.view button:hover:not(:disabled) {
background: var(--color-code-bg, #f3f4f6);
}

.view button:disabled {
opacity: 0.5;
cursor: default;
}

.view .pull {
border-color: #2563eb;
color: #2563eb;
font-weight: 600;
}

.leave {
margin-left: auto;
}

.actions .label {
margin-right: auto;
}

.scenarioBtns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.view .scenarioActive {
border-color: var(--color-accent, #4f46e5);
color: var(--color-accent, #4f46e5);
font-weight: 600;
}

.note {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--color-muted, #6b7280);
}

.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}

.badge[data-anchor-state='anchored'] {
color: #047857;
background: color-mix(in srgb, #10b981 16%, transparent);
}

.badge[data-anchor-state='reanchored'] {
color: #b45309;
background: color-mix(in srgb, #f59e0b 18%, transparent);
}

.badge[data-anchor-state='stale'] {
color: #6d28d9;
background: color-mix(in srgb, #8b5cf6 16%, transparent);
}

.badge[data-anchor-state='lost'] {
color: #6b7280;
background: color-mix(in srgb, #6b7280 16%, transparent);
}
184 changes: 184 additions & 0 deletions apps/web/src/collab/CollabDemoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CollabMemberRole } from '@open-design/contracts';
import { navigate } from '../router';
import { useCollab } from './useCollab';
import { PresenceBar } from './PresenceBar';
import { CommentDriftDemo } from './CommentDriftDemo';
import styles from './CollabDemoView.module.css';

const ROLES: CollabMemberRole[] = ['owner', 'admin', 'member'];

// A stable per-tab member id so two browser tabs joining the same project show
// up as distinct members in the presence overlay. NOT a real B identity — the
// demo fabricates it (see the banner). crypto.randomUUID is available in every
// runtime that ships this app; fall back defensively.
function makeDemoMemberId(): string {
const uuid =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${performance.now().toString(36)}`;
return `demo-${uuid.slice(0, 8)}`;
}

// Reuse one id per tab (sessionStorage survives reload but not a tab close), so
// reloading the demo doesn't pile up a fresh "Demo member" each time while two
// distinct tabs still get distinct identities.
const DEMO_MEMBER_ID_KEY = 'od-collab-demo-member-id';
function demoMemberId(): string {
try {
const existing = sessionStorage.getItem(DEMO_MEMBER_ID_KEY);
if (existing) return existing;
const id = makeDemoMemberId();
sessionStorage.setItem(DEMO_MEMBER_ID_KEY, id);
return id;
} catch {
return makeDemoMemberId();
}
}

/**
* Team collaboration demo surface. Drives the live presence + sync loop
* against the real daemon routes so the flow can be seen end-to-end: open this
* page in two tabs on the same project id and each tab appears in the other's
* presence overlay; an author publish advances the version both tabs poll.
*
* The ONLY stubs are the member identity and the "shared" entry — real B
* identity (token → memberId) and D visibility are not wired. Everything the
* daemon does here (presence set, coalesced publish, version head) is real.
*/
export function CollabDemoView({ projectId }: { projectId: string | null }) {
const [projectInput, setProjectInput] = useState(projectId ?? '');
const [name, setName] = useState('Demo member');
const [role, setRole] = useState<CollabMemberRole>('member');
const memberIdRef = useRef<string>('');
if (!memberIdRef.current) memberIdRef.current = demoMemberId();

const activeProjectId = projectId?.trim() || null;
const member = useMemo(
() => ({ memberId: memberIdRef.current, name: name.trim() || 'Demo member', role }),
[name, role],
);

const { present, publishedVersion, syncState, reportChange, requestPublish } = useCollab({
projectId: activeProjectId,
member,
enabled: Boolean(activeProjectId),
});

const shareToTeam = async () => {
if (!activeProjectId) return;
await fetch(`/api/projects/${encodeURIComponent(activeProjectId)}/collab/sync-intent`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event: 'project_team_share_requested', projectId: activeProjectId }),
});
};

// Track the version this tab has "pulled" so we can surface an out-of-date
// prompt when the author publishes a newer head — the member's pull cue.
const [pulledVersion, setPulledVersion] = useState<number | null>(null);
useEffect(() => {
// First observed version is treated as already-pulled (nothing to catch up on).
if (publishedVersion !== null && pulledVersion === null) setPulledVersion(publishedVersion);
}, [publishedVersion, pulledVersion]);
const behind = publishedVersion !== null && pulledVersion !== null && publishedVersion > pulledVersion;

return (
<div className={styles.view}>
<div className={styles.banner} role="note">
<strong>Collaboration demo</strong> — presence &amp; sync run live against the daemon.
The member identity and “shared session” entry are <em>stubbed</em>; real B identity and D
visibility are not wired yet.
</div>

{!activeProjectId ? (
<form
className={styles.picker}
onSubmit={(event) => {
event.preventDefault();
const id = projectInput.trim();
if (id) navigate({ kind: 'collab-demo', projectId: id });
}}
>
<label className={styles.field}>
<span>Project id</span>
<input
value={projectInput}
onChange={(event) => setProjectInput(event.target.value)}
placeholder="paste a project id to join"
autoFocus
/>
</label>
<button type="submit" disabled={!projectInput.trim()}>
Join
</button>
</form>
) : (
<div className={styles.session}>
<div className={styles.row}>
<span className={styles.label}>Project</span>
<code className={styles.mono}>{activeProjectId}</code>
<button type="button" className={styles.leave} onClick={() => navigate({ kind: 'collab-demo', projectId: null })}>
Leave
</button>
</div>

<div className={styles.row}>
<span className={styles.label}>You (demo)</span>
<input
className={styles.nameInput}
value={name}
onChange={(event) => setName(event.target.value)}
aria-label="Demo member name"
/>
<select value={role} onChange={(event) => setRole(event.target.value as CollabMemberRole)} aria-label="Demo member role">
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>

<div className={styles.row}>
<span className={styles.label}>Present</span>
<PresenceBar members={present} selfMemberId={member.memberId} />
<span className={styles.count}>{present.length} online</span>
</div>

<div className={styles.row}>
<span className={styles.label}>Published head</span>
<code className={styles.mono}>{publishedVersion ?? '—'}</code>
{behind && (
<button type="button" className={styles.pull} onClick={() => setPulledVersion(publishedVersion)}>
Pull v{publishedVersion}
</button>
)}
</div>

<div className={styles.row}>
<span className={styles.label}>Sync state</span>
<span className={styles.badge} data-anchor-state={syncState === 'sync_failed' ? 'lost' : syncState === 'synced' ? 'anchored' : 'stale'}>
{syncState ?? '—'}
</span>
</div>

<div className={styles.actions}>
<span className={styles.label}>Author</span>
<button type="button" onClick={() => reportChange()}>
Report change
</button>
<button type="button" onClick={() => requestPublish()}>
Publish
</button>
<button type="button" onClick={() => void shareToTeam()}>
Share to team
</button>
</div>
</div>
)}

<CommentDriftDemo />
</div>
);
}
Loading
Loading