@1agh/maude 1.4.4 → 1.4.6
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.
- package/apps/studio/annotations-model.ts +33 -1
- package/apps/studio/api.ts +19 -0
- package/apps/studio/canvas-comment-mount.tsx +48 -3
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +110 -14
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +108 -60
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/collab/index.ts +9 -0
- package/apps/studio/collab/persistence.ts +49 -1
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +181 -107
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +95 -7
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +101 -2
- package/apps/studio/sync/index.ts +124 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +13 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +45 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -170,7 +170,9 @@ function CanvasView({ src, view, setView, label }) {
|
|
|
170
170
|
className="dv-frame-wrap"
|
|
171
171
|
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
|
|
172
172
|
>
|
|
173
|
-
|
|
173
|
+
{/* A read-only preview: out of the tab order, so the modal's focus
|
|
174
|
+
never falls into a document whose keys the shell cannot see. */}
|
|
175
|
+
<iframe className="dv-frame" src={src} title={label} tabIndex={-1} />
|
|
174
176
|
</div>
|
|
175
177
|
) : (
|
|
176
178
|
<div className="dv-thumb-note">No saved version of this canvas to compare yet.</div>
|
|
@@ -236,6 +238,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
236
238
|
// HEAD), then re-pointable in-place via the "Saved version" picker.
|
|
237
239
|
const tgtFile = target?.file;
|
|
238
240
|
const tgtBeforeSha = target?.beforeSha;
|
|
241
|
+
const acceptedPreview = /^r\d+$/.test(tgtBeforeSha || '');
|
|
239
242
|
const [beforeSha, setBeforeSha] = useState(tgtBeforeSha || 'HEAD');
|
|
240
243
|
const [versions, setVersions] = useState(null); // per-file saved versions, or null while loading
|
|
241
244
|
|
|
@@ -253,7 +256,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
253
256
|
let cancelled = false;
|
|
254
257
|
(async () => {
|
|
255
258
|
const entries = (await loadLog(tgtFile)) || [];
|
|
256
|
-
if (!cancelled) setVersions(entries);
|
|
259
|
+
if (!cancelled) setVersions(Array.isArray(entries) ? entries : []);
|
|
257
260
|
})();
|
|
258
261
|
return () => {
|
|
259
262
|
cancelled = true;
|
|
@@ -263,16 +266,38 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
263
266
|
useEffect(() => {
|
|
264
267
|
if (!target) return;
|
|
265
268
|
const opener = document.activeElement;
|
|
269
|
+
// A modal keeps the keyboard inside it (S19): Tab / Shift+Tab cycle the
|
|
270
|
+
// sheet's own controls, and Escape is taken before anything behind it.
|
|
271
|
+
const controls = () =>
|
|
272
|
+
[
|
|
273
|
+
...(sheetRef.current?.querySelectorAll(
|
|
274
|
+
'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'
|
|
275
|
+
) ?? []),
|
|
276
|
+
].filter((el) => !el.closest('[hidden]'));
|
|
266
277
|
const onKey = (e) => {
|
|
267
278
|
if (e.key === 'Escape') {
|
|
268
279
|
e.preventDefault();
|
|
280
|
+
e.stopImmediatePropagation();
|
|
269
281
|
onClose();
|
|
282
|
+
} else if (e.key === 'Tab') {
|
|
283
|
+
const items = controls();
|
|
284
|
+
if (!items.length) return;
|
|
285
|
+
const inside = sheetRef.current?.contains(document.activeElement);
|
|
286
|
+
const first = items[0];
|
|
287
|
+
const last = items[items.length - 1];
|
|
288
|
+
if (e.shiftKey && (!inside || document.activeElement === first || document.activeElement === sheetRef.current)) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
last.focus();
|
|
291
|
+
} else if (!e.shiftKey && (!inside || document.activeElement === last)) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
first.focus();
|
|
294
|
+
}
|
|
270
295
|
}
|
|
271
296
|
};
|
|
272
|
-
|
|
297
|
+
document.addEventListener('keydown', onKey, true);
|
|
273
298
|
sheetRef.current?.focus();
|
|
274
299
|
return () => {
|
|
275
|
-
|
|
300
|
+
document.removeEventListener('keydown', onKey, true);
|
|
276
301
|
if (opener instanceof HTMLElement) opener.focus();
|
|
277
302
|
};
|
|
278
303
|
}, [target, onClose]);
|
|
@@ -335,12 +360,13 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
335
360
|
<label className="dv-verpick">
|
|
336
361
|
<span className="dv-verpick-lbl">Compare against</span>
|
|
337
362
|
<select
|
|
363
|
+
data-testid="history-preview-version"
|
|
338
364
|
className="dv-verpick-sel"
|
|
339
365
|
value={beforeSha}
|
|
340
366
|
onChange={(e) => setBeforeSha(e.target.value)}
|
|
341
367
|
aria-label={`Saved version of ${baseName(file)} to compare against`}
|
|
342
368
|
>
|
|
343
|
-
<option value="HEAD">Last saved</option>
|
|
369
|
+
{!acceptedPreview && <option value="HEAD">Last saved</option>}
|
|
344
370
|
{versions.map((v) => (
|
|
345
371
|
<option key={v.sha} value={v.sha}>
|
|
346
372
|
{`${v.message || 'Saved version'} · ${timeAgo(v.date)}`}
|
|
@@ -506,7 +532,7 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
506
532
|
<div className="dv-col">
|
|
507
533
|
<div className="dv-col-hd">
|
|
508
534
|
<span className="dv-col-tag is-after">Your version</span>
|
|
509
|
-
<span className="dv-col-who">now · unsaved</span>
|
|
535
|
+
<span className="dv-col-who">{acceptedPreview ? 'now · working copy' : 'now · unsaved'}</span>
|
|
510
536
|
</div>
|
|
511
537
|
<div className="dv-thumb is-after">
|
|
512
538
|
<CanvasView
|
|
@@ -559,17 +585,20 @@ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, o
|
|
|
559
585
|
<button
|
|
560
586
|
type="button"
|
|
561
587
|
className="btn btn--ghost btn--sm"
|
|
588
|
+
data-testid="history-preview-restore"
|
|
562
589
|
disabled={busy}
|
|
563
590
|
onClick={async () => {
|
|
564
591
|
if (
|
|
565
592
|
!window.confirm(
|
|
566
|
-
|
|
593
|
+
acceptedPreview
|
|
594
|
+
? `Restore version ${beforeSha.slice(1)} of “${baseName(file)}” as a new saved version?`
|
|
595
|
+
: `Restore the saved version of “${baseName(file)}”? Your unsaved changes to it are discarded.`
|
|
567
596
|
)
|
|
568
597
|
)
|
|
569
598
|
return;
|
|
570
599
|
setBusy(true);
|
|
571
600
|
try {
|
|
572
|
-
await onRestore(file);
|
|
601
|
+
await onRestore(file, beforeSha);
|
|
573
602
|
} finally {
|
|
574
603
|
setBusy(false);
|
|
575
604
|
}
|
|
@@ -182,6 +182,7 @@ export default function GitPanel({
|
|
|
182
182
|
// is swapped at the same call site; this prop is only how the panel knows
|
|
183
183
|
// which repo the rows it just received describe, so it can name it.
|
|
184
184
|
historySource = 'local',
|
|
185
|
+
historyRefresh = 0, // applied project revisions and restores invalidate this list
|
|
185
186
|
// `{ branch, project, hubHost }` — what the cell reported alongside the log.
|
|
186
187
|
// Null until the first successful cloud load.
|
|
187
188
|
cloudHistory = null,
|
|
@@ -465,7 +466,7 @@ export default function GitPanel({
|
|
|
465
466
|
useEffect(() => {
|
|
466
467
|
setLogFailed(false);
|
|
467
468
|
setLogScope(undefined);
|
|
468
|
-
}, [historySource]);
|
|
469
|
+
}, [historySource, historyRefresh]);
|
|
469
470
|
|
|
470
471
|
function toggleAll() {
|
|
471
472
|
setUnchecked((prev) => (prev.size === 0 ? new Set(files.map((f) => f.path)) : new Set()));
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
//
|
|
13
13
|
// No hashes, epochs or lane names: "Your version" / "The project's version".
|
|
14
14
|
|
|
15
|
-
import { useEffect, useMemo,
|
|
15
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
16
|
+
import { useLinkDialogFocus } from '../share-dialog.jsx';
|
|
16
17
|
|
|
17
18
|
/** Line diff by longest common subsequence — bounded, for a canvas source. */
|
|
18
19
|
export function lineDiff(a, b, max = 2000) {
|
|
@@ -63,7 +64,7 @@ export default function SourceConflictPanel({ slug, onClose }) {
|
|
|
63
64
|
const [sides, setSides] = useState(null);
|
|
64
65
|
const [error, setError] = useState('');
|
|
65
66
|
const [busy, setBusy] = useState('');
|
|
66
|
-
const ref =
|
|
67
|
+
const ref = useLinkDialogFocus(onClose);
|
|
67
68
|
|
|
68
69
|
useEffect(() => {
|
|
69
70
|
let alive = true;
|
|
@@ -80,19 +81,6 @@ export default function SourceConflictPanel({ slug, onClose }) {
|
|
|
80
81
|
};
|
|
81
82
|
}, [slug]);
|
|
82
83
|
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
const prev = document.activeElement;
|
|
85
|
-
ref.current?.querySelector('button')?.focus();
|
|
86
|
-
const onKey = (e) => {
|
|
87
|
-
if (e.key === 'Escape') onClose();
|
|
88
|
-
};
|
|
89
|
-
document.addEventListener('keydown', onKey);
|
|
90
|
-
return () => {
|
|
91
|
-
document.removeEventListener('keydown', onKey);
|
|
92
|
-
prev?.focus?.();
|
|
93
|
-
};
|
|
94
|
-
}, [onClose]);
|
|
95
|
-
|
|
96
84
|
const rows = useMemo(() => (sides ? withContext(lineDiff(sides.mine ?? '', sides.theirs ?? '')) : []), [sides]);
|
|
97
85
|
|
|
98
86
|
async function choose(choice) {
|
|
@@ -120,8 +108,8 @@ export default function SourceConflictPanel({ slug, onClose }) {
|
|
|
120
108
|
<div className="gi-dialog scp-dialog" ref={ref} data-testid="source-conflict-panel">
|
|
121
109
|
<div className="tp-dialog-head">
|
|
122
110
|
<div>
|
|
123
|
-
<h2 id="scp-title">
|
|
124
|
-
<p>Your
|
|
111
|
+
<h2 id="scp-title">Review changes to {slug}</h2>
|
|
112
|
+
<p>Your draft has not been shared. Compare it with the project’s version before choosing what to keep.</p>
|
|
125
113
|
</div>
|
|
126
114
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => onClose()} aria-label="Close" data-testid="source-conflict-close">
|
|
127
115
|
×
|
|
@@ -129,6 +117,20 @@ export default function SourceConflictPanel({ slug, onClose }) {
|
|
|
129
117
|
</div>
|
|
130
118
|
{sides && (
|
|
131
119
|
<>
|
|
120
|
+
<div className="scp-recovery">
|
|
121
|
+
<details>
|
|
122
|
+
<summary>Original draft</summary>
|
|
123
|
+
{sides.original !== null && sides.original !== undefined ? (
|
|
124
|
+
<pre tabIndex={0} role="region" aria-label="Original draft" data-testid="source-conflict-original">{sides.original}</pre>
|
|
125
|
+
) : <p>The original draft is not available on this device.</p>}
|
|
126
|
+
</details>
|
|
127
|
+
<details>
|
|
128
|
+
<summary>Starting version</summary>
|
|
129
|
+
{sides.base !== null && sides.base !== undefined ? (
|
|
130
|
+
<pre tabIndex={0} role="region" aria-label="Starting version" data-testid="source-conflict-base">{sides.base}</pre>
|
|
131
|
+
) : <p>The version this draft started from is not known.</p>}
|
|
132
|
+
</details>
|
|
133
|
+
</div>
|
|
132
134
|
<div className="scp-legend" aria-hidden="true">
|
|
133
135
|
<span className="scp-key scp-key--mine">Your version</span>
|
|
134
136
|
<span className="scp-key scp-key--theirs">The project’s version</span>
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
.st-mb-file b { color: var(--fg-0); font-weight: 600; }
|
|
81
81
|
.st-mb-sep { width: 1px; height: 16px; background: var(--border-default); }
|
|
82
82
|
.st-mb-count { display: inline-flex; align-items: center; gap: var(--space-2); font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-1); }
|
|
83
|
-
.st-mb-proj { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--
|
|
83
|
+
.st-mb-proj { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); }
|
|
84
84
|
.st-reportbug {
|
|
85
85
|
appearance: none; border: 0; background: transparent; cursor: pointer;
|
|
86
86
|
width: 26px; height: 26px; display: grid; place-items: center; border-radius: var(--radius-sm);
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-1); z-index: 30;
|
|
194
194
|
}
|
|
195
195
|
.st-sb-slot { display: inline-flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
|
196
|
-
.st-sb-slot .lbl { color: var(--fg-
|
|
196
|
+
.st-sb-slot .lbl { color: var(--fg-2); text-transform: uppercase; letter-spacing: var(--tracking-wide); font-size: 10px; flex: none; }
|
|
197
197
|
.st-sb-slot .val { color: var(--fg-0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
198
198
|
.st-sb-slot + .st-sb-slot { padding-left: var(--space-4); border-left: 1px solid var(--border-subtle); }
|
|
199
199
|
.st-sb-active { font-weight: 500; }
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
|
|
226
226
|
/* The running release. Ambient, not a state to act on — so it sits at the same
|
|
227
227
|
weight as a slot label rather than at .val's foreground. No new tokens. */
|
|
228
|
-
.st-sb-version .val { color: var(--fg-
|
|
228
|
+
.st-sb-version .val { color: var(--fg-2); }
|
|
229
229
|
|
|
230
230
|
/* ═══ Task 4 — body row · sidebar · tree · rail ═══════════════════════════════ */
|
|
231
231
|
|
|
@@ -243,7 +243,7 @@
|
|
|
243
243
|
.st-dockslot > .st-rpanel,
|
|
244
244
|
.st-dockslot > .st-sidebar { flex: 1 1 auto; width: 100%; min-height: 0; }
|
|
245
245
|
.st-docktabs { display: flex; gap: 2px; padding: var(--space-2) var(--space-2) 0; border-bottom: 1px solid var(--border-subtle); background: var(--bg-1); flex: none; }
|
|
246
|
-
.st-docktab { appearance: none; border: 0; background: transparent; cursor: pointer; padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm) var(--radius-sm) 0 0; color: var(--fg-
|
|
246
|
+
.st-docktab { appearance: none; border: 0; background: transparent; cursor: pointer; padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm) var(--radius-sm) 0 0; color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); transition: color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
|
|
247
247
|
.st-docktab:hover { color: var(--fg-1); background: var(--bg-2); }
|
|
248
248
|
.st-docktab.is-active { color: var(--fg-0); background: var(--bg-2); box-shadow: inset 0 -2px 0 var(--accent); }
|
|
249
249
|
.st-docktab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
@@ -308,8 +308,10 @@
|
|
|
308
308
|
accent language as .st-row.is-drop-target. */
|
|
309
309
|
.st-tree-sec-hd.is-drop-target { background: var(--accent-tint); color: var(--fg-0); box-shadow: inset 2px 0 0 var(--accent); }
|
|
310
310
|
.st-tree-sec-hd .st-sec-name { font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
|
311
|
+
/* --fg-2, not --fg-3: a 10px count is text, and --fg-3 is 3.0:1 on the
|
|
312
|
+
sidebar — below WCAG AA (axe on the cloud cell, F3 S19, 2026-09-24). */
|
|
311
313
|
.st-tree-sec-hd .st-pill {
|
|
312
|
-
margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--fg-
|
|
314
|
+
margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--fg-2);
|
|
313
315
|
padding: 0 6px; border: 1px solid var(--border-subtle); border-radius: var(--radius-pill);
|
|
314
316
|
}
|
|
315
317
|
.st-tree-empty { padding: var(--space-2) var(--space-5); color: var(--fg-3); font-size: var(--type-xs); font-style: italic; }
|
|
@@ -357,6 +359,8 @@
|
|
|
357
359
|
}
|
|
358
360
|
/* delete affordance — sibling of the row button (can't nest buttons). */
|
|
359
361
|
.st-row-wrap { position: relative; display: flex; align-items: center; }
|
|
362
|
+
.st-tree-item:focus-visible { outline: none; }
|
|
363
|
+
.st-tree-item:focus-visible > .st-row-wrap { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--radius-sm); }
|
|
360
364
|
.st-row-wrap .st-row { flex: 1; }
|
|
361
365
|
.st-row-del {
|
|
362
366
|
position: absolute; right: var(--space-2); top: 50%; transform: translateY(-50%);
|
|
@@ -564,6 +568,10 @@
|
|
|
564
568
|
.st-banner-close:hover { background: color-mix(in oklab, var(--fg-0) 12%, transparent); color: var(--fg-0); }
|
|
565
569
|
|
|
566
570
|
/* ─── Command palette (⌘K) ───────────────────────────────────────────────── */
|
|
571
|
+
/* The informational role notice sits BELOW a modal's scrim (9000): it covered
|
|
572
|
+
the preview dialog's own header (S19). Warnings and errors keep
|
|
573
|
+
.st-banner's 9100 and stay visible over a dialog. */
|
|
574
|
+
.st-banner.st-banner--info { z-index: 8990; }
|
|
567
575
|
.st-scrim { position: fixed; inset: 0; background: color-mix(in oklab, var(--bg-0) 62%, transparent); z-index: 9000; display: grid; place-items: start center; padding-top: 12vh; }
|
|
568
576
|
.st-palette {
|
|
569
577
|
width: 560px; max-width: 92vw; background: var(--bg-2); border: 1px solid var(--border-default);
|
|
@@ -1563,7 +1571,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1563
1571
|
}
|
|
1564
1572
|
.st-skel-cap {
|
|
1565
1573
|
font-size: var(--type-xs); letter-spacing: var(--tracking-wide);
|
|
1566
|
-
text-transform: uppercase; color: var(--fg-
|
|
1574
|
+
text-transform: uppercase; color: var(--fg-2);
|
|
1567
1575
|
}
|
|
1568
1576
|
.skel {
|
|
1569
1577
|
display: block;
|
|
@@ -1744,7 +1752,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
1744
1752
|
.gp-panel-title { font-family: var(--font-display); font-weight: 600; font-size: var(--type-md); color: var(--fg-0); flex: none; }
|
|
1745
1753
|
.gp-count { font-family: var(--font-mono); font-size: var(--type-xs); font-weight: 600; padding: 1px var(--space-2); border-radius: var(--radius-pill); background: var(--accent-tint); color: var(--accent); border: 1px solid var(--accent-muted); white-space: nowrap; flex: none; }
|
|
1746
1754
|
.gp-spacer { flex: 1; min-width: var(--space-2); }
|
|
1747
|
-
.gp-draft { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-
|
|
1755
|
+
.gp-draft { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-1); display: inline-flex; align-items: center; gap: var(--space-1); padding: 1px var(--space-2); border: 1px solid var(--border-default); border-radius: var(--radius-sm); background: var(--bg-2); white-space: nowrap; min-width: 0; flex: 0 1 auto; }
|
|
1748
1756
|
.gp-draft b { color: var(--fg-0); font-weight: 600; overflow: hidden; text-overflow: ellipsis; max-width: 90px; }
|
|
1749
1757
|
.gp-draft .gp-sep { color: var(--fg-3); }
|
|
1750
1758
|
.gp-x { appearance: none; border: none; background: transparent; color: var(--fg-2); font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 5px; border-radius: var(--radius-xs); }
|
|
@@ -2263,7 +2271,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2263
2271
|
.dv-zoom-btn:hover { background: var(--bg-3); color: var(--fg-0); }
|
|
2264
2272
|
.dv-zoom-val { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); min-width: 44px; text-align: center; cursor: pointer; appearance: none; border: none; background: transparent; }
|
|
2265
2273
|
.dv-zoom-val:hover { color: var(--fg-0); }
|
|
2266
|
-
.dv-zoom-sync { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-
|
|
2274
|
+
.dv-zoom-sync { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); padding-left: var(--space-2); border-left: 1px solid var(--border-subtle); margin-left: var(--space-1); }
|
|
2267
2275
|
|
|
2268
2276
|
/* overlay / slider compare (shares the locked view; frozen layers for the wipe) */
|
|
2269
2277
|
.dv-overlay { --dv-split: 52%; position: relative; flex: 1; min-height: 0; margin: 0 auto; width: 100%; max-width: 900px; background: var(--bg-0); border: 1px solid var(--border-default); border-radius: var(--radius-md); overflow: hidden; }
|
|
@@ -2639,7 +2647,11 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
|
|
|
2639
2647
|
.sp-ai-held { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
2640
2648
|
.sp-ai-actions { display: flex; gap: var(--space-2); }
|
|
2641
2649
|
/* ── SourceConflictPanel (plan T28) — two versions, line by line. ── */
|
|
2642
|
-
.gi-dialog.scp-dialog { width: min(760px, 94vw); max-height: min(820px, 90vh); overflow:
|
|
2650
|
+
.gi-dialog.scp-dialog { width: min(760px, 94vw); max-height: min(820px, 90vh); overflow: auto; display: flex; flex-direction: column; padding: var(--space-6); gap: var(--space-4); }
|
|
2651
|
+
.scp-recovery { display: grid; gap: var(--space-2); font-size: var(--type-xs); color: var(--fg-1); }
|
|
2652
|
+
.scp-recovery summary { cursor: pointer; padding: var(--space-2) 0; }
|
|
2653
|
+
.scp-recovery pre { margin: 0; padding: var(--space-3); max-height: 160px; overflow: auto; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-1); font: var(--type-xs)/1.55 var(--font-mono); }
|
|
2654
|
+
.scp-recovery summary:focus-visible, .scp-recovery pre:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
2643
2655
|
.scp-legend { display: flex; gap: var(--space-4); font-size: var(--type-xs); color: var(--fg-2); }
|
|
2644
2656
|
.scp-key::before { content: ''; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: var(--space-2); vertical-align: -1px; }
|
|
2645
2657
|
.scp-key--mine::before { background: color-mix(in oklch, var(--status-warn) 45%, transparent); }
|
|
@@ -1614,7 +1614,9 @@
|
|
|
1614
1614
|
position: fixed;
|
|
1615
1615
|
left: 16px;
|
|
1616
1616
|
bottom: 16px;
|
|
1617
|
-
|
|
1617
|
+
/* below a modal's scrim (.st-scrim 9000): it sat on top of the history
|
|
1618
|
+
preview dialog (S19). */
|
|
1619
|
+
z-index: 8990;
|
|
1618
1620
|
display: flex;
|
|
1619
1621
|
align-items: center;
|
|
1620
1622
|
gap: 12px;
|
|
@@ -2030,7 +2032,7 @@
|
|
|
2030
2032
|
.st-cloudwho-out {
|
|
2031
2033
|
font: inherit;
|
|
2032
2034
|
font-size: 12px;
|
|
2033
|
-
color: var(--u-fg-
|
|
2035
|
+
color: var(--u-fg-2);
|
|
2034
2036
|
background: none;
|
|
2035
2037
|
border: 0;
|
|
2036
2038
|
cursor: pointer;
|
|
@@ -64,6 +64,15 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
64
64
|
api,
|
|
65
65
|
fileForSlug,
|
|
66
66
|
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
67
|
+
// Issue #133 — record comment ids as synced only once the hub holds them,
|
|
68
|
+
// and only for the room that IS the hub's doc (pinned). No hub linked:
|
|
69
|
+
// nothing is "synced" — recording then would label local comments with
|
|
70
|
+
// the last hub's identity and a relink would drop them (security review
|
|
71
|
+
// F1a). Linked but the runtime is not up yet: not confirmed.
|
|
72
|
+
commentsConfirmed: (slug) => {
|
|
73
|
+
if (!ctx.cfg?.linkedHub || !registryRef?.isPinned(slug)) return false;
|
|
74
|
+
return ctx.syncControl?.current?.()?.commentsConfirmedOnHub?.(slug) === true;
|
|
75
|
+
},
|
|
67
76
|
// A room restored from its own `.ydoc.bin` must not outrank a sidecar the
|
|
68
77
|
// hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
|
|
69
78
|
// Only for rooms no hub provider owns: a pinned doc is the hub's replica
|
|
@@ -11,6 +11,7 @@ import type { Context } from '../context.ts';
|
|
|
11
11
|
// From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
|
|
12
12
|
// file, so reaching for it here would close a cycle (see sync/limits.ts).
|
|
13
13
|
import { commentKey } from '../sync/comment-identity.ts';
|
|
14
|
+
import { type CommentLedger, commentLedgerFor } from '../sync/comment-ledger.ts';
|
|
14
15
|
import { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, withinByteCap } from '../sync/limits.ts';
|
|
15
16
|
import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
16
17
|
|
|
@@ -60,6 +61,19 @@ export interface PersistenceDeps {
|
|
|
60
61
|
* seed). Absent → cache-only restore, the previous behavior.
|
|
61
62
|
*/
|
|
62
63
|
reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Issue #133 — which comment ids this machine synced before (see
|
|
66
|
+
* sync/comment-ledger.ts). Defaults to the process-wide ledger for the design
|
|
67
|
+
* root; tests inject a fresh one per simulated launch.
|
|
68
|
+
*/
|
|
69
|
+
commentLedger?: CommentLedger;
|
|
70
|
+
/**
|
|
71
|
+
* May the ledger record `slug`'s projected comments as synced? Only when the
|
|
72
|
+
* hub is known to hold them (see `commentsConfirmedOnHub`, sync/index.ts). A
|
|
73
|
+
* projection of a comment added offline would otherwise read as "synced" and
|
|
74
|
+
* the next cold start would drop it. Absent → always (tests, local projects).
|
|
75
|
+
*/
|
|
76
|
+
commentsConfirmed?: (slug: string) => boolean;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|
|
@@ -93,6 +107,7 @@ function withinCap(slug: string, lane: string, value: string, max: number): bool
|
|
|
93
107
|
export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
94
108
|
const { ctx, api, fileForSlug } = deps;
|
|
95
109
|
const stateDir = ensureStateDir(ctx.paths.designRoot);
|
|
110
|
+
const ledger = deps.commentLedger ?? commentLedgerFor(ctx.paths.designRoot);
|
|
96
111
|
|
|
97
112
|
// Per-slug: every comment identity this doc has EVER carried (issue #111).
|
|
98
113
|
//
|
|
@@ -180,6 +195,27 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
180
195
|
return ids;
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
// Issue #133 (plan Task 8) — a comment that stays on this disk and out of the
|
|
199
|
+
// shared document is invisible to every other peer, and nothing said so: the
|
|
200
|
+
// report behind #133 had to be reconstructed from code. Say it once per
|
|
201
|
+
// change of the count, in the server log (which the in-app bug report
|
|
202
|
+
// attaches), and say when it clears.
|
|
203
|
+
const localOnlyBySlug = new Map<string, number>();
|
|
204
|
+
function reportLocalOnly(slug: string, n: number): void {
|
|
205
|
+
const prev = localOnlyBySlug.get(slug) ?? 0;
|
|
206
|
+
if (n === prev) return;
|
|
207
|
+
localOnlyBySlug.set(slug, n);
|
|
208
|
+
if (n > 0) {
|
|
209
|
+
console.warn(
|
|
210
|
+
`[collab/${slug}] comments: ${n} on this disk ${n === 1 ? 'is' : 'are'} not in the shared document yet — kept, not overwritten; other peers do not see ${n === 1 ? 'it' : 'them'} until ${n === 1 ? 'it arrives' : 'they arrive'}.`
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
console.log(
|
|
214
|
+
`[collab/${slug}] comments: every comment on this disk is in the shared document again.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
183
219
|
function ydocBinPath(slug: string): string {
|
|
184
220
|
return path.join(stateDir, `${slug}.ydoc.bin`);
|
|
185
221
|
}
|
|
@@ -284,10 +320,22 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
284
320
|
// brings that id into the doc is itself a doc update, which re-arms the
|
|
285
321
|
// flush, and the next pass writes the merged state. A delete still
|
|
286
322
|
// materializes — its id IS in `everSeen`, so the write proceeds.
|
|
323
|
+
//
|
|
324
|
+
// Issue #133 — `everSeen` starts empty on every launch, so an id deleted
|
|
325
|
+
// by a peer while this machine was closed also reads as "never carried"
|
|
326
|
+
// and froze the file for good. The ledger knows it was synced from here
|
|
327
|
+
// before: an id in the ledger and absent from the doc is a delete.
|
|
287
328
|
const onDisk = await api.loadCommentsForFile(file);
|
|
288
|
-
const
|
|
329
|
+
const synced = ledger.get(slug);
|
|
330
|
+
const localOnly = onDisk.filter((c) => {
|
|
331
|
+
const k = commentKey(c);
|
|
332
|
+
return !everSeen.has(k) && !synced.has(k);
|
|
333
|
+
}).length;
|
|
334
|
+
const behind = localOnly > 0;
|
|
335
|
+
reportLocalOnly(slug, localOnly);
|
|
289
336
|
if (!behind && withinCap(slug, 'comments', JSON.stringify(list), MAX_COMMENTS_BYTES)) {
|
|
290
337
|
await api.saveCommentsForFile(file, list);
|
|
338
|
+
if (deps.commentsConfirmed?.(slug) ?? true) ledger.record(slug, list.map(commentKey));
|
|
291
339
|
}
|
|
292
340
|
}
|
|
293
341
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file comment-anchor.ts — where a comment lives when it is not on an element
|
|
3
|
+
* @scope apps/studio/comment-anchor.ts
|
|
4
|
+
* @purpose Two anchors besides the `data-cd-id` element selector (#134/#136):
|
|
5
|
+
*
|
|
6
|
+
* - `annotationId` — a comment placed on a sticky, shape, pen stroke, image,
|
|
7
|
+
* link or media card anchors to that annotation's `data-id`. Annotation ids
|
|
8
|
+
* are persisted in `*.annotations.svg` and stable across edits, undo and
|
|
9
|
+
* sync (see `stableAnnotationId` in annotations-model.ts for the one case
|
|
10
|
+
* they were not). The pin follows the annotation; a deleted annotation
|
|
11
|
+
* DETACHES the comment, it never deletes it.
|
|
12
|
+
* - `world` — a comment placed on empty canvas holds a WORLD point, so it stays
|
|
13
|
+
* put through pan and zoom. The legacy screen `bounds` captured at create
|
|
14
|
+
* time only matched the camera of that moment.
|
|
15
|
+
*
|
|
16
|
+
* Runs inside the canvas iframe, in both the canvas bundle and the separate
|
|
17
|
+
* comment-mount bundle — so the camera is read through `window.__maudeViewport`
|
|
18
|
+
* (canvas-lib.tsx), not through a module import that would be another copy.
|
|
19
|
+
* Annotation ids are peer-supplied (DDR-054): every id is validated before it
|
|
20
|
+
* reaches a selector.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface WorldPoint {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Viewport {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
zoom: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Shape rule for an annotation id — the `s_…` scheme plus imported ids. */
|
|
35
|
+
const ANNOTATION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
36
|
+
/** World coordinates beyond this are garbage, not a place on the board. */
|
|
37
|
+
const MAX_WORLD = 10_000_000;
|
|
38
|
+
|
|
39
|
+
export function isAnnotationId(v: unknown): v is string {
|
|
40
|
+
return typeof v === 'string' && ANNOTATION_ID_RE.test(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isWorldPoint(v: unknown): v is WorldPoint {
|
|
44
|
+
if (!v || typeof v !== 'object') return false;
|
|
45
|
+
const { x, y } = v as { x?: unknown; y?: unknown };
|
|
46
|
+
return (
|
|
47
|
+
typeof x === 'number' &&
|
|
48
|
+
typeof y === 'number' &&
|
|
49
|
+
Number.isFinite(x) &&
|
|
50
|
+
Number.isFinite(y) &&
|
|
51
|
+
Math.abs(x) <= MAX_WORLD &&
|
|
52
|
+
Math.abs(y) <= MAX_WORLD
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function liveCamera(): { vp: Viewport; left: number; top: number } | null {
|
|
57
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
|
58
|
+
const vp = window.__maudeViewport?.() ?? null;
|
|
59
|
+
if (!vp || !(vp.zoom > 0)) return null;
|
|
60
|
+
const host = document.querySelector('.dc-canvas');
|
|
61
|
+
if (!host) return null;
|
|
62
|
+
const r = host.getBoundingClientRect();
|
|
63
|
+
return { vp, left: r.left, top: r.top };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Client (iframe viewport) point → world point, or null with no world plane. */
|
|
67
|
+
export function clientToWorld(clientX: number, clientY: number): WorldPoint | null {
|
|
68
|
+
const cam = liveCamera();
|
|
69
|
+
if (!cam) return null;
|
|
70
|
+
const { vp } = cam;
|
|
71
|
+
return {
|
|
72
|
+
x: (clientX - cam.left - vp.x) / vp.zoom,
|
|
73
|
+
y: (clientY - cam.top - vp.y) / vp.zoom,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** World point → client point, or null with no world plane. */
|
|
78
|
+
export function worldToClient(p: WorldPoint): { x: number; y: number } | null {
|
|
79
|
+
const cam = liveCamera();
|
|
80
|
+
if (!cam) return null;
|
|
81
|
+
const { vp } = cam;
|
|
82
|
+
return { x: cam.left + vp.x + p.x * vp.zoom, y: cam.top + vp.y + p.y * vp.zoom };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The live element of an annotation, or null. */
|
|
86
|
+
export function annotationElement(id: string): Element | null {
|
|
87
|
+
if (typeof document === 'undefined' || !isAnnotationId(id)) return null;
|
|
88
|
+
// The id is already restricted to [A-Za-z0-9_-]; no escaping needed.
|
|
89
|
+
return document.querySelector(`[data-id="${id}"][data-tool]`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The annotation under a client point, if any. Geometric rather than
|
|
94
|
+
* `elementFromPoint`: in comment mode the annotation layer is not the hit
|
|
95
|
+
* target (its pointer events belong to the draw tools), which is exactly why a
|
|
96
|
+
* click on a sticky used to fall through to a floating comment.
|
|
97
|
+
*
|
|
98
|
+
* The smallest containing box wins, so a sticky inside a section anchors to
|
|
99
|
+
* the sticky. Sections are skipped: they frame artboards and other elements,
|
|
100
|
+
* and "a comment somewhere inside this section" is a floating comment.
|
|
101
|
+
*/
|
|
102
|
+
export function annotationIdAt(clientX: number, clientY: number): string | null {
|
|
103
|
+
if (typeof document === 'undefined') return null;
|
|
104
|
+
let best: { id: string; area: number } | null = null;
|
|
105
|
+
for (const el of Array.from(document.querySelectorAll('[data-id][data-tool]'))) {
|
|
106
|
+
if (el.getAttribute('data-tool') === 'section') continue;
|
|
107
|
+
const id = el.getAttribute('data-id');
|
|
108
|
+
if (!isAnnotationId(id)) continue;
|
|
109
|
+
const r = el.getBoundingClientRect();
|
|
110
|
+
if (r.width <= 0 && r.height <= 0) continue;
|
|
111
|
+
if (clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom) continue;
|
|
112
|
+
const area = Math.max(1, r.width) * Math.max(1, r.height);
|
|
113
|
+
if (!best || area <= best.area) best = { id, area };
|
|
114
|
+
}
|
|
115
|
+
return best?.id ?? null;
|
|
116
|
+
}
|