@adia-ai/web-modules 0.7.26 → 0.7.28

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.
@@ -72,7 +72,23 @@ class EditorSidebar extends UIElement {
72
72
  #storageKey = null;
73
73
  #onPointerDown = null;
74
74
  #onPointerUp = null;
75
+ #onPaneTransitionEnd = null;
75
76
  #pendingCollapseRaf = null;
77
+ // GH #223: an explicit expand() kicks off pane-ui's own width transition
78
+ // (editor-shell.bespoke.css). Early ResizeObserver samples mid-transition
79
+ // are legitimately still below SNAP_THRESHOLD while animating TOWARD a
80
+ // large target — #syncCollapsed() can't tell that apart from a genuine
81
+ // drag-to-collapse settle, and setting [collapsed] back to true re-matches
82
+ // the `!important` collapsed-width CSS rule, which snaps the pane straight
83
+ // back to 48px and PERMANENTLY interrupts the transition (no further
84
+ // resize ever occurs to self-correct — expand()/toggle() become no-ops).
85
+ // This flag suppresses collapse-inference for the duration of an
86
+ // in-flight explicit expand(); cleared by `transitionend` (primary) or a
87
+ // bounded fallback timer sized to the actual computed transition
88
+ // duration (defensive — an interrupted transition doesn't always fire
89
+ // `transitionend` in every browser).
90
+ #expandInFlight = false;
91
+ #expandSettleTimer = null;
76
92
 
77
93
  connected() {
78
94
  this.#pane = this.querySelector('pane-ui');
@@ -131,6 +147,14 @@ class EditorSidebar extends UIElement {
131
147
  this.#persistWidth();
132
148
  };
133
149
  document.addEventListener('pointerup', this.#onPointerUp);
150
+
151
+ // §GH-223: the authoritative "explicit transition has settled" signal.
152
+ this.#onPaneTransitionEnd = (e) => {
153
+ if (e.target !== this.#pane || e.propertyName !== 'width') return;
154
+ this.#clearExpandInFlight();
155
+ this.#syncCollapsed();
156
+ };
157
+ this.#pane.addEventListener('transitionend', this.#onPaneTransitionEnd);
134
158
  }
135
159
 
136
160
  disconnected() {
@@ -138,6 +162,10 @@ class EditorSidebar extends UIElement {
138
162
  cancelAnimationFrame(this.#pendingCollapseRaf);
139
163
  this.#pendingCollapseRaf = null;
140
164
  }
165
+ if (this.#expandSettleTimer !== null) {
166
+ clearTimeout(this.#expandSettleTimer);
167
+ this.#expandSettleTimer = null;
168
+ }
141
169
  this.#ro?.disconnect();
142
170
  this.#ro = null;
143
171
  if (this.#onPointerDown && this.#pane) {
@@ -148,6 +176,10 @@ class EditorSidebar extends UIElement {
148
176
  document.removeEventListener('pointerup', this.#onPointerUp);
149
177
  this.#onPointerUp = null;
150
178
  }
179
+ if (this.#onPaneTransitionEnd && this.#pane) {
180
+ this.#pane.removeEventListener('transitionend', this.#onPaneTransitionEnd);
181
+ this.#onPaneTransitionEnd = null;
182
+ }
151
183
  }
152
184
 
153
185
  // ── Public API ──
@@ -178,10 +210,44 @@ class EditorSidebar extends UIElement {
178
210
  this.collapsed = false;
179
211
  this.#persistWidth();
180
212
  this.#emitToggle();
213
+ this.#markExpandInFlight();
181
214
  }
182
215
 
183
216
  // ── Internal ──
184
217
 
218
+ // §GH-223: suppress ResizeObserver-driven collapse-inference for the
219
+ // duration of this explicit expand()'s own CSS transition — see the
220
+ // #expandInFlight field comment for the full failure mode. Sized to the
221
+ // pane's ACTUAL computed transition-duration (not a hardcoded guess) so
222
+ // a customized `--editor-sidebar-duration` is honored; skipped entirely
223
+ // when the computed duration is 0 (no transition to race against —
224
+ // ResizeObserver's first sample already lands at the final width).
225
+ #markExpandInFlight() {
226
+ if (this.#expandSettleTimer !== null) {
227
+ clearTimeout(this.#expandSettleTimer);
228
+ this.#expandSettleTimer = null;
229
+ }
230
+ const durationStr = getComputedStyle(this.#pane).transitionDuration.split(',')[0].trim();
231
+ const durationMs = (parseFloat(durationStr) || 0) * (durationStr.endsWith('ms') ? 1 : 1000);
232
+ if (durationMs <= 0) return; // no transition — nothing to race
233
+ this.#expandInFlight = true;
234
+ // Fallback only — transitionend is the primary settle signal (fires
235
+ // first in the normal case). +50ms buffer for timer/paint jitter.
236
+ this.#expandSettleTimer = setTimeout(() => {
237
+ this.#expandSettleTimer = null;
238
+ this.#clearExpandInFlight();
239
+ this.#syncCollapsed();
240
+ }, durationMs + 50);
241
+ }
242
+
243
+ #clearExpandInFlight() {
244
+ this.#expandInFlight = false;
245
+ if (this.#expandSettleTimer !== null) {
246
+ clearTimeout(this.#expandSettleTimer);
247
+ this.#expandSettleTimer = null;
248
+ }
249
+ }
250
+
185
251
  #syncCollapsed() {
186
252
  if (!this.#pane) return;
187
253
  if (getComputedStyle(this).display === 'none') return;
@@ -198,6 +264,14 @@ class EditorSidebar extends UIElement {
198
264
  return;
199
265
  }
200
266
 
267
+ // §GH-223: an explicit expand() is still mid-transition — this small
268
+ // sample is the pane animating THROUGH the sub-threshold range toward
269
+ // its (above-threshold) target, not a genuine settle. Trust the
270
+ // transitionend/timer handler for the authoritative final check;
271
+ // inferring collapse=true here is exactly the bug (the resulting
272
+ // `!important` CSS snap permanently interrupts the transition).
273
+ if (this.#expandInFlight) return;
274
+
201
275
  // §FB-50: small sample — may be a CSS-grid collapse transient during
202
276
  // the wide→narrow display-flip (pane shrinks before display:none
203
277
  // propagates to getComputedStyle). Defer one animation frame; if
@@ -209,6 +283,7 @@ class EditorSidebar extends UIElement {
209
283
  this.#pendingCollapseRaf = null;
210
284
  if (!this.#pane) return;
211
285
  if (getComputedStyle(this).display === 'none') return;
286
+ if (this.#expandInFlight) return;
212
287
  const r = this.#pane.getBoundingClientRect();
213
288
  if (r.width > 0 && r.width <= SNAP_THRESHOLD) {
214
289
  this.collapsed = true;
@@ -207,6 +207,153 @@ describe('editor-sidebar', () => {
207
207
  globalThis.getComputedStyle = origGCS;
208
208
  });
209
209
 
210
+ // ── GH #223: expand() permanently wedges when the width transition is enabled ──
211
+ // Root cause: pane-ui's real CSS transition (editor-shell.bespoke.css) animates
212
+ // width from 48px toward the restore target; early ResizeObserver samples
213
+ // mid-transition are legitimately still <= SNAP_THRESHOLD, and the old
214
+ // #syncCollapsed() couldn't tell that apart from a genuine drag-to-collapse
215
+ // settle — flipping [collapsed] back to true mid-flight re-matches the
216
+ // `!important` collapsed-width CSS rule, which snaps the pane back to 48px
217
+ // and permanently interrupts the transition (no further resize ever occurs
218
+ // to self-correct). These tests mock a nonzero `transitionDuration` on the
219
+ // pane (jsdom/happy-dom has no real CSS loaded) to exercise the suppression.
220
+ describe('expand() transition race (GH #223)', () => {
221
+ function withPaneTransitionDuration(pane, durationStr) {
222
+ const origGCS = globalThis.getComputedStyle;
223
+ globalThis.getComputedStyle = (el, ps) => {
224
+ if (el === pane) return { ...origGCS(el, ps), transitionDuration: durationStr };
225
+ return origGCS(el, ps);
226
+ };
227
+ return () => { globalThis.getComputedStyle = origGCS; };
228
+ }
229
+
230
+ it('does not latch [collapsed] on a small mid-transition RO sample while an explicit expand() is in flight', async () => {
231
+ let roCallback;
232
+ const savedRO = globalThis.ResizeObserver;
233
+ globalThis.ResizeObserver = class {
234
+ constructor(cb) { roCallback = cb; }
235
+ observe() {} unobserve() {} disconnect() {}
236
+ };
237
+
238
+ const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
239
+ const pane = sb.querySelector('pane-ui');
240
+ const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
241
+ pane.style.width = '48px';
242
+ sb.collapsed = true;
243
+
244
+ sb.expand(); // sets pane width to the restore target + marks expand-in-flight
245
+
246
+ // Mid-transition: the observer samples an intermediate, still-small width.
247
+ pane.style.width = '77px';
248
+ if (roCallback) roCallback([{ target: pane }]);
249
+ await new Promise((r) => requestAnimationFrame(r));
250
+ await tick();
251
+
252
+ // Must NOT have snapped back to collapsed — this is exactly the wedge bug.
253
+ expect(sb.collapsed).toBe(false);
254
+ expect(sb.hasAttribute('collapsed')).toBe(false);
255
+
256
+ restoreGCS();
257
+ globalThis.ResizeObserver = savedRO;
258
+ });
259
+
260
+ it('clears expand-in-flight and re-syncs on transitionend, landing on the correct final state', async () => {
261
+ let roCallback;
262
+ const savedRO = globalThis.ResizeObserver;
263
+ globalThis.ResizeObserver = class {
264
+ constructor(cb) { roCallback = cb; }
265
+ observe() {} unobserve() {} disconnect() {}
266
+ };
267
+
268
+ const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
269
+ const pane = sb.querySelector('pane-ui');
270
+ const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
271
+ pane.style.width = '48px';
272
+ sb.collapsed = true;
273
+
274
+ sb.expand();
275
+ pane.style.width = '240px'; // transition has now settled at its target
276
+ // `propertyName` is a TransitionEvent field, not a plain Event
277
+ // constructor option (the constructor silently drops unknown keys) —
278
+ // assign it as an own property after construction instead.
279
+ const transitionEnd = new Event('transitionend', { bubbles: false });
280
+ transitionEnd.propertyName = 'width';
281
+ pane.dispatchEvent(transitionEnd);
282
+ await tick();
283
+
284
+ expect(sb.collapsed).toBe(false);
285
+ expect(sb.hasAttribute('collapsed')).toBe(false);
286
+
287
+ // With expand-in-flight cleared, a genuinely small sample (e.g. a
288
+ // later user drag-to-collapse) must resume being trusted normally.
289
+ pane.style.width = '48px';
290
+ if (roCallback) roCallback([{ target: pane }]);
291
+ await new Promise((r) => requestAnimationFrame(r));
292
+ await tick();
293
+ expect(sb.collapsed).toBe(true);
294
+
295
+ restoreGCS();
296
+ globalThis.ResizeObserver = savedRO;
297
+ });
298
+
299
+ it('falls back to a bounded timer sized to the computed duration when transitionend never fires', async () => {
300
+ vi.useFakeTimers();
301
+ let roCallback;
302
+ const savedRO = globalThis.ResizeObserver;
303
+ globalThis.ResizeObserver = class {
304
+ constructor(cb) { roCallback = cb; }
305
+ observe() {} unobserve() {} disconnect() {}
306
+ };
307
+
308
+ const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
309
+ const pane = sb.querySelector('pane-ui');
310
+ const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
311
+ pane.style.width = '48px';
312
+ sb.collapsed = true;
313
+
314
+ sb.expand();
315
+ pane.style.width = '77px'; // stuck reporting a small value; no transitionend ever arrives
316
+ if (roCallback) roCallback([{ target: pane }]);
317
+ await vi.advanceTimersByTimeAsync(50); // rAF-equivalent settle window (fake timers)
318
+
319
+ // Still suppressed — the 180ms+50ms safety timer hasn't elapsed yet.
320
+ expect(sb.collapsed).toBe(false);
321
+
322
+ pane.style.width = '240px'; // by the time the safety timer fires, transition is done
323
+ await vi.advanceTimersByTimeAsync(300); // past the 180+50ms fallback
324
+ expect(sb.collapsed).toBe(false); // final re-sync sees the real (large) width
325
+
326
+ restoreGCS();
327
+ globalThis.ResizeObserver = savedRO;
328
+ vi.useRealTimers();
329
+ });
330
+
331
+ it('skips expand-in-flight suppression entirely when the computed transition duration is 0 (no transition to race)', async () => {
332
+ let roCallback;
333
+ const savedRO = globalThis.ResizeObserver;
334
+ globalThis.ResizeObserver = class {
335
+ constructor(cb) { roCallback = cb; }
336
+ observe() {} unobserve() {} disconnect() {}
337
+ };
338
+
339
+ const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
340
+ const pane = sb.querySelector('pane-ui');
341
+ // No transitionDuration mock — jsdom/happy-dom defaults to '0s'.
342
+ pane.style.width = '48px';
343
+ sb.collapsed = true;
344
+
345
+ sb.expand();
346
+ // With a 0-duration transition, the width jump is instantaneous — the
347
+ // observer's first sample already lands at the final (large) width.
348
+ pane.style.width = '240px';
349
+ if (roCallback) roCallback([{ target: pane }]);
350
+ await tick();
351
+
352
+ expect(sb.collapsed).toBe(false);
353
+ globalThis.ResizeObserver = savedRO;
354
+ });
355
+ });
356
+
210
357
  it('handles missing inner <pane-ui> gracefully (no crash)', () => {
211
358
  const sb = mount('<editor-sidebar slot="leading"></editor-sidebar>');
212
359
  // Should not throw
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-modules",
3
- "version": "0.7.26",
3
+ "version": "0.7.28",
4
4
  "description": "AdiaUI composite custom elements \u2014 shell, chat, editor, runtime clusters built from @adia-ai/web-components primitives. Subpath exports per cluster.",
5
5
  "type": "module",
6
6
  "exports": {