@1agh/maude 0.36.1 → 0.37.0

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.
@@ -7623,8 +7623,9 @@ function App() {
7623
7623
  loadLog={gitLoadLog}
7624
7624
  onClose={() => setDiffTarget(null)}
7625
7625
  onRestore={async (file) => {
7626
- await gitDiscard([file]);
7627
- setDiffTarget(null);
7626
+ const res = await gitDiscard([file]);
7627
+ if (res?.ok) setDiffTarget(null);
7628
+ else window.alert(res?.error || 'Could not restore that version. Try again.');
7628
7629
  }}
7629
7630
  onResolve={async (choice) => {
7630
7631
  // phase-28 (E3): apply the chosen side via /_api/git/resolve, which
@@ -37,6 +37,10 @@ export const openVerification = () =>
37
37
  invoke('github_open_verification', { url: 'https://github.com/login/device' });
38
38
  /** Show the device code as soon as the shell has it. Returns an unlisten promise. */
39
39
  export const onDeviceCode = (cb) => listen('github://device-code', cb);
40
+ /** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */
41
+ export const onSignedIn = (cb) => listen('github://signed-in', cb);
42
+ /** Fire when the native File ▸ New Project… menu item is chosen. Returns an unlisten promise. */
43
+ export const onMenuNewProject = (cb) => listen('menu://new-project', cb);
40
44
 
41
45
  // ── dev-server endpoints ────────────────────────────────────────────────────────
42
46
  async function api(path, opts = {}) {
@@ -63,6 +67,8 @@ export const createRepo = (body) => api('/_api/github/create-repo', { method: 'P
63
67
  export const invite = (username) => api('/_api/github/invite', { method: 'POST', body: JSON.stringify({ username }) });
64
68
  export const cloneRepo = (body) => api('/_api/github/clone', { method: 'POST', body: JSON.stringify(body) });
65
69
  export const createProject = (body) => api('/_api/github/create-project', { method: 'POST', body: JSON.stringify(body) });
70
+ /** Create a local-only project (git init + .design scaffold, no GitHub remote). body: { name, parentDir }. */
71
+ export const createLocalProject = (body) => api('/_api/project/create-local', { method: 'POST', body: JSON.stringify(body) });
66
72
  export const initDesign = (dir) => api('/_api/design/init', { method: 'POST', body: JSON.stringify({ dir }) });
67
73
  /** Phase 29 (E4) Door C — connect to a team hub (saves the global hub credential). */
68
74
  export const hubLink = (body) => api('/_api/hub/link', { method: 'POST', body: JSON.stringify(body) });
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { useEffect, useState } from 'react';
14
14
 
15
- import { cloneRepo, createProject, initDesign, invite, listRepos, openLocalProject, pickDirectory } from '../github.js';
15
+ import { cloneRepo, createLocalProject, createProject, initDesign, invite, listRepos, openLocalProject, pickDirectory } from '../github.js';
16
16
 
17
17
  function Icon({ name, size = 16 }) {
18
18
  const p = {
@@ -57,6 +57,12 @@ function Icon({ name, size = 16 }) {
57
57
  </>
58
58
  ),
59
59
  spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
60
+ laptop: (
61
+ <>
62
+ <rect x="3" y="3.5" width="10" height="7" rx="1" />
63
+ <path d="M1.5 13h13l-1.2-1.8H2.7z" />
64
+ </>
65
+ ),
60
66
  }[name];
61
67
  return (
62
68
  <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={name === 'spinner' ? 'cp-spin' : undefined}>
@@ -66,12 +72,12 @@ function Icon({ name, size = 16 }) {
66
72
  }
67
73
 
68
74
  const TITLES = {
69
- new: ['Create a new project', 'A private project on GitHub, set up for you.'],
75
+ new: ['Create a new project', 'On GitHub, or just locally on your computer.'],
70
76
  get: ['Pull a local copy', 'Pick a project, choose where to save it, and open it here.'],
71
77
  share: ['Share this project', 'Invite a teammate by their GitHub username.'],
72
78
  };
73
79
 
74
- export default function CreateProject({ view, identity, onClose }) {
80
+ export default function CreateProject({ view, identity, signedIn, onClose }) {
75
81
  const [title, sub] = TITLES[view] || TITLES.new;
76
82
  return (
77
83
  <div className="cp-modal" role="dialog" aria-modal="true" aria-label={title} onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
@@ -86,7 +92,7 @@ export default function CreateProject({ view, identity, onClose }) {
86
92
  <Icon name="x" size={15} />
87
93
  </button>
88
94
  </div>
89
- {view === 'new' && <NewView identity={identity} onClose={onClose} />}
95
+ {view === 'new' && <NewView identity={identity} signedIn={signedIn} onClose={onClose} />}
90
96
  {view === 'get' && <GetView />}
91
97
  {view === 'share' && <ShareView onClose={onClose} />}
92
98
  </div>
@@ -94,20 +100,24 @@ export default function CreateProject({ view, identity, onClose }) {
94
100
  );
95
101
  }
96
102
 
97
- function NewView({ identity, onClose }) {
103
+ function NewView({ identity, signedIn, onClose }) {
98
104
  const [name, setName] = useState('');
105
+ // 'github' = new repo on GitHub (needs sign-in); 'local' = just a local git repo,
106
+ // no remote. Default to local when signed out (GitHub isn't available then).
107
+ const [where, setWhere] = useState(signedIn ? 'github' : 'local');
99
108
  const [isPrivate, setIsPrivate] = useState(true);
100
109
  const [desc, setDesc] = useState('');
101
110
  const [busy, setBusy] = useState(false);
102
111
  const [step, setStep] = useState('');
103
112
  const [err, setErr] = useState('');
104
113
 
114
+ const local = where === 'local';
105
115
  const slug = name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
106
116
  const owner = identity?.login || 'you';
107
117
 
108
- // New project = create the GitHub repo AND a ready-to-design local project, then
109
- // open it. You pick where to save it (like a real "new project"), then design
110
- // and Publish when ready.
118
+ // New project = a ready-to-design local project, then open it. GitHub mode also
119
+ // creates the remote repo + sets origin; local mode is git init only (publish later).
120
+ // You pick where to save it, then design and Publish when ready.
111
121
  async function submit() {
112
122
  setErr('');
113
123
  setBusy(true);
@@ -115,8 +125,14 @@ function NewView({ identity, onClose }) {
115
125
  setStep('Choose where to save it…');
116
126
  const parentDir = await pickDirectory();
117
127
  if (!parentDir) { setBusy(false); setStep(''); return; } // cancelled
118
- setStep('Creating your project on GitHub…');
119
- const r = await createProject({ name, private: isPrivate, description: desc, parentDir });
128
+ let r;
129
+ if (local) {
130
+ setStep('Setting up your local project…');
131
+ r = await createLocalProject({ name, parentDir });
132
+ } else {
133
+ setStep('Creating your project on GitHub…');
134
+ r = await createProject({ name, private: isPrivate, description: desc, parentDir });
135
+ }
120
136
  if (!(r.ok && r.json?.ok)) {
121
137
  setErr(r.json?.error || 'Couldn’t create the project. Try again.');
122
138
  setBusy(false);
@@ -135,30 +151,48 @@ function NewView({ identity, onClose }) {
135
151
  return (
136
152
  <>
137
153
  <div className="cp-body">
138
- <label className="cp-field">
139
- <span className="cp-field-label">Project name</span>
140
- <input className="input cp-input" type="text" value={name} placeholder="Acme Rebrand" aria-label="Project name" onChange={(e) => setName(e.target.value)} />
141
- {slug && <span className="cp-field-help">Creates <b>github.com/{owner}/{slug}</b></span>}
142
- </label>
143
154
  <div className="cp-field">
144
- <span className="cp-field-label">Who can see it</span>
145
- <div className="seg cp-seg" role="group" aria-label="Project visibility">
146
- <button type="button" aria-pressed={isPrivate} onClick={() => setIsPrivate(true)}><Icon name="lock" size={14} /> Private</button>
147
- <button type="button" aria-pressed={!isPrivate} onClick={() => setIsPrivate(false)}><Icon name="globe" size={14} /> Public</button>
155
+ <span className="cp-field-label">Where</span>
156
+ <div className="seg cp-seg" role="group" aria-label="Where to create the project">
157
+ <button type="button" aria-pressed={!local} disabled={!signedIn} title={signedIn ? undefined : 'Sign in with GitHub first'} onClick={() => setWhere('github')}><Icon name="globe" size={14} /> GitHub</button>
158
+ <button type="button" aria-pressed={local} onClick={() => setWhere('local')}><Icon name="laptop" size={14} /> This computer only</button>
148
159
  </div>
149
- <span className="cp-field-help">{isPrivate ? 'Only you and people you invite. The safe default.' : 'Anyone on the internet can see this project.'}</span>
160
+ <span className="cp-field-help">
161
+ {local
162
+ ? 'A local git repo on your computer — no GitHub, no remote. You can publish it later.'
163
+ : signedIn
164
+ ? 'A repo on your GitHub account, cloned to your computer.'
165
+ : 'Sign in with GitHub (bottom-left) to publish. For now you can create a local project.'}
166
+ </span>
150
167
  </div>
151
168
  <label className="cp-field">
152
- <span className="cp-field-label">Description <span className="cp-optional">optional</span></span>
153
- <textarea className="textarea cp-textarea" rows={2} value={desc} placeholder="What is this project for?" aria-label="Project description" onChange={(e) => setDesc(e.target.value)} />
169
+ <span className="cp-field-label">Project name</span>
170
+ <input className="input cp-input" type="text" value={name} placeholder="Acme Rebrand" aria-label="Project name" onChange={(e) => setName(e.target.value)} />
171
+ {slug && <span className="cp-field-help">{local ? <>Creates a project folder <b>{slug}</b></> : <>Creates <b>github.com/{owner}/{slug}</b></>}</span>}
154
172
  </label>
173
+ {!local && (
174
+ <>
175
+ <div className="cp-field">
176
+ <span className="cp-field-label">Who can see it</span>
177
+ <div className="seg cp-seg" role="group" aria-label="Project visibility">
178
+ <button type="button" aria-pressed={isPrivate} onClick={() => setIsPrivate(true)}><Icon name="lock" size={14} /> Private</button>
179
+ <button type="button" aria-pressed={!isPrivate} onClick={() => setIsPrivate(false)}><Icon name="globe" size={14} /> Public</button>
180
+ </div>
181
+ <span className="cp-field-help">{isPrivate ? 'Only you and people you invite. The safe default.' : 'Anyone on the internet can see this project.'}</span>
182
+ </div>
183
+ <label className="cp-field">
184
+ <span className="cp-field-label">Description <span className="cp-optional">optional</span></span>
185
+ <textarea className="textarea cp-textarea" rows={2} value={desc} placeholder="What is this project for?" aria-label="Project description" onChange={(e) => setDesc(e.target.value)} />
186
+ </label>
187
+ </>
188
+ )}
155
189
  {err && <div className="callout callout--error"><span className="cp-cl-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
156
190
  </div>
157
191
  <div className="cp-ft">
158
192
  <span className="cp-spacer" />
159
193
  <button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button>
160
194
  <button type="button" className="btn btn--primary" onClick={submit} disabled={busy || !slug}>
161
- <Icon name="plus" size={15} /> {busy ? step || 'Creating…' : 'Create project'}
195
+ <Icon name="plus" size={15} /> {busy ? step || 'Creating…' : local ? 'Create local project' : 'Create project'}
162
196
  </button>
163
197
  </div>
164
198
  </>
@@ -343,7 +343,7 @@ export default function GitPanel({
343
343
  if (res.authRequired)
344
344
  setBanner({
345
345
  variant: 'info',
346
- text: res.error || 'Sign in with GitHub to publish — coming soon.',
346
+ text: res.error || 'Sign in with GitHub to publish.',
347
347
  });
348
348
  else if (res.conflict)
349
349
  // A push conflict (non-fast-forward) prompts a Get-latest; a Get-latest
@@ -15,6 +15,8 @@ import {
15
15
  isNativeApp,
16
16
  isSignedIn,
17
17
  onDeviceCode,
18
+ onMenuNewProject,
19
+ onSignedIn,
18
20
  openVerification,
19
21
  signIn,
20
22
  signOut,
@@ -107,6 +109,8 @@ export default function IdentityBar() {
107
109
  const [copied, setCopied] = useState(false);
108
110
  const unlistenRef = useRef(null);
109
111
  const railRef = useRef(null);
112
+ const stateRef = useRef(state);
113
+ stateRef.current = state; // keep current for the (subscribe-once) menu listener
110
114
 
111
115
  useEffect(() => {
112
116
  let alive = true;
@@ -116,7 +120,14 @@ export default function IdentityBar() {
116
120
  const signed = await isSignedIn();
117
121
  if (!alive) return;
118
122
  if (signed) {
119
- const r = await fetchIdentity();
123
+ let r = await fetchIdentity();
124
+ // A valid keychain token can still hit a transient identity-fetch failure at
125
+ // mount (bridge not ready / GitHub 5xx / rate-limit) — retry before falling back
126
+ // to the signed-out CTA so we don't flash "Sign in" at a signed-in user.
127
+ for (let i = 0; i < 2 && alive && !(r.ok && r.json?.ok); i += 1) {
128
+ await new Promise((res) => setTimeout(res, 800));
129
+ r = await fetchIdentity();
130
+ }
120
131
  if (!alive) return;
121
132
  if (r.ok && r.json?.ok) {
122
133
  setIdentity({ login: r.json.login, name: r.json.name, avatar_url: r.json.avatar_url });
@@ -133,6 +144,36 @@ export default function IdentityBar() {
133
144
  };
134
145
  }, [native]);
135
146
 
147
+ // Live-update when sign-in completes in ANOTHER surface (the wizard's Door A emits
148
+ // `github://signed-in`). Without this the rail can sit on "Sign in" with a valid token
149
+ // until a full reload. Flip to signed-in off the event's login, then enrich the profile.
150
+ useEffect(() => {
151
+ if (!native) return undefined;
152
+ const p = onSignedIn(async (login) => {
153
+ setIdentity((cur) => cur || { login, name: null, avatar_url: null });
154
+ setState('in');
155
+ const r = await fetchIdentity();
156
+ if (r.ok && r.json?.ok)
157
+ setIdentity({ login: r.json.login, name: r.json.name, avatar_url: r.json.avatar_url });
158
+ });
159
+ return () => {
160
+ p?.then?.((fn) => fn?.());
161
+ };
162
+ }, [native]);
163
+
164
+ // Native File ▸ New Project… (menu.rs emits `menu://new-project`). Open the create
165
+ // dialog once the GitHub check settled — even when signed out, since it offers a
166
+ // local-only project (no GitHub needed); the dialog gates the GitHub option itself.
167
+ useEffect(() => {
168
+ if (!native) return undefined;
169
+ const p = onMenuNewProject(() => {
170
+ if (stateRef.current !== 'loading') setView('new');
171
+ });
172
+ return () => {
173
+ p?.then?.((fn) => fn?.());
174
+ };
175
+ }, [native]);
176
+
136
177
  useEffect(() => {
137
178
  if (!menuOpen) return;
138
179
  const onDoc = (e) => {
@@ -288,7 +329,7 @@ export default function IdentityBar() {
288
329
  </div>
289
330
  )}
290
331
 
291
- {view && <CreateProject view={view} identity={identity} onClose={() => setView(null)} />}
332
+ {view && <CreateProject view={view} identity={identity} signedIn={state === 'in'} onClose={() => setView(null)} />}
292
333
  </div>
293
334
  );
294
335
  }
@@ -178,26 +178,26 @@ function AiReadiness() {
178
178
  }
179
179
 
180
180
  // ── A · Welcome (door picker) ────────────────────────────────────────────────
181
- function Welcome({ onGithub, onLocal, onHub, signing }) {
181
+ function Welcome({ onGithub, onLocal, onHub, signing, signedIn, identity }) {
182
182
  return (
183
183
  <main className="ob-main">
184
184
  <header className="ob-head">
185
185
  <span className="ob-eyebrow">Welcome</span>
186
186
  <h1>How would you like to start?</h1>
187
- <p>Most people sign in with GitHub — it's the simplest way to work with a team.</p>
187
+ <p>{signedIn ? `You're signed in as @${identity?.login || 'GitHub'}. Open a project, or start a new one.` : "Most people sign in with GitHub — it's the simplest way to work with a team."}</p>
188
188
  </header>
189
189
  <div className="ob-doors">
190
- <button type="button" className="ob-door ob-door--primary" aria-label="Continue with GitHub — recommended" onClick={onGithub} disabled={signing}>
190
+ <button type="button" data-testid="ob-door-github" className="ob-door ob-door--primary" aria-label="Continue with GitHub — recommended" onClick={onGithub} disabled={signing}>
191
191
  <span className="ob-door-icon ob-door-icon--gh"><GitHubMark size={26} /></span>
192
192
  <span className="ob-door-tx">
193
193
  <span className="ob-door-title">Continue with GitHub<span className="ob-door-tag">Recommended</span></span>
194
194
  <span className="ob-door-sub">Start a shared project, or open one a teammate shared with you.</span>
195
195
  </span>
196
196
  <span className="ob-door-cta">
197
- <span className="btn btn--primary ob-door-btn"><GitHubMark size={15} /> {signing ? 'Starting…' : 'Sign in with GitHub'}</span>
197
+ <span className="btn btn--primary ob-door-btn"><GitHubMark size={15} /> {signing ? 'Starting…' : signedIn ? 'Continue' : 'Sign in with GitHub'}</span>
198
198
  </span>
199
199
  </button>
200
- <button type="button" className="ob-door" aria-label="Open a folder on this computer" onClick={onLocal}>
200
+ <button type="button" data-testid="ob-door-local" className="ob-door" aria-label="Open a folder on this computer" onClick={onLocal}>
201
201
  <span className="ob-door-icon"><Icon name="folder-open" size={22} /></span>
202
202
  <span className="ob-door-tx">
203
203
  <span className="ob-door-title">Open a folder on this computer</span>
@@ -205,7 +205,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
205
205
  </span>
206
206
  <span className="ob-door-go" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
207
207
  </button>
208
- <button type="button" className="ob-door ob-door--advanced" aria-label="Connect to a team hub — advanced" onClick={onHub}>
208
+ <button type="button" data-testid="ob-door-hub" className="ob-door ob-door--advanced" aria-label="Connect to a team hub — advanced" onClick={onHub}>
209
209
  <span className="ob-door-icon ob-door-icon--quiet"><Icon name="server" size={18} /></span>
210
210
  <span className="ob-door-tx">
211
211
  <span className="ob-door-title">Connect to a team hub <span className="ob-door-adv">Advanced</span></span>
@@ -214,7 +214,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
214
214
  <span className="ob-door-go" aria-hidden="true"><Icon name="chevron-right" size={15} /></span>
215
215
  </button>
216
216
  </div>
217
- <p className="ob-foot-note">Maude never touches the terminal. Everything here happens in the app.</p>
217
+ <p className="ob-foot-note">Browse, version, and collaborate all in the app, no terminal.</p>
218
218
  <AiReadiness />
219
219
  <CrashOptIn />
220
220
  </main>
@@ -223,7 +223,7 @@ function Welcome({ onGithub, onLocal, onHub, signing }) {
223
223
 
224
224
  function BackBar({ onBack }) {
225
225
  return (
226
- <button type="button" className="ob-back" onClick={onBack}>
226
+ <button type="button" data-testid="ob-back" className="ob-back" onClick={onBack}>
227
227
  <Icon name="back" size={14} /> Back
228
228
  </button>
229
229
  );
@@ -272,7 +272,7 @@ function GitHubDoor({ identity, onBack }) {
272
272
  <h1>Open a project, or start a new one</h1>
273
273
  <p>Pick up a shared project below, or create a fresh one — it's private until you invite someone.</p>
274
274
  </header>
275
- <button type="button" className="ob-create" aria-label="Start a new project" onClick={() => setCreating(true)}>
275
+ <button type="button" data-testid="ob-github-create" className="ob-create" aria-label="Start a new project" onClick={() => setCreating(true)}>
276
276
  <span className="ob-create-icon"><Icon name="plus" size={20} /></span>
277
277
  <span className="ob-create-tx">
278
278
  <span className="ob-create-title">Start a new project</span>
@@ -280,12 +280,12 @@ function GitHubDoor({ identity, onBack }) {
280
280
  </span>
281
281
  <span className="btn btn--primary ob-create-btn"><Icon name="arrow-right" size={15} /> Create</span>
282
282
  </button>
283
- <div className="ob-section-label">Shared with you</div>
283
+ <div className="ob-section-label">Your projects</div>
284
284
  {err && <div className="callout callout--error ob-callout"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
285
285
  {repos === null && <div className="ob-foot-note">Loading your projects…</div>}
286
- {repos && repos.length === 0 && !err && <div className="ob-foot-note">No shared projects yet — create one to get started.</div>}
286
+ {repos && repos.length === 0 && !err && <div className="ob-foot-note">No projects yet — create one to get started.</div>}
287
287
  {repos && repos.length > 0 && (
288
- <div className="ob-repolist" role="group" aria-label="Shared projects you can open">
288
+ <div className="ob-repolist" role="group" aria-label="Projects you can open">
289
289
  {repos.map((r) => {
290
290
  const isBusy = busy === r.full_name;
291
291
  return (
@@ -417,12 +417,12 @@ function LocalDoor({ onBack }) {
417
417
  {err && <div className="callout callout--error ob-callout"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>}
418
418
  <div className="ob-form-actions">
419
419
  <button type="button" className="btn btn--ghost" onClick={() => setNeedsSetup(null)} disabled={busy}>Pick a different folder</button>
420
- <button type="button" className="btn btn--primary" onClick={setupHere} disabled={busy}><Icon name="folder" size={15} /> {busy ? 'Setting up…' : 'Set up Maude here'}</button>
420
+ <button type="button" data-testid="ob-local-setup" className="btn btn--primary" onClick={setupHere} disabled={busy}><Icon name="folder" size={15} /> {busy ? 'Setting up…' : 'Set up Maude here'}</button>
421
421
  </div>
422
422
  </>
423
423
  ) : (
424
424
  <>
425
- <button type="button" className="ob-drop" onClick={choose} disabled={busy} aria-label="Choose a project folder">
425
+ <button type="button" data-testid="ob-local-choose" className="ob-drop" onClick={choose} disabled={busy} aria-label="Choose a project folder">
426
426
  <span className="ob-drop-glyph"><Icon name="folder-open" size={34} /></span>
427
427
  <span className="ob-drop-title">{busy ? 'Opening…' : 'Choose a project folder'}</span>
428
428
  <span className="ob-drop-or">click to browse</span>
@@ -506,11 +506,11 @@ function DeviceCodeModal({ device, onClose }) {
506
506
  return (
507
507
  <div className="gi-modal" role="dialog" aria-modal="true" aria-label="Sign in with GitHub" onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}>
508
508
  <div className="gi-scrim" aria-hidden="true" onClick={onClose} />
509
- <div className="gi-dialog gi-dialog--code">
509
+ <div className="gi-dialog gi-dialog--code" data-testid="ob-device-modal">
510
510
  <div className="gi-dc-head">
511
511
  <span className="gi-dc-marks"><GitHubMark size={26} /></span>
512
512
  <h2>Sign in with GitHub</h2>
513
- <p>Maude opened GitHub in your browser. Enter this code to connect your account.</p>
513
+ <p>Maude opened GitHub in your browser. Enter the code there to connect — you'll grant Maude access to create and manage your project repos.</p>
514
514
  </div>
515
515
  <ol className="gi-dc-steps">
516
516
  <li>
@@ -523,7 +523,7 @@ function DeviceCodeModal({ device, onClose }) {
523
523
  <li><span className="gi-dc-step-n">2</span><span className="gi-dc-step-tx">Enter this code to connect Maude</span></li>
524
524
  </ol>
525
525
  <div className="gi-code">
526
- <span className="gi-code-val">{device.user_code}</span>
526
+ <span className="gi-code-val" data-testid="ob-device-code">{device.user_code}</span>
527
527
  <button type="button" className="btn btn--ghost gi-code-copy" onClick={copyCode} aria-label="Copy the code"><Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}</button>
528
528
  </div>
529
529
  <div className="gi-dc-status" aria-live="polite"><span className="gi-pulse" aria-hidden="true" /><span>Waiting for you to authorize in your browser…</span></div>
@@ -542,6 +542,7 @@ export default function OnboardingWizard() {
542
542
  const [device, setDevice] = useState(null);
543
543
  const [err, setErr] = useState('');
544
544
  const unlistenRef = useRef(null);
545
+ const cancelledRef = useRef(false);
545
546
 
546
547
  // If already signed in (re-onboarding), skip the sign-in step on the GitHub door.
547
548
  useEffect(() => {
@@ -560,16 +561,18 @@ export default function OnboardingWizard() {
560
561
 
561
562
  async function handleGithub() {
562
563
  if (signedIn) { setDoor('github'); return; }
564
+ cancelledRef.current = false;
563
565
  setErr(''); setSigning(true); setDevice(null);
564
566
  try {
565
567
  unlistenRef.current = onDeviceCode((p) => setDevice(p));
566
568
  const login = await signIn();
569
+ if (cancelledRef.current) return; // user hit Cancel — don't auto-advance on a late resolve
567
570
  const r = await fetchIdentity();
568
571
  setIdentity(r.ok && r.json?.ok ? { login: r.json.login, name: r.json.name } : { login });
569
572
  setSignedIn(true);
570
573
  setDoor('github');
571
574
  } catch (e) {
572
- setErr(String(e?.message || e || "Sign-in didn't finish. Please try again."));
575
+ if (!cancelledRef.current) setErr(String(e?.message || e || "Sign-in didn't finish. Please try again."));
573
576
  } finally {
574
577
  setSigning(false);
575
578
  setDevice(null);
@@ -581,10 +584,10 @@ export default function OnboardingWizard() {
581
584
  if (!native) return null;
582
585
 
583
586
  return (
584
- <div className="ob-overlay" role="dialog" aria-modal="true" aria-label="Welcome to Maude">
587
+ <div className="ob-overlay" data-testid="onboarding-wizard" role="dialog" aria-modal="true" aria-label="Welcome to Maude">
585
588
  <div className="ob-shell">
586
589
  <Rail signedInAs={signedIn ? identity?.login : null} />
587
- {door === 'welcome' && <Welcome signing={signing} onGithub={handleGithub} onLocal={() => setDoor('local')} onHub={() => setDoor('hub')} />}
590
+ {door === 'welcome' && <Welcome signing={signing} signedIn={signedIn} identity={identity} onGithub={handleGithub} onLocal={() => setDoor('local')} onHub={() => setDoor('hub')} />}
588
591
  {door === 'github' && <GitHubDoor identity={identity} onBack={() => setDoor('welcome')} />}
589
592
  {door === 'local' && <LocalDoor onBack={() => setDoor('welcome')} />}
590
593
  {door === 'hub' && <HubDoor onBack={() => setDoor('welcome')} />}
@@ -592,7 +595,7 @@ export default function OnboardingWizard() {
592
595
  {err && door === 'welcome' && (
593
596
  <div className="ob-toast callout callout--error" role="alert"><span className="ob-callout-glyph" style={{ color: 'var(--status-error)' }}><Icon name="x" /></span><span>{err}</span></div>
594
597
  )}
595
- {device && <DeviceCodeModal device={device} onClose={() => setDevice(null)} />}
598
+ {device && <DeviceCodeModal device={device} onClose={() => { cancelledRef.current = true; setSigning(false); setDevice(null); }} />}
596
599
  </div>
597
600
  );
598
601
  }
@@ -417,7 +417,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
417
417
  <span className="rb-pop-icon"><Icon name="plus" size={14} /></span>
418
418
  <span className="rb-pop-tx">
419
419
  <span className="rb-pop-name">New branch</span>
420
- <span className="rb-pop-sub">a separate line of work off the current branch</span>
420
+ <span className="rb-pop-sub">a separate line of work off what you're looking at now</span>
421
421
  </span>
422
422
  </button>
423
423
  </div>
@@ -435,11 +435,11 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
435
435
  <button type="button" className="btn btn--ghost btn--sm" onClick={() => { setNewDraft(false); setDraftName(''); setErr(''); }} disabled={busy}>Cancel</button>
436
436
  <button type="button" data-testid="switcher-new-branch-create" className="btn btn--primary btn--sm" onClick={createDraft} disabled={busy || !slug}><Icon name="draft" size={13} /> {busy ? 'Creating…' : 'Create branch'}</button>
437
437
  </div>
438
- <p className="rb-newdraft-hint">A branch is your own line of work off the current branch. Merge it into {sharedName} when you're happy, or throw it away — nothing else changes.</p>
438
+ <p className="rb-newdraft-hint">A branch is your own line of work off what you're looking at now. Merge it into {sharedName} when you're happy, or throw it away — nothing else changes.</p>
439
439
  </div>
440
440
  )}
441
441
 
442
- {switching ? (
442
+ {(switching || folding) ? (
443
443
  <div className="rb-switching" role="status" aria-live="polite">
444
444
  <Icon name="spinner" size={14} className="rb-spin" />
445
445
  <span>{folding ? <>Merging <b>{folding}</b> → {sharedName}…</> : downloading ? <>Downloading <b>{switching}</b>…</> : <>Opening <b>{switching}</b>…</>}</span>
@@ -466,7 +466,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
466
466
  <span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
467
467
  <h2 className="rb-sheet-title" id="rb-sheet-title">Merge this branch → {sharedName}</h2>
468
468
  <p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> becomes part of <b>{sharedName}</b> — the default branch everyone shares.</p>
469
- <p className="rb-sheet-meta">Teammates pick it up the next time they Get latest, and this branch is then deleted. Nothing else changes.</p>
469
+ <p className="rb-sheet-meta">Your work is now part of {sharedName}, kept in History. Teammates pick it up the next time they Get latest, and the empty branch is cleared away nothing is lost.</p>
470
470
  {err && <p className="rb-newdraft-err">{err}</p>}
471
471
  <div className="rb-sheet-actions">
472
472
  <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
@@ -14,7 +14,7 @@
14
14
  // the one honest hard thing, centered.
15
15
  //
16
16
  // Vocabulary is the canonical set ONLY (teaching-model table) — no raw git terms:
17
- // Save changes locally · Publish for everyone · Pull changes · Draft · Shared version.
17
+ // Save version · Publish · Get latest · branch · Shared version (matches the shipped buttons).
18
18
 
19
19
  import CollabModelInfographic from '../panels/CollabModelInfographic.jsx';
20
20
 
@@ -43,8 +43,8 @@ export const COLLAB_TOUR = [
43
43
  {
44
44
  target: "[data-tour='pull']",
45
45
  changes: true,
46
- title: 'Pull changes',
47
- body: 'When teammates publish, Pull brings their work onto your computer so you’re both looking at the same thing.',
46
+ title: 'Get latest',
47
+ body: 'When teammates publish, Get latest brings their work onto your computer so you’re both looking at the same thing.',
48
48
  placement: 'left',
49
49
  },
50
50
  {