From 191fe532a3b88b96af29a0e3075b8d3959213fcd Mon Sep 17 00:00:00 2001
From: Laura Sach <5183697+lawsie@users.noreply.github.com>
Date: Thu, 30 Jul 2026 16:38:59 +0100
Subject: [PATCH 1/2] Move bar on landscape mobile
---
accessibility/keyboardui.js | 315 +++++++++++++++++++++---------------
main/view.js | 4 +
style.css | 37 ++++-
3 files changed, 223 insertions(+), 133 deletions(-)
diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js
index 4d81ecfc..3fb7174b 100644
--- a/accessibility/keyboardui.js
+++ b/accessibility/keyboardui.js
@@ -7,11 +7,28 @@ 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.
+// modal instead of docked (see style.css).
const isNarrowLayout = () =>
window.matchMedia('(max-width: 1024px) and (orientation: landscape)').matches;
+// Docking is only worth it if a couple of control rows fit; below that the panel
+// scrolls one entry at a time (an iPhone SE in portrait leaves ~130px) and the
+// modal reads better. Measured row height is ~40px per em of the panel's
+// font-size control, on top of its 1em padding and header.
+const MIN_DOCKED_ROWS = 2;
+const PANEL_CHROME_HEIGHT = 65;
+const ROW_HEIGHT_PER_EM = 40;
+
+// #info-panel-body is `flex: 1`, so this measures the space the layout allots it
+// rather than its content, and stays valid while a panel is reparented out to
+// modal. Height 0 means unmeasurable (display:none, jsdom) — isNarrowLayout()
+// already covers the hidden case, so don't treat it as short here.
+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 +692,10 @@ const InfoPanel = {
this._body = document.getElementById('info-panel-body');
},
- register(id, label) {
+ // `owner` is the panel object. Route the tab through its toggle() so clicking
+ // the tab gets the same presentation logic (docked vs modal) as every other
+ // entry point; activate()/deactivate() alone only swap DOM classes.
+ register(id, label, owner) {
const btn = document.createElement('button');
btn.id = `info-tab-btn-${id}`;
btn.className = 'info-tab-btn bigbutton';
@@ -683,7 +703,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';
@@ -738,125 +758,43 @@ 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 the info-panel tabs. Mixing panels must set
+// _modalTitleId, _tabBtnId and _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', () => {
+ // Keep an open panel on the right side of the breakpoint so it never ends up
+ // docked in an info panel that is hidden or too short to read it in. The
+ // docked area also changes without a window resize — play mode hides the
+ // gizmo bar, the Canvas/Code toggle, the splitter — so observe the element
+ // itself, and keep the resize listener for the media-query flip (a panel
+ // going display:none has no box for ResizeObserver to report on).
+ watchDockedSpace() {
+ const reevaluate = () => {
if (this.panel.classList.contains('hidden')) return;
- if (isNarrowLayout() && !this._modalActive) this.enterModal();
- else if (!isNarrowLayout() && this._modalActive) {
+ if (this.shouldBeModal()) this.enterModal();
+ else if (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.
this.exitModal();
this.previousFocus?.focus();
}
- });
- },
-
- 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') {
+ // Deferred: enterModal() reparents the panel, and mutating the DOM inside
+ // the callback 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 +817,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 +859,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,6 +904,115 @@ 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) => {
@@ -1097,28 +1144,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 +1245,7 @@ const PlayerPanel = {
this.renderContent();
this.previousFocus = document.activeElement;
InfoPanel.activate('player');
+ if (this.shouldBeModal()) this.enterModal();
},
refreshTranslations() {
@@ -1208,6 +1253,7 @@ const PlayerPanel = {
},
hide() {
+ this.exitModal();
this.previousFocus?.focus();
this.previousFocus = null;
InfoPanel.deactivate('player');
@@ -1219,11 +1265,22 @@ 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();
+ const scroller = 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..c8f18c14 100644
--- a/main/view.js
+++ b/main/view.js
@@ -208,6 +208,10 @@ function resizeCanvas() {
canvas.style.width = `${Math.round(newWidth)}px`;
canvas.style.height = `${Math.round(newHeight)}px`;
+ // Landscape parks the info-panel tabs in the strip beside the canvas; they're
+ // position:fixed and so can't measure it, hence publishing the edge here.
+ document.documentElement.style.setProperty('--canvas-width', `${Math.round(newWidth)}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..747e50a9 100644
--- a/style.css
+++ b/style.css
@@ -1115,18 +1115,47 @@ 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
+ docked panel still fits, so this is scoped to landscape only. In this mode
+ both panels open as a modal (.shortcuts-modal) and the flock link moves into
+ the bottom bar. 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. */
@media (max-width: 1024px) and (orientation: landscape) {
+ /* `contents`, not `none`: the tab bar is the only way to open either panel
+ here, so the panel must keep rendering its children — but must not take a
+ column of its own in #canvasArea's row layout. */
aside#info-panel {
+ display: contents;
+ }
+
+ #info-panel-body {
display: none;
}
+ /* Park the tabs in the dead space below the docked gizmo block and above the
+ bottom bar. Fixed, so the height-bound canvas gives up nothing for them.
+ Qualified with the ancestor for the same cascade reason as above: the base
+ #info-panel-tabs rule sets background/border-top further down the file. */
+ aside#info-panel #info-panel-tabs {
+ position: fixed;
+ /* Spans the strip beside the canvas, whose edge resizeCanvas() publishes as
+ --canvas-width. Falls back to hugging the right 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 in this layout (see index.html),
+ so keep this one out of the a11y tree rather than showing it twice.
+ !important because showCanvasView() in main/view.js sets display inline. */
+ #info-panel-tabs #flocklink {
+ display: none !important;
+ }
+
.bottombar-flocklink {
position: absolute;
right: 12px;
From 3c2596086c5d99c226409f8c4a84ec4785132d00 Mon Sep 17 00:00:00 2001
From: Laura Sach <5183697+lawsie@users.noreply.github.com>
Date: Thu, 30 Jul 2026 17:14:24 +0100
Subject: [PATCH 2/2] Rabbit complaints
---
accessibility/keyboardui.js | 60 ++++++++++++++++++-------------------
main/view.js | 10 +++++--
style.css | 30 ++++++-------------
tests/keyboardui.test.js | 28 ++++++++++++++++-
4 files changed, 72 insertions(+), 56 deletions(-)
diff --git a/accessibility/keyboardui.js b/accessibility/keyboardui.js
index 3fb7174b..259cec10 100644
--- a/accessibility/keyboardui.js
+++ b/accessibility/keyboardui.js
@@ -5,24 +5,16 @@ 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).
+// 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;
-// Docking is only worth it if a couple of control rows fit; below that the panel
-// scrolls one entry at a time (an iPhone SE in portrait leaves ~130px) and the
-// modal reads better. Measured row height is ~40px per em of the panel's
-// font-size control, on top of its 1em padding and header.
+// 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;
-// #info-panel-body is `flex: 1`, so this measures the space the layout allots it
-// rather than its content, and stays valid while a panel is reparented out to
-// modal. Height 0 means unmeasurable (display:none, jsdom) — isNarrowLayout()
-// already covers the hidden case, so don't treat it as short here.
+// 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;
@@ -692,9 +684,7 @@ const InfoPanel = {
this._body = document.getElementById('info-panel-body');
},
- // `owner` is the panel object. Route the tab through its toggle() so clicking
- // the tab gets the same presentation logic (docked vs modal) as every other
- // entry point; activate()/deactivate() alone only swap DOM classes.
+ // 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}`;
@@ -735,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();
},
@@ -758,29 +751,28 @@ 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;
-// Modal presentation shared by the info-panel tabs. Mixing panels must set
-// _modalTitleId, _tabBtnId and _closeLabelKey.
+// Modal presentation shared by info-panel tabs; mixers must set _modalTitleId, _tabBtnId, _closeLabelKey.
const ModalPanelBehaviour = {
shouldBeModal() {
return isNarrowLayout() || isDockedAreaTooShort(this.fontSize);
},
- // Keep an open panel on the right side of the breakpoint so it never ends up
- // docked in an info panel that is hidden or too short to read it in. The
- // docked area also changes without a window resize — play mode hides the
- // gizmo bar, the Canvas/Code toggle, the splitter — so observe the element
- // itself, and keep the resize listener for the media-query flip (a panel
- // going display:none has no box for ResizeObserver to report on).
+ // 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 (this.shouldBeModal()) this.enterModal();
else if (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.
+ // 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;
+ }
}
};
@@ -788,9 +780,7 @@ const ModalPanelBehaviour = {
const dockedArea = document.getElementById('info-panel-body');
if (dockedArea && typeof ResizeObserver !== 'undefined') {
- // Deferred: enterModal() reparents the panel, and mutating the DOM inside
- // the callback trips "ResizeObserver loop completed with undelivered
- // notifications".
+ // rAF-deferred: mutating the DOM inside the callback (enterModal reparents) trips "ResizeObserver loop completed with undelivered notifications".
new ResizeObserver(() => requestAnimationFrame(reevaluate)).observe(dockedArea);
}
},
@@ -1016,7 +1006,11 @@ const ShortcutsPanel = {
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' });
@@ -1265,7 +1259,11 @@ const PlayerPanel = {
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' });
diff --git a/main/view.js b/main/view.js
index c8f18c14..6d0d755a 100644
--- a/main/view.js
+++ b/main/view.js
@@ -208,9 +208,13 @@ function resizeCanvas() {
canvas.style.width = `${Math.round(newWidth)}px`;
canvas.style.height = `${Math.round(newHeight)}px`;
- // Landscape parks the info-panel tabs in the strip beside the canvas; they're
- // position:fixed and so can't measure it, hence publishing the edge here.
- document.documentElement.style.setProperty('--canvas-width', `${Math.round(newWidth)}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.
diff --git a/style.css b/style.css
index 747e50a9..79ee11ea 100644
--- a/style.css
+++ b/style.css
@@ -1112,19 +1112,13 @@ 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
- both panels open as a modal (.shortcuts-modal) and the flock link moves into
- the bottom bar. 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`: the tab bar is the only way to open either panel
- here, so the panel must keep rendering its children — but must not take a
- column of its own in #canvasArea's row layout. */
+ /* 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;
}
@@ -1133,14 +1127,10 @@ button {
display: none;
}
- /* Park the tabs in the dead space below the docked gizmo block and above the
- bottom bar. Fixed, so the height-bound canvas gives up nothing for them.
- Qualified with the ancestor for the same cascade reason as above: the base
- #info-panel-tabs rule sets background/border-top further down the file. */
+ /* 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 strip beside the canvas, whose edge resizeCanvas() publishes as
- --canvas-width. Falls back to hugging the right before the first resize. */
+ /* 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)));
@@ -1149,9 +1139,7 @@ button {
justify-content: flex-end;
}
- /* #bottombar-flocklink is the visible copy in this layout (see index.html),
- so keep this one out of the a11y tree rather than showing it twice.
- !important because showCanvasView() in main/view.js sets display inline. */
+ /* #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;
}
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);
});
});