From 10a74848f2a395a08ab30aca7d2ec71ec0cd27c0 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 12 Jun 2026 12:17:30 +1000 Subject: [PATCH 1/5] docs: architecture --- docs/ARCHITECTURE.md | 360 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..9fe3919 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,360 @@ +# bwin — Software Architecture + +**bwin** ("Binary Window") is a lightweight, dependency-free **tiling window-manager library for the browser**: resizable panes, drag-and-drop rearrangement, minimize/maximize/close, and floating OS-window-like panels. Published as the npm package `bwin` (MIT). Entry: `dist/bwin.js` + `./bwin.css`. + +- **Docs site:** https://bhjsdev.github.io/bwin-docs/ +- **Stack:** vanilla JS (ES modules), Vite (dev/build), Vitest (tests), pnpm. +- **Current version:** see `package.json` (`0.4.x` at time of writing). + +> This document describes the internal architecture for contributors. For the public API/config reference, see the docs site. + +--- + +## 1. The window-construction metaphor + +bwin borrows real window-glazing 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. | `` | + +--- + +## 2. The big picture: model / view / behavior + +bwin cleanly separates three concerns: + +``` + Config (declarative) Model View (DOM) Behavior (features) + ┌────────────────────┐ ┌──────────────────┐ ┌────────────────┐ ┌────────────────────────┐ + │ ConfigRoot │ │ Sash tree │ │ │ │ resize (muntin drag) │ + │ └ ConfigNode tree │ ───▶ │ (root Sash │──▶│ │ │ drop (glass DnD) │ + │ buildSashTree() │ │ + children) │ │ │◀──│ fitContainer (RO) │ + └────────────────────┘ └──────────────────┘ │ │ │ glass actions │ + ▲ │ │ │ detached glass │ + walk() │ getById() └────────────────┘ └────────────────────────┘ + │ ▲ glaze()/update() render the tree + └───────────────────────┘ +``` + +- **Model** — `Sash` (`src/sash.js`) is the single source of truth for geometry. A binary tree of regions; each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag. Resizing mutates the tree; setters cascade geometry to children. +- **View** — `Frame` (`src/frame/`) walks the sash tree and renders DOM. `BinaryWindow` extends it with glass content, the sill, and floating glasses. Rendering is one-directional: model → DOM (`glaze()` for initial render, `update()` for incremental reconcile). +- **Behavior** — features (resize, drop, fit, glass actions, detached glass) are mixed onto the prototype via `assemble()` and attached to the live DOM in `enableFeatures()`. + +### The `mount()` lifecycle + +`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()`** — *behavior 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). + +--- + +## 3. The `assemble()` mixin pattern + +bwin avoids deep class hierarchies for features. Instead, `Frame`/`BinaryWindow` are assembled from **module objects** — plain objects whose keys become prototype methods: + +```js +// src/frame/frame.js +Frame.assemble(mainModule, muntinModule, paneModule, fitContainerModule, droppableModule, resizableModule); + +// src/binary-window/binary-window.js +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. + +**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. + +--- + +## 4. Configuration → Sash tree (`src/config/`) + +The public API constructs a window declaratively and bwin compiles it into a sash tree. + +```js +new BinaryWindow({ width, height, fitContainer, theme, children: [...] }).mount(containerEl); +``` + +### 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). + +### Build flow + +``` +ConfigRoot(settings) config-root.js + extends ConfigNode config-node.js + └ buildSashTree({ resizeStrategy }) + ├ createSash() ─────────────▶ new Sash(...) (store = nonCoreData) + ├ normConfig(children[0]) → createPrimaryConfigNode + ├ normConfig(children[1]) → createSecondaryConfigNode(…, primary) + │ (infers opposite position / complementary size from the sibling) + └ recurse into each child's buildSashTree() +``` + +- **`ConfigNode`** does geometry math up front: `getPosition`, `getSize`, and `setBounds` compute absolute `left/top/width/height` from the parent rect and the position/size. Sibling inference and validation live in `getPosition`/`getSize` (e.g. "sum of sibling sizes must equal 1 / parent dimension"). +- **`ConfigRoot`** is the entry; defaults `width/height` to the `333` debug sentinel (if it surfaces downstream, a real dimension failed to reach it). It strips `fitContainer`/`theme` as feature flags and forwards the rest. +- **`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. + +--- + +## 5. The Sash model (`src/sash.js`) + +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`). +- Lookups: `getById`, `getAllIds`, `getDescendantParentById`, `getChildSiblingById`, `getAllLeafDescendants`, `getLargestLeaf`. +- `walk(cb)` — **post-order** (deepest child first), used by the view to render/reconcile. +- `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. + +--- + +## 6. The `store` bag: non-core props (`sash.store`) + +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**. +4. `binary-window.js` `onPaneCreate` — `new Glass({ actions, ...sash.store, sash, binaryWindow: this })` re-surfaces store keys as top-level Glass options. +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`.) + +**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 | + +### 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. + +--- + +## 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. + +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. + +--- + +## 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 Drop / drag-rearrange (native HTML DnD) +Dragging an attached glass and dropping it on a pane rearranges the layout. Split across three layers: + +- **`frame/droppable.js`** — generic drop infra 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`). +- **`binary-window/glass/drag.js`** — arms the attached-glass drag and provides the **real `onPaneDrop`**. A native drag is one document-global gesture, so the dragged element is tracked in a **module-level** `activeDragGlassEl` (only one glass drags at a time). `mousedown` on a `bw-glass-header` (left button, `can-drag` not false) sets `draggable=true` on the glass; `dragstart` saves and disables the source pane's `can-drop` so it isn't its own target; `dragend`/`mouseup` clean up and carry `can-drop` back. +- **`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. +- **minimize** (`action.minimize.js`) — appends a `.bw-minimized-glass` button to the sill that stashes the live glass element + original position/rect/sash-id, then `removePane`s. Restoring re-inserts the glass (the `getMinimizedGlassElementBySashId` path in `binary-window.js removePane` also cleans up minimized entries). +- **maximize** (`action.maximize.js`) — toggles a `maximized` attribute, saving/restoring the pane's bounding rect; maximized panes go `0/0/100%/100%`. +- **detach** (`action.detach.js`) — see §8.5. + +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. | + +`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). + +**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*. + +--- + +## 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`. + +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. +- `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.** +- Re-implements the `undefined`-vs-`null` actions logic inline and imports `BUILTIN_ACTIONS`/`DEFAULT_GLASS_ACTIONS` from bwin → must be kept in sync by hand. +- Recomputes muntin geometry (`MUNTIN_SIZE=4`) → duplicated math. +- Hand-maintains `bwin.d.ts` because **bwin publishes no TypeScript types** → shipping `.d.ts` would remove the shim. + +--- + +## 10. Styling & theming (`src/css/`) + +CSS is split by concern: `vars.css` (custom properties — sizes, shadows, colors), `body.css` (resize cursors), `frame.css` (window/pane/muntin), `glass.css` (header/content/actions/tabs), `detached-glass.css` (floating panel + shadows + resize handles), `sill.css` (minimized dock). Theming is an attribute (`theme="…"` on ``, set via `setTheme`); CSS variables key off it. Drop previews, active-glass shadows, and resize cursors are all CSS-driven off attributes the JS toggles (`drop-area`, `[active]`, `maximized`, body classes). + +--- + +## 11. Conventions for contributors + +These are enforced project conventions (see `CLAUDE.md`): + +- **Terminology** — use the glazing 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. +- **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:`. +- **Git** — don't commit/push unless explicitly asked; no Claude co-author trailers. + +--- + +## 12. Repository map + +``` +src/ + index.js Public exports + CSS imports + sash.js Sash model: binary tree, geometry, min-size, resize strategies + position.js Position enum, opposite/zone math (getCursorPosition) + rect.js Rect helpers + utils.js genId/parseSize/createDomNode/strictAssign/swapChildNodes/throttle/… + config/ + config-root.js ConfigRoot (window-level: width/height/fitContainer/theme) + config-node.js ConfigNode: normalize forms, geometry, sibling inference, buildSashTree + sash-config.js SashConfig: pre-built Sash subtree as config + frame/ + frame.js Frame class + assemble() of base modules; mount/frame/enableFeatures + main.js createWindow/glaze/update (render & reconcile) + pane.js createPane/addPane/removePane/swapPanes (tree + DOM) + pane-utils.js pane element + addPaneSash split surgery + muntin.js muntin element create/update + resizable.js muntin-drag resize (document mouse*) + droppable.js generic native-DnD drop infra (onPaneDrop stub) + fit-container.js ResizeObserver → fit() + frame-utils.js getSashIdFromPane + binary-window/ + binary-window.js BinaryWindow extends Frame; glass on panes, sill, normActions + trim.js muntin-trim mixin (shrink ends at intersections) + glass/ + glass.js Glass component (header/tabs/actions/content) + DEFAULT_GLASS_ACTIONS + index.js glass mixin + BUILTIN_ACTIONS (deprecated alias) + action.js action wiring mixin + drag.js attached-glass native drag + real onPaneDrop + action.close/minimize/maximize/detach.js built-in actions + detached-glass/ + detached-glass.js DetachedGlass extends Glass + index.js detached mixin + DEFAULT_DETACHED_GLASS_ACTIONS + crud.js addDetachedGlass/removeDetachedGlass (cascade placement) + manager.js z-index/active-state singleton + activate/move/resize/drag/restore.js behaviors + action.attach/close/minimize.js detached actions + utils.js genStylesByPosition, resize handles + css/ vars/body/frame/glass/detached-glass/sill +dev/ Manual feature/bug test pages (scaffolding, not shipped) +``` From 2e0a1f489fe8bcaad9d6b7d5db70508d932700d7 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 12 Jun 2026 12:35:26 +1000 Subject: [PATCH 2/5] docs: refine arch doc --- CLAUDE.md | 9 +---- docs/ARCHITECTURE.md | 91 +++++++++++--------------------------------- 2 files changed, 24 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81a2150..73eed14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,7 @@ ## Terminology -bwin borrows real window-construction terms — match their meaning in code, comments, and docs: - -- **Window** — the whole layout; the root config node. Renders `` and carries window-level props (`width`, `height`, `fitContainer`). -- **Sash** — a node in the binary tree that organizes panes. Identified by a Sash ID (e.g. `AB-123`). A leaf sash renders a pane; a sash with children renders a muntin. -- **Pane** — a leaf sash, rendered as ``. Holds a single glass. -- **Muntin** — an internal (parent) sash, rendered as ``: the draggable vertical/horizontal divider used to resize panes. -- **Glass** / **attached glass** — the glass inside a `bw-pane` (its header + content). Plain "glass" by default; "attached glass" when contrasting with detached. -- **Detached glass** — the same glass component floating free outside any `bw-pane` (the OS-window-like panel from the detach action), appended directly to the window. +bwin borrows real window-construction terms — match their meaning in code, comments, and docs. The full glossary lives in [`docs/ARCHITECTURE.md` §1](docs/ARCHITECTURE.md#1-the-window-construction-metaphor). Use plain "glass" by default; say "attached glass" only when contrasting with detached. ## Naming diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9fe3919..1f0ec95 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,12 +1,8 @@ # bwin — Software Architecture -**bwin** ("Binary Window") is a lightweight, dependency-free **tiling window-manager library for the browser**: resizable panes, drag-and-drop rearrangement, minimize/maximize/close, and floating OS-window-like panels. Published as the npm package `bwin` (MIT). Entry: `dist/bwin.js` + `./bwin.css`. +**bwin** ("Binary Window") is a lightweight, dependency-free tiling window-manager library for the browser: resizable panes, drag-and-drop rearrangement, minimize/maximize/close, and floating OS-window-like panels. -- **Docs site:** https://bhjsdev.github.io/bwin-docs/ -- **Stack:** vanilla JS (ES modules), Vite (dev/build), Vitest (tests), pnpm. -- **Current version:** see `package.json` (`0.4.x` at time of writing). - -> This document describes the internal architecture for contributors. For the public API/config reference, see the docs site. +> This document describes the internal architecture for contributors. For the public API/config reference, see the [docs site](https://bhjsdev.github.io/bwin-docs/). --- @@ -26,26 +22,31 @@ bwin borrows real window-glazing vocabulary. Matching it in code, comments, and --- -## 2. The big picture: model / view / behavior +## 2. The big picture: model + two view layers -bwin cleanly separates three concerns: +bwin separates a **model** from **two stacked view layers**. The model is the single source of truth for geometry; each view layer renders the model to DOM and wires its own interactions. `BinaryWindow extends Frame`, so the layers stack rather than sit side by side. ``` - Config (declarative) Model View (DOM) Behavior (features) - ┌────────────────────┐ ┌──────────────────┐ ┌────────────────┐ ┌────────────────────────┐ - │ ConfigRoot │ │ Sash tree │ │ │ │ resize (muntin drag) │ - │ └ ConfigNode tree │ ───▶ │ (root Sash │──▶│ │ │ drop (glass DnD) │ - │ buildSashTree() │ │ + children) │ │ │◀──│ fitContainer (RO) │ - └────────────────────┘ └──────────────────┘ │ │ │ glass actions │ - ▲ │ │ │ detached glass │ - walk() │ getById() └────────────────┘ └────────────────────────┘ - │ ▲ glaze()/update() render the tree - └───────────────────────┘ + Config Model Frame BinaryWindow + (core tiling) (enhance) + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ ConfigRoot │ │ Sash tree │ │ bw-window │ │ bw-glass │ + │ ConfigNode │ ──> │ root Sash │ ──> │ bw-muntin │ ──> │ drag · drop │ + │ buildSash- │ │ + children │ │ bw-pane │ │ detached │ + │ Tree() │ │ │ │ resize · fit │ │ sill │ + └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ + ▲ │ + └─────────────────────────────────────────┘ + + ──> config compiles to the Model; the two view layers render it to DOM. + The arc: an interaction mutates the Sash tree, then update() re-renders. ``` -- **Model** — `Sash` (`src/sash.js`) is the single source of truth for geometry. A binary tree of regions; each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag. Resizing mutates the tree; setters cascade geometry to children. -- **View** — `Frame` (`src/frame/`) walks the sash tree and renders DOM. `BinaryWindow` extends it with glass content, the sill, and floating glasses. Rendering is one-directional: model → DOM (`glaze()` for initial render, `update()` for incremental reconcile). -- **Behavior** — features (resize, drop, fit, glass actions, detached glass) are mixed onto the prototype via `assemble()` and attached to the live DOM in `enableFeatures()`. +- **Model** — `Sash` (`src/sash.js`) is the single source of truth for geometry. A binary tree of regions; each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag. Setters cascade geometry to children; nothing else owns layout state. +- **Frame layer — core tiling** (`src/frame/`). The view layer that *mirrors the model*. It renders the window, **muntins**, and **panes**, and owns **pane resizing** — dragging a muntin mutates the sash tree, the defining feature of a tiling manager. It also handles generic drop infrastructure and fit-to-container. Frame 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: it renders a **glass** into each pane (`onPaneCreate`), and adds glass drag-and-drop rearrangement, the minimize/maximize/close/detach actions, **detached** (floating) glasses, and the **sill**. This layer is where "windows" (glasses) live; the Frame layer underneath only knows panes. + +Rendering is one-directional in both layers: model → DOM, via `glaze()` (initial render) and `update()` (incremental reconcile). An interaction in either layer mutates the Sash tree and calls `update()`; the DOM follows. Features are mixed onto each class's prototype via `assemble()` and attached to the live DOM in `enableFeatures()`. ### The `mount()` lifecycle @@ -105,7 +106,7 @@ A node with **no children** becomes a **pane**; a node **with children** becomes ConfigRoot(settings) config-root.js extends ConfigNode config-node.js └ buildSashTree({ resizeStrategy }) - ├ createSash() ─────────────▶ new Sash(...) (store = nonCoreData) + ├ createSash() ─────────────> new Sash(...) (store = nonCoreData) ├ normConfig(children[0]) → createPrimaryConfigNode ├ normConfig(children[1]) → createSecondaryConfigNode(…, primary) │ (infers opposite position / complementary size from the sibling) @@ -312,49 +313,3 @@ These are enforced project conventions (see `CLAUDE.md`): - **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:`. - **Git** — don't commit/push unless explicitly asked; no Claude co-author trailers. - ---- - -## 12. Repository map - -``` -src/ - index.js Public exports + CSS imports - sash.js Sash model: binary tree, geometry, min-size, resize strategies - position.js Position enum, opposite/zone math (getCursorPosition) - rect.js Rect helpers - utils.js genId/parseSize/createDomNode/strictAssign/swapChildNodes/throttle/… - config/ - config-root.js ConfigRoot (window-level: width/height/fitContainer/theme) - config-node.js ConfigNode: normalize forms, geometry, sibling inference, buildSashTree - sash-config.js SashConfig: pre-built Sash subtree as config - frame/ - frame.js Frame class + assemble() of base modules; mount/frame/enableFeatures - main.js createWindow/glaze/update (render & reconcile) - pane.js createPane/addPane/removePane/swapPanes (tree + DOM) - pane-utils.js pane element + addPaneSash split surgery - muntin.js muntin element create/update - resizable.js muntin-drag resize (document mouse*) - droppable.js generic native-DnD drop infra (onPaneDrop stub) - fit-container.js ResizeObserver → fit() - frame-utils.js getSashIdFromPane - binary-window/ - binary-window.js BinaryWindow extends Frame; glass on panes, sill, normActions - trim.js muntin-trim mixin (shrink ends at intersections) - glass/ - glass.js Glass component (header/tabs/actions/content) + DEFAULT_GLASS_ACTIONS - index.js glass mixin + BUILTIN_ACTIONS (deprecated alias) - action.js action wiring mixin - drag.js attached-glass native drag + real onPaneDrop - action.close/minimize/maximize/detach.js built-in actions - detached-glass/ - detached-glass.js DetachedGlass extends Glass - index.js detached mixin + DEFAULT_DETACHED_GLASS_ACTIONS - crud.js addDetachedGlass/removeDetachedGlass (cascade placement) - manager.js z-index/active-state singleton - activate/move/resize/drag/restore.js behaviors - action.attach/close/minimize.js detached actions - utils.js genStylesByPosition, resize handles - css/ vars/body/frame/glass/detached-glass/sill -dev/ Manual feature/bug test pages (scaffolding, not shipped) -``` From 1d490e7b9163714b8c269d6c15f727f3d2d19dac Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 12 Jun 2026 12:50:52 +1000 Subject: [PATCH 3/5] docs: minor update to arch doc --- docs/ARCHITECTURE.md | 52 ++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1f0ec95..82082a5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ ## 1. The window-construction metaphor -bwin borrows real window-glazing vocabulary. Matching it in code, comments, and docs is a hard project convention: +bwin borrows real window-construction vocabulary. Matching it in code, comments, and docs is a hard project convention: | Term | Meaning | Renders as | |------|---------|-----------| @@ -27,33 +27,47 @@ bwin borrows real window-glazing vocabulary. Matching it in code, comments, and bwin separates a **model** from **two stacked view layers**. The model is the single source of truth for geometry; each view layer renders the model to DOM and wires its own interactions. `BinaryWindow extends Frame`, so the layers stack rather than sit side by side. ``` - Config Model Frame BinaryWindow - (core tiling) (enhance) + Config Sash tree Frame BinaryWindow + (model) (core tiling) (enhance) ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ConfigRoot │ │ Sash tree │ │ bw-window │ │ bw-glass │ - │ ConfigNode │ ──> │ root Sash │ ──> │ bw-muntin │ ──> │ drag · drop │ - │ buildSash- │ │ + children │ │ bw-pane │ │ detached │ - │ Tree() │ │ │ │ resize · fit │ │ sill │ + │ ConfigNode │ ──> │ root Sash │ ──> │ bw-muntin │ ──> │ drag │ + │ buildSash- │ │ + children │ │ bw-pane │ │ drop │ + │ Tree() │ │ │ │ resize │ │ detached │ + │ │ │ │ │ fit │ │ sill │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ ▲ │ └─────────────────────────────────────────┘ - ──> config compiles to the Model; the two view layers render it to DOM. - The arc: an interaction mutates the Sash tree, then update() re-renders. + ──> config compiles to the sash tree; the two view layers render it to DOM. + Example flow: drag a muntin ─> update the sash tree ─> re-create the layout + (update()). Every interaction follows this shape: mutate the tree, re-render. ``` -- **Model** — `Sash` (`src/sash.js`) is the single source of truth for geometry. A binary tree of regions; each node holds `left/top/width/height`, `minWidth/minHeight`, `resizeStrategy`, and a `store` bag. Setters cascade geometry to children; nothing else owns layout state. -- **Frame layer — core tiling** (`src/frame/`). The view layer that *mirrors the model*. It renders the window, **muntins**, and **panes**, and owns **pane resizing** — dragging a muntin mutates the sash tree, the defining feature of a tiling manager. It also handles generic drop infrastructure and fit-to-container. Frame 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: it renders a **glass** into each pane (`onPaneCreate`), and adds glass drag-and-drop rearrangement, the minimize/maximize/close/detach actions, **detached** (floating) glasses, and the **sill**. This layer is where "windows" (glasses) live; the Frame layer underneath only knows panes. +**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. -Rendering is one-directional in both layers: model → DOM, via `glaze()` (initial render) and `update()` (incremental reconcile). An interaction in either layer mutates the Sash tree and calls `update()`; the DOM follows. Features are mixed onto each class's prototype via `assemble()` and attached to the live DOM in `enableFeatures()`. +**Frame layer — core tiling** (`src/frame/`) +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**. + +Rendering is one-directional in both layers: sash tree → DOM, via `glaze()` (initial render) and `update()` (incremental reconcile). An interaction in either layer mutates the sash tree and calls `update()`; the DOM follows. Features are mixed onto each class's prototype via `assemble()` and attached to the live DOM in `enableFeatures()`. ### The `mount()` lifecycle `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()`** — *behavior wiring.* Attaches event listeners for resize/drop/fit; `BinaryWindow` adds glass and detached-glass features. +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). @@ -107,8 +121,8 @@ ConfigRoot(settings) config-root.js extends ConfigNode config-node.js └ buildSashTree({ resizeStrategy }) ├ createSash() ─────────────> new Sash(...) (store = nonCoreData) - ├ normConfig(children[0]) → createPrimaryConfigNode - ├ normConfig(children[1]) → createSecondaryConfigNode(…, primary) + ├ normConfig(children[0]) ──> createPrimaryConfigNode + ├ normConfig(children[1]) ──> createSecondaryConfigNode(…, primary) │ (infers opposite position / complementary size from the sibling) └ recurse into each child's buildSashTree() ``` @@ -217,10 +231,10 @@ Because Sash IDs are stable across an operation (e.g. `removePane` promotes a si ### 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 Drop / drag-rearrange (native HTML DnD) -Dragging an attached glass and dropping it on a pane rearranges the layout. Split across three layers: +### 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 infra 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`). +- **`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`). - **`binary-window/glass/drag.js`** — arms the attached-glass drag and provides the **real `onPaneDrop`**. A native drag is one document-global gesture, so the dragged element is tracked in a **module-level** `activeDragGlassEl` (only one glass drags at a time). `mousedown` on a `bw-glass-header` (left button, `can-drag` not false) sets `draggable=true` on the glass; `dragstart` saves and disables the source pane's `can-drop` so it isn't its own target; `dragend`/`mouseup` clean up and carry `can-drop` back. - **`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`). @@ -306,7 +320,7 @@ CSS is split by concern: `vars.css` (custom properties — sizes, shadows, color These are enforced project conventions (see `CLAUDE.md`): -- **Terminology** — use the glazing metaphor (§1) precisely; don't pick a name whose well-known meaning differs from what the code does. +- **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. - **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. From 1b23915224698486007eb4d9a521aed4a89c8dca Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 12 Jun 2026 12:59:07 +1000 Subject: [PATCH 4/5] docs: add ai context docs --- CLAUDE.md | 33 ++++++------ docs/context/conventions.md | 70 ++++++++++++++++++++++++++ docs/context/react-bwin-integration.md | 31 ++++++++++++ 3 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 docs/context/conventions.md create mode 100644 docs/context/react-bwin-integration.md diff --git a/CLAUDE.md b/CLAUDE.md index 73eed14..0f6e68b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,34 +1,31 @@ # CLAUDE.md +Operational rules for working in this repo. Detailed background lives in `docs/` — read the relevant file before non-trivial work: + +- **[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** — system design: model + two view layers, the config→sash compile, the `store` bag, rendering/reconcile, interaction features, public API. +- **[`docs/context/conventions.md`](docs/context/conventions.md)** — full coding conventions (terminology, naming, comments, debug sentinels, interaction code, dev pages) with rationale. The sections below are the checklist; that file is the *why*. +- **[`docs/context/react-bwin-integration.md`](docs/context/react-bwin-integration.md)** — the downstream `react-bwin` contract; check before changing internals, `sash.store` keys, or actions defaults. + ## Git - **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)). +- **No Claude attribution trailers** — never add `Co-Authored-By: Claude` or `Generated with Claude Code` (or similar) to commit messages or PR descriptions. +- Type commits that only touch `dev/` as plain `chore:` — never `feat:`/`fix:`, no `(dev)` scope. It's test scaffolding, not library source. ## Testing - Don't run tests or builds after finishing a feature or fix unless asked. -## Terminology - -bwin borrows real window-construction terms — match their meaning in code, comments, and docs. The full glossary lives in [`docs/ARCHITECTURE.md` §1](docs/ARCHITECTURE.md#1-the-window-construction-metaphor). Use plain "glass" by default; say "attached glass" only when contrasting with detached. - -## Naming - -- 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 - -- 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:`. -- Wrap code keywords in backticks — API/method names, variable names, identifiers (e.g. `addPane`, `activeDragGlassEl`). +## Conventions (checklist) -## Debug sentinel values +Full detail + rationale in [`docs/context/conventions.md`](docs/context/conventions.md). -- 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. +- **Terminology** — use the window-construction metaphor precisely; glossary in [`docs/ARCHITECTURE.md` §1](docs/ARCHITECTURE.md#1-the-window-construction-metaphor). Plain "glass" by default; "attached glass" only when contrasting with detached. +- **Naming** — DOM-element vars get an `El` suffix with a specific noun (`activeGlassEl`, not `activeEl`); accessors named `get`; constants name their context (`MIN_RESIZE_WIDTH`, not `MIN_WIDTH`); prefer established domain terms. +- **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. +- **Interaction code** — for new pointer features prefer Pointer Events + `setPointerCapture`, delegated listeners on `windowElement`, affordance DOM created on demand, and `:scope >` child queries. (Some existing files use the older `document`+`mouse*` style; match the surrounding style when editing them.) ## Dev pages (`dev/`) diff --git a/docs/context/conventions.md b/docs/context/conventions.md new file mode 100644 index 0000000..1ac388b --- /dev/null +++ b/docs/context/conventions.md @@ -0,0 +1,70 @@ +# 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*. + +See also [`ARCHITECTURE.md`](../ARCHITECTURE.md) for the system design and [`react-bwin-integration.md`](./react-bwin-integration.md) for the downstream contract. + +--- + +## Terminology + +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". + +--- + +## 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. +- **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. + +**Why:** self-documenting names. A reader should know what a variable holds and where a constant applies without chasing its definition. + +--- + +## Comments + +- **Comment only when it adds something the code doesn't already say.** No restating the obvious. +- **Keep comments terse: ≤2 lines, ≤100 chars per line.** If one genuinely must run longer, prefix it with `RATIONAL:` so the length is clearly deliberate. +- **Wrap code keywords in backticks** — API/method names, variable names, identifiers (e.g. `` `addPane` ``, `` `activeDragGlassEl` ``). + +**Why:** terse, high-signal comments. Long unexplained comment blocks read as noise; the `RATIONAL:` prefix marks the rare case where the prose really is load-bearing. + +--- + +## Debug sentinel values + +Repeating-digit literals like `222` and `333` in default/fallback paths are **intentional debug sentinels, not magic numbers**. They mark a guard that should have supplied a real value. + +- **Don't rename them to constants or "tidy" them away.** +- The real default is applied upstream by a guard; the sentinel sits in the constructor/fallback as a tripwire. Example: `addDetachedGlass` applies the real default size (`200`), while `DetachedGlass`'s constructor falls back to `222`. A `222`-sized glass on screen means something constructed `DetachedGlass` directly, **bypassing the guard** — that's the bug to chase, not the literal. +- Likewise `ConfigRoot` defaults `width/height` to `333`; seeing `333` downstream means a real dimension never reached it. + +**Why:** they look like magic numbers but are diagnostic markers. If one surfaces in a lower-level API or the rendered output, investigate the bypassed guard — don't replace the sentinel. + +--- + +## Interaction code (drag / resize / pointer features) + +Preferred patterns for **new** pointer-driven interaction features: + +- **Pointer Events + `setPointerCapture`** over the older `document`-bound `mousemove`/`mouseup` pattern. One code path covers mouse/touch/pen, capture keeps move events flowing when the pointer leaves the target, and capture self-releases. +- **Delegated listeners on `windowElement`**, not per-element — keeps the listener count constant regardless of how many glasses/panes exist. +- **Create interaction DOM (e.g. resize handles) on demand** — on hover via `pointerover`/`pointerout`, not eagerly in a constructor — so idle elements cost zero extra nodes. +- **Scope child queries with `:scope > selector`** so handles/affordances aren't confused with arbitrary user content nested in `bw-glass-content`. + +**Why:** correctness (not losing the pointer mid-drag) and performance (listener count, node count when many elements exist) are weighed deliberately. + +**Note — existing code diverges:** `frame/resizable.js` and the attached-glass `binary-window/glass/drag.js` still use the older `document` + `mouse*` style. The detached-glass `move.js`/`resize.js` use the modern Pointer Events pattern. Follow the modern pattern for new features; match the surrounding style when editing existing files. + +--- + +## Dev pages (`dev/`) + +`dev/` is **test scaffolding** for manually exercising features and bugs — not shippable library source. + +- **Interactive testing items (buttons, inputs, forms, selects, etc.) go in the `.html` file**, not the `.js`. The paired `.js` queries them with `document.querySelector(...)` and wires behavior with `addEventListener`. Canonical example: `dev/features/add-remove-pane.html` / `add-remove-pane.js`. +- **Commits that only touch `dev/` are plain `chore:`** — never `feat:`/`fix:`, no `(dev)` scope. It's scaffolding, not library source. diff --git a/docs/context/react-bwin-integration.md b/docs/context/react-bwin-integration.md new file mode 100644 index 0000000..bd6a6de --- /dev/null +++ b/docs/context/react-bwin-integration.md @@ -0,0 +1,31 @@ +# Downstream contract — `react-bwin` + +`react-bwin` (`../react-bwin`) is the React wrapper that depends on `bwin`. It exposes a `` component using a `panes` prop instead of `children`, and forwards an imperative handle (`fit()`, `addPane()`, `removePane()`) that delegates to the bwin instance. + +Because **React must own DOM creation** but bwin's `mount()` creates DOM itself, the wrapper bypasses bwin's normal API and reaches into internals. Each coupling point below is a place where changing bwin's internal structure will break react-bwin — and a candidate for public API that would let the wrapper stop depending on private structure. + +> Keep this in mind when refactoring `Frame`/`BinaryWindow` internals, the `sash.store` bag, or the actions defaults. A summary lives in [`ARCHITECTURE.md` §9](../ARCHITECTURE.md#9-public-api-surface-srcindexjs); this file is the detailed list. + +--- + +## Coupling points + +- **Skips `mount()`** — manually sets `bwin.windowElement`, `bwin.containerElement`, `bwin.sillElement`, then calls `bwin.enableFeatures()` directly. Depends on the exact internal split between `frame()` (DOM creation) and `enableFeatures()` (interaction wiring). + - *Would be removed by:* a "bring-your-own-DOM" / hydrate mode that attaches features to existing elements. +- **Walks the sash tree** — `bwin.rootSash.walk(sash => ...)` using `sash.children`, `sash.leftChild`, `sash.topChild` to decide pane vs muntin and render them itself. + - *Would be removed by:* a public traversal API or a serializable layout descriptor. +- **Mutates `sash.domNode`** — sets it from React refs after render, since React (not bwin) creates the nodes. +- **Reads `sash.store.*`** — `actions`, `title`, `content`, `droppable`, `draggable`, `resizable`. The `store` "non-core props" bag is effectively **the integration contract** (see [`ARCHITECTURE.md` §6](../ARCHITECTURE.md#6-the-store-bag-non-core-props-sashstore)). +- **`addPane` content workaround** — bwin's `addPane` expects DOM-node content; React strips `content`, calls `addPane`, then queries the rendered `bw-pane[sash-id="…"] bw-glass-content` and `createPortal`s the ReactNode into it. Couples to bwin's internal tag names. + - *Would be removed by:* an API to get the content container for a sash, or a content-slot hook. +- **Re-implements the actions default logic inline** — `actions === undefined ? DEFAULT_GLASS_ACTIONS : Array.isArray ? ... : []` — because it renders panes without bwin's `Glass`. It imports `DEFAULT_GLASS_ACTIONS` (historically `BUILTIN_ACTIONS`) from bwin directly. **This duplicated logic must be kept in sync by hand** — the `undefined`-vs-`null` contract in [`ARCHITECTURE.md` §6](../ARCHITECTURE.md#6-the-store-bag-non-core-props-sashstore) is load-bearing here. +- **Duplicates muntin geometry** — `Muntin.tsx` recomputes divider geometry (`MUNTIN_SIZE = 4`, left/top from `leftChild.width`/`topChild.height`) that bwin also computes internally. +- **Hand-maintains TypeScript types** — ships `src/bwin.d.ts` shimming `declare module 'bwin'` because **bwin publishes no TypeScript types** (no `types` field in `package.json`). + - *Would be removed by:* shipping `.d.ts` from bwin. + +--- + +## Practical implications + +- **Version drift is expected** — react-bwin's `package.json` may pin an older `bwin` than the current core version; the wrapper can lag. +- **Before renaming/removing any of:** `windowElement` · `containerElement` · `sillElement` · `rootSash` · `enableFeatures()` · `frame()` · sash tree shape (`children`/`leftChild`/`topChild`/`domNode`) · `sash.store` keys · `DEFAULT_GLASS_ACTIONS` · the `bw-pane`/`bw-glass-content` tag names · `MUNTIN_SIZE` (4px) — check react-bwin and update it in lockstep, or land a public-API replacement first. From 88ddaa8cd736c044e0d8d10552d63ec6470b6849 Mon Sep 17 00:00:00 2001 From: Oh Xyz Date: Fri, 12 Jun 2026 13:53:24 +1000 Subject: [PATCH 5/5] docs: add tech-debt register Capture durable design flaws and known compromises in docs/TECH_DEBT.md (scoped distinct from GitHub issues, which track bugs/features). Link it from CLAUDE.md's docs pointer block. --- CLAUDE.md | 1 + docs/TECH_DEBT.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/TECH_DEBT.md diff --git a/CLAUDE.md b/CLAUDE.md index 0f6e68b..997df6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ Operational rules for working in this repo. Detailed background lives in `docs/` - **[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** — system design: model + two view layers, the config→sash compile, the `store` bag, rendering/reconcile, interaction features, public API. - **[`docs/context/conventions.md`](docs/context/conventions.md)** — full coding conventions (terminology, naming, comments, debug sentinels, interaction code, dev pages) with rationale. The sections below are the checklist; that file is the *why*. - **[`docs/context/react-bwin-integration.md`](docs/context/react-bwin-integration.md)** — the downstream `react-bwin` contract; check before changing internals, `sash.store` keys, or actions defaults. +- **[`docs/TECH_DEBT.md`](docs/TECH_DEBT.md)** — known design flaws and compromises. Check before reworking an area; update it when you take on or pay down debt. (Bugs/features → GitHub issues.) ## Git diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md new file mode 100644 index 0000000..efe238d --- /dev/null +++ b/docs/TECH_DEBT.md @@ -0,0 +1,97 @@ +# Tech debt + +Durable design flaws, known compromises, and structural issues worth tracking — the kind of thing that outlives a single bug. **Bugs and feature requests are tracked on the [GitHub issues page](https://github.com/bhjsdev/bwin/issues), not here.** Some entries below are the *root cause* behind one or more issues; link them when known. + +This complements [`ARCHITECTURE.md`](./ARCHITECTURE.md) (how things work) and [`context/`](./context/) (conventions, downstream contract). When you fix an item, delete it here; when you take on a new compromise knowingly, add it. + +| Severity | Meaning | +|----------|---------| +| high | Causes bugs, blocks a planned feature, or actively misleads contributors | +| medium | Friction or fragility; will bite during the next related change | +| low | Cosmetic / cleanup; safe to defer indefinitely | + +--- + +## [high] `sash.store` is not yet a pure one-way seed + +- **Where:** `src/sash.js` (the `TECH DEBT` comment on `this.store`), `src/frame/droppable.js`, `src/frame/muntin.js` +- **What:** `store` is *intended* as a one-way seed — it carries non-core props (`content`, `title`, `actions`, …) from the top-level API into pane/glass construction, then should stop being consulted. Moves like attach/detach/swap are meant to exchange state through the DOM (visible, debuggable), not through `store`. But two keys are still read long after construction: + - `onDrop` — read at drop time in `droppable.js` + - `resizable` — read when a pane is split in `muntin.js` +- **Impact:** `store` can't be cleared in `onPaneCreate` without breaking those two paths, so the "seed" invariant is leaky and the lifecycle is harder to reason about. It's also half of the [react-bwin integration contract](./context/react-bwin-integration.md), so the leak propagates downstream. +- **Fix direction:** move `onDrop`/`resizable` onto the DOM (attributes) or closures at build time, then clear `store` after `onPaneCreate`. See [`ARCHITECTURE.md` §6](./ARCHITECTURE.md#6-the-store-bag-non-core-props-sashstore). + +--- + +## [medium] Two coexisting interaction patterns (legacy `mouse*` vs Pointer Events) + +- **Where:** `src/frame/resizable.js` and `src/binary-window/glass/drag.js` (legacy) vs `src/binary-window/detached-glass/move.js`/`resize.js` (modern) +- **What:** Muntin resize and attached-glass drag use document-bound `mousedown`/`mousemove`/`mouseup`. Detached-glass move/resize use the preferred pattern: Pointer Events + `setPointerCapture`, delegated listeners on `windowElement`, affordance DOM created on demand. +- **Impact:** Two mental models for the same kind of feature. The legacy path is mouse-only (no clean touch/pen), can lose the pointer mid-drag, and binds document-global listeners. New contributors must know which file follows which convention. +- **Fix direction:** migrate `resizable.js` and `glass/drag.js` to the modern pattern documented in [`context/conventions.md`](./context/conventions.md#interaction-code-drag--resize--pointer-features). Note `glass/drag.js` also relies on the **native HTML DnD** API specifically (for cross-pane drop), so that migration is more involved than resize. + +--- + +## [medium] Drop infrastructure lives in `frame/` but is really a `binary-window` concern + +- **Where:** `src/frame/droppable.js` (`TODO` at top of file) +- **What:** `droppable.js` provides the generic drop scaffolding (`dragover`/`dragleave`/`drop`, `onPaneDrop` stub) in the **Frame layer**, but the only real consumer is the attached-glass drag in the **BinaryWindow layer**, which overrides `onPaneDrop`. The Frame layer has no drop behavior of its own. +- **Impact:** A feature that only makes sense with glasses sits in the layer that doesn't know about glasses — blurring the [Frame/BinaryWindow split](./ARCHITECTURE.md#2-the-big-picture-model--two-view-layers). +- **Fix direction:** consider moving the drop infra into `binary-window/`, next to `glass/drag.js`. + +--- + +## [medium] `addPane` seeds an empty placeholder glass (drop must `replaceChildren`) + +- **Where:** `src/binary-window/binary-window.js` (`addPane`), `src/binary-window/glass/drag.js` (`onPaneDrop`) +- **What:** `BinaryWindow.addPane` always creates a new pane pre-seeded with its own empty placeholder `Glass`. Callers that want to place an existing glass into the new pane must `replaceChildren(...)`, **not** `append(...)` — an append leaves two glasses (empty placeholder first), and a later detach's `querySelector('bw-glass-content')` then grabs the empty one, producing a blank detached glass. +- **Impact:** A non-obvious sequencing trap; this exact mistake has produced "blank glass" bugs (the kind of root cause that may underlie a GitHub issue). The placeholder is wasted work whenever the caller is about to replace it. +- **Fix direction:** let `addPane` optionally skip seeding the placeholder (e.g. accept a glass/content to mount, or a `seedGlass: false` flag), so the drop path doesn't create-then-discard a glass. + +--- + +## [medium] `react-bwin` depends on bwin internals (no stable public surface) + +- **Where:** cross-repo — `../react-bwin`; documented in [`context/react-bwin-integration.md`](./context/react-bwin-integration.md) +- **What:** The React wrapper bypasses `mount()`, walks the sash tree, mutates `sash.domNode`, reads `sash.store.*`, re-implements the actions `undefined`-vs-`null` logic, and duplicates muntin geometry (`MUNTIN_SIZE = 4`) — because bwin exposes no "bring-your-own-DOM" mode, no traversal/layout API, and no content-slot hook. +- **Impact:** Renaming or reshaping internals silently breaks react-bwin; duplicated logic (actions defaults, muntin math) must be hand-synced and drifts. +- **Fix direction:** formalize the integration points into public API (hydrate mode, layout descriptor, content-container accessor). See the per-point fix suggestions in the integration doc. + +--- + +## [low] bwin publishes no TypeScript types + +- **Where:** `package.json` (no `types` field); downstream `react-bwin` ships a hand-written `src/bwin.d.ts` shim +- **What:** No `.d.ts` is generated or shipped, so every TS consumer hand-maintains a `declare module 'bwin'` shim. +- **Impact:** Downstream type drift; no editor intellisense for library consumers. +- **Fix direction:** generate and ship `.d.ts` (add a `types` export); removes the downstream shim. + +--- + +## [low] Deprecated `BUILTIN_ACTIONS` export still present + +- **Where:** `src/index.js`, `src/binary-window/glass/index.js` (`@deprecated` comments) +- **What:** `BUILTIN_ACTIONS` is kept as a backwards-compat alias. Its value also *differs* from the current default (`DEFAULT_GLASS_ACTIONS`) — the old aggregate included `maximize`. +- **Impact:** Two names for "the default actions" with subtly different contents; a foot-gun for anyone who picks the deprecated one. +- **Fix direction:** remove on the next major version once downstreams (incl. react-bwin) have migrated to `DEFAULT_GLASS_ACTIONS`. + +--- + +## [low] Detached-glass repositioning has a disabled alternate path + +- **Where:** `src/binary-window/detached-glass/index.js` (`enableDetachedGlassFeatures`), `drag.js` +- **What:** Two repositioning implementations exist — `move.js` (pointer-based, free-floating; **enabled**) and `drag.js` (native DnD, docks to panes; **disabled** via a commented-out call). The comment says "enable exactly one." +- **Impact:** Dormant code that isn't exercised; intent ("which wins, and is docking a real feature?") isn't resolved. +- **Fix direction:** decide whether dock-on-drag is a wanted feature. If not, delete `drag.js`; if yes, design how it coexists with `move.js`. + +--- + +## [low] Unresolved design questions left as `@think-about` / `@todo` + +Open questions parked in code comments — not yet decided: + +- **`src/sash.js`** — what should happen when `minWidth`/`minHeight` is set larger than the sash's own width/height? (currently undefined behavior) +- **`src/frame/frame.js`** — should the frame resize immediately when `fitContainer` toggles? (a `fit()` method now exists, so this is partly addressed) +- **`src/frame/pane-utils.js`** — `addPaneSash` ignores `minWidth`/`minHeight` (and other Sash props); a pane added at runtime can't carry min-size constraints. + +**Impact:** edge-case behavior is unspecified; `addPaneSash`'s gap means runtime-added panes differ from config-built ones. **Fix direction:** decide the min-vs-own-size rule and thread the remaining Sash props through `addPaneSash`.