diff --git a/changelog.d/1425-right-rail-vertical-tabs.md b/changelog.d/1425-right-rail-vertical-tabs.md index b9ccd2443..27d5363d7 100644 --- a/changelog.d/1425-right-rail-vertical-tabs.md +++ b/changelog.d/1425-right-rail-vertical-tabs.md @@ -3,6 +3,8 @@ an accordion of collapsed launcher cards: the open panel sits beside the strip and at most one panel is open in every desktop HUD layout. The open tab and its panel share one surface and one outline, like a folder tab; the - other tabs are plain labels. The tabs are a keyboard-navigable tablist, - Escape inside a panel returns focus to its tab, and saved preferences and - share links are unchanged. Phones keep their sheet shell (gekh, #1425). + other tabs are plain labels. A first-time visitor on a desktop finds Display + open, so the strip reads as launchers; a stored choice still wins. The tabs + are a keyboard-navigable tablist, Escape inside a panel returns focus to its + tab, and saved preferences and share links are unchanged. Phones keep their + sheet shell (gekh, #1425). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cd815424..a55354457 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1524,6 +1524,15 @@ open panel's minimum height, so a short panel such as Context still reaches its tab. A first tab level with the panel's top shares its top edge, with no corner between them; Cyber's strip starts below the frame's upper chamfer. +A first-time visitor on a desktop viewport finds Display open +(`src/ui/panelPositionControls.js`): with every panel closed nothing shows +that the sideways labels are launchers, so the first panel opens to +demonstrate the strip. Phones keep it closed (their sheets present the +rail), a share link decides for itself, and a stored choice wins either way. +When the rail picks its one owner, a panel the visitor or a share link opened +outranks one open only by default, so a stored CCTV choice is not displaced +by the Display default. + ## Phone layout At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and @@ -2157,7 +2166,9 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > Missions · Environmental · Explore manually**. No layer and no optional API > call happens until a tile is clicked. The right-hand DISPLAY rail > (`pp-toggles`) now starts **collapsed** on a first run rather than expanded — -> a stored collapse state still wins, as before. +> a stored collapse state still wins, as before. (Superseded 2026-10-27 by the +> right rail tabs: on a desktop viewport DISPLAY starts open again on a first +> run; see "Right rail tabs".) > > **The ENVIRONMENTAL tile is quakes AND fires** — live USGS earthquakes plus > NASA FIRMS active fires (`layerIds: ['earthquakes', 'local-firms']`), with the diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index d76a48458..5bef25a92 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -981,19 +981,21 @@ test('the launcher keeps focus, restores it, and never disables the focused butt assert.match(keyboard, /addEventListener\('keydown', onKeyDown, true\)/); }); -test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => { +test('the DISPLAY rail starts open on a first desktop run, and a stored choice wins', () => { const ui = fs.readFileSync( new URL('./ui/panelPositionControls.js', import.meta.url), 'utf8', ); - // The rail opened by default to advertise HUD / DETECT / 3D. Those default ON - // now, so it was opening to offer controls for things already happening — - // while competing with the mission card for the one first impression there is. + // The right rail is a strip of label tabs: with every panel closed nothing + // shows that the labels are launchers, so DISPLAY opens on a first desktop + // run to demonstrate the strip. Phones keep their sheets and a share link + // decides for itself. assert.match( ui, - /if \(panelId === 'pp-toggles' && stored === null\) collapsed = true;/, - 'first run must leave DISPLAY collapsed', + /if \(panelId === 'pp-toggles' && stored === null\)\s+collapsed = !allowStored \|\| isPhoneViewport\(\);/, + 'first desktop run must open DISPLAY; phones and share links keep it closed', ); + assert.match(ui, /matchMedia\?\.\('\(max-width: 720px\)'\)/); // The first-run default may only apply when NOTHING is stored: a visitor who // opened the rail keeps it open, and one who closed it keeps it closed. const block = ui.slice( diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index ff296bf75..214585a84 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -13,6 +13,10 @@ const RETIRED_PANEL_KEY_PREFIXES = [ `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`, `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`, ]; +/** Phones present the rail as sheets (mobileShell.js), not as a tab strip. */ +const isPhoneViewport = () => + globalThis.matchMedia?.('(max-width: 720px)')?.matches === true; + export class PanelPositionControls { constructor({ syncPanelCollapseButton, showToast }) { this._syncPanelCollapseButton = syncPanelCollapseButton; @@ -66,15 +70,15 @@ export class PanelPositionControls { // storage unavailable } } - // DISPLAY starts COLLAPSED for a first-time visitor, then respects the - // user's persisted choice like every other panel. - // - // It used to start expanded, to advertise the HUD / DETECT / 3D toggles. - // That reason expired when those became ON by default: the rail now opens - // to offer controls for things already happening, while competing with the - // first-run mission card for the one first impression there is. A stored - // choice still wins in both directions, so anyone who opens it keeps it. - if (panelId === 'pp-toggles' && stored === null) collapsed = true; + // DISPLAY starts EXPANDED for a first-time visitor on a desktop viewport, + // where the right rail is a strip of label tabs (rightRailTabs.js): with + // every panel closed nothing shows that the labels are launchers, so the + // first panel opens to demonstrate the strip. Phones keep it collapsed + // (their sheets present the rail), and a share link decides for itself. + // A stored choice still wins in both directions, so anyone who closes it + // keeps it closed. + if (panelId === 'pp-toggles' && stored === null) + collapsed = !allowStored || isPhoneViewport(); panelEl.classList.toggle('collapsed', collapsed); // Bodies that expand themselves on first appearance must not override a // choice the user (or a share link) already made. diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 1290ebff6..152cce1ca 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -295,6 +295,31 @@ test('right layout uses keyboard focus when there is no preferred panel', () => assert.equal(f.second.classList.contains('collapsed'), false); }); +test('right layout lets a stored or shared choice outrank a panel open only by default', () => { + // Display opens by default on a first desktop visit; a visitor who had + // opened CCTV keeps CCTV as the owner, whatever the rail order. + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 900); + f.first.dataset.collapsedPreference = 'default'; + f.second.dataset.collapsedPreference = 'stored'; + f.run(); + assert.equal(f.first.classList.contains('rail-owner-collapsed'), true); + assert.equal(f.second.classList.contains('collapsed'), false); + const g = fixture('right'); + g.expand(g.first, 900); + g.expand(g.second, 900); + g.first.dataset.collapsedPreference = 'default'; + g.second.dataset.collapsedPreference = 'default'; + g.run(); + assert.equal( + g.first.classList.contains('collapsed'), + false, + 'all default: first wins', + ); + assert.equal(g.second.classList.contains('rail-owner-collapsed'), true); +}); + for (const collapsed of [true, false]) { test(`right layout ignores a hidden ${collapsed ? 'collapsed' : 'expanded'} panel`, () => { const baseline = fixture('right'); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 7743ed5e1..70164ea2e 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -75,10 +75,12 @@ export function layoutRightPanelRail({ (hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'), ); // One expanded owner: the latest explicit disclosure, else the panel that - // holds keyboard focus, else the first. Peers are presentation-collapsed - // without overwriting saved panel preferences; the marker is distinct from - // the left rail's space-based collapse. Phones present the rail as tab - // sheets that show several panels at once, so they get every panel back. + // holds keyboard focus, else one the visitor or a share link chose over one + // open only by default (panelPositionControls.js marks each), else the + // first. Peers are presentation-collapsed without overwriting saved panel + // preferences; the marker is distinct from the left rail's space-based + // collapse. Phones present the rail as tab sheets that show several panels + // at once, so they get every panel back. const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; if (!isMobile) { const expanded = panels.filter( @@ -87,6 +89,9 @@ export function layoutRightPanelRail({ const owner = expanded.find((panel) => panel.id === preferredPanelId) || expanded.find((panel) => panel.contains(documentRef?.activeElement)) || + expanded.find( + (panel) => panel.dataset?.collapsedPreference !== 'default', + ) || expanded[0]; for (const panel of expanded) { if (panel === owner) continue;