@1agh/maude 1.4.0 → 1.4.2

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.
@@ -3200,6 +3200,21 @@ export function PhotoPreviewBridge() {
3200
3200
  // clobbering a NEWER edit that already resolved first.
3201
3201
  const tokenRef = useRef<Map<string, number>>(new Map());
3202
3202
  const bakeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
3203
+ // Per-asset FETCH generation — the same guard one step earlier. Two synced
3204
+ // edits in a row start two sidecar fetches, and the older answer can arrive
3205
+ // last: applying it put the previous edit back on screen while every disk had
3206
+ // the newer one (surface run 2026-09-16, L13 on the hub: shown, then
3207
+ // reverted). A live preview counts as newer than any fetch in flight.
3208
+ const fetchGenRef = useRef<Map<string, number>>(new Map());
3209
+ const nextFetchGen = useCallback((asset: string): number => {
3210
+ const g = (fetchGenRef.current.get(asset) ?? 0) + 1;
3211
+ fetchGenRef.current.set(asset, g);
3212
+ return g;
3213
+ }, []);
3214
+ const isLatestFetch = useCallback(
3215
+ (asset: string, g: number): boolean => fetchGenRef.current.get(asset) === g,
3216
+ []
3217
+ );
3203
3218
 
3204
3219
  const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
3205
3220
  const token = (tokenRef.current.get(asset) ?? 0) + 1;
@@ -3231,6 +3246,8 @@ export function PhotoPreviewBridge() {
3231
3246
  clearTimeout(timers.get(asset));
3232
3247
  if (!edit || isDefaultEdit(edit)) {
3233
3248
  timers.delete(asset);
3249
+ // A bake already rendering an earlier edit must not land over this.
3250
+ tokenRef.current.set(asset, (tokenRef.current.get(asset) ?? 0) + 1);
3234
3251
  setPhotoElSrc(el, original);
3235
3252
  return;
3236
3253
  }
@@ -3270,17 +3287,20 @@ export function PhotoPreviewBridge() {
3270
3287
  }
3271
3288
  }
3272
3289
  if (!asset) return; // no photo on this canvas uses that asset
3273
- fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
3290
+ const target = asset;
3291
+ const g = nextFetchGen(target);
3292
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(target)}`)
3274
3293
  .then((r) => (r.ok ? r.json() : null))
3275
3294
  .then((edit) => {
3295
+ if (!isLatestFetch(target, g)) return; // a newer answer owns the render
3276
3296
  // A deleted/reset edit applies as null → restores the original src.
3277
- apply(asset as string, edit && !isDefaultEdit(edit) ? (edit as PhotoEdit) : null);
3297
+ apply(target, edit && !isDefaultEdit(edit) ? (edit as PhotoEdit) : null);
3278
3298
  })
3279
3299
  .catch(() => {});
3280
3300
  };
3281
3301
  document.addEventListener('maude:photo-edit-refreshed', onRefreshed);
3282
3302
  return () => document.removeEventListener('maude:photo-edit-refreshed', onRefreshed);
3283
- }, [apply]);
3303
+ }, [apply, nextFetchGen, isLatestFetch]);
3284
3304
 
3285
3305
  useEffect(() => {
3286
3306
  const onMsg = (e: MessageEvent) => {
@@ -3304,11 +3324,12 @@ export function PhotoPreviewBridge() {
3304
3324
  if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
3305
3325
  const asset = m.asset;
3306
3326
  const edit = (m.edit ?? null) as PhotoEdit | null;
3327
+ nextFetchGen(asset);
3307
3328
  apply(asset, edit);
3308
3329
  };
3309
3330
  window.addEventListener('message', onMsg);
3310
3331
  return () => window.removeEventListener('message', onMsg);
3311
- }, [apply]);
3332
+ }, [apply, nextFetchGen]);
3312
3333
  // Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
3313
3334
  // live `photo-preview` message above. Without this, a saved PhotoEdit is
3314
3335
  // invisible after anything that re-mounts the canvas doc (Cmd+R, an
@@ -3342,10 +3363,11 @@ export function PhotoPreviewBridge() {
3342
3363
  if (!asset || attempted.has(asset)) continue;
3343
3364
  if (++scanned > MAX_SCAN_PER_PASS) break;
3344
3365
  attempted.add(asset);
3366
+ const g = nextFetchGen(asset);
3345
3367
  fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
3346
3368
  .then((r) => (r.ok ? r.json() : null))
3347
3369
  .then((edit) => {
3348
- if (cancelled || !edit || isDefaultEdit(edit)) return;
3370
+ if (cancelled || !isLatestFetch(asset, g) || !edit || isDefaultEdit(edit)) return;
3349
3371
  apply(asset, edit);
3350
3372
  })
3351
3373
  .catch(() => {});
@@ -3367,7 +3389,7 @@ export function PhotoPreviewBridge() {
3367
3389
  cancelAnimationFrame(raf);
3368
3390
  observer.disconnect();
3369
3391
  };
3370
- }, [apply]);
3392
+ }, [apply, nextFetchGen, isLatestFetch]);
3371
3393
  return null; // mutates the real elements directly — no visible DOM of its own.
3372
3394
  }
3373
3395
  PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
12
12
  // import that Bun erases), so this pulls only string constants into the client
13
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
14
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
15
+ import { commandForEnter, matchCommands } from './command-palette-match.js';
15
16
  import {
16
17
  defaultScopeForFormat,
17
18
  isScopeValidForFormat,
@@ -878,15 +879,7 @@ function CommandPalette({ open, onClose, actions }) {
878
879
  ?.querySelector('.st-pal-item.is-active')
879
880
  ?.scrollIntoView({ block: 'nearest' });
880
881
  }, [active]);
881
- const filtered = useMemo(() => {
882
- const needle = q.trim().toLowerCase();
883
- if (!needle) return actions;
884
- return actions.filter(
885
- (a) =>
886
- a.label.toLowerCase().includes(needle) ||
887
- (a.group && a.group.toLowerCase().includes(needle))
888
- );
889
- }, [q, actions]);
882
+ const filtered = useMemo(() => matchCommands(actions, q), [q, actions]);
890
883
  useEffect(() => {
891
884
  if (active >= filtered.length) setActive(0);
892
885
  }, [filtered.length, active]);
@@ -925,7 +918,8 @@ function CommandPalette({ open, onClose, actions }) {
925
918
  setActive((i) => Math.max(0, i - 1));
926
919
  } else if (e.key === 'Enter') {
927
920
  e.preventDefault();
928
- if (filtered[active]) run(filtered[active]);
921
+ const pick = commandForEnter(actions, q, e.currentTarget.value, active);
922
+ if (pick) run(pick);
929
923
  }
930
924
  }}
931
925
  />
@@ -16818,7 +16812,19 @@ function App() {
16818
16812
  unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}
16819
16813
  savingIsManaged={savingIsManaged}
16820
16814
  changesOpen={changesOpen}
16821
- onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
16815
+ // A REPO IS NOT THE ONLY REASON THIS PANEL HAS SOMETHING TO SHOW.
16816
+ // Under managed saving the panel IS the project's history — every
16817
+ // accepted action with its author, and Undo on the ones that are
16818
+ // yours — and it comes from the hub, not from git. A managed copy on
16819
+ // a designer's machine has no `.git`, so gating the chip on a repo
16820
+ // left the invited designer with no visible way in at all: the View
16821
+ // menu and ⌘⇧G worked, and nothing on screen said so. The chip's own
16822
+ // `savingIsManaged` branch below was already written for this case
16823
+ // and was simply unreachable here. Found running S20 against a live
16824
+ // deployment; asserted in `team-project.e2e.ts` step 4b.
16825
+ onOpenChanges={
16826
+ gitStatus?.repo || savingIsManaged ? () => setChangesOpen(true) : undefined
16827
+ }
16822
16828
  version={cfg?.version}
16823
16829
  />
16824
16830
  </div>
@@ -0,0 +1,35 @@
1
+ // command-palette-match.js — which ⌘K command a query names.
2
+ //
3
+ // Kept out of app.jsx so the one decision that matters — what Enter runs —
4
+ // can be tested on its own.
5
+
6
+ /** The commands a query matches, in list order (label or group substring). */
7
+ export function matchCommands(actions, query) {
8
+ const needle = String(query ?? '')
9
+ .trim()
10
+ .toLowerCase();
11
+ if (!needle) return actions;
12
+ return actions.filter(
13
+ (a) => a.label.toLowerCase().includes(needle) || (a.group && a.group.toLowerCase().includes(needle))
14
+ );
15
+ }
16
+
17
+ /**
18
+ * The command Enter runs.
19
+ *
20
+ * WHAT IS IN THE BOX, not what the last render saw. A query typed or pasted
21
+ * and confirmed at once can reach Enter before the list re-rendered for it;
22
+ * the rendered list is then the unfiltered one and its first row — "New brief
23
+ * board" — ran instead of the command that was asked for (surface run
24
+ * 2026-09-16, "New video" on the hub). When the box and the rendered query
25
+ * disagree, the box wins and its first match runs; when they agree, the
26
+ * highlighted row does.
27
+ */
28
+ export function commandForEnter(actions, renderedQuery, liveQuery, activeIndex) {
29
+ const norm = (s) =>
30
+ String(s ?? '')
31
+ .trim()
32
+ .toLowerCase();
33
+ if (norm(liveQuery) !== norm(renderedQuery)) return matchCommands(actions, liveQuery)[0] ?? null;
34
+ return matchCommands(actions, renderedQuery)[activeIndex] ?? null;
35
+ }