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>
This commit is contained in:
topkoa
2026-07-12 20:44:16 -04:00
parent 3a50e593bf
commit 0955f0b6f2
2 changed files with 64 additions and 22 deletions
+5 -1
View File
@@ -104,7 +104,11 @@
function focus(id) { function focus(id) {
const card = cards.get(id); const card = cards.get(id);
if (!card) return; if (!card) return;
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); // Honour prefers-reduced-motion, as the flash animation below already does
// in panes.css. A smooth scroll is motion too, and a user who asked for less
// of it meant this as well.
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of // Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation. // the same card would otherwise be a no-op animation.
card.classList.remove('is-flash'); card.classList.remove('is-flash');
+59 -21
View File
@@ -74,25 +74,66 @@
if (node.tagName === 'LINK' && have.has(node.href)) return; if (node.tagName === 'LINK' && have.has(node.href)) return;
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ } try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
}); });
// Carry the theme/scale hooks the app hangs on <html> and <body>. v3 keys _syncChrome(doc);
// off these for its colour tokens and interface scale, and a panel that }
// lands without them renders in the wrong palette at the wrong size.
// // The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, // for its colour tokens and its interface scale, and a panel that lands without
// and panes.css hangs the pane window's own chrome off it. Overwriting the // them renders in the wrong palette at the wrong size.
// class list would take that with it and the window would lose its own //
// layout — the app's classes and the pane document's are both wanted. // MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
// panes.css hangs the pane window's own chrome off it. Overwriting the class
// list would take that with it and the window would lose its own layout — the
// app's classes and the pane document's are both wanted.
//
// Re-run on every theme/scale change for as long as the pane is open (see
// _followChrome). A one-time snapshot would leave an already-open pane rendering
// at the old scale the moment the user touched Interface size — "looks identical"
// has to keep being true, not merely start out true.
function _syncChrome(doc) {
try { try {
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c)); document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
document.body.classList.forEach((c) => doc.body.classList.add(c)); document.body.classList.forEach((c) => doc.body.classList.add(c));
// The inline style on <html> carries the interface-scale custom property // The inline style on <html> carries the interface-scale custom property
// (--fb-scale). Merge it in rather than replacing the attribute, for the // (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
// same reason. // sets no inline style of its own, so there is nothing here to preserve —
const scale = document.documentElement.style.cssText; // and merging by concatenation would grow the attribute without bound as
if (scale) doc.documentElement.style.cssText += ';' + scale; // the user dragged the scale slider.
} catch (e) { /* non-fatal */ } doc.documentElement.style.cssText = document.documentElement.style.cssText;
} catch (e) { /* the window may be closing under us */ }
} }
// paneId -> stop following the app's theme/scale
const chromeFollowers = new Map();
function _followChrome(paneId, doc) {
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
const sync = () => _syncChrome(doc);
bus.on('scale:changed', sync);
bus.on('theme:changed', sync);
bus.on('v3:cosmetics-applied', sync);
chromeFollowers.set(paneId, () => {
bus.off('scale:changed', sync);
bus.off('theme:changed', sync);
bus.off('v3:cosmetics-applied', sync);
});
}
function _unfollowChrome(paneId) {
const off = chromeFollowers.get(paneId);
if (off) { off(); chromeFollowers.delete(paneId); }
}
// How long a "we cannot even see the pop-out's document" condition has to persist
// before we call it fatal. A SecurityError means the window is not reachable from
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
// rather than bailing on the first tick, because 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 the 10s a user would otherwise stare at a detached panel for.
const UNREACHABLE_GRACE_MS = 1000;
// Wait for the REAL pane document. // Wait for the REAL pane document.
// //
// window.open() returns immediately, with an `about:blank` document that is // window.open() returns immediately, with an `about:blank` document that is
@@ -103,14 +144,6 @@
// So we do not trust readyState, and we do not trust 'load' (which may have // So we do not trust readyState, and we do not trust 'load' (which may have
// fired for about:blank before we could listen). We wait for the one thing that // fired for about:blank before we could listen). We wait for the one thing that
// only exists in the document we actually want: pane.html's #fb-pane-root. // only exists in the document we actually want: pane.html's #fb-pane-root.
// How long a "we cannot even see the pop-out's document" condition has to persist
// before we call it fatal. A SecurityError means the window is not reachable from
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
// rather than bailing on the first tick, because 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 the 10s a user would otherwise stare at a detached panel for.
const UNREACHABLE_GRACE_MS = 1000;
function _whenReady(w, onReady, onFail) { function _whenReady(w, onReady, onFail) {
const deadline = performance.now() + 10000; const deadline = performance.now() + 10000;
@@ -190,6 +223,10 @@
root.appendChild(doc.adoptNode(el)); root.appendChild(doc.adoptNode(el));
doc.title = spec.title + ' — fee[dB]ack'; doc.title = spec.title + ' — fee[dB]ack';
// Keep the pane window's theme and interface scale in step with the app for
// as long as it is open. Stopped in unplace().
_followChrome(spec.id, doc);
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES. // THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
// //
// When the user closes a pane window, its document is torn down — and the // When the user closes a pane window, its document is torn down — and the
@@ -267,6 +304,7 @@
} }
function unplace(id, el) { function unplace(id, el) {
_unfollowChrome(id);
// Hand the element back unmarked. The manager returns it to its home right // 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 // 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. // stayed .fb-paned would come back with its own positioning stripped.