mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(panes): pop-out windows — the pane realm, hub, and remote transport
A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.
## A purpose-built document, not the app shell with a flag on it
`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.
The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.
The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.
## The channel
BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.
hello -> snapshot resync-on-open, always. The snapshot is the only way
the pane realm learns anything.
state main is authoritative. A pane's write is a REQUEST;
main applies it and echoes to every realm, so a
losing write self-corrects instead of splitting brain.
rpc / rpc:reply ctx.call() -> the capability bus, with a 10s deadline.
Without one, a main window that died mid-call leaves
the pane's promise pending forever.
event allowlisted bus events, JSON-safe. A CustomEvent
carrying a DOM node (highway:canvas-replaced does)
would throw on postMessage and take the channel down
for everyone, so detail is round-tripped through JSON.
stream one coalesced message per pane per frame, OVERWRITING
anything not yet flushed. Queueing would build a
backlog: Chromium throttles a backgrounded window, and
the main window is exactly what's backgrounded while
the user looks at the pane.
sub / unsub refcounts the main-realm sampler.
bye both directions.
## The follower clock
The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().
## Failure modes, all of them
- Main window closes -> `bye {main-closed}` and the pane says so plainly,
rather than showing a frozen playhead that looks live. The host also
closes its windows outright; a pane that cannot be fed should not be on
screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
renderer never sends `bye`), the pane closes, and the chip's dialog comes
back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
cross a window boundary. The window host declines it (canHost) and the
router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
pane cannot be auto-restored on page load — it would only ever produce a
"blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
out again on the next click. (autoRestore: false. The desktop host will
set it true.)
Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.
Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -94,6 +94,7 @@
|
||||
id: 'core_mixer',
|
||||
title: 'Mixer',
|
||||
icon: '🎚',
|
||||
script: '/static/panes/builtin/mixer-pane.js',
|
||||
persist: false,
|
||||
// Faders come and go with the song (a song with no stems unregisters
|
||||
// them), so the pane has to hear about it. These ride on top of the
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
id: 'now_playing',
|
||||
title: 'Now Playing',
|
||||
icon: '🎵',
|
||||
// How the pane REALM loads this same file to get the same mount(). A pane
|
||||
// with no `script` can only ever be docked.
|
||||
script: '/static/panes/builtin/now-playing.js',
|
||||
// Levels are transient; nothing here is worth remembering across a reload.
|
||||
persist: false,
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
// ── Local transport (main realm) ─────────────────────────────────────────
|
||||
|
||||
const CALL_TIMEOUT_MS = 2100; // matches core's own audio-mix calls
|
||||
const RPC_TIMEOUT_MS = 10000; // cross-realm deadline; generous, but never infinite
|
||||
|
||||
function createLocalTransport(paneId) {
|
||||
return {
|
||||
@@ -159,6 +160,162 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ── Envelopes ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// { v, type, paneId, hostId, seq, payload }
|
||||
//
|
||||
// type: hello pane→main the pane realm booted; main replies `snapshot`
|
||||
// snapshot main→pane spec + state + current song. Resync-on-open, always.
|
||||
// state both { path, value }. Main is authoritative: a pane's
|
||||
// write is a request; main applies it and echoes to
|
||||
// every realm, so a losing write self-corrects.
|
||||
// rpc pane→main { seq, domain, command, payload }
|
||||
// rpc:reply main→pane { seq, ok, result | error }
|
||||
// event main→pane a mirrored feedBack bus event { name, detail }
|
||||
// stream main→pane coalesced numerics (playhead, meters)
|
||||
// sub/unsub pane→main drives the main-realm sampler's refcount
|
||||
// bye both clean teardown (main-closed / pane-closed)
|
||||
|
||||
function envelope(type, paneId, hostId, payload) {
|
||||
return { v: PROTOCOL_VERSION, type: type, paneId: paneId, hostId: hostId, payload: payload };
|
||||
}
|
||||
|
||||
function openChannel() {
|
||||
if (typeof BroadcastChannel !== 'function') return null;
|
||||
return new BroadcastChannel(CHANNEL_NAME);
|
||||
}
|
||||
|
||||
// ── Remote transport (pane realm) ────────────────────────────────────────
|
||||
|
||||
const MAX_EXTRAP_S = 2.0; // never extrapolate the clock further than this
|
||||
|
||||
function createRemoteTransport(paneId, hostId, channel) {
|
||||
const listeners = new Map(); // event name -> Set<fn>
|
||||
const streamSubs = new Map(); // stream name -> Set<fn>
|
||||
const pending = new Map(); // rpc seq -> { resolve, reject, timer }
|
||||
let rpcSeq = 0;
|
||||
let song = null;
|
||||
|
||||
// The follower clock. The main window broadcasts the playhead every frame
|
||||
// — but Chromium throttles a BACKGROUNDED window's rAF to ~1 Hz, and the
|
||||
// main window is exactly what's in the background while the user looks at
|
||||
// this pane. So we extrapolate between messages instead of rendering 1 Hz
|
||||
// stutter: anchor + observedRate * elapsed.
|
||||
//
|
||||
// observedRate is measured from the broadcasts themselves (Δt / Δwall), so
|
||||
// it tracks the speed slider without being told about it. Capped at
|
||||
// MAX_EXTRAP_S so a dead main window decays into a frozen clock rather
|
||||
// than a clock that confidently runs away.
|
||||
let anchorT = 0, anchorWall = 0, observedRate = 1, playing = false, duration = 0;
|
||||
|
||||
function _onPlayhead(p) {
|
||||
const now = performance.now();
|
||||
if (anchorWall && p.playing && playing) {
|
||||
const dt = p.t - anchorT;
|
||||
const dw = (now - anchorWall) / 1000;
|
||||
// Ignore seeks and pauses when learning the rate: a jump is not a
|
||||
// tempo. Only smooth, forward-moving deltas teach us anything.
|
||||
if (dw > 0.05 && dt > 0 && dt < dw * 4) {
|
||||
const r = dt / dw;
|
||||
observedRate = observedRate * 0.8 + r * 0.2; // light smoothing
|
||||
}
|
||||
}
|
||||
if (!p.playing) observedRate = 1; // a paused clock has no rate to learn
|
||||
anchorT = p.t;
|
||||
anchorWall = now;
|
||||
playing = p.playing;
|
||||
duration = p.duration;
|
||||
}
|
||||
|
||||
function playhead() {
|
||||
if (!anchorWall) return anchorT;
|
||||
if (!playing) return anchorT;
|
||||
const elapsed = Math.min(MAX_EXTRAP_S, (performance.now() - anchorWall) / 1000);
|
||||
return anchorT + observedRate * elapsed;
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
const p = msg.payload || {};
|
||||
switch (msg.type) {
|
||||
case 'event': {
|
||||
const set_ = listeners.get(p.name);
|
||||
// Shaped like a CustomEvent so a pane's handler is identical in
|
||||
// both realms — `e.detail`, not `e`.
|
||||
if (set_) set_.forEach((fn) => { try { fn({ detail: p.detail }); } catch (e) { console.error('[pane] event handler threw', e); } });
|
||||
if (p.name === 'song:loaded') song = p.detail || null;
|
||||
break;
|
||||
}
|
||||
case 'stream': {
|
||||
if (p.playhead) _onPlayhead(p.playhead);
|
||||
for (const name in p) {
|
||||
const set_ = streamSubs.get(name);
|
||||
if (set_) set_.forEach((fn) => { try { fn(p[name]); } catch (e) { console.error('[pane] stream handler threw', e); } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rpc:reply': {
|
||||
const call = pending.get(p.seq);
|
||||
if (!call) return; // already timed out
|
||||
pending.delete(p.seq);
|
||||
clearTimeout(call.timer);
|
||||
if (p.ok) call.resolve(p.result);
|
||||
else call.reject(new Error(p.error || 'pane rpc failed'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'remote',
|
||||
handle: handle,
|
||||
setSong: (s) => { song = s; },
|
||||
|
||||
call(domain, command, payload) {
|
||||
if (!channel) return Promise.reject(new Error('pane ctx.call: no channel'));
|
||||
const seq = ++rpcSeq;
|
||||
return new Promise((resolve, reject) => {
|
||||
// Every call gets a deadline. Without one, a main window that
|
||||
// died mid-call leaves the pane's promise pending forever and
|
||||
// its UI stuck on "Pending".
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(seq);
|
||||
reject(new Error('PaneRpcTimeout: ' + domain + '/' + command));
|
||||
}, RPC_TIMEOUT_MS);
|
||||
pending.set(seq, { resolve, reject, timer });
|
||||
channel.postMessage(envelope('rpc', paneId, hostId, { seq, domain, command, payload: payload || {} }));
|
||||
});
|
||||
},
|
||||
|
||||
on(name, fn) {
|
||||
let set_ = listeners.get(name);
|
||||
if (!set_) { set_ = new Set(); listeners.set(name, set_); }
|
||||
set_.add(fn);
|
||||
return () => set_.delete(fn);
|
||||
},
|
||||
|
||||
subscribe(stream, fn) {
|
||||
let set_ = streamSubs.get(stream);
|
||||
if (!set_) {
|
||||
set_ = new Set();
|
||||
streamSubs.set(stream, set_);
|
||||
if (channel) channel.postMessage(envelope('sub', paneId, hostId, { stream }));
|
||||
}
|
||||
set_.add(fn);
|
||||
return () => {
|
||||
set_.delete(fn);
|
||||
if (!set_.size && channel) channel.postMessage(envelope('unsub', paneId, hostId, { stream }));
|
||||
};
|
||||
},
|
||||
|
||||
playhead: playhead,
|
||||
song: () => song,
|
||||
|
||||
// No toast stack in a pane window, and routing it back to the main
|
||||
// window would pop the message up somewhere the user isn't looking.
|
||||
toast(opts) { console.info('[pane]', (opts && opts.title) || '', (opts && opts.message) || ''); },
|
||||
};
|
||||
}
|
||||
|
||||
// ── ctx ──────────────────────────────────────────────────────────────────
|
||||
// What a pane's mount() actually receives. Every subscription it hands out
|
||||
// is tracked, so unmount() can drop them all — a pane physically cannot
|
||||
@@ -229,8 +386,13 @@
|
||||
CHANNEL_NAME,
|
||||
DEFAULT_EVENTS,
|
||||
CALL_TIMEOUT_MS,
|
||||
RPC_TIMEOUT_MS,
|
||||
MAX_EXTRAP_S,
|
||||
envelope,
|
||||
openChannel,
|
||||
createStateStore,
|
||||
createLocalTransport,
|
||||
createRemoteTransport,
|
||||
createCtx,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* fee[dB]ack — pane hub (main realm).
|
||||
*
|
||||
* The server side of the pane channel. Every popped-out pane talks to exactly
|
||||
* this file, and this file is the only thing in the app that knows a pane might
|
||||
* be in another window.
|
||||
*
|
||||
* It answers `hello` with a snapshot, forwards allowlisted bus events, runs the
|
||||
* pane's capability calls on its behalf, applies its state writes (the main realm
|
||||
* is the sole authority), and samples the streams it asks for — pushing plain
|
||||
* numbers, because the AnalyserNode behind `meters` can never cross a window
|
||||
* boundary.
|
||||
*
|
||||
* Nothing here is reachable from a pane. A pane sees `ctx`, and `ctx` is all.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const B = window.__fbPaneBridge;
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!B || !panes || !bus) { console.error('[panes] pane-hub.js loaded too early'); return; }
|
||||
|
||||
const channel = B.openChannel();
|
||||
if (!channel) return; // no BroadcastChannel → the window host declines to open anything anyway
|
||||
|
||||
// paneId -> { streams: Map<name, unsub>, pending: Object|null, rafId }
|
||||
const conns = new Map();
|
||||
// Bus events we have hooked, so N panes share one listener per event.
|
||||
const busHooks = new Map(); // event name -> { fn, refs }
|
||||
|
||||
function send(type, paneId, payload) {
|
||||
channel.postMessage(B.envelope(type, paneId, 'main', payload));
|
||||
}
|
||||
|
||||
// ── Bus mirroring ────────────────────────────────────────────────────────
|
||||
|
||||
function _hookEvent(name) {
|
||||
let hook = busHooks.get(name);
|
||||
if (hook) { hook.refs++; return; }
|
||||
const fn = (e) => {
|
||||
// Only forward to panes that actually want this event, and only
|
||||
// structured-cloneable detail — a CustomEvent carrying a DOM node
|
||||
// (highway:canvas-replaced does) would throw on postMessage and kill
|
||||
// the channel for everyone.
|
||||
let detail = null;
|
||||
try { detail = JSON.parse(JSON.stringify(e.detail === undefined ? null : e.detail)); }
|
||||
catch (err) { detail = null; } // not serialisable: the event still fires, sans payload
|
||||
conns.forEach((_, paneId) => {
|
||||
const spec = panes.get(paneId);
|
||||
if (spec && spec.events.indexOf(name) >= 0) send('event', paneId, { name, detail });
|
||||
});
|
||||
};
|
||||
bus.on(name, fn);
|
||||
busHooks.set(name, { fn, refs: 1 });
|
||||
}
|
||||
|
||||
function _unhookEvent(name) {
|
||||
const hook = busHooks.get(name);
|
||||
if (!hook) return;
|
||||
if (--hook.refs > 0) return;
|
||||
bus.off(name, hook.fn);
|
||||
busHooks.delete(name);
|
||||
}
|
||||
|
||||
// ── Streams ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The sampler (pane-streams.js) fires per frame. We do NOT post per stream
|
||||
// per frame — we coalesce every stream a pane wants into ONE message and post
|
||||
// it on the next frame, overwriting anything not yet flushed.
|
||||
//
|
||||
// Overwriting rather than queueing is the whole trick: Chromium throttles a
|
||||
// backgrounded window (which the MAIN window is, while the user looks at the
|
||||
// pane), so a queue would grow a backlog of stale frames and then dump them.
|
||||
// The pane extrapolates its own clock between whatever it does receive.
|
||||
|
||||
function _flush(paneId) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
conn.rafId = null;
|
||||
if (!conn.pending) return;
|
||||
const payload = conn.pending;
|
||||
conn.pending = null;
|
||||
send('stream', paneId, payload);
|
||||
}
|
||||
|
||||
function _onStreamValue(paneId, name, value) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
if (!conn.pending) conn.pending = {};
|
||||
conn.pending[name] = value; // last value for this frame wins
|
||||
if (conn.rafId == null) conn.rafId = requestAnimationFrame(() => _flush(paneId));
|
||||
}
|
||||
|
||||
function _subscribe(paneId, name) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn || conn.streams.has(name)) return;
|
||||
conn.streams.set(name, window.__fbPaneStreams.subscribe(name, (v) => _onStreamValue(paneId, name, v)));
|
||||
}
|
||||
|
||||
function _unsubscribe(paneId, name) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
const unsub = conn.streams.get(name);
|
||||
if (unsub) { unsub(); conn.streams.delete(name); }
|
||||
}
|
||||
|
||||
// ── Connections ──────────────────────────────────────────────────────────
|
||||
|
||||
function _connect(paneId) {
|
||||
if (conns.has(paneId)) _disconnect(paneId); // a reloaded pane window says hello again
|
||||
conns.set(paneId, { streams: new Map(), pending: null, rafId: null });
|
||||
const spec = panes.get(paneId);
|
||||
if (spec) spec.events.forEach(_hookEvent);
|
||||
}
|
||||
|
||||
function _disconnect(paneId) {
|
||||
const conn = conns.get(paneId);
|
||||
if (!conn) return;
|
||||
conn.streams.forEach((unsub) => unsub());
|
||||
if (conn.rafId != null) cancelAnimationFrame(conn.rafId);
|
||||
conns.delete(paneId);
|
||||
const spec = panes.get(paneId);
|
||||
if (spec) spec.events.forEach(_unhookEvent);
|
||||
}
|
||||
|
||||
function _snapshot(paneId) {
|
||||
const entry = panes._entry(paneId);
|
||||
const spec = panes.get(paneId);
|
||||
if (!entry || !spec) return null;
|
||||
return {
|
||||
spec: { id: spec.id, title: spec.title, icon: spec.icon, script: spec.script },
|
||||
state: entry.state.all(),
|
||||
song: (window.feedBack && window.feedBack.currentSong) || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Channel ──────────────────────────────────────────────────────────────
|
||||
|
||||
channel.addEventListener('message', (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.hostId === 'main') return;
|
||||
const paneId = msg.paneId;
|
||||
const p = msg.payload || {};
|
||||
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
const snap = _snapshot(paneId);
|
||||
if (!snap) {
|
||||
// The pane window outlived its registration (main window
|
||||
// reloaded while a pane window stayed open). Tell it so it can
|
||||
// close itself rather than sit there frozen.
|
||||
send('bye', paneId, { reason: 'unknown-pane' });
|
||||
return;
|
||||
}
|
||||
_connect(paneId);
|
||||
send('snapshot', paneId, snap);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rpc': {
|
||||
const caps = window.feedBack.capabilities;
|
||||
const reply = (ok, result, error) => send('rpc:reply', paneId, { seq: p.seq, ok, result, error });
|
||||
if (!caps || typeof caps.command !== 'function') { reply(false, null, 'capability bus unavailable'); return; }
|
||||
caps.command(p.domain, p.command, {
|
||||
requester: 'pane.' + paneId,
|
||||
origin: 'pane',
|
||||
payload: p.payload || {},
|
||||
timeoutMs: B.CALL_TIMEOUT_MS,
|
||||
}).then((result) => {
|
||||
// The result crosses a window boundary, so it must survive
|
||||
// structured clone. A capability that answers with a live
|
||||
// object (a node, a function) would otherwise throw here and
|
||||
// take the channel down with it.
|
||||
let safe = null;
|
||||
try { safe = JSON.parse(JSON.stringify(result === undefined ? null : result)); }
|
||||
catch (err) { reply(false, null, 'result is not serialisable'); return; }
|
||||
reply(true, safe, null);
|
||||
}).catch((err) => reply(false, null, String((err && err.message) || err)));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'state': {
|
||||
const entry = panes._entry(paneId);
|
||||
if (!entry) return;
|
||||
// The main realm is authoritative: apply, then echo to everyone.
|
||||
// A pane's own optimistic paint is corrected by the echo, so two
|
||||
// panes racing on one key converge instead of diverging.
|
||||
if (entry.state.set(p.path, p.value)) send('state', paneId, { path: p.path, value: p.value });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sub': _subscribe(paneId, p.stream); break;
|
||||
case 'unsub': _unsubscribe(paneId, p.stream); break;
|
||||
|
||||
case 'bye': {
|
||||
_disconnect(paneId);
|
||||
// The pane window is going away for good (the user closed it).
|
||||
// Closing the pane un-hides whatever dialog the chip hid, which is
|
||||
// the only outcome that leaves the user able to find their UI again.
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The main window is the only thing feeding the panes. When it goes, they
|
||||
// cannot be fed — tell them, so they show a dead state instead of a
|
||||
// convincing but frozen one. (The window host also closes them outright; this
|
||||
// covers a host that can't, such as the desktop's own windows.)
|
||||
window.addEventListener('beforeunload', () => {
|
||||
conns.forEach((_, paneId) => send('bye', paneId, { reason: 'main-closed' }));
|
||||
});
|
||||
|
||||
// A pane that is closed from THIS side (the stub, the launcher, the tray)
|
||||
// must be told, or its window sits there orphaned.
|
||||
bus.on('panes:closed', (e) => {
|
||||
const id = e.detail && e.detail.id;
|
||||
if (conns.has(id)) { send('bye', id, { reason: 'closed-by-host' }); _disconnect(id); }
|
||||
});
|
||||
})();
|
||||
@@ -86,6 +86,12 @@
|
||||
persist: spec.persist !== false, // default on; opt out with `persist: false`
|
||||
initialState: spec.initialState || {},
|
||||
defaultHost: spec.defaultHost || 'window',
|
||||
// URL of the module the pane REALM loads to obtain this pane's
|
||||
// mount(). A pane with no script can only ever be docked — it exists
|
||||
// solely as a closure in this realm, and there is no honest way to
|
||||
// move a closure across a window boundary. The window host declines
|
||||
// such panes and the router falls back to the dock.
|
||||
script: spec.script || null,
|
||||
mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js
|
||||
width: spec.width || 380,
|
||||
height: spec.height || 560,
|
||||
@@ -97,15 +103,16 @@
|
||||
// unmount(id), focus(id) }`. Higher priority wins when a pane asks for a
|
||||
// host it can't have.
|
||||
|
||||
function _resolveHost(preferred) {
|
||||
function _resolveHost(preferred, spec) {
|
||||
const wanted = hosts.get(preferred);
|
||||
if (wanted && wanted.available()) return wanted;
|
||||
// Fall back to the best host that IS available, preferring the highest
|
||||
// priority. The dock registers at priority 0, so it is always the floor —
|
||||
// a pane can never fail to open just because no window host exists.
|
||||
if (wanted && wanted.available() && wanted.canHost(spec)) return wanted;
|
||||
// Fall back to the best host that IS available and WILL take this pane,
|
||||
// preferring the highest priority. The dock registers at priority 0 and
|
||||
// accepts everything, so it is always the floor — a pane can never fail
|
||||
// to open just because the window host is unavailable or declines it.
|
||||
let best = null;
|
||||
hosts.forEach((h) => {
|
||||
if (!h.available()) return;
|
||||
if (!h.available() || !h.canHost(spec)) return;
|
||||
if (!best || h.priority > best.priority) best = h;
|
||||
});
|
||||
return best;
|
||||
@@ -133,8 +140,18 @@
|
||||
// Reopen where the user left it. Deferred a tick so a plugin can call
|
||||
// register() and attachChip() back-to-back — the chip must exist before
|
||||
// the pane opens or it has nothing to hide.
|
||||
const remembered = _readJSON(HOSTS_KEY, {})[s.id];
|
||||
if (remembered) setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
|
||||
//
|
||||
// A host may refuse to be auto-restored: a browser blocks window.open()
|
||||
// without a user gesture, so restoring a popped-out pane on page load
|
||||
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
|
||||
// in the dock, and the chip pops it out again on the user's next click.
|
||||
// (The desktop host has no such restriction and restores in place.)
|
||||
let remembered = _readJSON(HOSTS_KEY, {})[s.id];
|
||||
if (remembered) {
|
||||
const h = hosts.get(remembered);
|
||||
if (h && h.autoRestore === false) remembered = 'dock';
|
||||
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
|
||||
}
|
||||
|
||||
return () => unregister(s.id);
|
||||
}
|
||||
@@ -151,12 +168,31 @@
|
||||
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
|
||||
if (open.has(id)) { focusPane(id); return true; }
|
||||
|
||||
const host = _resolveHost(opts.host || spec.defaultHost);
|
||||
const host = _resolveHost(opts.host || spec.defaultHost, spec);
|
||||
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
||||
|
||||
const state = B.createStateStore(spec.persist ? _readJSON(STATE_KEY(id), spec.initialState) : spec.initialState);
|
||||
if (spec.persist) state.subscribe(() => _scheduleSave(id, state));
|
||||
|
||||
// A REMOTE host (a pop-out window) runs the pane's mount() in its own
|
||||
// realm — this realm never sees the pane's DOM and must not call mount()
|
||||
// itself. All we own here is the authoritative state store; pane-hub.js
|
||||
// serves the other realm from it.
|
||||
if (host.remote) {
|
||||
let handle;
|
||||
try {
|
||||
handle = host.mount(spec);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to open a window for', id, e);
|
||||
return false;
|
||||
}
|
||||
if (!handle) return false; // host already explained itself (popup blocked, etc.)
|
||||
open.set(id, { spec, hostId: host.id, state, remote: true, handle });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
_emit('panes:opened', { id: id, host: host.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
let root;
|
||||
try {
|
||||
root = host.mount(spec);
|
||||
@@ -199,13 +235,17 @@
|
||||
if (!entry) return false;
|
||||
open.delete(id);
|
||||
|
||||
// Order matters: the pane tears down its own DOM/listeners first, then
|
||||
// ctx drops everything it handed out, then the host removes the shell.
|
||||
// Reversing any of these hands the pane a root that has already been
|
||||
// detached, or leaks the subscriptions its unmount() assumed it kept.
|
||||
try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); }
|
||||
catch (e) { console.error('[panes] pane threw in unmount():', id, e); }
|
||||
try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); }
|
||||
// Order matters for a local pane: the pane tears down its own DOM and
|
||||
// listeners first, then ctx drops everything it handed out, then the host
|
||||
// removes the shell. Reversing any of these hands the pane a root that has
|
||||
// already been detached, or leaks the subscriptions its unmount() assumed
|
||||
// it kept. A remote pane's mount/unmount ran in the other realm and
|
||||
// teardown goes with the window, so there is nothing to do but close it.
|
||||
if (!entry.remote) {
|
||||
try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); }
|
||||
catch (e) { console.error('[panes] pane threw in unmount():', id, e); }
|
||||
try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); }
|
||||
}
|
||||
|
||||
const host = hosts.get(entry.hostId);
|
||||
try { if (host) host.unmount(id); } catch (e) { console.error('[panes] host', entry.hostId, 'threw in unmount:', id, e); }
|
||||
@@ -256,7 +296,15 @@
|
||||
hosts.set(host.id, {
|
||||
id: host.id,
|
||||
priority: host.priority || 0,
|
||||
// `remote: true` means the pane's mount() runs in ANOTHER JS realm.
|
||||
// The manager then owns only the state store, and pane-hub.js serves
|
||||
// the pane over the channel.
|
||||
remote: !!host.remote,
|
||||
// `autoRestore: false` — this host cannot be opened without a user
|
||||
// gesture, so a pane remembered here comes back in the dock instead.
|
||||
autoRestore: host.autoRestore !== false,
|
||||
available: typeof host.available === 'function' ? host.available : () => true,
|
||||
canHost: typeof host.canHost === 'function' ? host.canHost : () => true,
|
||||
mount: host.mount,
|
||||
unmount: host.unmount,
|
||||
focus: host.focus,
|
||||
@@ -281,6 +329,11 @@
|
||||
// a future out-of-tree host (a plugin shipping its own window shell) can
|
||||
// participate without a private import.
|
||||
registerHost,
|
||||
|
||||
// Host-internal. pane-hub.js serves a pop-out realm from the
|
||||
// authoritative state store, which only lives here. Not part of the pane
|
||||
// API — panes must never reach for this.
|
||||
_entry: (id) => open.get(id) || null,
|
||||
};
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* fee[dB]ack — pane runtime (the pop-out realm).
|
||||
*
|
||||
* This is what runs inside a pane window. It is NOT the app: there is no
|
||||
* highway, no library, no shell, no <audio>, no capability bus, no audio graph.
|
||||
* There is this file, the bridge, and the pane's own script.
|
||||
*
|
||||
* That is deliberate. The splitscreen follower reuses the full app shell with a
|
||||
* `?ssFollower=1` flag and pays for it — an anti-flash block that must run before
|
||||
* any script parses, bail-outs in app.js and shell.js, and ~40 lines of CSS
|
||||
* hiding core elements by id. It loads the whole app to throw it away. A pane
|
||||
* window has nothing to throw away.
|
||||
*
|
||||
* The cost is that `window.feedBack` here is a deliberate, documented SUBSET.
|
||||
* We install exactly what a pane is promised and nothing more, so a pane reaching
|
||||
* for something it was never given fails loudly at authoring time instead of
|
||||
* subtly at runtime.
|
||||
*
|
||||
* Boot: hello → snapshot → load the pane's script → mount(root, ctx).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const B = window.__fbPaneBridge;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const paneId = params.get('pane');
|
||||
const scriptUrl = params.get('script');
|
||||
|
||||
const rootEl = document.getElementById('pane-root');
|
||||
const titleEl = document.getElementById('pane-title');
|
||||
const statusEl = document.getElementById('pane-status');
|
||||
|
||||
function fail(message) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.hidden = false;
|
||||
rootEl.hidden = true;
|
||||
}
|
||||
|
||||
if (!paneId || !scriptUrl) { fail('This window was opened without a pane to show.'); return; }
|
||||
if (!B) { fail('Pane bridge failed to load.'); return; }
|
||||
|
||||
const channel = B.openChannel();
|
||||
if (!channel) { fail('This browser has no BroadcastChannel, so a pane window cannot be kept in sync.'); return; }
|
||||
|
||||
// The registration shim. A pane script is the SAME file whether it runs in the
|
||||
// app (where it registers with the real pane manager) or here — it calls
|
||||
// `feedBack.panes.register(spec)` either way. Here, that call just hands us
|
||||
// the spec.
|
||||
let captured = null;
|
||||
window.feedBack = {
|
||||
panes: {
|
||||
register(spec) {
|
||||
if (spec && spec.id === paneId) captured = spec;
|
||||
return () => {};
|
||||
},
|
||||
// A pane window hosts one pane. Chip/dock/launcher calls are
|
||||
// meaningless here, but a shared pane script may make them at load —
|
||||
// so they must exist and do nothing rather than throw and take the
|
||||
// pane's module down with them.
|
||||
attachChip: () => () => {},
|
||||
get: () => null,
|
||||
isOpen: () => false,
|
||||
list: () => [],
|
||||
},
|
||||
};
|
||||
|
||||
const transport = B.createRemoteTransport(paneId, 'pane:' + paneId, channel);
|
||||
let ctx = null;
|
||||
let mounted = false;
|
||||
|
||||
channel.addEventListener('message', (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || msg.v !== B.PROTOCOL_VERSION || msg.paneId !== paneId) return;
|
||||
if (msg.hostId !== 'main') return; // ignore our own echoes
|
||||
|
||||
if (msg.type === 'snapshot') { onSnapshot(msg.payload); return; }
|
||||
|
||||
if (msg.type === 'bye') {
|
||||
const reason = (msg.payload && msg.payload.reason) || '';
|
||||
// 'main-closed' is the interesting one: nothing will ever feed this
|
||||
// window again. Say so plainly rather than leaving a frozen playhead
|
||||
// that looks live.
|
||||
if (reason === 'main-closed') fail('fee[dB]ack closed. This pane is no longer live.');
|
||||
else window.close(); // closed deliberately from the app side
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'state' && state) {
|
||||
// The authoritative echo. Applying it unconditionally is what makes a
|
||||
// losing write self-correct.
|
||||
state.set(msg.payload.path, msg.payload.value);
|
||||
return;
|
||||
}
|
||||
|
||||
transport.handle(msg);
|
||||
});
|
||||
|
||||
let state = null;
|
||||
|
||||
function onSnapshot(snap) {
|
||||
if (mounted) return; // a duplicate snapshot (main reloaded) — the window will be told `bye` if stale
|
||||
titleEl.textContent = snap.spec.icon + ' ' + snap.spec.title;
|
||||
document.title = snap.spec.title + ' — fee[dB]ack';
|
||||
transport.setSong(snap.song);
|
||||
|
||||
state = B.createStateStore(snap.state);
|
||||
// A pane's write is a REQUEST. We send it and let the main realm's echo
|
||||
// apply it, so there is exactly one authority and no split brain. The
|
||||
// local store is not written here — the echo does that.
|
||||
const authoritative = {
|
||||
get: (path) => state.get(path),
|
||||
all: () => state.all(),
|
||||
subscribe: (fn) => state.subscribe(fn),
|
||||
set: (path, value) => {
|
||||
channel.postMessage(B.envelope('state', paneId, 'pane:' + paneId, { path, value }));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
ctx = B.createCtx({
|
||||
paneId: paneId,
|
||||
host: 'pane:' + paneId,
|
||||
transport: transport,
|
||||
state: authoritative,
|
||||
onClose: () => window.close(),
|
||||
});
|
||||
|
||||
loadScript(snap.spec.script).then(() => {
|
||||
if (!captured) { fail('The pane script loaded but registered nothing.'); return; }
|
||||
statusEl.hidden = true;
|
||||
rootEl.hidden = false;
|
||||
try {
|
||||
captured.mount(rootEl, ctx);
|
||||
mounted = true;
|
||||
} catch (err) {
|
||||
console.error('[pane] mount() threw', err);
|
||||
fail('This pane failed to start.');
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[pane] failed to load', snap.spec.script, err);
|
||||
fail('Could not load this pane.');
|
||||
});
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error('script load failed: ' + url));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// Tell the app we're gone so it can un-hide the dialog the chip hid. If this
|
||||
// never arrives (a crash), the host's `closed` poll reaps us anyway — but the
|
||||
// clean path should not depend on the fallback.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try { channel.postMessage(B.envelope('bye', paneId, 'pane:' + paneId, { reason: 'pane-closed' })); }
|
||||
catch (e) { /* channel already torn down */ }
|
||||
});
|
||||
|
||||
// Resync-on-open, always: the snapshot is the only way this realm learns
|
||||
// anything, so ask for it as the very first thing we do.
|
||||
channel.postMessage(B.envelope('hello', paneId, 'pane:' + paneId, {}));
|
||||
|
||||
// If nobody answers, the main window is gone or never had this pane. Don't
|
||||
// spin forever on a blank window.
|
||||
setTimeout(() => { if (!mounted && !state) fail('fee[dB]ack is not running, or this pane is no longer available.'); }, 5000);
|
||||
})();
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out window host (browser path).
|
||||
*
|
||||
* Opens a real OS window per pane and hands it off to pane-hub.js, which serves
|
||||
* it over BroadcastChannel. Registers as the `window` host at priority 10, so it
|
||||
* outranks the dock and `panes.detach()` prefers it.
|
||||
*
|
||||
* This is the BROWSER implementation: a plain same-origin `window.open()`, which
|
||||
* is what the splitscreen follower has done for years. Electron already permits
|
||||
* it — main.ts's setWindowOpenHandler returns `action: 'allow'` for same-origin
|
||||
* URLs, and that is load-bearing: `deny` would push the URL to the system
|
||||
* browser, a different Chromium instance, where BroadcastChannel cannot reach it
|
||||
* and the pane would silently never sync.
|
||||
*
|
||||
* The desktop app will register its own host at a higher priority (a real
|
||||
* BrowserWindow, with a system tray, always-on-top, and remembered bounds).
|
||||
* Nothing else changes when it does — that is the point of the host registry.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.registerHost !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-window-host.js');
|
||||
return;
|
||||
}
|
||||
|
||||
const wins = new Map(); // paneId -> Window
|
||||
let reaper = null;
|
||||
|
||||
// A pane window the user closed with the OS X button never gets to say `bye`
|
||||
// reliably (a crashed renderer certainly doesn't). Poll `closed` and reap —
|
||||
// otherwise the pane stays "open" forever, its chip stays stubbed out, and
|
||||
// the user has no way back to their dialog. Same trick splitscreen uses.
|
||||
function _startReaper() {
|
||||
if (reaper != null) return;
|
||||
reaper = setInterval(() => {
|
||||
wins.forEach((w, id) => {
|
||||
if (w.closed) panes.close(id); // → unmount() below clears the entry
|
||||
});
|
||||
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function mount(spec) {
|
||||
const url = new URL(window.location.origin + '/pane');
|
||||
url.searchParams.set('pane', spec.id);
|
||||
// The pane realm loads its mount() from this URL. Everything else it
|
||||
// needs (title, state, the song) arrives in the snapshot — URL params
|
||||
// are open-time only and must never be a state channel.
|
||||
url.searchParams.set('script', spec.script);
|
||||
|
||||
const w = window.open(url.toString(), 'fbpane-' + spec.id,
|
||||
'popup,width=' + spec.width + ',height=' + spec.height);
|
||||
|
||||
if (!w) {
|
||||
// Popup blocked. Bail BEFORE the manager records anything, so the
|
||||
// caller's dialog stays exactly where it was — and say so out loud
|
||||
// rather than appearing to do nothing.
|
||||
if (window.fbNotify) {
|
||||
window.fbNotify.show({
|
||||
title: 'Pop-out blocked',
|
||||
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
|
||||
icon: '⚠️', accent: '#f59e0b',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
wins.set(spec.id, w);
|
||||
_startReaper();
|
||||
return w;
|
||||
}
|
||||
|
||||
function unmount(id) {
|
||||
const w = wins.get(id);
|
||||
wins.delete(id);
|
||||
// Closing an already-closed window is a no-op, and closing one we opened
|
||||
// is always permitted (same-origin, script-opened).
|
||||
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
const w = wins.get(id);
|
||||
if (w && !w.closed) { try { w.focus(); } catch (e) { /* OS may refuse */ } }
|
||||
}
|
||||
|
||||
panes.registerHost({
|
||||
id: 'window',
|
||||
priority: 10,
|
||||
remote: true,
|
||||
// A browser blocks window.open() outside a user gesture, so a pane
|
||||
// remembered here cannot be restored on page load — it would only ever
|
||||
// produce a "pop-up blocked" toast. The manager brings it back in the dock
|
||||
// and the chip pops it out again on the user's next click. The desktop
|
||||
// host overrides this: it opens real BrowserWindows and needs no gesture.
|
||||
autoRestore: false,
|
||||
// No BroadcastChannel means no way to feed the pane once it's open. Better
|
||||
// to keep it docked than to open a window that renders forever-stale data.
|
||||
available: () => typeof BroadcastChannel === 'function',
|
||||
// A pane with no `script` exists only as a closure in this realm. There is
|
||||
// no honest way to move a closure across a window boundary, so decline it
|
||||
// and let the router fall back to the dock.
|
||||
canHost: (spec) => !!spec.script,
|
||||
mount, unmount, focus,
|
||||
});
|
||||
|
||||
// The pane windows are ours; they must not outlive us. A pane window whose
|
||||
// main window is gone can never be fed again — leaving it on screen showing a
|
||||
// frozen playhead is worse than closing it.
|
||||
window.addEventListener('beforeunload', () => {
|
||||
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="fb-pane-window">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pane — fee[dB]ack</title>
|
||||
<link rel="icon" href="/static/assets/favicon.png">
|
||||
<!-- Deliberately minimal. This is NOT the app shell with a flag on it: no
|
||||
highway, no library, no v3 shell, no <audio>, no Tailwind. A pane window
|
||||
has nothing to hide, so it needs no anti-flash hack and boots in
|
||||
milliseconds. Panes needing utility classes ship their own stylesheet via
|
||||
the `styles` manifest key, exactly as they must in the main window. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="fb-pane-window-head">
|
||||
<span id="pane-title" class="fb-pane-window-title">fee[dB]ack</span>
|
||||
</header>
|
||||
<p id="pane-status" class="fb-pane-window-status">Connecting…</p>
|
||||
<main id="pane-root" class="fb-pane-window-body fb-selectable" hidden></main>
|
||||
|
||||
<!-- Order matters: the bridge defines the transport the runtime uses. Both are
|
||||
classic scripts, so the pane's own script (injected by the runtime) sees
|
||||
the same global scope it would in the main window. -->
|
||||
<script src="/static/panes/pane-bridge.js"></script>
|
||||
<script src="/static/panes/pane-runtime.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -158,6 +158,55 @@
|
||||
.fb-pane-card.is-flash { animation: none; }
|
||||
}
|
||||
|
||||
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────── */
|
||||
/* This document loads no Tailwind and no app stylesheet — it is the whole page,
|
||||
so it carries its own reset. Keep it tiny; a pane window must boot instantly. */
|
||||
|
||||
html.fb-pane-window,
|
||||
html.fb-pane-window body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
color: #cbd5e1;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none; /* chrome isn't selectable; .fb-selectable opts content back in */
|
||||
}
|
||||
html.fb-pane-window body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fb-pane-window-head {
|
||||
flex: 0 0 auto;
|
||||
padding: .55rem .8rem;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, .6);
|
||||
background: rgba(30, 41, 59, .6);
|
||||
-webkit-app-region: drag; /* the desktop host opens this frameless */
|
||||
}
|
||||
.fb-pane-window-title {
|
||||
font-size: .8rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.fb-pane-window-status {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
color: #64748b;
|
||||
font-size: .8rem;
|
||||
text-align: center;
|
||||
}
|
||||
.fb-pane-window-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: .85rem;
|
||||
}
|
||||
|
||||
/* Core CSS defines this for the app; a pane window loads no app CSS, so repeat
|
||||
it here. Same rule, same intent: copy-worthy text stays copyable. */
|
||||
.fb-selectable, .fb-selectable * { -webkit-user-select: text; user-select: text; }
|
||||
|
||||
/* ── Shared widgets for built-in panes ───────────────────────────────────── */
|
||||
|
||||
.fb-pane-row {
|
||||
|
||||
@@ -1326,6 +1326,8 @@
|
||||
<script defer src="/static/panes/pane-streams.js"></script>
|
||||
<script defer src="/static/panes/pane-manager.js"></script>
|
||||
<script defer src="/static/panes/pane-dock.js"></script>
|
||||
<script defer src="/static/panes/pane-window-host.js"></script>
|
||||
<script defer src="/static/panes/pane-hub.js"></script>
|
||||
<script defer src="/static/panes/pane-chip.js"></script>
|
||||
<script defer src="/static/panes/pane-launcher.js"></script>
|
||||
<script defer src="/static/panes/builtin/now-playing.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user