Skip to content
Closed
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
12 changes: 12 additions & 0 deletions docs/plugin-foundation/PLUGIN-AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ overlays: [{
}]
```

> **The host owns placement — render RELATIVE content, don't self-position.** Your
> `render()` output is dropped into a host-positioned, ~300px-wide slot that
> expands upward from the bottom-left, clear of the canvas's zoom/dark-mode
> controls and height-capped to the canvas stage (a tall overlay scrolls within
> the slot rather than covering the top toolbar). Do **not** set `position:
> fixed`/`absolute` with your own `top`/`left`/`bottom` on the root — that escapes
> the safe zone and can cover app controls (the host does not sandbox this; it's a
> convention). Style the panel's look (background, border, padding) but let the
> host place and size it — no need to set a shadow, the host slot provides one. At
> most one overlay is open at a time, and it shares its slot with the plugin
> manager.

### Export slots — action-time **void** `onSelect(ctx)`

```js
Expand Down
47 changes: 33 additions & 14 deletions web/src/components/PluginErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,28 +37,47 @@ export default class PluginErrorBoundary extends React.Component {
<div
role="alert"
style={{
position: "absolute", left: 14, bottom: 60, zIndex: 50, maxWidth: 320,
// Relative content — the host positions the panel slot (Option A).
width: "100%", boxSizing: "border-box",
padding: "10px 12px", background: "var(--paper-bright)",
border: "1px solid var(--c-danger)", boxShadow: "var(--shadow-2)",
border: "1px solid var(--c-danger)", // shadow from the host slot wrapper
fontSize: 12.5, color: "var(--ink)",
}}
>
<strong style={{ color: "var(--c-danger)" }}>
Plugin “{this.props.label}” unavailable
</strong>
<div style={{ color: "var(--ink-muted)", marginTop: 4 }}>{detail}</div>
{this.props.onClose && (
<button
type="button"
onClick={this.props.onClose}
style={{
marginTop: 8, padding: "4px 10px", border: "1px solid var(--ink-faint)",
background: "var(--paper-bright)", cursor: "pointer", fontSize: 12,
}}
>
Dismiss
</button>
)}
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
{this.props.onClose && (
<button
type="button"
onClick={this.props.onClose}
style={{
padding: "4px 10px", border: "1px solid var(--ink-faint)",
background: "var(--paper-bright)", cursor: "pointer", fontSize: 12,
}}
>
Dismiss
</button>
)}
{/* Quick-eject: a render-crashed plugin can be turned off for good.
The host filters on the disabled set, so the crashed slot
disappears on the re-render and stays gone across reloads. */}
{this.props.onDisable && (
<button
type="button"
onClick={this.props.onDisable}
style={{
padding: "4px 10px", border: "1px solid var(--c-danger)",
background: "var(--paper-bright)", color: "var(--c-danger)",
cursor: "pointer", fontSize: 12,
}}
>
Disable plugin
</button>
)}
</div>
</div>
);
}
Expand Down
202 changes: 160 additions & 42 deletions web/src/components/PluginOverlayHost.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// state (which overlay is open, if any) so the canvas monolith stays additive:
// the canvas hands it one `api` capability bag (#167's CanvasApi) and this host
// does the rest — launcher buttons, per-plugin context minting, error
// isolation, one-overlay-at-a-time enforcement.
// isolation, one-overlay-at-a-time enforcement, and PLACEMENT: overlays render
// as relative content into a host-positioned safe zone (Option A) — anchored at
// left:58 clear of the native zoom/dark-mode column, and height-capped to the
// canvas stage. So a well-behaved (relative) plugin can't cover those controls
// or collide with the manager. (It does NOT sandbox: a plugin that sets its own
// position:fixed/absolute can still escape — a documented convention, not a
// clip.)
//
// The version gate + its plugin-naming console.warn live in loadFeaturePlugins
// (registry.ts, via the pure selectRenderablePlugins) — this host consumes that
Expand All @@ -12,19 +18,32 @@
import React, { useEffect, useState } from "react";
import { loadFeaturePlugins } from "../lib/plugins/registry.js";
import { mintPluginCtx } from "../lib/plugins/host.js";
import { useDisabledPluginIds } from "../lib/plugins/useDisabledPlugins.js";
import { setPluginDisabled } from "../lib/plugins/pluginPrefs.js";
import PluginErrorBoundary from "./PluginErrorBoundary.jsx";

const launcherStyle = {
flexShrink: 0, // launchers never shrink — only the panel gives way when tall
padding: "6px 10px", border: "1px solid var(--ink-faint)",
background: "var(--paper-bright)", color: "var(--ink)", cursor: "pointer",
fontSize: 12, fontWeight: 600, boxShadow: "var(--shadow-1)", textAlign: "left",
};

// The host-owned width of the panel slot (overlay + manager). Plugins render
// RELATIVE content that fills this — they don't pick their own size or position.
const PANEL_WIDTH = 300;

export default function PluginOverlayHost({ api, onActionError }) {
const [plugins, setPlugins] = useState([]);
// Single open slot — one overlay at a time in v1 (concurrent overlays are
// explicitly deferred). Value is the "pluginId::overlayId" key, or null.
const [openKey, setOpenKey] = useState(null);
// Manager popover open/closed. Independent of an overlay being open — the
// manager is the ONLY re-enable path, so it must be reachable even when every
// plugin is disabled (no launchers, no open overlay).
const [showManager, setShowManager] = useState(false);
// Per-user disabled set (reactive: re-renders when toggled here or elsewhere).
const disabled = useDisabledPluginIds();

// Load the in-tree feature descriptors once. `live` guards a resolve that
// lands after unmount (and makes StrictMode's double-mount harmless: the first
Expand All @@ -38,55 +57,154 @@ export default function PluginOverlayHost({ api, onActionError }) {
return () => { live = false; };
}, []);

const slots = plugins.flatMap((plugin) =>
plugin.overlays.map((overlay) => ({
plugin,
overlay,
key: `${plugin.id}::${overlay.id}`,
})),
);
if (slots.length === 0) return null;
// If the currently-open overlay's plugin gets disabled (here or in another
// tab), close it — otherwise re-enabling later would silently reopen the old
// overlay. `openSlot.find` already renders nothing once filtered, but the key
// must be cleared too.
useEffect(() => {
if (openKey && disabled.has(openKey.split("::")[0])) setOpenKey(null);
}, [disabled, openKey]);
Comment on lines +64 to +66

// Launcher/overlay slots come ONLY from ENABLED plugins — a disabled plugin
// contributes no launcher and no overlay. The manager below still iterates the
// FULL `plugins` set so a disabled plugin can be re-enabled.
const slots = plugins
.filter((p) => !disabled.has(p.id))
.flatMap((plugin) =>
plugin.overlays.map((overlay) => ({
plugin,
overlay,
key: `${plugin.id}::${overlay.id}`,
})),
);

// Gate on the FULL loaded set, not the (filtered) slots: the manager must
// render whenever ANY plugin is loaded — including export-only plugins with no
// overlay, and the all-disabled case where there are zero slots but the user
// still needs a way back in.
if (plugins.length === 0) return null;

const close = () => setOpenKey(null);
const openSlot = slots.find((s) => s.key === openKey) ?? null;

// Option A — the HOST owns placement. All plugin UI lives in ONE bottom-anchored
// column at left:58 (clear of the canvas's native zoom/dark-mode column at
// left:14). The panel slot (overlay OR manager) sits ABOVE the launchers and
// expands upward; manager and overlay are MUTUALLY EXCLUSIVE, so they can't
// cover each other, and a plugin's overlay renders as RELATIVE content into a
// host-sized box — it can't self-position over the canvas.
const openOverlay = (key) => { setShowManager(false); setOpenKey((v) => (v === key ? null : key)); };
const toggleManager = () => { setOpenKey(null); setShowManager((v) => !v); };

return (
<>
<div
style={{
position: "absolute", left: 14, bottom: 14, zIndex: 40,
display: "flex", flexDirection: "column", gap: 6,
}}
>
{slots.map(({ overlay, key }) => (
<button
key={key}
type="button"
title={overlay.label}
aria-pressed={openKey === key}
style={launcherStyle}
onClick={() => setOpenKey((v) => (v === key ? null : key))}
<div
style={{
position: "absolute", left: 58, bottom: 14, zIndex: 40,
// maxHeight binds to the STAGE — this column is a direct child of it (a
// definite-height ancestor, like the native panel at TakeoffCanvas:5925),
// so the cap actually clamps. The flex-shrinkable panel below then scrolls
// within it instead of the whole column growing over the top toolbar.
maxHeight: "calc(100% - 28px)",
display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 6,
}}
>
{/* PANEL SLOT — top of the column, expands upward. The plugin's overlay is
plain RELATIVE content dropped into this host-positioned, width-bounded,
stage-height-capped box, so a relative overlay stays clear of the
zoom/dark-mode column and scrolls within the stage instead of covering
the top toolbar. The per-slot error boundary contains a render throw
(degrades to a notice); action-time throws surface via `onActionError`. */}
{openSlot && (
// The flex-shrinkable panel: natural height when it fits, but once the
// column hits its stage-bound maxHeight a tall overlay shrinks (flex:0 1
// auto + minHeight:0) and SCROLLS here (overflowY:auto) instead of pushing
// the column over the top toolbar. Shadow on the wrapper — an element's
// own box-shadow isn't clipped by its own overflow (a descendant's would).
<div style={{ width: PANEL_WIDTH, flex: "0 1 auto", minHeight: 0, overflowY: "auto", boxShadow: "var(--shadow-2)" }}>
<PluginErrorBoundary
key={openSlot.key}
label={openSlot.plugin.id}
onClose={close}
onDisable={() => setPluginDisabled(openSlot.plugin.id, true)}
>
{overlay.icon ? `${overlay.icon} ` : ""}{overlay.label}
</button>
))}
</div>
{openSlot.overlay.render({
ctx: mintPluginCtx(api, openSlot.plugin.id, onActionError),
onClose: close,
})}
</PluginErrorBoundary>
</div>
)}

{/* At most ONE overlay is rendered — openSlot is a single slot or null, so
one-overlay-at-a-time is enforced structurally, not just visually. Each
render-time slot is wrapped in its own error boundary: a plugin that
throws in RENDER degrades to a "feature unavailable" notice and the
canvas survives. Action-time throws (a plugin's own onClick calling a
ctx command) can't reach the boundary; `onActionError`, threaded into
the minted ctx, contains + surfaces those instead. */}
{openSlot && (
<PluginErrorBoundary key={openSlot.key} label={openSlot.plugin.id} onClose={close}>
{openSlot.overlay.render({
ctx: mintPluginCtx(api, openSlot.plugin.id, onActionError),
onClose: close,
{/* Manager — shares the panel slot with the overlay (mutually exclusive).
Always reachable whenever any plugin is loaded, so re-enable works even
with every plugin disabled. Lists the FULL set with an Enable/Disable
toggle each. */}
{showManager && (
<div
role="dialog"
aria-label="Plugin manager"
style={{
width: PANEL_WIDTH, boxSizing: "border-box",
flex: "0 1 auto", minHeight: 0, overflowY: "auto",
padding: "8px 10px", background: "var(--paper-bright)",
border: "1px solid var(--ink-faint)", boxShadow: "var(--shadow-2)",
display: "flex", flexDirection: "column", gap: 6,
}}
>
{plugins.map((plugin) => {
const off = disabled.has(plugin.id);
return (
<div
key={plugin.id}
style={{ display: "flex", alignItems: "center", gap: 10, justifyContent: "space-between" }}
>
<span
style={{ fontSize: 12, color: off ? "var(--ink-muted)" : "var(--ink)" }}
title={plugin.id}
>
{plugin.id}
</span>
<button
type="button"
onClick={() => setPluginDisabled(plugin.id, !off)}
style={{
padding: "3px 8px", fontSize: 11, cursor: "pointer",
border: `1px solid ${off ? "var(--ink-faint)" : "var(--c-danger)"}`,
background: "var(--paper-bright)",
color: off ? "var(--ink)" : "var(--c-danger)",
}}
>
{off ? "Enable" : "Disable"}
</button>
</div>
);
})}
</PluginErrorBoundary>
</div>
)}
</>

{/* Launchers + the manager toggle — the stable button group at the bottom
(nearest the corner); the panel above expands upward. */}
{slots.map(({ overlay, key }) => (
<button
key={key}
type="button"
title={overlay.label}
aria-pressed={openKey === key}
style={launcherStyle}
onClick={() => openOverlay(key)}
>
{overlay.icon ? `${overlay.icon} ` : ""}{overlay.label}
</button>
))}
<button
type="button"
title="Manage plugins"
aria-pressed={showManager}
style={launcherStyle}
onClick={toggleManager}
>
⚙ Plugins
</button>
</div>
);
}
6 changes: 4 additions & 2 deletions web/src/features/takeoff-notes/plugin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ function NotesPanel({ ctx, onClose }) {
return (
<div
style={{
position: "absolute", left: 14, bottom: 60, zIndex: 45, width: 300,
// Relative content: the host positions + sizes the overlay slot (Option
// A). A plugin overlay renders plain content and never self-positions.
width: "100%", boxSizing: "border-box",
background: "var(--paper-bright)", border: "1px solid var(--ink)",
boxShadow: "var(--shadow-2)", color: "var(--ink)", fontSize: 12.5,
color: "var(--ink)", fontSize: 12.5, // shadow comes from the host slot wrapper
}}
>
<header
Expand Down
Loading
Loading