Presentation: rotated frame breaks spotlight alignment #108

Open
opened 2026-04-29 13:18:26 +00:00 by AhmedHanafy725 · 3 comments
Member

Summary

Presenting a rotated frame is broken: the spotlight cutout is an axis-aligned rectangle that doesn't match the frame outline, and the fit-to-viewport math also ignores rotation, so the spotlight sits in the wrong place. Content outside the rotated frame leaks through the spotlight backdrop.

Steps to reproduce

  1. Add a frame to the board and rotate it (any non-zero rotation).
  2. Start presentation on that frame.

Expected

The spotlight cutout matches the rotated frame outline; only the frame contents are visible inside it; everything else is masked by the dark backdrop.

Actual

The spotlight is an axis-aligned rectangle that doesn't align with the rotated frame. Other board content (kanban, calendar, drawings) leaks through the corners of the AABB.

![image](/attachments/Screenshot from 2026-04-29 16-15-29.png)

Notes

  • Root cause is in frames.js::focusFrame: it uses bg.width()/bg.height() directly for the screen rect and fit math, without accounting for frame.rotation().
  • Fix should also rotate the #pres-spotlight div via CSS transform so the cutout matches the rotated frame.
  • Tradeoff: rotated frames won't perfectly fill the viewport — the AABB will leave canvas-background triangles at the corners. Counter-rotating the stage would fix that but risks breaking hit testing and webframe overlay positioning, so out of scope.
## Summary Presenting a rotated frame is broken: the spotlight cutout is an axis-aligned rectangle that doesn't match the frame outline, and the fit-to-viewport math also ignores rotation, so the spotlight sits in the wrong place. Content outside the rotated frame leaks through the spotlight backdrop. ## Steps to reproduce 1. Add a frame to the board and rotate it (any non-zero rotation). 2. Start presentation on that frame. ## Expected The spotlight cutout matches the rotated frame outline; only the frame contents are visible inside it; everything else is masked by the dark backdrop. ## Actual The spotlight is an axis-aligned rectangle that doesn't align with the rotated frame. Other board content (kanban, calendar, drawings) leaks through the corners of the AABB. ![image](/attachments/Screenshot from 2026-04-29 16-15-29.png) ## Notes - Root cause is in `frames.js::focusFrame`: it uses `bg.width()/bg.height()` directly for the screen rect and fit math, without accounting for `frame.rotation()`. - Fix should also rotate the `#pres-spotlight` div via CSS `transform` so the cutout matches the rotated frame. - Tradeoff: rotated frames won't perfectly fill the viewport — the AABB will leave canvas-background triangles at the corners. Counter-rotating the stage would fix that but risks breaking hit testing and webframe overlay positioning, so out of scope.
Author
Member

Implementation Spec for Issue #108

Objective

Make presentation respect frame rotation: the spotlight cutout must align with the rotated frame outline, and the fit-to-viewport math must use the rotated frame's bounding box, not its un-rotated dimensions.

Requirements

  • Rotated frames present without leaking other board content through the spotlight backdrop.
  • Frame contents inside the rotated frame are visible; everything else is masked.
  • Non-rotated frames behave exactly as before (no regression).
  • Two parts: frames.js carries rotation in the screen-rect payload, and the two templates apply CSS transform: rotate(...) to #pres-spotlight.

Files to Modify

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.jsfocusFrame uses getClientRect({ relativeTo: stage }) for the AABB-based fit and stores rotation on _focusedScreenRect.
  • crates/hero_whiteboard_ui/templates/web/board.htmlpositionSpotlight applies CSS rotation.
  • crates/hero_whiteboard_ui/templates/web/board_view.html — same.

Implementation Plan

Step 1: rotation-aware focusFrame

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js

Replace the manual bg.width()/bg.height()-based fit with a getClientRect({ relativeTo: stage }) AABB fit. The frame's pivot in screen pixels is still (stagePos.x + frame.x() * scale, stagePos.y + frame.y() * scale) because Konva groups rotate around their local origin (0,0), which sits at the group's (x, y) in stage coords.

Pseudocode:

function focusFrame(frame) {
    var stage = WhiteboardCanvas.getStage();
    var bg = frame.findOne('.bg');
    if (!bg) return;
    var rotation = frame.rotation() || 0;

    var aabb = frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage });
    var newScale = Math.min(stage.width() / aabb.width, stage.height() / aabb.height);
    WhiteboardCanvas.setZoom(newScale);
    var stagePos = {
        x: -aabb.x * newScale + (stage.width() - aabb.width * newScale) / 2,
        y: -aabb.y * newScale + (stage.height() - aabb.height * newScale) / 2,
    };
    stage.position(stagePos);
    WhiteboardCanvas.drawGrid();

    var stageBox = stage.container().getBoundingClientRect();
    _focusedScreenRect = {
        left: stageBox.left + stagePos.x + frame.x() * newScale,
        top: stageBox.top + stagePos.y + frame.y() * newScale,
        width: bg.width() * newScale,
        height: bg.height() * newScale,
        rotation: rotation,
    };
    if (presentationMode) _applyWebframePresentationVisibility(frame);
}

Notes:

  • aabb already accounts for the group's rotation (Konva applies the transform when computing getClientRect with relativeTo).
  • _focusedScreenRect.width/height stay as the un-rotated frame size in screen pixels — that's the size of the spotlight cutout before CSS rotation.
  • _focusedScreenRect.left/top is the rotation pivot in screen pixels; matches the un-rotated top-left when rotation === 0, so the existing template code keeps working.
  • Add rotation: rotation to the returned _emit() payload (which already passes screenRect through).

Dependencies: none.

Step 2: rotate the spotlight div

Files: crates/hero_whiteboard_ui/templates/web/board.html, crates/hero_whiteboard_ui/templates/web/board_view.html

Update positionSpotlight(rect) in both templates:

function positionSpotlight(rect) {
    if (!rect) return;
    spotlight.style.left = rect.left + 'px';
    spotlight.style.top = rect.top + 'px';
    spotlight.style.width = rect.width + 'px';
    spotlight.style.height = rect.height + 'px';
    spotlight.style.transformOrigin = '0 0';
    spotlight.style.transform = rect.rotation ? 'rotate(' + rect.rotation + 'deg)' : 'none';
}

The existing CSS keeps box-shadow: 0 0 0 9999px rgba(0,0,0,0.92) — after rotating the spotlight box around its top-left corner, the 9999px uniform shadow still covers the whole viewport, and the (now rotated) inner cutout matches the frame outline.

Dependencies: Step 1.

Acceptance Criteria

  • Rotate a frame ~30°, present it: the spotlight cutout aligns with the frame edges; no other board content leaks through.
  • Rotate a frame 90°, 180°, 270°: spotlight still aligns; frame still fits viewport.
  • Non-rotated frame: unchanged.
  • Multi-window: presenter and audience both see the rotated spotlight cutout (both templates updated).
  • cargo fmt, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --lib clean.

Notes

  • Counter-rotation out of scope. Counter-rotating the stage so the rotated frame appears axis-aligned would let it perfectly fill the viewport, but that risks breaking hit testing, cursor positioning, webframe overlay positioning, and other widgets. The accepted tradeoff is small canvas-background triangles in the corners where the rotated frame doesn't touch the AABB.
  • Konva pivot: groups rotate around the group's local (0,0), which is the group's (x, y) in stage coords. The screen-pixel pivot is (stagePos.x + frame.x() * scale, stagePos.y + frame.y() * scale). Setting transform-origin: 0 0 and CSS rotate(R deg) on the spotlight div with that left/top makes the cutout rotate around the same pivot.
  • Box-shadow under rotation: 0 0 0 9999px is uniform spread with no offset, so it remains a screen-filling backdrop after rotation.
## Implementation Spec for Issue #108 ### Objective Make presentation respect frame rotation: the spotlight cutout must align with the rotated frame outline, and the fit-to-viewport math must use the rotated frame's bounding box, not its un-rotated dimensions. ### Requirements - Rotated frames present without leaking other board content through the spotlight backdrop. - Frame contents inside the rotated frame are visible; everything else is masked. - Non-rotated frames behave exactly as before (no regression). - Two parts: `frames.js` carries rotation in the screen-rect payload, and the two templates apply CSS `transform: rotate(...)` to `#pres-spotlight`. ### Files to Modify - `crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js` — `focusFrame` uses `getClientRect({ relativeTo: stage })` for the AABB-based fit and stores rotation on `_focusedScreenRect`. - `crates/hero_whiteboard_ui/templates/web/board.html` — `positionSpotlight` applies CSS rotation. - `crates/hero_whiteboard_ui/templates/web/board_view.html` — same. ### Implementation Plan #### Step 1: rotation-aware focusFrame File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js` Replace the manual `bg.width()/bg.height()`-based fit with a `getClientRect({ relativeTo: stage })` AABB fit. The frame's pivot in screen pixels is still `(stagePos.x + frame.x() * scale, stagePos.y + frame.y() * scale)` because Konva groups rotate around their local origin (0,0), which sits at the group's `(x, y)` in stage coords. Pseudocode: ```js function focusFrame(frame) { var stage = WhiteboardCanvas.getStage(); var bg = frame.findOne('.bg'); if (!bg) return; var rotation = frame.rotation() || 0; var aabb = frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage }); var newScale = Math.min(stage.width() / aabb.width, stage.height() / aabb.height); WhiteboardCanvas.setZoom(newScale); var stagePos = { x: -aabb.x * newScale + (stage.width() - aabb.width * newScale) / 2, y: -aabb.y * newScale + (stage.height() - aabb.height * newScale) / 2, }; stage.position(stagePos); WhiteboardCanvas.drawGrid(); var stageBox = stage.container().getBoundingClientRect(); _focusedScreenRect = { left: stageBox.left + stagePos.x + frame.x() * newScale, top: stageBox.top + stagePos.y + frame.y() * newScale, width: bg.width() * newScale, height: bg.height() * newScale, rotation: rotation, }; if (presentationMode) _applyWebframePresentationVisibility(frame); } ``` Notes: - `aabb` already accounts for the group's rotation (Konva applies the transform when computing `getClientRect` with `relativeTo`). - `_focusedScreenRect.width/height` stay as the un-rotated frame size in screen pixels — that's the size of the spotlight cutout *before* CSS rotation. - `_focusedScreenRect.left/top` is the rotation pivot in screen pixels; matches the un-rotated top-left when `rotation === 0`, so the existing template code keeps working. - Add `rotation: rotation` to the returned `_emit()` payload (which already passes `screenRect` through). Dependencies: none. #### Step 2: rotate the spotlight div Files: `crates/hero_whiteboard_ui/templates/web/board.html`, `crates/hero_whiteboard_ui/templates/web/board_view.html` Update `positionSpotlight(rect)` in both templates: ```js function positionSpotlight(rect) { if (!rect) return; spotlight.style.left = rect.left + 'px'; spotlight.style.top = rect.top + 'px'; spotlight.style.width = rect.width + 'px'; spotlight.style.height = rect.height + 'px'; spotlight.style.transformOrigin = '0 0'; spotlight.style.transform = rect.rotation ? 'rotate(' + rect.rotation + 'deg)' : 'none'; } ``` The existing CSS keeps `box-shadow: 0 0 0 9999px rgba(0,0,0,0.92)` — after rotating the spotlight box around its top-left corner, the 9999px uniform shadow still covers the whole viewport, and the (now rotated) inner cutout matches the frame outline. Dependencies: Step 1. ### Acceptance Criteria - [ ] Rotate a frame ~30°, present it: the spotlight cutout aligns with the frame edges; no other board content leaks through. - [ ] Rotate a frame 90°, 180°, 270°: spotlight still aligns; frame still fits viewport. - [ ] Non-rotated frame: unchanged. - [ ] Multi-window: presenter and audience both see the rotated spotlight cutout (both templates updated). - [ ] `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace --lib` clean. ### Notes - **Counter-rotation out of scope.** Counter-rotating the stage so the rotated frame appears axis-aligned would let it perfectly fill the viewport, but that risks breaking hit testing, cursor positioning, webframe overlay positioning, and other widgets. The accepted tradeoff is small canvas-background triangles in the corners where the rotated frame doesn't touch the AABB. - **Konva pivot:** groups rotate around the group's local (0,0), which is the group's `(x, y)` in stage coords. The screen-pixel pivot is `(stagePos.x + frame.x() * scale, stagePos.y + frame.y() * scale)`. Setting `transform-origin: 0 0` and CSS `rotate(R deg)` on the spotlight div with that left/top makes the cutout rotate around the same pivot. - **Box-shadow under rotation:** `0 0 0 9999px` is uniform spread with no offset, so it remains a screen-filling backdrop after rotation.
Author
Member

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo check -p hero_whiteboard_ui — clean
  • cargo test --workspace --lib — 0 failed
  • node --check frames.js — clean
## Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo check -p hero_whiteboard_ui` — clean - `cargo test --workspace --lib` — 0 failed - `node --check frames.js` — clean
Author
Member

Implementation Summary

Make presentation respect frame rotation: spotlight cutout aligns with the rotated frame, fit-to-viewport uses the rotated AABB.

crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js

focusFrame now uses frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage }) for the AABB-based fit. This already accounts for the group's rotation, so the math is identical to the previous code when rotation is zero. The screen rect emitted to the host template gains a rotation field — the un-rotated cutout size in pixels stays the same; the host applies CSS rotation.

var aabb = frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage });
var newScale = Math.min(stage.width() / aabb.width, stage.height() / aabb.height);
// stagePos centers the AABB in the viewport
_focusedScreenRect = {
    left: stageBox.left + stagePos.x + frame.x() * newScale,  // pivot
    top:  stageBox.top  + stagePos.y + frame.y() * newScale,  // pivot
    width:  bg.width()  * newScale,
    height: bg.height() * newScale,
    rotation: rotation,
};

crates/hero_whiteboard_ui/templates/web/board.html and board_view.html

positionSpotlight now applies transform: rotate(<deg>) with transform-origin: 0 0. Because Konva groups rotate around their local (0,0) — which is the group's (x, y) in stage coords — setting the spotlight's left/top to the un-rotated top-left in screen pixels and rotating around that pivot makes the cutout align with the rotated frame. The box-shadow: 0 0 0 9999px rgba(0,0,0,0.92) is a uniform spread with no offset, so the backdrop still fills the screen after rotation.

Files Changed

  • crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js+15/-13
  • crates/hero_whiteboard_ui/templates/web/board.html+2
  • crates/hero_whiteboard_ui/templates/web/board_view.html+2

Test Results

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo check -p hero_whiteboard_ui — clean
  • cargo test --workspace --lib — 0 failed
  • node --check frames.js — clean

Manual smoke

  1. Rotate a frame ~30°, present — spotlight matches the rotated frame outline; no other content leaks through.
  2. Rotate to 90° / 180° / 270°, present — alignment still correct.
  3. Non-rotated frame — visually unchanged.
  4. Audience tab via ?present=1 — same behavior in board_view.html.

Notes

  • Counter-rotating the entire stage was deliberately avoided to keep hit testing, cursor positioning, and webframe overlay positioning intact. Tradeoff: a rotated frame leaves canvas-background triangles in the AABB corners where it doesn't reach.
  • Konva's getClientRect with relativeTo: stage returns the AABB after the group's local transform but before the stage's pan/scale, which is exactly what the existing fit math expected (the AABB lives in stage coords).
## Implementation Summary Make presentation respect frame rotation: spotlight cutout aligns with the rotated frame, fit-to-viewport uses the rotated AABB. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js` `focusFrame` now uses `frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage })` for the AABB-based fit. This already accounts for the group's rotation, so the math is identical to the previous code when rotation is zero. The screen rect emitted to the host template gains a `rotation` field — the un-rotated cutout size in pixels stays the same; the host applies CSS rotation. ```js var aabb = frame.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage }); var newScale = Math.min(stage.width() / aabb.width, stage.height() / aabb.height); // stagePos centers the AABB in the viewport _focusedScreenRect = { left: stageBox.left + stagePos.x + frame.x() * newScale, // pivot top: stageBox.top + stagePos.y + frame.y() * newScale, // pivot width: bg.width() * newScale, height: bg.height() * newScale, rotation: rotation, }; ``` ### `crates/hero_whiteboard_ui/templates/web/board.html` and `board_view.html` `positionSpotlight` now applies `transform: rotate(<deg>)` with `transform-origin: 0 0`. Because Konva groups rotate around their local (0,0) — which is the group's `(x, y)` in stage coords — setting the spotlight's `left/top` to the un-rotated top-left in screen pixels and rotating around that pivot makes the cutout align with the rotated frame. The `box-shadow: 0 0 0 9999px rgba(0,0,0,0.92)` is a uniform spread with no offset, so the backdrop still fills the screen after rotation. ### Files Changed - `crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js` — `+15/-13` - `crates/hero_whiteboard_ui/templates/web/board.html` — `+2` - `crates/hero_whiteboard_ui/templates/web/board_view.html` — `+2` ### Test Results - `cargo fmt --all -- --check` — clean - `cargo clippy --workspace --all-targets -- -D warnings` — clean - `cargo check -p hero_whiteboard_ui` — clean - `cargo test --workspace --lib` — 0 failed - `node --check frames.js` — clean ### Manual smoke 1. Rotate a frame ~30°, present — spotlight matches the rotated frame outline; no other content leaks through. 2. Rotate to 90° / 180° / 270°, present — alignment still correct. 3. Non-rotated frame — visually unchanged. 4. Audience tab via `?present=1` — same behavior in `board_view.html`. ### Notes - Counter-rotating the entire stage was deliberately avoided to keep hit testing, cursor positioning, and webframe overlay positioning intact. Tradeoff: a rotated frame leaves canvas-background triangles in the AABB corners where it doesn't reach. - Konva's `getClientRect` with `relativeTo: stage` returns the AABB *after* the group's local transform but *before* the stage's pan/scale, which is exactly what the existing fit math expected (the AABB lives in stage coords).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#108
No description provided.