@1agh/maude 0.54.0 → 0.56.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.
Files changed (53) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/bin/_smart-frames.mjs +187 -20
  3. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  4. package/apps/studio/bin/_transcribe.mjs +40 -3
  5. package/apps/studio/bin/smoke.sh +7 -1
  6. package/apps/studio/canvas-build-sandbox.ts +101 -9
  7. package/apps/studio/canvas-build-worker.ts +7 -1
  8. package/apps/studio/canvas-build.ts +9 -1
  9. package/apps/studio/client/app.jsx +49 -3
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +505 -45
  12. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  13. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  14. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  15. package/apps/studio/client/styles/4-components.css +72 -0
  16. package/apps/studio/cloud/endpoints.ts +104 -9
  17. package/apps/studio/collab/persistence.ts +29 -2
  18. package/apps/studio/config.schema.json +3 -3
  19. package/apps/studio/context.ts +41 -0
  20. package/apps/studio/dist/client.bundle.js +1545 -1545
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/generation/gemma-models.ts +312 -14
  24. package/apps/studio/generation/prefs.ts +7 -2
  25. package/apps/studio/generation/runtime-probe.ts +50 -0
  26. package/apps/studio/generation/whisper-models.ts +124 -0
  27. package/apps/studio/hmr-broadcast.ts +67 -0
  28. package/apps/studio/http.ts +210 -110
  29. package/apps/studio/input-router.tsx +55 -2
  30. package/apps/studio/server.ts +11 -9
  31. package/apps/studio/sync/autocommit.ts +61 -2
  32. package/apps/studio/sync/cell-pairing.ts +174 -0
  33. package/apps/studio/sync/codec.ts +11 -5
  34. package/apps/studio/sync/index.ts +239 -26
  35. package/apps/studio/sync/limits.ts +49 -0
  36. package/apps/studio/sync/loopback.ts +21 -0
  37. package/apps/studio/sync/projection.ts +47 -12
  38. package/apps/studio/sync/supervisor.ts +178 -0
  39. package/apps/studio/test/cloud-endpoints.test.ts +326 -4
  40. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  41. package/apps/studio/test/gemma-models.test.ts +245 -0
  42. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  43. package/apps/studio/test/input-router.test.ts +95 -0
  44. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  45. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  46. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  47. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  48. package/apps/studio/test/whisper-setup.test.ts +97 -0
  49. package/apps/studio/whats-new.json +45 -0
  50. package/apps/studio/ws.ts +9 -1
  51. package/cli/commands/kg.mjs +9 -2
  52. package/package.json +8 -8
  53. package/plugins/design/dependencies.json +21 -3
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { useEffect, useRef, useState } from 'react';
16
16
 
17
- import { invoke, isNativeApp, listen } from '../github.js';
17
+ import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
18
18
 
19
19
  const api = async (path, init) => {
20
20
  const res = await fetch(path, init);
@@ -22,6 +22,87 @@ const api = async (path, init) => {
22
22
  return { ok: res.ok, status: res.status, json };
23
23
  };
24
24
 
25
+ /**
26
+ * Reach the OS browser from whichever surface this is running on.
27
+ *
28
+ * In the desktop shell `window.open` does nothing at all — WKWebView drops it
29
+ * silently — which is what used to strand a person mid-sign-in holding a code
30
+ * and no page to type it into. The shell therefore goes through the Rust
31
+ * opener, which zone-locks the URL against the configured cloud address (the
32
+ * webview is untrusted, DDR-054: there is deliberately no general opener).
33
+ *
34
+ * Best-effort by design: an older shell has no such command, a self-hosted
35
+ * origin can drift, and the browser can block the popup — so every caller ALSO
36
+ * shows the address as something the person can click or copy by hand.
37
+ */
38
+ /**
39
+ * Is this an address we are willing to render as a link at all?
40
+ *
41
+ * The zone lock lives in Rust, but the dialog SHOWS the address and offers to
42
+ * copy it — so an off-zone `verification_url` (hostile/compromised control
43
+ * plane, MITM'd plaintext self-host) would be presented in trusted app chrome
44
+ * with a label inviting the person to open it by hand. Refusing to render it is
45
+ * the only place that can be stopped (attacker pass 2026-08-04, B4).
46
+ */
47
+ export function isDisplayableUrl(url, cloudUrl) {
48
+ let u;
49
+ let base;
50
+ try {
51
+ u = new URL(String(url));
52
+ base = new URL(String(cloudUrl ?? DEFAULT_CLOUD_URL));
53
+ } catch {
54
+ return false;
55
+ }
56
+ if (!/^https?:$/.test(u.protocol)) return false;
57
+ // Same two arms the Rust opener enforces. A scheme check alone was NOT enough:
58
+ // `https://evil.example/activate?code=…` from a hostile or MITM'd control plane
59
+ // passed it and still rendered as a live href with a Copy button — and a
60
+ // middle-click or "Open link" never reaches the click handler, let alone Rust
61
+ // (attacker re-review 2026-08-04, NEW-3).
62
+ if (u.origin === base.origin) return true;
63
+ const host = u.hostname;
64
+ return (
65
+ u.protocol === 'https:' &&
66
+ !u.port &&
67
+ (u.pathname === '' || u.pathname === '/') &&
68
+ !u.search &&
69
+ !u.hash &&
70
+ (host === DEFAULT_CLOUD_HOST || host.endsWith(`.${DEFAULT_CLOUD_HOST}`))
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Reach the OS browser. Resolves to true when the open was accepted.
76
+ *
77
+ * A refusal must NOT be swallowed: the Rust side refusing is precisely the
78
+ * security event, and a silently dead click under a label reading "if your
79
+ * browser didn't open, go to:" teaches the person to copy the address into a
80
+ * browser the lock cannot see (B4). Callers surface the failure instead.
81
+ */
82
+ function openExternal(url) {
83
+ if (!url) return Promise.resolve('failed');
84
+ if (isNativeApp()) {
85
+ return openCloudUrl(url).then(
86
+ () => 'ok',
87
+ // A REFUSAL is the security event and reads differently from "no browser
88
+ // would start" — collapsing both into one boolean lost the distinction the
89
+ // dialog needs to say something true.
90
+ (err) => (/Refusing to open/i.test(String(err)) ? 'refused' : 'failed')
91
+ );
92
+ }
93
+ // `window.open(..., 'noopener')` returns null even on SUCCESS (that is the
94
+ // whole point of noopener), so its result says nothing about whether a tab
95
+ // opened. Treating null as failure made the browser build print "Maude
96
+ // couldn't open your browser" on every single sign-in — training people that
97
+ // the one warning this dialog has is noise (attacker re-review, NEW-2).
98
+ try {
99
+ window.open(url, '_blank', 'noopener');
100
+ return Promise.resolve('ok');
101
+ } catch {
102
+ return Promise.resolve('failed');
103
+ }
104
+ }
105
+
25
106
  /**
26
107
  * Parse a maude://open/<project>?code=mhc_… deep link. Untrusted input — a
27
108
  * drive-by page can navigate to maude:// — so nothing here is acted on
@@ -31,19 +112,174 @@ const api = async (path, init) => {
31
112
  export function parseDeepLink(url) {
32
113
  const m = /^maude:\/\/open\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?\?(.*)$/.exec(String(url ?? ''));
33
114
  if (!m) return null;
115
+ // The platform caps a project id at 40 chars (deriveProjectId), but that bound
116
+ // lives server-side and a drive-by maude:// link never passes through it — so
117
+ // without this, arbitrary attacker text renders in the dialog's heading, prose
118
+ // and aria-label. Nothing longer can name a real project anyway.
119
+ if (m[1].length > 40) return null;
34
120
  const code = new URLSearchParams(m[2]).get('code') ?? '';
35
121
  if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
36
122
  return { project: m[1], code };
37
123
  }
38
124
 
39
- /** The share view's address for a project cell URL (viewer's browser home). */
125
+ /**
126
+ * A viewer's browser home for a project — the project's OWN address.
127
+ *
128
+ * It used to derive `view-<host>`, which no longer exists: the read-only gallery
129
+ * was deleted in Cloud Phase 25 C5, and `tenantFromHostname` now hard-refuses any
130
+ * `view-` label (deliberately, so a leftover custom domain can't spin up a cell
131
+ * literally named `view-alligators`). So this returned a guaranteed 404 — which
132
+ * was invisible while `window.open` was a no-op in the desktop shell, and would
133
+ * have become a visibly dead menu item the moment the opener started working
134
+ * (attacker re-review 2026-08-04). The browser door replaced the gallery: the
135
+ * real project, for people who actually have access.
136
+ */
40
137
  export function shareViewUrl(projectUrl, projectId) {
41
138
  try {
42
139
  const u = new URL(projectUrl);
43
- return `${u.protocol}//view-${u.host}`;
140
+ return `${u.protocol}//${u.host}`;
44
141
  } catch {
45
- return `https://view-${projectId}.cloud.maude.sh`;
142
+ return `https://${projectId}.${DEFAULT_CLOUD_HOST}`;
143
+ }
144
+ }
145
+
146
+ /** Where Maude Cloud lives by default — mirrors the Rust opener's compiled host. */
147
+ const DEFAULT_CLOUD_URL = 'https://cloud.maude.sh';
148
+ const DEFAULT_CLOUD_HOST = 'cloud.maude.sh';
149
+
150
+ /**
151
+ * Fold a name the way the PLATFORM does, so a folder and a project id can be
152
+ * compared at all.
153
+ *
154
+ * This mirrors `deriveProjectId` (apps/cloud/checkout.mjs) deliberately, NFKD
155
+ * strip and 40-char cap included. A plain kebab-case fold looked equivalent and
156
+ * wasn't: `Zkušební tým` derives the id `zkusebni-tym` on the platform but folded
157
+ * to `zku-ebn-t-m` here, so every accented project name failed to match its own
158
+ * workspace and warned on a perfectly legitimate connect — and any name over 40
159
+ * characters did the same, because the platform truncates and this didn't. A
160
+ * warning that fires on correct behaviour is a warning people learn to dismiss.
161
+ */
162
+ const norm = (s) =>
163
+ String(s ?? '')
164
+ .normalize('NFKD')
165
+ .replace(/[̀-ͯ]/g, '')
166
+ .toLowerCase()
167
+ .replace(/[^a-z0-9]+/g, '-')
168
+ .replace(/^-+|-+$/g, '')
169
+ .replace(/-{2,}/g, '-')
170
+ .slice(0, 40)
171
+ .replace(/-+$/, '');
172
+
173
+ /** The host an address carries, or '' if it isn't a readable URL. */
174
+ const hostOf = (url) => {
175
+ try {
176
+ return new URL(url).hostname;
177
+ } catch {
178
+ return '';
179
+ }
180
+ };
181
+
182
+ /** The project label a cell address carries, or '' if it carries none. */
183
+ function projectFromHubUrl(hubUrl, cloudHost) {
184
+ const host = hostOf(hubUrl);
185
+ if (!cloudHost || host === cloudHost) return '';
186
+ // `<project>.cloud.maude.sh` — only meaningful INSIDE the configured zone.
187
+ if (!host.endsWith(`.${cloudHost}`)) return '';
188
+ return norm(host.slice(0, -(cloudHost.length + 1)).replace(/^view-/, ''));
189
+ }
190
+
191
+ /**
192
+ * Does this folder look like the cloud project a link is naming?
193
+ *
194
+ * A hint about a decision the person makes — never an authorization gate. It
195
+ * exists because the confusing half of "Open in Maude" is that it attaches
196
+ * whatever folder happens to be open, so somebody with one project open who
197
+ * presses it on another is one click from syncing the wrong designs into the
198
+ * wrong workspace.
199
+ *
200
+ * WHAT THIS FUNCTION LEARNED THE HARD WAY (attacker pass 2026-08-04, B1): the
201
+ * first version treated a near-match — either name containing the other — as
202
+ * agreement, to avoid crying wolf over `alligators-web` vs `alligators`. That
203
+ * reasoning assumed a NON-ADVERSARIAL name collision. It isn't one: the name
204
+ * being compared against is a cloud project id, which anyone can register. An
205
+ * attacker registers `alligators-design-sync`, it contains `alligators`, and the
206
+ * hint falls silent on exactly the case it exists for. The server's 409 does not
207
+ * stand behind it either, because the claim and the actual project agree — it
208
+ * genuinely IS the attacker's project.
209
+ *
210
+ * So a near-match no longer resolves to "fine". It gets its OWN state, and says
211
+ * out loud that two similar names are still two different workspaces. Only an
212
+ * exact match (or a credentialed link) is silent.
213
+ *
214
+ * States: 'linked' (a real, credentialed link to this project) · 'match' (the
215
+ * names are the same) · 'similar' (alike but NOT the same — warn, softly) ·
216
+ * 'mismatch' (unrelated, or linked elsewhere — warn) · 'unknown' (no signal:
217
+ * status hasn't resolved, or the addresses carry no project name, as on every
218
+ * self-host and e2e stub at `http://127.0.0.1:<port>`). 'unknown' warns too —
219
+ * quietly, because failing open here is what B3 was.
220
+ */
221
+ export function localIdentityHint({
222
+ localProject,
223
+ linkedHubUrl,
224
+ linkedHubCredentialed,
225
+ cloudUrl,
226
+ claimed,
227
+ resolved = true,
228
+ }) {
229
+ if (!resolved) return 'unknown';
230
+ const want = norm(claimed);
231
+ if (!want) return 'unknown';
232
+
233
+ // A linkedHub only reassures when a stored credential corroborates it — the
234
+ // config file it comes from is committed and travels with the repo (B2).
235
+ const linkedProject = linkedHubUrl ? projectFromHubUrl(linkedHubUrl, hostOf(cloudUrl)) : '';
236
+ if (linkedProject && linkedHubCredentialed) {
237
+ return linkedProject === want ? 'linked' : 'mismatch';
238
+ }
239
+
240
+ // Otherwise the folder's own name is the signal — the one that catches the
241
+ // reported case (one project open, "Open in Maude" pressed on another).
242
+ const local = norm(localProject);
243
+ if (!local) return 'unknown';
244
+ if (local === want) return 'match';
245
+ const shorter = local.length <= want.length ? local : want;
246
+ const related = shorter.length >= 4 && (local.includes(want) || want.includes(local));
247
+ return related ? 'similar' : 'mismatch';
248
+ }
249
+
250
+ /**
251
+ * What to say after a successful Connect.
252
+ *
253
+ * The old copy said "Linked to <X> — restart the studio server to start
254
+ * syncing", which was three problems in one line: it named a thing the person
255
+ * cannot see (there is no visible server in the desktop app), gave them a task
256
+ * instead of a result, and never explained what connecting had achieved. The
257
+ * server now cycles the sync runtime itself, so this reports an OUTCOME —
258
+ * and on the paths where syncing genuinely didn't start, it says which one and
259
+ * what to do, rather than prescribing a restart for every cause.
260
+ *
261
+ * Returns `{ text, title }` — `text` is the rail's one line, `title` the full
262
+ * sentence on hover (the rail truncates).
263
+ */
264
+ export function connectOutcomeNote(project, sync) {
265
+ const name = project || 'the workspace';
266
+ if (sync?.syncing) {
267
+ const n = sync.canvases ?? 0;
268
+ return {
269
+ text: `Syncing with ${name} — ${n} canvas${n === 1 ? '' : 'es'}.`,
270
+ title: `This project’s canvases now sync with the ${name} workspace. Live status is in the “hub sync” slot in the status bar.`,
271
+ };
46
272
  }
273
+ if (sync?.reason === 'nothing-syncable') {
274
+ return {
275
+ text: `Connected to ${name} — nothing to sync yet.`,
276
+ title: sync.detail || 'No canvases in this project are syncable yet.',
277
+ };
278
+ }
279
+ return {
280
+ text: `Connected to ${name} — ${sync?.detail ?? 'restart Maude to start syncing.'}`,
281
+ title: sync?.detail || 'Restart Maude to start syncing.',
282
+ };
47
283
  }
48
284
 
49
285
  function Spark({ size = 15 }) {
@@ -70,6 +306,14 @@ function Icon({ name, size = 15 }) {
70
306
  <path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
71
307
  </>
72
308
  ),
309
+ // Direction, not departure — the decision modal's two sides are joined by
310
+ // "flows into", and the `external` glyph read as "opens in the browser".
311
+ 'arrow-right': (
312
+ <>
313
+ <line x1="2.5" y1="8" x2="12.5" y2="8" />
314
+ <polyline points="9 4.5 12.5 8 9 11.5" />
315
+ </>
316
+ ),
73
317
  link: (
74
318
  <>
75
319
  <path d="M6.5 9.5l3-3" />
@@ -99,20 +343,45 @@ export default function CloudBar() {
99
343
  const [device, setDevice] = useState(null); // { userCode, verificationUrl, deviceCode }
100
344
  const [projects, setProjects] = useState(null);
101
345
  const [busy, setBusy] = useState('');
102
- const [note, setNote] = useState('');
346
+ const [note, setNote] = useState(null); // { text, title } — connectOutcomeNote
103
347
  const [error, setError] = useState('');
104
348
  const [copied, setCopied] = useState(false);
349
+ const [copiedLink, setCopiedLink] = useState(false);
350
+ const [openFailed, setOpenFailed] = useState(''); // '' | 'refused' | 'failed'
351
+ const [cloudUrl, setCloudUrl] = useState('https://cloud.maude.sh');
352
+ // `resolved` gates the deep-link dialog: rendering the decision before the
353
+ // data it turns on made the hint fail OPEN on the launch path (B3).
354
+ const [local, setLocal] = useState({ project: '', linkedHub: null, resolved: false });
105
355
  const [pending, setPending] = useState(null); // { project, code } from maude://
106
356
  const railRef = useRef(null);
107
357
  const pollRef = useRef(null);
108
358
 
109
359
  useEffect(() => {
110
- api('/_api/cloud/status').then((r) => {
111
- if (r.ok && r.json?.connected) {
112
- setEmail(r.json.email);
113
- setState('in');
114
- } else setState('out');
115
- });
360
+ api('/_api/cloud/status')
361
+ .then((r) => {
362
+ if (r.json?.url) setCloudUrl(r.json.url);
363
+ // Resolved even on an error body — what must never happen is staying
364
+ // un-resolved forever and leaving the dialog unreachable; an empty
365
+ // project name resolves to 'unknown', which warns rather than reassures.
366
+ setLocal({
367
+ project: r.json?.project ?? '',
368
+ linkedHub: r.json?.linkedHub ?? null,
369
+ resolved: true,
370
+ });
371
+ if (r.ok && r.json?.connected) {
372
+ setEmail(r.json.email);
373
+ setState('in');
374
+ } else setState('out');
375
+ })
376
+ .catch(() => {
377
+ // A rejected fetch (the sidecar restarting under a launch-path deep link)
378
+ // would otherwise leave `resolved` false forever — and since the dialog
379
+ // is gated on it, the parked link would never be shown at all, silently
380
+ // burning a one-shot two-minute code. Resolve into the 'unknown' branch,
381
+ // which warns, and let the rail fall back to signed-out.
382
+ setLocal((l) => ({ ...l, resolved: true }));
383
+ setState('out');
384
+ });
116
385
  return () => clearInterval(pollRef.current);
117
386
  }, []);
118
387
 
@@ -124,7 +393,7 @@ export default function CloudBar() {
124
393
  const consume = (url) => {
125
394
  const parsed = parseDeepLink(url);
126
395
  if (!parsed) return;
127
- // NEVER replace a link the person is already looking at. The strip is a
396
+ // NEVER replace a link the person is already looking at. The dialog is a
128
397
  // single slot, so an overwrite would swap the code out from under a
129
398
  // click that was aimed at the previous one — the person confirms the
130
399
  // name they read and connects the link that arrived last (validate
@@ -157,12 +426,24 @@ export default function CloudBar() {
157
426
  setBusy('');
158
427
  setPending(null);
159
428
  if (r.ok && r.json?.ok) {
160
- setNote(`Linked to ${r.json.project ?? pending.project} — restart the studio server to start syncing.`);
429
+ setNote(connectOutcomeNote(r.json.project ?? pending.project, r.json.sync));
161
430
  } else {
162
431
  setError(r.json?.error || 'The workspace could not be connected.');
163
432
  }
164
433
  }
165
434
 
435
+ // Escape answers the decision modal the same way the scrim does: Not now.
436
+ // Declining must always be the cheap move — the dashboard re-mints a code on
437
+ // the next "Open in Maude".
438
+ useEffect(() => {
439
+ if (!pending) return undefined;
440
+ const onKey = (e) => {
441
+ if (e.key === 'Escape') setPending(null);
442
+ };
443
+ document.addEventListener('keydown', onKey);
444
+ return () => document.removeEventListener('keydown', onKey);
445
+ }, [pending]);
446
+
166
447
  useEffect(() => {
167
448
  if (!menuOpen) return undefined;
168
449
  const onDoc = (e) => {
@@ -180,7 +461,8 @@ export default function CloudBar() {
180
461
  return;
181
462
  }
182
463
  setDevice(r.json);
183
- window.open(r.json.verificationUrl, '_blank', 'noopener');
464
+ setOpenFailed('');
465
+ openExternal(r.json.verificationUrl).then((s) => setOpenFailed(s === 'ok' ? '' : s));
184
466
  pollRef.current = setInterval(async () => {
185
467
  const p = await api('/_api/cloud/signin/poll', {
186
468
  method: 'POST',
@@ -221,7 +503,7 @@ export default function CloudBar() {
221
503
 
222
504
  async function connect(projectId) {
223
505
  setBusy(projectId);
224
- setNote('');
506
+ setNote(null);
225
507
  setError('');
226
508
  const r = await api('/_api/cloud/attach', {
227
509
  method: 'POST',
@@ -230,7 +512,7 @@ export default function CloudBar() {
230
512
  });
231
513
  setBusy('');
232
514
  if (r.ok && r.json?.ok) {
233
- setNote(`Linked to ${projectId} — restart the studio server to start syncing.`);
515
+ setNote(connectOutcomeNote(r.json.project ?? projectId, r.json.sync));
234
516
  } else {
235
517
  setError(r.json?.error || 'The workspace could not be connected.');
236
518
  }
@@ -245,41 +527,158 @@ export default function CloudBar() {
245
527
  setState('out');
246
528
  }
247
529
 
248
- function copyCode() {
249
- if (!device?.userCode) return;
250
- navigator.clipboard?.writeText(device.userCode).then(
530
+ // One clipboard write, one 1.5 s "Copied" flash. The code and the link are the
531
+ // same gesture, so they share the mechanism — and both swallow a refusal
532
+ // (no clipboard permission) silently: the value is on screen either way.
533
+ function copyToClipboard(text, mark) {
534
+ if (!text) return;
535
+ navigator.clipboard?.writeText(text).then(
251
536
  () => {
252
- setCopied(true);
253
- setTimeout(() => setCopied(false), 1500);
537
+ mark(true);
538
+ setTimeout(() => mark(false), 1500);
254
539
  },
255
540
  () => {}
256
541
  );
257
542
  }
258
543
 
544
+ const copyCode = () => copyToClipboard(device?.userCode, setCopied);
545
+ const copyLink = () => copyToClipboard(device?.verificationUrl, setCopiedLink);
546
+
259
547
  if (state === 'loading') return null;
260
548
 
549
+ const hint = pending
550
+ ? localIdentityHint({
551
+ localProject: local.project,
552
+ linkedHubUrl: local.linkedHub?.url,
553
+ linkedHubCredentialed: local.linkedHub?.credentialed,
554
+ cloudUrl,
555
+ claimed: pending.project,
556
+ resolved: local.resolved,
557
+ })
558
+ : 'unknown';
559
+ // Anything but a clean, corroborated agreement gets the warning treatment and
560
+ // a demoted Connect. Silence is reserved for the two cases we can vouch for.
561
+ const reassured = hint === 'linked' || hint === 'match';
562
+
261
563
  return (
262
564
  <div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
263
- {pending && (
264
- <div className="gi-rail-confirm" data-testid="cloud-deeplink">
265
- <span className="gi-rail-hint">Connect this project to {pending.project}?</span>
266
- <button
267
- type="button"
268
- className="btn btn--sm"
269
- disabled={busy === 'deeplink'}
270
- onClick={connectPending}
271
- data-testid="cloud-deeplink-connect"
272
- >
273
- {busy === 'deeplink' ? 'Connecting…' : 'Connect'}
274
- </button>
275
- <button
276
- type="button"
277
- className="btn btn--ghost btn--sm"
278
- onClick={() => setPending(null)}
279
- data-testid="cloud-deeplink-dismiss"
280
- >
281
- Not now
282
- </button>
565
+ {/* Not until `local` has resolved: the launch-path deep link can beat the
566
+ status fetch, and a modal rendered without the local half showed no
567
+ warning and a primary Connect — failing OPEN in the one scenario with
568
+ the least context (attacker pass B3). */}
569
+ {pending && local.resolved && (
570
+ <div
571
+ className="gi-modal"
572
+ role="dialog"
573
+ aria-modal="true"
574
+ aria-label={`Connect this folder to ${pending.project}`}
575
+ >
576
+ <div className="gi-scrim" aria-hidden="true" onClick={() => setPending(null)} />
577
+ <div className="gi-dialog gi-dialog--code" data-testid="cloud-deeplink-dialog">
578
+ <div className="gi-dc-head">
579
+ <span className="gi-dc-marks"><Icon name="link" size={24} /></span>
580
+ <h2>Connect to {pending.project}?</h2>
581
+ </div>
582
+ {/* Both sides, named. The old strip said "Connect this project to X?"
583
+ — which never told anyone WHICH project "this" was, the one thing
584
+ the decision turns on. */}
585
+ <div className="gi-dl-pair">
586
+ <span className="gi-dl-side">
587
+ <span className="gi-dl-lbl">This folder</span>
588
+ <span className="gi-dl-val" data-testid="cloud-deeplink-local">{local.project || 'the open project'}</span>
589
+ </span>
590
+ <span className="gi-dl-arrow" aria-hidden="true"><Icon name="arrow-right" size={16} /></span>
591
+ <span className="gi-dl-side">
592
+ <span className="gi-dl-lbl">Cloud project</span>
593
+ <span className="gi-dl-val">{pending.project}</span>
594
+ </span>
595
+ </div>
596
+ <p className="gi-dl-what">
597
+ This folder’s <code>.design/</code> canvases will sync with the {pending.project}{' '}
598
+ workspace. Nothing else in this repo is uploaded.
599
+ </p>
600
+ {hint === 'linked' && (
601
+ <p className="gi-dl-note">This folder is already linked to {pending.project} — connecting refreshes the link.</p>
602
+ )}
603
+ {!reassured && (
604
+ <div className="gi-dl-warn" data-testid="cloud-deeplink-mismatch">
605
+ <strong>
606
+ {hint === 'similar' ? (
607
+ <>
608
+ {local.project} and {pending.project} are different workspaces.
609
+ </>
610
+ ) : hint === 'unknown' ? (
611
+ <>Maude can’t tell which project this folder is.</>
612
+ ) : (
613
+ <>
614
+ This folder looks like {local.project || 'something else'}, not{' '}
615
+ {pending.project}.
616
+ </>
617
+ )}
618
+ </strong>
619
+ <span>
620
+ Connecting will sync <em>this folder’s</em> designs into {pending.project}. If you
621
+ meant to work on {pending.project}, open its folder in Maude and press “Open in
622
+ Maude” again. Don’t have it on this machine? It opens in{' '}
623
+ {isDisplayableUrl(cloudUrl, cloudUrl) ? (
624
+ <a
625
+ className="gi-dc-link-a"
626
+ href={cloudUrl}
627
+ rel="noopener noreferrer"
628
+ target="_blank"
629
+ onClick={(e) => {
630
+ e.preventDefault();
631
+ openExternal(cloudUrl);
632
+ }}
633
+ >
634
+ your browser
635
+ </a>
636
+ ) : (
637
+ 'your browser'
638
+ )}
639
+ .
640
+ </span>
641
+ </div>
642
+ )}
643
+ <div className="gi-dc-foot">
644
+ {/* On a mismatch the safe action leads and connecting is the one you
645
+ have to mean. No project switcher lives here on purpose: the
646
+ handoff code is one-shot with a two-minute life, and a switch
647
+ restarts the server — the person would come back to a dead code
648
+ and a question they already answered. */}
649
+ {!reassured ? (
650
+ <>
651
+ <button type="button" className="btn" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
652
+ Not now
653
+ </button>
654
+ <button
655
+ type="button"
656
+ className="btn btn--ghost"
657
+ disabled={busy === 'deeplink'}
658
+ onClick={connectPending}
659
+ data-testid="cloud-deeplink-connect"
660
+ >
661
+ {busy === 'deeplink' ? 'Connecting…' : 'Connect anyway'}
662
+ </button>
663
+ </>
664
+ ) : (
665
+ <>
666
+ <button
667
+ type="button"
668
+ className="btn"
669
+ disabled={busy === 'deeplink'}
670
+ onClick={connectPending}
671
+ data-testid="cloud-deeplink-connect"
672
+ >
673
+ {busy === 'deeplink' ? 'Connecting…' : 'Connect'}
674
+ </button>
675
+ <button type="button" className="btn btn--ghost" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
676
+ Not now
677
+ </button>
678
+ </>
679
+ )}
680
+ </div>
681
+ </div>
283
682
  </div>
284
683
  )}
285
684
  {state === 'out' && (
@@ -306,7 +705,11 @@ export default function CloudBar() {
306
705
  <span className="gi-rail-login">{email ?? 'Maude Cloud'}</span>
307
706
  <span className="gi-rail-caret"><Icon name="chevron-up" size={13} /></span>
308
707
  </button>
309
- {note && <span className="gi-rail-hint" title={note}>{note}</span>}
708
+ {note && (
709
+ <span className="gi-rail-hint" title={note.title} data-testid="cloud-connect-note">
710
+ {note.text}
711
+ </span>
712
+ )}
310
713
  {error && <span className="gi-rail-err" title={error}>{error}</span>}
311
714
  {menuOpen && (
312
715
  <div className="gi-menu" role="menu" aria-label="Maude Cloud">
@@ -332,7 +735,7 @@ export default function CloudBar() {
332
735
  type="button"
333
736
  className="gi-menu-item"
334
737
  role="menuitem"
335
- onClick={() => window.open(shareViewUrl(p.url, p.id), '_blank', 'noopener')}
738
+ onClick={() => openExternal(shareViewUrl(p.url, p.id))}
336
739
  title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
337
740
  data-testid={`cloud-project-${p.id}`}
338
741
  >
@@ -356,7 +759,7 @@ export default function CloudBar() {
356
759
  )
357
760
  )}
358
761
  <div className="gi-menu-sep" />
359
- <button type="button" className="gi-menu-item" role="menuitem" onClick={() => window.open('https://cloud.maude.sh', '_blank', 'noopener')}>
762
+ <button type="button" className="gi-menu-item" role="menuitem" onClick={() => openExternal(cloudUrl)}>
360
763
  <Icon name="external" size={15} /> Open the dashboard
361
764
  </button>
362
765
  <button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
@@ -374,7 +777,11 @@ export default function CloudBar() {
374
777
  <div className="gi-dc-head">
375
778
  <span className="gi-dc-marks"><Spark size={26} /></span>
376
779
  <h2>Sign in to Maude Cloud</h2>
377
- <p>Maude opened your dashboard in the browser. Confirm this code there to connect.</p>
780
+ {/* Says what to DO, not what supposedly already happened. The old
781
+ copy claimed the browser had opened — a claim this surface
782
+ cannot make, since the shell's open is best-effort and the
783
+ person is the one who finds out it didn't. */}
784
+ <p>Confirm this code on your Maude Cloud dashboard to connect.</p>
378
785
  </div>
379
786
  <div className="gi-code">
380
787
  <span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
@@ -382,6 +789,59 @@ export default function CloudBar() {
382
789
  <Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
383
790
  </button>
384
791
  </div>
792
+ {/* The manual path, always present — never conditional on whether the
793
+ open "worked". It is the only thing standing between an older
794
+ shell / a drifted self-host origin / a blocked popup and a dead
795
+ end. The address already carries the code (`/activate?code=…`),
796
+ so following it is one click, not a re-type. */}
797
+ {device.verificationUrl && isDisplayableUrl(device.verificationUrl, cloudUrl) && (
798
+ <div className="gi-dc-link">
799
+ <span className="gi-dc-link-lbl">
800
+ {openFailed === 'failed'
801
+ ? 'Maude couldn’t open your browser. Open this address yourself:'
802
+ : 'If your browser didn’t open, go to:'}
803
+ </span>
804
+ <div className="gi-dc-link-row">
805
+ <a
806
+ className="gi-dc-url gi-dc-link-a"
807
+ href={device.verificationUrl}
808
+ rel="noopener noreferrer"
809
+ target="_blank"
810
+ data-testid="cloud-signin-link"
811
+ onClick={(e) => {
812
+ e.preventDefault();
813
+ openExternal(device.verificationUrl).then((s) =>
814
+ setOpenFailed(s === 'ok' ? '' : s)
815
+ );
816
+ }}
817
+ >
818
+ {device.verificationUrl}
819
+ </a>
820
+ <button type="button" className="btn btn--ghost btn--sm" onClick={copyLink} aria-label="Copy the link">
821
+ <Icon name="copy" size={14} /> {copiedLink ? 'Copied' : 'Copy link'}
822
+ </button>
823
+ </div>
824
+ </div>
825
+ )}
826
+ {openFailed === 'refused' && (
827
+ // A REFUSAL is not "your browser didn't open" — it is Maude saying
828
+ // that address isn't Maude Cloud. Saying so, and NOT offering to
829
+ // help open it, is the whole difference (attacker re-review NEW-2).
830
+ <div className="gi-dl-warn" data-testid="cloud-signin-refused">
831
+ <strong>Maude wouldn’t open that address.</strong>
832
+ <span>It isn’t a Maude Cloud address. Sign in on your dashboard instead.</span>
833
+ </div>
834
+ )}
835
+ {device.verificationUrl && !isDisplayableUrl(device.verificationUrl, cloudUrl) && (
836
+ // An address we won't put behind a link or a copy button. Naming it
837
+ // is the point — silently dropping it would look like a bug.
838
+ <div className="gi-dl-warn" data-testid="cloud-signin-badurl">
839
+ <strong>Maude Cloud sent an address that doesn’t look right.</strong>
840
+ <span>
841
+ Nothing was opened. Sign in on your dashboard directly instead of following it.
842
+ </span>
843
+ </div>
844
+ )}
385
845
  <div className="gi-dc-status" aria-live="polite">
386
846
  <span className="gi-pulse" aria-hidden="true" />
387
847
  <span>Waiting for you to confirm in the browser…</span>