mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 22:42:25 +00:00
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>
256 lines
7.8 KiB
CSS
256 lines
7.8 KiB
CSS
/* fee[dB]ack — detachable panes.
|
|
*
|
|
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin can use the
|
|
* chip and the dock without shipping its own stylesheet — the same reason
|
|
* .fb-selectable is hand-authored in core CSS.
|
|
*
|
|
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
|
|
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) — those numbers live
|
|
* *inside* #player's stacking context, and #player itself is `position:fixed;
|
|
* z-index:100` covering the viewport. A dock below 100 is invisible on the
|
|
* player screen, which is the one screen panes exist for.
|
|
*
|
|
* So: body-level ladder. #player 100 < dock 110 < toasts 120 < modals 200.
|
|
* A pane is persistent furniture that outranks the player, but never outranks a
|
|
* notification or an interruption.
|
|
*/
|
|
|
|
/* ── The pop-out chip ────────────────────────────────────────────────────── */
|
|
|
|
.fb-pane-chip {
|
|
flex: 0 0 auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.5rem;
|
|
height: 1.5rem;
|
|
margin-left: auto; /* right-align inside a flex header */
|
|
border: 1px solid rgba(51, 65, 85, .7);
|
|
border-radius: .4rem;
|
|
background: rgba(30, 41, 59, .8);
|
|
color: #94a3b8;
|
|
font-size: .8rem;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
transition: color .15s, border-color .15s, background .15s;
|
|
}
|
|
.fb-pane-chip:hover {
|
|
color: #e2e8f0;
|
|
border-color: #4080e0;
|
|
background: rgba(64, 128, 224, .18);
|
|
}
|
|
.fb-pane-chip:focus-visible {
|
|
outline: 2px solid #4080e0;
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
/* The dialog the chip lives in, while its pane is popped out. A dedicated class
|
|
rather than `.hidden`/[hidden]: the dialogs we attach to (the mixer popover,
|
|
the rail popovers) toggle those themselves, and two owners of one class is a
|
|
bug waiting to happen. */
|
|
.fb-pane-detached { display: none !important; }
|
|
|
|
/* What the user sees in the dialog's place. */
|
|
.fb-pane-stub {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .4rem;
|
|
padding: .35rem .6rem;
|
|
border: 1px dashed rgba(64, 128, 224, .55);
|
|
border-radius: .5rem;
|
|
background: rgba(64, 128, 224, .08);
|
|
color: #93b4e8;
|
|
font-size: .72rem;
|
|
cursor: pointer;
|
|
transition: background .15s, border-color .15s;
|
|
}
|
|
.fb-pane-stub:hover {
|
|
background: rgba(64, 128, 224, .18);
|
|
border-color: #4080e0;
|
|
}
|
|
.fb-pane-stub-glyph { font-size: .85rem; }
|
|
|
|
/* ── The dock ────────────────────────────────────────────────────────────── */
|
|
|
|
.fb-pane-dock {
|
|
position: fixed;
|
|
top: 4.5rem; /* clear of the topbar */
|
|
right: 1rem;
|
|
bottom: 1rem;
|
|
z-index: 110; /* above #player (100), below toasts (120) */
|
|
width: 22rem;
|
|
max-width: calc(100vw - 2rem);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .6rem;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
/* The dock is a frame around cards, not a surface — pointer events belong to
|
|
the cards, so the empty space below them never eats a click meant for the
|
|
highway. */
|
|
pointer-events: none;
|
|
scrollbar-width: thin;
|
|
}
|
|
.fb-pane-dock.is-empty { display: none; }
|
|
|
|
.fb-pane-card {
|
|
pointer-events: auto;
|
|
flex: 0 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: rgba(15, 23, 42, .96);
|
|
border: 1px solid rgba(51, 65, 85, .6);
|
|
border-radius: .9rem;
|
|
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fb-pane-card-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
padding: .5rem .7rem;
|
|
border-bottom: 1px solid rgba(51, 65, 85, .5);
|
|
background: rgba(30, 41, 59, .6);
|
|
}
|
|
.fb-pane-card-title {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: .78rem;
|
|
font-weight: 600;
|
|
color: #cbd5e1;
|
|
}
|
|
.fb-pane-card-btn {
|
|
flex: 0 0 auto;
|
|
width: 1.4rem;
|
|
height: 1.4rem;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 0;
|
|
border-radius: .35rem;
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
font-size: .75rem;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
}
|
|
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
|
|
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
|
|
|
.fb-pane-card-body {
|
|
padding: .75rem;
|
|
font-size: .8rem;
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
/* focus(id) — brief highlight so a re-open of an already-open pane says so
|
|
instead of appearing to do nothing. */
|
|
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
|
|
@keyframes fb-pane-flash {
|
|
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
|
|
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.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 {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: .75rem;
|
|
padding: .2rem 0;
|
|
}
|
|
.fb-pane-key {
|
|
color: #94a3b8;
|
|
font-size: .7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: .04em;
|
|
white-space: nowrap;
|
|
}
|
|
.fb-pane-val {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: .78rem;
|
|
color: #e2e8f0;
|
|
text-align: right;
|
|
}
|
|
.fb-pane-val.is-num { font-variant-numeric: tabular-nums; }
|
|
.fb-pane-dim { color: #64748b; font-size: .72rem; }
|
|
|
|
.fb-pane-bar {
|
|
position: relative;
|
|
height: .35rem;
|
|
border-radius: 999px;
|
|
background: rgba(51, 65, 85, .8);
|
|
overflow: hidden;
|
|
}
|
|
.fb-pane-bar-fill {
|
|
position: absolute;
|
|
inset: 0 auto 0 0;
|
|
width: 100%;
|
|
border-radius: inherit;
|
|
background: #4080e0;
|
|
transform: scaleX(0);
|
|
transform-origin: left;
|
|
/* scaleX is compositor-only; a width write would re-run layout every frame. */
|
|
}
|
|
.fb-pane-bar-fill.is-level { background: linear-gradient(90deg, #22c55e, #eab308 70%, #ef4444); }
|