mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
main
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41bb4482fe |
docs(panes): say which repo the desktop half lives in
Two comments pointed at `main.ts` and `pane-hosts.ts` as though they were in this repo. They are not — they are in got-feedback/feedBack-desktop, and a contributor reading only this codebase would go looking for files that do not exist. Named the repo and the paths, and said the part that actually matters: nothing here depends on that code. In a plain browser a pane window is simply a pop-up; the desktop side only upgrades it. And the frame-name prefix is a contract across two repos with no build-time link between them, so the comment IS the link — worth saying out loud. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
0955f0b6f2 |
fix(panes): keep a pane window in step with theme + interface scale
The pane window got a ONE-TIME snapshot of the app's theme classes and the interface-scale custom property. The app changes both at runtime — Interface size emits `scale:changed`, the theme emits `theme:changed` / `v3:cosmetics-applied` — so an already-open pane went on rendering at the old scale, in the old palette, the moment the user touched either. "Looks identical" has to keep being true, not merely start out true. A pane window now follows those three events for as long as it is open, and stops on unplace(). The inline style is assigned wholesale rather than merged: unlike the class lists (where pane.html's own `fb-pane-window` must survive), there is nothing in the pane document's inline style to preserve — and concatenating on every change would grow the attribute without bound as the user dragged the scale slider. Also: the dock's focus() always smooth-scrolled, ignoring prefers-reduced-motion — which panes.css already honours for the card's flash animation. A smooth scroll is motion too, and someone who asked for less of it meant this as well. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
3a50e593bf |
fix(panes): fail fast when the pane window is unreachable; validate opts.header
Two from review. 1. _whenReady's own comment said a SecurityError means the pop-out is not reachable from this realm and "no amount of waiting will fix it" — and then it waited the full 10s deadline anyway. Ten seconds of a detached panel and a half-popped-out UI, for a condition we had already diagnosed as fatal. It now gives up after a 1s grace instead. Not instantly, deliberately: a throw *during* the navigation from about:blank to /pane would otherwise take down a pop-out that was about to work perfectly. A second is far more than that transition needs and far less than a user should spend staring at a detached panel. 2. attachChip() took opts.header on trust. It's a public plugin API, and a truthy non-Element header (a selector string, a wrapper object, a ref) is an easy mistake — one that surfaced as a confusing DOM exception from deep inside core instead of a TypeError naming the offending pane. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
5049be0523 |
fix(panes): the dock is born empty, so say so
panes.css hides an empty dock (.fb-pane-dock.is-empty { display: none }), but
the element was created without the class — so between creation and the first
card it was a visible-to-CSS, announced-to-screen-readers role="region"
landmark containing nothing.
Harmless in practice today (the dock is created lazily, on the same tick as the
card that prompted it), but the CSS contract should hold from first paint rather
than from the first _syncEmpty(), and any future caller of dock() gets the right
thing for free.
Found by CodeRabbit on #928.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
de2a42bd35 |
fix(panes): coerce plugin-supplied pane sizes to numbers
`spec.width` / `spec.height` are plugin-controlled, and the window host builds
window.open()'s feature string by concatenation:
'popup,width=' + spec.width + ',height=' + spec.height
`spec.width || 380` passed anything truthy straight through. So a width of
'300,menubar=1' would not merely be an invalid size — it would inject window
features. Less dramatically, any non-numeric value produced a malformed feature
string and a pane that failed to open for no visible reason.
They now go through _size(): Number, round, reject anything not finite and
positive, clamp to 120..4000. A hostile or careless value falls back to the
default instead of reaching window.open() at all.
Verified against the obvious inputs: '300,menubar=1' -> 380 (default), '300' ->
300, 0/-50/NaN/{}/'abc' -> 380, 1e9 -> 4000, 5 -> 120.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
671aba950c |
chore(panes): drop the leftover adoption diagnostics
A ~15-line console.info dumping computed styles, sizes, child counts and the element's inline style on every single pop-out. It was instrumentation written to chase the "panel comes home dead" bug, and it should have gone out with the rest of the debugging — it survived the cleanup. Removed rather than downgraded to console.debug: nothing here is worth keeping even behind a flag. The failures it was built to diagnose are all handled and commented now, and the paths that can still go wrong (window never loads, adopt throws) already log a console.error that says what happened. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
95d6d8a46e |
fix(panes): keep panes.css last in the pane window's cascade
_copyStyles appended the app's stylesheets to the pane document — which already links panes.css — so they landed AFTER it. In the app document panes.css loads last, after tailwind/style/v3, and its rules win ties. In the pane window that order was silently inverted, letting core styles override the pane chrome and the .fb-paned placement rules. Cascade order is not a detail here. "Looks identical" has to include the order things are said in, or the same markup with the same sheets can still render differently. The clones now go in BEFORE pane.html's own link, preserving their relative order among themselves and leaving panes.css last, exactly as in the app. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
f43779c99e |
fix(panes): detach the element when the pop-out starts, not when it lands
The window host's place() is asynchronous — it opens the window, waits for /pane to load, and only then adopts the element in. But the manager emits `panes:opened` as soon as place() returns, and the chip reacts by putting its "popped out" stub where the element used to be. So for that gap the user saw BOTH: the real panel still sitting in its original spot, and a stub next to it claiming the panel had left. On a window that never loads, that lasts the full 10s readiness timeout. Detach the element as soon as we commit to moving it. That is not destructive: the node keeps its owner document, its listeners and its closures — it is simply out of the tree, waiting for a document to be adopted into. And if the window never loads, closePane() puts it straight back at its home, which is exactly what the failure path already does. The dock host has no such gap; its place() moves the element synchronously. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
82aa8a757e |
fix(panes): harden the persisted host map against unsafe pane ids
A pane id is plugin-controlled, and it becomes a KEY in the persisted
{ paneId: hostId } map. `__proto__` and friends are not ids, they are booby
traps:
- `map['__proto__'] = 'window'` on a plain object corrupts the map, and
can reach Object.prototype.
- `map[id]` on a polluted (or hand-edited) object can return a value straight
off the prototype chain for a pane that was never remembered at all — so a
pane could be "restored" to a host nobody ever put it in.
Three layers, because each is a one-liner:
- Reject `__proto__` / `constructor` / `prototype` as pane ids at
registration, so they never reach storage.
- Re-key whatever comes out of localStorage onto a null-prototype object, so
a corrupt or hand-edited value cannot smuggle a prototype in.
- Read with an own-property check.
Also from the same review:
- Removed `window.__fbPaneWindows`. It was exposed for pane-desktop.js back
when that file needed to reach the window handles; the rewrite dropped that
need and nothing has referenced it since. Dead global, and its comment
described a collaborator that no longer exists.
- Corrected the /pane cache comment. It claimed a stale page would leave the
window blank, which stopped being true when the readiness check gained a
`doc.body` fallback — it would still work, just without the pane window's
own layout. A comment that describes a failure mode the code no longer has
is worse than no comment.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
cb425ed48d |
fix(panes): don't hide a docked pane; don't force display; restore visibility
Six more findings from CodeRabbit on #928. Three are real bugs. 1. THE CHIP HID DOCKED PANES. `_onOpened` decided "did the pane take my element?" from `ownerDocument !== document`. That is true for a pane in a pop-out window — and false for a pane moved into the DOCK, which lives in this very document. So docking a pane stamped `.fb-pane-detached` (display:none !important) onto the panel the user was looking at, and put the stub next to it instead of at its home. The element cannot answer this question — `isConnected` is true in a pane window, `ownerDocument` is this one in the dock. Both were live bugs. Ask the manager, which knows exactly what it handed to the host: `panes.elementOf(id)`. That holds for every host, and for reconciling after the fact (detail == null), which is what a plugin rebuilding its panel mid-pop-out triggers. 2. `.fb-paned` FORCED `display: block !important`. A panel that is `display:flex` or `grid` would be silently re-laid-out while detached — the exact opposite of "placement only", and precisely the kind of surprise this feature exists to avoid. Removed. Making a hidden panel visible is a separate job, and it now belongs to the manager, which does it without touching the panel's display MODE: clear `hidden`, and clear an inline `display:none` if that is how the panel hides. 3. VISIBILITY IS NOW RESTORED. The hosts used to set `el.hidden = false` and never put it back, so the docs' "core only changes placement" was a lie and a panel's hidden state was quietly lost. The manager stashes both `hidden` and the inline `display` on open and restores them on dock: a panel that was closed when you opened its pane from the tray goes back to being closed; one that was open stays open. Plus: - The launcher rebuilt its whole list on every panes:opened/closed — including the one fired by clicking a button in that list — destroying the button under the user's finger and dropping focus to <body>. It now restores focus to the toggled pane's button. - `_copyStyles` cloned every stylesheet link, including the panes.css that pane.html already loads. Skip sheets the pane document already has. - Docs: the chip may route to the DOCK, not always a window (it goes through detach() → the host router). `header` precedence was documented backwards — an explicit `header` wins. And the visibility contract above is now written down rather than being a surprise. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
859b0036e5 |
fix(panes): review fixes — stranded elements, duplicate listener, class clobber
Three real findings from CodeRabbit on #928, all in current code. 1. closePane() adopted the element out of the pane window ONLY when its original home was still connected. If the panel never had a parent (a plugin that builds it lazily and hands it straight over) or its container was torn down while the pane was out (a screen change), the whole block was skipped — leaving the element inside a window we then close, which strips every listener in its subtree. That is exactly the "comes home dead" failure this ordering exists to prevent; the guard just moved it from the common path to the rare one, where it is far harder to spot. Adopting and re-homing are two different jobs and only one of them is allowed to fail. Adopt UNCONDITIONALLY — that is what rescues the element — and insert only when there is somewhere to insert it. With no home the element ends up owned by this document but not in it: detached, intact, listeners alive, ready for the plugin to re-insert. 2. The pane window's `beforeunload` handler was registered TWICE, comment block and all — a bad scripted edit on my part. Harmless (the handler is idempotent via panes.isOpen) but dead duplicate code. Also fixed the stale comment further down that still claimed there was no beforeunload listener at all. 3. _copyStyles ASSIGNED className on the pane document's <html> and <body> instead of merging. pane.html sets `class="fb-pane-window"` on <html>, and panes.css hangs the pane window's own chrome off exactly that — so copying the app's classes over it silently took the pane window's own layout with them. Merge both class lists, and append the interface-scale inline style rather than replacing the attribute. Also guarded the docs' integration example behind a feedBack.panes check: the doc says the API is optional, and then showed an example that would throw on a host without it. Not applicable (reviewed against |
||
|
|
a7348052ae |
fix(panes): give the pop-out stub a focus ring
.fb-pane-stub is a <button>, and both of its sibling controls (.fb-pane-chip, .fb-pane-card-btn) have an explicit :focus-visible outline. It didn't, so keyboard focus fell back to the UA default and looked inconsistent next to them. Found by CodeRabbit on #928. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
1e5282e27e |
fix(panes): get the element out before the pane window's document dies
Docking a popped-out panel brought it home DEAD. It rendered perfectly —
right markup, right size, right place — and every control in it was inert:
the close button, the sliders, the presets, even the pop-out chip. A
photograph of a panel.
Closing a pane window tears down its document, and the panel was still
inside it. The node itself survives (the manager holds a reference), but
every event listener in its subtree goes with the document that hosted
them. Two paths did this:
1. closePane() called the host's unplace() — which closes the window —
BEFORE adopting the element back. Order is now reversed, and the
comment says why so nobody helpfully "tidies" it back.
2. The user closing the pane window themselves was only noticed by the
`closed` poll, which by definition runs AFTER the document is gone.
The window now gets a `beforeunload` listener that brings the element
home while its document is still alive.
That listener has to be attached AFTER /pane loads: window.open() hands
back a throwaway about:blank document, and anything registered on it is
discarded when the real page replaces it. This is the same trap that made
the pane window blank in the first place — adopt into about:blank and the
panel is destroyed a moment later — and it is now handled in both places.
The `closed` poll stays, but only as a last-resort net for a CRASHED pane
window, where nothing can be saved.
Also fixed while chasing this:
- The chip stamped `.fb-pane-detached` (display:none !important) onto the
element to hide it in the main window — and that element is the one we
move, so the class travelled with it and blanked the pane window. The
chip now only hides an element the pane did NOT take, and marks the hole
with its stub otherwise. "Did not take" is an ownerDocument test, not
isConnected: a panel sitting in a pane window IS connected, just not
here, and a plugin that rebuilds its panel (Camera Director does, on
every mode change) re-runs attachChip while popped out.
- The stub was inserted "before the element", which is nowhere — the
element has left the document. The manager now hands over the element's
recorded home, and the stub goes there.
- GET /pane sent no cache headers. A stale copy is especially nasty here:
the opener waits for an element inside that page before adopting, so an
old cached version means the pane window just sits there blank.
Verified in the desktop app: pop out, use the controls in the pane window,
dock back, use them again. Panel comes home alive.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
9e9f0fdac6 |
fix(panes): adopt into the real pane document, not about:blank
Both pop-outs opened blank. window.open() returns immediately, and the window it hands back already has a document — an `about:blank` one, whose readyState is 'complete'. So the host cheerfully adopted the panel into THAT, it worked for a few milliseconds, and then /pane finished loading, replaced the document, and took the panel with it. Blank window, vanished element. Waiting for 'load' is no better: it may already have fired for about:blank before we could listen. So don't trust readyState and don't trust 'load' — wait for the one thing that exists only in the document we actually want: pane.html's #fb-pane-root. Poll for it (guarding the cross-document window while it is mid-swap), give up after 10s, and on failure bring the element home rather than stranding it in a window that never loaded. Also drop the popup's 'beforeunload' listener: it was registered on the about:blank window and discarded along with it, so it never fired. The `closed` poll is what notices a user shutting a pane window — as it must be anyway, since a crashed renderer never says goodbye either. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
188bdaa837 |
feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.
What a user wants from "pop this out" is the thing they popped out.
So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.
The plugin's side collapses to two lines:
feedBack.panes.register({ id, title, element: () => panelEl });
feedBack.panes.attachChip(panelEl, id);
and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.
Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.
Consequences worth knowing:
- The window MUST be opened by the renderer with window.open(), not by the
desktop's main process: a window we did not open gives this realm no handle
to its document, and without the handle there is nothing to adopt into.
Electron turns the same-origin window.open() into a real BrowserWindow
anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
A plugin panel is nearly always a fixed overlay pinned to a corner of the
app; alone in a 380px window that positioning is nonsense. Colours, borders,
padding, fonts and the panel's own internal layout are untouched — the whole
promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
among its siblings.
- The plugin's code still runs in the main window. So a document.body
.appendChild() inside a panel (a tooltip, a popover) lands in the main
window, not the pane — anchor to the panel instead. And a continuously
animating panel may run slowly while the main window is backgrounded, since
its rAF lives there. Both documented.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
330995588c |
feat(panes): panes.state(id) — let a plugin apply its own pane's values
mirrorGlobal covers the case where a pane drives a plain global that some renderer reads each frame. It does not cover the far more common one: a plugin whose MAIN-realm code is the authority — it clamps, it persists, it emits events, it owns the audio graph or the camera rig — and which must therefore APPLY the pane's values itself rather than have core splat them somewhere. Camera Director is the case that forced this. Its brain is the sole writer of the camera store, the sole broadcaster on splitscreen's channel, and the only thing that clamps an axis to its legal range. A pane cannot write window.__h3dCamCtl behind its back without desynchronising its presets, its persistence, and the panel's own sliders — and running the brain inside the pane realm would make it a SECOND store writer and a second broadcaster, racing the real one. So: `panes.state(id)` hands the main realm the open pane's store (get/set/all/subscribe). A plugin seeds it on `panes:opened`, subscribes, and applies what comes back through its own API. The pane stays realm-agnostic — it only ever touches ctx.state — and the plugin stays the single source of truth. For that to work, the hub now broadcasts EVERY change to the store, not just the ones a pane asked for: it subscribes to the store on connect rather than echoing pane-originated writes by hand. A value the plugin clamps or corrects therefore reaches the pane window immediately, and there is exactly one path by which state arrives in a pane — so it cannot drift. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
254e26bb3a |
feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs
Three things a plugin needs before it can actually use panes.
## mirrorGlobal — the camera-director problem
The 3D highways read their free camera from a plain global,
`window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in
_resolveFreeCam(). A camera panel in the main window just writes that
object and the camera moves. A panel in a POP-OUT window cannot:
window.__h3dCamCtl there is a different object in a different realm, and
writing it moves nothing.
So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and
pane-mirror.js (main realm, where the renderers live) copies that pane's
state onto the global whenever it changes. highway_3d, keys_highway_3d
and drum_highway_3d are NOT modified and do not know panes exist.
The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A
renderer may be holding the reference, and swapping in a new object would
leave it reading an orphan. Keys the pane doesn't set are left alone
rather than deleted — the global may carry a renderer's own bookkeeping.
Closing the pane deliberately leaves the global as-is: closing the camera
panel should not snap the camera back to a default, which is exactly what
happens today (nobody clears __h3dCamCtl).
## Manifest-declared panes
"panes": [{ "id": "camera_director", "title": "Camera Director",
"script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }]
Declaring a pane beats calling panes.register() from screen.js because it
becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN
EVER HAVING BEEN VISITED — core registers a stub from the manifest and
fetches the script only when the user opens it. A pane you can only reach
by first navigating to the screen it was meant to replace is not much of a
pane.
The script sets `window.feedBackPane_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> convention, and the SAME file is what
a pop-out window loads in its own realm.
`script` is validated as a relpath under the plugin's src/ and served
through the sandboxed /api/plugins/<id>/src/ route — the containment rule
`styles` already has for assets/. Traversal, absolute paths, drive letters,
backslashes and non-.js are rejected; a bad entry is dropped with a warning
rather than failing the whole plugin, because one malformed pane should not
cost the user a working plugin.
Note the projection is written TWICE — _nav_entry() and the /api/plugins
route re-project independently — so panes had to be added to both, plus the
pending branch (a pane can be opened while its plugin is still installing
deps; the script is fetched on open, not at discovery).
## docs/plugin-panes.md
The contract, and the one rule it all hangs on: mount(root, ctx) runs in a
realm that may not have the app in it. Everything comes through ctx, or the
pane works docked and silently dies popped out.
Verified: manifest validation rejects ../.., C:\, non-.js, dupes and
missing fields while passing a good entry; /api/plugins projects panes[] for
all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held
the way _resolveFreeCam holds it sees the change, and a renderer's own field
on that object survives — both for a local write and for a write arriving
over the channel from a pop-out realm.
pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including
the one in tests/test_plugins.py) and are unrelated.
Signed-off-by: topkoa <topkoa@gmail.com>
|
||
|
|
d508380532 |
feat(panes): desktop host — real windows and the system tray
Registers a `desktop` pane host at priority 20, above the browser pop-up host (10) and the dock (0), whenever the Electron bridge exposes feedBackDesktop.panes. A popped-out pane then gets a real BrowserWindow: it remembers where you put it, can float above everything, minimizes to the system tray, and appears in the tray's menu. In a plain browser — or on an older desktop build that predates the bridge — this file registers nothing and the browser pop-up host handles detach exactly as before. Nothing else in the pane system changes. That is what the host registry is for. Two things only this realm can decide, so it owns them: - The user closed a pane window (or it crashed). Close the pane, or the dialog its pop-out chip hid never comes back and the user is left with no way to reach their own UI. - The tray asked to toggle a pane it has no window for. Main cannot know what opening one means — the pane might belong in the dock — so it asks. Unlike a browser pop-up, this host needs no user gesture, so it sets autoRestore: true — a pane you left popped out comes back popped out, where you left it, on the next launch. Pairs with got-feedback/feedBack-desktop#103. Signed-off-by: topkoa <topkoa@gmail.com> |
||
|
|
fefb9051a4 |
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>
|
||
|
|
e5cbea2e9f |
feat(panes): core detachable pane system + pop-out chip
The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.
Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.
The adoption cost for a plugin is two calls:
feedBack.panes.register({ id, title, icon, mount, unmount });
feedBack.panes.attachChip(myExistingDialogEl, id);
attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.
Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:
ctx.call(domain, cmd, payload) -> the capability bus
ctx.on(event, fn) -> the feedBack bus (allowlisted)
ctx.subscribe(stream, fn) -> playhead / meters
ctx.state.get/set -> persisted, main realm is the only writer
ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()
ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.
Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.
Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.
Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.
Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.
Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.
Signed-off-by: topkoa <topkoa@gmail.com>
|