@volter/editor-blender 0.1.150 → 0.1.152

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 (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +99 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +145 -333
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +45 -31
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +3 -30
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -48
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -1,37 +1,4 @@
1
- /**
2
- * WHAT THE PROPERTIES SECTIONS ARE LOOKING AT — the page-side half of the RNA
3
- * door (WORK.md §Blender in the tab is Blender, "Inspection parity", I1).
4
- *
5
- * React-free ON PURPOSE: this module is a plain store with `subscribe` /
6
- * `version` / `state`, so the host's Blender side can import it without
7
- * pulling React into its closure, and the sections read it through one
8
- * `useSyncExternalStore` (`blender-properties-view.tsx`).
9
- *
10
- * THE SUBJECT IS THE PRESENTED BLENDER CONTEXT. The person clicks a node
11
- * in our viewport; the adapter hands that node's `THREE.Object3D`; the
12
- * presented view answers which Blender object that three object IS
13
- * (`BlenderRuntimeView.blenderObjectName`, a lookup by object identity in the
14
- * frame's own table — never a name match). That name goes to
15
- * `rna_context(object=…)`, which builds the Properties context around it
16
- * exactly as `buttons_context.cc` builds one around
17
- * `BKE_view_layer_active_object_get`. Reading our selection never WRITES the
18
- * engine's active object: that is a mutation, and the document would save it.
19
- * With no selected node, the presented frame's active object remains the
20
- * Properties subject; no active object resolves the scene instead.
21
- *
22
- * WHEN IT RE-READS. Three times, and each is a signal rather than a poll:
23
- * - a PRESENT (`BlenderRuntimeView.subscribeFrames`) — every mutation
24
- * presents (`session.py::dispatch`), so a frame is "what you are showing is
25
- * stale";
26
- * - the RNA DOOR'S OWN VERSION (`subscribeBlenderRna`, ruling 3 of
27
- * 2026-09-19) — because a write can change what RNA answers and nothing the
28
- * presenter draws, so no frame ships: `Node.panel_states[n].is_collapsed`
29
- * and `Object.hide_render` are the two measured cases;
30
- * - the SELECTION moving to another datablock.
31
- * A re-read that changes which TABS exist also calls the host door's
32
- * `documents.contextChanged`, because the tab rail IS the section list and
33
- * nothing in a host store moved.
34
- */
1
+ /** React-free Properties store over the RNA door. Resolve the subject from the presented Blender context without mutating selection. Re-read on presented frames, RNA writes and selection changes; notify documents.contextChanged whenever the resolved tab context changes. */
35
2
 
36
3
  import type { BlenderRnaContext, BlenderRnaView } from '@volter/blender-engine/browser/rna';
37
4
  import { editorHost } from '@volter/sdk/host';
@@ -41,9 +8,12 @@ import {
41
8
  blenderRna,
42
9
  blenderRnaContext,
43
10
  blenderRnaSet,
11
+ blenderModelChanging,
12
+ blenderModelConflict,
44
13
  blenderSessionStarted,
45
14
  noteBlenderRnaChanged,
46
15
  subscribeBlenderRna,
16
+ subscribeBlenderStarting,
47
17
  } from '../host/blender-runtime-host';
48
18
  import { blenderOutlinerState } from './blender-outliner-model';
49
19
 
@@ -65,13 +35,10 @@ const isPresentedView = (value: unknown): value is PresentedView =>
65
35
  typeof (value as PresentedView).blenderObjectName === 'function' &&
66
36
  typeof (value as PresentedView).blenderSelection === 'function';
67
37
 
68
- /** The active authoring adapter, as much of it as this module reads. The
69
- * adapter is deliberately `unknown` at the contribution point (adapter-native
70
- * API, never a fabricated projection), and `@volter/editor-blender` holds no host
71
- * import — so the shape it needs is stated here and checked at runtime. */
38
+ /** The adapter-native fields this SDK-host reader uses, checked at runtime. */
72
39
  interface Object3DAuthoringLike {
73
40
  readonly documentId: string;
74
- readonly hierarchy: { object3D(id: string): THREE.Object3D | null };
41
+ readonly hierarchy: { nativeObject(id: string): THREE.Object3D | null };
75
42
  readonly documentRootObject: THREE.Object3D | null;
76
43
  }
77
44
 
@@ -79,7 +46,7 @@ const isObject3DAuthoring = (value: unknown): value is Object3DAuthoringLike =>
79
46
  typeof value === 'object' &&
80
47
  value !== null &&
81
48
  typeof (value as Object3DAuthoringLike).documentId === 'string' &&
82
- typeof (value as Object3DAuthoringLike).hierarchy?.object3D === 'function';
49
+ typeof (value as Object3DAuthoringLike).hierarchy?.nativeObject === 'function';
83
50
 
84
51
  function presentedView(): PresentedView | null {
85
52
  const published = editorHost().documents.context(blenderPresentationDocumentId());
@@ -127,20 +94,14 @@ export function resolveBlenderSubject(
127
94
  if (node.id === adapter.documentId)
128
95
  return adapter.documentRootObject === view.root ? { kind: 'scene' } : null;
129
96
  // A COLLECTION ROW HAS NO OBJECT, so it has to be resolved before the object
130
- // lookup rather than after it — `object3D` answers null for one, which is the
131
- // honest answer and used to end the resolution here (I3's standing OPEN: the
132
- // rail simply stayed on the previous subject). The row table is this
97
+ // lookup rather than after it — `nativeObject` answers null for one, which would
98
+ // leave the rail on the previous subject. The row table is this
133
99
  // package's own (`blender-outliner-model.ts`), so this is a map lookup.
134
100
  const row = blenderOutlinerState().byId.get(node.id);
135
101
  if (row?.type === 'TSE_LAYER_COLLECTION') return { kind: 'collection', path: row.path };
136
- const object = adapter.hierarchy.object3D(node.id);
102
+ const object = adapter.hierarchy.nativeObject(node.id);
137
103
  if (object === null) return null;
138
- // THE SCENE COLLECTION IS THE SCENE SUBJECT. Under I3 the hierarchy is
139
- // Blender's Outliner, whose root row is the Scene Collection and NOT a
140
- // document row of the host's — so the row that stands for the whole file is
141
- // the one whose object is the presented ROOT, and it stands for exactly what
142
- // the document row did: the tabs that need no object (Render, Output, View
143
- // Layer, Scene, World, Collection).
104
+ // The Scene Collection root resolves the scene subject and its object-independent Properties tabs.
144
105
  if (object === view.root) return { kind: 'scene' };
145
106
  const name = view.blenderObjectName(object);
146
107
  return name === null ? null : { kind: 'object', name };
@@ -185,6 +146,8 @@ let contextRequest = 0;
185
146
  /** Views read at the current generation. A held view outside this set is
186
147
  * STALE: still shown, re-read on the next ask. */
187
148
  const fresh = new Set<string>();
149
+ /** Paths Blender answered with nothing at the current generation: it holds no model. */
150
+ const answeredNothing = new Set<string>();
188
151
 
189
152
  function publish(next: Partial<BlenderPropertiesState>, tabsMayHaveChanged = false): void {
190
153
  state = { ...state, ...next };
@@ -211,50 +174,30 @@ export function blenderPropertiesState(): BlenderPropertiesState {
211
174
  return state;
212
175
  }
213
176
 
214
- /** THE ENGINE MOVED: everything read off it is stale. The CONTEXT is
215
- * re-read immediately (the tabs may have changed — a modifier was added, a
216
- * bone became active); the datablock views are dropped and re-read by
217
- * whichever section is still showing them.
218
- *
219
- * Two things call this, and the second is why it is no longer named
220
- * `onFrame`: a PRESENTED FRAME, and the RNA door's own version (ruling 3,
221
- * 2026-09-19 — `blenderRnaVersion`). A write that changes nothing the
222
- * presenter draws ships no frame, and before this the rail simply kept the
223
- * values it had. */
177
+ /** Invalidate datablock caches and re-read context on RNA version changes, including writes that produce no presented-frame change. */
224
178
  function onEngineMoved(): void {
225
179
  // STALE, NOT EMPTY. The generation moves, so every answer already in flight
226
180
  // is dropped when it lands (a deleted duplicate's late read cannot overwrite
227
181
  // the new selection) -- but the panel keeps showing what it holds until the
228
- // re-read answers. Clearing here blanked and repainted the whole Properties
229
- // panel on every edit, because every edit presents a frame.
182
+ // re-read answers. Keep the settled panel visible while each edit presents its frame.
230
183
  const subject = readFor;
231
184
  readGeneration += 1;
232
185
  inFlight.clear();
233
186
  fresh.clear();
187
+ answeredNothing.clear();
234
188
  if (subject !== null) scheduleContextRead(subject, true);
235
189
  else publish({});
236
190
  }
237
191
 
238
- // THE FRAME IS ONE SIGNAL AND THE RNA DOOR IS THE OTHER. This subscription is
239
- // module-level rather than taken beside the frame one, because the RNA door
240
- // exists from the first import while the document's view arrives later — and a
241
- // write made before the first present is exactly the case that went unseen.
192
+ // Subscribe on module import so RNA writes before the first presented frame
193
+ // also invalidate the context. Frame changes provide the other signal.
242
194
  subscribeBlenderRna(() => {
243
195
  onEngineMoved();
244
196
  });
245
197
 
246
- /**
247
- * THE READ NEVER STARTS INSIDE A RENDER. `showBlenderSubject` is called from a
248
- * tab's `match()`, which the host runs while it is RENDERING the workspace —
249
- * and the read's first act is to publish `loading`, which is a `setState` in
250
- * every subscribed component. React said so by name ("Cannot update a
251
- * component (BlenderPropertiesSection) while rendering a different
252
- * component"), measured live 2026-09-19. The WANT is recorded
253
- * synchronously so a second matcher in the same pass does not queue a second
254
- * read; the read itself runs on the next turn of the loop, outside the render.
255
- */
256
198
  let scheduledKey: string | null = null;
257
199
 
200
+ /** Queue the context read outside React render; record the request synchronously to deduplicate matchers in the same composition. */
258
201
  function scheduleContextRead(key: string, refresh = false): void {
259
202
  if (scheduledKey === key) return;
260
203
  scheduledKey = key;
@@ -278,12 +221,7 @@ function watchFrames(): void {
278
221
  if (frameSubscription !== null) return;
279
222
  const view = presentedView();
280
223
  if (view === null) return;
281
- // A FRAME IS REPORTED TO THE RNA DOOR'S VERSION, not consumed here. The
282
- // subscription lives in this module because this module is what the document
283
- // publishes its view to, but the signal belongs to every view over the
284
- // engine — so it is minted in ONE place (ruling 3) and this module hears its
285
- // own frame back through `subscribeBlenderRna` above, exactly as the node
286
- // view and the UV view do.
224
+ // Report frames through the shared RNA version so Properties, node and UV views receive the same engine-change signal.
287
225
  frameSubscription = view.subscribeFrames(noteBlenderRnaChanged);
288
226
  }
289
227
 
@@ -297,6 +235,7 @@ async function readContext(key: string, refresh = false): Promise<void> {
297
235
  readGeneration += 1;
298
236
  inFlight.clear();
299
237
  fresh.clear();
238
+ answeredNothing.clear();
300
239
  }
301
240
  const generation = readGeneration;
302
241
  const request = ++contextRequest;
@@ -311,17 +250,22 @@ async function readContext(key: string, refresh = false): Promise<void> {
311
250
  publish({ loading: false, error: null });
312
251
  return;
313
252
  }
314
- // ALWAYS a tab-rail notification, and that is a correction rather than a
315
- // convenience: the gate a tab matches on is BOTH the subject this context
316
- // was read for and the tab list, so comparing only the list missed the
317
- // case that actually happens — selecting the Cube after the document row
318
- // reads the SAME thirteen tabs for a different subject, the gated tabs'
319
- // `state.object !== object` went false, and nothing ever said so. Measured
320
- // live 2026-09-19: Collection/Modifiers/Particles/Object Data/Material
321
- // disappeared on the first selection and only came back on a re-select.
253
+ // Notify the tab rail after every context read; both the subject identity and tab list determine matches.
322
254
  publish({ context, loading: false, error: null }, true);
323
255
  } catch (error) {
324
256
  if (generation !== readGeneration || request !== contextRequest) return;
257
+ if (blenderModelChanging()) {
258
+ // Refused while Blender changes its model (a switch): still reading, and read again once the
259
+ // change is over, when the tabs ask for their subject again.
260
+ readFor = null;
261
+ publish({ loading: true, error: null });
262
+ const stop = subscribeBlenderStarting(() => {
263
+ if (blenderModelChanging()) return;
264
+ stop();
265
+ publish({}, true);
266
+ });
267
+ return;
268
+ }
325
269
  publish({ context: null, loading: false, error: describe(error) }, true);
326
270
  }
327
271
  }
@@ -354,7 +298,11 @@ export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
354
298
  if (generation !== readGeneration) return;
355
299
  inFlight.delete(path);
356
300
  fresh.add(path);
357
- if (view === null) return;
301
+ if (view === null) {
302
+ answeredNothing.add(path);
303
+ publish({});
304
+ return;
305
+ }
358
306
  const views = new Map(state.views);
359
307
  views.set(path, view);
360
308
  publish({ views, error: null });
@@ -362,11 +310,30 @@ export function blenderRnaViewFor(path: string): BlenderRnaView | undefined {
362
310
  .catch((error: unknown) => {
363
311
  if (generation !== readGeneration) return;
364
312
  inFlight.delete(path);
313
+ // Refused while Blender changes its model (a switch): the read waits for it and is asked again
314
+ // once the change is over (`subscribeBlenderRnaAvailability`), rather than failing.
315
+ if (blenderModelChanging()) return;
365
316
  publish({ error: describe(error) });
366
317
  });
367
318
  return held;
368
319
  }
369
320
 
321
+ /** Why `path` cannot be read, in a person's words, or null while its read may still come: Blender is on another
322
+ * document than the editor shows, or holds no model, or answered nothing for it. While Blender starts or changes its
323
+ * model (a switch) the read waits. Its section says the reason rather than waiting on a read that will not come. */
324
+ export function blenderRnaUnavailable(path: string): string | null {
325
+ if (state.views.get(path) !== undefined || blenderModelChanging()) return null;
326
+ const conflict = blenderModelConflict();
327
+ if (conflict !== null) return conflict;
328
+ if (!blenderSessionStarted() || answeredNothing.has(path)) return `Blender holds no model, so ${path} cannot be read.`;
329
+ return null;
330
+ }
331
+
332
+ /** Hear when what {@link blenderRnaUnavailable} answers may have changed. */
333
+ export function subscribeBlenderRnaAvailability(listener: () => void): () => void {
334
+ return subscribeBlenderStarting(listener);
335
+ }
336
+
370
337
  /**
371
338
  * WRITE ONE PROPERTY. The engine refuses a read-only one by name and this
372
339
  * surfaces that refusal verbatim; a successful write presents, and the frame
@@ -384,6 +351,7 @@ export async function writeBlenderRnaProperty(
384
351
  // The write presented; a re-read of this one datablock closes the loop
385
352
  // even if the frame listener has not been taken yet.
386
353
  fresh.delete(path);
354
+ answeredNothing.delete(path);
387
355
  publish({ error: null });
388
356
  return true;
389
357
  } catch (error) {
@@ -1,39 +1,4 @@
1
- /**
2
- * ONE PROPERTIES TAB IS ONE `selection.inspector` CONTRIBUTION — the factory
3
- * every `properties-*.inspector.tsx` in this package is built from (WORK.md
4
- * §Blender in the tab is Blender, "Inspection parity", I1, item 3).
5
- *
6
- * WHY A TAB IS A SECTION, and not a rail of our own: under the `properties`
7
- * presentation the inspector's sections ARE the vertical icon rail, 1:1
8
- * (`packages/sdk/src/kit/components/InspectionProjection.tsx`,
9
- * `PropertiesColumn`). So the way to make Blender's tab rail appear is to
10
- * contribute Blender's tabs as sections — which is also exactly the shape I2
11
- * needs, because each of these modules then grows a CURATED body over the same
12
- * door while the generic view stays beneath it.
13
- *
14
- * WHICH TABS STAND is `buttons_context.cc`'s answer, computed in the engine
15
- * (`session.py::rna_context`) and read here. Two kinds of tab:
16
- *
17
- * - `standing: 'any' | 'object'` — a tab `buttons_context.cc` builds a path
18
- * to UNCONDITIONALLY given a scene (`buttons_context_path_scene`,
19
- * `_view_layer`, `_world`) or given an object at all
20
- * (`buttons_context_path_object`, of which only the OBJECT tab is standing
21
- * here — Physics and Constraints share that path but are context-gated,
22
- * because Blender's Properties editor follows the ACTIVE OBJECT rather
23
- * than the row that was clicked, and only the door knows there is one).
24
- * These match from the SELECTION alone, synchronously.
25
- * - everything else — Modifiers (an object type set), Particles (a mesh),
26
- * Object Data (the object has data), Bone (an active bone), Material,
27
- * Collection, Texture — which only the engine can answer, so they match
28
- * against the context the door last returned.
29
- *
30
- * THE STANDING TABS EXIST SO THE RAIL IS NEVER EMPTY: `match()` is
31
- * synchronous and the first composition has read nothing, so if every tab
32
- * waited on the door there would be no rail at all. The READ itself is driven
33
- * from `match` (see the note there), and when its answer lands it calls
34
- * `documents.contextChanged`, which re-derives the whole inspection and brings
35
- * the rest of the rail in.
36
- */
1
+ /** Build one selection.inspector contribution per Blender Properties tab. Standing scene/object tabs match synchronously; context-dependent tabs use the engine’s answer. Matchers drive the shared read, whose publication recomposes the rail; only the active tab mounts its body. */
37
2
 
38
3
  import type {
39
4
  ToolContributionNode,
@@ -52,11 +17,7 @@ export interface BlenderPropertiesTab {
52
17
  /** Whether this tab needs the engine's answer to exist, or stands on the
53
18
  * selection alone — see the header. */
54
19
  readonly standing?: 'any' | 'object';
55
- /** BLENDER'S OWN PANELS for this tab, in Blender's order, transcribed from
56
- * the matching `scripts/startup/bl_ui/properties_*.py`
57
- * ({@link BlenderCuratedPanel}). The generic RNA view stays beneath them
58
- * under "All properties"; a tab that declares none is the generic view
59
- * alone, which is what I1 shipped. */
20
+ /** Curated panels follow Blender’s panel order. The generic RNA view remains beneath them under All properties. */
60
21
  readonly curated?: readonly BlenderCuratedPanel[];
61
22
  }
62
23
 
@@ -64,15 +25,7 @@ export function blenderPropertiesTabMatch(tab: BlenderPropertiesTab) {
64
25
  return (node: ToolContributionNode | null, adapter: unknown): boolean => {
65
26
  const subject = resolveBlenderSubject(node, adapter);
66
27
  if (subject === null) return false;
67
- // THE READ IS DRIVEN FROM `match`, NOT FROM A BODY, and that is a
68
- // correction the live walk forced: under the `properties` presentation
69
- // the host mounts ONLY THE ACTIVE TAB's body, so a read driven from a
70
- // body never happened at all unless the person had already clicked a
71
- // Blender tab — the rail showed the standing tabs and nothing else, for
72
- // good (measured 2026-09-19). A matcher runs on every composition,
73
- // active tab or not, so this is the one place that always sees the
74
- // subject. It is idempotent by construction: `showBlenderSubject`
75
- // returns immediately unless the subject moved.
28
+ // Drive the idempotent subject read from match, which runs for every composition even when this tab’s body is unmounted.
76
29
  showBlenderSubject(subject);
77
30
  if (tab.standing === 'any') return true;
78
31
  if (tab.standing === 'object') return subject.kind === 'object';