From 35b08ca80286fcb1e6c65dcc4e1299512d533e6d Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 18:08:35 +1000 Subject: [PATCH 01/17] refactor: replace pane lifecycle hooks with an event emitter Replace the onBeforePaneAdd/onPaneAdd/onBeforePaneRemove/onPaneRemove override hooks with an on/off/emit event system. A before-* listener vetoes by returning false; emit runs every listener and reports a veto if any returned false. Listeners are per-instance (Map of event name -> Set of listeners), fixing the cross-instance leak a shared emitter would have had. --- dev/window/bwin-add-remove-panes.js | 32 ++--- docs/ARCHITECTURE.md | 21 +-- src/frame/event.js | 26 ++++ src/frame/event.test.js | 212 ++++++++++++++++++++++++++++ src/frame/frame.js | 4 +- src/frame/pane.js | 15 +- 6 files changed, 273 insertions(+), 37 deletions(-) create mode 100644 src/frame/event.js create mode 100644 src/frame/event.test.js diff --git a/dev/window/bwin-add-remove-panes.js b/dev/window/bwin-add-remove-panes.js index 164ae44..30c57bb 100644 --- a/dev/window/bwin-add-remove-panes.js +++ b/dev/window/bwin-add-remove-panes.js @@ -21,25 +21,25 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); -bwin.onBeforePaneRemove = (paneSash) => { - console.log('onBeforePaneRemove:', paneSash); - console.log('onBeforePaneRemove - domNode: ', paneSash.domNode); - return true; -}; +bwin.on('before-pane-add', (targetPaneSash) => { + console.log('before-pane-add (target):', targetPaneSash); + // return false to veto the add + return false; +}); -bwin.onPaneRemove = (paneSash) => { - console.log('onPaneRemove:', paneSash); - console.log('onPaneRemove - domNode: ', paneSash.domNode); -}; +bwin.on('pane-add', (newPaneSash) => { + console.log('pane-add (new):', newPaneSash); +}); -bwin.onBeforePaneAdd = (targetPaneSash) => { - console.log('onBeforePaneAdd:', targetPaneSash); - return true; -}; +bwin.on('before-pane-remove', (paneSash) => { + console.log('before-pane-remove:', paneSash); + // return false to veto the removal + // return false; +}); -bwin.onPaneAdd = (targetPaneSash) => { - console.log('onPaneAdd:', targetPaneSash); -}; +bwin.on('pane-remove', (paneSash) => { + console.log('pane-remove:', paneSash); +}); document.querySelector('#add-pane').addEventListener('click', () => { const parentId = document.querySelector('#sash-id').value.trim(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 483ecf4..93f7924 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -100,7 +100,7 @@ BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule, sillModule); **Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, _not_ `enableResize`. When adding a feature, prefix its methods with the feature name. -**Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` — plus the pane add/remove lifecycle hooks (`onBeforePaneAdd`/`onPaneAdd`/`onBeforePaneRemove`/`onPaneRemove`, see §7) — as empty "intended to be overridden" hooks. +**Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` as empty "intended to be overridden" hooks. (The pane add/remove **lifecycle is observed via events**, not override hooks — see §7.) --- @@ -252,16 +252,16 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si `createPaneElement`/`updatePaneElement` write `top/left/width/height` styles and `sash-id`/`position` attributes. `addPaneSash` (+ the four `addPaneSashTo{Left,Right,Top,Bottom}` helpers) performs the **tree surgery** for a split: it converts the target leaf into a split parent (target gets a fresh muntin ID, `domNode = null`), creates the two child sashes (one inherits the target's old ID + `domNode`), and returns the new sash. Sizing honors `size` as fraction or px. -### Pane add/remove lifecycle hooks (`frame/pane.js`) +### Pane add/remove lifecycle events (`frame/event.js` + `frame/pane.js`) -`addPane`/`removePane` (the `paneModule` mixin) fire four empty, overridable hooks around the tree mutation + `update()`. They're no-ops by default, intended to be set by consumer code (or a subclass); like the other lifecycle hooks they live undifferentiated on the instance (see §3). +`addPane`/`removePane` (the `paneModule` mixin) **emit events** around the tree mutation + `update()`. The `eventModule` mixin (`frame/event.js`) provides `on`/`off`/`emit` — a minimal per-instance emitter (a `Map` of event name → `Set` of listeners, lazily created on first `on`/`emit`, so each `Frame`/`BinaryWindow` keeps its own listeners). `detail` is the relevant `Sash`. -- `onBeforePaneAdd(targetPaneSash)` — before the split. Returning `false` **vetoes** the add: no tree surgery runs and `addPane` returns `null`. `BinaryWindow.addPane` short-circuits to `null` in turn (no placeholder `Glass` is seeded). -- `onPaneAdd(newPaneSash)` — after `update()`, with the new sash rendered. -- `onBeforePaneRemove(paneSash)` — before the removal. Returning `false` **vetoes** the remove: `removePane` bails out before touching the tree. -- `onPaneRemove(paneSash)` — after `update()`. At this point `paneSash.domNode` still exists but has already been **detached from the DOM** during `update()`, so it's a handle to the removed (orphaned) node, not a live element. +- `before-pane-add` — `(targetPaneSash)`, before the split. A listener returning `false` **vetoes** the add: no tree surgery runs and `addPane` returns `null`. `BinaryWindow.addPane` short-circuits to `null` in turn (no placeholder `Glass` is seeded). +- `pane-add` — `(newPaneSash)`, after `update()`, with the new sash rendered. +- `before-pane-remove` — `(paneSash)`, before the removal. A listener returning `false` **vetoes** the remove: `removePane` bails out before touching the tree. +- `pane-remove` — `(paneSash)`, after `update()`. At this point `paneSash.domNode` still exists but has already been **detached from the DOM** during `update()`, so it's a handle to the removed (orphaned) node, not a live element. -A hook returning `false` is the only veto signal — any other return value (including `undefined`, the no-op default) proceeds. +`emit` runs **every** listener, then reports a veto if **any** of them returned `false` (so veto is order-independent and all listeners still see the event). Returning `false` is the only veto signal — any other return value (including `undefined`) proceeds. Only the `before-*` events are vetoable. ### Muntins (`frame/muntin.js` + `binary-window/trim.js`) @@ -362,8 +362,9 @@ Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position` Key methods on `BinaryWindow`: - `mount(containerEl)` / `frame()` / `enableFeatures()` — lifecycle. -- `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. Returns `null` if the `onBeforePaneAdd` hook vetoed the add (§7). -- `removePane(sashId)` — remove a pane (and clean up a minimized sill pot if present). A no-op if the `onBeforePaneRemove` hook vetoed the remove (§7). +- `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. Returns `null` if a `before-pane-add` listener vetoed the add (§7). +- `removePane(sashId)` — remove a pane (and clean up a minimized sill pot if present). A no-op if a `before-pane-remove` listener vetoed the remove (§7). +- `on(eventName, listener)` / `off(eventName, listener)` — subscribe/unsubscribe to lifecycle events (§7). - `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels inside the window. - `addWindowlessGlass(options)` / `removeWindowlessGlass(id)` — _static_ floating panels on `document.body`, with no owning window (§8.6). - `setTheme(theme)` / `fit()`. diff --git a/src/frame/event.js b/src/frame/event.js new file mode 100644 index 0000000..dc58fba --- /dev/null +++ b/src/frame/event.js @@ -0,0 +1,26 @@ +export default { + on(eventName, listener) { + this.eventListeners ??= new Map(); + + if (!this.eventListeners.has(eventName)) { + this.eventListeners.set(eventName, new Set()); + } + this.eventListeners.get(eventName).add(listener); + }, + + off(eventName, listener) { + this.eventListeners?.get(eventName)?.delete(listener); + }, + + // Runs every listener; returns `false` when any listener vetoed (returned `false`), else `true`. + emit(eventName, detail) { + const listeners = this.eventListeners?.get(eventName); + if (!listeners) return true; + + let allowed = true; + for (const listener of listeners) { + if (listener(detail) === false) allowed = false; + } + return allowed; + }, +}; diff --git a/src/frame/event.test.js b/src/frame/event.test.js new file mode 100644 index 0000000..903692d --- /dev/null +++ b/src/frame/event.test.js @@ -0,0 +1,212 @@ +import { describe, it, expect } from 'vitest'; +import eventModule from './event'; + +// Each test gets a fresh emitter, mirroring how `eventModule` is assembled onto +// a `Frame`/`BinaryWindow` instance (its methods run with `this` as that instance). +function makeEmitter() { + return Object.create(eventModule); +} + +describe('on / emit', () => { + it('calls a registered listener with the emitted detail', () => { + const e = makeEmitter(); + const detail = { sash: 'a' }; + let received; + e.on('pane-add', (d) => (received = d)); + + e.emit('pane-add', detail); + + expect(received).toBe(detail); // same reference, not a copy + }); + + it('passes `undefined` detail through when none is given', () => { + const e = makeEmitter(); + let called = false; + let received = 'sentinel'; + e.on('pane-add', (d) => { + called = true; + received = d; + }); + + e.emit('pane-add'); + + expect(called).toBe(true); + expect(received).toBeUndefined(); + }); + + it('calls all listeners for an event, in registration order', () => { + const e = makeEmitter(); + const calls = []; + e.on('pane-add', () => calls.push('first')); + e.on('pane-add', () => calls.push('second')); + e.on('pane-add', () => calls.push('third')); + + e.emit('pane-add'); + + expect(calls).toEqual(['first', 'second', 'third']); + }); + + it('only calls listeners for the emitted event name', () => { + const e = makeEmitter(); + let addCalls = 0; + let removeCalls = 0; + e.on('pane-add', () => addCalls++); + e.on('pane-remove', () => removeCalls++); + + e.emit('pane-add'); + + expect(addCalls).toBe(1); + expect(removeCalls).toBe(0); + }); + + it('registers the same listener only once (Set dedupe)', () => { + const e = makeEmitter(); + let count = 0; + const listener = () => count++; + e.on('pane-add', listener); + e.on('pane-add', listener); + + e.emit('pane-add'); + + expect(count).toBe(1); + }); + + it('returns true and does not throw when an event has no listeners', () => { + const e = makeEmitter(); + expect(e.emit('pane-add')).toBe(true); + }); + + it('does not throw when emitting before any listener was ever registered', () => { + const e = makeEmitter(); + // `eventListeners` map is not created until the first `on`/`emit`. + expect(() => e.emit('never-registered')).not.toThrow(); + }); +}); + +describe('emit veto semantics', () => { + it('returns true when no listener returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => {}); + expect(e.emit('before-pane-remove')).toBe(true); + }); + + it('returns false when a listener returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => false); + expect(e.emit('before-pane-remove')).toBe(false); + }); + + it('returns false if any one of several listeners returns false', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => true); + e.on('before-pane-remove', () => false); + e.on('before-pane-remove', () => undefined); + expect(e.emit('before-pane-remove')).toBe(false); + }); + + it('vetoes regardless of which listener returns false (order-independent)', () => { + const veto = makeEmitter(); + veto.on('e', () => false); + veto.on('e', () => true); + + const veto2 = makeEmitter(); + veto2.on('e', () => true); + veto2.on('e', () => false); + + expect(veto.emit('e')).toBe(false); + expect(veto2.emit('e')).toBe(false); + }); + + it('runs every listener even after one has vetoed (no short-circuit)', () => { + const e = makeEmitter(); + const calls = []; + e.on('before-pane-remove', () => { + calls.push('first'); + return false; + }); + e.on('before-pane-remove', () => calls.push('second')); + + e.emit('before-pane-remove'); + + expect(calls).toEqual(['first', 'second']); + }); + + it('only an explicit `false` vetoes — other falsy returns proceed', () => { + for (const value of [undefined, null, 0, '', NaN]) { + const e = makeEmitter(); + e.on('before-pane-remove', () => value); + expect(e.emit('before-pane-remove')).toBe(true); + } + }); + + it('does not treat a truthy return as a veto', () => { + const e = makeEmitter(); + e.on('before-pane-remove', () => 'yes'); + expect(e.emit('before-pane-remove')).toBe(true); + }); +}); + +describe('off', () => { + it('removes a previously registered listener', () => { + const e = makeEmitter(); + let count = 0; + const listener = () => count++; + e.on('pane-add', listener); + + e.off('pane-add', listener); + e.emit('pane-add'); + + expect(count).toBe(0); + }); + + it('leaves other listeners on the same event intact', () => { + const e = makeEmitter(); + let kept = 0; + const removed = () => {}; + const keptListener = () => kept++; + e.on('pane-add', removed); + e.on('pane-add', keptListener); + + e.off('pane-add', removed); + e.emit('pane-add'); + + expect(kept).toBe(1); + }); + + it('is a no-op for a listener that was never registered', () => { + const e = makeEmitter(); + e.on('pane-add', () => {}); + expect(() => e.off('pane-add', () => {})).not.toThrow(); + }); + + it('is a no-op for an unknown event name', () => { + const e = makeEmitter(); + expect(() => e.off('unknown', () => {})).not.toThrow(); + }); + + it('does not throw when called before any listener was registered', () => { + const e = makeEmitter(); + expect(() => e.off('pane-add', () => {})).not.toThrow(); + }); +}); + +describe('instance isolation', () => { + it('does not share listeners between instances', () => { + const a = makeEmitter(); + const b = makeEmitter(); + let aCalls = 0; + a.on('pane-add', () => aCalls++); + + b.emit('pane-add'); // b has no listeners of its own + + expect(aCalls).toBe(0); + }); + + it("a veto on one instance does not affect another instance's emit", () => { + const a = makeEmitter(); + const b = makeEmitter(); + a.on('before-pane-remove', () => false); + + expect(b.emit('before-pane-remove')).toBe(true); + }); +}); diff --git a/src/frame/frame.js b/src/frame/frame.js index abd3d9f..c65dc11 100644 --- a/src/frame/frame.js +++ b/src/frame/frame.js @@ -7,6 +7,7 @@ import muntinModule from './muntin'; import fitContainerModule from './fit-container'; import resizableModule from './resizable'; import droppableModule from './droppable'; +import eventModule from './event'; const DEBUG = import.meta.env.VITE_DEBUG == 'true' ? true : false; @@ -70,5 +71,6 @@ Frame.assemble( paneModule, fitContainerModule, droppableModule, - resizableModule + resizableModule, + eventModule ); diff --git a/src/frame/pane.js b/src/frame/pane.js index b396aa5..80c3598 100644 --- a/src/frame/pane.js +++ b/src/frame/pane.js @@ -80,12 +80,13 @@ export default { const targetPaneSash = this.rootSash.getById(targetPaneSashId); if (!targetPaneSash) throw new Error('[bwin] Parent sash not found when adding pane'); - const mustAdd = this.onBeforePaneAdd(targetPaneSash); + const mustAdd = this.emit('before-pane-add', targetPaneSash); if (mustAdd === false) return null; const newPaneSash = addPaneSash(targetPaneSash, { position, size, id, minWidth, minHeight }); this.update(); - this.onPaneAdd(newPaneSash); + + this.emit('pane-add', newPaneSash); return newPaneSash; }, @@ -101,7 +102,7 @@ export default { const sash = this.rootSash.getById(sashId); if (!sash) throw new Error('[bwin] Sash not found when removing pane'); - const mustRemove = this.onBeforePaneRemove(sash); + const mustRemove = this.emit('before-pane-remove', sash); if (mustRemove === false) return; const siblingSash = parentSash.getChildSiblingById(sashId); @@ -137,7 +138,7 @@ export default { this.update(); // `sash.domNode` still exists at this point, // but was removed from the DOM during `this.update()` - this.onPaneRemove(sash); + this.emit('pane-remove', sash); }, swapPanes(sourcePaneEl, targetPaneEl) { @@ -156,12 +157,6 @@ export default { sourcePaneEl.setAttribute('can-drop', targetPaneCanDrop); targetPaneEl.setAttribute('can-drop', sourcePaneCanDrop); }, - - // To be overridden by user code - onBeforePaneAdd(targetPaneSash) {}, - onPaneAdd(newPaneSash) {}, - onBeforePaneRemove(paneSash) {}, - onPaneRemove(paneSash) {}, }; function __debug(parentEl) { From 84025beddf952b36bc4d2486f308001f18f5f0a1 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 21:19:13 +1000 Subject: [PATCH 02/17] feat: wire up action events --- dev/window/bwin-action-hooks.html | 14 ++++++ dev/window/bwin-action-hooks.js | 46 +++++++++++++++++++ dev/window/bwin-add-remove-panes.js | 2 +- .../detached-glass/action.attach.js | 2 + .../detached-glass/action.close.js | 4 +- .../detached-glass/action.minimize.js | 2 + src/binary-window/glass/action.close.js | 3 ++ src/binary-window/glass/action.detach.js | 1 + src/binary-window/glass/action.maximize.js | 5 +- src/binary-window/glass/action.minimize.js | 2 + src/binary-window/sill.js | 2 + 11 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 dev/window/bwin-action-hooks.html create mode 100644 dev/window/bwin-action-hooks.js diff --git a/dev/window/bwin-action-hooks.html b/dev/window/bwin-action-hooks.html new file mode 100644 index 0000000..639a3ba --- /dev/null +++ b/dev/window/bwin-action-hooks.html @@ -0,0 +1,14 @@ + + + + + + + + +
+

Debug

+
+
+ + diff --git a/dev/window/bwin-action-hooks.js b/dev/window/bwin-action-hooks.js new file mode 100644 index 0000000..9c86211 --- /dev/null +++ b/dev/window/bwin-action-hooks.js @@ -0,0 +1,46 @@ +import { BinaryWindow, BUILTIN_ACTIONS } from '../../src'; + +const settings = { + width: 444, + height: 333, + children: [ + { position: 'left', size: '40%', actions: BUILTIN_ACTIONS }, + { + children: [ + { position: 'top', size: '30%' }, + { position: 'bottom', size: '70%' }, + ], + }, + ], +}; + +const bwin = new BinaryWindow(settings); +bwin.mount(document.querySelector('#container')); + +bwin.on('detach', (glassEl) => { + console.log('detach:', glassEl); +}); + +bwin.on('close', (glassEl) => { + console.log('close:', glassEl); +}); + +bwin.on('maximize', (glassEl) => { + console.log('maximize:', glassEl); +}); + +bwin.on('unmaximize', (glassEl) => { + console.log('unmaximize:', glassEl); +}); + +bwin.on('minimize', (glassEl) => { + console.log('minimize:', glassEl); +}); + +bwin.on('restore', (glassEl) => { + console.log('restore:', glassEl); +}); + +bwin.on('attach', (glassEl) => { + console.log('attach:', glassEl); +}); diff --git a/dev/window/bwin-add-remove-panes.js b/dev/window/bwin-add-remove-panes.js index 30c57bb..7ca9b5c 100644 --- a/dev/window/bwin-add-remove-panes.js +++ b/dev/window/bwin-add-remove-panes.js @@ -24,7 +24,7 @@ bwin.mount(document.querySelector('#container')); bwin.on('before-pane-add', (targetPaneSash) => { console.log('before-pane-add (target):', targetPaneSash); // return false to veto the add - return false; + // return false; }); bwin.on('pane-add', (newPaneSash) => { diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 43833df..1cc5c62 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -35,5 +35,7 @@ export default { // Skip the close animation: the glass is being moved into a pane, not dismissed. binaryWindow.removeDetachedGlass(detachedGlassEl.id, false); + + binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); }, }; diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index fb53635..25f5487 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -5,11 +5,13 @@ export default { placement: 'bar', label: '', className: 'bw-action--close', - onClick: (event) => { + onClick: (event, binaryWindow) => { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; // Manager handles both detached and windowless glass (no binaryWindow needed). detachedGlassManager.removeDetachedGlass(glassEl.id, { animateClose: true }); + + binaryWindow.emit('close', glassEl); }, }; diff --git a/src/binary-window/detached-glass/action.minimize.js b/src/binary-window/detached-glass/action.minimize.js index 5658d50..2ed81c5 100644 --- a/src/binary-window/detached-glass/action.minimize.js +++ b/src/binary-window/detached-glass/action.minimize.js @@ -21,5 +21,7 @@ export default { animateElementToElement(detachedGlassEl, potEl, () => { detachedGlassEl.style.display = 'none'; }); + + binaryWindow.emit('minimize', detachedGlassEl); }, }; diff --git a/src/binary-window/glass/action.close.js b/src/binary-window/glass/action.close.js index 4c50ed9..5abf524 100644 --- a/src/binary-window/glass/action.close.js +++ b/src/binary-window/glass/action.close.js @@ -7,6 +7,9 @@ export default { className: 'bw-action--close', onClick: (event, binaryWindow) => { const sashId = getSashIdFromPane(event.target); + const glassEl = binaryWindow.rootSash.getById(sashId).domNode.querySelector('bw-glass'); + binaryWindow.removePane(sashId); + binaryWindow.emit('close', glassEl); }, }; diff --git a/src/binary-window/glass/action.detach.js b/src/binary-window/glass/action.detach.js index ea20a50..74559b1 100644 --- a/src/binary-window/glass/action.detach.js +++ b/src/binary-window/glass/action.detach.js @@ -29,5 +29,6 @@ export default { transferGlass(glassEl, detachedGlassEl); binaryWindow.removePane(paneSashId); + binaryWindow.emit('detach', detachedGlassEl); }, }; diff --git a/src/binary-window/glass/action.maximize.js b/src/binary-window/glass/action.maximize.js index 5e6f92f..3af1813 100644 --- a/src/binary-window/glass/action.maximize.js +++ b/src/binary-window/glass/action.maximize.js @@ -5,8 +5,9 @@ export default { placement: 'bar', label: '', className: 'bw-action--maximize', - onClick: (event) => { + onClick: (event, binaryWindow) => { const paneEl = event.target.closest('bw-pane'); + const glassEl = paneEl.querySelector('bw-glass'); if (paneEl.hasAttribute('maximized')) { paneEl.removeAttribute('maximized'); @@ -14,6 +15,7 @@ export default { paneEl.style.top = `${paneEl.bwOriginalBoundingRect.top}px`; paneEl.style.width = `${paneEl.bwOriginalBoundingRect.width}px`; paneEl.style.height = `${paneEl.bwOriginalBoundingRect.height}px`; + binaryWindow.emit('unmaximize', glassEl); } else { paneEl.setAttribute('maximized', ''); @@ -22,6 +24,7 @@ export default { paneEl.style.top = '0'; paneEl.style.width = '100%'; paneEl.style.height = '100%'; + binaryWindow.emit('maximize', glassEl); } }, }; diff --git a/src/binary-window/glass/action.minimize.js b/src/binary-window/glass/action.minimize.js index 32699ca..237923e 100644 --- a/src/binary-window/glass/action.minimize.js +++ b/src/binary-window/glass/action.minimize.js @@ -24,5 +24,7 @@ export default { potEl.bwOriginalSashId = paneSashId; sillEl.append(potEl); + + binaryWindow.emit('minimize', glassEl); }, }; diff --git a/src/binary-window/sill.js b/src/binary-window/sill.js index a15b455..ed4b3da 100644 --- a/src/binary-window/sill.js +++ b/src/binary-window/sill.js @@ -34,6 +34,7 @@ export default { animateDetachedGlassOpen(detachedGlassEl); potEl.remove(); detachedGlassManager.bringToFront(detachedGlassEl); + this.emit('restore', detachedGlassEl); }); }, @@ -89,6 +90,7 @@ export default { withGlass: false, }); newSashPane.domNode.append(potEl.bwGlassElement); + this.emit('restore', potEl.bwGlassElement); } }, From 24dca4b250c4fc8d2a61c7143de1e2d65238a5d0 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 21:20:58 +1000 Subject: [PATCH 03/17] refactor: rename param name --- src/animate.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/animate.js b/src/animate.js index 0abfc65..78ba35f 100644 --- a/src/animate.js +++ b/src/animate.js @@ -1,7 +1,7 @@ // FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out. // Both must be laid out (in the DOM, not `display:none`) so their rects measure. -// Runs `onDone` when the flight ends (e.g. to hide/remove the source). -export function animateElementToElement(sourceEl, targetEl, onDone) { +// Runs `onFinish` when the flight ends (e.g. to hide/remove the source). +export function animateElementToElement(sourceEl, targetEl, onFinish) { const SHRINK_FLIGHT_DURATION = 200; const sourceRect = sourceEl.getBoundingClientRect(); @@ -33,7 +33,7 @@ export function animateElementToElement(sourceEl, targetEl, onDone) { () => { sourceEl.style.pointerEvents = ''; sourceEl.style.transformOrigin = ''; - onDone?.(); + onFinish?.(); }, { once: true } ); From 4f5154f88cdbf413d8b2769c33fb7268c7f41b76 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 21:48:19 +1000 Subject: [PATCH 04/17] refactor: handle removal of panes with animation complete --- src/animate.js | 6 +++--- .../detached-glass/action.attach.js | 7 ++++--- .../detached-glass/action.close.js | 10 ++++------ .../detached-glass/action.minimize.js | 3 +-- src/binary-window/detached-glass/crud.js | 8 ++++---- src/binary-window/detached-glass/manager.js | 4 ++-- src/binary-window/detached-glass/utils.js | 17 ++++++++++------- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/animate.js b/src/animate.js index 78ba35f..71edcf0 100644 --- a/src/animate.js +++ b/src/animate.js @@ -1,7 +1,7 @@ // FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out. // Both must be laid out (in the DOM, not `display:none`) so their rects measure. -// Runs `onFinish` when the flight ends (e.g. to hide/remove the source). -export function animateElementToElement(sourceEl, targetEl, onFinish) { +// Runs `onComplete` when the flight ends (e.g. to hide/remove the source). +export function animateElementToElement(sourceEl, targetEl, onComplete) { const SHRINK_FLIGHT_DURATION = 200; const sourceRect = sourceEl.getBoundingClientRect(); @@ -33,7 +33,7 @@ export function animateElementToElement(sourceEl, targetEl, onFinish) { () => { sourceEl.style.pointerEvents = ''; sourceEl.style.transformOrigin = ''; - onFinish?.(); + onComplete?.(); }, { once: true } ); diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 1cc5c62..80b8c66 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -34,8 +34,9 @@ export default { transferGlass(detachedGlassEl, paneSash.domNode); // Skip the close animation: the glass is being moved into a pane, not dismissed. - binaryWindow.removeDetachedGlass(detachedGlassEl.id, false); - - binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); + binaryWindow.removeDetachedGlass(detachedGlassEl.id, { + animateClose: false, + onComplete: () => binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')), + }); }, }; diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index 25f5487..f83fc8f 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -1,5 +1,3 @@ -import { detachedGlassManager } from './manager'; - export default { type: 'detached-glass-builtin', placement: 'bar', @@ -9,9 +7,9 @@ export default { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; - // Manager handles both detached and windowless glass (no binaryWindow needed). - detachedGlassManager.removeDetachedGlass(glassEl.id, { animateClose: true }); - - binaryWindow.emit('close', glassEl); + binaryWindow.removeDetachedGlass(glassEl.id, { + animateClose: true, + onComplete: () => binaryWindow.emit('close', glassEl), + }); }, }; diff --git a/src/binary-window/detached-glass/action.minimize.js b/src/binary-window/detached-glass/action.minimize.js index 2ed81c5..9183143 100644 --- a/src/binary-window/detached-glass/action.minimize.js +++ b/src/binary-window/detached-glass/action.minimize.js @@ -20,8 +20,7 @@ export default { potEl.bwDetachedGlassElement = detachedGlassEl; animateElementToElement(detachedGlassEl, potEl, () => { detachedGlassEl.style.display = 'none'; + binaryWindow.emit('minimize', detachedGlassEl); }); - - binaryWindow.emit('minimize', detachedGlassEl); }, }; diff --git a/src/binary-window/detached-glass/crud.js b/src/binary-window/detached-glass/crud.js index d89180f..b16d86a 100644 --- a/src/binary-window/detached-glass/crud.js +++ b/src/binary-window/detached-glass/crud.js @@ -53,11 +53,11 @@ export default { return glassEl; }, - removeDetachedGlass(detachedGlassId, animateClose = true) { - return detachedGlassManager.removeDetachedGlass(detachedGlassId, { animateClose }); + removeDetachedGlass(...args) { + return detachedGlassManager.removeDetachedGlass(...args); }, - updateDetachedGlass(detachedGlassId, options) { - return detachedGlassManager.updateDetachedGlass(detachedGlassId, options); + updateDetachedGlass(...args) { + return detachedGlassManager.updateDetachedGlass(...args); }, }; diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index 01d978b..6415e06 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -55,12 +55,12 @@ class DetachedGlassManager { // Unregister and tear down: splice from the registry AND remove the DOM node // (animated by default) plus any modal backdrop. - removeDetachedGlass(id, { animateClose = true } = {}) { + removeDetachedGlass(id, { animateClose = true, onComplete = () => {} } = {}) { const index = this.detachedGlassElements.findIndex((glassEl) => glassEl.id === id); if (index === -1) return null; const [removedGlassEl] = this.detachedGlassElements.splice(index, 1); - removeDetachedGlassElement(removedGlassEl, animateClose); + removeDetachedGlassElement(removedGlassEl, animateClose, onComplete); return removedGlassEl; } diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index ee38846..4a89084 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -34,22 +34,25 @@ export function animateDetachedGlassOpen(detachedGlassEl) { ); } -// Remove a detached glass element from the DOM (+ any modal backdrop). CSS can't -// animate a normal element out, so `[closing]` drives the close animation and we -// defer the actual removal until it ends. Pass `animateClose: false` to remove now. -export function removeDetachedGlassElement(detachedGlassEl, animateClose = true) { - const remove = () => { +export function removeDetachedGlassElement( + detachedGlassEl, + animateClose = true, + onComplete = () => {} +) { + const handleRemove = () => { detachedGlassEl.remove(); + detachedGlassEl.removeAttribute('closing'); removeGlassBackdrop(detachedGlassEl.id); + onComplete(); }; if (!animateClose) { - remove(); + handleRemove(); return; } detachedGlassEl.setAttribute('closing', ''); - detachedGlassEl.addEventListener('animationend', remove, { once: true }); + detachedGlassEl.addEventListener('animationend', handleRemove, { once: true }); } // Viewport-space top-left of an absolutely-positioned element's containing block. From 8ee0207468af7aa959a3ba76fd816dc3526c157e Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 22:03:15 +1000 Subject: [PATCH 05/17] refactor: enhance attach experience --- .../detached-glass/action.attach.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 80b8c66..17c20df 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -26,17 +26,17 @@ export default { size = 0.5; } - const paneSash = binaryWindow.addPane(targetSashId, { - position, - size, - }); - - transferGlass(detachedGlassEl, paneSash.domNode); - - // Skip the close animation: the glass is being moved into a pane, not dismissed. binaryWindow.removeDetachedGlass(detachedGlassEl.id, { - animateClose: false, - onComplete: () => binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')), + animateClose: true, + onComplete: () => { + const paneSash = binaryWindow.addPane(targetSashId, { + position, + size, + }); + + transferGlass(detachedGlassEl, paneSash.domNode); + binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); + }, }); }, }; From 61544b74a844cb8d82190b93faa98f6e1633e55d Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 23:04:48 +1000 Subject: [PATCH 06/17] refactor: make animateElementToElement return a promise Drop the onComplete callback; return animation.finished so callers chain .then() instead. --- src/animate.js | 17 ++++++----------- .../detached-glass/action.minimize.js | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/animate.js b/src/animate.js index 71edcf0..208f10e 100644 --- a/src/animate.js +++ b/src/animate.js @@ -1,7 +1,7 @@ // FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out. // Both must be laid out (in the DOM, not `display:none`) so their rects measure. -// Runs `onComplete` when the flight ends (e.g. to hide/remove the source). -export function animateElementToElement(sourceEl, targetEl, onComplete) { +// Resolves when the flight ends (e.g. to then hide/remove the source). +export function animateElementToElement(sourceEl, targetEl) { const SHRINK_FLIGHT_DURATION = 200; const sourceRect = sourceEl.getBoundingClientRect(); @@ -28,13 +28,8 @@ export function animateElementToElement(sourceEl, targetEl, onComplete) { // top-left origin so the translate/scale maps the source corner onto the target corner sourceEl.style.transformOrigin = 'top left'; - animation.addEventListener( - 'finish', - () => { - sourceEl.style.pointerEvents = ''; - sourceEl.style.transformOrigin = ''; - onComplete?.(); - }, - { once: true } - ); + return animation.finished.then(() => { + sourceEl.style.pointerEvents = ''; + sourceEl.style.transformOrigin = ''; + }); } diff --git a/src/binary-window/detached-glass/action.minimize.js b/src/binary-window/detached-glass/action.minimize.js index 9183143..8c15fb7 100644 --- a/src/binary-window/detached-glass/action.minimize.js +++ b/src/binary-window/detached-glass/action.minimize.js @@ -18,7 +18,7 @@ export default { throw new Error(`[bwin] Detached Glass element not found when minimizing`); potEl.bwDetachedGlassElement = detachedGlassEl; - animateElementToElement(detachedGlassEl, potEl, () => { + animateElementToElement(detachedGlassEl, potEl).then(() => { detachedGlassEl.style.display = 'none'; binaryWindow.emit('minimize', detachedGlassEl); }); From 9c5e1f878b47635d809bfe854aa8850c1f8a6950 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 27 Jun 2026 23:07:32 +1000 Subject: [PATCH 07/17] refactor: rename animateClose option to animate Rename the close-animation toggle on removeDetachedGlass and removeDetachedGlassElement (and removeWindowlessGlass) from animateClose to animate. --- docs/ARCHITECTURE.md | 2 +- src/binary-window/binary-window.js | 6 +++--- src/binary-window/detached-glass/action.attach.js | 2 +- src/binary-window/detached-glass/action.close.js | 2 +- src/binary-window/detached-glass/manager.js | 4 ++-- src/binary-window/detached-glass/manager.test.js | 8 ++++---- src/binary-window/detached-glass/utils.js | 8 ++------ 7 files changed, 14 insertions(+), 18 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 93f7924..945895c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -328,7 +328,7 @@ Floating `` panels that mimic OS windows, appended directly t **Manager owns the lifecycle; the caller only owns the DOM `append`.** `detachedGlassManager` is the single point for add/remove/update: - `addDetachedGlass(options)` — **builds the `DetachedGlass`, registers + `bringToFront`s it, and plays the open animation** (`animateDetachedGlassOpen`, unless `animateOpen: false`). Returns the glass **element** (`glass.domNode`), not the instance. The **only** thing left to the caller is the DOM `append`, because the parent differs (`crud.js` → `windowElement`, `addWindowlessGlass` → `document.body`); the windowless modal backdrop reads the returned element's `style.zIndex` (set by `bringToFront`) and sits at `z − 1`. `crud.js` pre-computes cascade placement + the size guard, then forwards everything as options. -- `removeDetachedGlass(id, { animateClose = true } = {})` — **owns the full teardown.** Splices from the array **and** removes the DOM node + modal backdrop via `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`). The **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through it. +- `removeDetachedGlass(id, { animate = true } = {})` — **owns the full teardown.** Splices from the array **and** removes the DOM node + modal backdrop via `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`). The **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through it. - `updateDetachedGlass(id, options)` — tentative stub (throws) for a future in-place update path. `bringToFront` / `getActiveDetachedGlass` stay agnostic about whether a glass lives in a `bw-window` or on `document.body`. diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 457faeb..367c1b3 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -166,11 +166,11 @@ export class BinaryWindow extends Frame { * * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove * @param {Object} [options] - * @param {boolean} [options.animateClose=true] - Whether to play the close animation before removal. + * @param {boolean} [options.animate=true] - Whether to play the close animation before removal. * @returns {Element|null} - The removed element, or null if no glass had that id */ - static removeWindowlessGlass(windowlessGlassId, { animateClose = true } = {}) { - return detachedGlassManager.removeDetachedGlass(windowlessGlassId, { animateClose }); + static removeWindowlessGlass(windowlessGlassId, { animate = true } = {}) { + return detachedGlassManager.removeDetachedGlass(windowlessGlassId, { animate }); } } diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 17c20df..7cfdae3 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -27,7 +27,7 @@ export default { } binaryWindow.removeDetachedGlass(detachedGlassEl.id, { - animateClose: true, + animate: true, onComplete: () => { const paneSash = binaryWindow.addPane(targetSashId, { position, diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index f83fc8f..683e25e 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -8,7 +8,7 @@ export default { if (!glassEl) return; binaryWindow.removeDetachedGlass(glassEl.id, { - animateClose: true, + animate: true, onComplete: () => binaryWindow.emit('close', glassEl), }); }, diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index 6415e06..f672187 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -55,12 +55,12 @@ class DetachedGlassManager { // Unregister and tear down: splice from the registry AND remove the DOM node // (animated by default) plus any modal backdrop. - removeDetachedGlass(id, { animateClose = true, onComplete = () => {} } = {}) { + removeDetachedGlass(id, { animate = true, onComplete = () => {} } = {}) { const index = this.detachedGlassElements.findIndex((glassEl) => glassEl.id === id); if (index === -1) return null; const [removedGlassEl] = this.detachedGlassElements.splice(index, 1); - removeDetachedGlassElement(removedGlassEl, animateClose, onComplete); + removeDetachedGlassElement(removedGlassEl, animate, onComplete); return removedGlassEl; } diff --git a/src/binary-window/detached-glass/manager.test.js b/src/binary-window/detached-glass/manager.test.js index 6ed0422..8b42003 100644 --- a/src/binary-window/detached-glass/manager.test.js +++ b/src/binary-window/detached-glass/manager.test.js @@ -110,17 +110,17 @@ describe('removeDetachedGlass', () => { it('unregisters the glass and returns the removed element', () => { const glassEl = addGlass({ id: 'gone' }); - const removed = detachedGlassManager.removeDetachedGlass('gone', { animateClose: false }); + const removed = detachedGlassManager.removeDetachedGlass('gone', { animate: false }); expect(removed).toBe(glassEl); expect(detachedGlassManager.detachedGlassElements).not.toContain(glassEl); }); - it('removes the node from the DOM when animateClose is false', () => { + it('removes the node from the DOM when animate is false', () => { const glassEl = addGlass({ id: 'gone' }); document.body.append(glassEl); - detachedGlassManager.removeDetachedGlass('gone', { animateClose: false }); + detachedGlassManager.removeDetachedGlass('gone', { animate: false }); expect(glassEl.isConnected).toBe(false); }); @@ -129,7 +129,7 @@ describe('removeDetachedGlass', () => { expect(detachedGlassManager.removeDetachedGlass('missing')).toBeNull(); }); - it('defaults animateClose to true (node stays until the close animation ends)', () => { + it('defaults animate to true (node stays until the close animation ends)', () => { const glassEl = addGlass({ id: 'gone' }); document.body.append(glassEl); diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 4a89084..302405c 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -34,11 +34,7 @@ export function animateDetachedGlassOpen(detachedGlassEl) { ); } -export function removeDetachedGlassElement( - detachedGlassEl, - animateClose = true, - onComplete = () => {} -) { +export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete = () => {}) { const handleRemove = () => { detachedGlassEl.remove(); detachedGlassEl.removeAttribute('closing'); @@ -46,7 +42,7 @@ export function removeDetachedGlassElement( onComplete(); }; - if (!animateClose) { + if (!animate) { handleRemove(); return; } From e8cb8176923a6e177359ccbeae201158c61ad5f4 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 11:34:15 +1000 Subject: [PATCH 08/17] refactor: make removeDetachedGlass return a promise Convert removeDetachedGlass from an onComplete callback to a promise that resolves with the removed element (after the close animation, if any) or null when no glass matches the id. Update the close/attach actions to await it, the removeWindowlessGlass wrapper's JSDoc, and the manager tests. --- src/binary-window/binary-window.js | 7 ++++--- .../detached-glass/action.attach.js | 19 ++++++++----------- .../detached-glass/action.close.js | 8 +++----- src/binary-window/detached-glass/manager.js | 10 ++++++---- .../detached-glass/manager.test.js | 8 ++++---- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 367c1b3..3b6dd11 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -167,10 +167,11 @@ export class BinaryWindow extends Frame { * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove * @param {Object} [options] * @param {boolean} [options.animate=true] - Whether to play the close animation before removal. - * @returns {Element|null} - The removed element, or null if no glass had that id + * @returns {Promise} - Resolves with the removed element (after the close + * animation, if any), or null if no glass had that id */ - static removeWindowlessGlass(windowlessGlassId, { animate = true } = {}) { - return detachedGlassManager.removeDetachedGlass(windowlessGlassId, { animate }); + static removeWindowlessGlass(...args) { + return detachedGlassManager.removeDetachedGlass(...args); } } diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js index 7cfdae3..99f1111 100644 --- a/src/binary-window/detached-glass/action.attach.js +++ b/src/binary-window/detached-glass/action.attach.js @@ -5,7 +5,7 @@ export default { placement: 'bar', label: '', className: 'bw-action--attach', - onClick: (event, binaryWindow) => { + onClick: async (event, binaryWindow) => { const detachedGlassEl = event.target.closest('bw-glass[detached]'); const originalPosition = detachedGlassEl.bwOriginalPosition; const originalSiblingSashId = detachedGlassEl.bwOriginalSiblingSashId; @@ -26,17 +26,14 @@ export default { size = 0.5; } - binaryWindow.removeDetachedGlass(detachedGlassEl.id, { - animate: true, - onComplete: () => { - const paneSash = binaryWindow.addPane(targetSashId, { - position, - size, - }); + await binaryWindow.removeDetachedGlass(detachedGlassEl.id); - transferGlass(detachedGlassEl, paneSash.domNode); - binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); - }, + const paneSash = binaryWindow.addPane(targetSashId, { + position, + size, }); + + transferGlass(detachedGlassEl, paneSash.domNode); + binaryWindow.emit('attach', paneSash.domNode.querySelector('bw-glass')); }, }; diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index 683e25e..2a07f53 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -3,13 +3,11 @@ export default { placement: 'bar', label: '', className: 'bw-action--close', - onClick: (event, binaryWindow) => { + onClick: async (event, binaryWindow) => { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; - binaryWindow.removeDetachedGlass(glassEl.id, { - animate: true, - onComplete: () => binaryWindow.emit('close', glassEl), - }); + await binaryWindow.removeDetachedGlass(glassEl.id); + binaryWindow.emit('close', glassEl); }, }; diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index f672187..ffa4afc 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -55,13 +55,15 @@ class DetachedGlassManager { // Unregister and tear down: splice from the registry AND remove the DOM node // (animated by default) plus any modal backdrop. - removeDetachedGlass(id, { animate = true, onComplete = () => {} } = {}) { + removeDetachedGlass(id, { animate = true } = {}) { const index = this.detachedGlassElements.findIndex((glassEl) => glassEl.id === id); - if (index === -1) return null; + if (index === -1) return Promise.resolve(null); const [removedGlassEl] = this.detachedGlassElements.splice(index, 1); - removeDetachedGlassElement(removedGlassEl, animate, onComplete); - return removedGlassEl; + + return new Promise((resolve) => + removeDetachedGlassElement(removedGlassEl, animate, () => resolve(removedGlassEl)) + ); } // Tentative: in-place update of an existing detached glass (title/content/etc.). diff --git a/src/binary-window/detached-glass/manager.test.js b/src/binary-window/detached-glass/manager.test.js index 8b42003..59d16b3 100644 --- a/src/binary-window/detached-glass/manager.test.js +++ b/src/binary-window/detached-glass/manager.test.js @@ -107,10 +107,10 @@ describe('bringToFront', () => { }); describe('removeDetachedGlass', () => { - it('unregisters the glass and returns the removed element', () => { + it('unregisters the glass and resolves with the removed element', async () => { const glassEl = addGlass({ id: 'gone' }); - const removed = detachedGlassManager.removeDetachedGlass('gone', { animate: false }); + const removed = await detachedGlassManager.removeDetachedGlass('gone', { animate: false }); expect(removed).toBe(glassEl); expect(detachedGlassManager.detachedGlassElements).not.toContain(glassEl); @@ -125,8 +125,8 @@ describe('removeDetachedGlass', () => { expect(glassEl.isConnected).toBe(false); }); - it('returns null when no glass has that id', () => { - expect(detachedGlassManager.removeDetachedGlass('missing')).toBeNull(); + it('resolves with null when no glass has that id', async () => { + await expect(detachedGlassManager.removeDetachedGlass('missing')).resolves.toBeNull(); }); it('defaults animate to true (node stays until the close animation ends)', () => { From 495f658e853eb0696d1cd0bad62472007356af96 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 13:55:46 +1000 Subject: [PATCH 09/17] refactor: move animation setting to crud layer --- src/binary-window/detached-glass/crud.js | 9 +++++-- src/binary-window/detached-glass/manager.js | 11 +++------ src/binary-window/detached-glass/utils.js | 27 +++++++++++++++------ src/css/detached-glass.css | 8 ++---- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/binary-window/detached-glass/crud.js b/src/binary-window/detached-glass/crud.js index b16d86a..95433d3 100644 --- a/src/binary-window/detached-glass/crud.js +++ b/src/binary-window/detached-glass/crud.js @@ -1,4 +1,5 @@ import { detachedGlassManager } from './manager'; +import { removeDetachedGlassElement } from './utils'; const DEFAULT_GLASS_WIDTH = 200; const DEFAULT_GLASS_HEIGHT = 200; @@ -53,8 +54,12 @@ export default { return glassEl; }, - removeDetachedGlass(...args) { - return detachedGlassManager.removeDetachedGlass(...args); + removeDetachedGlass(id, { animate = true } = {}) { + const removedGlassEl = detachedGlassManager.removeDetachedGlass(id); + + return new Promise((resolve) => + removeDetachedGlassElement(removedGlassEl, animate, () => resolve(removedGlassEl)) + ); }, updateDetachedGlass(...args) { diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index ffa4afc..b6262e2 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -53,17 +53,12 @@ class DetachedGlassManager { return this.topZIndex; } - // Unregister and tear down: splice from the registry AND remove the DOM node - // (animated by default) plus any modal backdrop. - removeDetachedGlass(id, { animate = true } = {}) { + removeDetachedGlass(id) { const index = this.detachedGlassElements.findIndex((glassEl) => glassEl.id === id); - if (index === -1) return Promise.resolve(null); + if (index === -1) return null; const [removedGlassEl] = this.detachedGlassElements.splice(index, 1); - - return new Promise((resolve) => - removeDetachedGlassElement(removedGlassEl, animate, () => resolve(removedGlassEl)) - ); + return removedGlassEl; } // Tentative: in-place update of an existing detached glass (title/content/etc.). diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 302405c..29e707c 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -25,21 +25,35 @@ export function removeGlassBackdrop(glassId) { // Play the open animation by setting `[opening]` (see detached-glass.css), then // clear it once the animation ends so it can re-run on the next restore. -export function animateDetachedGlassOpen(detachedGlassEl) { +export function animateDetachedGlassOpen(detachedGlassEl, onComplete) { detachedGlassEl.setAttribute('opening', ''); detachedGlassEl.addEventListener( 'animationend', - () => detachedGlassEl.removeAttribute('opening'), + () => { + detachedGlassEl.removeAttribute('opening'); + onComplete?.(); + }, { once: true } ); } -export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete = () => {}) { +export function animateDetachedGlassClose(detachedGlassEl, onComplete) { + detachedGlassEl.setAttribute('closing', ''); + detachedGlassEl.addEventListener( + 'animationend', + () => { + detachedGlassEl.removeAttribute('closing'); + onComplete?.(); + }, + { once: true } + ); +} + +export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete) { const handleRemove = () => { detachedGlassEl.remove(); - detachedGlassEl.removeAttribute('closing'); removeGlassBackdrop(detachedGlassEl.id); - onComplete(); + onComplete?.(); }; if (!animate) { @@ -47,8 +61,7 @@ export function removeDetachedGlassElement(detachedGlassEl, animate = true, onCo return; } - detachedGlassEl.setAttribute('closing', ''); - detachedGlassEl.addEventListener('animationend', handleRemove, { once: true }); + animateDetachedGlassClose(detachedGlassEl, handleRemove); } // Viewport-space top-left of an absolutely-positioned element's containing block. diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index dd640b3..a16c5da 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -1,16 +1,10 @@ bw-glass[detached] { box-shadow: var(--bw-detached-glass-shadow); - /* OS-style open: scale up from slightly small + fade in. Only `transform`/ - `opacity` so it never fights move/resize (which set top/left/width/height). - Driven by `[opening]` (set in JS), which is cleared once the animation ends. - Set on insert and on restore from the sill (when display flips back). */ &[opening] { animation: bw-detached-glass-open 0.18s ease-out; } - /* OS-style close: reverse of open. Driven by `[closing]` (set in JS), which - defers DOM removal by the same duration — CSS can't animate an element out. */ &[closing] { animation: bw-detached-glass-close 0.18s ease-in forwards; pointer-events: none; @@ -27,6 +21,7 @@ bw-glass[detached] { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); @@ -38,6 +33,7 @@ bw-glass[detached] { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.9); From ffcb2a180ea8ccea20da69044bcf3ccd0c9f7bc3 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 14:54:18 +1000 Subject: [PATCH 10/17] refactor: detached glass manager, windowless glass, action, animation, etc... --- src/animate.js | 2 +- src/binary-window/binary-window.js | 66 +------------------ .../detached-glass/action.close.js | 11 +++- src/binary-window/detached-glass/crud.js | 24 ++++--- src/binary-window/detached-glass/manager.js | 9 +-- src/binary-window/glass/action.detach.js | 15 ++--- src/binary-window/windowless-glass.js | 63 ++++++++++++++++++ src/frame/frame.js | 6 ++ 8 files changed, 105 insertions(+), 91 deletions(-) create mode 100644 src/binary-window/windowless-glass.js diff --git a/src/animate.js b/src/animate.js index 208f10e..e793ac4 100644 --- a/src/animate.js +++ b/src/animate.js @@ -2,7 +2,7 @@ // Both must be laid out (in the DOM, not `display:none`) so their rects measure. // Resolves when the flight ends (e.g. to then hide/remove the source). export function animateElementToElement(sourceEl, targetEl) { - const SHRINK_FLIGHT_DURATION = 200; + const SHRINK_FLIGHT_DURATION = 180; const sourceRect = sourceEl.getBoundingClientRect(); const targetRect = targetEl.getBoundingClientRect(); diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 3b6dd11..31d380f 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -3,10 +3,10 @@ import glassModule, { Glass } from './glass'; import { createDomNode } from '../utils'; import trimModule from './trim'; import sillModule from './sill'; -import detachedGlassModule, { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; -import { detachedGlassManager } from './detached-glass/manager'; +import detachedGlassModule from './detached-glass'; import { normActions } from './utils'; import { updateGlass } from './glass/utils'; +import windowlessGlassStaticModule from './windowless-glass'; export class BinaryWindow extends Frame { sillElement = null; @@ -112,70 +112,10 @@ export class BinaryWindow extends Frame { this.theme = theme; this.windowElement.setAttribute('theme', theme); } - - /** - * Add a windowless glass: a detached glass that floats on `document.body` instead - * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the - * shared glass manager (z-index/activation) like an in-window detached glass. - * - * @param {Object} [options] - * @param {boolean} [options.modal] - When true, append a `` - * behind the glass to block interaction with everything underneath. - * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [options.position='center'] - Where to anchor the glass. - * @param {number} [options.width] - Glass width in px. - * @param {number} [options.height] - Glass height in px. - * @param {number} [options.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`). - * @param {number} [options.offsetX] - Per-axis override of `offset` on the x-axis. - * @param {number} [options.offsetY] - Per-axis override of `offset` on the y-axis. - * @param {string} [options.id] - Glass id; auto-generated (suffixed `-F`) when omitted. - * @param {Object[]} [options.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only). - * @param {string|Node} [options.title] - Header title. - * @param {string|Node} [options.content] - Glass body content. - * @param {Object[]} [options.tabs] - Header tabs (shown instead of `title`). - * @param {boolean} [options.draggable=true] - Whether the header can be dragged to move the glass. - * @param {boolean} [options.resizable=true] - Whether resize handles appear on hover so the glass can be resized. - * @param {boolean} [options.animateOpen=true] - Whether to play the open animation on insert. - * @returns {Element} - The `bw-glass[detached][windowless]` element - */ - static addWindowlessGlass(options = {}) { - const { modal, ...glassOptions } = options; - - const glassEl = detachedGlassManager.addDetachedGlass({ - actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, - position: 'center', - ...glassOptions, - }); - - glassEl.setAttribute('windowless', ''); - document.body.append(glassEl); - - if (modal) { - const backdropEl = document.createElement('bw-glass-backdrop'); - backdropEl.setAttribute('for', glassEl.id); - // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). - backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; - document.body.append(backdropEl); - } - - return glassEl; - } - - /** - * Remove a windowless glass by id, unregistering it from the shared glass manager - * and detaching it from `document.body`. Also removes its modal backdrop, if any. - * - * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove - * @param {Object} [options] - * @param {boolean} [options.animate=true] - Whether to play the close animation before removal. - * @returns {Promise} - Resolves with the removed element (after the close - * animation, if any), or null if no glass had that id - */ - static removeWindowlessGlass(...args) { - return detachedGlassManager.removeDetachedGlass(...args); - } } BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule, sillModule); +BinaryWindow.assembleStatic(windowlessGlassStaticModule); // Enable features that do not need a BinaryWindow instance // e.g. handle pointer events diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js index 2a07f53..3f3045e 100644 --- a/src/binary-window/detached-glass/action.close.js +++ b/src/binary-window/detached-glass/action.close.js @@ -1,3 +1,5 @@ +import { BinaryWindow } from '../binary-window'; + export default { type: 'detached-glass-builtin', placement: 'bar', @@ -7,7 +9,12 @@ export default { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; - await binaryWindow.removeDetachedGlass(glassEl.id); - binaryWindow.emit('close', glassEl); + if (glassEl.hasAttribute('windowless')) { + await BinaryWindow.removeWindowlessGlass(glassEl.id); + } + else { + await binaryWindow.removeDetachedGlass(glassEl.id); + binaryWindow.emit('close', glassEl); + } }, }; diff --git a/src/binary-window/detached-glass/crud.js b/src/binary-window/detached-glass/crud.js index 95433d3..c5f3120 100644 --- a/src/binary-window/detached-glass/crud.js +++ b/src/binary-window/detached-glass/crud.js @@ -1,5 +1,6 @@ import { detachedGlassManager } from './manager'; -import { removeDetachedGlassElement } from './utils'; +import { removeDetachedGlassElement, animateDetachedGlassOpen } from './utils'; +import { transferGlass } from '../glass/utils'; const DEFAULT_GLASS_WIDTH = 200; const DEFAULT_GLASS_HEIGHT = 200; @@ -25,15 +26,12 @@ function getCascadedPlacement(windowEl, { width, height }) { } export default { - addDetachedGlass(options = {}) { - const { width: optWidth, height: optHeight, position: optPosition } = options; - - // Guard size here so the constructor never falls back to its 222 debug default. - const width = optWidth ?? DEFAULT_GLASS_WIDTH; - const height = optHeight ?? DEFAULT_GLASS_HEIGHT; + addDetachedGlass({ animate = true, originalGlassElement, ...glassOptions } = {}) { + const width = glassOptions.width ?? DEFAULT_GLASS_WIDTH; + const height = glassOptions.height ?? DEFAULT_GLASS_HEIGHT; // An explicit position wins; otherwise cascade from the active glass. - const { position, offsetX, offsetY } = optPosition + const { position, offsetX, offsetY } = glassOptions.position ? {} : getCascadedPlacement(this.windowElement, { width, height }); @@ -44,14 +42,20 @@ export default { position, offsetX, offsetY, - ...options, + ...glassOptions, width, height, }); + if (originalGlassElement) { + transferGlass(originalGlassElement, glassEl); + } + this.windowElement.append(glassEl); - return glassEl; + if (!animate) return Promise.resolve(glassEl); + + return new Promise((resolve) => animateDetachedGlassOpen(glassEl, () => resolve(glassEl))); }, removeDetachedGlass(id, { animate = true } = {}) { diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index b6262e2..4f1e690 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -1,5 +1,4 @@ import { DetachedGlass } from './detached-glass'; -import { animateDetachedGlassOpen, removeDetachedGlassElement } from './utils'; class DetachedGlassManager { constructor() { @@ -10,10 +9,8 @@ class DetachedGlassManager { // Caller owns only the DOM `append` (parent differs: `bw-window` vs. `document.body`) // and reads the returned element's `style.zIndex` for the windowless modal backdrop. - addDetachedGlass(options = {}) { - const { animateOpen = true, ...glassOptions } = options; - - const glassEl = new DetachedGlass(glassOptions).domNode; + addDetachedGlass(options) { + const glassEl = new DetachedGlass(options).domNode; // Ids must be unique in the stack: remove/update/backdrop all key off the id. if (this.getDetachedGlassById(glassEl.id)) { @@ -23,8 +20,6 @@ class DetachedGlassManager { this.detachedGlassElements.push(glassEl); this.bringToFront(glassEl); - if (animateOpen) animateDetachedGlassOpen(glassEl); - return glassEl; } diff --git a/src/binary-window/glass/action.detach.js b/src/binary-window/glass/action.detach.js index 74559b1..e85adf4 100644 --- a/src/binary-window/glass/action.detach.js +++ b/src/binary-window/glass/action.detach.js @@ -1,5 +1,3 @@ -import { transferGlass } from './utils'; - const DETACHED_GLASS_INSET = 15; export default { @@ -7,16 +5,19 @@ export default { placement: 'bar', label: '', className: 'bw-action--detach', - onClick: (event, binaryWindow) => { - if (!binaryWindow.addDetachedGlass) throw new Error('[bwin] Failed to detach glass from pane'); - + onClick: async (event, binaryWindow) => { const paneEl = event.target.closest('bw-pane'); const glassEl = paneEl.querySelector('bw-glass'); const windowRect = binaryWindow.windowElement.getBoundingClientRect(); const width = windowRect.width - DETACHED_GLASS_INSET * 2; const height = windowRect.height - DETACHED_GLASS_INSET * 2; - const detachedGlassEl = binaryWindow.addDetachedGlass({ position: 'center', width, height }); + const detachedGlassEl = await binaryWindow.addDetachedGlass({ + position: 'center', + width, + height, + originalGlassElement: glassEl, + }); const paneSashId = paneEl.getAttribute('sash-id'); const paneSash = binaryWindow.rootSash.getById(paneSashId); @@ -26,8 +27,6 @@ export default { detachedGlassEl.bwOriginalPosition = paneEl.getAttribute('position'); detachedGlassEl.bwOriginalRelativeSize = paneSash.getRelativeSize(); - transferGlass(glassEl, detachedGlassEl); - binaryWindow.removePane(paneSashId); binaryWindow.emit('detach', detachedGlassEl); }, diff --git a/src/binary-window/windowless-glass.js b/src/binary-window/windowless-glass.js new file mode 100644 index 0000000..59358a5 --- /dev/null +++ b/src/binary-window/windowless-glass.js @@ -0,0 +1,63 @@ +import { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; +import { detachedGlassManager } from './detached-glass/manager'; +import { animateDetachedGlassOpen, removeDetachedGlassElement } from './detached-glass/utils'; + +export default { + /** + * Add a windowless glass: a detached glass that floats on `document.body` instead + * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the + * shared glass manager (z-index/activation) like an in-window detached glass. + * + * @param {Object} [glassOptions] + * @param {boolean} [glassOptions.modal] - When true, append a `` + * behind the glass to block interaction with everything underneath. + * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [glassOptions.position='center'] - Where to anchor the glass. + * @param {number} [glassOptions.width] - Glass width in px. + * @param {number} [glassOptions.height] - Glass height in px. + * @param {number} [glassOptions.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`). + * @param {number} [glassOptions.offsetX] - Per-axis override of `offset` on the x-axis. + * @param {number} [glassOptions.offsetY] - Per-axis override of `offset` on the y-axis. + * @param {string} [glassOptions.id] - Glass id; auto-generated (suffixed `-F`) when omitted. + * @param {Object[]} [glassOptions.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only). + * @param {string|Node} [glassOptions.title] - Header title. + * @param {string|Node} [glassOptions.content] - Glass body content. + * @param {Object[]} [glassOptions.tabs] - Header tabs (shown instead of `title`). + * @param {boolean} [glassOptions.draggable=true] - Whether the header can be dragged to move the glass. + * @param {boolean} [glassOptions.resizable=true] - Whether resize handles appear on hover so the glass can be resized. + * @param {boolean} [glassOptions.animateOpen=true] - Whether to play the open animation on insert. + * @returns {Element} - The `bw-glass[detached][windowless]` element + */ + addWindowlessGlass({ animate = true, modal = false, ...glassOptions } = {}) { + const glassEl = detachedGlassManager.addDetachedGlass({ + actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, + position: 'center', + ...glassOptions, + }); + + glassEl.setAttribute('windowless', ''); + document.body.append(glassEl); + + if (modal) { + const backdropEl = document.createElement('bw-glass-backdrop'); + backdropEl.setAttribute('for', glassEl.id); + // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). + backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; + document.body.append(backdropEl); + } + + if (!animate) return Promise.resolve(glassEl); + return new Promise((resolve) => animateDetachedGlassOpen(glassEl, () => resolve(glassEl))); + }, + + removeWindowlessGlass(id, { animate = true } = {}) { + const detachedGlassEl = detachedGlassManager.removeDetachedGlass(id); + + if (!animate) { + return Promise.resolve(detachedGlassEl); + } + + return new Promise((resolve) => + removeDetachedGlassElement(detachedGlassEl, animate, () => resolve(detachedGlassEl)) + ); + }, +}; diff --git a/src/frame/frame.js b/src/frame/frame.js index c65dc11..2303e5b 100644 --- a/src/frame/frame.js +++ b/src/frame/frame.js @@ -63,6 +63,12 @@ export class Frame { strictAssign(this.prototype, module); }); } + + static assembleStatic(...modules) { + modules.forEach((module) => { + strictAssign(this, module); + }); + } } Frame.assemble( From 84a227610cf689c42682aeb5f7fb31062f0fc783 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 15:00:42 +1000 Subject: [PATCH 11/17] feat: fade windowless glass backdrop in and out Animate the modal backdrop's opacity on open and close so it transitions in sync with the glass instead of appearing/disappearing instantly. --- src/binary-window/detached-glass/utils.js | 26 ++++++++++++++++++--- src/binary-window/windowless-glass.js | 7 +++++- src/css/detached-glass.css | 28 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 29e707c..e617678 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -17,10 +17,28 @@ export function getResizeHandleOverhang(glassEl) { return (parseFloat(size) || 0) / 2; } +// Play the backdrop fade-in by setting `[opening]` (see detached-glass.css), then +// clear it once the animation ends. +export function animateGlassBackdropOpen(backdropEl) { + backdropEl.setAttribute('opening', ''); + backdropEl.addEventListener('animationend', () => backdropEl.removeAttribute('opening'), { + once: true, + }); +} + // Remove the modal backdrop tied to a glass id, if one exists (windowless modal glass). -export function removeGlassBackdrop(glassId) { +// When `animate`, fade it out (in sync with the glass close) before removing. +export function removeGlassBackdrop(glassId, animate = false) { const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`); - backdropEl?.remove(); + if (!backdropEl) return; + + if (!animate) { + backdropEl.remove(); + return; + } + + backdropEl.setAttribute('closing', ''); + backdropEl.addEventListener('animationend', () => backdropEl.remove(), { once: true }); } // Play the open animation by setting `[opening]` (see detached-glass.css), then @@ -50,9 +68,11 @@ export function animateDetachedGlassClose(detachedGlassEl, onComplete) { } export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete) { + // Fade the backdrop out in sync with the glass close; both share the same duration. + removeGlassBackdrop(detachedGlassEl.id, animate); + const handleRemove = () => { detachedGlassEl.remove(); - removeGlassBackdrop(detachedGlassEl.id); onComplete?.(); }; diff --git a/src/binary-window/windowless-glass.js b/src/binary-window/windowless-glass.js index 59358a5..19679b6 100644 --- a/src/binary-window/windowless-glass.js +++ b/src/binary-window/windowless-glass.js @@ -1,6 +1,10 @@ import { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; import { detachedGlassManager } from './detached-glass/manager'; -import { animateDetachedGlassOpen, removeDetachedGlassElement } from './detached-glass/utils'; +import { + animateDetachedGlassOpen, + animateGlassBackdropOpen, + removeDetachedGlassElement, +} from './detached-glass/utils'; export default { /** @@ -43,6 +47,7 @@ export default { // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; document.body.append(backdropEl); + if (animate) animateGlassBackdropOpen(backdropEl); } if (!animate) return Promise.resolve(glassEl); diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index a16c5da..f02ebfe 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -51,6 +51,34 @@ bw-glass-backdrop { position: fixed; inset: 0; background: var(--bw-glass-backdrop-color); + + &[opening] { + animation: bw-glass-backdrop-open 0.28s ease-out; + } + + &[closing] { + animation: bw-glass-backdrop-close 0.28s ease-in forwards; + } +} + +@keyframes bw-glass-backdrop-open { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes bw-glass-backdrop-close { + from { + opacity: 1; + } + + to { + opacity: 0; + } } bw-glass-resize-handle { From 335c7ff2ae5b24e45ce40487ede94908b04c7d1a Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 15:03:45 +1000 Subject: [PATCH 12/17] chore: cleanup comments --- src/binary-window/detached-glass/utils.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index e617678..915144e 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -17,8 +17,6 @@ export function getResizeHandleOverhang(glassEl) { return (parseFloat(size) || 0) / 2; } -// Play the backdrop fade-in by setting `[opening]` (see detached-glass.css), then -// clear it once the animation ends. export function animateGlassBackdropOpen(backdropEl) { backdropEl.setAttribute('opening', ''); backdropEl.addEventListener('animationend', () => backdropEl.removeAttribute('opening'), { @@ -26,8 +24,6 @@ export function animateGlassBackdropOpen(backdropEl) { }); } -// Remove the modal backdrop tied to a glass id, if one exists (windowless modal glass). -// When `animate`, fade it out (in sync with the glass close) before removing. export function removeGlassBackdrop(glassId, animate = false) { const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`); if (!backdropEl) return; @@ -41,8 +37,6 @@ export function removeGlassBackdrop(glassId, animate = false) { backdropEl.addEventListener('animationend', () => backdropEl.remove(), { once: true }); } -// Play the open animation by setting `[opening]` (see detached-glass.css), then -// clear it once the animation ends so it can re-run on the next restore. export function animateDetachedGlassOpen(detachedGlassEl, onComplete) { detachedGlassEl.setAttribute('opening', ''); detachedGlassEl.addEventListener( @@ -68,7 +62,6 @@ export function animateDetachedGlassClose(detachedGlassEl, onComplete) { } export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete) { - // Fade the backdrop out in sync with the glass close; both share the same duration. removeGlassBackdrop(detachedGlassEl.id, animate); const handleRemove = () => { From 75b8d5bc6a9d76507341dc26246b1ae50307d990 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 15:07:29 +1000 Subject: [PATCH 13/17] chore: test modal --- dev/window/bwin-detached-glass.js | 1 + dev/window/bwin-detached-windowless-glass.js | 1 + 2 files changed, 2 insertions(+) diff --git a/dev/window/bwin-detached-glass.js b/dev/window/bwin-detached-glass.js index 4e61865..41f369b 100644 --- a/dev/window/bwin-detached-glass.js +++ b/dev/window/bwin-detached-glass.js @@ -160,6 +160,7 @@ document.querySelector('#add-fullscreen').addEventListener('click', () => { width: document.documentElement.clientWidth - EDGE * 2, height: document.documentElement.clientHeight - EDGE * 2, content: createGlassContent('fullscreen'), + modal: true, }); }); diff --git a/dev/window/bwin-detached-windowless-glass.js b/dev/window/bwin-detached-windowless-glass.js index bb42d08..5ed19bd 100644 --- a/dev/window/bwin-detached-windowless-glass.js +++ b/dev/window/bwin-detached-windowless-glass.js @@ -43,6 +43,7 @@ document.querySelector('#add-fullscreen').addEventListener('click', () => { width: document.documentElement.clientWidth - EDGE * 2, height: document.documentElement.clientHeight - EDGE * 2, content: createContent('fullscreen'), + modal: true, }); }); From ce5df76c13d5224cb48bccb5321938fa4364a294 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 16:07:54 +1000 Subject: [PATCH 14/17] refactor: extract shared animateElementByAttribute util Replace the per-element animate wrappers (animateDetachedGlassOpen/Close, animateGlassBackdropOpen) with a single animateElementByAttribute(element, attribute, onComplete) in src/animate.js, and call it directly at each site. --- src/animate.js | 15 +++++++++ src/binary-window/detached-glass/crud.js | 7 +++-- src/binary-window/detached-glass/utils.js | 38 +++-------------------- src/binary-window/windowless-glass.js | 13 ++++---- 4 files changed, 30 insertions(+), 43 deletions(-) diff --git a/src/animate.js b/src/animate.js index e793ac4..73c6d6b 100644 --- a/src/animate.js +++ b/src/animate.js @@ -1,3 +1,18 @@ +// Drive a CSS animation by toggling `attribute` on `element`: set it (which the +// stylesheet keys the animation off), then clear it on `animationend` and run +// `onComplete`. The cleared attribute lets the animation re-run on the next call. +export function animateElementByAttribute(element, attribute, onComplete) { + element.setAttribute(attribute, ''); + element.addEventListener( + 'animationend', + () => { + element.removeAttribute(attribute); + onComplete?.(); + }, + { once: true } + ); +} + // FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out. // Both must be laid out (in the DOM, not `display:none`) so their rects measure. // Resolves when the flight ends (e.g. to then hide/remove the source). diff --git a/src/binary-window/detached-glass/crud.js b/src/binary-window/detached-glass/crud.js index c5f3120..7a8f55e 100644 --- a/src/binary-window/detached-glass/crud.js +++ b/src/binary-window/detached-glass/crud.js @@ -1,6 +1,7 @@ import { detachedGlassManager } from './manager'; -import { removeDetachedGlassElement, animateDetachedGlassOpen } from './utils'; +import { removeDetachedGlassElement } from './utils'; import { transferGlass } from '../glass/utils'; +import { animateElementByAttribute } from '@/animate'; const DEFAULT_GLASS_WIDTH = 200; const DEFAULT_GLASS_HEIGHT = 200; @@ -55,7 +56,9 @@ export default { if (!animate) return Promise.resolve(glassEl); - return new Promise((resolve) => animateDetachedGlassOpen(glassEl, () => resolve(glassEl))); + return new Promise((resolve) => + animateElementByAttribute(glassEl, 'opening', () => resolve(glassEl)) + ); }, removeDetachedGlass(id, { animate = true } = {}) { diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 915144e..e38897e 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -1,3 +1,5 @@ +import { animateElementByAttribute } from '@/animate'; + // Edges first, corners last so corner handles paint on top of the edge handles const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; @@ -17,13 +19,6 @@ export function getResizeHandleOverhang(glassEl) { return (parseFloat(size) || 0) / 2; } -export function animateGlassBackdropOpen(backdropEl) { - backdropEl.setAttribute('opening', ''); - backdropEl.addEventListener('animationend', () => backdropEl.removeAttribute('opening'), { - once: true, - }); -} - export function removeGlassBackdrop(glassId, animate = false) { const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`); if (!backdropEl) return; @@ -33,32 +28,7 @@ export function removeGlassBackdrop(glassId, animate = false) { return; } - backdropEl.setAttribute('closing', ''); - backdropEl.addEventListener('animationend', () => backdropEl.remove(), { once: true }); -} - -export function animateDetachedGlassOpen(detachedGlassEl, onComplete) { - detachedGlassEl.setAttribute('opening', ''); - detachedGlassEl.addEventListener( - 'animationend', - () => { - detachedGlassEl.removeAttribute('opening'); - onComplete?.(); - }, - { once: true } - ); -} - -export function animateDetachedGlassClose(detachedGlassEl, onComplete) { - detachedGlassEl.setAttribute('closing', ''); - detachedGlassEl.addEventListener( - 'animationend', - () => { - detachedGlassEl.removeAttribute('closing'); - onComplete?.(); - }, - { once: true } - ); + animateElementByAttribute(backdropEl, 'closing', () => backdropEl.remove()); } export function removeDetachedGlassElement(detachedGlassEl, animate = true, onComplete) { @@ -74,7 +44,7 @@ export function removeDetachedGlassElement(detachedGlassEl, animate = true, onCo return; } - animateDetachedGlassClose(detachedGlassEl, handleRemove); + animateElementByAttribute(detachedGlassEl, 'closing', handleRemove); } // Viewport-space top-left of an absolutely-positioned element's containing block. diff --git a/src/binary-window/windowless-glass.js b/src/binary-window/windowless-glass.js index 19679b6..98ca050 100644 --- a/src/binary-window/windowless-glass.js +++ b/src/binary-window/windowless-glass.js @@ -1,10 +1,7 @@ +import { animateElementByAttribute } from '@/animate'; import { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; import { detachedGlassManager } from './detached-glass/manager'; -import { - animateDetachedGlassOpen, - animateGlassBackdropOpen, - removeDetachedGlassElement, -} from './detached-glass/utils'; +import { removeDetachedGlassElement } from './detached-glass/utils'; export default { /** @@ -47,11 +44,13 @@ export default { // addDetachedGlass reserved the slot just below the glass (`topZIndex += 2`). backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; document.body.append(backdropEl); - if (animate) animateGlassBackdropOpen(backdropEl); + if (animate) animateElementByAttribute(backdropEl, 'opening'); } if (!animate) return Promise.resolve(glassEl); - return new Promise((resolve) => animateDetachedGlassOpen(glassEl, () => resolve(glassEl))); + return new Promise((resolve) => + animateElementByAttribute(glassEl, 'opening', () => resolve(glassEl)) + ); }, removeWindowlessGlass(id, { animate = true } = {}) { From 64eb5c7167aef447715d3acd13fce13b0cc76a6c Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 16:08:00 +1000 Subject: [PATCH 15/17] refactor: run sill glass-restore logic in animation onComplete Defer the pot removal, bring-to-front, and restore emit until the open animation finishes, instead of firing them immediately on click. --- src/binary-window/sill.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/binary-window/sill.js b/src/binary-window/sill.js index ed4b3da..8b940b5 100644 --- a/src/binary-window/sill.js +++ b/src/binary-window/sill.js @@ -2,7 +2,7 @@ import { getMetricsFromElement } from '@/utils'; import { getIntersectRect } from '@/rect'; import { Position } from '@/position'; import { detachedGlassManager } from './detached-glass/manager'; -import { animateDetachedGlassOpen } from './detached-glass/utils'; +import { animateElementByAttribute } from '@/animate'; export default { enableSillFeatures() { @@ -31,10 +31,12 @@ export default { if (!detachedGlassEl) return; detachedGlassEl.style.display = ''; - animateDetachedGlassOpen(detachedGlassEl); - potEl.remove(); - detachedGlassManager.bringToFront(detachedGlassEl); - this.emit('restore', detachedGlassEl); + + animateElementByAttribute(detachedGlassEl, 'opening', () => { + potEl.remove(); + detachedGlassManager.bringToFront(detachedGlassEl); + this.emit('restore', detachedGlassEl); + }); }); }, From 6e3f6e7cd575069d377648614ffcdbd32602f06b Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 16:20:45 +1000 Subject: [PATCH 16/17] feat: add closeOnBackdropClick option to addWindowlessGlass When a modal windowless glass is opened with closeOnBackdropClick, clicking the backdrop dismisses the glass. Extracts removeWindowlessGlass to a module function so the click handler can call it without relying on `this`. --- .../bwin-detached-windowless-glass.html | 1 + dev/window/bwin-detached-windowless-glass.js | 10 +++++ src/binary-window/windowless-glass.js | 44 ++++++++++++------- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/dev/window/bwin-detached-windowless-glass.html b/dev/window/bwin-detached-windowless-glass.html index 1099961..3d937ba 100644 --- a/dev/window/bwin-detached-windowless-glass.html +++ b/dev/window/bwin-detached-windowless-glass.html @@ -14,6 +14,7 @@

BinaryWindow - windowless glass

+ diff --git a/dev/window/bwin-detached-windowless-glass.js b/dev/window/bwin-detached-windowless-glass.js index 5ed19bd..4a4c45f 100644 --- a/dev/window/bwin-detached-windowless-glass.js +++ b/dev/window/bwin-detached-windowless-glass.js @@ -19,6 +19,16 @@ document.querySelector('#add-modal').addEventListener('click', () => { }); }); +// Modal that dismisses itself when the backdrop (not the glass) is clicked. +document.querySelector('#add-modal-close-on-backdrop').addEventListener('click', () => { + BinaryWindow.addWindowlessGlass({ + modal: true, + closeOnBackdropClick: true, + title: 'Modal (click backdrop to close)', + content: createContent('click outside me'), + }); +}); + // Placed relative to the body's top-left via offsetX/offsetY. document.querySelector('#add-positioned').addEventListener('click', () => { BinaryWindow.addWindowlessGlass({ diff --git a/src/binary-window/windowless-glass.js b/src/binary-window/windowless-glass.js index 98ca050..5480216 100644 --- a/src/binary-window/windowless-glass.js +++ b/src/binary-window/windowless-glass.js @@ -3,6 +3,18 @@ import { DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './detached-glass'; import { detachedGlassManager } from './detached-glass/manager'; import { removeDetachedGlassElement } from './detached-glass/utils'; +function removeWindowlessGlass(id, { animate = true } = {}) { + const detachedGlassEl = detachedGlassManager.removeDetachedGlass(id); + + if (!animate) { + return Promise.resolve(detachedGlassEl); + } + + return new Promise((resolve) => + removeDetachedGlassElement(detachedGlassEl, animate, () => resolve(detachedGlassEl)) + ); +} + export default { /** * Add a windowless glass: a detached glass that floats on `document.body` instead @@ -10,8 +22,10 @@ export default { * shared glass manager (z-index/activation) like an in-window detached glass. * * @param {Object} [glassOptions] - * @param {boolean} [glassOptions.modal] - When true, append a `` + * @param {boolean} [glassOptions.animate=true] - Whether to play the open animation (and fade the backdrop in). + * @param {boolean} [glassOptions.modal=false] - When true, append a `` * behind the glass to block interaction with everything underneath. + * @param {boolean} [glassOptions.closeOnBackdropClick=false] - When `modal`, clicking the backdrop closes the glass. * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [glassOptions.position='center'] - Where to anchor the glass. * @param {number} [glassOptions.width] - Glass width in px. * @param {number} [glassOptions.height] - Glass height in px. @@ -25,10 +39,14 @@ export default { * @param {Object[]} [glassOptions.tabs] - Header tabs (shown instead of `title`). * @param {boolean} [glassOptions.draggable=true] - Whether the header can be dragged to move the glass. * @param {boolean} [glassOptions.resizable=true] - Whether resize handles appear on hover so the glass can be resized. - * @param {boolean} [glassOptions.animateOpen=true] - Whether to play the open animation on insert. - * @returns {Element} - The `bw-glass[detached][windowless]` element + * @returns {Promise} - Resolves to the `bw-glass[detached][windowless]` element once the open animation completes. */ - addWindowlessGlass({ animate = true, modal = false, ...glassOptions } = {}) { + addWindowlessGlass({ + animate = true, + modal = false, + closeOnBackdropClick = false, + ...glassOptions + } = {}) { const glassEl = detachedGlassManager.addDetachedGlass({ actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS, position: 'center', @@ -45,23 +63,19 @@ export default { backdropEl.style.zIndex = Number(glassEl.style.zIndex) - 1; document.body.append(backdropEl); if (animate) animateElementByAttribute(backdropEl, 'opening'); + if (closeOnBackdropClick) { + backdropEl.addEventListener('click', () => removeWindowlessGlass(glassEl.id), { + once: true, + }); + } } if (!animate) return Promise.resolve(glassEl); + return new Promise((resolve) => animateElementByAttribute(glassEl, 'opening', () => resolve(glassEl)) ); }, - removeWindowlessGlass(id, { animate = true } = {}) { - const detachedGlassEl = detachedGlassManager.removeDetachedGlass(id); - - if (!animate) { - return Promise.resolve(detachedGlassEl); - } - - return new Promise((resolve) => - removeDetachedGlassElement(detachedGlassEl, animate, () => resolve(detachedGlassEl)) - ); - }, + removeWindowlessGlass, }; From fbebdccaec5107330d60126a0125697bff8b1f10 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 28 Jun 2026 16:35:51 +1000 Subject: [PATCH 17/17] docs: sync architecture + conventions with event emitter and glass overhaul Document action events, assembleStatic, the manager/crud animation split, windowless glass closeOnBackdropClick + backdrop transitions, and the shared animateElementByAttribute helper. --- docs/ARCHITECTURE.md | 56 ++++++++++++++++++++++--------------- docs/context/conventions.md | 4 +-- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 945895c..c7dfbbf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,6 +98,8 @@ BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule, sillModule); `assemble()` uses `strictAssign` (`src/utils.js`), which **throws if a key already exists** on the target prototype. This makes the method namespace a flat, collision-checked shared space. +A sibling `assembleStatic(...modules)` mixes module objects onto the **class itself** (also via `strictAssign`) rather than the prototype, for methods that take no instance. `BinaryWindow.assembleStatic(windowlessGlassStaticModule)` is how `addWindowlessGlass` / `removeWindowlessGlass` (`binary-window/windowless-glass.js`) land as static methods — they manage glasses with no owning window, so they can't be instance methods (see §8.6). + **Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, _not_ `enableResize`. When adding a feature, prefix its methods with the feature name. **Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` as empty "intended to be overridden" hooks. (The pane add/remove **lifecycle is observed via events**, not override hooks — see §7.) @@ -263,6 +265,16 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si `emit` runs **every** listener, then reports a veto if **any** of them returned `false` (so veto is order-independent and all listeners still see the event). Returning `false` is the only veto signal — any other return value (including `undefined`) proceeds. Only the `before-*` events are vetoable. +### Action events (`binary-window/**/action.*.js` + `binary-window/sill.js`) + +The same per-instance emitter carries **glass-action notifications** (none are vetoable). Each built-in action calls `binaryWindow.emit(name, glassEl)` after it has mutated the layout; `detail` is the affected `` element (not a sash): + +- attached-glass actions — `close`, `minimize`, `maximize` / `unmaximize` (the toggle emits one or the other), and `detach`. +- detached-glass actions — `attach`, `minimize`, and `close` (for an **in-window** detached glass only). The detached **close** action on a _windowless_ glass routes through the static `BinaryWindow.removeWindowlessGlass` (no instance), so it emits nothing. +- sill restore (`sill.js`) — `restore`, emitted from both un-pot paths (the click-to-restore listener in `enableSillFeatures` and the programmatic `restorePane`) once the glass is back in place. + +Async teardown is awaited before the event fires where it matters: `detach` awaits `addDetachedGlass` (so `detail` is the settled detached element), and `attach` awaits `removeDetachedGlass` before re-adding the pane. `minimize` (both attached and detached) emits inside the flight animation's `.then()`, after the glass has landed on its pot. + ### Muntins (`frame/muntin.js` + `binary-window/trim.js`) `createMuntin`/`updateMuntin` position a `muntinSize`-px (4px) divider at the boundary between the two children, marked `vertical` (left/right split) or `horizontal` (top/bottom split). `sash.store.resizable === false` sets `resizable="false"`. `trim.js` (a `BinaryWindow` mixin) shrinks each muntin by half its size at both ends via `onMuntinCreate`/`onMuntinUpdate` so dividers don't overlap at intersections. @@ -309,26 +321,26 @@ Built-in actions are **pane-centric** (`closest('bw-pane')`), so they don't work Floating `` panels that mimic OS windows, appended directly to `windowElement` (not bound to any pane/sash). The canonical "feature folder," mirrored by `glass/`: -| File | Role | -| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `index.js` | re-exports `DetachedGlass` + the action defaults; the assembled mixin (`enableDetachedGlassStandaloneFeatures` + `crud` spread). See the split below. | -| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | -| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | -| `manager.js` | `detachedGlassManager` singleton: the shared registry + focus/stacking coordinator for **all** detached glasses (in-window and windowless alike), and the single entry point for their lifecycle — `addDetachedGlass` (build + register + `bringToFront` + open animation), `removeDetachedGlass` (unregister **and** remove the DOM node + backdrop), `updateDetachedGlass` (stub), plus `bringToFront` / `getActiveDetachedGlass`. See the split below. | -| `activate.js` | click-to-focus → `bringToFront`. Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | -| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. Clamps to the viewport so a drag never grows the page; an off-screen glass (e.g. after a browser resize) may drag inward but never further out (`clampAxis` relaxes only the breached edge to the last applied position). | -| `resize.js` | 8 resize handles created **on demand** (on hover). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | -| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | -| `utils.js` | `genStylesByPosition`, resize-handle creation, `removeGlassBackdrop`, `getContainingBlockOrigin`. | +| File | Role | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.js` | re-exports `DetachedGlass` + the action defaults; the assembled mixin (`enableDetachedGlassStandaloneFeatures` + `crud` spread). See the split below. | +| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. | +| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. | +| `manager.js` | `detachedGlassManager` singleton: the shared registry + focus/stacking coordinator for **all** detached glasses (in-window and windowless alike). It owns _registration_, not the DOM or animation — `addDetachedGlass` (build + register + `bringToFront`, returns the element), `removeDetachedGlass` (unregister + splice, returns the element), `updateDetachedGlass` (stub), plus `bringToFront` / `getActiveDetachedGlass`. The DOM `append`/`remove` and the open/close animation live one layer up in `crud.js` / `windowless-glass.js`. See the split below. | +| `activate.js` | click-to-focus → `bringToFront`. Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | +| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. Clamps to the viewport so a drag never grows the page; an off-screen glass (e.g. after a browser resize) may drag inward but never further out (`clampAxis` relaxes only the breached edge to the last applied position). | +| `resize.js` | 8 resize handles created **on demand** (on hover). Document-global; installed by `enableDetachedGlassStandaloneFeatures`. | +| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. | +| `utils.js` | `genStylesByPosition`, resize-handle creation, `removeGlassBackdrop`, `getContainingBlockOrigin`. | -`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. **minimize** (`action.minimize.js`) stashes the glass on a sill pot (`bwDetachedGlassElement`), then plays a FLIP-style flight — `animateElementToElement` (`src/animate.js`) shrinks/flies the glass onto its pot before deferring `display:none` into the animation's `onDone`. +`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. **minimize** (`action.minimize.js`) stashes the glass on a sill pot (`bwDetachedGlassElement`), then plays a FLIP-style flight — `animateElementToElement` (`src/animate.js`) shrinks/flies the glass onto its pot and resolves a promise; its `.then()` then sets `display:none` and emits the `minimize` event (§7). **Standalone vs. per-instance features.** `activate`/`move`/`resize` attach **document-global, instance-independent** listeners (they find their target via `closest('bw-glass[detached]')` and never read `this`). They're installed by `enableDetachedGlassStandaloneFeatures()`, called **once at module load** in `binary-window.js` (module evaluation is one-time, so no idempotency flag is needed). This is what makes the static `addWindowlessGlass` path work with **no mounted window** — importing `BinaryWindow` is enough to wire move/resize/activate. Un-potting (restore from the sill) is **not** here: it's a per-instance sill feature wired by `enableSillFeatures()` (see `sill.js`), which needs `this.sillElement` (windowless glasses have no sill). _Historical note:_ an earlier `drag.js` offered native-DnD repositioning (docked to panes) as an alternative to `move.js`; it was removed in favor of free-floating `move.js`. -**Manager owns the lifecycle; the caller only owns the DOM `append`.** `detachedGlassManager` is the single point for add/remove/update: +**Manager owns _registration_; the layer above owns DOM + animation.** `detachedGlassManager` is the single registry, but the DOM `append`/`remove` and the open/close animation live in the `crud.js` (in-window) and `windowless-glass.js` (windowless) layers, which both wrap the manager and return a `Promise`: -- `addDetachedGlass(options)` — **builds the `DetachedGlass`, registers + `bringToFront`s it, and plays the open animation** (`animateDetachedGlassOpen`, unless `animateOpen: false`). Returns the glass **element** (`glass.domNode`), not the instance. The **only** thing left to the caller is the DOM `append`, because the parent differs (`crud.js` → `windowElement`, `addWindowlessGlass` → `document.body`); the windowless modal backdrop reads the returned element's `style.zIndex` (set by `bringToFront`) and sits at `z − 1`. `crud.js` pre-computes cascade placement + the size guard, then forwards everything as options. -- `removeDetachedGlass(id, { animate = true } = {})` — **owns the full teardown.** Splices from the array **and** removes the DOM node + modal backdrop via `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`). The **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through it. +- `detachedGlassManager.addDetachedGlass(options)` — **builds the `DetachedGlass`, registers + `bringToFront`s it**, and returns the glass **element** (`glass.domNode`), not the instance. It does **not** touch the DOM or animate. The wrapping layer then `append`s it (parent differs: `crud.js` → `windowElement`, `addWindowlessGlass` → `document.body`) and, unless `animate: false`, plays the open animation via `animateElementByAttribute(glassEl, 'opening', …)` (resolving the returned promise on `animationend`). `crud.js` pre-computes cascade placement + the size guard and forwards them as options; the windowless modal backdrop reads the returned element's `style.zIndex` (set by `bringToFront`) and sits at `z − 1`. +- `detachedGlassManager.removeDetachedGlass(id)` — **unregisters only**: splices from the array and returns the element (or `null`). The wrapping layer then runs `removeDetachedGlassElement` (`utils.js`, `[closing]` attr + deferred `.remove()`, plus the modal backdrop's own `[closing]` fade) — skipped when `animate: false`. The detached **close** action, `binaryWindow.removeDetachedGlass`, and `removeWindowlessGlass` all route through this pair. - `updateDetachedGlass(id, options)` — tentative stub (throws) for a future in-place update path. `bringToFront` / `getActiveDetachedGlass` stay agnostic about whether a glass lives in a `bw-window` or on `document.body`. @@ -337,8 +349,8 @@ Floating `` panels that mimic OS windows, appended directly t **Detach → attach round-trip:** -- **detach** (`glass/action.detach.js`): creates a centered detached glass sized to the window, then **moves the real content/title elements** into it (`replaceWith`), stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, and `removePane`s the source. -- **attach** (`detached-glass/action.attach.js`): reads the stashed origin and rebuilds a pane via `addPane`, moving the inner content nodes back in. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%, then `removeDetachedGlass`. +- **detach** (`glass/action.detach.js`): `await`s a centered detached glass sized to the window — passing the pane's glass as `originalGlassElement`, so `crud.js` **moves the real content/title elements** into the new glass (`transferGlass`) as part of the build. It then stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, `removePane`s the source, and emits `detach` (§7). +- **attach** (`detached-glass/action.attach.js`): reads the stashed origin, `await`s `removeDetachedGlass` **first**, then rebuilds a pane via `addPane` and moves the inner content nodes back in (`transferGlass`), emitting `attach`. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%. > **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop _moves the same element_. @@ -346,8 +358,8 @@ Floating `` panels that mimic OS windows, appended directly t A **windowless glass** is a detached glass with no owning window: it's appended to `document.body` instead of a `` and isn't bound to any `BinaryWindow` instance. It's still registered with the shared `detachedGlassManager`, so z-index/activation stacking works the same as for an in-window detached glass. Move/resize/activate work **without any mounted window** because those listeners are document-global and installed at module load (see "Standalone vs. per-instance features" in §8.5). -- **Create** (`binary-window.js` `addWindowlessGlass`) — calls `detachedGlassManager.addDetachedGlass({ position: 'center', ... })`, which builds the `DetachedGlass`, registers + `bringToFront`s it, and plays the open animation; then sets the `windowless` attribute and appends it to `document.body`. **Remove** (`removeWindowlessGlass(id)`) — delegates to `detachedGlassManager.removeDetachedGlass(id)`, which unregisters, removes the node, and tears down its modal backdrop (below). -- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. Both `removeWindowlessGlass` and the detached **close** action remove the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Styled in `detached-glass.css` (`position: fixed; inset: 0`), color from `--bw-glass-backdrop-color`. +- **Create** (`binary-window/windowless-glass.js` `addWindowlessGlass`, a **static** method mixed onto `BinaryWindow` via `assembleStatic` — see §3) — calls `detachedGlassManager.addDetachedGlass({ position: 'center', ... })` to build + register the glass, sets the `windowless` attribute, appends it to `document.body`, then (unless `animate: false`) plays the open animation. Returns a `Promise` that resolves once the open animation completes. **Remove** (`removeWindowlessGlass(id, { animate = true })`, also static) — `detachedGlassManager.removeDetachedGlass(id)` to unregister, then `removeDetachedGlassElement` to animate out + remove the node and its modal backdrop (below). Also returns a `Promise`. +- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. It fades **in** (via `animateElementByAttribute(backdropEl, 'opening')`, unless `animate: false`) and **out** (the `[closing]` rule, applied by `removeGlassBackdrop`); both `[opening]`/`[closing]` backdrop animations are styled in `detached-glass.css` alongside its base `position: fixed; inset: 0` and `--bw-glass-backdrop-color`. Both `removeWindowlessGlass` and the detached **close** action tear down the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Passing `closeOnBackdropClick: true` (only meaningful with `modal`) wires a one-shot `click` listener on the backdrop that calls `removeWindowlessGlass`. - **Actions** — with no `binaryWindow`, the _minimize_ and _attach_ actions (which need a window to minimize into / attach to) don't apply, so the default set is **close only**: `DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction]` (vs. `[minimize, attach, close]` for an in-window detached glass). - **Containing block** (`detached-glass/utils.js` `getContainingBlockOrigin`) — both kinds are `position: absolute`, but their reference frame differs. An in-window detached glass's `offsetParent` is the positioned `bw-window`, so the origin is that window's rect top-left. A windowless glass on a _static_ `body` has no positioned ancestor → its containing block is the **initial** one (document origin), so the origin is `{ left: -scrollX, top: -scrollY }`. Drag (`move.js`) and resize math both route through this so viewport bounding stays correct regardless of scroll. - **Modal z-index reservation** (`manager.js`) — `bringToFront` increments `topZIndex` by **2** (not 1) and returns the new value, reserving the odd slot just below for a modal overlay on a windowless glass. @@ -364,9 +376,9 @@ Key methods on `BinaryWindow`: - `mount(containerEl)` / `frame()` / `enableFeatures()` — lifecycle. - `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass. Returns `null` if a `before-pane-add` listener vetoed the add (§7). - `removePane(sashId)` — remove a pane (and clean up a minimized sill pot if present). A no-op if a `before-pane-remove` listener vetoed the remove (§7). -- `on(eventName, listener)` / `off(eventName, listener)` — subscribe/unsubscribe to lifecycle events (§7). -- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels inside the window. -- `addWindowlessGlass(options)` / `removeWindowlessGlass(id)` — _static_ floating panels on `document.body`, with no owning window (§8.6). +- `on(eventName, listener)` / `off(eventName, listener)` — subscribe/unsubscribe to lifecycle and action events: the pane events (`before-pane-add`/`pane-add`/`before-pane-remove`/`pane-remove`) and the action events (`close`/`minimize`/`maximize`/`unmaximize`/`detach`/`attach`/`restore`) — see §7. +- `addDetachedGlass(options)` / `removeDetachedGlass(id, { animate })` — floating panels inside the window. Both return a `Promise` that resolves once the open/close animation completes. +- `BinaryWindow.addWindowlessGlass(options)` / `BinaryWindow.removeWindowlessGlass(id, { animate })` — **static** methods (no instance) for floating panels on `document.body` with no owning window; both return a `Promise` (§8.6). - `setTheme(theme)` / `fit()`. **Per-window `actions` config** is normalized by `BinaryWindow.normActions` into `[glassActions, detachedGlassActions]`, supporting forms like `[a1, a2]`, `[[glassActions]]`, `[glassActions, detachedGlassActions]`, `[undefined, detachedGlassActions]`, etc. `undefined` → both defaults; `null`/`[]` → none. diff --git a/docs/context/conventions.md b/docs/context/conventions.md index c115ca1..6d1862a 100644 --- a/docs/context/conventions.md +++ b/docs/context/conventions.md @@ -73,9 +73,9 @@ Preferred patterns for **new** pointer-driven interaction features: ## Animations (enter / exit) - **Enter animations are plain CSS** — an `animation:` on the element's base selector fires once when it's inserted (or un-hidden). No JS needed. Example: `bw-glass[detached] { animation: bw-detached-glass-open 0.18s ease-out; }`. -- **Exit animations need a JS dance** — CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`). The pattern: set a **`[closing]` attribute** the CSS keys the exit animation off, then **defer `.remove()` until `animationend`** (`{ once: true }`, so the listener cleans itself up). Keep the duration only in the stylesheet — no JS-side constant to drift. A boolean opt-out removes immediately, skipping the animation. Canonical use: `removeDetachedGlassElement` in `binary-window/detached-glass/utils.js`. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation. The mirror image is `animateDetachedGlassOpen`: set `[opening]`, clear it on `animationend` so the enter animation can re-run on the next restore from the sill. +- **Enter and exit both run through one shared attribute-driven helper** — `animateElementByAttribute(element, attribute, onComplete)` in `src/animate.js`. It sets the attribute (which the stylesheet keys an `animation:` off), then on `animationend` clears it (so the animation can re-run next time) and runs `onComplete`. Enter uses `[opening]`, exit uses `[closing]`. This is what makes the exit case work: CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`), so the `[closing]` rule plays the exit animation while `onComplete` defers the `.remove()` until it ends. Keep the duration only in the stylesheet — no JS-side constant to drift. A boolean opt-out removes immediately, skipping the animation. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation. Canonical uses: `removeDetachedGlassElement` / `removeGlassBackdrop` in `binary-window/detached-glass/utils.js` (exit), and the `[opening]` calls in `crud.js`/`windowless-glass.js`/`sill.js` (enter on insert and on restore from the sill). - **For genuinely discrete elements (popover/dialog), use the platform** instead of the `[closing]` dance — `@starting-style` for the enter state and `transition-behavior: allow-discrete` on `display`/`overlay` for the exit. Example: the `bw-action-menu` popover in `glass.action.css`. -- **Run-time geometry (one element flying onto another) is WAAPI, not CSS** — when the start/end transforms depend on _measured_ rects (you can't know them at authoring time), use `element.animate(...)` and compute the keyframes from `getBoundingClientRect()`. The shared home is `animateElementToElement(sourceEl, targetEl, onDone)` in `src/animate.js`: a FLIP-style flight that translate/scales `sourceEl` onto `targetEl` with `transform-origin: top left`, fades it out, disables `pointer-events` during the flight, clears the inline styles on finish, then runs `onDone` (e.g. to hide/remove the source). Both elements must be laid out (not `display:none`) so their rects measure. Canonical use: `detached-glass/action.minimize.js` defers `display:none` into `onDone` so the glass shrinks into its sill pot before hiding. +- **Run-time geometry (one element flying onto another) is WAAPI, not CSS** — when the start/end transforms depend on _measured_ rects (you can't know them at authoring time), use `element.animate(...)` and compute the keyframes from `getBoundingClientRect()`. The shared home is `animateElementToElement(sourceEl, targetEl)` in `src/animate.js`: a FLIP-style flight that translate/scales `sourceEl` onto `targetEl` with `transform-origin: top left`, fades it out, disables `pointer-events` during the flight, clears the inline styles on finish, and **returns the `animation.finished` promise** so the caller can chain teardown. Both elements must be laid out (not `display:none`) so their rects measure. Canonical use: `detached-glass/action.minimize.js` does `animateElementToElement(...).then(() => { ... })` to defer `display:none` (and emit the `minimize` event) until the glass has shrunk into its sill pot. - **Animate only `transform`/`opacity`** for enter/exit so the animation never fights features that set `top`/`left`/`width`/`height` (drag/resize write those directly). **Why:** keep the simple case simple (CSS-only enter), and make the unavoidable JS for exit a single predictable shape rather than ad-hoc per feature. Reach for WAAPI only when the geometry can't be known until run time, and route those through the one shared helper rather than re-deriving the FLIP math per feature.