diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js
index 4d81ecfc..259cec10 100644
--- a/accessibility/keyboardui.js
+++ b/accessibility/keyboardui.js
@@ -5,13 +5,22 @@ import { SHORTCUTS_HELP_URL } from '../config.js';
import { stopCanvasKeyboardMode } from '../ui/canvas-utils.js';
import { focusToolboxRestoringCategory } from '../main/toolboxfocus.js';
-// Matches the CSS `(max-width: 1024px) and (orientation: landscape)` breakpoint
-// where the info panel is hidden and its shortcuts panel must be shown as a
-// modal instead of docked (see style.css). Portrait phones are narrow too but
-// tall enough for the docked panel to still fit, so landscape is required.
+// Must match the CSS breakpoint in style.css that hides the docked info panel.
const isNarrowLayout = () =>
window.matchMedia('(max-width: 1024px) and (orientation: landscape)').matches;
+// Measured: ~65px chrome plus ~40px per em per row; below 2 rows the modal reads better than a docked scroll.
+const MIN_DOCKED_ROWS = 2;
+const PANEL_CHROME_HEIGHT = 65;
+const ROW_HEIGHT_PER_EM = 40;
+
+// offsetHeight is 0 both when too short and when unmeasurable (hidden/jsdom); isNarrowLayout() already handles the hidden case.
+const isDockedAreaTooShort = (fontSize) => {
+ const height = document.getElementById('info-panel-body')?.offsetHeight ?? 0;
+ const needed = PANEL_CHROME_HEIGHT + MIN_DOCKED_ROWS * ROW_HEIGHT_PER_EM * fontSize;
+ return height > 0 && height < needed;
+};
+
// Area menu accessed with Ctrl + B to quickly skip to
// different areas on the interface
@@ -675,7 +684,8 @@ const InfoPanel = {
this._body = document.getElementById('info-panel-body');
},
- register(id, label) {
+ // owner.toggle(), not activate()/deactivate(), so the tab gets the same docked/modal logic as every other entry point.
+ register(id, label, owner) {
const btn = document.createElement('button');
btn.id = `info-tab-btn-${id}`;
btn.className = 'info-tab-btn bigbutton';
@@ -683,7 +693,7 @@ const InfoPanel = {
btn.setAttribute('aria-selected', 'false');
btn.setAttribute('aria-controls', `info-tab-panel-${id}`);
btn.textContent = label;
- btn.addEventListener('click', () => this.toggle(id));
+ btn.addEventListener('click', () => (owner ? owner.toggle() : this.toggle(id)));
this._tablist.appendChild(btn);
const divider = document.createElement('div');
divider.className = 'toolbar-divider';
@@ -715,6 +725,9 @@ const InfoPanel = {
tab.btn.setAttribute('aria-selected', 'true');
tab.btn.classList.add('active');
tab.panel.classList.remove('hidden');
+ // All tab panels share this scroll container, so switching tabs must
+ // reset it or the new tab opens pre-scrolled to the old tab's position.
+ this._body.scrollTop = 0;
tab.panel.focus();
},
@@ -738,125 +751,40 @@ const SHORTCUTS_FONT_SIZES = [0.8, 1.0, 1.2, 1.4, 1.6, 1.8];
const SHORTCUTS_FONT_SIZE_KEY = 'flock-shortcuts-font-size';
const SHORTCUTS_FONT_SIZE_DEFAULT = 1.2;
-const ShortcutsPanel = {
- panel: null,
- previousFocus: null,
- fontSize:
- parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT,
-
- init() {
- this.createPanel();
- this.setupListeners();
- window.flockShortcutsPanel = this;
+// Modal presentation shared by info-panel tabs; mixers must set _modalTitleId, _tabBtnId, _closeLabelKey.
+const ModalPanelBehaviour = {
+ shouldBeModal() {
+ return isNarrowLayout() || isDockedAreaTooShort(this.fontSize);
+ },
- // If the viewport crosses the breakpoint while the panel is open, switch it
- // between docked and modal so it never ends up docked in a hidden panel.
- window.addEventListener('resize', () => {
+ // Resize listener catches the media-query flip; ResizeObserver catches docked-area size changes without a window resize (e.g. play mode).
+ watchDockedSpace() {
+ const reevaluate = () => {
if (this.panel.classList.contains('hidden')) return;
- if (isNarrowLayout() && !this._modalActive) this.enterModal();
- else if (!isNarrowLayout() && this._modalActive) {
- // exitModal() itself doesn't restore focus (unlike hide(), which calls
- // it and then does this) — without it, closing the modal this way (e.g.
- // rotating the device) can leave focus on the just-removed close button.
+ if (this.shouldBeModal()) this.enterModal();
+ else if (this._modalActive) {
+ // exitModal() reparents the panel, which blurs whatever was focused even if that element survives the move; refocus it in place rather than jumping to previousFocus, which is only for the removed close button.
+ const active = document.activeElement;
+ const activeSurvives = this.panel.contains(active) && active !== this._closeBtn;
this.exitModal();
- this.previousFocus?.focus();
+ if (activeSurvives) {
+ active.focus();
+ } else {
+ this.previousFocus?.focus();
+ this.previousFocus = null;
+ }
}
- });
- },
-
- adjustFontSize(delta) {
- const sizes = SHORTCUTS_FONT_SIZES;
- const idx = sizes.indexOf(this.fontSize);
- const next = sizes[Math.max(0, Math.min(sizes.length - 1, idx + delta))];
- if (next === this.fontSize) return;
- this.fontSize = next;
- localStorage.setItem(SHORTCUTS_FONT_SIZE_KEY, next);
- this.panel.querySelector('#shortcuts-list').style.fontSize = next + 'em';
- this.panel.querySelector('.shortcuts-decrease-btn').disabled = next === sizes[0];
- this.panel.querySelector('.shortcuts-increase-btn').disabled = next === sizes[sizes.length - 1];
- },
-
- createPanel() {
- const panel = InfoPanel.register('shortcuts', translate('shortcut_panel_title'));
- const btn = document.getElementById('info-tab-btn-shortcuts');
- btn.setAttribute('aria-label', translate('shortcut_panel_title'));
- btn.setAttribute('title', translate('shortcut_panel_title'));
- btn.innerHTML = `
`;
- panel.innerHTML = `
-
-
- `;
- this.panel = panel;
- const sizes = SHORTCUTS_FONT_SIZES;
- const decreaseBtn = panel.querySelector('.shortcuts-decrease-btn');
- const increaseBtn = panel.querySelector('.shortcuts-increase-btn');
- decreaseBtn.disabled = this.fontSize === sizes[0];
- increaseBtn.disabled = this.fontSize === sizes[sizes.length - 1];
- decreaseBtn.addEventListener('click', () => this.adjustFontSize(-1));
- increaseBtn.addEventListener('click', () => this.adjustFontSize(1));
- panel.querySelector('#shortcuts-list').style.fontSize = this.fontSize + 'em';
- this.renderContent();
- },
-
- renderContent() {
- document
- .getElementById('info-tab-btn-shortcuts')
- .setAttribute('aria-label', translate('shortcut_panel_title'));
- this.panel.querySelector('#shortcuts-panel-title').textContent =
- translate('shortcut_panel_title');
- this.panel
- .querySelector('.help-link-button')
- .setAttribute('aria-label', translate('shortcut_panel_help_link'));
- const container = this.panel.querySelector('#shortcuts-list');
- const groups = getShortcuts().reduce((acc, s) => {
- (acc[s.category] ??= []).push(s);
- return acc;
- }, {});
- container.innerHTML = Object.entries(groups)
- .map(
- ([cat, items]) => `
- ${cat}
-
- ${items.map(({ label, keys }) => `- ${label}
- ${formatKeys(keys)}
`).join('')}
-
- `
- )
- .join('');
- },
-
- show() {
- this.renderContent();
- this.previousFocus = document.activeElement;
- InfoPanel.activate('shortcuts');
- document.getElementById('shortcutsBtn')?.classList.add('active');
- if (isNarrowLayout()) this.enterModal();
- },
-
- refreshTranslations() {
- this.renderContent();
- },
+ };
- hide() {
- this.exitModal();
- this.previousFocus?.focus();
- this.previousFocus = null;
- InfoPanel.deactivate('shortcuts');
- document.getElementById('shortcutsBtn')?.classList.remove('active');
- },
+ window.addEventListener('resize', reevaluate);
- toggle() {
- this.panel.classList.contains('hidden') ? this.show() : this.hide();
+ const dockedArea = document.getElementById('info-panel-body');
+ if (dockedArea && typeof ResizeObserver !== 'undefined') {
+ // rAF-deferred: mutating the DOM inside the callback (enterModal reparents) trips "ResizeObserver loop completed with undelivered notifications".
+ new ResizeObserver(() => requestAnimationFrame(reevaluate)).observe(dockedArea);
+ }
},
- // --- Modal presentation (narrow layouts where the docked panel has no room) ---
-
// Reparent the panel to , mark it a dialog, inert the rest of the page
// and trap focus. Reparenting is required so it escapes the info panel (which
// is display:none in narrow mode) and the canvas area's overflow clipping.
@@ -879,15 +807,15 @@ const ShortcutsPanel = {
panel.classList.add('shortcuts-modal');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
- panel.setAttribute('aria-labelledby', 'shortcuts-panel-title');
+ panel.setAttribute('aria-labelledby', this._modalTitleId);
// Visible close control — there's no tab to click shut in modal mode, and
// Escape/backdrop aren't discoverable (and Escape isn't available on touch).
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'bigbutton shortcuts-modal-close';
- closeBtn.setAttribute('aria-label', translate('shortcut_panel_close'));
- closeBtn.setAttribute('title', translate('shortcut_panel_close'));
+ closeBtn.setAttribute('aria-label', translate(this._closeLabelKey));
+ closeBtn.setAttribute('title', translate(this._closeLabelKey));
closeBtn.innerHTML = 'X';
closeBtn.addEventListener('click', () => this.hide());
panel.querySelector('.shortcuts-panel-controls')?.appendChild(closeBtn);
@@ -921,7 +849,7 @@ const ShortcutsPanel = {
panel.classList.remove('shortcuts-modal');
panel.setAttribute('role', 'tabpanel');
panel.removeAttribute('aria-modal');
- panel.setAttribute('aria-labelledby', 'info-tab-btn-shortcuts');
+ panel.setAttribute('aria-labelledby', this._tabBtnId);
this._closeBtn?.remove();
this._closeBtn = null;
@@ -966,10 +894,123 @@ const ShortcutsPanel = {
first.focus();
}
},
+};
+
+const ShortcutsPanel = {
+ ...ModalPanelBehaviour,
+ panel: null,
+ previousFocus: null,
+ _modalTitleId: 'shortcuts-panel-title',
+ _tabBtnId: 'info-tab-btn-shortcuts',
+ _closeLabelKey: 'shortcut_panel_close',
+ fontSize:
+ parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT,
+
+ init() {
+ this.createPanel();
+ this.setupListeners();
+ this.watchDockedSpace();
+ window.flockShortcutsPanel = this;
+ },
+
+ adjustFontSize(delta) {
+ const sizes = SHORTCUTS_FONT_SIZES;
+ const idx = sizes.indexOf(this.fontSize);
+ const next = sizes[Math.max(0, Math.min(sizes.length - 1, idx + delta))];
+ if (next === this.fontSize) return;
+ this.fontSize = next;
+ localStorage.setItem(SHORTCUTS_FONT_SIZE_KEY, next);
+ this.panel.querySelector('#shortcuts-list').style.fontSize = next + 'em';
+ this.panel.querySelector('.shortcuts-decrease-btn').disabled = next === sizes[0];
+ this.panel.querySelector('.shortcuts-increase-btn').disabled = next === sizes[sizes.length - 1];
+ },
+
+ createPanel() {
+ const panel = InfoPanel.register('shortcuts', translate('shortcut_panel_title'), this);
+ const btn = document.getElementById('info-tab-btn-shortcuts');
+ btn.setAttribute('aria-label', translate('shortcut_panel_title'));
+ btn.setAttribute('title', translate('shortcut_panel_title'));
+ btn.innerHTML = ``;
+ panel.innerHTML = `
+
+
+ `;
+ this.panel = panel;
+ const sizes = SHORTCUTS_FONT_SIZES;
+ const decreaseBtn = panel.querySelector('.shortcuts-decrease-btn');
+ const increaseBtn = panel.querySelector('.shortcuts-increase-btn');
+ decreaseBtn.disabled = this.fontSize === sizes[0];
+ increaseBtn.disabled = this.fontSize === sizes[sizes.length - 1];
+ decreaseBtn.addEventListener('click', () => this.adjustFontSize(-1));
+ increaseBtn.addEventListener('click', () => this.adjustFontSize(1));
+ panel.querySelector('#shortcuts-list').style.fontSize = this.fontSize + 'em';
+ this.renderContent();
+ },
+
+ renderContent() {
+ document
+ .getElementById('info-tab-btn-shortcuts')
+ .setAttribute('aria-label', translate('shortcut_panel_title'));
+ this.panel.querySelector('#shortcuts-panel-title').textContent =
+ translate('shortcut_panel_title');
+ this.panel
+ .querySelector('.help-link-button')
+ .setAttribute('aria-label', translate('shortcut_panel_help_link'));
+ const container = this.panel.querySelector('#shortcuts-list');
+ const groups = getShortcuts().reduce((acc, s) => {
+ (acc[s.category] ??= []).push(s);
+ return acc;
+ }, {});
+ container.innerHTML = Object.entries(groups)
+ .map(
+ ([cat, items]) => `
+ ${cat}
+
+ ${items.map(({ label, keys }) => `- ${label}
- ${formatKeys(keys)}
`).join('')}
+
+ `
+ )
+ .join('');
+ },
+
+ show() {
+ this.renderContent();
+ this.previousFocus = document.activeElement;
+ InfoPanel.activate('shortcuts');
+ document.getElementById('shortcutsBtn')?.classList.add('active');
+ if (this.shouldBeModal()) this.enterModal();
+ },
+
+ refreshTranslations() {
+ this.renderContent();
+ },
+
+ hide() {
+ this.exitModal();
+ this.previousFocus?.focus();
+ this.previousFocus = null;
+ InfoPanel.deactivate('shortcuts');
+ document.getElementById('shortcutsBtn')?.classList.remove('active');
+ },
+
+ toggle() {
+ this.panel.classList.contains('hidden') ? this.show() : this.hide();
+ },
setupListeners() {
this.panel.addEventListener('keydown', (e) => {
- const scroller = document.getElementById('info-panel-body');
+ // Modal mode reparents the panel to and makes it the scroll
+ // container itself; #info-panel-body only scrolls in docked mode.
+ const scroller = this._modalActive
+ ? this.panel
+ : document.getElementById('info-panel-body');
if (e.key === 'ArrowUp') {
e.preventDefault();
scroller?.scrollBy({ top: -100, behavior: 'instant' });
@@ -1097,28 +1138,25 @@ function getPlayerControls() {
}
// On-screen and gamepad counterpart to ShortcutsPanel: a second info panel tab.
-// Docked-only — narrow landscape hides #info-panel outright (along with this
-// tab), so there's no modal counterpart to fall back to.
const PlayerPanel = {
+ ...ModalPanelBehaviour,
panel: null,
previousFocus: null,
+ _modalTitleId: 'player-panel-title',
+ _tabBtnId: 'info-tab-btn-player',
+ _closeLabelKey: 'close',
fontSize:
parseFloat(localStorage.getItem(SHORTCUTS_FONT_SIZE_KEY)) || SHORTCUTS_FONT_SIZE_DEFAULT,
init() {
this.createPanel();
this.setupListeners();
+ this.watchDockedSpace();
window.flockPlayerPanel = this;
-
- // Rotating into narrow landscape hides the info panel mid-view, which would
- // leave this panel marked active but invisible.
- window.addEventListener('resize', () => {
- if (isNarrowLayout() && !this.panel.classList.contains('hidden')) this.hide();
- });
},
createPanel() {
- const panel = InfoPanel.register('player', translate('player_section_onscreen'));
+ const panel = InfoPanel.register('player', translate('player_section_onscreen'), this);
const btn = document.getElementById('info-tab-btn-player');
btn.innerHTML = ``;
panel.innerHTML = `
@@ -1201,6 +1239,7 @@ const PlayerPanel = {
this.renderContent();
this.previousFocus = document.activeElement;
InfoPanel.activate('player');
+ if (this.shouldBeModal()) this.enterModal();
},
refreshTranslations() {
@@ -1208,6 +1247,7 @@ const PlayerPanel = {
},
hide() {
+ this.exitModal();
this.previousFocus?.focus();
this.previousFocus = null;
InfoPanel.deactivate('player');
@@ -1219,11 +1259,26 @@ const PlayerPanel = {
setupListeners() {
this.panel.addEventListener('keydown', (e) => {
- if (e.key !== 'Escape') return;
- e.preventDefault();
- e.stopPropagation();
- this.hide();
- document.getElementById('info-tab-btn-player')?.focus();
+ // Modal mode reparents the panel to and makes it the scroll
+ // container itself; #info-panel-body only scrolls in docked mode.
+ const scroller = this._modalActive
+ ? this.panel
+ : document.getElementById('info-panel-body');
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ scroller?.scrollBy({ top: -100, behavior: 'instant' });
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ scroller?.scrollBy({ top: 100, behavior: 'instant' });
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.hide();
+ const tabBtn = document.getElementById('info-tab-btn-player');
+ if (tabBtn?.offsetParent) tabBtn.focus();
+ }
});
},
};
diff --git a/main/view.js b/main/view.js
index a3ab40b4..6d0d755a 100644
--- a/main/view.js
+++ b/main/view.js
@@ -208,6 +208,14 @@ function resizeCanvas() {
canvas.style.width = `${Math.round(newWidth)}px`;
canvas.style.height = `${Math.round(newHeight)}px`;
+ // position:fixed info-panel tabs (landscape) can't measure the canvas strip themselves, so publish its
+ // edge here. Uses the canvas's actual viewport-right edge, not newWidth alone, since the canvas can sit
+ // offset from the area's left edge (e.g. beside the gizmo toolbar in gizmosBesideCanvas() layouts).
+ document.documentElement.style.setProperty(
+ '--canvas-width',
+ `${Math.round(canvas.getBoundingClientRect().right)}px`
+ );
+
// The engine owns the buffer and renders at devicePixelRatio; sizing it to
// CSS px here leaves it 1x until engine.resize(), drawing GUI px oversized.
if (flock.engine) return;
diff --git a/style.css b/style.css
index 1f15b2b7..79ee11ea 100644
--- a/style.css
+++ b/style.css
@@ -1112,21 +1112,38 @@ button {
}
}
-/* Narrow AND short: landscape at low resolutions (e.g. small laptop/tablet
- windows) where there's no room beside the canvas to dock the info panel's
- shortcuts panel. Portrait phones are narrow too, but tall enough that the
- docked panel still fits, so this is scoped to landscape only. In this mode:
- shortcuts open as a modal (.shortcuts-modal), the flock link moves into the
- bottom bar, and Ctrl+B "area 5" drops out on its own (renderHighlights skips
- 0-size nodes). Selector is `aside#info-panel` (not just `#info-panel`) so it
- out-specifies the base `#info-panel` rule, declared later in the file, which
- would otherwise win the cascade. Must match the JS breakpoint used by
- `isNarrowLayout()` in accessibility/keyboardui.js. */
+/* Landscape-only: at low resolutions there's no room beside the canvas to dock
+ the info panel (portrait is narrow too but tall enough to stay docked). Both
+ panels become modal and the flock link moves to the bottom bar. Must match
+ `isNarrowLayout()` in accessibility/keyboardui.js. `aside#info-panel`, not
+ `#info-panel`, is needed to out-specify the base rule further down the file. */
@media (max-width: 1024px) and (orientation: landscape) {
+ /* contents, not none: children (the tab bar) still need to render, but shouldn't take a column in #canvasArea's row layout. */
aside#info-panel {
+ display: contents;
+ }
+
+ #info-panel-body {
display: none;
}
+ /* Fixed in the dead space below the gizmo block and above the bottom bar, so the canvas keeps its full height; ancestor-qualified for the same cascade reason as above. */
+ aside#info-panel #info-panel-tabs {
+ position: fixed;
+ /* Spans the canvas strip published as --canvas-width by resizeCanvas(); falls back to hugging the right edge before the first resize. */
+ left: var(--canvas-width, auto);
+ right: 0;
+ bottom: calc(52px + max(0px, env(safe-area-inset-bottom, 0px)));
+ z-index: 1001;
+ margin-bottom: 0;
+ justify-content: flex-end;
+ }
+
+ /* #bottombar-flocklink is the visible copy here (index.html); !important overrides the inline display showCanvasView() sets in main/view.js. */
+ #info-panel-tabs #flocklink {
+ display: none !important;
+ }
+
.bottombar-flocklink {
position: absolute;
right: 12px;
diff --git a/tests/keyboardui.test.js b/tests/keyboardui.test.js
index c563fb48..532c2111 100644
--- a/tests/keyboardui.test.js
+++ b/tests/keyboardui.test.js
@@ -493,6 +493,7 @@ export function runKeyboardUiTests(flock) {
const closeBtn = ShortcutsPanel.panel.querySelector('.shortcuts-modal-close');
closeBtn.focus();
expect(document.activeElement).to.equal(closeBtn);
+ const expectedFocus = ShortcutsPanel.previousFocus;
window.matchMedia = () => ({ matches: false });
try {
@@ -501,7 +502,32 @@ export function runKeyboardUiTests(flock) {
window.matchMedia = saved;
}
expect(ShortcutsPanel._modalActive).to.equal(false);
- expect(document.activeElement).to.equal(ShortcutsPanel.previousFocus);
+ expect(document.activeElement).to.equal(expectedFocus);
+ expect(ShortcutsPanel.previousFocus).to.equal(null);
+ });
+
+ it('leaves focus on a surviving control when exiting modal on resize', function () {
+ const saved = window.matchMedia;
+ window.matchMedia = () => ({ matches: true });
+ try {
+ ShortcutsPanel.show();
+ window.dispatchEvent(new Event('resize'));
+ } finally {
+ window.matchMedia = saved;
+ }
+ expect(ShortcutsPanel._modalActive).to.equal(true);
+ const increaseBtn = ShortcutsPanel.panel.querySelector('.shortcuts-increase-btn');
+ increaseBtn.focus();
+ expect(document.activeElement).to.equal(increaseBtn);
+
+ window.matchMedia = () => ({ matches: false });
+ try {
+ window.dispatchEvent(new Event('resize'));
+ } finally {
+ window.matchMedia = saved;
+ }
+ expect(ShortcutsPanel._modalActive).to.equal(false);
+ expect(document.activeElement).to.equal(increaseBtn);
});
});