Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ Floating `<bw-glass detached>` panels that mimic OS windows, appended directly t
| `action.attach.js` / `action.close.js` / `action.minimize.js` | the detached action set. |
| `utils.js` | `genStylesByPosition`, resize-handle creation, `removeGlassBackdrop`, `getContainingBlockOrigin`. |

`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`.
`DEFAULT_DETACHED_GLASS_ACTIONS = [minimize, attach, close]`. **minimize** (`action.minimize.js`) stashes the glass on a sill pot (`bwDetachedGlassElement`), then plays a FLIP-style flight — `animateElementToElement` (`src/animate.js`) shrinks/flies the glass onto its pot before deferring `display:none` into the animation's `onDone`.

**Standalone vs. per-instance features.** `activate`/`move`/`resize` attach **document-global, instance-independent** listeners (they find their target via `closest('bw-glass[detached]')` and never read `this`). They're installed by `enableDetachedGlassStandaloneFeatures()`, called **once at module load** in `binary-window.js` (module evaluation is one-time, so no idempotency flag is needed). This is what makes the static `addWindowlessGlass` path work with **no mounted window** — importing `BinaryWindow` is enough to wire move/resize/activate. Un-potting (restore from the sill) is **not** here: it's a per-instance sill feature wired by `enableSillFeatures()` (see `sill.js`), which needs `this.sillElement` (windowless glasses have no sill). _Historical note:_ an earlier `drag.js` offered native-DnD repositioning (docked to panes) as an alternative to `move.js`; it was removed in favor of free-floating `move.js`.

Expand Down
20 changes: 3 additions & 17 deletions docs/context/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,26 +72,12 @@ Preferred patterns for **new** pointer-driven interaction features:
## Animations (enter / exit)

- **Enter animations are plain CSS** — an `animation:` on the element's base selector fires once when it's inserted (or un-hidden). No JS needed. Example: `bw-glass[detached] { animation: bw-detached-glass-open 0.18s ease-out; }`.
- **Exit animations need a JS dance** — CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`). The pattern: set a **`[closing]` attribute** the CSS keys the exit animation off, then **defer `.remove()` with a `setTimeout` matching the CSS duration**. Hold the duration in a named constant next to the helper and keep it in sync with the stylesheet by hand.

```js
export const DETACHED_GLASS_CLOSE_DURATION = 180; // keep in sync with the 0.18s in CSS
export function removeDetachedGlassElement(el, timeout = DETACHED_GLASS_CLOSE_DURATION) {
el.setAttribute('closing', '');
setTimeout(() => {
el.remove();
removeGlassBackdrop(el.id);
}, timeout);
}
```

Canonical use: `binary-window/detached-glass/utils.js`. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation.

- **Prefer the timeout over `animationend`** for exit removal — `animationend` bubbles from descendants (a child popover/menu animating), so it needs `event.target`/`animationName` guards plus listener cleanup; the flat timeout is simpler and its constant doubles as documentation.
- **Exit animations need a JS dance** — CSS can't animate a _normal_ element out of the DOM (only popover/dialog get `transition-behavior: allow-discrete`). The pattern: set a **`[closing]` attribute** the CSS keys the exit animation off, then **defer `.remove()` until `animationend`** (`{ once: true }`, so the listener cleans itself up). Keep the duration only in the stylesheet — no JS-side constant to drift. A boolean opt-out removes immediately, skipping the animation. Canonical use: `removeDetachedGlassElement` in `binary-window/detached-glass/utils.js`. Add `pointer-events: none` to the `[closing]` rule so the dying element can't be re-clicked mid-animation. The mirror image is `animateDetachedGlassOpen`: set `[opening]`, clear it on `animationend` so the enter animation can re-run on the next restore from the sill.
- **For genuinely discrete elements (popover/dialog), use the platform** instead of the `[closing]` dance — `@starting-style` for the enter state and `transition-behavior: allow-discrete` on `display`/`overlay` for the exit. Example: the `bw-action-menu` popover in `glass.action.css`.
- **Run-time geometry (one element flying onto another) is WAAPI, not CSS** — when the start/end transforms depend on _measured_ rects (you can't know them at authoring time), use `element.animate(...)` and compute the keyframes from `getBoundingClientRect()`. The shared home is `animateElementToElement(sourceEl, targetEl, onDone)` in `src/animate.js`: a FLIP-style flight that translate/scales `sourceEl` onto `targetEl` with `transform-origin: top left`, fades it out, disables `pointer-events` during the flight, clears the inline styles on finish, then runs `onDone` (e.g. to hide/remove the source). Both elements must be laid out (not `display:none`) so their rects measure. Canonical use: `detached-glass/action.minimize.js` defers `display:none` into `onDone` so the glass shrinks into its sill pot before hiding.
- **Animate only `transform`/`opacity`** for enter/exit so the animation never fights features that set `top`/`left`/`width`/`height` (drag/resize write those directly).

**Why:** keep the simple case simple (CSS-only enter), and make the unavoidable JS for exit a single predictable shape rather than ad-hoc per feature.
**Why:** keep the simple case simple (CSS-only enter), and make the unavoidable JS for exit a single predictable shape rather than ad-hoc per feature. Reach for WAAPI only when the geometry can't be known until run time, and route those through the one shared helper rather than re-deriving the FLIP math per feature.

---

Expand Down
40 changes: 40 additions & 0 deletions src/animate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// FLIP-style flight: shrink and fly `sourceEl` onto `targetEl`, then fade out.
// Both must be laid out (in the DOM, not `display:none`) so their rects measure.
// Runs `onDone` when the flight ends (e.g. to hide/remove the source).
export function animateElementToElement(sourceEl, targetEl, onDone) {
const SHRINK_FLIGHT_DURATION = 200;

const sourceRect = sourceEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();

const scaleX = targetRect.width / sourceRect.width;
const scaleY = targetRect.height / sourceRect.height;
const translateX = targetRect.left - sourceRect.left;
const translateY = targetRect.top - sourceRect.top;

sourceEl.style.pointerEvents = 'none';

const animation = sourceEl.animate(
[
{ transform: 'translate(0, 0) scale(1)', opacity: 1 },
{
transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`,
opacity: 0,
},
],
{ duration: SHRINK_FLIGHT_DURATION, easing: 'ease-in' }
);

// top-left origin so the translate/scale maps the source corner onto the target corner
sourceEl.style.transformOrigin = 'top left';

animation.addEventListener(
'finish',
() => {
sourceEl.style.pointerEvents = '';
sourceEl.style.transformOrigin = '';
onDone?.();
},
{ once: true }
);
}
5 changes: 4 additions & 1 deletion src/binary-window/detached-glass/action.minimize.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createDomNode } from '@/utils';
import { animateElementToElement } from '@/animate';

export default {
type: 'detached-glass-builtin',
Expand All @@ -17,6 +18,8 @@ export default {
throw new Error(`[bwin] Detached Glass element not found when minimizing`);

potEl.bwDetachedGlassElement = detachedGlassEl;
detachedGlassEl.style.display = 'none';
animateElementToElement(detachedGlassEl, potEl, () => {
detachedGlassEl.style.display = 'none';
});
},
};