diff --git a/dev/features/dark-theme.html b/dev/features/bwin-dark-theme.html
similarity index 80%
rename from dev/features/dark-theme.html
rename to dev/features/bwin-dark-theme.html
index 76d9187..d309608 100644
--- a/dev/features/dark-theme.html
+++ b/dev/features/bwin-dark-theme.html
@@ -3,12 +3,13 @@
-
+
Dark Theme
+
diff --git a/dev/features/bwin-detached-glass.js b/dev/features/bwin-detached-glass.js
index 394d3e0..4e61865 100644
--- a/dev/features/bwin-detached-glass.js
+++ b/dev/features/bwin-detached-glass.js
@@ -132,4 +132,35 @@ document.querySelector('#add-default').addEventListener('click', () => {
bwin.addDetachedGlass({ content: createGlassContent('default') });
});
+// Static method: floats on document.body, not inside any bw-window.
+document.querySelector('#add-windowless').addEventListener('click', () => {
+ BinaryWindow.addWindowlessGlass({
+ title: 'Windowless glass',
+ content: createGlassContent('windowless'),
+ });
+});
+
+// Modal: a backdrop is appended behind the glass to block everything underneath.
+document.querySelector('#add-modal').addEventListener('click', () => {
+ BinaryWindow.addWindowlessGlass({
+ modal: true,
+ title: 'Modal windowless glass',
+ content: createGlassContent('modal'),
+ });
+});
+
+// Windowless glass filling the viewport with a 50px inset on every edge.
+document.querySelector('#add-fullscreen').addEventListener('click', () => {
+ const EDGE = 20;
+ BinaryWindow.addWindowlessGlass({
+ title: 'Fullscreen popup',
+ draggable: false,
+ position: 'top-left',
+ offset: EDGE,
+ width: document.documentElement.clientWidth - EDGE * 2,
+ height: document.documentElement.clientHeight - EDGE * 2,
+ content: createGlassContent('fullscreen'),
+ });
+});
+
// document.querySelector('#add-default').click();
diff --git a/dev/index.js b/dev/index.js
index 66b367a..1306fbe 100644
--- a/dev/index.js
+++ b/dev/index.js
@@ -53,11 +53,14 @@ navEl.querySelector('#_toggle-theme').addEventListener('click', () => {
const goDark = windowEls[0].getAttribute('theme') !== 'dark';
- windowEls.forEach((windowEl) => {
+ // Windowless glasses float on the page body, outside bw-window, so theme them too.
+ const themedEls = [...windowEls, ...frameDoc.querySelectorAll('bw-glass[windowless]')];
+
+ themedEls.forEach((el) => {
if (goDark) {
- windowEl.setAttribute('theme', 'dark');
+ el.setAttribute('theme', 'dark');
} else {
- windowEl.removeAttribute('theme');
+ el.removeAttribute('theme');
}
});
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 82082a5..22c676d 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -10,15 +10,16 @@
bwin borrows real window-construction vocabulary. Matching it in code, comments, and docs is a hard project convention:
-| Term | Meaning | Renders as |
-|------|---------|-----------|
-| **Window** | The whole layout; the root config node. Carries window-level props (`width`, `height`, `fitContainer`, `theme`). | `` |
-| **Sash** | A node in the binary tree that organizes panes. Identified by a **Sash ID** (e.g. `AB-123`). | — (model only) |
-| **Pane** | A *leaf* sash. Holds a single glass. | `` |
-| **Muntin** | An *internal* (parent) sash: the draggable divider used to resize the two children. | `` |
-| **Glass** / **attached glass** | The content inside a pane: header (title/tabs + action buttons) + content. | `` inside a `` |
-| **Detached glass** | The same glass component floating free outside any pane (the OS-window-like panel produced by the detach action). | `` appended to `` |
-| **Sill** | The dock at the bottom of the window holding minimized glasses. | `` |
+| Term | Meaning | Renders as |
+| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
+| **Window** | The whole layout; the root config node. Carries window-level props (`width`, `height`, `fitContainer`, `theme`). | `` |
+| **Sash** | A node in the binary tree that organizes panes. Identified by a **Sash ID** (e.g. `AB-123`). | — (model only) |
+| **Pane** | A _leaf_ sash. Holds a single glass. | `` |
+| **Muntin** | An _internal_ (parent) sash: the draggable divider used to resize the two children. | `` |
+| **Glass** / **attached glass** | The content inside a pane: header (title/tabs + action buttons) + content. | `` inside a `` |
+| **Detached glass** | The same glass component floating free outside any pane (the OS-window-like panel produced by the detach action). | `` appended to `` |
+| **Windowless glass** | A detached glass with _no owning window_ — it floats on `document.body` instead of inside a ``. Created via `addWindowlessGlass`. | `` appended to `` |
+| **Sill** | The dock at the bottom of the window holding minimized glasses. | `` |
---
@@ -46,17 +47,20 @@ bwin separates a **model** from **two stacked view layers**. The model is the si
**Model — `Sash`** (`src/sash.js`)
The single source of truth for geometry; nothing else owns layout state.
+
- A binary tree of regions. Each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag.
- Setters cascade geometry down to children, so the tree stays consistent after any change.
**Frame layer — core tiling** (`src/frame/`)
-The view layer that *mirrors the model*. Renders the structure, owns resizing.
+The view layer that _mirrors the model_. Renders the structure, owns resizing.
+
- Renders the window, **muntins**, and **panes**.
- Owns **pane resizing**: dragging a muntin mutates the sash tree — the defining feature of a tiling manager. Also handles generic drop infrastructure and fit-to-container.
- Renders panes but **not** their contents — a `` is an empty region until the layer above fills it.
**BinaryWindow layer — enhanced interaction** (`src/binary-window/`)
`extends Frame` and adds the user-facing window experience on top.
+
- Renders a **glass** into each pane (`onPaneCreate`) — this layer is where "windows" (glasses) live; the Frame layer underneath only knows panes.
- Adds glass drag-and-drop rearrangement, the minimize/maximize/close/detach actions, **detached** (floating) glasses, and the **sill**.
@@ -66,8 +70,8 @@ Rendering is one-directional in both layers: sash tree → DOM, via `glaze()` (i
`Frame.mount(containerEl)` is the normal entry point and runs two phases:
-1. **`frame(containerEl)`** — *DOM creation.* Creates ``, calls `glaze()` to render the whole sash tree, appends to the container. `BinaryWindow.frame()` additionally appends ``.
-2. **`enableFeatures()`** — *interaction wiring.* Attaches event listeners for resize/drop/fit; `BinaryWindow` adds glass and detached-glass features.
+1. **`frame(containerEl)`** — _DOM creation._ Creates ``, calls `glaze()` to render the whole sash tree, appends to the container. `BinaryWindow.frame()` additionally appends ``.
+2. **`enableFeatures()`** — _interaction wiring._ Attaches event listeners for resize/drop/fit; `BinaryWindow` adds glass and detached-glass features.
This split is deliberate and is itself an integration seam — `react-bwin` skips `mount()`, sets `windowElement`/`containerElement`/`sillElement` from its own React-created DOM, then calls `enableFeatures()` directly (see §9).
@@ -79,7 +83,14 @@ bwin avoids deep class hierarchies for features. Instead, `Frame`/`BinaryWindow`
```js
// src/frame/frame.js
-Frame.assemble(mainModule, muntinModule, paneModule, fitContainerModule, droppableModule, resizableModule);
+Frame.assemble(
+ mainModule,
+ muntinModule,
+ paneModule,
+ fitContainerModule,
+ droppableModule,
+ resizableModule
+);
// src/binary-window/binary-window.js
BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule);
@@ -87,7 +98,7 @@ BinaryWindow.assemble(glassModule, detachedGlassModule, trimModule);
`assemble()` uses `strictAssign` (`src/utils.js`), which **throws if a key already exists** on the target prototype. This makes the method namespace a flat, collision-checked shared space.
-**Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, *not* `enableResize`. When adding a feature, prefix its methods with the feature name.
+**Consequence — namespace your mixin methods.** Because all module methods share one namespace, a new feature must not reuse an existing method name. `frame/resizable.js` already owns `enableResize`, so the detached-glass resize feature is `enableDetachedGlassResize`, _not_ `enableResize`. When adding a feature, prefix its methods with the feature name.
**Override via composition.** Modules later in the `assemble()` list can't silently clobber (strictAssign throws), so genuine overrides happen through subclassing: `BinaryWindow.onPaneCreate` overrides `Frame`'s, `binary-window/glass/drag.js`'s `onPaneDrop` overrides the empty stub in `frame/droppable.js`, and `trimModule.onMuntinCreate` wraps muntin creation. The base modules leave `onPaneCreate`/`onPaneDrop`/`onMuntinCreate` as empty "intended to be overridden" hooks.
@@ -104,15 +115,17 @@ new BinaryWindow({ width, height, fitContainer, theme, children: [...] }).mount(
### Config node forms
Each child config node may be written three ways (`ConfigNode.normConfig`):
+
- **Object** — `{ position, size, content, children, ... }` (full form).
- **Array** — shorthand for `{ children: [...] }` (a split with no other props).
- **String/number** — shorthand for `{ size }` (e.g. `"60%"`, `0.4`, `300`).
Node props:
+
- **Structural** (named by the config layer): `position` (`left|right|top|bottom`), `size` (px number, fraction `0.4`, or `"60%"`), `id`, `minWidth`, `minHeight`, `children`, `resizeStrategy`.
- **Everything else** → `...rest` → `nonCoreData` (see §6).
-A node with **no children** becomes a **pane**; a node **with children** becomes a **split** (rendered as a muntin + its two subtrees). Maximum **two children** per node — an omitted second child has its position/size *inferred* from the first (opposite position, complementary size).
+A node with **no children** becomes a **pane**; a node **with children** becomes a **split** (rendered as a muntin + its two subtrees). Maximum **two children** per node — an omitted second child has its position/size _inferred_ from the first (opposite position, complementary size).
### Build flow
@@ -132,6 +145,7 @@ ConfigRoot(settings) config-root.js
- **`SashConfig`** (`config/sash-config.js`) is an alternate entry: a pre-built `Sash` subtree can be passed directly to `Frame`/`BinaryWindow` instead of a config object — `Frame`'s constructor branches on `settings instanceof SashConfig`.
### `parseSize` (`src/utils.js`)
+
Normalizes sizes: `"50%"` → `0.5` (fraction < 1), `"100px"` / `100` → `100` (absolute px). Fractions and absolutes are handled distinctly throughout the geometry code.
---
@@ -141,6 +155,7 @@ Normalizes sizes: `"50%"` → `0.5` (fraction < 1), `"100px"` / `100` → `100`
The heart of the layout engine. A `Sash` is a binary-tree node; it never touches the DOM (except holding a `domNode` reference set by the view).
### Tree shape & queries
+
- `children` is `[]` (leaf/pane) or exactly two nodes (split). `isLeaf()` / `isSplit()`.
- Positional accessors: `leftChild`/`rightChild`/`topChild`/`bottomChild`, plus `getChildren()` → `[top, right, bottom, left]`.
- A split is either **left/right** (`isLeftRightSplit`) or **top/bottom** (`isTopBottomSplit`).
@@ -149,16 +164,21 @@ The heart of the layout engine. A `Sash` is a binary-tree node; it never touches
- `swapIds(id1, id2)` — exchanges two sashes' IDs in place (used by the center-drop swap).
### Geometry & cascading setters
+
`top`/`left`/`width`/`height` are getter/setter pairs over private `_`-fields. **Setting a dimension cascades to children** so the subtree stays consistent:
+
- Setting `width` on a left/right split distributes the delta between `leftChild` and `rightChild` (proportionally by default), enforcing each side's `calcMinWidth()`; setting it on a top/bottom split widens both children equally.
- `height` is symmetric for top/bottom vs left/right.
- `top`/`left` shift both children by the same delta.
### Min-size propagation
+
`calcMinWidth()` / `calcMinHeight()` recurse: a left/right split's min width is the **sum** of children's min widths (they share the axis), while its min height is the **max**; top/bottom splits are the mirror. This is what stops a resize from crushing a deeply-nested pane below its minimum. Initial leaf minimums come from `VITE_DEFAULT_SASH_MIN_WIDTH/HEIGHT`.
### Resize strategies
+
`resizeStrategy` is `'classic'` (default) or `'natural'`:
+
- **classic** — both children share the delta proportionally.
- **natural** — only one child changes size; the other holds, depending on which side the divider belongs to.
@@ -169,6 +189,7 @@ The heart of the layout engine. A `Sash` is a binary-tree node; it never touches
Structural props are named explicitly in the config layer; **everything else is opaque pass-through** carried in `sash.store`. This is how `content`, `title`, `tabs`, `actions`, `draggable`, `droppable`, `resizable`, and event handlers (`onDrop`) travel from declarative config to pane/glass construction.
### The path (declarative config)
+
1. `config-node.js` — constructor destructures only structural keys; the `...rest` becomes `this.nonCoreData`. (Threaded through `createPrimary/SecondaryConfigNode` so it survives recursion.)
2. `createSash()` passes `nonCoreData` as the Sash's `store`.
3. `sash.js` — `this.store = store`; the Sash **never inspects it**.
@@ -176,27 +197,32 @@ Structural props are named explicitly in the config layer; **everything else is
5. `glass.js` — the `Glass` constructor names them (`title`, `content`, `tabs`, `actions`, `draggable`) with defaults.
### `store` is a one-way seed (by intent)
-`store` carries data from the top-level API into construction, then **should stop**. It is *not* a live model — moves like attach/detach/swap exchange data through the **DOM**, not through `store`, because the DOM is visible and debuggable in the browser. (See the `RATIONAL:` block in `sash.js`.)
+
+`store` carries data from the top-level API into construction, then **should stop**. It is _not_ a live model — moves like attach/detach/swap exchange data through the **DOM**, not through `store`, because the DOM is visible and debuggable in the browser. (See the `RATIONAL:` block in `sash.js`.)
**Known tech debt:** `store` is not yet a pure seed. Two keys are still read long after construction — `onDrop` (read at drop time in `droppable.js`) and `resizable` (read when a pane is split in `muntin.js`). Until those move onto DOM/closures at build time, `store` can't be cleared in `onPaneCreate`.
### Store-key consumer map
+
`store` has no fixed schema. Recognized keys and where they're read:
-| Key | Read by | Effect |
-|-----|---------|--------|
-| `content` | `frame/pane.js` (and re-surfaced to `Glass`) | DOM-node'd and placed in the pane/glass content |
-| `droppable === false` | `frame/pane.js` | sets `can-drop="false"` on the pane |
-| `onDrop` (fn) | `frame/droppable.js` | called `onDrop(event, sash)` on drop |
-| `resizable === false` | `frame/muntin.js` | sets `resizable="false"` on the muntin |
-| `actions` / `title` / `tabs` / `draggable` / rest | `binary-window.js onPaneCreate` → `Glass` | spread into the `Glass` constructor |
+| Key | Read by | Effect |
+| ------------------------------------------------- | -------------------------------------------- | ----------------------------------------------- |
+| `content` | `frame/pane.js` (and re-surfaced to `Glass`) | DOM-node'd and placed in the pane/glass content |
+| `droppable === false` | `frame/pane.js` | sets `can-drop="false"` on the pane |
+| `onDrop` (fn) | `frame/droppable.js` | called `onDrop(event, sash)` on drop |
+| `resizable === false` | `frame/muntin.js` | sets `resizable="false"` on the muntin |
+| `actions` / `title` / `tabs` / `draggable` / rest | `binary-window.js onPaneCreate` → `Glass` | spread into the `Glass` constructor |
### The `undefined` vs `null` actions contract (load-bearing)
+
A default parameter fires **only on `undefined`**. This is relied on by docs and react-bwin:
+
- User **omits** `actions` → absent from store → `undefined` → `Glass` defaults to `DEFAULT_GLASS_ACTIONS`.
- User writes `actions: null` (or `[]`) → reaches `Glass` as a non-array → `createActions` renders **no** buttons.
### Two other paths to `Glass`
+
- **Imperative `addPane(id, props)`** destructures `{position, size, id, ...glassProps}` and passes `glassProps` straight to `new Glass` — bypassing ConfigNode/store entirely.
- **`DetachedGlass`** defaults `actions` to `DEFAULT_DETACHED_GLASS_ACTIONS` instead.
@@ -205,13 +231,17 @@ A default parameter fires **only on `undefined`**. This is relied on by docs and
## 7. The view: rendering & reconciliation (`src/frame/`)
### `glaze()` — initial render (`frame/main.js`)
+
`rootSash.walk(...)` (post-order) over the whole tree:
+
- split sash → `createMuntin` + `onMuntinCreate`, **appended** (muntins end up on top in z-order).
- leaf sash → `createPane` + `onPaneCreate`, **prepended**.
- each sash's `domNode` is set to its element.
### `update()` — incremental reconcile (`frame/main.js`)
+
After any tree mutation (resize, add/remove/swap pane), `update()` reconciles DOM to model:
+
1. Resize `` to the root sash.
2. Remove DOM elements whose `sash-id` is no longer in `rootSash.getAllIds()`.
3. `walk` the tree: for each sash, **create** its element if new, else **update** position/size in place (`updatePane`/`updateMuntin`) and fire the `on*Update` hooks.
@@ -219,9 +249,11 @@ After any tree mutation (resize, add/remove/swap pane), `update()` reconciles DO
Because Sash IDs are stable across an operation (e.g. `removePane` promotes a sibling's ID into its parent; splits hand the new muntin a fresh `genId`), `update()` can tell apart "moved" from "new/removed".
### Pane geometry (`frame/pane-utils.js`)
+
`createPaneElement`/`updatePaneElement` write `top/left/width/height` styles and `sash-id`/`position` attributes. `addPaneSash` (+ the four `addPaneSashTo{Left,Right,Top,Bottom}` helpers) performs the **tree surgery** for a split: it converts the target leaf into a split parent (target gets a fresh muntin ID, `domNode = null`), creates the two child sashes (one inherits the target's old ID + `domNode`), and returns the new sash. Sizing honors `size` as fraction or px.
### Muntins (`frame/muntin.js` + `binary-window/trim.js`)
+
`createMuntin`/`updateMuntin` position a `muntinSize`-px (4px) divider at the boundary between the two children, marked `vertical` (left/right split) or `horizontal` (top/bottom split). `sash.store.resizable === false` sets `resizable="false"`. `trim.js` (a `BinaryWindow` mixin) shrinks each muntin by half its size at both ends via `onMuntinCreate`/`onMuntinUpdate` so dividers don't overlap at intersections.
---
@@ -229,9 +261,11 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si
## 8. Interaction features
### 8.1 Resize (`frame/resizable.js`)
+
Drag a muntin to resize its two children. Uses **document-bound `mousedown`/`mousemove`/`mouseup`** (the older pattern — see §11). On `mousedown` over a `` (unless `resizable="false"`), records the active muntin sash; on `mousemove`, applies the delta to the two children (clamped by `calcMinWidth`/`calcMinHeight`) and calls `update()`; on `mouseup`, clears state. A `body--bw-resize-x/y` class sets the cursor during the drag.
### 8.2 Glass drag-and-drop rearrangement (native HTML DnD)
+
Dragging an attached glass and dropping it on a pane rearranges the layout. Split across three files:
- **`frame/droppable.js`** — generic drop infrastructure on `windowElement`: `dragover` (must `preventDefault` to allow drop; finds the `` under cursor, computes the zone via `getCursorPosition`, writes it to the pane's `drop-area` attribute so CSS paints a preview), `dragleave` (with a Chrome child-element guard), and `drop` (resolves the sash, calls the overridable `onPaneDrop(event, sash)` stub, then any `sash.store.onDrop`).
@@ -239,15 +273,18 @@ Dragging an attached glass and dropping it on a pane rearranges the layout. Spli
- **`src/position.js`** — `getCursorPosition` splits a pane into 5 zones (top/right/bottom/left/center) using the two diagonals plus a center box (`centerRadio = 0.3`).
**`onPaneDrop` branches on the drop zone:**
+
- **center** → `swapPanes` (`frame/pane.js`): swap the two sashes' IDs in the tree, swap their DOM child nodes, swap `sash-id`/`can-drop` attributes. No new pane — the two glasses trade places.
- **top/right/bottom/left** → split: `removePane(oldSashId)` (the dragged glass's original pane; its sibling collapses up), then `addPane(targetSash.id, { position: dropArea, id: oldSashId })`, then **move the same glass element** into the new pane via `replaceChildren(activeDragGlassEl)`.
> **Gotcha:** `BinaryWindow.addPane` always seeds a new pane with its own empty placeholder `Glass`. The drop must `replaceChildren` (not `append`) — a plain append would leave **two** glasses (the empty placeholder first), and a later detach's `querySelector('bw-glass-content')` would then grab the empty one → a blank detached glass.
### 8.3 Fit container (`frame/fit-container.js`)
+
When `fitContainer` is set, a `ResizeObserver` on the container calls `fit()` (inside `requestAnimationFrame`), which sets the root sash to the container's client size and `update()`s. `fit()` is also exposed as a public method.
### 8.4 Glass actions (`binary-window/glass/`)
+
Each built-in action is a small object `{ label, className, onClick(event, binaryWindow) }`. `Glass.createActions()` renders them as buttons in `` and wires `onClick`. `DEFAULT_GLASS_ACTIONS = [minimize, detach, close]`.
- **close** (`action.close.js`) — removes the pane.
@@ -258,49 +295,66 @@ Each built-in action is a small object `{ label, className, onClick(event, binar
Built-in actions are **pane-centric** (`closest('bw-pane')`), so they don't work on detached glasses — which is why detached glass ships its own action set.
### 8.5 Detached glass (`binary-window/detached-glass/`)
+
Floating `` panels that mimic OS windows, appended directly to `windowElement` (not bound to any pane/sash). The canonical "feature folder," mirrored by `glass/`:
-| File | Role |
-|------|------|
-| `index.js` | re-exports `DetachedGlass` + `DEFAULT_DETACHED_GLASS_ACTIONS`; the assembled mixin (`enableDetachedGlassFeatures` + spread of the behavior modules). |
-| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. |
-| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. |
-| `manager.js` | `detachedGlassManager` singleton: tracks glasses, `bringToFront` (rising z-index + sole `[active]` marker), find/remove. |
-| `activate.js` | click-to-focus → `bringToFront`. |
-| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). |
-| `resize.js` | 8 resize handles created **on demand** (on hover). |
-| `drag.js` | *alternative* repositioning via native DnD (docks to panes) — currently **disabled** in favor of `move.js` (free-floating). Enable exactly one. |
-| `restore.js` | restore a minimized detached glass from its sill button. |
-| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. |
-| `utils.js` | `genStylesByPosition`, resize-handle creation. |
+| File | Role |
+| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `index.js` | re-exports `DetachedGlass` + `DEFAULT_DETACHED_GLASS_ACTIONS`; the assembled mixin (`enableDetachedGlassFeatures` + spread of the behavior modules). |
+| `detached-glass.js` | `DetachedGlass extends Glass`; positions/sizes the floating node, defaults `actions = DEFAULT_DETACHED_GLASS_ACTIONS`. |
+| `crud.js` | `addDetachedGlass` / `removeDetachedGlass` (public). Cascades placement down-right from the active glass; guards size so the constructor's `222` debug default never fires. |
+| `manager.js` | `detachedGlassManager` singleton: tracks glasses, `bringToFront` (rising z-index + sole `[active]` marker), find/remove. |
+| `activate.js` | click-to-focus → `bringToFront`. |
+| `move.js` | drag the header to reposition (pointer events + `setPointerCapture`). |
+| `resize.js` | 8 resize handles created **on demand** (on hover). |
+| `drag.js` | _alternative_ repositioning via native DnD (docks to panes) — currently **disabled** in favor of `move.js` (free-floating). Enable exactly one. |
+| `restore.js` | restore a minimized detached glass from its sill button. |
+| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. |
+| `utils.js` | `genStylesByPosition`, resize-handle creation. |
`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`.
-**Position** supports the four corners + `center` (centered via `calc(50% - size/2)`, *not* translate, so left/top stay in sync with drag/resize math; `offset` has no effect on centered).
+**Position** supports the four corners + `center` (centered via `calc(50% - size/2)`, _not_ translate, so left/top stay in sync with drag/resize math; `offset` has no effect on centered).
**Detach → attach round-trip:**
+
- **detach** (`glass/action.detach.js`): creates a centered detached glass sized to the window, then **moves the real content/title elements** into it (`replaceWith`), stashes the origin (`bwOriginalSiblingSashId`, `bwOriginalPosition`, `bwOriginalRelativeSize`) on the DOM node, and `removePane`s the source.
- **attach** (`detached-glass/action.attach.js`): reads the stashed origin and rebuilds a pane via `addPane`, moving the inner content nodes back in. If the original sibling is gone, it falls back to splitting the **largest leaf** (right if wide, bottom if tall) at 50%, then `removeDetachedGlass`.
-> **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop *moves the same element*.
+> **Key contrast with the attached-glass split:** detach/attach round-trips data through the **DOM** (extract content nodes, build a fresh `Glass`), whereas the attached-glass drop _moves the same element_.
+
+### 8.6 Windowless glass (`BinaryWindow.addWindowlessGlass`)
+
+A **windowless glass** is a detached glass with no owning window: it's appended to `document.body` instead of a `` and isn't bound to any `BinaryWindow` instance. It's still registered with the shared `detachedGlassManager`, so z-index/activation stacking works the same as for an in-window detached glass.
+
+- **Create** (`binary-window.js` `addWindowlessGlass`) — builds a `DetachedGlass` (`position: 'center'` default), sets the `windowless` attribute, appends it to `document.body`, registers it with the manager, and `bringToFront`s it. **Remove** (`removeWindowlessGlass(id)`) — unregisters from the manager, removes the node, and tears down its modal backdrop (below).
+- **Modal backdrop** — passing `modal: true` appends a `` to `document.body` that blocks interaction with everything underneath. Its `z-index` is set inline to `glassZIndex - 1` — the odd slot the `topZIndex += 2` reservation (above) leaves free — so it sits directly below its glass. Both `removeWindowlessGlass` and the detached **close** action remove the matching `bw-glass-backdrop[for="…"]` via the shared `removeGlassBackdrop` helper in `detached-glass/utils.js`. Styled in `detached-glass.css` (`position: fixed; inset: 0`), color from `--bw-glass-backdrop-color`.
+- **Actions** — with no `binaryWindow`, the _minimize_ and _attach_ actions (which need a window to minimize into / attach to) don't apply, so the default set is **close only**: `DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction]` (vs. `[minimize, attach, close]` for an in-window detached glass).
+- **Containing block** (`detached-glass/utils.js` `getContainingBlockOrigin`) — both kinds are `position: absolute`, but their reference frame differs. An in-window detached glass's `offsetParent` is the positioned `bw-window`, so the origin is that window's rect top-left. A windowless glass on a _static_ `body` has no positioned ancestor → its containing block is the **initial** one (document origin), so the origin is `{ left: -scrollX, top: -scrollY }`. Drag (`move.js`) and resize math both route through this so viewport bounding stays correct regardless of scroll.
+- **Modal z-index reservation** (`manager.js`) — `bringToFront` increments `topZIndex` by **2** (not 1) and returns the new value, reserving the odd slot just below for a modal overlay on a windowless glass.
+- **Theming caveat** — windowless glasses live outside `bw-window`, so theming code must select `bw-glass[windowless]` separately (see `dev/index.js`).
---
## 9. Public API surface (`src/index.js`)
-Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position`, `DEFAULT_GLASS_ACTIONS`, `DEFAULT_DETACHED_GLASS_ACTIONS`, and the **deprecated** `BUILTIN_ACTIONS` (backwards-compat alias). CSS is imported here (`vars/body/frame/glass/detached-glass/sill`) and shipped as `bwin.css`.
+Exports: `Frame`, `BinaryWindow`, `Sash`, `SashConfig`, `ConfigRoot`, `Position`, `DEFAULT_GLASS_ACTIONS`, `DEFAULT_DETACHED_GLASS_ACTIONS`, `DEFAULT_WINDOWLESS_GLASS_ACTIONS`, and the **deprecated** `BUILTIN_ACTIONS` (backwards-compat alias). CSS is imported here (`vars/body/frame/glass/detached-glass/sill`) and shipped as `bwin.css`.
Key methods on `BinaryWindow`:
+
- `mount(containerEl)` / `frame()` / `enableFeatures()` — lifecycle.
- `addPane(targetSashId, { position, size, id, ...glassProps })` — split a pane and attach a glass.
- `removePane(sashId)` — remove a pane (or clean up a minimized entry).
-- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels.
+- `addDetachedGlass(options)` / `removeDetachedGlass(id)` — floating panels inside the window.
+- `addWindowlessGlass(options)` / `removeWindowlessGlass(id)` — _static_ floating panels on `document.body`, with no owning window (§8.6).
- `setTheme(theme)` / `fit()`.
**Per-window `actions` config** is normalized by `BinaryWindow.normActions` into `[glassActions, detachedGlassActions]`, supporting forms like `[a1, a2]`, `[[glassActions]]`, `[glassActions, detachedGlassActions]`, `[undefined, detachedGlassActions]`, etc. `undefined` → both defaults; `null`/`[]` → none.
### Downstream coupling — `react-bwin`
+
The React wrapper (`../react-bwin`) must own DOM creation, so it bypasses `mount()` and reaches into internals. These are signals for public API bwin could formalize:
+
- Skips `mount()`; sets `windowElement`/`containerElement`/`sillElement` then calls `enableFeatures()` → wants a "bring-your-own-DOM"/hydrate mode.
- Walks the sash tree (`rootSash.walk`, `children`, `leftChild`/`topChild`) and mutates `sash.domNode` from refs → wants a public traversal/serializable-layout API.
- Reads `sash.store.*` (`actions`, `title`, `content`, `droppable`, `draggable`, `resizable`) → **`store` is effectively the integration contract.**
@@ -322,7 +376,7 @@ These are enforced project conventions (see `CLAUDE.md`):
- **Terminology** — use the window-construction metaphor (§1) precisely; don't pick a name whose well-known meaning differs from what the code does.
- **Naming** — suffix DOM-element variables with `El` and keep the noun specific (`activeGlassEl`, not `activeEl`); name accessors `get`. Name constants for their context (`MIN_RESIZE_WIDTH`, not `MIN_WIDTH`).
-- **Interaction code (preferred for new features)** — Pointer Events + `setPointerCapture` (one path for mouse/touch/pen; capture keeps move events flowing and self-releases) over document-bound `mouse*`; **delegated listeners on `windowElement`** (constant listener count); **create affordance DOM on demand** (on hover), not eagerly; scope child queries with `:scope > selector`. *Note:* existing `resizable.js` and the attached-glass `drag.js` still use the older `document` + `mouse*` style; detached glass `move.js`/`resize.js` use the modern pattern.
+- **Interaction code (preferred for new features)** — Pointer Events + `setPointerCapture` (one path for mouse/touch/pen; capture keeps move events flowing and self-releases) over document-bound `mouse*`; **delegated listeners on `windowElement`** (constant listener count); **create affordance DOM on demand** (on hover), not eagerly; scope child queries with `:scope > selector`. _Note:_ existing `resizable.js` and the attached-glass `drag.js` still use the older `document` + `mouse*` style; detached glass `move.js`/`resize.js` use the modern pattern.
- **Comments** — only when they add what the code doesn't say; ≤2 lines / 100 chars; prefix a genuinely longer one with `RATIONAL:`; wrap identifiers in backticks.
- **Debug sentinels** — repeating-digit literals (`222`, `333`) in default/fallback paths are intentional tripwires, **not** magic numbers. Don't tidy them. If one surfaces downstream, a guard upstream was bypassed — investigate that, don't rename it.
- **`dev/`** — test scaffolding, not shippable source. Interactive controls go in the `.html`; the paired `.js` queries them and wires behavior. Commits touching only `dev/` are plain `chore:`.
diff --git a/docs/context/conventions.md b/docs/context/conventions.md
index 1ac388b..a124aff 100644
--- a/docs/context/conventions.md
+++ b/docs/context/conventions.md
@@ -1,6 +1,6 @@
# Coding conventions
-The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the *why*.
+The full rationale behind the rules summarized in [`CLAUDE.md`](../../CLAUDE.md). Read this when writing or reviewing bwin source; `CLAUDE.md` is the quick checklist, this is the _why_.
See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`react-bwin-integration.md`](./react-bwin-integration.md) for the downstream contract.
@@ -10,13 +10,13 @@ See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`rea
Use the window-construction metaphor precisely — the full glossary is [`ARCHITECTURE.md` §1](../ARCHITECTURE.md#1-the-window-construction-metaphor). 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).
-Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass".
+Use plain "glass" by default; say "attached glass" only when contrasting with "detached glass". A **windowless glass** is a detached glass with no owning window (floats on `document.body`); use that exact term — not "free glass" (the old name) or "floating glass".
---
## Naming
-- **DOM-element variables get an `El` suffix with a *specific* noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available.
+- **DOM-element variables get an `El` suffix with a _specific_ noun** — `activeGlassEl`, not `activeEl`, and not a vague `glassEl` when more specificity is available.
- **Element accessors are named `get`** — e.g. `getActiveGlass` (returns the element that `activeGlassEl` would hold).
- **Constants name the context they apply to, not just the quantity** — `MIN_RESIZE_WIDTH`, not `MIN_WIDTH`, so a resize-time minimum isn't confused with an unrelated creation-time size default.
- **Prefer established domain/library terms** and match their conventional meaning.
diff --git a/src/binary-window/binary-window.js b/src/binary-window/binary-window.js
index ac8b227..ec58495 100644
--- a/src/binary-window/binary-window.js
+++ b/src/binary-window/binary-window.js
@@ -1,8 +1,14 @@
import { Frame } from '../frame/frame';
-import glassModule, { Glass, DEFAULT_GLASS_ACTIONS } from './glass';
+import glassModule, { Glass } from './glass';
import { createDomNode } from '../utils';
import trimModule from './trim';
-import detachedGlassModule, { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
+import detachedGlassModule, {
+ DetachedGlass,
+ DEFAULT_WINDOWLESS_GLASS_ACTIONS,
+} from './detached-glass';
+import { detachedGlassManager } from './detached-glass/manager';
+import { removeGlassBackdrop } from './detached-glass/utils';
+import { normActions } from './utils';
// debug: ci round 2
export class BinaryWindow extends Frame {
@@ -12,7 +18,7 @@ export class BinaryWindow extends Frame {
super(settings);
this.theme = settings.theme || '';
- this.actions = BinaryWindow.normActions(settings.actions);
+ this.actions = normActions(settings.actions);
}
frame() {
@@ -95,30 +101,68 @@ export class BinaryWindow extends Frame {
}
}
- // Returns [glassActions, detachedGlassActions]
- static normActions(actions) {
- if (actions === undefined) return [DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS];
- if (!actions || !Array.isArray(actions) || actions.length === 0) return [[], []];
-
- // [glassActions]
- if (actions.length === 1 && Array.isArray(actions[0])) return [actions[0], DEFAULT_DETACHED_GLASS_ACTIONS];
-
- // [action1, action2, ...]
- if (!actions.some(Array.isArray)) return [actions, DEFAULT_DETACHED_GLASS_ACTIONS];
+ /**
+ * Add a windowless glass: a detached glass that floats on `document.body` instead
+ * of inside a `bw-window`, so it isn't owned by any window instance. Managed by the
+ * shared glass manager (z-index/activation) like an in-window detached glass.
+ *
+ * @param {Object} [options]
+ * @param {boolean} [options.modal] - When true, append a ``
+ * behind the glass to block interaction with everything underneath.
+ * @param {'center'|'top-left'|'top-right'|'bottom-left'|'bottom-right'} [options.position='center'] - Where to anchor the glass.
+ * @param {number} [options.width] - Glass width in px.
+ * @param {number} [options.height] - Glass height in px.
+ * @param {number} [options.offset=0] - Distance in px from the anchored corner/edge (no effect on `center`).
+ * @param {number} [options.offsetX] - Per-axis override of `offset` on the x-axis.
+ * @param {number} [options.offsetY] - Per-axis override of `offset` on the y-axis.
+ * @param {string} [options.id] - Glass id; auto-generated (suffixed `-F`) when omitted.
+ * @param {Object[]} [options.actions] - Action buttons; defaults to `DEFAULT_WINDOWLESS_GLASS_ACTIONS` (close only).
+ * @param {string|Node} [options.title] - Header title.
+ * @param {string|Node} [options.content] - Glass body content.
+ * @param {Object[]} [options.tabs] - Header tabs (shown instead of `title`).
+ * @param {boolean} [options.draggable=true] - Whether the header can be dragged to move the glass.
+ * @returns {DetachedGlass}
+ */
+ static addWindowlessGlass(options = {}) {
+ const { modal, ...glassOptions } = options;
+
+ const glass = new DetachedGlass({
+ actions: DEFAULT_WINDOWLESS_GLASS_ACTIONS,
+ position: 'center',
+ ...glassOptions,
+ });
+
+ glass.domNode.setAttribute('windowless', '');
+
+ document.body.append(glass.domNode);
+ detachedGlassManager.addGlassByElement(glass.domNode);
+ // bringToFront reserves the z-index slot just below the glass for this backdrop.
+ const glassZIndex = detachedGlassManager.bringToFront(glass.domNode);
+
+ if (modal) {
+ const backdropEl = document.createElement('bw-glass-backdrop');
+ backdropEl.setAttribute('for', glass.domNode.id);
+ backdropEl.style.zIndex = glassZIndex - 1;
+ document.body.append(backdropEl);
+ }
- // [undefined, detachedGlassActions]
- if (actions.length >= 2 && !Array.isArray(actions[0]) && Array.isArray(actions[1]))
- return [[], actions[1]];
+ return glass;
+ }
- // [glassActions, undefined]
- if (actions.length >= 2 && Array.isArray(actions[0]) && !Array.isArray(actions[1]))
- return [actions[0], []];
+ /**
+ * Remove a windowless glass by id, unregistering it from the shared glass manager
+ * and detaching it from `document.body`. Also removes its modal backdrop, if any.
+ *
+ * @param {string} windowlessGlassId - The id of the `bw-glass[windowless]` to remove
+ * @returns {Element|null} - The removed element, or null if no glass had that id
+ */
+ static removeWindowlessGlass(windowlessGlassId) {
+ const removedGlassEl = detachedGlassManager.removeGlassById(windowlessGlassId);
+ removedGlassEl?.remove();
- // [glassActions, detachedGlassActions]
- if (actions.length >= 2 && Array.isArray(actions[0]) && Array.isArray(actions[1]))
- return actions;
+ removeGlassBackdrop(windowlessGlassId);
- throw new Error(`[bwin] Invalid actions format`);
+ return removedGlassEl;
}
}
diff --git a/src/binary-window/binary-window.test.js b/src/binary-window/binary-window.test.js
index edb4f8b..88e1fa5 100644
--- a/src/binary-window/binary-window.test.js
+++ b/src/binary-window/binary-window.test.js
@@ -1,45 +1,45 @@
import { describe, it, expect } from 'vitest';
-import { BinaryWindow } from './binary-window';
+import { normActions } from './utils';
import { DEFAULT_GLASS_ACTIONS } from './glass';
import { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
-describe('BinaryWindow.normActions', () => {
+describe('normActions', () => {
it('returns the builtin actions when actions is undefined', () => {
- expect(BinaryWindow.normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]);
+ expect(normActions(undefined)).toEqual([DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS]);
});
it('returns [[], []] when actions is null, empty, or not an array', () => {
- expect(BinaryWindow.normActions(null)).toEqual([[], []]);
- expect(BinaryWindow.normActions('a')).toEqual([[], []]);
- expect(BinaryWindow.normActions({})).toEqual([[], []]);
- expect(BinaryWindow.normActions([])).toEqual([[], []]);
+ expect(normActions(null)).toEqual([[], []]);
+ expect(normActions('a')).toEqual([[], []]);
+ expect(normActions({})).toEqual([[], []]);
+ expect(normActions([])).toEqual([[], []]);
});
it('returns [glassActions, DEFAULT_DETACHED_GLASS_ACTIONS] for a single grouped array', () => {
const a = { label: 'A' };
- expect(BinaryWindow.normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]);
+ expect(normActions([[a]])).toEqual([[a], DEFAULT_DETACHED_GLASS_ACTIONS]);
});
it('returns [actions, DEFAULT_DETACHED_GLASS_ACTIONS] when actions is a flat array', () => {
const a = { label: 'A' };
const b = { label: 'B' };
- expect(BinaryWindow.normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]);
+ expect(normActions([a, b])).toEqual([[a, b], DEFAULT_DETACHED_GLASS_ACTIONS]);
});
it('returns [[], detachedGlassActions] when first group is absent', () => {
const b = { label: 'B' };
- expect(BinaryWindow.normActions([undefined, [b]])).toEqual([[], [b]]);
- expect(BinaryWindow.normActions([null, [b]])).toEqual([[], [b]]);
+ expect(normActions([undefined, [b]])).toEqual([[], [b]]);
+ expect(normActions([null, [b]])).toEqual([[], [b]]);
});
it('returns [glassActions, []] when second group is absent', () => {
const a = { label: 'A' };
- expect(BinaryWindow.normActions([[a], undefined])).toEqual([[a], []]);
- expect(BinaryWindow.normActions([[a], null])).toEqual([[a], []]);
+ expect(normActions([[a], undefined])).toEqual([[a], []]);
+ expect(normActions([[a], null])).toEqual([[a], []]);
});
it('returns actions as-is when both groups are arrays', () => {
@@ -47,10 +47,10 @@ describe('BinaryWindow.normActions', () => {
const b = { label: 'B' };
const grouped = [[a], [b]];
- expect(BinaryWindow.normActions(grouped)).toBe(grouped);
+ expect(normActions(grouped)).toBe(grouped);
});
it('throws when an array is present but neither of the first two slots is one', () => {
- expect(() => BinaryWindow.normActions([null, null, []])).toThrow('[bwin] Invalid actions format');
+ expect(() => normActions([null, null, []])).toThrow('[bwin] Invalid actions format');
});
});
diff --git a/src/binary-window/detached-glass/action.attach.js b/src/binary-window/detached-glass/action.attach.js
index 7ad71b9..1d5d123 100644
--- a/src/binary-window/detached-glass/action.attach.js
+++ b/src/binary-window/detached-glass/action.attach.js
@@ -1,4 +1,4 @@
-import { extractChildNodes } from '@/utils';
+import { transferGlass } from '../glass/utils';
export default {
label: '',
@@ -24,16 +24,13 @@ export default {
size = 0.5;
}
- // Pass the inner nodes, not the bw-glass-content wrapper — Glass adds its own.
- const contentEl = detachedGlassEl.querySelector('bw-glass-content');
-
- binaryWindow.addPane(targetSashId, {
+ const paneSash = binaryWindow.addPane(targetSashId, {
position,
size,
- content: extractChildNodes(contentEl),
- title: detachedGlassEl.querySelector('bw-glass-title')?.textContent || '',
});
+ transferGlass(detachedGlassEl, paneSash.domNode);
+
binaryWindow.removeDetachedGlass(detachedGlassEl.id);
},
};
diff --git a/src/binary-window/detached-glass/action.close.js b/src/binary-window/detached-glass/action.close.js
index 11c1c6c..839970c 100644
--- a/src/binary-window/detached-glass/action.close.js
+++ b/src/binary-window/detached-glass/action.close.js
@@ -1,4 +1,5 @@
import { detachedGlassManager } from './manager';
+import { removeGlassBackdrop } from './utils';
export default {
label: '',
@@ -9,6 +10,8 @@ export default {
detachedGlassManager.removeGlassById(glassEl.id);
glassEl.remove();
+
+ // Remove the modal backdrop tied to this glass, if any (windowless modal glass).
+ removeGlassBackdrop(glassEl.id);
},
};
-
diff --git a/src/binary-window/detached-glass/activate.js b/src/binary-window/detached-glass/activate.js
index 5f5d919..125834b 100644
--- a/src/binary-window/detached-glass/activate.js
+++ b/src/binary-window/detached-glass/activate.js
@@ -4,7 +4,7 @@ export default {
enableDetachedGlassActivate() {
// 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) => {
+ document.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
const glassEl = event.target.closest?.('bw-glass[detached]');
diff --git a/src/binary-window/detached-glass/detached-glass.js b/src/binary-window/detached-glass/detached-glass.js
index c778f2a..df3539c 100644
--- a/src/binary-window/detached-glass/detached-glass.js
+++ b/src/binary-window/detached-glass/detached-glass.js
@@ -6,6 +6,11 @@ import attachAction from './action.attach';
import minimizeAction from './action.minimize';
export const DEFAULT_DETACHED_GLASS_ACTIONS = [minimizeAction, attachAction, closeAction];
+
+// A windowless glass floats on `document.body` with no owning window, so minimize/attach
+// (which need a `binaryWindow`) don't apply — close is the only built-in that works.
+export const DEFAULT_WINDOWLESS_GLASS_ACTIONS = [closeAction];
+
export class DetachedGlass extends Glass {
constructor(options) {
const {
diff --git a/src/binary-window/detached-glass/index.js b/src/binary-window/detached-glass/index.js
index beafd9e..6954fb4 100644
--- a/src/binary-window/detached-glass/index.js
+++ b/src/binary-window/detached-glass/index.js
@@ -5,7 +5,11 @@ import dragModule from './drag';
import resizeModule from './resize';
import restoreModule from './restore';
-export { DetachedGlass, DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
+export {
+ DetachedGlass,
+ DEFAULT_DETACHED_GLASS_ACTIONS,
+ DEFAULT_WINDOWLESS_GLASS_ACTIONS,
+} from './detached-glass';
export default {
enableDetachedGlassFeatures() {
diff --git a/src/binary-window/detached-glass/manager.js b/src/binary-window/detached-glass/manager.js
index a1c155e..e2c3255 100644
--- a/src/binary-window/detached-glass/manager.js
+++ b/src/binary-window/detached-glass/manager.js
@@ -18,14 +18,17 @@ class DetachedGlassManager {
// Already front-most (it owns the [active] marker) → nothing to raise.
if (glassEl.hasAttribute('active')) return;
- this.topZIndex += 1;
+ // Reserve 1 for modal on windowless glass
+ this.topZIndex += 2;
glassEl.style.zIndex = this.topZIndex;
// 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'));
+ // Cleared across all managed glasses, so a detached and a windowless glass
+ // (different parents) can't both look active at once.
+ this.glasses.forEach((el) => el !== glassEl && el.removeAttribute('active'));
glassEl.setAttribute('active', '');
+
+ return this.topZIndex;
}
removeGlassById(id) {
diff --git a/src/binary-window/detached-glass/move.js b/src/binary-window/detached-glass/move.js
index a37e563..08f6cb2 100644
--- a/src/binary-window/detached-glass/move.js
+++ b/src/binary-window/detached-glass/move.js
@@ -1,3 +1,6 @@
+import { clamp } from '@/utils';
+import { getResizeHandleOverhang, getContainingBlockOrigin } from './utils';
+
export default {
enableDetachedGlassMove() {
let activeMoveGlassEl = null;
@@ -5,8 +8,13 @@ export default {
let moveStartY = 0;
let moveStartLeft = 0;
let moveStartTop = 0;
+ // Window-relative bounds that keep the glass within the viewport, captured at grab time.
+ let minMoveLeft = 0;
+ let maxMoveLeft = 0;
+ let minMoveTop = 0;
+ let maxMoveTop = 0;
- this.windowElement.addEventListener('pointerdown', (event) => {
+ document.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
// Move from anywhere in the header (incl. title text), but not its buttons.
@@ -25,18 +33,31 @@ export default {
moveStartX = event.pageX;
moveStartY = event.pageY;
- // Normalize corner-anchored geometry to window-relative left/top.
- const windowRect = this.windowElement.getBoundingClientRect();
+ // Normalize corner-anchored geometry to left/top relative to the glass's
+ // containing block (the window for a detached glass, the viewport for a windowless one).
+ const origin = getContainingBlockOrigin(glassEl);
const glassRect = glassEl.getBoundingClientRect();
- moveStartLeft = glassRect.left - windowRect.left;
- moveStartTop = glassRect.top - windowRect.top;
+ moveStartLeft = glassRect.left - origin.left;
+ moveStartTop = glassRect.top - origin.top;
+
+ // Bound the move to the viewport so dragging past an edge never grows the
+ // page. clientWidth/Height exclude scrollbars; the handle overhang on the
+ // right/bottom is reserved so hover handles stay on-screen too.
+ const overhang = getResizeHandleOverhang(glassEl);
+ const viewportWidth = document.documentElement.clientWidth;
+ const viewportHeight = document.documentElement.clientHeight;
+ minMoveLeft = -origin.left;
+ maxMoveLeft = viewportWidth - glassRect.width - overhang - origin.left;
+ minMoveTop = -origin.top;
+ maxMoveTop = viewportHeight - glassRect.height - overhang - origin.top;
});
- this.windowElement.addEventListener('pointermove', (event) => {
+ document.addEventListener('pointermove', (event) => {
if (!activeMoveGlassEl) return;
- const left = moveStartLeft + (event.pageX - moveStartX);
- const top = moveStartTop + (event.pageY - moveStartY);
+ // Clamp to the viewport; a glass larger than the viewport pins to the top-left edge.
+ const left = clamp(moveStartLeft + (event.pageX - moveStartX), minMoveLeft, maxMoveLeft);
+ const top = clamp(moveStartTop + (event.pageY - moveStartY), minMoveTop, maxMoveTop);
activeMoveGlassEl.style.right = 'auto';
activeMoveGlassEl.style.bottom = 'auto';
@@ -44,7 +65,7 @@ export default {
activeMoveGlassEl.style.top = `${top}px`;
});
- this.windowElement.addEventListener('pointerup', (event) => {
+ document.addEventListener('pointerup', (event) => {
if (!activeMoveGlassEl) return;
if (event.target.hasPointerCapture?.(event.pointerId)) {
diff --git a/src/binary-window/detached-glass/resize.js b/src/binary-window/detached-glass/resize.js
index 614c8a4..628161e 100644
--- a/src/binary-window/detached-glass/resize.js
+++ b/src/binary-window/detached-glass/resize.js
@@ -1,4 +1,4 @@
-import { createResizeHandles } from './utils';
+import { createResizeHandles, getContainingBlockOrigin } from './utils';
const MIN_RESIZE_WIDTH = 100;
const MIN_RESIZE_HEIGHT = 60;
@@ -26,12 +26,12 @@ export default {
let resizeStartRect = null;
// Handles exist only while a glass is hovered, so idle glasses cost no extra DOM.
- this.windowElement.addEventListener('pointerover', (event) => {
+ document.addEventListener('pointerover', (event) => {
const glassEl = event.target.closest?.('bw-glass[detached]');
if (glassEl) addResizeHandles(glassEl);
});
- this.windowElement.addEventListener('pointerout', (event) => {
+ document.addEventListener('pointerout', (event) => {
const glassEl = event.target.closest?.('bw-glass[detached]');
if (!glassEl) return;
@@ -45,7 +45,7 @@ export default {
removeResizeHandles(glassEl);
});
- this.windowElement.addEventListener('pointerdown', (event) => {
+ document.addEventListener('pointerdown', (event) => {
if (event.button !== 0 || event.target.tagName !== 'BW-GLASS-RESIZE-HANDLE') return;
const glassEl = event.target.closest('bw-glass[detached]');
@@ -59,19 +59,19 @@ export default {
resizeStartX = event.pageX;
resizeStartY = event.pageY;
- // Normalize corner-anchored geometry to window-relative left/top/width/height
- // so every edge resizes with the same math.
- const windowRect = this.windowElement.getBoundingClientRect();
+ // Normalize corner-anchored geometry to containing-block-relative
+ // left/top/width/height so every edge resizes with the same math.
+ const origin = getContainingBlockOrigin(glassEl);
const glassRect = glassEl.getBoundingClientRect();
resizeStartRect = {
- left: glassRect.left - windowRect.left,
- top: glassRect.top - windowRect.top,
+ left: glassRect.left - origin.left,
+ top: glassRect.top - origin.top,
width: glassRect.width,
height: glassRect.height,
};
});
- this.windowElement.addEventListener('pointermove', (event) => {
+ document.addEventListener('pointermove', (event) => {
if (!activeResizeGlassEl) return;
const dir = activeResizeDir;
@@ -105,7 +105,7 @@ export default {
activeResizeGlassEl.style.height = `${height}px`;
});
- this.windowElement.addEventListener('pointerup', (event) => {
+ document.addEventListener('pointerup', (event) => {
if (!activeResizeGlassEl) return;
if (event.target.hasPointerCapture?.(event.pointerId)) {
diff --git a/src/binary-window/detached-glass/utils.js b/src/binary-window/detached-glass/utils.js
index 99a28f0..ebba68b 100644
--- a/src/binary-window/detached-glass/utils.js
+++ b/src/binary-window/detached-glass/utils.js
@@ -9,6 +9,36 @@ export function createResizeHandles() {
});
}
+// Resize handles straddle the glass border, so each overhangs its edge by half
+// the handle size. Returns that overhang, so callers can keep handles on-screen.
+export function getResizeHandleOverhang(glassEl) {
+ const styles = getComputedStyle(glassEl);
+ const size = styles.getPropertyValue('--bw-detached-glass-resize-handle-size');
+ return (parseFloat(size) || 0) / 2;
+}
+
+// Remove the modal backdrop tied to a glass id, if one exists (windowless modal glass).
+export function removeGlassBackdrop(glassId) {
+ const backdropEl = document.querySelector(`bw-glass-backdrop[for="${glassId}"]`);
+ backdropEl?.remove();
+}
+
+// Viewport-space top-left of an absolutely-positioned element's containing block.
+export function getContainingBlockOrigin(el) {
+ const offsetParentEl = el.offsetParent;
+
+ // Detached glass: the positioned `bw-window` IS the containing block, so its
+ // rect's top-left is the origin directly.
+ if (offsetParentEl && getComputedStyle(offsetParentEl).position !== 'static') {
+ const { left, top } = offsetParentEl.getBoundingClientRect();
+ return { left, top };
+ }
+
+ // Windowless glass on a static `body`: the containing block is the initial one
+ // (the canvas at the document origin), which sits scroll-distance above the viewport.
+ return { left: -window.scrollX, top: -window.scrollY };
+}
+
// `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 }) {
diff --git a/src/binary-window/glass/action.detach.js b/src/binary-window/glass/action.detach.js
index d0766fb..a004a74 100644
--- a/src/binary-window/glass/action.detach.js
+++ b/src/binary-window/glass/action.detach.js
@@ -1,3 +1,5 @@
+import { transferGlass } from './utils';
+
const DETACHED_GLASS_INSET = 15;
export default {
@@ -7,8 +9,7 @@ export default {
if (!binaryWindow.addDetachedGlass) throw new Error('[bwin] Failed to detach glass from pane');
const paneEl = event.target.closest('bw-pane');
- const glassContentEl = paneEl.querySelector('bw-glass-content');
- const glassTitleEl = paneEl.querySelector('bw-glass-title');
+ const glassEl = paneEl.querySelector('bw-glass');
const windowRect = binaryWindow.windowElement.getBoundingClientRect();
const width = windowRect.width - DETACHED_GLASS_INSET * 2;
@@ -23,10 +24,7 @@ export default {
detachedGlass.domNode.bwOriginalPosition = paneEl.getAttribute('position');
detachedGlass.domNode.bwOriginalRelativeSize = paneSash.getRelativeSize();
- detachedGlass.contentElement.replaceWith(glassContentEl);
-
- // Attached glass may only render tabs without title element
- if (glassTitleEl) detachedGlass.titleElement.replaceWith(glassTitleEl);
+ transferGlass(glassEl, detachedGlass.domNode);
binaryWindow.removePane(paneSashId);
},
diff --git a/src/binary-window/glass/utils.js b/src/binary-window/glass/utils.js
new file mode 100644
index 0000000..8e2a2c6
--- /dev/null
+++ b/src/binary-window/glass/utils.js
@@ -0,0 +1,22 @@
+// Moves the title/tabs and content children from one glass to another, keeping
+// the target's own header actions. Used when detaching/attaching a glass.
+export function transferGlass(sourceGlassElement, targetGlassElement) {
+ const sourceHeaderEl = sourceGlassElement.querySelector('bw-glass-header');
+ const targetHeaderEl = targetGlassElement.querySelector('bw-glass-header');
+
+ // Remove the target's placeholder title/tabs before adopting the source's
+ targetHeaderEl.querySelector('bw-glass-title')?.remove();
+ targetHeaderEl.querySelector('bw-glass-tab-container')?.remove();
+
+ const sourceTitleEl = sourceHeaderEl.querySelector('bw-glass-title');
+ const sourceTabsEl = sourceHeaderEl.querySelector('bw-glass-tab-container');
+
+ // prepend keeps the title/tabs ahead of the target's action container
+ if (sourceTabsEl) targetHeaderEl.prepend(sourceTabsEl);
+ if (sourceTitleEl) targetHeaderEl.prepend(sourceTitleEl);
+
+ // append moves each node out of the source content element
+ const sourceContentEl = sourceGlassElement.querySelector('bw-glass-content');
+ const targetContentEl = targetGlassElement.querySelector('bw-glass-content');
+ targetContentEl.append(...sourceContentEl.childNodes);
+}
diff --git a/src/binary-window/utils.js b/src/binary-window/utils.js
new file mode 100644
index 0000000..29c4acb
--- /dev/null
+++ b/src/binary-window/utils.js
@@ -0,0 +1,28 @@
+import { DEFAULT_DETACHED_GLASS_ACTIONS } from './detached-glass';
+import { DEFAULT_GLASS_ACTIONS } from './glass';
+
+// Returns [glassActions, detachedGlassActions]
+export function normActions(actions) {
+ if (actions === undefined) return [DEFAULT_GLASS_ACTIONS, DEFAULT_DETACHED_GLASS_ACTIONS];
+ if (!actions || !Array.isArray(actions) || actions.length === 0) return [[], []];
+
+ // [glassActions]
+ if (actions.length === 1 && Array.isArray(actions[0]))
+ return [actions[0], DEFAULT_DETACHED_GLASS_ACTIONS];
+
+ // [action1, action2, ...]
+ if (!actions.some(Array.isArray)) return [actions, DEFAULT_DETACHED_GLASS_ACTIONS];
+
+ // [undefined, detachedGlassActions]
+ if (actions.length >= 2 && !Array.isArray(actions[0]) && Array.isArray(actions[1]))
+ return [[], actions[1]];
+
+ // [glassActions, undefined]
+ if (actions.length >= 2 && Array.isArray(actions[0]) && !Array.isArray(actions[1]))
+ return [actions[0], []];
+
+ // [glassActions, detachedGlassActions]
+ if (actions.length >= 2 && Array.isArray(actions[0]) && Array.isArray(actions[1])) return actions;
+
+ throw new Error(`[bwin] Invalid actions format`);
+}
diff --git a/src/css/detached-glass.css b/src/css/detached-glass.css
index f29c10c..e1783a5 100644
--- a/src/css/detached-glass.css
+++ b/src/css/detached-glass.css
@@ -13,6 +13,13 @@ bw-glass[detached] > bw-glass-header[can-drag='true'] {
}
}
+/* Modal backdrop behind a windowless glass; z-index set inline to sit just below its glass */
+bw-glass-backdrop {
+ position: fixed;
+ inset: 0;
+ background: var(--bw-glass-backdrop-color);
+}
+
bw-glass-resize-handle {
position: absolute;
z-index: 2;
diff --git a/src/css/theme.css b/src/css/theme.css
new file mode 100644
index 0000000..9bec68a
--- /dev/null
+++ b/src/css/theme.css
@@ -0,0 +1,25 @@
+/* TODO: differentiate settings between window and windowless glass */
+bw-window[theme='dark'],
+bw-glass[windowless][theme='dark'] {
+ color-scheme: dark;
+
+ --bw-glass-border-color: hsl(0 0% 40%);
+ --bw-glass-border-color-disabled: hsl(0 0% 30%);
+ --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 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-minimized-glass-highlight-color: hsl(0 0% 65% / 0.8);
+
+ bw-glass-content {
+ color: hsl(0 0% 90%);
+ background-color: hsl(0 0% 16%);
+ }
+
+ .bw-glass-tab:hover,
+ .bw-glass-action:hover,
+ .bw-minimized-glass:hover {
+ background-color: hsl(0 0% 26%);
+ }
+}
diff --git a/src/css/vars.css b/src/css/vars.css
index 048827e..36eae1e 100644
--- a/src/css/vars.css
+++ b/src/css/vars.css
@@ -1,4 +1,4 @@
-bw-window {
+:root {
--bw-font-family: system-ui;
--bw-font-size: 14px;
--bw-drop-area-bg-color: hsl(0, 0%, 0%, 0.05);
@@ -16,40 +16,14 @@ bw-window {
--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);
+ --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;
- --bw-sill-gap: 6px;
-}
-
-bw-window[theme='dark'] {
- /* Render buttons, scrollbars, and other native UI elements in dark mode */
- color-scheme: dark;
-
- --bw-glass-border-color: hsl(0 0% 40%);
- --bw-glass-border-color-disabled: hsl(0 0% 30%);
- --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 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-minimized-glass-highlight-color: hsl(0 0% 65% / 0.8);
+ /* Modal backdrop behind a windowless glass */
+ --bw-glass-backdrop-color: hsl(0, 0%, 0%, 0.4);
- bw-pane,
- bw-muntin {
- background-color: hsl(0 0% 14%);
- }
-
- bw-glass-content {
- color: hsl(0 0% 90%);
- background-color: hsl(0 0% 16%);
- }
-
- .bw-glass-tab:hover,
- .bw-glass-action:hover,
- .bw-minimized-glass:hover {
- background-color: hsl(0 0% 26%);
- }
+ --bw-sill-gap: 6px;
}
diff --git a/src/index.js b/src/index.js
index af5b2f5..4a44ac7 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,11 +4,12 @@ import './css/frame.css';
import './css/glass.css';
import './css/detached-glass.css';
import './css/sill.css';
+import './css/theme.css';
export { Frame } from './frame/frame';
export { BinaryWindow } from './binary-window/binary-window';
export { DEFAULT_GLASS_ACTIONS } from './binary-window/glass';
-export { DEFAULT_DETACHED_GLASS_ACTIONS } from './binary-window/detached-glass';
+export { DEFAULT_DETACHED_GLASS_ACTIONS, DEFAULT_WINDOWLESS_GLASS_ACTIONS } from './binary-window/detached-glass';
export { Sash } from './sash';
export { SashConfig } from './config/sash-config';
export { ConfigRoot } from './config/config-root';
diff --git a/src/utils.js b/src/utils.js
index 2a4c72b..9d7bece 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -137,6 +137,18 @@ export function parseSize(size) {
return NaN;
}
+/**
+ * Clamp a number to the inclusive range [min, max]
+ *
+ * @param {number} value - The value to clamp
+ * @param {number} min - The lower bound
+ * @param {number} max - The upper bound
+ * @returns {number}
+ */
+export function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
/**
* Check if a value is a plain object, not array, null, etc
*