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>
This commit is contained in:
topkoa
2026-07-12 19:24:00 -04:00
parent b74a364857
commit cb425ed48d
7 changed files with 360 additions and 292 deletions
+16 -8
View File
@@ -88,14 +88,22 @@
// a wrapper, a launcher row — that element is still here, and we hide it as
// before.
function _onOpened(rec, detail) {
// "Moved" means the element is not in THIS document — either because this
// open took it, or because it is already sitting in a pane window from an
// earlier one. The ownerDocument test is what makes re-attaching a chip
// safe: a plugin that rebuilds its panel (Camera Director does, on every
// mode change) re-runs attachChip while the pane is still popped out, and
// an isConnected test would say "still here" — it IS connected, to the pane
// window — and we would stamp display:none onto the live pane.
const moved = (detail && detail.el === rec.el) || rec.el.ownerDocument !== document;
// Did the pane take MY element?
//
// Ask the manager, which knows exactly what it handed to the host. Do not
// try to infer it from the element:
//
// - `isConnected` says "still here" for a panel sitting in a pane window.
// It IS connected — to that window.
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
// which is in this very document. Hiding it there would blank a pane the
// user is looking at.
//
// Both were live bugs. The manager's answer is the only one that holds for
// every host, and it works when reconciling after the fact (detail == null),
// which is what a plugin rebuilding its panel mid-pop-out triggers.
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
const moved = takenEl === rec.el;
if (!moved && rec.el.isConnected) {
rec.el.classList.add('fb-pane-detached');
+113 -114
View File
@@ -1,114 +1,113 @@
/*
* fee[dB]ack — pane dock (the in-window pane host).
*
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* opening the next), which is exactly why you cannot watch the mixer while riding
* the camera. Cards here coexist.
*
* As everywhere in this system, the card holds the plugin's REAL element — moved,
* not copied. The dock is a frame; the panel inside it is the panel.
*
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* child outside every .screen, so the per-song teardown never sees it.
*
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
* (an OS window), a pane can always land here, so opening one can never fail.
*/
(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-dock.js');
return;
}
let dockEl = null;
const cards = new Map(); // paneId -> card element
function dock() {
if (dockEl && dockEl.isConnected) return dockEl;
dockEl = document.getElementById('fb-pane-dock');
if (!dockEl) {
dockEl = document.createElement('div');
dockEl.id = 'fb-pane-dock';
dockEl.className = 'fb-pane-dock';
dockEl.setAttribute('role', 'region');
dockEl.setAttribute('aria-label', 'Panes');
document.body.appendChild(dockEl);
}
return dockEl;
}
function _syncEmpty() {
dock().classList.toggle('is-empty', cards.size === 0);
}
function place(spec, el) {
const card = document.createElement('section');
card.className = 'fb-pane-card';
card.dataset.paneId = spec.id;
card.setAttribute('aria-label', spec.title);
const head = document.createElement('header');
head.className = 'fb-pane-card-head';
const title = document.createElement('span');
title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title comes from a plugin.
title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button');
close.type = 'button';
close.className = 'fb-pane-card-btn';
close.setAttribute('aria-label', 'Close ' + spec.title);
close.title = 'Close';
close.textContent = '✕';
close.addEventListener('click', () => panes.close(spec.id));
head.appendChild(title);
head.appendChild(close);
const body = document.createElement('div');
body.className = 'fb-pane-card-body';
// Same neutralisation as the window host: the panel was a fixed overlay
// pinned to a corner of the app, and inside a card that positioning is
// nonsense. .fb-paned unpins it and nothing else.
el.classList.add('fb-paned');
el.hidden = false;
body.appendChild(el);
card.appendChild(head);
card.appendChild(body);
dock().appendChild(card);
cards.set(spec.id, card);
_syncEmpty();
}
function unplace(id, el) {
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const card = cards.get(id);
if (card) card.remove();
cards.delete(id);
_syncEmpty();
}
function focus(id) {
const card = cards.get(id);
if (!card) return;
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation.
card.classList.remove('is-flash');
void card.offsetWidth;
card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700);
}
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
})();
/*
* fee[dB]ack — pane dock (the in-window pane host).
*
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* opening the next), which is exactly why you cannot watch the mixer while riding
* the camera. Cards here coexist.
*
* As everywhere in this system, the card holds the plugin's REAL element — moved,
* not copied. The dock is a frame; the panel inside it is the panel.
*
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* child outside every .screen, so the per-song teardown never sees it.
*
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
* (an OS window), a pane can always land here, so opening one can never fail.
*/
(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-dock.js');
return;
}
let dockEl = null;
const cards = new Map(); // paneId -> card element
function dock() {
if (dockEl && dockEl.isConnected) return dockEl;
dockEl = document.getElementById('fb-pane-dock');
if (!dockEl) {
dockEl = document.createElement('div');
dockEl.id = 'fb-pane-dock';
dockEl.className = 'fb-pane-dock';
dockEl.setAttribute('role', 'region');
dockEl.setAttribute('aria-label', 'Panes');
document.body.appendChild(dockEl);
}
return dockEl;
}
function _syncEmpty() {
dock().classList.toggle('is-empty', cards.size === 0);
}
function place(spec, el) {
const card = document.createElement('section');
card.className = 'fb-pane-card';
card.dataset.paneId = spec.id;
card.setAttribute('aria-label', spec.title);
const head = document.createElement('header');
head.className = 'fb-pane-card-head';
const title = document.createElement('span');
title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title comes from a plugin.
title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button');
close.type = 'button';
close.className = 'fb-pane-card-btn';
close.setAttribute('aria-label', 'Close ' + spec.title);
close.title = 'Close';
close.textContent = '✕';
close.addEventListener('click', () => panes.close(spec.id));
head.appendChild(title);
head.appendChild(close);
const body = document.createElement('div');
body.className = 'fb-pane-card-body';
// Same neutralisation as the window host: the panel was a fixed overlay
// pinned to a corner of the app, and inside a card that positioning is
// nonsense. .fb-paned unpins it and nothing else.
el.classList.add('fb-paned');
body.appendChild(el);
card.appendChild(head);
card.appendChild(body);
dock().appendChild(card);
cards.set(spec.id, card);
_syncEmpty();
}
function unplace(id, el) {
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const card = cards.get(id);
if (card) card.remove();
cards.delete(id);
_syncEmpty();
}
function focus(id) {
const card = cards.get(id);
if (!card) return;
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation.
card.classList.remove('is-flash');
void card.offsetWidth;
card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700);
}
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
})();
+10
View File
@@ -25,6 +25,14 @@
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
if (!listEl) return;
const all = panes.list();
// Toggling a pane from this list fires panes:opened/closed, which re-renders
// the list — destroying the very button the user just pressed and dropping
// focus to <body>. Remember which one had it and give it back, so keyboard
// and screen-reader users can toggle several panes without losing their place.
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
? document.activeElement.dataset.paneId : null;
listEl.replaceChildren();
if (!all.length) {
@@ -39,6 +47,7 @@
const b = document.createElement('button');
b.type = 'button';
b.className = 'v3-pop-btn';
b.dataset.paneId = p.id;
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
b.addEventListener('click', (e) => {
@@ -46,6 +55,7 @@
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
});
listEl.appendChild(b);
if (p.id === focusedId) b.focus();
});
}
+29 -1
View File
@@ -135,14 +135,30 @@
// the pane window, which then renders nothing at all.
el.classList.remove('fb-pane-detached');
// Make it visible, and remember exactly how it wasn't.
//
// A plugin's panel is usually hidden until its launcher is clicked, and a
// pane can be opened from the tray or the rail without that ever happening.
// So we un-hide it — but only in the two ways a panel is actually hidden
// (`hidden`, or an inline `display:none`), and we put both back on dock.
//
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
// must stay flex. Neutralising placement is one thing; silently re-laying
// out someone's panel is another.
const vis = { hidden: el.hidden, display: el.style.display };
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
try {
host.place(spec, el);
} catch (e) {
console.error('[panes] host', host.id, 'failed to take', id, e);
el.hidden = vis.hidden;
el.style.display = vis.display;
return false;
}
open.set(id, { spec, hostId: host.id, el, home });
open.set(id, { spec, hostId: host.id, el, home, vis });
if (opts.remember !== false) _rememberHost(id, host.id);
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
// `home` rides along because the element has LEFT this document — anything
@@ -207,6 +223,14 @@
const host = hosts.get(entry.hostId);
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
// Put its visibility back exactly as we found it. A panel that was closed
// when the pane was opened from the tray goes back to being closed; one that
// was open stays open. We forced it visible; we un-force it.
if (entry.vis) {
entry.el.hidden = entry.vis.hidden;
entry.el.style.display = entry.vis.display;
}
if (opts.remember !== false) _rememberHost(id, null);
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
_emit('panes:closed', { id: id, host: entry.hostId });
@@ -298,6 +322,10 @@
// Where an open pane's element came from. The chip needs this to mark the
// hole the element left, since it can no longer ask the element itself.
homeOf: (id) => { const e = open.get(id); return e ? e.home : null; },
// The element a host actually took. The chip needs this to tell "the pane
// took MY element" from "the pane took something else" — and it cannot ask
// the element, which may now be in a dock card or another window entirely.
elementOf: (id) => { const e = open.get(id); return e ? e.el : null; },
get: (id) => specs.get(id) || null,
list: () => Array.from(specs.values()).map((s) => ({
id: s.id, title: s.title, icon: s.icon,
+6 -4
View File
@@ -55,7 +55,13 @@
// Cloned rather than shared: a <link> node can only live in one document, and
// we are not about to steal the app's own stylesheet out of its head.
function _copyStyles(doc) {
// pane.html already links panes.css, so don't clone a second copy of it —
// duplicate sheets cost a redundant fetch and an extra style recalc for no
// change in appearance.
const have = new Set(
Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href));
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
if (node.tagName === 'LINK' && have.has(node.href)) return;
try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ }
});
// Carry the theme/scale hooks the app hangs on <html> and <body>. v3 keys
@@ -150,10 +156,6 @@
// casting a drop shadow over nothing. Neutralise the *placement* while
// touching nothing else about how it looks.
el.classList.add('fb-paned');
// Some panels are hidden until opened (Camera Director's is `hidden` until
// you click its launcher). It is being shown on purpose now.
el.hidden = false;
root.appendChild(doc.adoptNode(el));
doc.title = spec.title + ' — fee[dB]ack';
+6 -2
View File
@@ -34,8 +34,12 @@
max-height: none !important;
z-index: auto !important;
box-shadow: none !important;
/* A panel hidden until its launcher is clicked is being shown on purpose now. */
display: block !important;
/* Deliberately NO `display` override. Forcing `display:block` would silently
re-lay-out a panel that is `display:flex` or `grid` — which is the opposite
of "placement only", and exactly the kind of surprise this feature exists to
avoid. Making a hidden panel visible is the manager's job (it clears the
element's `hidden`/inline `display:none` on open and restores them on dock),
and it does it without touching the panel's own display mode. */
}
/* ── The pop-out chip ────────────────────────────────────────────────────── */