@1agh/maude 1.2.0 → 1.3.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 (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
@@ -0,0 +1,91 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { notify } from '../notifications.tsx';
3
+
4
+ /** Modal keyboard handling shared by the share and file-link decisions. */
5
+ export function useLinkDialogFocus(onClose) {
6
+ const ref = useRef(null);
7
+ const closeRef = useRef(onClose);
8
+ closeRef.current = onClose;
9
+ useEffect(() => {
10
+ const previous = document.activeElement;
11
+ const el = ref.current;
12
+ const controls = () => [...el.querySelectorAll('button:not(:disabled), a[href], input:not(:disabled)')];
13
+ controls()[0]?.focus();
14
+ const key = (event) => {
15
+ if (event.key === 'Escape') {
16
+ event.preventDefault();
17
+ event.stopImmediatePropagation();
18
+ closeRef.current();
19
+ } else if (event.key === 'Tab') {
20
+ const items = controls();
21
+ const first = items[0];
22
+ const last = items.at(-1);
23
+ if (event.shiftKey && (document.activeElement === first || !el.contains(document.activeElement))) {
24
+ event.preventDefault(); last?.focus();
25
+ } else if (!event.shiftKey && (document.activeElement === last || !el.contains(document.activeElement))) {
26
+ event.preventDefault(); first?.focus();
27
+ }
28
+ }
29
+ };
30
+ document.addEventListener('keydown', key, true);
31
+ return () => { document.removeEventListener('keydown', key, true); previous?.focus?.(); };
32
+ }, []);
33
+ return ref;
34
+ }
35
+
36
+ export async function copyShareLink(value) {
37
+ try {
38
+ await navigator.clipboard.writeText(value);
39
+ notify({ title: 'Link copied', kind: 'success' });
40
+ return true;
41
+ } catch {
42
+ notify({ title: 'Could not copy link', description: 'Select the address and copy it manually.', kind: 'error' });
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function ShareDialog({ target, links, shell, onClose, Icon }) {
48
+ const ref = useLinkDialogFocus(onClose);
49
+ const [copied, setCopied] = useState(null);
50
+ const timer = useRef(null);
51
+ useEffect(() => () => clearTimeout(timer.current), []);
52
+ const copy = async (kind, value) => {
53
+ if (await copyShareLink(value)) {
54
+ setCopied(kind);
55
+ clearTimeout(timer.current);
56
+ timer.current = setTimeout(() => setCopied(null), 1500);
57
+ }
58
+ };
59
+ return (
60
+ <div className="st-scrim" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
61
+ <div ref={ref} className="st-dialog st-share-dialog" role="dialog" aria-modal="true" aria-label={`Share ${target.label}`} data-testid="share-dialog">
62
+ <div className="st-dialog-hd">
63
+ <span className="st-dialog-title">Share {target.label}</span>
64
+ <button type="button" className="st-iconbtn" aria-label="Close share dialog" onClick={onClose}><Icon name="x" size={15} /></button>
65
+ </div>
66
+ <div className="st-dialog-bd">
67
+ <p className="st-share-path">{target.rel}</p>
68
+ {!links.web && <p className="st-dialog-note">Connect this project to Maude Cloud to get a web link.</p>}
69
+ {[
70
+ ['web', 'Web link', links.web],
71
+ ['app', 'Open in Maude app', links.app],
72
+ ['local', 'Local link (this Mac only)', links.local],
73
+ ].filter(([, , value]) => value).map(([kind, label, value]) => (
74
+ <div key={kind} className="st-share-link">
75
+ <label className="st-dialog-lbl" htmlFor={`share-${kind}-url`}>{label}</label>
76
+ <div className="st-dialog-row">
77
+ <input id={`share-${kind}-url`} data-testid={`share-${kind}-url`} readOnly value={value} onFocus={(e) => e.target.select()} />
78
+ <button type="button" className="btn btn--ghost" aria-label={`Copy ${label}`} data-testid={`share-copy-${kind}`} onClick={() => copy(kind, value)}>
79
+ <Icon name={copied === kind ? 'check' : 'link'} size={14} />{copied === kind ? 'Copied' : 'Copy'}
80
+ </button>
81
+ </div>
82
+ {kind === 'app' && links.appIsLocalOnly && <p className="st-share-hint">Opens the folder on this Mac only.</p>}
83
+ {kind === 'app' && shell !== 'native' && <a href={value} data-testid="share-open-app">Open in app</a>}
84
+ </div>
85
+ ))}
86
+ {links.web && <p className="st-share-hint">People need access to this project to open the link.</p>}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,94 @@
1
+ // Shell addresses use the same design-root-relative file identity as canvas URLs.
2
+ // RETURN_RULES twin: apps/hub/src/return-to.mjs; native: project_resolve.rs.
3
+ const PROJECT_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
4
+
5
+ /** Validate an already decoded file identity. Never decode a filesystem path. */
6
+ export function normalizeOpenPath(raw, designRel = '.design') {
7
+ if (typeof raw !== 'string' || !raw || raw.length > 512) return null;
8
+ if (/^[a-z]:/i.test(raw) || raw.includes('\\')) return null;
9
+ if ([...raw].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) return null;
10
+ const prefix = `${designRel.replace(/^\.\//, '').replace(/\/$/, '')}/`;
11
+ const rel = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
12
+ return rel.split('/').some((s) => s === '' || s === '.' || s === '..') ? null : rel;
13
+ }
14
+
15
+ /** Decode a URL value exactly once, then validate. */
16
+ export function normalizeOpenParam(raw, designRel = '.design') {
17
+ if (typeof raw !== 'string') return null;
18
+ try {
19
+ return normalizeOpenPath(decodeURIComponent(raw), designRel);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export function readOpenParam(location, designRel = '.design') {
26
+ const values = new URLSearchParams(location.search).getAll('open');
27
+ // URLSearchParams already decoded the value; a second decode changes filenames.
28
+ return values.length === 1 ? normalizeOpenPath(values[0], designRel) : null;
29
+ }
30
+
31
+ export const encodeOpenPath = (rel) => rel.split('/').map(encodeURIComponent).join('/');
32
+
33
+ export function withOpenParam(location, rel) {
34
+ const path = normalizeOpenPath(rel);
35
+ return path ? `?open=${encodeOpenPath(path)}` : location.pathname;
36
+ }
37
+
38
+ function httpUrl(raw) {
39
+ try {
40
+ const u = new URL(raw);
41
+ return /^https?:$/.test(u.protocol) && !u.username && !u.password ? u : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function projectIdFromHubUrl(raw) {
48
+ const host = httpUrl(raw)?.hostname;
49
+ if (!host || host.includes(':') || /^[\d.]+$/.test(host) || host.endsWith('.localhost')) return null;
50
+ const labels = host.split('.');
51
+ return labels.length >= 3 && PROJECT_RE.test(labels[0]) ? labels[0] : null;
52
+ }
53
+
54
+ export function projectSlug(name) {
55
+ const slug = String(name ?? '').split('/').pop().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '');
56
+ return PROJECT_RE.test(slug) ? slug : null;
57
+ }
58
+
59
+ /** A configured hub identity takes precedence over the folder's fallback name. */
60
+ export function localIdentityMatches(local, project) {
61
+ const linked = projectIdFromHubUrl(local.linkedHub?.url);
62
+ return linked ? linked === project : projectSlug(local.project) === project;
63
+ }
64
+
65
+ export function buildShareLinks({ rel, shell, location, linkedHubUrl, project, localUrl }) {
66
+ const path = normalizeOpenPath(rel);
67
+ const result = { web: null, app: null, local: null, projectLabel: project ?? '', appIsLocalOnly: false };
68
+ if (!path) return result;
69
+ const query = `?open=${encodeOpenPath(path)}`;
70
+ const publicUrl = (value) => {
71
+ const url = httpUrl(value);
72
+ return url && !/^(localhost|127\.[\d.]+|\[::1\])$/.test(url.hostname) && !url.hostname.endsWith('.localhost') ? url : null;
73
+ };
74
+ const hub = publicUrl(linkedHubUrl);
75
+ const cloud = shell === 'cloud' ? publicUrl(location?.origin) : null;
76
+ const publicId = projectIdFromHubUrl(cloud?.origin ?? hub?.origin);
77
+ const id = publicId ?? projectSlug(project);
78
+ result.projectLabel = id ?? project ?? '';
79
+ result.web = cloud ? `${cloud.origin}${location.pathname}${query}` : hub ? `${hub.origin}/${query}` : null;
80
+ result.app = id ? `maude://open/${id}${query}` : null;
81
+ result.appIsLocalOnly = !!id && !publicId;
82
+ const local = shell !== 'cloud' && httpUrl(localUrl);
83
+ result.local = local ? `${local.origin}/${query}` : null;
84
+ return result;
85
+ }
86
+
87
+ export function parseFileDeepLink(raw) {
88
+ const m = /^maude:\/\/open\/([a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)\/?\?([^#]*)$/.exec(String(raw ?? ''));
89
+ if (!m) return null;
90
+ const params = new URLSearchParams(m[2]);
91
+ if ([...params.keys()].some((key) => key !== 'open')) return null;
92
+ const rel = readOpenParam({ search: m[2] });
93
+ return rel ? { project: m[1], rel } : null;
94
+ }
@@ -584,25 +584,7 @@
584
584
  .st-pal-icon { width: 26px; height: 26px; border-radius: var(--radius-sm); background: var(--bg-4); display: grid; place-items: center; color: var(--fg-1); flex: none; }
585
585
  .st-pal-item.is-active .st-pal-icon { background: var(--accent); color: var(--accent-fg); }
586
586
 
587
- /* ─── What's-new toast (Plan C P6) ─────────────────────────────────────────
588
- * Lifted from Studio.css `.st-toast*`; repositioned fixed bottom-left for the
589
- * real shell (the mockup floated it inside the stage). Mirrors ai-banner. */
590
- .st-toast {
591
- /* bottom raised above the in-canvas zoom pill (`.dc-zoom-tb`, bottom-left). */
592
- position: fixed; left: 16px; bottom: 84px; width: 320px; z-index: 8000;
593
- background: var(--bg-2); border: 1px solid var(--border-default); border-left: 3px solid var(--accent);
594
- border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: var(--space-4);
595
- display: flex; flex-direction: column; gap: var(--space-2);
596
- animation: st-toast-in var(--dur-route) var(--ease-out);
597
- }
598
- @keyframes st-toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
599
- @media (prefers-reduced-motion: reduce) { .st-toast { animation-duration: 1ms; } }
600
- .st-toast-hd { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
601
- .st-toast-title { font-size: var(--type-md); font-weight: 600; color: var(--fg-0); }
602
- .st-toast-txt { font-size: var(--type-sm); color: var(--fg-1); line-height: 1.5; }
603
587
  .st-toast-actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
604
- .st-toast-close { position: absolute; top: var(--space-2); right: var(--space-2); appearance: none; border: 0; background: transparent; color: var(--fg-3); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 6px; border-radius: var(--radius-xs); }
605
- .st-toast-close:hover { background: var(--bg-3); color: var(--fg-0); }
606
588
 
607
589
  /* ─── Export & handoff dialog (Plan C P9/P10) ──────────────────────────────
608
590
  * Lifted from Studio.css `.st-dialog*` / `.st-fmt*`. Rendered inside `.st-scrim`. */
@@ -2197,6 +2179,8 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2197
2179
  .gp-version-msg { color: var(--fg-0); font-size: var(--type-sm); overflow: hidden; text-overflow: ellipsis; }
2198
2180
  .gp-version-meta { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); margin-top: 2px; }
2199
2181
  .gp-version-when { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); white-space: nowrap; transition: opacity var(--dur-soft) ease; }
2182
+ /* accepted revisions (T27/T28) — a row carries its own actions, so it is not a button */
2183
+ .gp-version-actions { grid-column: 2 / -1; display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: calc(-1 * var(--space-1)); }
2200
2184
 
2201
2185
  /* phase-27.1 — per-file History rows are click-to-preview when a canvas is open */
2202
2186
  .gp-history-scope { color: var(--fg-1); font-size: var(--type-xs); padding: var(--space-1) var(--space-2) var(--space-2); margin: 0; }
@@ -2648,6 +2632,39 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2648
2632
  .ob-spin { animation-duration: 1ms; }
2649
2633
  }
2650
2634
 
2635
+ /* ── Team projects (plan T22) — the designer's way in: recent managed copies,
2636
+ Maude Cloud projects, a team server sign-in. Composes the ob-* door parts; the
2637
+ dialog frame reuses gi-modal/gi-dialog from the project switcher. ── */
2638
+ .gi-dl-open { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); padding-bottom: var(--space-4); border-bottom: 1px solid var(--border-default); }
2639
+ .sp-ai-held { display: flex; flex-direction: column; gap: var(--space-2); }
2640
+ .sp-ai-actions { display: flex; gap: var(--space-2); }
2641
+ /* ── SourceConflictPanel (plan T28) — two versions, line by line. ── */
2642
+ .gi-dialog.scp-dialog { width: min(760px, 94vw); max-height: min(820px, 90vh); overflow: hidden; display: flex; flex-direction: column; padding: var(--space-6); gap: var(--space-4); }
2643
+ .scp-legend { display: flex; gap: var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
2644
+ .scp-key::before { content: ''; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: var(--space-2); vertical-align: -1px; }
2645
+ .scp-key--mine::before { background: color-mix(in oklch, var(--status-warn) 45%, transparent); }
2646
+ .scp-key--theirs::before { background: color-mix(in oklch, var(--status-info) 45%, transparent); }
2647
+ .scp-diff { flex: 1; min-height: 120px; overflow: auto; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-1); font-family: var(--font-mono); font-size: var(--type-xs); line-height: 1.55; }
2648
+ .scp-diff:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2649
+ .scp-line { display: flex; gap: var(--space-2); padding: 0 var(--space-3); white-space: pre; }
2650
+ .scp-line code { font: inherit; color: var(--fg-1); }
2651
+ .scp-mark { width: 1ch; color: var(--fg-3); flex: none; }
2652
+ .scp-line--mine { background: color-mix(in oklch, var(--status-warn) 14%, transparent); }
2653
+ .scp-line--theirs { background: color-mix(in oklch, var(--status-info) 14%, transparent); }
2654
+ .scp-line--skip { color: var(--fg-3); font-style: italic; }
2655
+ .scp-line--skip code { color: var(--fg-3); }
2656
+ .scp-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
2657
+ .scp-hint { font-size: var(--type-xs); color: var(--fg-2); }
2658
+ .scp-error { margin: 0; color: var(--status-error); font-size: var(--type-sm); }
2659
+ .tp { display: flex; flex-direction: column; gap: var(--space-5); max-width: 720px; }
2660
+ .tp-section { display: flex; flex-direction: column; gap: var(--space-3); }
2661
+ .tp-device { display: flex; flex-direction: column; gap: var(--space-3); max-width: 560px; }
2662
+ .tp-device .ob-form-actions { align-items: center; justify-content: space-between; }
2663
+ .gi-dialog.tp-dialog { width: min(620px, 94vw); max-height: min(760px, 90vh); overflow: auto; padding: var(--space-6); gap: var(--space-4); }
2664
+ .tp-dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
2665
+ .tp-dialog-head h2 { margin: 0; font-size: var(--type-lg); color: var(--fg-0); }
2666
+ .tp-dialog-head p { margin: var(--space-1) 0 0; color: var(--fg-2); font-size: var(--type-sm); }
2667
+
2651
2668
  /* ── Phase 29 (E4) — RepoBranchSwitcher. REDESIGNED to the approved mockup
2652
2669
  (.design/ui/RepoBranchSwitcher.tsx): a compact ONE-LINE dock at the BOTTOM of the
2653
2670
  sidebar (above the IdentityBar avatar) that opens ONE popup UPWARD. Mirrors the
@@ -1454,7 +1454,7 @@
1454
1454
  animation-duration: 1ms;
1455
1455
  }
1456
1456
  }
1457
- .st-toast .st-export-progress {
1457
+ .maude-notice .st-export-progress {
1458
1458
  margin-top: var(--space-1, 4px);
1459
1459
  }
1460
1460
  /* Busy state for the Save…/Download button — the bytes fetch + (native) the
@@ -2183,3 +2183,20 @@
2183
2183
  opacity: 0.6;
2184
2184
  font-size: 0.9em;
2185
2185
  }
2186
+
2187
+ /* Issue #103: full export diagnostics are opt-in and stay inside the panel. */
2188
+ .st-export-item__error { min-width: 0; width: 100%; }
2189
+ .st-export-item__error summary { cursor: pointer; }
2190
+ .st-export-item__error pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow: auto; font: inherit; }
2191
+ .st-export-item:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
2192
+
2193
+ /* Share links reuse the shell dialog material. */
2194
+ .st-share-dialog { max-height: 90vh; overflow-y: auto; }
2195
+ .st-share-path, .st-share-hint { margin: 0; color: var(--fg-2); font-size: var(--type-xs); overflow-wrap: anywhere; }
2196
+ .st-share-link { display: flex; flex-direction: column; gap: var(--space-2); }
2197
+ .st-share-link input { flex: 1; min-width: 0; padding: var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); color: var(--fg-0); font: inherit; }
2198
+ .st-share-link a { color: var(--fg-1); font-size: var(--type-xs); }
2199
+ .st-share-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--fg-2); cursor: pointer; }
2200
+ .st-share-btn:hover { background: var(--bg-2); color: var(--fg-0); }
2201
+ .st-share-btn:disabled { opacity: .4; cursor: default; }
2202
+ .st-share-btn:focus-visible, .st-share-link input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -9,7 +9,24 @@
9
9
  // only `position` is overridden inline since this menu anchors to an
10
10
  // arbitrary row, not the menubar's fixed offset.
11
11
 
12
- import { useEffect, useRef, useState } from 'react';
12
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
13
+
14
+ const EDGE = 8;
15
+
16
+ /**
17
+ * Where a menu of `size` goes so it stays on screen: below the anchor when it
18
+ * fits, else above it, else pinned to the viewport edge (scrolling inside).
19
+ * A row near the bottom of a long tree used to open its menu off-screen.
20
+ */
21
+ export function placeMenu(anchor, size, viewport) {
22
+ let top = anchor.bottom + 4;
23
+ if (top + size.height > viewport.height - EDGE) {
24
+ const above = anchor.top - 4 - size.height;
25
+ top = above >= EDGE ? above : Math.max(EDGE, viewport.height - EDGE - size.height);
26
+ }
27
+ const left = Math.max(EDGE, Math.min(anchor.left, viewport.width - EDGE - size.width));
28
+ return { top, left, maxHeight: viewport.height - 2 * EDGE };
29
+ }
13
30
 
14
31
  /**
15
32
  * One shared menu instance per tree (mirrors useTreeDrag — only one row's
@@ -26,7 +43,14 @@ export function useRowMenu() {
26
43
  e.stopPropagation();
27
44
  const anchorEl = e.currentTarget;
28
45
  const r = anchorEl.getBoundingClientRect();
29
- setState({ x: r.left, y: r.bottom + 4, anchorEl, view: 'root', extra });
46
+ setState({
47
+ x: r.left,
48
+ y: r.bottom + 4,
49
+ anchor: { top: r.top, bottom: r.bottom, left: r.left },
50
+ anchorEl,
51
+ view: 'root',
52
+ extra,
53
+ });
30
54
  };
31
55
  const close = () => {
32
56
  setState((s) => {
@@ -46,6 +70,25 @@ export function useRowMenu() {
46
70
  */
47
71
  export function TreeRowMenu({ state, onClose, rootItems, destinations, onPickDestination }) {
48
72
  const ref = useRef(null);
73
+ const [placed, setPlaced] = useState(null);
74
+ const view = state?.view;
75
+ useLayoutEffect(() => {
76
+ const el = ref.current;
77
+ if (!state || !el) {
78
+ setPlaced(null);
79
+ return;
80
+ }
81
+ const anchor = state.anchor ?? { top: state.y - 4, bottom: state.y - 4, left: state.x };
82
+ setPlaced(
83
+ placeMenu(
84
+ anchor,
85
+ { width: el.offsetWidth, height: el.scrollHeight },
86
+ { width: window.innerWidth, height: window.innerHeight }
87
+ )
88
+ );
89
+ // Re-place when the view switches (Move to… is a different height).
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [state?.anchorEl, view]);
49
92
  useEffect(() => {
50
93
  if (!state) return;
51
94
  const el = ref.current;
@@ -79,7 +122,9 @@ export function TreeRowMenu({ state, onClose, rootItems, destinations, onPickDes
79
122
  }, [state, onClose]);
80
123
 
81
124
  if (!state) return null;
82
- const style = { position: 'fixed', left: state.x, top: state.y };
125
+ const style = placed
126
+ ? { position: 'fixed', left: placed.left, top: placed.top, maxHeight: placed.maxHeight, overflowY: 'auto' }
127
+ : { position: 'fixed', left: state.x, top: state.y };
83
128
 
84
129
  if (state.view === 'move-to') {
85
130
  return (
@@ -9,6 +9,7 @@
9
9
  // (entry.tour[]) land in Phase 3 — the panel/toast leave a hook for them.
10
10
 
11
11
  import { useCallback, useEffect, useState } from 'react';
12
+ import { dismissNotice, notify } from '../notifications.tsx';
12
13
  import { bestSeenVersion, computeUnseen } from './whats-new-seen.js';
13
14
 
14
15
  const WN_SEEN = 'mdcc-whatsnew-seen';
@@ -143,37 +144,28 @@ export function WhatsNewBadge({ count = 0, onOpen }) {
143
144
  }
144
145
 
145
146
  export function WhatsNewToast({ wn }) {
146
- if (!wn.showToast || !wn.toastEntry) return null;
147
- const e = wn.toastEntry;
148
- const total = wn.unseen.length;
149
- // Plan C P6 — restyled to the maude `.st-toast` family (was `mdcc-wn-toast`),
150
- // matching `.design/ui/Studio.tsx` AB-D. Behavior unchanged.
151
- return (
152
- <div className="st-toast" role="status" aria-live="polite">
153
- <button
154
- type="button"
155
- className="st-toast-close"
156
- aria-label="Dismiss"
157
- onClick={wn.dismissToast}
158
- >
159
- ×
160
- </button>
161
- <div className="st-toast-hd">
162
- <span aria-hidden="true">✦</span>
163
- What's new{e.version ? ` · v${e.version}` : ''}
164
- </div>
165
- <div className="st-toast-title">{e.title}</div>
166
- <div className="st-toast-txt">{e.summary}</div>
167
- <div className="st-toast-actions">
168
- <button type="button" className="btn btn--primary btn--sm" onClick={wn.openPanel}>
169
- {total > 1 ? `See all (${total})` : 'Details'}
170
- </button>
171
- <button type="button" className="btn btn--ghost btn--sm" onClick={wn.dismissToast}>
172
- Dismiss
173
- </button>
174
- </div>
175
- </div>
176
- );
147
+ const id = `whats-new-${wn.feedVersion}`;
148
+ useEffect(() => {
149
+ if (!wn.showToast || !wn.toastEntry) {
150
+ dismissNotice(id);
151
+ return;
152
+ }
153
+ notify({
154
+ id,
155
+ group: 'whats-new',
156
+ kind: 'info',
157
+ duration: 10_000,
158
+ title: `What's new${wn.toastEntry.version ? ` · v${wn.toastEntry.version}` : ''} — ${wn.toastEntry.title}`,
159
+ description: wn.toastEntry.summary,
160
+ action: {
161
+ label: wn.unseen.length > 1 ? `See all (${wn.unseen.length})` : 'Details',
162
+ onClick: wn.openPanel,
163
+ },
164
+ onDismiss: wn.dismissToast,
165
+ });
166
+ }, [id, wn.showToast, wn.toastEntry, wn.unseen.length, wn.openPanel, wn.dismissToast]);
167
+ useEffect(() => () => dismissNotice(id), [id]);
168
+ return null;
177
169
  }
178
170
 
179
171
  export function WhatsNewPanel({ wn, onStartTour }) {
@@ -229,7 +221,9 @@ export function WhatsNewPanel({ wn, onStartTour }) {
229
221
  <div className="mdcc-wn-item__hd">
230
222
  <span className={'mdcc-wn-kind mdcc-wn-kind--' + e.kind}>{e.kind}</span>
231
223
  <span className="mdcc-wn-item__title">{e.title}</span>
232
- <span className="mdcc-wn-item__ver">{e.version ? `v${e.version}` : 'next'}</span>
224
+ <span className="mdcc-wn-item__ver">
225
+ {e.version ? `v${e.version}` : 'next'}
226
+ </span>
233
227
  </div>
234
228
  <p className="mdcc-wn-item__summary">{e.summary}</p>
235
229
  {isSafeHref(e.learnMore) && (
@@ -309,6 +309,75 @@ export function createCloudEndpoints(ctx: Ctx) {
309
309
  });
310
310
  },
311
311
 
312
+ /**
313
+ * Open a project as a MANAGED local copy (T21): the same open → cell
314
+ * exchange → stored credential as `attach`, but nothing is written into
315
+ * the folder this studio happens to serve — the desktop creates the
316
+ * project's own copy and switches to it.
317
+ */
318
+ async openManaged(projectId: string): Promise<CloudEndpointResult> {
319
+ const file = readCloudFile();
320
+ if (!file)
321
+ return { status: 401, json: { ok: false, error: 'Sign in to Maude Cloud first.' } };
322
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(projectId ?? ''))) {
323
+ return { status: 400, json: { ok: false, error: 'unknown project' } };
324
+ }
325
+ const opened = await cloudFetch('/projects/open', {
326
+ method: 'POST',
327
+ headers: { authorization: `Bearer ${file.token}`, 'content-type': 'application/json' },
328
+ body: JSON.stringify({ project: projectId }),
329
+ });
330
+ if (opened.status !== 200 || !opened.body?.token || !opened.body.url) {
331
+ return {
332
+ status: 502,
333
+ json: { ok: false, error: 'The project could not be opened with this account.' },
334
+ };
335
+ }
336
+ return exchangeAtCell({
337
+ workspaceUrl: opened.body.url,
338
+ projectToken: opened.body.token,
339
+ role: opened.body.role,
340
+ project: projectId,
341
+ });
342
+ },
343
+
344
+ /** The maude:// handoff lane, as a managed copy (see `attachCode`). */
345
+ async openManagedCode(code: string, claimedProject?: string): Promise<CloudEndpointResult> {
346
+ if (typeof code !== 'string' || !/^mhc_[0-9a-f]{16,128}$/.test(code)) {
347
+ return { status: 400, json: { ok: false, error: 'That link is not valid.' } };
348
+ }
349
+ const exchanged = await cloudFetch('/auth/handoff/exchange', {
350
+ method: 'POST',
351
+ headers: { 'content-type': 'application/json' },
352
+ body: JSON.stringify({ code }),
353
+ });
354
+ if (exchanged.status !== 200 || !exchanged.body?.token || !exchanged.body.url) {
355
+ return {
356
+ status: 410,
357
+ json: {
358
+ ok: false,
359
+ error:
360
+ 'That link expired — it works once, for two minutes. Press “Open in Maude” on the project page again.',
361
+ },
362
+ };
363
+ }
364
+ if (claimedProject && exchanged.body.project && claimedProject !== exchanged.body.project) {
365
+ return {
366
+ status: 409,
367
+ json: {
368
+ ok: false,
369
+ error: `That link said ${claimedProject} but it opens ${exchanged.body.project}. Nothing was opened — open the project from its own page instead.`,
370
+ },
371
+ };
372
+ }
373
+ return exchangeAtCell({
374
+ workspaceUrl: exchanged.body.url,
375
+ projectToken: exchanged.body.token,
376
+ role: exchanged.body.role,
377
+ project: exchanged.body.project,
378
+ });
379
+ },
380
+
312
381
  /**
313
382
  * Attach via a one-time handoff code — the maude:// lane (Phase 17). The
314
383
  * code came from an untrusted URL, so it is exchanged ONLY against the
@@ -664,6 +733,69 @@ export function createCloudEndpoints(ctx: Ctx) {
664
733
  }
665
734
  }
666
735
 
736
+ /**
737
+ * The cell exchange alone: project token → hub session → stored credential.
738
+ * Writes nothing into any project folder (a managed open creates its own).
739
+ */
740
+ async function exchangeAtCell({
741
+ workspaceUrl,
742
+ projectToken,
743
+ role,
744
+ project,
745
+ }: {
746
+ workspaceUrl: string;
747
+ projectToken: string;
748
+ role?: string;
749
+ project?: string;
750
+ }): Promise<CloudEndpointResult> {
751
+ const r = await exchangeCredential(workspaceUrl, projectToken);
752
+ if (!r) {
753
+ return {
754
+ status: 502,
755
+ json: {
756
+ ok: false,
757
+ error: 'The workspace did not accept the sign-in. Try again in a minute.',
758
+ },
759
+ };
760
+ }
761
+ return {
762
+ status: 200,
763
+ json: { ok: true, url: r.url, role: r.role ?? role ?? null, project: project ?? null },
764
+ };
765
+ }
766
+
767
+ async function exchangeCredential(
768
+ workspaceUrl: string,
769
+ projectToken: string
770
+ ): Promise<{ url: string; role?: string } | null> {
771
+ let hubToken: string | null = null;
772
+ let hubTokenExpiresAt: number | undefined;
773
+ let vouchedRole: string | undefined;
774
+ try {
775
+ const res = await fetch(`${workspaceUrl}/auth/login`, {
776
+ method: 'POST',
777
+ headers: { 'content-type': 'application/json' },
778
+ body: JSON.stringify({ token: projectToken }),
779
+ });
780
+ const body = (await res.json().catch(() => ({}))) as CloudBody & {
781
+ expiresAt?: unknown;
782
+ user?: { role?: unknown };
783
+ };
784
+ if (res.ok && body?.token) {
785
+ hubToken = body.token;
786
+ if (typeof body.expiresAt === 'number' && Number.isFinite(body.expiresAt))
787
+ hubTokenExpiresAt = body.expiresAt;
788
+ if (typeof body.user?.role === 'string' && body.user.role) vouchedRole = body.user.role;
789
+ }
790
+ } catch {
791
+ /* handled by the caller */
792
+ }
793
+ if (!hubToken) return null;
794
+ const norm = normalizeUrl(workspaceUrl);
795
+ saveHubCredential(norm, hubToken, vouchedRole, hubTokenExpiresAt);
796
+ return { url: norm, role: vouchedRole };
797
+ }
798
+
667
799
  /** The shared tail of every attach: cell exchange → credential + linkedHub. */
668
800
  async function linkToWorkspace({
669
801
  workspaceUrl,
@@ -64,6 +64,10 @@ export function createCollab(ctx: Context, api: Api): Collab {
64
64
  fileForSlug,
65
65
  shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
66
66
  });
67
+ // Accepted revisions: browser writes to a synced canvas's room are refused
68
+ // (the room doc is the hub's accepted replica). Asked per frame — the save
69
+ // mode can change while the studio runs.
70
+ persistence.acceptedMode = () => ctx.syncControl?.current?.()?.acceptedMode?.() === true;
67
71
  const registry = createRegistry(persistence);
68
72
  registryRef = registry;
69
73
 
@@ -284,7 +284,11 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
284
284
  const svg = map.get('svg');
285
285
  if (typeof svg === 'string' && svg) {
286
286
  if (withinCap(slug, 'annotations', svg, MAX_ANNOTATIONS_BYTES)) {
287
- await api.saveAnnotations(file, svg);
287
+ // Projection must never re-enter onAnnotationsChanged: an old flush
288
+ // finishing after a new edit otherwise republishes the old SVG and
289
+ // rolls back every peer. The API checks freshness after async IO and
290
+ // before its atomic rename; a later doc update schedules a new flush.
291
+ await api.projectAnnotations(file, svg, () => map.get('svg') === svg);
288
292
  }
289
293
  }
290
294
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Awareness } from 'y-protocols/awareness';
10
10
 
11
+ import { ANNOTATION_WRITE_ID, validAnnotationWriteId } from '../annotations-sync.ts';
11
12
  import { applyCommentsToDoc } from '../sync/codec.ts';
12
13
  import { bridgeAwareness } from './awareness-bridge.ts';
13
14
  import { Y_TYPES } from './persistence.ts';
@@ -52,7 +53,7 @@ export interface Registry {
52
53
  * post-write SVG; the room replaces `Y.Map.svg` so collab peers see the
53
54
  * updated stroke set without waiting for a cold-open re-seed.
54
55
  */
55
- syncRoomFromAnnotations(slug: string, svg: string): void;
56
+ syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void;
56
57
  /**
57
58
  * Phase 30 — project agent editing-presence onto a slug's room awareness so
58
59
  * it crosses the hub (the loopback `ai-activity` bus event does not). `null`
@@ -192,13 +193,18 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
192
193
  applyCommentsToDoc(room.doc, comments as unknown[], 'inspector-write');
193
194
  }
194
195
 
195
- function syncRoomFromAnnotations(slug: string, svg: string): void {
196
+ function syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void {
196
197
  const room = rooms.get(slug);
197
198
  if (!room) return;
198
199
  const map = room.doc.getMap<string>(Y_TYPES.annotations);
199
- if (map.get('svg') === svg) return; // no-op guard — same rationale as comments
200
+ // Identical disk notifications remain no-ops. A new UI operation may
201
+ // deliberately restore identical content while an author has pending work.
202
+ const id = validAnnotationWriteId(writeId) ? writeId : undefined;
203
+ if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return;
200
204
  room.doc.transact(() => {
201
205
  map.set('svg', svg);
206
+ if (id) map.set(ANNOTATION_WRITE_ID, id);
207
+ else map.delete(ANNOTATION_WRITE_ID);
202
208
  }, 'inspector-write');
203
209
  }
204
210