Skip to content

Host-owned plugin-UI placement (region protection) - #181

Open
knmurphy wants to merge 6 commits into
feat/plugin-disablefrom
feat/plugin-placement
Open

knmurphy wants to merge 6 commits into
feat/plugin-disablefrom
feat/plugin-placement

Conversation

@knmurphy

Copy link
Copy Markdown
Owner

Problem

Plugins self-positioned their own overlays. Because each plugin decided where to draw, plugin UI drifted over the canvas's native controls — the zoom and dark-mode column in the bottom-left — and the plugin manager and an open plugin overlay could overlap and cover each other. Placement was effectively unowned, so nothing guaranteed the native controls stayed reachable.

Option A — the host owns placement

The host now owns plugin-UI placement:

  • Overlays render relative content into a host-positioned, size-bounded slot. Plugins no longer choose their own coordinates — they hand the host content, and the host places it.
  • All plugin UI lives in a single bottom-left column that is kept clear of the native zoom/dark-mode controls.
  • The manager and an open overlay are mutually exclusive — only one occupies the column at a time, so they can never cover each other.

Bounded to the canvas stage

The overlay is bounded to the canvas stage, not the viewport:

  • The host is mounted inside the stage.
  • The column carries a maxHeight: calc(100% - 28px) cap, plus flex-shrink and internal scroll.

The result: even a very tall overlay (tested at 2000px) scrolls within the stage instead of growing up over the top toolbar. The cap only works because it binds to a definite-height ancestor — see the canvas edit below.

Honest limitation

This is a convention, not a sandbox. A plugin that uses position: fixed can still escape the slot and draw anywhere. The host owns the provided slot; it cannot forcibly contain a plugin that opts out of the layout. This is documented as expected behavior, not a security boundary.

The one canvas edit

<PluginOverlayHost/> was relocated into the stage so that the maxHeight: calc(100% - 28px) cap binds to a definite-height ancestor (the stage) rather than resolving against an indefinite one and silently doing nothing. This is the only canvas change. The #167 frozen core is untouched.

Verification

This took three empirical browser rounds. The first two height-cap approaches looked correct on paper but were inert in the browser — the cap resolved against an indefinite-height ancestor and had no effect. Both were caught in-browser before the working flexbox-based fix landed. The gate is green (861 tests). A CHANGELOG entry is included.


This PR is stacked on the plugin-disable work — base is feat/plugin-disable, branch-only. The diff here is just the placement / region-protection work.

Closes #178
Part of #179 (epic) · design in #166

knmurphy added 6 commits July 22, 2026 05:04
The overlay-host launcher/manager column sat at left:14 bottom:14 — the exact
coords of the canvas's native zoom/fit/dark-mode control column (TakeoffCanvas
5813) — so ✎ Notes / ⚙ Plugins (and the Notes panel + crash notice at left:14
bottom:60) covered the dark-mode toggle and zoom buttons. Shift all plugin UI to
left:58, clear of the 34px-wide native column: launcher/manager (host), the crash
notice (error boundary), and the reference Notes panel. Pure layout; no behavior
change.
Plugins no longer self-position their overlays. All plugin UI now lives in ONE
host-positioned column (left:58, clear of the canvas's native zoom/dark-mode
controls); the overlay renders as RELATIVE content into a host-sized,
scroll-capped panel slot, so a plugin can't land its UI over app controls. The
manager and an open overlay share that slot and are MUTUALLY EXCLUSIVE, so they
can no longer cover each other (the overlap seen when both were open).

- PluginOverlayHost: single flex column; panel slot (overlay|manager) above the
  launchers; openOverlay/toggleManager enforce mutual exclusion; PANEL_WIDTH is
  host-owned; overlay wrapped in a width-bounded maxHeight:60vh scroll box.
- takeoff-notes reference plugin: drop self-positioning; render relative content.
- PluginErrorBoundary: notice is relative content in the host slot, not absolute.
- PLUGIN-AUTHORING: document that overlays render relative content; the host owns
  placement; don't self-position (position:fixed/absolute escapes the safe zone).

#167 frozen core untouched; canvas untouched. Gate green (861 tests).
…, honest docs

Apply adversarial-review findings (no blockers; all minor):
- Browser MINOR: a tall relative overlay grew to maxHeight:60vh (viewport) and
  could reach the TOP toolbar. Bound both the overlay slot and the manager to the
  canvas STAGE via maxHeight:calc(100% - 28px) + overflowY:auto — matching the
  native panels (TakeoffCanvas:5925) — so a tall overlay scrolls within the stage
  instead of spilling over the top chrome. The zoom/dark-mode column was never at
  risk (relative content grows rightward, away from it) — confirmed by review.
- Static MINOR: the slot's overflow clipped the inner panel's drop-shadow. Put
  the shadow on the host wrapper (its own box-shadow isn't self-clipped) and drop
  the now-redundant shadow from the reference panel + crash notice.
- Soften the overstated 'can't cover the canvas's own controls' comment + doc:
  the honest guarantee is 'clear of the zoom/dark-mode column, bounded to the
  stage'; position:fixed still escapes (documented convention, not a sandbox).

Gate green (861 tests). Core + canvas untouched.
…t cap works

Re-verification showed the calc(100% - 28px) overlay cap was inert: PluginOverlay
Host was mounted at the 100vh root (sibling of the stage), whose auto-height host
column gave the percentage nothing definite to bind to — a tall overlay grew
unbounded and covered the top toolbar. Move the mount INSIDE the canvas stage
(TakeoffCanvas:5340 — flex:1, position:relative, overflow:hidden, below the
chrome), beside the native zoom column. Now the stage is the definite-height
positioning ancestor: the cap clamps, a tall overlay scrolls within the stage,
and overflow:hidden clips any spillover — so plugin UI can't reach the top
toolbar. Launchers stay visually put (stage bottom = viewport bottom). Pure
relocation of one element; behavior otherwise unchanged. Gate green (861).
Round-2 fix was still inert: the slot's maxHeight:calc(100%) bound to the auto-
height host COLUMN (its containing block), not the stage — so it clamped nothing
and a tall overlay was clipped (data loss, no scrollbar) by the stage's
overflow:hidden. Correct mechanism: put the stage-relative cap on the host COLUMN
(a direct child of the definite-height stage, like the native panel at :5925),
then flex-distribute inside it — launchers flexShrink:0, the panel flex:0 1 auto +
minHeight:0 + overflowY:auto. A tall overlay now shrinks to the space above the
launchers and SCROLLS; a short one keeps its natural size; neither reaches the top
toolbar. Gate green (861).
Copilot AI review requested due to automatic review settings July 22, 2026 11:07
@knmurphy

Copy link
Copy Markdown
Owner Author

Evidence — host-owned placement + region protection

Native zoom/dark-mode column no longer covered; launchers beside it
Native zoom/dark-mode column no longer covered; launchers beside it

Overlay + manager open, native column still clear
Overlay + manager open, native column still clear

Overlay is relative content in the host slot
Overlay is relative content in the host slot

Manager and overlay are mutually exclusive
Manager and overlay are mutually exclusive

Crash notice renders relative in-slot
Crash notice renders relative in-slot

Documented limit: a position:fixed plugin still escapes
Documented limit: a position:fixed plugin still escapes

A 2000px overlay clamps to the stage and SCROLLS — toolbar/launchers/zoom column all clear
A 2000px overlay clamps to the stage and SCROLLS — toolbar/launchers/zoom column all clear

A short overlay keeps its natural size
A short overlay keeps its natural size

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements host-owned plugin UI placement (region protection) for the in-canvas plugin overlay system. It moves overlay mounting into the canvas stage so the host can enforce a bottom-left safe column that stays clear of the native zoom/dark-mode controls and height-caps/scrolls within the stage.

Changes:

  • Relocates <PluginOverlayHost /> into the canvas stage to make stage-relative maxHeight clamping effective.
  • Updates PluginOverlayHost to fully own placement: a single bottom-left column (left:58) with a fixed-width panel slot, internal scrolling, and mutual exclusivity between manager vs. open overlay.
  • Updates the reference overlay and the error boundary to render relative content (no self-positioning), and documents the new placement contract for plugin authors.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
web/src/pages/TakeoffCanvas.jsx Mounts PluginOverlayHost inside the stage container so height caps bind to a definite-height ancestor.
web/src/components/PluginOverlayHost.jsx Implements Option A placement: safe column, stage-relative maxHeight + scroll, and manager/overlay mutual exclusivity.
web/src/features/takeoff-notes/plugin.jsx Converts overlay root UI to relative, host-sized rendering (no absolute positioning).
web/src/components/PluginErrorBoundary.jsx Converts the crash notice UI to relative, host-sized rendering (shadow now provided by host wrapper).
docs/plugin-foundation/PLUGIN-AUTHORING.md Documents “host owns placement” expectations and the non-sandbox limitation.
CHANGELOG.md Records the behavior change and rationale for #178.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants