Ported from the classic webchat network navbar, themed to Orbit: a full-width bar
across the top with the network logo/name and a row of external portal links. New
'navbar' UI slot rendered in a flex shell above the app grid (collapses to just the
app when unused); the orbit-navbar plugin fills it from a 'navbar' config section
(brand + links), falling back to the branding name/icon. First-party, so it runs
in-page per the trust model.
The guest (bridge + SDK) was hand-written vanilla JS in public/plugin-sandbox.html
— untyped, unlinted, and hand-duplicating protocol constants (drift risk that grew
with the API). Move it to src/modules/sandbox/guest/ as TypeScript that imports the
shared types from protocol.ts (rpc() is now typed against RpcMethod, so a method not
wired on the host won't compile). A small Vite plugin transpiles it into
plugin-sandbox.html: served on the fly in dev, emitted into the build otherwise —
kept unminified so the served security-boundary code stays auditable, and inline so
the response keeps its own header CSP (an external script can't use 'self' in an
opaque origin). Behavior-neutral: verified clock/radio/dice mount, radio opens/plays
identically, trigger centered.
Full sandbox re-audit: the isolation/capability model is sound (opaque origin,
fail-closed gate, irc-raw split, 'raw' withheld, namespaced storage, cloneable
event payloads). The gap was that sandboxed plugins couldn't register the slash
commands / shortcuts the registry already supports in-page.
- orbit.command(name, run, help): the host registers via the registry and relays
(args, rest) to the guest; the plugin's action still goes through gated verbs.
- orbit.shortcut(combo, run): modifier REQUIRED — the shortcut loop fires while
typing, so a bare-key combo would keylog and eat keystrokes; the host rejects it.
- Registration hooks are disposed on each handshake so the adoption reload can't
duplicate them.
- ui.claim now validates the slot against the known set (bogus slot -> footer).
Verified live: /hello relays to the guest and posts to the channel. Template +
docs updated.
A <button> sizes to fit-content, and the panel root was a plain block, so when
the wider card opened the trigger left-aligned under it and slid onto the
neighbouring tab. Center the panel root (flex column, align-items center) so the
trigger stays over its own slot open or closed. Fixes it for every panel.
The pain of writing a sandboxed plugin came from re-solving the same hard parts
by hand: theming, the async frame-resize lag, upward growth, no half-drawn
flash, native-looking controls. Move all of it into the guest bootstrap:
- orbit.panel(opts): a nav tab + floating panel that opens upward, fully in
view, no flash/grey/black, matching the native tabs — author just fills body.
- orbit.el.button/row/slider: pre-themed atoms.
- guest defaults: box-sizing, sane line-height, text defaults to --ink.
Rewrote the radio on top of it (208 -> 116 lines, all the fiddly logic gone),
added a ~15-line template (orbit-hello.js) and docs/PLUGIN-SDK.md.
overflow:hidden on the guest root paints an opaque backdrop and kills the
iframe transparency, so opening the radio flashed a black box before the card
appeared. Hide the scrollbars with scrollbar-width/::-webkit-scrollbar instead
— transparency stays intact.
On open the card is display:block but visibility:hidden; it is revealed only
once the guest viewport (frame) has grown to fit it, detected via the frame's
own resize event. Stops the half-drawn card flashing down by the composer on
the first click while the async frame resize catches up. Guest gets
overflow:hidden so a mid-resize frame never shows scrollbars.
Slot shares the nav row as an equal flex column so the Radio tab lines up
with the others; trigger mirrors the .tab hover/active effect. Frame stays
hidden until the guest reports its size, so it no longer flashes at screen
center before the slot CSS applies.
Absolutely position the nav slot's iframe and pin it to the bottom, so
growing its height (opening the card) can only extend upward. Replaces the
flex align trick that grew the card downward off-screen in some browsers.
Keep the tab label fixed so the centered frame never shifts sideways.
You had to reload the tab by hand to pick up a new build, and when the service
worker did reload it was an abrupt white flash. Now the build stamps version.json
with the commit and the running app (which already knows its own __GIT_COMMIT__)
polls it whenever the tab regains focus. When a newer build is live it cross-fades
a themed curtain (the theme's own --bg + accent spinner) and reloads, then holds
that curtain over the fresh load until the app mounts and fades it out - so it reads
as an in-place refresh, no flash, no manual reload.
If you're mid-message it waits until the composer is empty (or the tab is hidden)
before reloading, and a loop guard stops it re-reloading for a commit it already
tried. sw.js serves version.json network-only so the probe always sees the live id;
index.html restores the themed bg pre-paint so dark themes don't flash light.
Existing open tabs need one manual reload to pick up this machinery; after that it's
automatic.
The dice feature's buttons styled themselves with var(--panel) and var(--green-soft),
neither of which exists: --panel was never a token and --green-soft got renamed to
--accent-soft. So the buttons had no fill and the bare <button> picked up the OS dark
UA chrome (the sandbox iframe declared color-scheme: light dark, following the visitor's
OS, not the app). On a dark desktop the flip/roll buttons came out dark in a light theme.
Use the real, mirrored tokens (--accent-soft fill, --accent hover) so the buttons always
carry the theme's own accent, and pin the sandbox iframe's color-scheme to the app theme
(from its --bg) so no plugin's native controls track the OS instead of the skin. Drop the
dead --panel from the mirror list.