From 73face8ca600f319f8e25f60e9e8cfc888038a54 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Thu, 4 Jun 2026 17:24:50 +1000 Subject: [PATCH 01/57] feat: structure the basic layout of float-pane feature --- CLAUDE.md | 4 ++++ dev/features/float-pane.html | 17 +---------------- dev/features/float-pane.js | 10 ++++++++-- src/binary-window/binary-window.js | 3 ++- src/binary-window/float-pane-manager.js | 23 +++++++++++++++++++++++ src/binary-window/float-pane-utils.js | 16 ++++++++++++++++ src/binary-window/float-pane.js | 10 ++++++++++ src/css/frame.css | 1 + 8 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 src/binary-window/float-pane-manager.js create mode 100644 src/binary-window/float-pane-utils.js create mode 100644 src/binary-window/float-pane.js diff --git a/CLAUDE.md b/CLAUDE.md index 4696747..4ca9ba8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,3 +7,7 @@ ## Testing - Don't run tests or build after completing a feature or fixing a bug unless asked. + +## Dev feature examples (`dev/features/`) + +- Put interactive testing items (buttons, inputs, forms, selects, etc.) into the `.html` file, not the `.js` file. The paired `.js` queries them with `document.querySelector(...)` and wires up behavior with `addEventListener`. See `add-remove-pane.html` / `add-remove-pane.js` for the pattern. diff --git a/dev/features/float-pane.html b/dev/features/float-pane.html index 9b9b770..2a0e5bc 100644 --- a/dev/features/float-pane.html +++ b/dev/features/float-pane.html @@ -5,28 +5,13 @@ Float Pane -

Float Pane

+
-
This is a floating pane
diff --git a/dev/features/float-pane.js b/dev/features/float-pane.js index 9df5aa4..5e087eb 100644 --- a/dev/features/float-pane.js +++ b/dev/features/float-pane.js @@ -35,11 +35,11 @@ const settings = { width: 444, height: 333, children: [ - { position: 'left', size: '40%', content: parentElem }, + { position: 'left', size: '40%' }, { children: [ { position: 'top', size: '30%' }, - { position: 'bottom', size: '70%' }, + { position: 'bottom', size: '70%', content: parentElem }, ], }, ], @@ -47,3 +47,9 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); + +const addFloatPaneButton = document.querySelector('#add-float-pane'); +addFloatPaneButton.addEventListener('click', () => { + const floatPaneEl = bwin.createFloatPane(); + bwin.windowElement.append(floatPaneEl); +}); diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 59122ae..7e8145e 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -4,6 +4,7 @@ import { createDomNode } from '../utils'; import draggableModule from './draggable'; import trimModule from './trim'; import actionsModule from './actions'; +import floatPaneModule from './float-pane'; export class BinaryWindow extends Frame { sillElement = null; @@ -83,4 +84,4 @@ export class BinaryWindow extends Frame { } } -BinaryWindow.assemble(draggableModule, trimModule, actionsModule); +BinaryWindow.assemble(draggableModule, trimModule, actionsModule, floatPaneModule); diff --git a/src/binary-window/float-pane-manager.js b/src/binary-window/float-pane-manager.js new file mode 100644 index 0000000..ca5547b --- /dev/null +++ b/src/binary-window/float-pane-manager.js @@ -0,0 +1,23 @@ + +class FloatPaneManager { + constructor() { + this.floatPanes = []; + } + + add(floatPane) { + this.floatPanes.push(floatPane); + } + + remove(id) { + const index = this.floatPanes.findIndex(fp => fp.id === id); + + if (index !== -1) { + const [removed] = this.floatPanes.splice(index, 1); + return removed; + } + + return null; + } +} + +export const floatPaneManager = new FloatPaneManager(); \ No newline at end of file diff --git a/src/binary-window/float-pane-utils.js b/src/binary-window/float-pane-utils.js new file mode 100644 index 0000000..f75017f --- /dev/null +++ b/src/binary-window/float-pane-utils.js @@ -0,0 +1,16 @@ +import floatPane from "./float-pane"; + + export function createFloatPaneElement() { + const floatPaneEl = document.createElement('bw-float-pane'); + floatPaneEl.style.position = 'absolute'; + floatPaneEl.style.top = '20px'; + floatPaneEl.style.right = '20px'; + floatPaneEl.style.width = '200px'; + floatPaneEl.style.height = '250px'; + floatPaneEl.style.backgroundColor = 'pink'; + floatPaneEl.style.opacity = '0.95'; + + floatPaneEl.setAttribute('active', 'true'); + + return floatPaneEl; + } \ No newline at end of file diff --git a/src/binary-window/float-pane.js b/src/binary-window/float-pane.js new file mode 100644 index 0000000..d50214d --- /dev/null +++ b/src/binary-window/float-pane.js @@ -0,0 +1,10 @@ +import { createFloatPaneElement } from "./float-pane-utils.js"; +import { floatPaneManager } from "./float-pane-manager.js"; + +export default { + createFloatPane() { + const floatPaneEl = createFloatPaneElement(); + floatPaneManager.add(floatPaneEl); + return floatPaneEl; + }, +}; diff --git a/src/css/frame.css b/src/css/frame.css index f9ea828..aeafde0 100644 --- a/src/css/frame.css +++ b/src/css/frame.css @@ -10,6 +10,7 @@ bw-window { } bw-pane { + isolation: isolate; position: absolute; overflow: auto; box-sizing: border-box; From e841b5048c5215f95dcb19d6d261909387deec44 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 5 Jun 2026 15:28:29 +1000 Subject: [PATCH 02/57] refactor: inline glass class defaults --- src/binary-window/glass.js | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/binary-window/glass.js b/src/binary-window/glass.js index 57aeb0a..d7d2acf 100644 --- a/src/binary-window/glass.js +++ b/src/binary-window/glass.js @@ -1,24 +1,16 @@ import { createDomNode } from '../utils'; import { BUILTIN_ACTIONS } from './actions'; -const DEFAULTS = { - title: null, - content: null, - tabs: [], - actions: undefined, - draggable: true, -}; - export class Glass { domNode; constructor({ - title = DEFAULTS.title, - content = DEFAULTS.content, - tabs = DEFAULTS.tabs, - actions = DEFAULTS.actions, - draggable = DEFAULTS.draggable, - sash, + title = null, + content = null, + tabs = [], + actions = undefined, + draggable = true, + sash = null, binaryWindow, }) { this.title = title; From 50ce52fd69ecfc54c6bda0707393634152074352 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 5 Jun 2026 16:27:07 +1000 Subject: [PATCH 03/57] feat: add glass to a float pane --- dev/features/float-pane.js | 4 +++- src/binary-window/draggable.js | 13 ++++++++--- src/binary-window/float-pane-utils.js | 33 ++++++++++++++++----------- src/binary-window/float-pane.js | 22 ++++++++++++++---- src/frame/droppable.js | 3 +++ 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/dev/features/float-pane.js b/dev/features/float-pane.js index 5e087eb..26ddcb6 100644 --- a/dev/features/float-pane.js +++ b/dev/features/float-pane.js @@ -50,6 +50,8 @@ bwin.mount(document.querySelector('#container')); const addFloatPaneButton = document.querySelector('#add-float-pane'); addFloatPaneButton.addEventListener('click', () => { - const floatPaneEl = bwin.createFloatPane(); + const floatPaneEl = bwin.addFloatPane(); bwin.windowElement.append(floatPaneEl); }); + +addFloatPaneButton.click(); \ No newline at end of file diff --git a/src/binary-window/draggable.js b/src/binary-window/draggable.js index 27d3c66..171c771 100644 --- a/src/binary-window/draggable.js +++ b/src/binary-window/draggable.js @@ -26,6 +26,7 @@ export default { } }, + // TODO: handle float-pane drag enableDrag() { // Identify which "glass" element to be dragged // Prevent drag from being triggered by child elements, e.g. action buttons @@ -66,6 +67,9 @@ export default { event.dataTransfer.effectAllowed = 'move'; const paneEl = this.activeDragGlassEl.closest('bw-pane'); + // TODO: handle float pane drag and drop + if (!paneEl) return; + // Save original `can-drop` attribute for later carry-over this.activeDragGlassPaneCanDrop = paneEl.getAttribute('can-drop') !== 'false'; paneEl.setAttribute('can-drop', false); @@ -74,10 +78,13 @@ export default { this.windowElement.addEventListener('dragend', () => { if (this.activeDragGlassEl) { this.activeDragGlassEl.removeAttribute('draggable'); + + const paneEl = this.activeDragGlassEl.closest('bw-pane'); + // TODO: handle float pane drag and drop + if (!paneEl) return; + // Carry over `can-drop` attribute - this.activeDragGlassEl - .closest('bw-pane') - .setAttribute('can-drop', this.activeDragGlassPaneCanDrop); + paneEl.setAttribute('can-drop', this.activeDragGlassPaneCanDrop); this.activeDragGlassEl = null; } }); diff --git a/src/binary-window/float-pane-utils.js b/src/binary-window/float-pane-utils.js index f75017f..29b3519 100644 --- a/src/binary-window/float-pane-utils.js +++ b/src/binary-window/float-pane-utils.js @@ -1,16 +1,23 @@ -import floatPane from "./float-pane"; +import floatPane from './float-pane'; +import { genId } from '../utils'; - export function createFloatPaneElement() { - const floatPaneEl = document.createElement('bw-float-pane'); - floatPaneEl.style.position = 'absolute'; - floatPaneEl.style.top = '20px'; - floatPaneEl.style.right = '20px'; - floatPaneEl.style.width = '200px'; - floatPaneEl.style.height = '250px'; - floatPaneEl.style.backgroundColor = 'pink'; - floatPaneEl.style.opacity = '0.95'; +// TODO: position can be 'center', 'top-left', 'top-center', 'top-right', etc +export function createFloatPaneElement({ width, height, offset, position, id }) { + const floatPaneEl = document.createElement('bw-float-pane'); + floatPaneEl.style.position = 'absolute'; - floatPaneEl.setAttribute('active', 'true'); + if (position === 'top-right') { + floatPaneEl.style.top = `${offset}px`; + floatPaneEl.style.right = `${offset}px`; + floatPaneEl.style.width = `${width}px`; + floatPaneEl.style.height = `${height}px`; + } - return floatPaneEl; - } \ No newline at end of file + floatPaneEl.style.backgroundColor = 'pink'; + floatPaneEl.style.opacity = '0.95'; + + floatPaneEl.setAttribute('sash-id', id || genId()); + floatPaneEl.setAttribute('active', 'true'); + + return floatPaneEl; +} diff --git a/src/binary-window/float-pane.js b/src/binary-window/float-pane.js index d50214d..5dbd273 100644 --- a/src/binary-window/float-pane.js +++ b/src/binary-window/float-pane.js @@ -1,10 +1,24 @@ -import { createFloatPaneElement } from "./float-pane-utils.js"; -import { floatPaneManager } from "./float-pane-manager.js"; +import { createFloatPaneElement } from './float-pane-utils.js'; +import { floatPaneManager } from './float-pane-manager.js'; +import { Glass } from './glass.js'; export default { - createFloatPane() { - const floatPaneEl = createFloatPaneElement(); + addFloatPane(props = {}) { + const { + position = 'top-right', + width = 200, + height = 200, + offset = -20, + id, + ...glassProps + } = props; + + const glass = new Glass({ ...glassProps, binaryWindow: this }); + const floatPaneEl = createFloatPaneElement({ position, width, height, offset, id }); + + floatPaneEl.append(glass.domNode); floatPaneManager.add(floatPaneEl); + return floatPaneEl; }, }; diff --git a/src/frame/droppable.js b/src/frame/droppable.js index bd8c4a2..32d5533 100644 --- a/src/frame/droppable.js +++ b/src/frame/droppable.js @@ -1,5 +1,8 @@ import { getCursorPosition } from '../position'; + +// TODO: consider moving this to `binary-window` +// because it works closely with `draggable` and `float-pane` export default { activeDropPaneEl: null, From 304df344bc8b34b35757da634b66fb6851ee2a13 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 5 Jun 2026 16:43:41 +1000 Subject: [PATCH 04/57] fix: use dom id to mark float pane. sash-id are only used for panes bound to sash tree --- src/binary-window/draggable.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/binary-window/draggable.js b/src/binary-window/draggable.js index 171c771..27d3c66 100644 --- a/src/binary-window/draggable.js +++ b/src/binary-window/draggable.js @@ -26,7 +26,6 @@ export default { } }, - // TODO: handle float-pane drag enableDrag() { // Identify which "glass" element to be dragged // Prevent drag from being triggered by child elements, e.g. action buttons @@ -67,9 +66,6 @@ export default { event.dataTransfer.effectAllowed = 'move'; const paneEl = this.activeDragGlassEl.closest('bw-pane'); - // TODO: handle float pane drag and drop - if (!paneEl) return; - // Save original `can-drop` attribute for later carry-over this.activeDragGlassPaneCanDrop = paneEl.getAttribute('can-drop') !== 'false'; paneEl.setAttribute('can-drop', false); @@ -78,13 +74,10 @@ export default { this.windowElement.addEventListener('dragend', () => { if (this.activeDragGlassEl) { this.activeDragGlassEl.removeAttribute('draggable'); - - const paneEl = this.activeDragGlassEl.closest('bw-pane'); - // TODO: handle float pane drag and drop - if (!paneEl) return; - // Carry over `can-drop` attribute - paneEl.setAttribute('can-drop', this.activeDragGlassPaneCanDrop); + this.activeDragGlassEl + .closest('bw-pane') + .setAttribute('can-drop', this.activeDragGlassPaneCanDrop); this.activeDragGlassEl = null; } }); From 45a44701404d45a5724dd58786871eeec9075639 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 5 Jun 2026 18:20:40 +1000 Subject: [PATCH 05/57] fix: append float pane to bw window and enchance check at drag and drop --- dev/features/float-pane.js | 1 - src/binary-window/draggable.js | 16 ++++++++++++---- src/binary-window/float-pane-utils.js | 3 ++- src/binary-window/float-pane.js | 2 ++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/dev/features/float-pane.js b/dev/features/float-pane.js index 26ddcb6..c0374ec 100644 --- a/dev/features/float-pane.js +++ b/dev/features/float-pane.js @@ -51,7 +51,6 @@ bwin.mount(document.querySelector('#container')); const addFloatPaneButton = document.querySelector('#add-float-pane'); addFloatPaneButton.addEventListener('click', () => { const floatPaneEl = bwin.addFloatPane(); - bwin.windowElement.append(floatPaneEl); }); addFloatPaneButton.click(); \ No newline at end of file diff --git a/src/binary-window/draggable.js b/src/binary-window/draggable.js index 27d3c66..c76c62b 100644 --- a/src/binary-window/draggable.js +++ b/src/binary-window/draggable.js @@ -5,8 +5,14 @@ export default { activeDragGlassEl: null, activeDragGlassPaneCanDrop: false, + // Only handles drag from panes, not from float panes + activeDragGlassExists() { + return this.activeDragGlassEl && this.activeDragGlassEl.parentElement.matches('bw-pane'); + }, + onPaneDrop(event, sash) { - if (!this.activeDragGlassEl) return; + if (!this.activeDragGlassExists()) return; + const dropArea = this.activeDropPaneEl.getAttribute('drop-area'); // Swap the content of the two panes @@ -48,7 +54,7 @@ export default { }); document.addEventListener('mouseup', () => { - if (this.activeDragGlassEl) { + if (this.activeDragGlassExists()) { this.activeDragGlassEl.removeAttribute('draggable'); this.activeDragGlassEl = null; } @@ -58,7 +64,8 @@ export default { if ( !(event.target instanceof HTMLElement) || !event.target.matches('bw-glass') || - !this.activeDragGlassEl + // Only handles drag from panes, not from float panes + !this.activeDragGlassExists() ) { return; } @@ -67,12 +74,13 @@ export default { const paneEl = this.activeDragGlassEl.closest('bw-pane'); // Save original `can-drop` attribute for later carry-over + // Because after drop, a new pane is created and it needs to know if original pane can be dropped or not this.activeDragGlassPaneCanDrop = paneEl.getAttribute('can-drop') !== 'false'; paneEl.setAttribute('can-drop', false); }); this.windowElement.addEventListener('dragend', () => { - if (this.activeDragGlassEl) { + if (this.activeDragGlassExists()) { this.activeDragGlassEl.removeAttribute('draggable'); // Carry over `can-drop` attribute this.activeDragGlassEl diff --git a/src/binary-window/float-pane-utils.js b/src/binary-window/float-pane-utils.js index 29b3519..cccbb43 100644 --- a/src/binary-window/float-pane-utils.js +++ b/src/binary-window/float-pane-utils.js @@ -16,7 +16,8 @@ export function createFloatPaneElement({ width, height, offset, position, id }) floatPaneEl.style.backgroundColor = 'pink'; floatPaneEl.style.opacity = '0.95'; - floatPaneEl.setAttribute('sash-id', id || genId()); + // Generate an ID like "AB-123-F" to diff from Sash IDs + floatPaneEl.setAttribute('id', id || genId() + '-F'); floatPaneEl.setAttribute('active', 'true'); return floatPaneEl; diff --git a/src/binary-window/float-pane.js b/src/binary-window/float-pane.js index 5dbd273..6a7ad20 100644 --- a/src/binary-window/float-pane.js +++ b/src/binary-window/float-pane.js @@ -17,6 +17,8 @@ export default { const floatPaneEl = createFloatPaneElement({ position, width, height, offset, id }); floatPaneEl.append(glass.domNode); + this.windowElement.append(floatPaneEl); + floatPaneManager.add(floatPaneEl); return floatPaneEl; From 1ec8c8e062669d40728729a1be4f2b81d8dcacdc Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 17:46:26 +1000 Subject: [PATCH 06/57] refactor: replace float pane with detached glass --- .../{float-pane.html => detached-glass.html} | 8 ++--- .../{float-pane.js => detached-glass.js} | 8 ++--- src/binary-window/binary-window.js | 4 +-- src/binary-window/detached-glass/class.js | 33 +++++++++++++++++++ src/binary-window/detached-glass/index.js | 1 + src/binary-window/detached-glass/manager.js | 22 +++++++++++++ src/binary-window/detached-glass/module.js | 12 +++++++ src/binary-window/detached-glass/utils.js | 14 ++++++++ src/binary-window/draggable.js | 20 +++++------ src/binary-window/float-pane-manager.js | 23 ------------- src/binary-window/float-pane-utils.js | 24 -------------- src/binary-window/float-pane.js | 26 --------------- 12 files changed, 101 insertions(+), 94 deletions(-) rename dev/features/{float-pane.html => detached-glass.html} (56%) rename dev/features/{float-pane.js => detached-glass.js} (86%) create mode 100644 src/binary-window/detached-glass/class.js create mode 100644 src/binary-window/detached-glass/index.js create mode 100644 src/binary-window/detached-glass/manager.js create mode 100644 src/binary-window/detached-glass/module.js create mode 100644 src/binary-window/detached-glass/utils.js delete mode 100644 src/binary-window/float-pane-manager.js delete mode 100644 src/binary-window/float-pane-utils.js delete mode 100644 src/binary-window/float-pane.js diff --git a/dev/features/float-pane.html b/dev/features/detached-glass.html similarity index 56% rename from dev/features/float-pane.html rename to dev/features/detached-glass.html index 2a0e5bc..2948629 100644 --- a/dev/features/float-pane.html +++ b/dev/features/detached-glass.html @@ -3,14 +3,14 @@ - - Float Pane + + Detached Glass
-

Float Pane

- +

Detached Glass

+
diff --git a/dev/features/float-pane.js b/dev/features/detached-glass.js similarity index 86% rename from dev/features/float-pane.js rename to dev/features/detached-glass.js index c0374ec..7b8ae4d 100644 --- a/dev/features/float-pane.js +++ b/dev/features/detached-glass.js @@ -48,9 +48,9 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); -const addFloatPaneButton = document.querySelector('#add-float-pane'); -addFloatPaneButton.addEventListener('click', () => { - const floatPaneEl = bwin.addFloatPane(); +const addDetachedGlassButton = document.querySelector('#add-detached-glass'); +addDetachedGlassButton.addEventListener('click', () => { + const glass = bwin.addDetachedGlass(); }); -addFloatPaneButton.click(); \ No newline at end of file +addDetachedGlassButton.click(); \ No newline at end of file diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 7e8145e..3612ad8 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -4,7 +4,7 @@ import { createDomNode } from '../utils'; import draggableModule from './draggable'; import trimModule from './trim'; import actionsModule from './actions'; -import floatPaneModule from './float-pane'; +import detachedGlassModule from './detached-glass'; export class BinaryWindow extends Frame { sillElement = null; @@ -84,4 +84,4 @@ export class BinaryWindow extends Frame { } } -BinaryWindow.assemble(draggableModule, trimModule, actionsModule, floatPaneModule); +BinaryWindow.assemble(draggableModule, trimModule, actionsModule, detachedGlassModule); diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js new file mode 100644 index 0000000..50c788a --- /dev/null +++ b/src/binary-window/detached-glass/class.js @@ -0,0 +1,33 @@ +import { Glass } from '../glass'; +import { genId } from '@/utils.js'; +import { genStylesByPosition } from './utils'; + +export class DetachedGlass extends Glass { + constructor(options) { + const { + position = 'top-right', + width = 200, + height = 200, + offset = -20, + id, + ...glassOptions + } = options; + + super(glassOptions); + + this.domNode.setAttribute('id', id || genId() + '-F'); + this.domNode.setAttribute('detached', ''); + this.domNode.setAttribute('active', ''); + + this.domNode.style.position = 'absolute'; + this.domNode.style.width = `${width}px`; + this.domNode.style.height = `${height}px`; + + const { top, left, right, bottom } = genStylesByPosition(position, offset); + + this.domNode.style.top = top; + this.domNode.style.left = left; + this.domNode.style.right = right; + this.domNode.style.bottom = bottom; + } +} diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js new file mode 100644 index 0000000..d533cc9 --- /dev/null +++ b/src/binary-window/detached-glass/index.js @@ -0,0 +1 @@ +export { default } from './module'; diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js new file mode 100644 index 0000000..c1fb58f --- /dev/null +++ b/src/binary-window/detached-glass/manager.js @@ -0,0 +1,22 @@ +class DetachedGlassManager { + constructor() { + this.glasses = []; + } + + add(glass) { + this.glasses.push(glass); + } + + remove(id) { + const index = this.glasses.findIndex((g) => g.id === id); + + if (index !== -1) { + const [removed] = this.glasses.splice(index, 1); + return removed; + } + + return null; + } +} + +export const detachedGlassManager = new DetachedGlassManager(); diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js new file mode 100644 index 0000000..abdfae3 --- /dev/null +++ b/src/binary-window/detached-glass/module.js @@ -0,0 +1,12 @@ +import { DetachedGlass } from './class'; +import { detachedGlassManager } from './manager'; + +export default { + addDetachedGlass(options = {}) { + const glass = new DetachedGlass(options); + this.windowElement.append(glass.domNode); + detachedGlassManager.add(glass.domNode); + + return glass; + }, +}; diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js new file mode 100644 index 0000000..75f9ab5 --- /dev/null +++ b/src/binary-window/detached-glass/utils.js @@ -0,0 +1,14 @@ +export function genStylesByPosition(position, offset) { + switch (position) { + case 'top-left': + return { top: `${offset}px`, left: `${offset}px`, right: 'auto', bottom: 'auto' }; + case 'top-right': + return { top: `${offset}px`, right: `${offset}px`, left: 'auto', bottom: 'auto' }; + case 'bottom-left': + return { bottom: `${offset}px`, left: `${offset}px`, right: 'auto', top: 'auto' }; + case 'bottom-right': + return { bottom: `${offset}px`, right: `${offset}px`, left: 'auto', top: 'auto' }; + default: + throw new Error(`Position "${position}" is not supported for detached glass.`); + } +} diff --git a/src/binary-window/draggable.js b/src/binary-window/draggable.js index c76c62b..51d3029 100644 --- a/src/binary-window/draggable.js +++ b/src/binary-window/draggable.js @@ -5,14 +5,9 @@ export default { activeDragGlassEl: null, activeDragGlassPaneCanDrop: false, - // Only handles drag from panes, not from float panes - activeDragGlassExists() { - return this.activeDragGlassEl && this.activeDragGlassEl.parentElement.matches('bw-pane'); - }, - onPaneDrop(event, sash) { - if (!this.activeDragGlassExists()) return; - + if (!this.activeDragGlassEl) return; + const dropArea = this.activeDropPaneEl.getAttribute('drop-area'); // Swap the content of the two panes @@ -47,6 +42,10 @@ export default { } const headerEl = event.target; + + // Only handles drag from panes, not from detached glasses + if (!headerEl.closest('bw-pane')) return; + const glassEl = headerEl.closest('bw-glass'); glassEl.setAttribute('draggable', true); @@ -54,7 +53,7 @@ export default { }); document.addEventListener('mouseup', () => { - if (this.activeDragGlassExists()) { + if (this.activeDragGlassEl) { this.activeDragGlassEl.removeAttribute('draggable'); this.activeDragGlassEl = null; } @@ -64,8 +63,7 @@ export default { if ( !(event.target instanceof HTMLElement) || !event.target.matches('bw-glass') || - // Only handles drag from panes, not from float panes - !this.activeDragGlassExists() + !this.activeDragGlassEl ) { return; } @@ -80,7 +78,7 @@ export default { }); this.windowElement.addEventListener('dragend', () => { - if (this.activeDragGlassExists()) { + if (this.activeDragGlassEl) { this.activeDragGlassEl.removeAttribute('draggable'); // Carry over `can-drop` attribute this.activeDragGlassEl diff --git a/src/binary-window/float-pane-manager.js b/src/binary-window/float-pane-manager.js deleted file mode 100644 index ca5547b..0000000 --- a/src/binary-window/float-pane-manager.js +++ /dev/null @@ -1,23 +0,0 @@ - -class FloatPaneManager { - constructor() { - this.floatPanes = []; - } - - add(floatPane) { - this.floatPanes.push(floatPane); - } - - remove(id) { - const index = this.floatPanes.findIndex(fp => fp.id === id); - - if (index !== -1) { - const [removed] = this.floatPanes.splice(index, 1); - return removed; - } - - return null; - } -} - -export const floatPaneManager = new FloatPaneManager(); \ No newline at end of file diff --git a/src/binary-window/float-pane-utils.js b/src/binary-window/float-pane-utils.js deleted file mode 100644 index cccbb43..0000000 --- a/src/binary-window/float-pane-utils.js +++ /dev/null @@ -1,24 +0,0 @@ -import floatPane from './float-pane'; -import { genId } from '../utils'; - -// TODO: position can be 'center', 'top-left', 'top-center', 'top-right', etc -export function createFloatPaneElement({ width, height, offset, position, id }) { - const floatPaneEl = document.createElement('bw-float-pane'); - floatPaneEl.style.position = 'absolute'; - - if (position === 'top-right') { - floatPaneEl.style.top = `${offset}px`; - floatPaneEl.style.right = `${offset}px`; - floatPaneEl.style.width = `${width}px`; - floatPaneEl.style.height = `${height}px`; - } - - floatPaneEl.style.backgroundColor = 'pink'; - floatPaneEl.style.opacity = '0.95'; - - // Generate an ID like "AB-123-F" to diff from Sash IDs - floatPaneEl.setAttribute('id', id || genId() + '-F'); - floatPaneEl.setAttribute('active', 'true'); - - return floatPaneEl; -} diff --git a/src/binary-window/float-pane.js b/src/binary-window/float-pane.js deleted file mode 100644 index 6a7ad20..0000000 --- a/src/binary-window/float-pane.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createFloatPaneElement } from './float-pane-utils.js'; -import { floatPaneManager } from './float-pane-manager.js'; -import { Glass } from './glass.js'; - -export default { - addFloatPane(props = {}) { - const { - position = 'top-right', - width = 200, - height = 200, - offset = -20, - id, - ...glassProps - } = props; - - const glass = new Glass({ ...glassProps, binaryWindow: this }); - const floatPaneEl = createFloatPaneElement({ position, width, height, offset, id }); - - floatPaneEl.append(glass.domNode); - this.windowElement.append(floatPaneEl); - - floatPaneManager.add(floatPaneEl); - - return floatPaneEl; - }, -}; From a8d30e2d45ee48914999b876d1677e159102168c Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 17:58:57 +1000 Subject: [PATCH 07/57] chore: use @ as root path --- jsconfig.json | 8 ++++++++ vite.config.js | 6 ++++++ vitest.config.js | 6 ++++++ 3 files changed, 20 insertions(+) create mode 100644 jsconfig.json diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5a1f2d2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.js b/vite.config.js index 060c361..455f112 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,14 @@ import { defineConfig } from 'vite'; +import { fileURLToPath, URL } from 'node:url'; export default defineConfig({ root: './dev', envDir: '../', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, build: { outDir: '../dist', emptyOutDir: true, diff --git a/vitest.config.js b/vitest.config.js index 647a9e5..0a12782 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,6 +1,12 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath, URL } from 'node:url'; export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, test: { environment: 'jsdom', }, From 0a980b5a4db2a8484a4e50caec87d00f1a92c98c Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 18:05:24 +1000 Subject: [PATCH 08/57] fix: detached glass shows text copy cursor when dragging on glass header --- src/css/glass.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/css/glass.css b/src/css/glass.css index df723a5..29cc216 100644 --- a/src/css/glass.css +++ b/src/css/glass.css @@ -68,6 +68,8 @@ bw-glass-header { flex-shrink: 0; display: flex; align-items: center; + /* Prevent native text-select/drag while dragging the header */ + user-select: none; gap: var(--bw-glass-header-gap); overflow: hidden; padding-inline: var(--bw-glass-header-gap); From 867c06fcf7031717aee0d739d58dbc1d840d1aa1 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 18:05:51 +1000 Subject: [PATCH 09/57] chore: cleanup --- dev/features/detached-glass.js | 2 +- src/binary-window/draggable.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev/features/detached-glass.js b/dev/features/detached-glass.js index 7b8ae4d..0baa72d 100644 --- a/dev/features/detached-glass.js +++ b/dev/features/detached-glass.js @@ -50,7 +50,7 @@ bwin.mount(document.querySelector('#container')); const addDetachedGlassButton = document.querySelector('#add-detached-glass'); addDetachedGlassButton.addEventListener('click', () => { - const glass = bwin.addDetachedGlass(); + bwin.addDetachedGlass( ); }); addDetachedGlassButton.click(); \ No newline at end of file diff --git a/src/binary-window/draggable.js b/src/binary-window/draggable.js index 51d3029..951da04 100644 --- a/src/binary-window/draggable.js +++ b/src/binary-window/draggable.js @@ -1,5 +1,4 @@ import { getSashIdFromPane } from '../frame/frame-utils'; -import { swapChildNodes } from '../utils'; export default { activeDragGlassEl: null, From 820391dc8ab6656dbde35cf2007e18efc391c9d3 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 18:45:45 +1000 Subject: [PATCH 10/57] feat(detached-glass): resize --- dev/features/detached-glass.html | 5 +- dev/features/detached-glass.js | 11 +- src/binary-window/binary-window.js | 1 + src/binary-window/detached-glass/class.js | 2 +- src/binary-window/detached-glass/module.js | 134 +++++++++++++++++++++ src/binary-window/detached-glass/utils.js | 11 ++ src/css/detached-glass.css | 71 +++++++++++ src/index.js | 1 + 8 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 src/css/detached-glass.css diff --git a/dev/features/detached-glass.html b/dev/features/detached-glass.html index 2948629..a5e5eca 100644 --- a/dev/features/detached-glass.html +++ b/dev/features/detached-glass.html @@ -10,7 +10,10 @@

Detached Glass

- + + + +
diff --git a/dev/features/detached-glass.js b/dev/features/detached-glass.js index 0baa72d..787e645 100644 --- a/dev/features/detached-glass.js +++ b/dev/features/detached-glass.js @@ -48,9 +48,8 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); -const addDetachedGlassButton = document.querySelector('#add-detached-glass'); -addDetachedGlassButton.addEventListener('click', () => { - bwin.addDetachedGlass( ); -}); - -addDetachedGlassButton.click(); \ No newline at end of file +document.querySelectorAll('button[data-position]').forEach((button) => { + button.addEventListener('click', () => { + bwin.addDetachedGlass({ position: button.dataset.position, title: button.dataset.position }); + }); +}); \ No newline at end of file diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 3612ad8..a66d0c0 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -20,6 +20,7 @@ export class BinaryWindow extends Frame { super.enableFeatures(); this.enableDrag(); this.enableActions(); + this.enableDetachedGlassResize(); } onPaneCreate(paneEl, sash) { diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 50c788a..0677e1e 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -24,7 +24,7 @@ export class DetachedGlass extends Glass { this.domNode.style.height = `${height}px`; const { top, left, right, bottom } = genStylesByPosition(position, offset); - + this.domNode.style.top = top; this.domNode.style.left = left; this.domNode.style.right = right; diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index abdfae3..8ff1a8c 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -1,7 +1,31 @@ import { DetachedGlass } from './class'; import { detachedGlassManager } from './manager'; +import { createResizeHandles } from './utils'; + +const MIN_WIDTH = 100; +const MIN_HEIGHT = 60; + +function hasResizeHandles(glassEl) { + return Boolean(glassEl.querySelector(':scope > bw-glass-resize-handle')); +} + +function addResizeHandles(glassEl) { + if (!hasResizeHandles(glassEl)) { + glassEl.append(...createResizeHandles()); + } +} + +function removeResizeHandles(glassEl) { + glassEl.querySelectorAll(':scope > bw-glass-resize-handle').forEach((el) => el.remove()); +} export default { + activeResizeGlassEl: null, + activeResizeDir: '', + resizeStartX: 0, + resizeStartY: 0, + resizeStartRect: null, + addDetachedGlass(options = {}) { const glass = new DetachedGlass(options); this.windowElement.append(glass.domNode); @@ -9,4 +33,114 @@ export default { return glass; }, + + enableDetachedGlassResize() { + // Create resize handles only while a detached glass is hovered, and + // remove them on leave — so idle glasses cost no extra DOM nodes. + // Delegated on `windowElement`: a constant 5 listeners regardless of + // how many detached glasses exist. `closest` on target/relatedTarget + // gives enter/leave semantics for the whole glass subtree (handles + // included), so moving onto a handle does not count as leaving. + this.windowElement.addEventListener('pointerover', (event) => { + const glassEl = event.target.closest?.('bw-glass[detached]'); + if (glassEl) addResizeHandles(glassEl); + }); + + this.windowElement.addEventListener('pointerout', (event) => { + const glassEl = event.target.closest?.('bw-glass[detached]'); + if (!glassEl) return; + + // Still inside the same glass subtree → not a real leave. + const toGlassEl = event.relatedTarget?.closest?.('bw-glass[detached]'); + if (toGlassEl === glassEl) return; + + // Keep handles while this glass is being resized; cleaned up on pointerup. + if (glassEl === this.activeResizeGlassEl) return; + + removeResizeHandles(glassEl); + }); + + // Identify which detached glass and which edge/corner to resize. + // Pointer events give a single code path for mouse/touch/pen, and + // `setPointerCapture` keeps move events flowing even when the pointer + // leaves the handle or the window, so no document-level listeners. + this.windowElement.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || event.target.tagName !== 'BW-GLASS-RESIZE-HANDLE') return; + + const glassEl = event.target.closest('bw-glass[detached]'); + if (!glassEl) return; + + event.preventDefault(); + event.target.setPointerCapture(event.pointerId); + + this.activeResizeGlassEl = glassEl; + this.activeResizeDir = event.target.getAttribute('resize-dir'); + this.resizeStartX = event.pageX; + this.resizeStartY = event.pageY; + + // Normalize corner-anchored geometry (top/right/bottom/left + offset) + // into plain left/top/width/height so every edge resizes with the same math. + const windowRect = this.windowElement.getBoundingClientRect(); + const glassRect = glassEl.getBoundingClientRect(); + this.resizeStartRect = { + left: glassRect.left - windowRect.left, + top: glassRect.top - windowRect.top, + width: glassRect.width, + height: glassRect.height, + }; + }); + + this.windowElement.addEventListener('pointermove', (event) => { + if (!this.activeResizeGlassEl) return; + + const dir = this.activeResizeDir; + const distX = event.pageX - this.resizeStartX; + const distY = event.pageY - this.resizeStartY; + const start = this.resizeStartRect; + + let { left, top, width, height } = start; + + if (dir.includes('e')) { + width = Math.max(MIN_WIDTH, start.width + distX); + } + else if (dir.includes('w')) { + width = Math.max(MIN_WIDTH, start.width - distX); + left = start.left + (start.width - width); + } + + if (dir.includes('s')) { + height = Math.max(MIN_HEIGHT, start.height + distY); + } + else if (dir.includes('n')) { + height = Math.max(MIN_HEIGHT, start.height - distY); + top = start.top + (start.height - height); + } + + const glassEl = this.activeResizeGlassEl; + glassEl.style.right = 'auto'; + glassEl.style.bottom = 'auto'; + glassEl.style.left = `${left}px`; + glassEl.style.top = `${top}px`; + glassEl.style.width = `${width}px`; + glassEl.style.height = `${height}px`; + }); + + this.windowElement.addEventListener('pointerup', (event) => { + if (!this.activeResizeGlassEl) return; + + if (event.target.hasPointerCapture?.(event.pointerId)) { + event.target.releasePointerCapture(event.pointerId); + } + + const glassEl = this.activeResizeGlassEl; + + this.activeResizeGlassEl = null; + this.activeResizeDir = ''; + this.resizeStartRect = null; + + // If the drag ended with the pointer outside the glass, the guarded + // pointerout never removed the handles — drop them now. + if (!glassEl.matches(':hover')) removeResizeHandles(glassEl); + }); + }, }; diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 75f9ab5..3442fc7 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -1,3 +1,14 @@ +// 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']; + +export function createResizeHandles() { + return RESIZE_DIRECTIONS.map((dir) => { + const handleEl = document.createElement('bw-glass-resize-handle'); + handleEl.setAttribute('resize-dir', dir); + return handleEl; + }); +} + export function genStylesByPosition(position, offset) { switch (position) { case 'top-left': diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css new file mode 100644 index 0000000..ecef77a --- /dev/null +++ b/src/css/detached-glass.css @@ -0,0 +1,71 @@ +bw-glass-resize-handle { + /* Thickness of the grab zone, straddling the glass border */ + --bw-resize-handle-size: 8px; + position: absolute; + z-index: 2; + touch-action: none; + + &[resize-dir='n'] { + top: calc(var(--bw-resize-handle-size) / -2); + left: 0; + right: 0; + height: var(--bw-resize-handle-size); + cursor: ns-resize; + } + + &[resize-dir='s'] { + bottom: calc(var(--bw-resize-handle-size) / -2); + left: 0; + right: 0; + height: var(--bw-resize-handle-size); + cursor: ns-resize; + } + + &[resize-dir='e'] { + top: 0; + bottom: 0; + right: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + cursor: ew-resize; + } + + &[resize-dir='w'] { + top: 0; + bottom: 0; + left: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + cursor: ew-resize; + } + + &[resize-dir='ne'] { + top: calc(var(--bw-resize-handle-size) / -2); + right: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + height: var(--bw-resize-handle-size); + cursor: nesw-resize; + } + + &[resize-dir='nw'] { + top: calc(var(--bw-resize-handle-size) / -2); + left: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + height: var(--bw-resize-handle-size); + cursor: nwse-resize; + } + + &[resize-dir='se'] { + bottom: calc(var(--bw-resize-handle-size) / -2); + right: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + height: var(--bw-resize-handle-size); + cursor: nwse-resize; + } + + &[resize-dir='sw'] { + bottom: calc(var(--bw-resize-handle-size) / -2); + left: calc(var(--bw-resize-handle-size) / -2); + width: var(--bw-resize-handle-size); + height: var(--bw-resize-handle-size); + cursor: nesw-resize; + } +} diff --git a/src/index.js b/src/index.js index d88cb49..2630612 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import './css/vars.css'; import './css/body.css'; import './css/frame.css'; import './css/glass.css'; +import './css/detached-glass.css'; import './css/sill.css'; export { Frame } from './frame/frame'; From 6d7b58467c22cf059d9970b2c066bbe68ddea723 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 19:58:42 +1000 Subject: [PATCH 11/57] feat(detached-glass): move --- dev/features/detached-glass.html | 1 + dev/features/detached-glass.js | 4 +- src/binary-window/binary-window.js | 2 + src/binary-window/detached-glass/class.js | 3 +- src/binary-window/detached-glass/module.js | 89 ++++++++++++++++++++++ src/binary-window/detached-glass/utils.js | 13 +++- src/css/detached-glass.css | 67 +++++++++------- src/css/vars.css | 8 ++ 8 files changed, 157 insertions(+), 30 deletions(-) diff --git a/dev/features/detached-glass.html b/dev/features/detached-glass.html index a5e5eca..f38ef26 100644 --- a/dev/features/detached-glass.html +++ b/dev/features/detached-glass.html @@ -14,6 +14,7 @@

Detached Glass

+
diff --git a/dev/features/detached-glass.js b/dev/features/detached-glass.js index 787e645..9e3af73 100644 --- a/dev/features/detached-glass.js +++ b/dev/features/detached-glass.js @@ -52,4 +52,6 @@ document.querySelectorAll('button[data-position]').forEach((button) => { button.addEventListener('click', () => { bwin.addDetachedGlass({ position: button.dataset.position, title: button.dataset.position }); }); -}); \ No newline at end of file +}); + +document.querySelector('button[data-position="center"]').click(); \ No newline at end of file diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index a66d0c0..23241b8 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -20,7 +20,9 @@ export class BinaryWindow extends Frame { super.enableFeatures(); this.enableDrag(); this.enableActions(); + this.enableDetachedGlassActivate(); this.enableDetachedGlassResize(); + this.enableDetachedGlassMove(); } onPaneCreate(paneEl, sash) { diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 0677e1e..036f327 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -17,13 +17,12 @@ export class DetachedGlass extends Glass { this.domNode.setAttribute('id', id || genId() + '-F'); this.domNode.setAttribute('detached', ''); - this.domNode.setAttribute('active', ''); this.domNode.style.position = 'absolute'; this.domNode.style.width = `${width}px`; this.domNode.style.height = `${height}px`; - const { top, left, right, bottom } = genStylesByPosition(position, offset); + const { top, left, right, bottom } = genStylesByPosition({ position, offset, width, height }); this.domNode.style.top = top; this.domNode.style.left = left; diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index 8ff1a8c..b5a8a83 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -5,6 +5,21 @@ import { createResizeHandles } from './utils'; const MIN_WIDTH = 100; const MIN_HEIGHT = 60; +// Rising counter so the most recently grabbed glass stacks on top, like an OS window. +let topZIndex = 1; + +function bringToFront(glassEl) { + topZIndex += 1; + glassEl.style.zIndex = topZIndex; + + // Mark this glass as the active (focused) one, clearing the rest — like + // focusing an OS window. Drives the stronger drop-shadow in CSS. + glassEl.parentElement + ?.querySelectorAll(':scope > bw-glass[detached][active]') + .forEach((el) => el !== glassEl && el.removeAttribute('active')); + glassEl.setAttribute('active', ''); +} + function hasResizeHandles(glassEl) { return Boolean(glassEl.querySelector(':scope > bw-glass-resize-handle')); } @@ -26,14 +41,88 @@ export default { resizeStartY: 0, resizeStartRect: null, + activeMoveGlassEl: null, + moveStartX: 0, + moveStartY: 0, + moveStartLeft: 0, + moveStartTop: 0, + addDetachedGlass(options = {}) { const glass = new DetachedGlass(options); this.windowElement.append(glass.domNode); detachedGlassManager.add(glass.domNode); + bringToFront(glass.domNode); return glass; }, + enableDetachedGlassActivate() { + // Clicking anywhere in a detached glass focuses it and brings it to front, + // like an OS window. Runs for move/resize grabs too (they bubble here), + // so focus handling lives in one place. + this.windowElement.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + + const glassEl = event.target.closest?.('bw-glass[detached]'); + if (glassEl) bringToFront(glassEl); + }); + }, + + enableDetachedGlassMove() { + // Drag the header to move the glass freely, like an OS window. + // Same conventions as resize: delegated pointer events on windowElement, + // setPointerCapture so the drag survives the pointer leaving the header, + // and geometry normalized to window-relative left/top. + this.windowElement.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + + // Start a move from anywhere in the header (incl. the title text), + // but not from its interactive controls (action buttons, tabs). + const headerEl = event.target.closest('bw-glass-header'); + if (!headerEl || event.target.closest('button')) return; + if (headerEl.getAttribute('can-drag') === 'false') return; + + const glassEl = headerEl.closest('bw-glass[detached]'); + if (!glassEl) return; + + event.preventDefault(); + headerEl.setPointerCapture(event.pointerId); + + this.activeMoveGlassEl = glassEl; + this.moveStartX = event.pageX; + this.moveStartY = event.pageY; + + // Normalize corner-anchored geometry to window-relative left/top. + const windowRect = this.windowElement.getBoundingClientRect(); + const glassRect = glassEl.getBoundingClientRect(); + this.moveStartLeft = glassRect.left - windowRect.left; + this.moveStartTop = glassRect.top - windowRect.top; + }); + + this.windowElement.addEventListener('pointermove', (event) => { + if (!this.activeMoveGlassEl) return; + + const left = this.moveStartLeft + (event.pageX - this.moveStartX); + const top = this.moveStartTop + (event.pageY - this.moveStartY); + + const glassEl = this.activeMoveGlassEl; + glassEl.style.right = 'auto'; + glassEl.style.bottom = 'auto'; + glassEl.style.left = `${left}px`; + glassEl.style.top = `${top}px`; + }); + + this.windowElement.addEventListener('pointerup', (event) => { + if (!this.activeMoveGlassEl) return; + + if (event.target.hasPointerCapture?.(event.pointerId)) { + event.target.releasePointerCapture(event.pointerId); + } + + this.activeMoveGlassEl = null; + }); + }, + enableDetachedGlassResize() { // Create resize handles only while a detached glass is hovered, and // remove them on leave — so idle glasses cost no extra DOM nodes. diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 3442fc7..38251e1 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -9,7 +9,9 @@ export function createResizeHandles() { }); } -export function genStylesByPosition(position, offset) { +// `offset` nudges the glass from the anchored corner/edge; it has no effect on +// a centered glass, which is positioned purely from its own size. +export function genStylesByPosition({ position, offset, width, height }) { switch (position) { case 'top-left': return { top: `${offset}px`, left: `${offset}px`, right: 'auto', bottom: 'auto' }; @@ -19,6 +21,15 @@ export function genStylesByPosition(position, offset) { return { bottom: `${offset}px`, left: `${offset}px`, right: 'auto', top: 'auto' }; case 'bottom-right': return { bottom: `${offset}px`, right: `${offset}px`, left: 'auto', top: 'auto' }; + case 'center': + // Offset by half the glass size so it is centered, not just top-left at center. + // Use calc() rather than a transform to keep left/top in sync with drag/resize math. + return { + top: `calc(50% - ${height / 2}px)`, + left: `calc(50% - ${width / 2}px)`, + right: 'auto', + bottom: 'auto', + }; default: throw new Error(`Position "${position}" is not supported for detached glass.`); } diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index ecef77a..d0228e5 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -1,71 +1,86 @@ +bw-glass[detached] { + box-shadow: var(--bw-detached-glass-shadow); + + /* The focused glass (front-most) casts a more pronounced shadow */ + &[active] { + box-shadow: var(--bw-detached-glass-shadow-active); + } +} + +bw-glass[detached] > bw-glass-header[can-drag='true'] { + cursor: grab; + + &:active { + cursor: grabbing; + } +} + bw-glass-resize-handle { - /* Thickness of the grab zone, straddling the glass border */ - --bw-resize-handle-size: 8px; position: absolute; z-index: 2; touch-action: none; &[resize-dir='n'] { - top: calc(var(--bw-resize-handle-size) / -2); + top: calc(var(--bw-detached-glass-resize-handle-size) / -2); left: 0; right: 0; - height: var(--bw-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: ns-resize; } &[resize-dir='s'] { - bottom: calc(var(--bw-resize-handle-size) / -2); + bottom: calc(var(--bw-detached-glass-resize-handle-size) / -2); left: 0; right: 0; - height: var(--bw-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: ns-resize; } &[resize-dir='e'] { top: 0; bottom: 0; - right: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); + right: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); cursor: ew-resize; } &[resize-dir='w'] { top: 0; bottom: 0; - left: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); + left: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); cursor: ew-resize; } &[resize-dir='ne'] { - top: calc(var(--bw-resize-handle-size) / -2); - right: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); - height: var(--bw-resize-handle-size); + top: calc(var(--bw-detached-glass-resize-handle-size) / -2); + right: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: nesw-resize; } &[resize-dir='nw'] { - top: calc(var(--bw-resize-handle-size) / -2); - left: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); - height: var(--bw-resize-handle-size); + top: calc(var(--bw-detached-glass-resize-handle-size) / -2); + left: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: nwse-resize; } &[resize-dir='se'] { - bottom: calc(var(--bw-resize-handle-size) / -2); - right: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); - height: var(--bw-resize-handle-size); + bottom: calc(var(--bw-detached-glass-resize-handle-size) / -2); + right: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: nwse-resize; } &[resize-dir='sw'] { - bottom: calc(var(--bw-resize-handle-size) / -2); - left: calc(var(--bw-resize-handle-size) / -2); - width: var(--bw-resize-handle-size); - height: var(--bw-resize-handle-size); + bottom: calc(var(--bw-detached-glass-resize-handle-size) / -2); + left: calc(var(--bw-detached-glass-resize-handle-size) / -2); + width: var(--bw-detached-glass-resize-handle-size); + height: var(--bw-detached-glass-resize-handle-size); cursor: nesw-resize; } } diff --git a/src/css/vars.css b/src/css/vars.css index f616808..cebd8da 100644 --- a/src/css/vars.css +++ b/src/css/vars.css @@ -13,6 +13,12 @@ bw-window { --bw-glass-header-gap: 4px; --bw-glass-header-bg-color: hsl(0, 0%, 97%); + --bw-detached-glass-shadow: 0 2px 8px hsl(0, 0%, 0%, 0.15); + --bw-detached-glass-shadow-active: 0 6px 20px hsl(0, 0%, 0%, 0.3); + + /* Thickness of the resize grab zone, straddling the glass border */ + --bw-detached-glass-resize-handle-size: 12px; + --bw-sill-gap: 6px; } @@ -25,6 +31,8 @@ bw-window[theme='dark'] { --bw-glass-header-bg-color: hsl(0 0% 18%); --bw-glass-bg-color-disabled: hsl(0 0% 16%); --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); + --bw-detached-glass-shadow: 0 2px 8px hsl(0 0% 0% / 0.5); + --bw-detached-glass-shadow-active: 0 6px 20px hsl(0 0% 0% / 0.7); bw-pane, bw-muntin { From 6cca4ec1253bd369148cf9ae446046179e3307da Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 20:12:11 +1000 Subject: [PATCH 12/57] chore: shorten comments --- src/binary-window/detached-glass/module.js | 41 ++++++++-------------- src/binary-window/detached-glass/utils.js | 3 +- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index b5a8a83..2fae557 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -9,11 +9,13 @@ const MIN_HEIGHT = 60; let topZIndex = 1; function bringToFront(glassEl) { + // Already front-most (it owns the [active] marker) → nothing to raise. + if (glassEl.hasAttribute('active')) return; + topZIndex += 1; glassEl.style.zIndex = topZIndex; - // Mark this glass as the active (focused) one, clearing the rest — like - // focusing an OS window. Drives the stronger drop-shadow in CSS. + // Only the front-most glass keeps [active]; it drives the stronger shadow in CSS. glassEl.parentElement ?.querySelectorAll(':scope > bw-glass[detached][active]') .forEach((el) => el !== glassEl && el.removeAttribute('active')); @@ -57,9 +59,8 @@ export default { }, enableDetachedGlassActivate() { - // Clicking anywhere in a detached glass focuses it and brings it to front, - // like an OS window. Runs for move/resize grabs too (they bubble here), - // so focus handling lives in one place. + // Clicking anywhere in a detached glass brings it to front. Move/resize + // grabs bubble here too, so focus handling lives in one place. this.windowElement.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; @@ -69,15 +70,10 @@ export default { }, enableDetachedGlassMove() { - // Drag the header to move the glass freely, like an OS window. - // Same conventions as resize: delegated pointer events on windowElement, - // setPointerCapture so the drag survives the pointer leaving the header, - // and geometry normalized to window-relative left/top. this.windowElement.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; - // Start a move from anywhere in the header (incl. the title text), - // but not from its interactive controls (action buttons, tabs). + // Move from anywhere in the header (incl. title text), but not its buttons. const headerEl = event.target.closest('bw-glass-header'); if (!headerEl || event.target.closest('button')) return; if (headerEl.getAttribute('can-drag') === 'false') return; @@ -86,6 +82,7 @@ export default { if (!glassEl) return; event.preventDefault(); + // setPointerCapture keeps move events flowing when the pointer leaves the header. headerEl.setPointerCapture(event.pointerId); this.activeMoveGlassEl = glassEl; @@ -124,12 +121,7 @@ export default { }, enableDetachedGlassResize() { - // Create resize handles only while a detached glass is hovered, and - // remove them on leave — so idle glasses cost no extra DOM nodes. - // Delegated on `windowElement`: a constant 5 listeners regardless of - // how many detached glasses exist. `closest` on target/relatedTarget - // gives enter/leave semantics for the whole glass subtree (handles - // included), so moving onto a handle does not count as leaving. + // Handles exist only while a glass is hovered, so idle glasses cost no extra DOM. this.windowElement.addEventListener('pointerover', (event) => { const glassEl = event.target.closest?.('bw-glass[detached]'); if (glassEl) addResizeHandles(glassEl); @@ -139,20 +131,16 @@ export default { const glassEl = event.target.closest?.('bw-glass[detached]'); if (!glassEl) return; - // Still inside the same glass subtree → not a real leave. + // Moving within the same glass subtree (e.g. onto a handle) is not a leave. const toGlassEl = event.relatedTarget?.closest?.('bw-glass[detached]'); if (toGlassEl === glassEl) return; - // Keep handles while this glass is being resized; cleaned up on pointerup. + // Keep handles while resizing; pointerup cleans them up. if (glassEl === this.activeResizeGlassEl) return; removeResizeHandles(glassEl); }); - // Identify which detached glass and which edge/corner to resize. - // Pointer events give a single code path for mouse/touch/pen, and - // `setPointerCapture` keeps move events flowing even when the pointer - // leaves the handle or the window, so no document-level listeners. this.windowElement.addEventListener('pointerdown', (event) => { if (event.button !== 0 || event.target.tagName !== 'BW-GLASS-RESIZE-HANDLE') return; @@ -167,8 +155,8 @@ export default { this.resizeStartX = event.pageX; this.resizeStartY = event.pageY; - // Normalize corner-anchored geometry (top/right/bottom/left + offset) - // into plain left/top/width/height so every edge resizes with the same math. + // Normalize corner-anchored geometry to window-relative left/top/width/height + // so every edge resizes with the same math. const windowRect = this.windowElement.getBoundingClientRect(); const glassRect = glassEl.getBoundingClientRect(); this.resizeStartRect = { @@ -227,8 +215,7 @@ export default { this.activeResizeDir = ''; this.resizeStartRect = null; - // If the drag ended with the pointer outside the glass, the guarded - // pointerout never removed the handles — drop them now. + // pointerout was suppressed during the resize; drop handles if no longer hovered. if (!glassEl.matches(':hover')) removeResizeHandles(glassEl); }); }, diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 38251e1..28a3d26 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -22,8 +22,7 @@ export function genStylesByPosition({ position, offset, width, height }) { case 'bottom-right': return { bottom: `${offset}px`, right: `${offset}px`, left: 'auto', top: 'auto' }; case 'center': - // Offset by half the glass size so it is centered, not just top-left at center. - // Use calc() rather than a transform to keep left/top in sync with drag/resize math. + // calc() rather than a translate transform, so left/top stay in sync with drag/resize math. return { top: `calc(50% - ${height / 2}px)`, left: `calc(50% - ${width / 2}px)`, From aa1cd452a4ebba66716a2d4d28d919f07e203e24 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 20:16:04 +1000 Subject: [PATCH 13/57] refactor: create actions dir to host actions --- .../{actions.close.js => actions/close.js} | 2 +- src/binary-window/actions/index.js | 1 + .../{actions.maximize.js => actions/maximize.js} | 2 +- .../{actions.minimize.js => actions/minimize.js} | 2 +- src/binary-window/{actions.js => actions/module.js} | 12 ++++++------ 5 files changed, 10 insertions(+), 9 deletions(-) rename src/binary-window/{actions.close.js => actions/close.js} (77%) create mode 100644 src/binary-window/actions/index.js rename src/binary-window/{actions.maximize.js => actions/maximize.js} (93%) rename src/binary-window/{actions.minimize.js => actions/minimize.js} (92%) rename src/binary-window/{actions.js => actions/module.js} (92%) diff --git a/src/binary-window/actions.close.js b/src/binary-window/actions/close.js similarity index 77% rename from src/binary-window/actions.close.js rename to src/binary-window/actions/close.js index 32442fd..b6c273e 100644 --- a/src/binary-window/actions.close.js +++ b/src/binary-window/actions/close.js @@ -1,4 +1,4 @@ -import { getSashIdFromPane } from '../frame/frame-utils'; +import { getSashIdFromPane } from '../../frame/frame-utils'; export default { label: '', diff --git a/src/binary-window/actions/index.js b/src/binary-window/actions/index.js new file mode 100644 index 0000000..908ab06 --- /dev/null +++ b/src/binary-window/actions/index.js @@ -0,0 +1 @@ +export { default, BUILTIN_ACTIONS } from './module'; diff --git a/src/binary-window/actions.maximize.js b/src/binary-window/actions/maximize.js similarity index 93% rename from src/binary-window/actions.maximize.js rename to src/binary-window/actions/maximize.js index ed31377..0e75950 100644 --- a/src/binary-window/actions.maximize.js +++ b/src/binary-window/actions/maximize.js @@ -1,4 +1,4 @@ -import { getMetricsFromElement } from '../utils'; +import { getMetricsFromElement } from '../../utils'; export default { label: '', diff --git a/src/binary-window/actions.minimize.js b/src/binary-window/actions/minimize.js similarity index 92% rename from src/binary-window/actions.minimize.js rename to src/binary-window/actions/minimize.js index f7d03e6..9528842 100644 --- a/src/binary-window/actions.minimize.js +++ b/src/binary-window/actions/minimize.js @@ -1,4 +1,4 @@ -import { createDomNode, getMetricsFromElement } from '../utils'; +import { createDomNode, getMetricsFromElement } from '../../utils'; export default { label: '', diff --git a/src/binary-window/actions.js b/src/binary-window/actions/module.js similarity index 92% rename from src/binary-window/actions.js rename to src/binary-window/actions/module.js index e792593..0d75ea6 100644 --- a/src/binary-window/actions.js +++ b/src/binary-window/actions/module.js @@ -1,9 +1,9 @@ -import closeAction from './actions.close'; -import minimizeAction from './actions.minimize'; -import maximizeAction from './actions.maximize'; -import { getMetricsFromElement } from '../utils'; -import { getIntersectRect } from '../rect'; -import { Position } from '../position'; +import closeAction from './close'; +import minimizeAction from './minimize'; +import maximizeAction from './maximize'; +import { getMetricsFromElement } from '../../utils'; +import { getIntersectRect } from '../../rect'; +import { Position } from '../../position'; export const BUILTIN_ACTIONS = [minimizeAction, maximizeAction, closeAction]; From 81a89d8e828531aaccfed10c4b043e20a13d475d Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 20:18:11 +1000 Subject: [PATCH 14/57] refactor: path --- src/binary-window/actions/close.js | 2 +- src/binary-window/actions/maximize.js | 2 +- src/binary-window/actions/minimize.js | 2 +- src/binary-window/actions/module.js | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/binary-window/actions/close.js b/src/binary-window/actions/close.js index b6c273e..1e46595 100644 --- a/src/binary-window/actions/close.js +++ b/src/binary-window/actions/close.js @@ -1,4 +1,4 @@ -import { getSashIdFromPane } from '../../frame/frame-utils'; +import { getSashIdFromPane } from '@/frame/frame-utils'; export default { label: '', diff --git a/src/binary-window/actions/maximize.js b/src/binary-window/actions/maximize.js index 0e75950..632a265 100644 --- a/src/binary-window/actions/maximize.js +++ b/src/binary-window/actions/maximize.js @@ -1,4 +1,4 @@ -import { getMetricsFromElement } from '../../utils'; +import { getMetricsFromElement } from '@/utils'; export default { label: '', diff --git a/src/binary-window/actions/minimize.js b/src/binary-window/actions/minimize.js index 9528842..e6b2a7a 100644 --- a/src/binary-window/actions/minimize.js +++ b/src/binary-window/actions/minimize.js @@ -1,4 +1,4 @@ -import { createDomNode, getMetricsFromElement } from '../../utils'; +import { createDomNode, getMetricsFromElement } from '@/utils'; export default { label: '', diff --git a/src/binary-window/actions/module.js b/src/binary-window/actions/module.js index 0d75ea6..81c5425 100644 --- a/src/binary-window/actions/module.js +++ b/src/binary-window/actions/module.js @@ -1,9 +1,9 @@ import closeAction from './close'; import minimizeAction from './minimize'; import maximizeAction from './maximize'; -import { getMetricsFromElement } from '../../utils'; -import { getIntersectRect } from '../../rect'; -import { Position } from '../../position'; +import { getMetricsFromElement } from '@/utils'; +import { getIntersectRect } from '@/rect'; +import { Position } from '@/position'; export const BUILTIN_ACTIONS = [minimizeAction, maximizeAction, closeAction]; From 7d22c82f6749b9dfea7e644aaf7150672fddb376 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 20:48:04 +1000 Subject: [PATCH 15/57] refactor: consolidate glass and actions modules to make them have the same shape of detached glass --- src/binary-window/binary-window.js | 5 ++--- src/binary-window/detached-glass/class.js | 4 +++- src/binary-window/detached-glass/close.js | 15 +++++++++++++++ src/binary-window/detached-glass/index.js | 2 ++ src/binary-window/{glass.js => glass/class.js} | 13 ++++--------- src/binary-window/{actions => glass}/close.js | 0 src/binary-window/{actions => glass}/index.js | 1 + src/binary-window/{actions => glass}/maximize.js | 0 src/binary-window/{actions => glass}/minimize.js | 0 src/binary-window/{actions => glass}/module.js | 0 src/index.js | 3 ++- 11 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 src/binary-window/detached-glass/close.js rename src/binary-window/{glass.js => glass/class.js} (89%) rename src/binary-window/{actions => glass}/close.js (100%) rename src/binary-window/{actions => glass}/index.js (61%) rename src/binary-window/{actions => glass}/maximize.js (100%) rename src/binary-window/{actions => glass}/minimize.js (100%) rename src/binary-window/{actions => glass}/module.js (100%) diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 23241b8..5fee77c 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -1,9 +1,8 @@ import { Frame } from '../frame/frame'; -import { Glass } from './glass'; +import glassModule, { Glass } from './glass'; import { createDomNode } from '../utils'; import draggableModule from './draggable'; import trimModule from './trim'; -import actionsModule from './actions'; import detachedGlassModule from './detached-glass'; export class BinaryWindow extends Frame { @@ -87,4 +86,4 @@ export class BinaryWindow extends Frame { } } -BinaryWindow.assemble(draggableModule, trimModule, actionsModule, detachedGlassModule); +BinaryWindow.assemble(draggableModule, trimModule, glassModule, detachedGlassModule); diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 036f327..c641dfe 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -1,6 +1,7 @@ import { Glass } from '../glass'; import { genId } from '@/utils.js'; import { genStylesByPosition } from './utils'; +import { DETACHED_GLASS_ACTIONS } from './close'; export class DetachedGlass extends Glass { constructor(options) { @@ -10,10 +11,11 @@ export class DetachedGlass extends Glass { height = 200, offset = -20, id, + actions = DETACHED_GLASS_ACTIONS, ...glassOptions } = options; - super(glassOptions); + super({ ...glassOptions, actions }); this.domNode.setAttribute('id', id || genId() + '-F'); this.domNode.setAttribute('detached', ''); diff --git a/src/binary-window/detached-glass/close.js b/src/binary-window/detached-glass/close.js new file mode 100644 index 0000000..f61e215 --- /dev/null +++ b/src/binary-window/detached-glass/close.js @@ -0,0 +1,15 @@ +import { detachedGlassManager } from './manager'; + +export const closeAction = { + label: '', + className: 'bw-glass-action--close', + onClick: (event) => { + const glassEl = event.target.closest('bw-glass[detached]'); + if (!glassEl) return; + + detachedGlassManager.remove(glassEl.id); + glassEl.remove(); + }, +}; + +export const DETACHED_GLASS_ACTIONS = [closeAction]; diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index d533cc9..6dc3329 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -1 +1,3 @@ +export { DetachedGlass } from './class'; export { default } from './module'; +export { DETACHED_GLASS_ACTIONS } from './close'; diff --git a/src/binary-window/glass.js b/src/binary-window/glass/class.js similarity index 89% rename from src/binary-window/glass.js rename to src/binary-window/glass/class.js index d7d2acf..36ab540 100644 --- a/src/binary-window/glass.js +++ b/src/binary-window/glass/class.js @@ -1,5 +1,5 @@ -import { createDomNode } from '../utils'; -import { BUILTIN_ACTIONS } from './actions'; +import { createDomNode } from '@/utils'; +import { BUILTIN_ACTIONS } from './module'; export class Glass { domNode; @@ -8,7 +8,7 @@ export class Glass { title = null, content = null, tabs = [], - actions = undefined, + actions = BUILTIN_ACTIONS, draggable = true, sash = null, binaryWindow, @@ -61,12 +61,7 @@ export class Glass { createActions() { const containerEl = document.createElement('bw-glass-action-container'); - const actions = - this.actions === undefined - ? BUILTIN_ACTIONS - : Array.isArray(this.actions) - ? this.actions - : []; + const actions = Array.isArray(this.actions) ? this.actions : []; for (const action of actions) { const label = action?.label ?? action; diff --git a/src/binary-window/actions/close.js b/src/binary-window/glass/close.js similarity index 100% rename from src/binary-window/actions/close.js rename to src/binary-window/glass/close.js diff --git a/src/binary-window/actions/index.js b/src/binary-window/glass/index.js similarity index 61% rename from src/binary-window/actions/index.js rename to src/binary-window/glass/index.js index 908ab06..b3b3f08 100644 --- a/src/binary-window/actions/index.js +++ b/src/binary-window/glass/index.js @@ -1 +1,2 @@ +export { Glass } from './class'; export { default, BUILTIN_ACTIONS } from './module'; diff --git a/src/binary-window/actions/maximize.js b/src/binary-window/glass/maximize.js similarity index 100% rename from src/binary-window/actions/maximize.js rename to src/binary-window/glass/maximize.js diff --git a/src/binary-window/actions/minimize.js b/src/binary-window/glass/minimize.js similarity index 100% rename from src/binary-window/actions/minimize.js rename to src/binary-window/glass/minimize.js diff --git a/src/binary-window/actions/module.js b/src/binary-window/glass/module.js similarity index 100% rename from src/binary-window/actions/module.js rename to src/binary-window/glass/module.js diff --git a/src/index.js b/src/index.js index 2630612..828014d 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,8 @@ import './css/sill.css'; export { Frame } from './frame/frame'; export { BinaryWindow } from './binary-window/binary-window'; -export { BUILTIN_ACTIONS } from './binary-window/actions'; +export { BUILTIN_ACTIONS } from './binary-window/glass'; +export { DETACHED_GLASS_ACTIONS } from './binary-window/detached-glass'; export { Sash } from './sash'; export { SashConfig } from './config/sash-config'; export { ConfigRoot } from './config/config-root'; From f4fd57b2feefdaaa02d695819e217ec082e329d5 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 21:01:44 +1000 Subject: [PATCH 16/57] refactor: remove export of detached glass actions --- src/binary-window/detached-glass/index.js | 1 - src/index.js | 1 - 2 files changed, 2 deletions(-) diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index 6dc3329..567264f 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -1,3 +1,2 @@ export { DetachedGlass } from './class'; export { default } from './module'; -export { DETACHED_GLASS_ACTIONS } from './close'; diff --git a/src/index.js b/src/index.js index 828014d..7b4b0cc 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,6 @@ import './css/sill.css'; export { Frame } from './frame/frame'; export { BinaryWindow } from './binary-window/binary-window'; export { BUILTIN_ACTIONS } from './binary-window/glass'; -export { DETACHED_GLASS_ACTIONS } from './binary-window/detached-glass'; export { Sash } from './sash'; export { SashConfig } from './config/sash-config'; export { ConfigRoot } from './config/config-root'; From 222f94869b1e251df988622313f37cb777e5c024 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 21:50:07 +1000 Subject: [PATCH 17/57] feat: export detached glass actions --- src/binary-window/detached-glass/class.js | 4 ++-- src/binary-window/detached-glass/close.js | 2 +- src/binary-window/detached-glass/index.js | 1 + src/index.js | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index c641dfe..e83b701 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -1,7 +1,7 @@ import { Glass } from '../glass'; import { genId } from '@/utils.js'; import { genStylesByPosition } from './utils'; -import { DETACHED_GLASS_ACTIONS } from './close'; +import { BUILTIN_ACTIONS_2 } from './close'; export class DetachedGlass extends Glass { constructor(options) { @@ -11,7 +11,7 @@ export class DetachedGlass extends Glass { height = 200, offset = -20, id, - actions = DETACHED_GLASS_ACTIONS, + actions = BUILTIN_ACTIONS_2, ...glassOptions } = options; diff --git a/src/binary-window/detached-glass/close.js b/src/binary-window/detached-glass/close.js index f61e215..08e8100 100644 --- a/src/binary-window/detached-glass/close.js +++ b/src/binary-window/detached-glass/close.js @@ -12,4 +12,4 @@ export const closeAction = { }, }; -export const DETACHED_GLASS_ACTIONS = [closeAction]; +export const BUILTIN_ACTIONS_2 = [closeAction]; diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index 567264f..3c12440 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -1,2 +1,3 @@ export { DetachedGlass } from './class'; export { default } from './module'; +export { BUILTIN_ACTIONS_2 } from './close'; diff --git a/src/index.js b/src/index.js index 7b4b0cc..b51bcec 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,7 @@ import './css/sill.css'; export { Frame } from './frame/frame'; export { BinaryWindow } from './binary-window/binary-window'; export { BUILTIN_ACTIONS } from './binary-window/glass'; +export { BUILTIN_ACTIONS_2 } from './binary-window/detached-glass'; export { Sash } from './sash'; export { SashConfig } from './config/sash-config'; export { ConfigRoot } from './config/config-root'; From 17fda457a02948536000e81fad27f2d58f40d63b Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 22:00:13 +1000 Subject: [PATCH 18/57] chore: rename detached glass example --- .../{detached-glass.html => bwin-detached-glass.html} | 4 ++-- dev/features/{detached-glass.js => bwin-detached-glass.js} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename dev/features/{detached-glass.html => bwin-detached-glass.html} (84%) rename dev/features/{detached-glass.js => bwin-detached-glass.js} (100%) diff --git a/dev/features/detached-glass.html b/dev/features/bwin-detached-glass.html similarity index 84% rename from dev/features/detached-glass.html rename to dev/features/bwin-detached-glass.html index f38ef26..f7cb392 100644 --- a/dev/features/detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -3,13 +3,13 @@ - + Detached Glass
-

Detached Glass

+

BinaryWindow - detached glass

diff --git a/dev/features/detached-glass.js b/dev/features/bwin-detached-glass.js similarity index 100% rename from dev/features/detached-glass.js rename to dev/features/bwin-detached-glass.js From d686ff638f62972f1f547e5c07073d36abceae8b Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 22:16:38 +1000 Subject: [PATCH 19/57] refactor: imports exports --- src/binary-window/detached-glass/class.js | 4 ++-- src/binary-window/detached-glass/close.js | 1 - src/binary-window/detached-glass/index.js | 5 ++++- src/binary-window/glass/class.js | 6 ++++-- src/binary-window/glass/index.js | 8 +++++++- src/binary-window/glass/module.js | 5 ----- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index e83b701..05e34cc 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -1,7 +1,7 @@ import { Glass } from '../glass'; import { genId } from '@/utils.js'; import { genStylesByPosition } from './utils'; -import { BUILTIN_ACTIONS_2 } from './close'; +import { closeAction } from './close'; export class DetachedGlass extends Glass { constructor(options) { @@ -11,7 +11,7 @@ export class DetachedGlass extends Glass { height = 200, offset = -20, id, - actions = BUILTIN_ACTIONS_2, + actions = [closeAction], ...glassOptions } = options; diff --git a/src/binary-window/detached-glass/close.js b/src/binary-window/detached-glass/close.js index 08e8100..9c0f254 100644 --- a/src/binary-window/detached-glass/close.js +++ b/src/binary-window/detached-glass/close.js @@ -12,4 +12,3 @@ export const closeAction = { }, }; -export const BUILTIN_ACTIONS_2 = [closeAction]; diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index 3c12440..746d93a 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -1,3 +1,6 @@ +import { closeAction } from './close'; + export { DetachedGlass } from './class'; export { default } from './module'; -export { BUILTIN_ACTIONS_2 } from './close'; + +export const BUILTIN_ACTIONS_2 = [closeAction]; diff --git a/src/binary-window/glass/class.js b/src/binary-window/glass/class.js index 36ab540..034d7d4 100644 --- a/src/binary-window/glass/class.js +++ b/src/binary-window/glass/class.js @@ -1,5 +1,7 @@ import { createDomNode } from '@/utils'; -import { BUILTIN_ACTIONS } from './module'; +import closeAction from './close'; +import minimizeAction from './minimize'; +import maximizeAction from './maximize'; export class Glass { domNode; @@ -8,7 +10,7 @@ export class Glass { title = null, content = null, tabs = [], - actions = BUILTIN_ACTIONS, + actions = [minimizeAction, maximizeAction, closeAction], draggable = true, sash = null, binaryWindow, diff --git a/src/binary-window/glass/index.js b/src/binary-window/glass/index.js index b3b3f08..e8e322c 100644 --- a/src/binary-window/glass/index.js +++ b/src/binary-window/glass/index.js @@ -1,2 +1,8 @@ +import closeAction from './close'; +import minimizeAction from './minimize'; +import maximizeAction from './maximize'; + export { Glass } from './class'; -export { default, BUILTIN_ACTIONS } from './module'; +export { default } from './module'; + +export const BUILTIN_ACTIONS = [minimizeAction, maximizeAction, closeAction]; diff --git a/src/binary-window/glass/module.js b/src/binary-window/glass/module.js index 81c5425..fad6d9c 100644 --- a/src/binary-window/glass/module.js +++ b/src/binary-window/glass/module.js @@ -1,12 +1,7 @@ -import closeAction from './close'; -import minimizeAction from './minimize'; -import maximizeAction from './maximize'; import { getMetricsFromElement } from '@/utils'; import { getIntersectRect } from '@/rect'; import { Position } from '@/position'; -export const BUILTIN_ACTIONS = [minimizeAction, maximizeAction, closeAction]; - export default { enableActions() { this.handleMinimizedGlassClick(); From 104a38ad13e36b8a9d4fab817b1cb07ee8a55773 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sat, 6 Jun 2026 22:49:44 +1000 Subject: [PATCH 20/57] feat: handle default actions for glass and detached glass --- dev/features/bwin-detached-glass.js | 10 +++++--- src/binary-window/binary-window.js | 17 +++++++++++++- src/binary-window/binary-window.test.js | 27 ++++++++++++++++++++++ src/binary-window/detached-glass/module.js | 2 +- src/frame/frame.js | 1 - 5 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 src/binary-window/binary-window.test.js diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 9e3af73..29df9c8 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -1,4 +1,4 @@ -import { BinaryWindow } from '../../src'; +import { BinaryWindow, BUILTIN_ACTIONS, BUILTIN_ACTIONS_2 } from '../../src'; const elem = document.createElement('div'); const zIndex = 100; @@ -34,8 +34,12 @@ parentElem.appendChild(elem2); const settings = { width: 444, height: 333, + actions: [ + [BUILTIN_ACTIONS[0], BUILTIN_ACTIONS[2]], + ['XXX', BUILTIN_ACTIONS_2[0]], + ], children: [ - { position: 'left', size: '40%' }, + { position: 'left', size: '40%', actions: [] }, { children: [ { position: 'top', size: '30%' }, @@ -54,4 +58,4 @@ document.querySelectorAll('button[data-position]').forEach((button) => { }); }); -document.querySelector('button[data-position="center"]').click(); \ No newline at end of file +document.querySelector('button[data-position="center"]').click(); diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index 5fee77c..dbea429 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -8,6 +8,13 @@ import detachedGlassModule from './detached-glass'; export class BinaryWindow extends Frame { sillElement = null; + constructor(settings) { + super(settings); + + this.theme = settings.theme || ''; + this.actions = BinaryWindow.normActions(settings.actions); + } + frame() { super.frame(...arguments); const sillEl = createDomNode(''); @@ -25,7 +32,8 @@ export class BinaryWindow extends Frame { } onPaneCreate(paneEl, sash) { - const glass = new Glass({ ...sash.store, sash, binaryWindow: this }); + const glassActions = this.actions[0]; + const glass = new Glass({ actions: glassActions, ...sash.store, sash, binaryWindow: this }); paneEl.innerHTML = ''; paneEl.append(glass.domNode); @@ -84,6 +92,13 @@ export class BinaryWindow extends Frame { minimizedGlassEl.remove(); } } + + // Returns [glassActions, detachedGlassActions] + static normActions(actions) { + if (!Array.isArray(actions)) return [[], []]; + if (!actions.some(Array.isArray)) return [actions, []]; + return actions; + } } BinaryWindow.assemble(draggableModule, trimModule, glassModule, detachedGlassModule); diff --git a/src/binary-window/binary-window.test.js b/src/binary-window/binary-window.test.js new file mode 100644 index 0000000..1960517 --- /dev/null +++ b/src/binary-window/binary-window.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { BinaryWindow } from './binary-window'; + +describe('BinaryWindow.normActions', () => { + it('returns [[], []] when actions is not an array', () => { + expect(BinaryWindow.normActions(undefined)).toEqual([[], []]); + expect(BinaryWindow.normActions(null)).toEqual([[], []]); + expect(BinaryWindow.normActions('a')).toEqual([[], []]); + expect(BinaryWindow.normActions({})).toEqual([[], []]); + }); + + it('returns [actions, []] when actions is a flat array', () => { + const a = { label: 'A' }; + const b = { label: 'B' }; + + expect(BinaryWindow.normActions([])).toEqual([[], []]); + expect(BinaryWindow.normActions([a, b])).toEqual([[a, b], []]); + }); + + it('returns actions as-is when it already contains arrays', () => { + const a = { label: 'A' }; + const b = { label: 'B' }; + const grouped = [[a], [b]]; + + expect(BinaryWindow.normActions(grouped)).toBe(grouped); + }); +}); diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index 2fae557..124127b 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -50,7 +50,7 @@ export default { moveStartTop: 0, addDetachedGlass(options = {}) { - const glass = new DetachedGlass(options); + const glass = new DetachedGlass({ actions: this.actions[1], ...options }); this.windowElement.append(glass.domNode); detachedGlassManager.add(glass.domNode); bringToFront(glass.domNode); diff --git a/src/frame/frame.js b/src/frame/frame.js index 943ee8d..abd3d9f 100644 --- a/src/frame/frame.js +++ b/src/frame/frame.js @@ -35,7 +35,6 @@ export class Frame { } this.fitContainer = config.fitContainer; - this.theme = config.theme; } frame(containerEl) { From 4cd40af917ef021f36b339aced4d45a0ecb13e0d Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 10:47:12 +1000 Subject: [PATCH 21/57] feat: add offset x and y to addDetacchedGlass api --- dev/features/bwin-detached-glass.html | 9 +++++++-- dev/features/bwin-detached-glass.js | 15 ++++++++++++++- src/binary-window/detached-glass/class.js | 13 +++++++++++-- src/binary-window/detached-glass/utils.js | 21 ++++++++++++--------- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/dev/features/bwin-detached-glass.html b/dev/features/bwin-detached-glass.html index f7cb392..b084e02 100644 --- a/dev/features/bwin-detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -10,11 +10,16 @@

BinaryWindow - detached glass

- + + + +
+ - + +
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 29df9c8..3fae10e 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -52,9 +52,22 @@ const settings = { const bwin = new BinaryWindow(settings); bwin.mount(document.querySelector('#container')); +const offsetInput = document.querySelector('#offset'); +const offsetXInput = document.querySelector('#offsetX'); +const offsetYInput = document.querySelector('#offsetY'); + +// Empty input → undefined, so genStylesByPosition can fall back to `offset`. +const toOffset = (input) => (input.value === '' ? undefined : Number(input.value)); + document.querySelectorAll('button[data-position]').forEach((button) => { button.addEventListener('click', () => { - bwin.addDetachedGlass({ position: button.dataset.position, title: button.dataset.position }); + bwin.addDetachedGlass({ + position: button.dataset.position, + title: button.dataset.position, + offset: Number(offsetInput.value), + offsetX: toOffset(offsetXInput), + offsetY: toOffset(offsetYInput), + }); }); }); diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 05e34cc..4ae6763 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -9,7 +9,9 @@ export class DetachedGlass extends Glass { position = 'top-right', width = 200, height = 200, - offset = -20, + offset = 0, + offsetX, + offsetY, id, actions = [closeAction], ...glassOptions @@ -24,7 +26,14 @@ export class DetachedGlass extends Glass { this.domNode.style.width = `${width}px`; this.domNode.style.height = `${height}px`; - const { top, left, right, bottom } = genStylesByPosition({ position, offset, width, height }); + const { top, left, right, bottom } = genStylesByPosition({ + position, + offset, + offsetX, + offsetY, + width, + height, + }); this.domNode.style.top = top; this.domNode.style.left = left; diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js index 28a3d26..99a28f0 100644 --- a/src/binary-window/detached-glass/utils.js +++ b/src/binary-window/detached-glass/utils.js @@ -9,23 +9,26 @@ export function createResizeHandles() { }); } -// `offset` nudges the glass from the anchored corner/edge; it has no effect on -// a centered glass, which is positioned purely from its own size. -export function genStylesByPosition({ position, offset, width, height }) { +// `offset` nudges the glass from the anchored corner/edge. `offsetX`/`offsetY` +// override it per-axis; when only one is given, `offset` fills the other axis. +export function genStylesByPosition({ position, offset, offsetX, offsetY, width, height }) { + const x = offsetX ?? offset; + const y = offsetY ?? offset; + switch (position) { case 'top-left': - return { top: `${offset}px`, left: `${offset}px`, right: 'auto', bottom: 'auto' }; + return { top: `${y}px`, left: `${x}px`, right: 'auto', bottom: 'auto' }; case 'top-right': - return { top: `${offset}px`, right: `${offset}px`, left: 'auto', bottom: 'auto' }; + return { top: `${y}px`, right: `${x}px`, left: 'auto', bottom: 'auto' }; case 'bottom-left': - return { bottom: `${offset}px`, left: `${offset}px`, right: 'auto', top: 'auto' }; + return { bottom: `${y}px`, left: `${x}px`, right: 'auto', top: 'auto' }; case 'bottom-right': - return { bottom: `${offset}px`, right: `${offset}px`, left: 'auto', top: 'auto' }; + return { bottom: `${y}px`, right: `${x}px`, left: 'auto', top: 'auto' }; case 'center': // calc() rather than a translate transform, so left/top stay in sync with drag/resize math. return { - top: `calc(50% - ${height / 2}px)`, - left: `calc(50% - ${width / 2}px)`, + top: `calc(50% - ${height / 2}px + ${y}px)`, + left: `calc(50% - ${width / 2}px + ${x}px)`, right: 'auto', bottom: 'auto', }; From 531ff163b9a6d381776e61deefb4e017e4f1ab44 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 11:07:08 +1000 Subject: [PATCH 22/57] feat: cascade detached glass from active one; naming/comment conventions --- CLAUDE.md | 9 ++++++++ dev/features/bwin-detached-glass.html | 7 +++--- dev/features/bwin-detached-glass.js | 5 +++++ src/binary-window/detached-glass/class.js | 2 +- src/binary-window/detached-glass/close.js | 2 +- src/binary-window/detached-glass/manager.js | 17 +++++++++----- src/binary-window/detached-glass/module.js | 25 +++++++++++++++++++-- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ca9ba8..23ef53c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,15 @@ - Don't run tests or build after completing a feature or fixing a bug unless asked. +## Naming conventions + +- Variables holding a DOM element get an `El` suffix, and keep the noun specific rather than generic — `activeGlassEl`, not `activeEl`. Accessor methods that return an element are named `get` to match (e.g. `getActiveGlass`). + +## Comments + +- Only comment when it adds something the code doesn't already say. +- Keep comments under 2 lines (each line max 100 chars). If a comment genuinely needs to be longer, prefix it with `RATIONAL:`. + ## Dev feature examples (`dev/features/`) - Put interactive testing items (buttons, inputs, forms, selects, etc.) into the `.html` file, not the `.js` file. The paired `.js` queries them with `document.querySelector(...)` and wires up behavior with `addEventListener`. See `add-remove-pane.html` / `add-remove-pane.js` for the pattern. diff --git a/dev/features/bwin-detached-glass.html b/dev/features/bwin-detached-glass.html index b084e02..8874c33 100644 --- a/dev/features/bwin-detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -14,10 +14,11 @@

BinaryWindow - detached glass

+ - - - + + +
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 3fae10e..f2bd98e 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -71,4 +71,9 @@ document.querySelectorAll('button[data-position]').forEach((button) => { }); }); +// No options → exercises the DetachedGlass constructor defaults. +document.querySelector('#add-default').addEventListener('click', () => { + bwin.addDetachedGlass(); +}); + document.querySelector('button[data-position="center"]').click(); diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 4ae6763..e88aecc 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -6,7 +6,7 @@ import { closeAction } from './close'; export class DetachedGlass extends Glass { constructor(options) { const { - position = 'top-right', + position, width = 200, height = 200, offset = 0, diff --git a/src/binary-window/detached-glass/close.js b/src/binary-window/detached-glass/close.js index 9c0f254..8ecb1e0 100644 --- a/src/binary-window/detached-glass/close.js +++ b/src/binary-window/detached-glass/close.js @@ -7,7 +7,7 @@ export const closeAction = { const glassEl = event.target.closest('bw-glass[detached]'); if (!glassEl) return; - detachedGlassManager.remove(glassEl.id); + detachedGlassManager.removeGlass(glassEl.id); glassEl.remove(); }, }; diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js index c1fb58f..df156a2 100644 --- a/src/binary-window/detached-glass/manager.js +++ b/src/binary-window/detached-glass/manager.js @@ -3,16 +3,21 @@ class DetachedGlassManager { this.glasses = []; } - add(glass) { - this.glasses.push(glass); + addGlass(glassEl) { + this.glasses.push(glassEl); } - remove(id) { - const index = this.glasses.findIndex((g) => g.id === id); + // The front-most glass owns the [active] marker (set in bringToFront). + getActiveGlass() { + return this.glasses.find((glassEl) => glassEl.hasAttribute('active')) ?? null; + } + + removeGlass(id) { + const index = this.glasses.findIndex((glassEl) => glassEl.id === id); if (index !== -1) { - const [removed] = this.glasses.splice(index, 1); - return removed; + const [removedGlassEl] = this.glasses.splice(index, 1); + return removedGlassEl; } return null; diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index 124127b..cbb1348 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -5,6 +5,9 @@ import { createResizeHandles } from './utils'; const MIN_WIDTH = 100; const MIN_HEIGHT = 60; +// Cascade offset down-right, sized so the glass behind keeps its title and buttons visible. +const CASCADE_OFFSET = 25; + // Rising counter so the most recently grabbed glass stacks on top, like an OS window. let topZIndex = 1; @@ -50,14 +53,32 @@ export default { moveStartTop: 0, addDetachedGlass(options = {}) { - const glass = new DetachedGlass({ actions: this.actions[1], ...options }); + // An explicit position wins; otherwise cascade from the active glass. + const placement = options.position ? {} : this.cascadeFromActiveGlass(); + + const glass = new DetachedGlass({ actions: this.actions[1], ...placement, ...options }); this.windowElement.append(glass.domNode); - detachedGlassManager.add(glass.domNode); + detachedGlassManager.addGlass(glass.domNode); bringToFront(glass.domNode); return glass; }, + cascadeFromActiveGlass() { + const activeGlassEl = detachedGlassManager.getActiveGlass(); + if (!activeGlassEl) return { position: 'center' }; + + // Anchor the new glass from the top-left, cascaded down-right of the active one. + const windowRect = this.windowElement.getBoundingClientRect(); + const activeRect = activeGlassEl.getBoundingClientRect(); + + return { + position: 'top-left', + offsetX: activeRect.left - windowRect.left + CASCADE_OFFSET, + offsetY: activeRect.top - windowRect.top + CASCADE_OFFSET, + }; + }, + enableDetachedGlassActivate() { // Clicking anywhere in a detached glass brings it to front. Move/resize // grabs bubble here too, so focus handling lives in one place. From e44265b2c12a1ea7beae605a4216bff0ea612752 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 11:37:16 +1000 Subject: [PATCH 23/57] feat: keep cascaded glass within viewport; guard default size in addDetachedGlass --- CLAUDE.md | 1 + src/binary-window/detached-glass/class.js | 5 +- src/binary-window/detached-glass/module.js | 53 +++++++++++++++------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23ef53c..2a0c350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ ## Naming conventions - Variables holding a DOM element get an `El` suffix, and keep the noun specific rather than generic — `activeGlassEl`, not `activeEl`. Accessor methods that return an element are named `get` to match (e.g. `getActiveGlass`). +- Constants name the context they apply to, not just the quantity — `MIN_RESIZE_WIDTH`, not `MIN_WIDTH`, so they don't get confused with unrelated values like creation-time defaults. ## Comments diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index e88aecc..8163f4a 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -7,8 +7,9 @@ export class DetachedGlass extends Glass { constructor(options) { const { position, - width = 200, - height = 200, + // 222 is a deliberate debugging tell that addDetachedGlass's guard was bypassed. + width = 222, + height = 222, offset = 0, offsetX, offsetY, diff --git a/src/binary-window/detached-glass/module.js b/src/binary-window/detached-glass/module.js index cbb1348..4506156 100644 --- a/src/binary-window/detached-glass/module.js +++ b/src/binary-window/detached-glass/module.js @@ -2,8 +2,11 @@ import { DetachedGlass } from './class'; import { detachedGlassManager } from './manager'; import { createResizeHandles } from './utils'; -const MIN_WIDTH = 100; -const MIN_HEIGHT = 60; +const DEFAULT_GLASS_WIDTH = 200; +const DEFAULT_GLASS_HEIGHT = 200; + +const MIN_RESIZE_WIDTH = 100; +const MIN_RESIZE_HEIGHT = 60; // Cascade offset down-right, sized so the glass behind keeps its title and buttons visible. const CASCADE_OFFSET = 25; @@ -53,10 +56,25 @@ export default { moveStartTop: 0, addDetachedGlass(options = {}) { - // An explicit position wins; otherwise cascade from the active glass. - const placement = options.position ? {} : this.cascadeFromActiveGlass(); + // Guard size here so the constructor never falls back to its 222 debug default. + const width = options.width ?? DEFAULT_GLASS_WIDTH; + const height = options.height ?? DEFAULT_GLASS_HEIGHT; - const glass = new DetachedGlass({ actions: this.actions[1], ...placement, ...options }); + // An explicit position wins; otherwise cascade from the active glass. + const { position, offsetX, offsetY } = options.position + ? {} + : this.getCascadedPlacement({ width, height }); + + const glass = new DetachedGlass({ + actions: this.actions[1], + // Placement first so caller options can override it; size last so it always wins. + position, + offsetX, + offsetY, + ...options, + width, + height, + }); this.windowElement.append(glass.domNode); detachedGlassManager.addGlass(glass.domNode); bringToFront(glass.domNode); @@ -64,19 +82,22 @@ export default { return glass; }, - cascadeFromActiveGlass() { + getCascadedPlacement({ width, height }) { const activeGlassEl = detachedGlassManager.getActiveGlass(); if (!activeGlassEl) return { position: 'center' }; - // Anchor the new glass from the top-left, cascaded down-right of the active one. + // Cascade down-right of the active glass, anchored from the top-left. const windowRect = this.windowElement.getBoundingClientRect(); const activeRect = activeGlassEl.getBoundingClientRect(); - return { - position: 'top-left', - offsetX: activeRect.left - windowRect.left + CASCADE_OFFSET, - offsetY: activeRect.top - windowRect.top + CASCADE_OFFSET, - }; + let offsetX = activeRect.left - windowRect.left + CASCADE_OFFSET; + let offsetY = activeRect.top - windowRect.top + CASCADE_OFFSET; + + // Wrap back to the top-left inset once a step would run off the right/bottom edge. + if (offsetX + width > windowRect.width) offsetX = CASCADE_OFFSET; + if (offsetY + height > windowRect.height) offsetY = CASCADE_OFFSET; + + return { position: 'top-left', offsetX, offsetY }; }, enableDetachedGlassActivate() { @@ -199,18 +220,18 @@ export default { let { left, top, width, height } = start; if (dir.includes('e')) { - width = Math.max(MIN_WIDTH, start.width + distX); + width = Math.max(MIN_RESIZE_WIDTH, start.width + distX); } else if (dir.includes('w')) { - width = Math.max(MIN_WIDTH, start.width - distX); + width = Math.max(MIN_RESIZE_WIDTH, start.width - distX); left = start.left + (start.width - width); } if (dir.includes('s')) { - height = Math.max(MIN_HEIGHT, start.height + distY); + height = Math.max(MIN_RESIZE_HEIGHT, start.height + distY); } else if (dir.includes('n')) { - height = Math.max(MIN_HEIGHT, start.height - distY); + height = Math.max(MIN_RESIZE_HEIGHT, start.height - distY); top = start.top + (start.height - height); } From 16fce32fcd1c0f3cf83be1dfd63c033338895e4f Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 11:44:44 +1000 Subject: [PATCH 24/57] chore: comment and ai guide --- CLAUDE.md | 4 ++++ src/config/config-root.js | 1 + 2 files changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2a0c350..63470d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,10 @@ - Only comment when it adds something the code doesn't already say. - Keep comments under 2 lines (each line max 100 chars). If a comment genuinely needs to be longer, prefix it with `RATIONAL:`. +## Debug sentinel values + +- Repeating-digit literals like `222` and `333` in default/fallback paths are intentional debug sentinels, not magic numbers. If such a value surfaces in a lower-level API or the rendered output, a guard upstream was bypassed and a real value leaked. Don't "tidy" them into named constants or replace them. + ## Dev feature examples (`dev/features/`) - Put interactive testing items (buttons, inputs, forms, selects, etc.) into the `.html` file, not the `.js` file. The paired `.js` queries them with `document.querySelector(...)` and wires up behavior with `addEventListener`. See `add-remove-pane.html` / `add-remove-pane.js` for the pattern. diff --git a/src/config/config-root.js b/src/config/config-root.js index f8eeb0b..cbb2b2a 100644 --- a/src/config/config-root.js +++ b/src/config/config-root.js @@ -1,6 +1,7 @@ import { ConfigNode } from './config-node'; import { Position } from '../position'; +// 333 is a debug sentinel: if it surfaces downstream, a real width/height failed to reach here. const DEFAULTS = { width: 333, height: 333, From df634e296852716a0bc9ee7f45f401c79b6012ad Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 11:52:39 +1000 Subject: [PATCH 25/57] refactor: cursor style when detached glass being dragged --- src/css/detached-glass.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css index d0228e5..f29c10c 100644 --- a/src/css/detached-glass.css +++ b/src/css/detached-glass.css @@ -8,10 +8,8 @@ bw-glass[detached] { } bw-glass[detached] > bw-glass-header[can-drag='true'] { - cursor: grab; - &:active { - cursor: grabbing; + cursor: move; } } From 6ec169d116aa615a77cb546d72594ff7f4e99ed1 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 12:31:19 +1000 Subject: [PATCH 26/57] chore: add a perf example --- dev/features/bwin-detached-glass.html | 4 ++++ dev/features/bwin-detached-glass.js | 31 ++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dev/features/bwin-detached-glass.html b/dev/features/bwin-detached-glass.html index 8874c33..9cf29d1 100644 --- a/dev/features/bwin-detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -20,6 +20,10 @@

BinaryWindow - detached glass

+ + +
+
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index f2bd98e..9b9c707 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -76,4 +76,33 @@ document.querySelector('#add-default').addEventListener('click', () => { bwin.addDetachedGlass(); }); -document.querySelector('button[data-position="center"]').click(); +// A heavy backdrop-filter layer + many shadowed boxes: the whole region must +// re-blur whenever anything beneath repaints. left/top dragging repaints every +// frame (janky); a composited transform does not (smooth) — compare by dragging. +const lagLayer = document.createElement('div'); +Object.assign(lagLayer.style, { + position: 'absolute', + inset: '0', + pointerEvents: 'none', + backdropFilter: 'blur(8px)', +}); + +for (let i = 0; i < 400; i++) { + const box = document.createElement('div'); + Object.assign(box.style, { + position: 'absolute', + left: `${(i * 37) % 90}%`, + top: `${(i * 53) % 90}%`, + width: '60px', + height: '60px', + borderRadius: '50%', + background: `hsl(${(i * 17) % 360} 80% 60% / 0.5)`, + boxShadow: '0 0 40px 20px rgba(0,0,0,0.25)', + filter: 'blur(2px)', + }); + lagLayer.appendChild(box); +} + +document.querySelector('#add-lag-layer').addEventListener('click', () => { + bwin.addDetachedGlass({content: lagLayer.cloneNode(true)}); +}); \ No newline at end of file From 81d3cb6cc6646e22ad5805102d038b119a0e2366 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Sun, 7 Jun 2026 13:43:01 +1000 Subject: [PATCH 27/57] feat: minimize and restore --- dev/features/bwin-detached-glass.html | 4 --- dev/features/bwin-detached-glass.js | 35 ++------------------ src/binary-window/binary-window.js | 5 ++- src/binary-window/detached-glass/class.js | 3 +- src/binary-window/detached-glass/index.js | 3 +- src/binary-window/detached-glass/minimize.js | 21 ++++++++++++ src/binary-window/detached-glass/module.js | 25 ++++++++++++++ src/binary-window/glass/module.js | 2 ++ src/css/sill.css | 1 + 9 files changed, 58 insertions(+), 41 deletions(-) create mode 100644 src/binary-window/detached-glass/minimize.js diff --git a/dev/features/bwin-detached-glass.html b/dev/features/bwin-detached-glass.html index 9cf29d1..8874c33 100644 --- a/dev/features/bwin-detached-glass.html +++ b/dev/features/bwin-detached-glass.html @@ -20,10 +20,6 @@

BinaryWindow - detached glass

- - -
-
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 9b9c707..3f10779 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -36,7 +36,7 @@ const settings = { height: 333, actions: [ [BUILTIN_ACTIONS[0], BUILTIN_ACTIONS[2]], - ['XXX', BUILTIN_ACTIONS_2[0]], + ['XXX', ...BUILTIN_ACTIONS_2], ], children: [ { position: 'left', size: '40%', actions: [] }, @@ -74,35 +74,6 @@ document.querySelectorAll('button[data-position]').forEach((button) => { // No options → exercises the DetachedGlass constructor defaults. document.querySelector('#add-default').addEventListener('click', () => { bwin.addDetachedGlass(); -}); - -// A heavy backdrop-filter layer + many shadowed boxes: the whole region must -// re-blur whenever anything beneath repaints. left/top dragging repaints every -// frame (janky); a composited transform does not (smooth) — compare by dragging. -const lagLayer = document.createElement('div'); -Object.assign(lagLayer.style, { - position: 'absolute', - inset: '0', - pointerEvents: 'none', - backdropFilter: 'blur(8px)', -}); - -for (let i = 0; i < 400; i++) { - const box = document.createElement('div'); - Object.assign(box.style, { - position: 'absolute', - left: `${(i * 37) % 90}%`, - top: `${(i * 53) % 90}%`, - width: '60px', - height: '60px', - borderRadius: '50%', - background: `hsl(${(i * 17) % 360} 80% 60% / 0.5)`, - boxShadow: '0 0 40px 20px rgba(0,0,0,0.25)', - filter: 'blur(2px)', - }); - lagLayer.appendChild(box); -} +}) -document.querySelector('#add-lag-layer').addEventListener('click', () => { - bwin.addDetachedGlass({content: lagLayer.cloneNode(true)}); -}); \ No newline at end of file +document.querySelector('#add-default').click(); \ No newline at end of file diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js index dbea429..b15d2c4 100644 --- a/src/binary-window/binary-window.js +++ b/src/binary-window/binary-window.js @@ -26,9 +26,8 @@ export class BinaryWindow extends Frame { super.enableFeatures(); this.enableDrag(); this.enableActions(); - this.enableDetachedGlassActivate(); - this.enableDetachedGlassResize(); - this.enableDetachedGlassMove(); + this.enableDetachedGlassFeatures(); + } onPaneCreate(paneEl, sash) { diff --git a/src/binary-window/detached-glass/class.js b/src/binary-window/detached-glass/class.js index 8163f4a..90a563a 100644 --- a/src/binary-window/detached-glass/class.js +++ b/src/binary-window/detached-glass/class.js @@ -2,6 +2,7 @@ import { Glass } from '../glass'; import { genId } from '@/utils.js'; import { genStylesByPosition } from './utils'; import { closeAction } from './close'; +import { minimizeAction } from './minimize'; export class DetachedGlass extends Glass { constructor(options) { @@ -14,7 +15,7 @@ export class DetachedGlass extends Glass { offsetX, offsetY, id, - actions = [closeAction], + actions = [minimizeAction, closeAction], ...glassOptions } = options; diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js index 746d93a..0d86d03 100644 --- a/src/binary-window/detached-glass/index.js +++ b/src/binary-window/detached-glass/index.js @@ -1,6 +1,7 @@ import { closeAction } from './close'; +import { minimizeAction } from './minimize'; export { DetachedGlass } from './class'; export { default } from './module'; -export const BUILTIN_ACTIONS_2 = [closeAction]; +export const BUILTIN_ACTIONS_2 = [minimizeAction, closeAction]; diff --git a/src/binary-window/detached-glass/minimize.js b/src/binary-window/detached-glass/minimize.js new file mode 100644 index 0000000..4be4f4d --- /dev/null +++ b/src/binary-window/detached-glass/minimize.js @@ -0,0 +1,21 @@ +import { createDomNode } from '@/utils'; +import { detachedGlassManager } from './manager'; + +export const minimizeAction = { + label: '', + className: 'bw-glass-action--minimize', + onClick: (event, binaryWindow) => { + const sillEl = binaryWindow.sillElement; + if (!sillEl) throw new Error(`[bwin] Sill element not found when minimizing`); + + const minimizedDetachedGlassEl = createDomNode(' +
  • diff --git a/dev/index.js b/dev/index.js index fcdd95b..9dd2ebd 100644 --- a/dev/index.js +++ b/dev/index.js @@ -45,13 +45,16 @@ function route() { window.addEventListener('hashchange', route); route(); -navEl.querySelector('#_toggle-bg').addEventListener('click', () => { - const bgColor = 'hsl(0 0 90)'; - const bodyEl = iframeEl.contentDocument?.body; +navEl.querySelector('#_toggle-theme').addEventListener('click', () => { + const windowEls = iframeEl.contentDocument?.querySelectorAll('bw-window'); - if (!bodyEl) return; + if (!windowEls?.length) return; - // Compare against '' rather than bgColor: the CSSOM re-serializes colors on - // read (e.g. hsl() -> rgb()), so equality with the original string fails. - bodyEl.style.backgroundColor = bodyEl.style.backgroundColor ? '' : bgColor; + windowEls.forEach((windowEl) => { + if (windowEl.getAttribute('theme') === 'dark') { + windowEl.removeAttribute('theme'); + } else { + windowEl.setAttribute('theme', 'dark'); + } + }); }); From 88db61bb1ce984b309b3e7c64bcfb0be85cdbe6f Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Mon, 8 Jun 2026 16:15:27 +1000 Subject: [PATCH 51/57] docs: restructure CLAUDE.md so each rule leads with the action Rephrase rules as imperatives, shorten headings, and add a working cross-reference from the dev/ commit rule to the Dev pages section. --- CLAUDE.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a3f66d..4aa63c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,29 +1,31 @@ # CLAUDE.md -## Git rules +## Git -- **Never `git commit` or `git push` unless explicitly asked in that same message.** Approval doesn't carry over — ask each time. -- When asked to commit, also print the commit message in the reply. +- **Don't `git commit` or `git push` unless the same message explicitly asks for it.** Approval doesn't carry over — ask each time. +- When committing, print the commit message in your reply. +- Type commits that only touch `dev/` as plain `chore:` — never `feat:`/`fix:`, no `(dev)` scope. It's test scaffolding, not library source (see [Dev pages](#dev-pages-dev)). ## Testing -- Don't run tests or build after completing a feature or fixing a bug unless asked. +- Don't run tests or builds after finishing a feature or fix unless asked. -## Naming conventions +## Naming -- Variables holding a DOM element get an `El` suffix, and keep the noun specific rather than generic — `activeGlassEl`, not `activeEl`. Accessor methods that return an element are named `get` to match (e.g. `getActiveGlass`). -- Constants name the context they apply to, not just the quantity — `MIN_RESIZE_WIDTH`, not `MIN_WIDTH`, so they don't get confused with unrelated values like creation-time defaults. -- When naming functions, variables, etc., prefer established terms from the relevant domain/library and match their conventional meaning — don't pick a name whose well-known meaning differs from what the code does. (e.g. in the DOM, jQuery's `unwrap` removes the wrapper in-place, so `extractChildNodes` is clearer for moving children into a fragment.) +- Suffix DOM-element variables with `El`, and keep the noun specific: `activeGlassEl`, not `activeEl`. Name element accessors `get` to match (e.g. `getActiveGlass`). +- Name constants for the context they apply to, not just the quantity: `MIN_RESIZE_WIDTH`, not `MIN_WIDTH` — so they aren't confused with unrelated values like creation-time defaults. +- Prefer established domain/library terms and match their conventional meaning. Don't pick a name whose well-known meaning differs from what the code does — e.g. jQuery's `unwrap` removes the wrapper in place, so `extractChildNodes` is clearer for moving children into a fragment. ## Comments -- Only comment when it adds something the code doesn't already say. -- Keep comments under 2 lines (each line max 100 chars). If a comment genuinely needs to be longer, prefix it with `RATIONAL:`. +- Comment only when it adds something the code doesn't already say. +- Keep comments to 2 lines max, 100 chars per line. If one genuinely needs more, prefix it with `RATIONAL:`. ## Debug sentinel values -- Repeating-digit literals like `222` and `333` in default/fallback paths are intentional debug sentinels, not magic numbers. If such a value surfaces in a lower-level API or the rendered output, a guard upstream was bypassed and a real value leaked. Don't "tidy" them into named constants or replace them. +- Leave repeating-digit literals like `222` and `333` in default/fallback paths alone — they're intentional debug sentinels, not magic numbers. Don't rename them to constants or replace them. If one surfaces in a lower-level API or the rendered output, a guard upstream was bypassed and a real value leaked — investigate that instead. -## Dev feature examples (`dev/features/`) +## Dev pages (`dev/`) -- Put interactive testing items (buttons, inputs, forms, selects, etc.) into the `.html` file, not the `.js` file. The paired `.js` queries them with `document.querySelector(...)` and wires up behavior with `addEventListener`. See `add-remove-pane.html` / `add-remove-pane.js` for the pattern. +- Treat `dev/` as test scaffolding for manually exercising features/bugs, not shippable library source. +- Put interactive testing items (buttons, inputs, forms, selects, etc.) in the `.html` file, not the `.js`. The paired `.js` queries them with `document.querySelector(...)` and wires up behavior with `addEventListener`. See `dev/features/add-remove-pane.html` / `add-remove-pane.js` for the pattern. From 2de425de9ba0c5808f1d5b2bf0b25af97014c068 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 13:12:54 +1000 Subject: [PATCH 52/57] feat: strengthen detached glass box shadow in light and dark modes --- src/css/vars.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/css/vars.css b/src/css/vars.css index cebd8da..6dd2293 100644 --- a/src/css/vars.css +++ b/src/css/vars.css @@ -13,8 +13,8 @@ bw-window { --bw-glass-header-gap: 4px; --bw-glass-header-bg-color: hsl(0, 0%, 97%); - --bw-detached-glass-shadow: 0 2px 8px hsl(0, 0%, 0%, 0.15); - --bw-detached-glass-shadow-active: 0 6px 20px hsl(0, 0%, 0%, 0.3); + --bw-detached-glass-shadow: 0 2px 6px hsl(0, 0%, 0%, 0.12), 0 4px 14px hsl(0, 0%, 0%, 0.22); + --bw-detached-glass-shadow-active: 0 4px 10px hsl(0, 0%, 0%, 0.22), 0 10px 30px hsl(0, 0%, 0%, 0.38); /* Thickness of the resize grab zone, straddling the glass border */ --bw-detached-glass-resize-handle-size: 12px; @@ -31,8 +31,8 @@ bw-window[theme='dark'] { --bw-glass-header-bg-color: hsl(0 0% 18%); --bw-glass-bg-color-disabled: hsl(0 0% 16%); --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); - --bw-detached-glass-shadow: 0 2px 8px hsl(0 0% 0% / 0.5); - --bw-detached-glass-shadow-active: 0 6px 20px hsl(0 0% 0% / 0.7); + --bw-detached-glass-shadow: 0 2px 6px hsl(0 0% 0% / 0.55), 0 4px 14px hsl(0 0% 0% / 0.65); + --bw-detached-glass-shadow-active: 0 4px 10px hsl(0 0% 0% / 0.65), 0 10px 30px hsl(0 0% 0% / 0.8); bw-pane, bw-muntin { From 59fc321b2996280159c60582928bdf08eb98a059 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 13:12:54 +1000 Subject: [PATCH 53/57] chore: toggle iframe background, text, and color-scheme with theme button --- dev/index.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/dev/index.js b/dev/index.js index 9dd2ebd..66b367a 100644 --- a/dev/index.js +++ b/dev/index.js @@ -46,15 +46,22 @@ window.addEventListener('hashchange', route); route(); navEl.querySelector('#_toggle-theme').addEventListener('click', () => { - const windowEls = iframeEl.contentDocument?.querySelectorAll('bw-window'); + const frameDoc = iframeEl.contentDocument; + const windowEls = frameDoc?.querySelectorAll('bw-window'); if (!windowEls?.length) return; + const goDark = windowEls[0].getAttribute('theme') !== 'dark'; + windowEls.forEach((windowEl) => { - if (windowEl.getAttribute('theme') === 'dark') { - windowEl.removeAttribute('theme'); - } else { + if (goDark) { windowEl.setAttribute('theme', 'dark'); + } else { + windowEl.removeAttribute('theme'); } }); + + frameDoc.body.style.backgroundColor = goDark ? 'hsl(0 0% 12%)' : ''; + frameDoc.body.style.color = goDark ? 'hsl(0 0% 90%)' : ''; + frameDoc.documentElement.style.colorScheme = goDark ? 'dark' : ''; }); From f16990f920f876dfc9c6aa1e8d7434e7a9d71181 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 13:47:39 +1000 Subject: [PATCH 54/57] feat: flash minimized glass background to draw attention when added to sill --- src/css/sill.css | 19 +++++++++++++++++-- src/css/vars.css | 3 +++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/css/sill.css b/src/css/sill.css index 5dd7d82..d1004a9 100644 --- a/src/css/sill.css +++ b/src/css/sill.css @@ -9,8 +9,9 @@ bw-sill { padding-inline: var(--bw-glass-clearance); } -.bw-minimized-detached-glass, -.bw-minimized-glass { +.bw-minimized-glass, +.bw-minimized-detached-glass + { display: block; flex-basis: 10%; height: 10px; @@ -26,4 +27,18 @@ bw-sill { &:active { transform: scale(0.95); } + + animation: bw-minimized-glass-highlight 2s ease-out; +} + +@keyframes bw-minimized-glass-highlight { + 0% { + background-color: var(--bw-glass-header-bg-color); + } + 30% { + background-color: var(--bw-glass-highlight-color); + } + 100% { + background-color: var(--bw-glass-header-bg-color); + } } diff --git a/src/css/vars.css b/src/css/vars.css index 6dd2293..7e978c5 100644 --- a/src/css/vars.css +++ b/src/css/vars.css @@ -13,6 +13,8 @@ bw-window { --bw-glass-header-gap: 4px; --bw-glass-header-bg-color: hsl(0, 0%, 97%); + --bw-glass-highlight-color: hsl(0, 0%, 85%); + --bw-detached-glass-shadow: 0 2px 6px hsl(0, 0%, 0%, 0.12), 0 4px 14px hsl(0, 0%, 0%, 0.22); --bw-detached-glass-shadow-active: 0 4px 10px hsl(0, 0%, 0%, 0.22), 0 10px 30px hsl(0, 0%, 0%, 0.38); @@ -33,6 +35,7 @@ bw-window[theme='dark'] { --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); --bw-detached-glass-shadow: 0 2px 6px hsl(0 0% 0% / 0.55), 0 4px 14px hsl(0 0% 0% / 0.65); --bw-detached-glass-shadow-active: 0 4px 10px hsl(0 0% 0% / 0.65), 0 10px 30px hsl(0 0% 0% / 0.8); + --bw-glass-highlight-color: hsl(0 0% 65% / 0.8); bw-pane, bw-muntin { From 015a73bd157e4eb825d2f3a388725fbeafcf628d Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 13:47:39 +1000 Subject: [PATCH 55/57] chore: tweak detached-glass dev window width --- dev/features/bwin-detached-glass.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js index 606137c..fb945e7 100644 --- a/dev/features/bwin-detached-glass.js +++ b/dev/features/bwin-detached-glass.js @@ -35,7 +35,7 @@ parentElem.appendChild(elem); parentElem.appendChild(elem2); const settings = { - width: 999, + width: 777, height: 444, actions: [BUILTIN_ACTIONS], children: [ From 64a6572a7d5b02c331db2b7c006b90965a372383 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 14:42:56 +1000 Subject: [PATCH 56/57] chore: code format --- src/css/sill.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/css/sill.css b/src/css/sill.css index d1004a9..136ff7a 100644 --- a/src/css/sill.css +++ b/src/css/sill.css @@ -10,8 +10,7 @@ bw-sill { } .bw-minimized-glass, -.bw-minimized-detached-glass - { +.bw-minimized-detached-glass { display: block; flex-basis: 10%; height: 10px; From 1212731c8b86d0ebd777752ead1616205d973ff9 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Tue, 9 Jun 2026 14:48:04 +1000 Subject: [PATCH 57/57] refactor: rename highlight var to --bw-minimized-glass-highlight-color --- src/css/sill.css | 2 +- src/css/vars.css | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/css/sill.css b/src/css/sill.css index 136ff7a..a36c7b4 100644 --- a/src/css/sill.css +++ b/src/css/sill.css @@ -35,7 +35,7 @@ bw-sill { background-color: var(--bw-glass-header-bg-color); } 30% { - background-color: var(--bw-glass-highlight-color); + background-color: var(--bw-minimized-glass-highlight-color); } 100% { background-color: var(--bw-glass-header-bg-color); diff --git a/src/css/vars.css b/src/css/vars.css index 7e978c5..5cd5718 100644 --- a/src/css/vars.css +++ b/src/css/vars.css @@ -13,7 +13,7 @@ bw-window { --bw-glass-header-gap: 4px; --bw-glass-header-bg-color: hsl(0, 0%, 97%); - --bw-glass-highlight-color: hsl(0, 0%, 85%); + --bw-minimized-glass-highlight-color: hsl(0, 0%, 85%); --bw-detached-glass-shadow: 0 2px 6px hsl(0, 0%, 0%, 0.12), 0 4px 14px hsl(0, 0%, 0%, 0.22); --bw-detached-glass-shadow-active: 0 4px 10px hsl(0, 0%, 0%, 0.22), 0 10px 30px hsl(0, 0%, 0%, 0.38); @@ -35,7 +35,7 @@ bw-window[theme='dark'] { --bw-drop-area-bg-color: hsl(0 0% 100% / 0.1); --bw-detached-glass-shadow: 0 2px 6px hsl(0 0% 0% / 0.55), 0 4px 14px hsl(0 0% 0% / 0.65); --bw-detached-glass-shadow-active: 0 4px 10px hsl(0 0% 0% / 0.65), 0 10px 30px hsl(0 0% 0% / 0.8); - --bw-glass-highlight-color: hsl(0 0% 65% / 0.8); + --bw-minimized-glass-highlight-color: hsl(0 0% 65% / 0.8); bw-pane, bw-muntin {