@1agh/maude 1.3.0 → 1.4.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 (36) hide show
  1. package/apps/studio/annotation-edit-base.ts +36 -0
  2. package/apps/studio/annotations-layer.tsx +13 -2
  3. package/apps/studio/api.ts +51 -2
  4. package/apps/studio/canvas-build.ts +49 -8
  5. package/apps/studio/canvas-edit.ts +77 -0
  6. package/apps/studio/canvas-lib.tsx +25 -10
  7. package/apps/studio/canvas-list-watch.ts +33 -2
  8. package/apps/studio/client/app.jsx +99 -4
  9. package/apps/studio/client/canvas-url.js +39 -1
  10. package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
  11. package/apps/studio/client/photo-knobs.jsx +3 -3
  12. package/apps/studio/client/styles/3-shell-maude.css +1 -0
  13. package/apps/studio/collab/awareness-bridge.ts +65 -14
  14. package/apps/studio/comments-overlay.tsx +86 -2
  15. package/apps/studio/context.ts +93 -1
  16. package/apps/studio/dist/client.bundle.js +619 -619
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/accepted-guard.ts +36 -0
  20. package/apps/studio/hmr-broadcast.ts +34 -1
  21. package/apps/studio/http.ts +15 -1
  22. package/apps/studio/managed-projects.ts +22 -0
  23. package/apps/studio/server.ts +3 -0
  24. package/apps/studio/sync/accepted-link.ts +3 -0
  25. package/apps/studio/sync/file-plane.ts +15 -0
  26. package/apps/studio/sync/index.ts +138 -12
  27. package/apps/studio/sync/presentation.ts +30 -0
  28. package/apps/studio/sync/status.ts +5 -1
  29. package/apps/studio/sync/transaction-client.ts +29 -0
  30. package/apps/studio/use-tool-mode.tsx +44 -0
  31. package/apps/studio/whats-new.json +72 -0
  32. package/apps/studio/ws.ts +9 -2
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +48 -7
  35. package/plugins/flow/.claude-plugin/config.schema.json +15 -2
  36. package/plugins/flow/README.md +1 -1
@@ -338,6 +338,11 @@ export function CommentsOverlay(): React.ReactNode {
338
338
  // safe because they carry identical content; the Y.Array path just
339
339
  // reaches us first (no 800 ms debounce delay).
340
340
  const collab = useCollab();
341
+ // Who is looking — the same identity a new comment is signed with (the
342
+ // project's git user on a desktop, the vouched member in a cloud cell).
343
+ // Asked directly: this overlay also runs in the comment-mount bundle, where
344
+ // the collab context is another module's and reads as null.
345
+ const me = useViewerName();
341
346
  useEffect(() => {
342
347
  if (!collab) return;
343
348
  const arr = collab.doc.getArray<OverlayComment>('comments');
@@ -566,6 +571,11 @@ export function CommentsOverlay(): React.ReactNode {
566
571
  onPatch={(patch) => handlePatch(focused.id, patch)}
567
572
  onDelete={() => handleDelete(focused.id)}
568
573
  onReply={(body) => handleReply(focused.id, body)}
574
+ mine={
575
+ !isReadOnlyCanvas() &&
576
+ !!(me ?? collab?.myName) &&
577
+ (focused.author ?? '').trim() === (me ?? collab?.myName ?? '').trim()
578
+ }
569
579
  />
570
580
  );
571
581
  })()}
@@ -1048,13 +1058,33 @@ function CommentComposer({
1048
1058
  // directly to `/_api/comments/<id>/reply` because that endpoint exists only on
1049
1059
  // Bun runtime and lives in `http.ts`.
1050
1060
 
1051
- function CommentThread({
1061
+ let viewerName: Promise<string | null> | null = null;
1062
+ /** The viewer's display identity, asked once per document. */
1063
+ function useViewerName(): string | null {
1064
+ const [name, setName] = useState<string | null>(null);
1065
+ useEffect(() => {
1066
+ if (typeof fetch === 'undefined') return;
1067
+ viewerName ??= fetch('/_api/git-user')
1068
+ .then((r) => (r.ok ? r.json() : null))
1069
+ .then((j) => (typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null))
1070
+ .catch(() => null);
1071
+ let alive = true;
1072
+ void viewerName.then((n) => alive && setName(n));
1073
+ return () => {
1074
+ alive = false;
1075
+ };
1076
+ }, []);
1077
+ return name;
1078
+ }
1079
+
1080
+ export function CommentThread({
1052
1081
  comment,
1053
1082
  sequence,
1054
1083
  onClose,
1055
1084
  onPatch,
1056
1085
  onDelete,
1057
1086
  onReply,
1087
+ mine = false,
1058
1088
  }: {
1059
1089
  comment: OverlayComment;
1060
1090
  sequence: number;
@@ -1062,12 +1092,23 @@ function CommentThread({
1062
1092
  onPatch: (patch: Record<string, unknown>) => void;
1063
1093
  onDelete: () => void;
1064
1094
  onReply: (body: string) => Promise<boolean>;
1095
+ /** The person looking wrote this comment — they may edit its text. */
1096
+ mine?: boolean;
1065
1097
  }) {
1066
1098
  const dialogRef = useRef<HTMLDivElement | null>(null);
1067
1099
  const replyRef = useRef<HTMLTextAreaElement | null>(null);
1068
1100
  const rafRef = useRef<number | null>(null);
1069
1101
  const [reply, setReply] = useState('');
1070
1102
  const [sending, setSending] = useState(false);
1103
+ // Editing your own comment: the text in place, saved as a patch (the same
1104
+ // channel resolve/reopen use). null = not editing.
1105
+ const [draft, setDraft] = useState<string | null>(null);
1106
+ const saveDraft = useCallback(() => {
1107
+ const v = (draft ?? '').trim();
1108
+ if (!v) return;
1109
+ if (v !== comment.text) onPatch({ text: v });
1110
+ setDraft(null);
1111
+ }, [draft, comment.text, onPatch]);
1071
1112
 
1072
1113
  // Live anchor — popover tracks the pin via rAF so it stays glued to its
1073
1114
  // target through pan / zoom (FigJam parity). Writing to the dialog style
@@ -1176,7 +1217,40 @@ function CommentThread({
1176
1217
  {selectorChip ? <code className="cm-thread__selector">{selectorChip}</code> : null}
1177
1218
  </div>
1178
1219
 
1179
- <div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
1220
+ {draft === null ? (
1221
+ <div className="cm-thread__body">{renderBodyWithMentions(comment.text)}</div>
1222
+ ) : (
1223
+ <div className="cm-thread__reply-form">
1224
+ <MentionAwareTextarea
1225
+ className="cm-thread__reply-textarea"
1226
+ value={draft}
1227
+ placeholder="Edit your comment… ⌘↵ to save"
1228
+ onChange={setDraft}
1229
+ onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
1230
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1231
+ e.preventDefault();
1232
+ saveDraft();
1233
+ }
1234
+ }}
1235
+ rows={3}
1236
+ ariaLabel="Edit comment"
1237
+ />
1238
+ <div className="cm-thread__reply-actions">
1239
+ <button type="button" className="cm-btn" onClick={() => setDraft(null)}>
1240
+ Cancel
1241
+ </button>
1242
+ <button
1243
+ type="button"
1244
+ className="cm-btn cm-btn--primary"
1245
+ data-testid="comment-edit-save"
1246
+ disabled={!draft.trim()}
1247
+ onClick={saveDraft}
1248
+ >
1249
+ Save
1250
+ </button>
1251
+ </div>
1252
+ </div>
1253
+ )}
1180
1254
 
1181
1255
  {(comment.thread ?? []).map((r) => (
1182
1256
  <div className="cm-thread__reply" key={r.id}>
@@ -1218,6 +1292,16 @@ function CommentThread({
1218
1292
  </div>
1219
1293
 
1220
1294
  <div className="cm-thread__actions">
1295
+ {mine && draft === null ? (
1296
+ <button
1297
+ type="button"
1298
+ className="cm-btn"
1299
+ data-testid="comment-edit"
1300
+ onClick={() => setDraft(comment.text)}
1301
+ >
1302
+ Edit
1303
+ </button>
1304
+ ) : null}
1221
1305
  {comment.status === 'resolved' ? (
1222
1306
  <button type="button" className="cm-btn" onClick={() => onPatch({ status: 'open' })}>
1223
1307
  ↺ Reopen
@@ -329,6 +329,96 @@ function resolveRepoRoot(): string {
329
329
  return process.cwd();
330
330
  }
331
331
 
332
+ /** Where the sync runtime keeps what the linked project says about itself. */
333
+ export const PROJECT_CONFIG_CACHE_REL = '_state/project-config.json';
334
+
335
+ export interface ProjectConfig {
336
+ canvasGroups: { label: string; path: string }[];
337
+ designSystems: { name: string; path: string; tokensCssRel?: string }[];
338
+ }
339
+
340
+ /**
341
+ * The project's own labels and design systems, as the hub reported them —
342
+ * UNTRUSTED (DDR-054): only short names and contained relative paths survive.
343
+ */
344
+ export function sanitizeProjectConfig(raw: unknown): ProjectConfig | null {
345
+ if (!raw || typeof raw !== 'object') return null;
346
+ const r = raw as { canvasGroups?: unknown; designSystems?: unknown };
347
+ const groups = Array.isArray(r.canvasGroups) ? r.canvasGroups : [];
348
+ const systems = Array.isArray(r.designSystems) ? r.designSystems : [];
349
+ const canvasGroups = groups
350
+ .filter(
351
+ (g): g is { label: string; path: string } =>
352
+ !!g &&
353
+ typeof g === 'object' &&
354
+ typeof (g as { label?: unknown }).label === 'string' &&
355
+ (g as { label: string }).label.length > 0 &&
356
+ (g as { label: string }).label.length <= 64 &&
357
+ isContainedRel((g as { path?: unknown }).path)
358
+ )
359
+ .slice(0, 32)
360
+ .map((g) => ({ label: g.label, path: g.path.replace(/^\/+|\/+$/g, '') }));
361
+ const designSystems = systems
362
+ .filter(
363
+ (d): d is { name: string; path: string; tokensCssRel?: string } =>
364
+ !!d &&
365
+ typeof d === 'object' &&
366
+ typeof (d as { name?: unknown }).name === 'string' &&
367
+ /^[\w .-]{1,64}$/.test((d as { name: string }).name) &&
368
+ isContainedRel((d as { path?: unknown }).path) &&
369
+ ((d as { tokensCssRel?: unknown }).tokensCssRel == null ||
370
+ isContainedRel((d as { tokensCssRel?: unknown }).tokensCssRel))
371
+ )
372
+ .slice(0, 16)
373
+ .map((d) => ({
374
+ name: d.name,
375
+ path: d.path.replace(/^\/+|\/+$/g, ''),
376
+ ...(typeof d.tokensCssRel === 'string' ? { tokensCssRel: d.tokensCssRel } : {}),
377
+ }));
378
+ return { canvasGroups, designSystems };
379
+ }
380
+
381
+ /**
382
+ * What the linked project says about itself that this copy's own config does
383
+ * not. A managed desktop copy is declared from group PATHS alone, so its
384
+ * groups had no labels ("system", never "Design system") and it knew no
385
+ * design systems: the project's design system never appeared on a teammate's
386
+ * desktop (plan T31/L16). This only FILLS IN — a label the local config chose
387
+ * and a local design system of the same name always win — and only for a
388
+ * group this copy already declares.
389
+ */
390
+ function withProjectConfig(cfg: DevServerConfig, repoRoot: string): DevServerConfig {
391
+ if (!cfg.linkedHub) return cfg;
392
+ let project: ProjectConfig | null = null;
393
+ try {
394
+ const designRel = String(cfg.designRoot ?? '.design').replace(/^\/+|\/+$/g, '');
395
+ project = sanitizeProjectConfig(
396
+ JSON.parse(readFileSync(path.join(repoRoot, designRel, PROJECT_CONFIG_CACHE_REL), 'utf8'))
397
+ );
398
+ } catch {
399
+ return cfg;
400
+ }
401
+ if (!project) return cfg;
402
+ const labels = new Map(project.canvasGroups.map((g) => [g.path, g.label]));
403
+ const canvasGroups = (cfg.canvasGroups ?? []).map((g) => {
404
+ const p = String(g.path ?? '').replace(/^\/+|\/+$/g, '');
405
+ const label = labels.get(p);
406
+ return label && (!g.label || g.label === p) ? { ...g, label } : g;
407
+ });
408
+ const declared = new Set((cfg.designSystems ?? []).map((d) => d.name));
409
+ const groupPaths = canvasGroups.map((g) => String(g.path).replace(/^\/+|\/+$/g, ''));
410
+ const adopted = project.designSystems.filter(
411
+ (d) =>
412
+ !declared.has(d.name) && groupPaths.some((g) => d.path === g || d.path.startsWith(`${g}/`))
413
+ );
414
+ if (adopted.length === 0 && canvasGroups.every((g, i) => g === cfg.canvasGroups[i])) return cfg;
415
+ return {
416
+ ...cfg,
417
+ canvasGroups,
418
+ ...(adopted.length ? { designSystems: [...(cfg.designSystems ?? []), ...adopted] } : {}),
419
+ };
420
+ }
421
+
332
422
  function loadConfig(repoRoot: string): DevServerConfig {
333
423
  const configPath = path.join(repoRoot, '.design', 'config.json');
334
424
  let raw: string;
@@ -339,7 +429,9 @@ function loadConfig(repoRoot: string): DevServerConfig {
339
429
  }
340
430
  try {
341
431
  const parsed = JSON.parse(raw);
342
- return normalizeConfig({ ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' });
432
+ return normalizeConfig(
433
+ withProjectConfig({ ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' }, repoRoot)
434
+ );
343
435
  } catch (e) {
344
436
  const msg = e instanceof Error ? e.message : String(e);
345
437
  console.error(` warn: ${configPath} is not valid JSON: ${msg}. Using defaults.`);