@volter/world-platform 2.0.40 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,8 @@ a.button.primary { background: var(--accent); border-color: var(--accent); color
20
20
  /* the sign-in card: the brand, one way in */
21
21
  body.portal #root { width: min(440px, 100%); }
22
22
  body.portal .card { width: 100%; }
23
+ /* the platform's own server pages (pages.ts pageShell: the CLI's approval, a refusal) are the same narrow card */
24
+ body.portal > main.card { width: min(480px, 100%); }
23
25
  body.portal .card .brand h1 { font-size: 15px; margin: 0; }
24
26
  body.portal .card .brand h1 a { color: var(--ink); text-decoration: none; }
25
27
  main.boot { max-width: 520px; display: grid; gap: 14px; padding-top: 18vh; }
@@ -79,7 +79,7 @@ type OrgWorld = { name: string; host: string; base: string; provisionedAt: strin
79
79
  /** An invitation to your address still pending, where the provider keeps them (GET /-/orgs). */
80
80
  type Invitation = { id: string; org: { id: string; slug: string | null; name: string }; role: string; expiresAt: string | null };
81
81
  type OrgRow = { id: string; slug: string | null; name: string; role: string; held?: string | null; flags?: Record<string, boolean>; worlds?: OrgWorld[]; security?: { membersCanInvite: boolean } };
82
- type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean };
82
+ type WorldRow = { org: string; world: string; name: string; base: string; origin?: string; twins: Array<{ vendor: string }>; /** kept to named members */ restricted?: boolean; /** its landed changesets, when the World answered */ landed?: number };
83
83
  const slugOf = (o: OrgRow): string => o.slug ?? o.id;
84
84
  const isAdmin = (o: OrgRow): boolean => o.role === 'admin' || o.role === 'org:admin';
85
85
  const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
@@ -226,7 +226,8 @@ function Palette({ open, onClose, orgs, shown, worlds, pages, support }: { open:
226
226
  const items: Item[] = useMemo(() => {
227
227
  const all: Item[] = [];
228
228
  if (shown) for (const [k, label] of pages) all.push({ label, hint: shown.name, run: () => go({ kind: 'org', org: slugOf(shown), page: k }) });
229
- for (const w of worlds) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
229
+ // a World opens at an origin of its own, where its host serves its dashboard; one without has nothing to open
230
+ for (const w of worlds) if (w.origin) all.push({ label: `Open ${w.name}`, hint: 'World', run: () => { window.location.assign(`/-/worlds/${w.name}/open`); } });
230
231
  for (const o of orgs) if (o.id !== shown?.id) all.push({ label: `Switch to ${o.name}`, hint: 'org', run: () => go({ kind: 'org', org: slugOf(o), page: 'worlds' }) });
231
232
  all.push({ label: 'Account and tokens', hint: 'you', run: () => go({ kind: 'you', page: 'account' }) });
232
233
  if (support) all.push({ label: 'Help', hint: 'you', run: () => go({ kind: 'you', page: 'help' }) });
@@ -303,7 +304,7 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
303
304
  return (
304
305
  <>
305
306
  <Head title="Worlds" count={load.state === 'ok' ? rows.length : undefined}>{rows.length > 0 ? <input type="search" className="toolbar-search" aria-label="Find a World" placeholder="Find a World…" value={q} onChange={(e) => setQ(e.target.value)} /> : null}{make}</Head>
306
- <GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} version={version} />
307
+ <GettingStarted orgs={orgs} worlds={allRows - rows.filter((r) => r.name.split('/')[1] === 'sample').length} firstWorld={rows.find((r) => r.name.split('/')[1] !== 'sample')?.name} pushed={rows.some((r) => r.name.split('/')[1] !== 'sample' && (r.landed ?? 0) > 0)} version={version} refresh={onChanged} />
307
308
  {load.state !== 'ok' ? <Pending load={load} what="the org's Worlds" retry={onChanged} /> : rows.length === 0 ? (
308
309
  <Empty title="No Worlds yet" action={<div className="actions-row">{make}{!readOnly ? <button type="button" onClick={() => void trySample()} disabled={sampling}>{sampling ? 'Making the sample…' : 'Try a sample World'}</button> : null}</div>}>A World stands in for the services your app uses: your app talks to it as it would to Stripe or GitHub, nothing real happens, and you see everything it did on each vendor's own screens.{!readOnly ? <span className="mode sample-note">The sample is a small shop's Stripe and Slack, seeded as its app would leave them. It counts as one of the org's Worlds; delete it when you are done.</span> : null}</Empty>
309
310
  ) : (
@@ -312,11 +313,11 @@ function WorldsPage({ org, rows, load, orgs, allRows, version, readOnly, onChang
312
313
  <thead><tr><th>World</th><th>Stand-ins for</th><th>Host</th><th>Created</th><th className="num"><span className="sr">Actions</span></th></tr></thead>
313
314
  <tbody>{shown.map((w) => { const rec = record.get(w.name); const short = w.name.split('/')[1] ?? w.name; return (<React.Fragment key={w.name}>
314
315
  <tr>
315
- <td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line"><a href={`/-/worlds/${w.name}/open`}>{short}</a>{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
316
+ <td><span className="vendor-cell"><Initial of={short} small /><span className="who"><span className="name-line">{w.origin ? <a href={`/-/worlds/${w.name}/open`}>{short}</a> : short}{w.restricted ? <span className="badge restricted" title="Only some members of the org reach it">Restricted</span> : null}</span>{w.origin ? <span className="mode mono">{new URL(w.origin).host}</span> : null}</span></span></td>
316
317
  <td><span className="marks" title={w.twins.map((t) => vendorName(t.vendor)).join(', ')}>{w.twins.slice(0, 5).map((t) => <Mark key={t.vendor} vendor={t.vendor} />)}{w.twins.length > 5 ? <span className="mode">+{w.twins.length - 5}</span> : null}<span className="mode">{w.twins.length === 1 ? vendorName(w.twins[0]!.vendor) : `${w.twins.length} vendors`}</span></span></td>
317
318
  <td className="mode">{rec?.host ?? '—'}</td>
318
319
  <td className="mode">{rec ? <span title={when(rec.provisionedAt)}>{ago(rec.provisionedAt)}</span> : '—'}</td>
319
- <td className="num row-actions"><a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a>
320
+ <td className="num row-actions">{w.origin ? <a className="button primary" href={`/-/worlds/${w.name}/open`} aria-label={`Open ${w.name}`}>Open</a> : <span className="mode" title="Its host serves no dashboard: the command and your app reach it at its address">No dashboard</span>}
320
321
  <Menu label={`More for ${w.name}`} items={[
321
322
  { label: 'Copy the command to use it', onSelect: () => { void navigator.clipboard?.writeText(`volter remote add origin ${w.name}`).then(() => toast('Copied: volter remote add origin …')); } },
322
323
  !readOnly ? { label: 'Make a key…', onSelect: () => setKeyFor(w.name) } : null,
@@ -1055,23 +1056,39 @@ function HelpPage({ orgs, onChanged }: { orgs: string[]; onChanged?: () => void
1055
1056
  }
1056
1057
 
1057
1058
  /** The first run: what a person does next, with the real commands, as a checklist with its progress; dismissed for good. */
1058
- function GettingStarted({ orgs, worlds, firstWorld, version }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; version: number }): React.JSX.Element | null {
1059
+ function GettingStarted({ orgs, worlds, firstWorld, pushed, version, refresh }: { orgs: string[]; worlds: number; firstWorld?: string | undefined; pushed: boolean; version: number; refresh: () => void }): React.JSX.Element | null {
1059
1060
  const KEY = 'volter.console.onboarding';
1060
1061
  const [dismissed, setDismissed] = useState<boolean>(() => { try { return localStorage.getItem(KEY) === 'done'; } catch { return false; } });
1061
1062
  const [tokensMade, setTokensMade] = useState<boolean | null>(null);
1062
1063
  const mounted = useRef(true); useEffect(() => () => { mounted.current = false; }, []);
1063
1064
  useEffect(() => { void door('/-/tokens').then(async (r) => { if (mounted.current) setTokensMade(r.ok ? ((await r.json()) as { tokens: unknown[] }).tokens.length > 0 : false); }).catch(() => undefined); }, [version]);
1064
- if (dismissed) return null;
1065
1065
  const org = orgs[0];
1066
1066
  // the way in is the app's folder (docs/contributing/architecture.md): the command makes the World from the vendors
1067
1067
  // the app uses; Create World above is the way for a person without a terminal
1068
1068
  const steps: Array<[boolean, React.ReactNode]> = [
1069
1069
  [orgs.length > 0, 'Create or join an org'],
1070
- [tokensMade === true, <>Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></>],
1071
- [worlds > 0, <>In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></>],
1072
- [false, <>Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></>],
1070
+ [tokensMade === true, <React.Fragment key="command">Install the command and sign it in: <code>npm install -g @volter/world</code>, then <code>volter login {ORIGIN}</code></React.Fragment>],
1071
+ [worlds > 0, <React.Fragment key="world">In your app's folder, make its World here: <code>volter world init</code>, then <code>volter remote add origin {firstWorld ?? `${org ?? '<org>'}/<world>`}{firstWorld ? '' : ' --create'}</code></React.Fragment>],
1072
+ [pushed, <React.Fragment key="push">Send it what your app did: <code>volter world changeset -m "…"</code>, then <code>volter world push</code></React.Fragment>],
1073
1073
  ];
1074
1074
  const done = steps.filter(([d]) => d).length;
1075
+ // unfinished, the list re-reads what it ticks from while its tab is in view: its last steps happen in a terminal, and
1076
+ // a World made or a push landed there ticks here without a reload
1077
+ const again = useRef(refresh); again.current = refresh;
1078
+ useEffect(() => {
1079
+ if (dismissed || done === steps.length) return;
1080
+ const every = setInterval(() => { if (document.visibilityState === 'visible') again.current(); }, 5000);
1081
+ return () => clearInterval(every);
1082
+ }, [dismissed, done, steps.length]);
1083
+ if (dismissed) return null;
1084
+ const dismiss = (): void => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); };
1085
+ // all four done: what comes next, in a line, and the card goes when you say
1086
+ if (done === steps.length) return (
1087
+ <section aria-labelledby="getting-started" className="onboarding">
1088
+ <div className="onboarding-head"><h3 id="getting-started">You are set up</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: '100%' }} /></div><button type="button" onClick={dismiss}>Done</button></div>
1089
+ <p className="mode onboarding-alt">Your app's changes land in {firstWorld ?? 'its World'}. Next: {org ? <Go to={{ kind: 'org', org, page: 'members' }}>invite your team</Go> : 'invite your team'}, who open it as themselves, and give each pull request a preview World with the GitHub Action.</p>
1090
+ </section>
1091
+ );
1075
1092
  return (
1076
1093
  <section aria-labelledby="getting-started" className="onboarding">
1077
1094
  <div className="onboarding-head"><h3 id="getting-started">Get started</h3><span className="mode">{done} of {steps.length}</span><div className="progress" aria-hidden="true"><span style={{ width: `${(done / steps.length) * 100}%` }} /></div><button type="button" className="icon-button" aria-label="Dismiss getting started" onClick={() => { try { localStorage.setItem(KEY, 'done'); } catch { /* no storage */ } setDismissed(true); }}>✕</button></div>