diff --git a/library/composables/useDashboardLayout.js b/library/composables/useDashboardLayout.js index 5cf207bc4..08d3544f3 100644 --- a/library/composables/useDashboardLayout.js +++ b/library/composables/useDashboardLayout.js @@ -90,6 +90,10 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue' // existing layout survives the change; nothing writes to it. Delete it once no reader can plausibly // still be carrying one. const LEGACY_SHARED_KEY = 'rd-dashboard-layout' +// The default key, named once so the legacy read-through can ask "am I the default?" instead of +// re-typing the literal — a fallback comparing against a copy of its own default is a fallback that +// silently stops matching the day someone bumps the version suffix. +const DEFAULT_STORAGE_KEY = 'rd-dashboard-layout-v6' // Tree rail WIDER than the charts rail — see the rail-balance note in research-dashboard.vue. // These must stay in lockstep with the CSS `:host` defaults, since reset() snaps back to them. @@ -170,7 +174,7 @@ export function useDashboardLayout(options = {}) { // in the commit that first trips over it. // The caller passes its own id (the shell uses config.id). The default keeps a caller that // passes nothing working, and still namespaces it away from any caller that does. - storageKey = 'rd-dashboard-layout-v6', + storageKey = DEFAULT_STORAGE_KEY, } = options const defaults = { ...DEFAULTS, ...(options.defaults || {}) } @@ -271,7 +275,14 @@ export function useDashboardLayout(options = {}) { // every reader's saved layout. Read-only and never written back: the new key is written on // the next drag-end, and the old value is simply left where it is rather than migrated and // deleted — a migration that also deletes is one that cannot be rolled back. - const raw = localStorage.getItem(storageKey) || localStorage.getItem(LEGACY_SHARED_KEY) + // ★ ONLY FOR THE DEFAULT KEY (go-int, 2026-09-27) — and this was my own regression from kit + // landing 2. I added the read-through so namespacing would not discard a reader's saved + // layout, and applied it unconditionally: so a caller that passes its OWN storageKey, which + // is the entire point of namespacing, still inherited Big Picture's rail widths on first + // load. The migration meant to protect one dashboard's layout was handing it to every other + // dashboard. A fallback that ignores the key it is falling back FROM is not a fallback. + const legacyOk = storageKey === DEFAULT_STORAGE_KEY + const raw = localStorage.getItem(storageKey) || (legacyOk ? localStorage.getItem(LEGACY_SHARED_KEY) : null) if (!raw) return false const s = JSON.parse(raw) if (Number.isFinite(s.facetW)) facetW.value = clamp(s.facetW, clamps.facetW) @@ -333,6 +344,18 @@ export function useDashboardLayout(options = {}) { const startX = e.clientX const startFacet = facetW.value const startRail = railW.value + // ★ THE X DELTA HAS A READING DIRECTION (go-int, 2026-09-27). Under dir=rtl the grid mirrors — + // the facet rail sits on the RIGHT — so dragging its handle toward the map INCREASES clientX + // where in LTR it decreases it. The maths below was written once, in LTR, and the result in + // Arabic is that dragging the facet handle shrinks the CHARTS rail: both handles fight, and + // neither does what the cursor says. This reaches Big Picture in ar today, not only go-int. + // Read per drag rather than cached: a host can flip dir between gestures (our own composites + // set it on the custom-element host from the runtime locale), and a cached sign would be wrong + // for the rest of the session. + const rtl = (() => { + try { return getComputedStyle(unref(shellRef)).direction === 'rtl' } catch (_) { return false } + })() + const xSign = rtl ? -1 : 1 const handleEl = e.currentTarget // ── PAGE-SCROLL AXIS SWITCH (BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING) ────────────────────── // In page-scroll mode (research-dashboard.vue's pageScroll / dashboard-plus-grid.vue) the @@ -405,11 +428,11 @@ export function useDashboardLayout(options = {}) { pending = null if (axis === 'facet') { // left facet edge → dragging right widens the facet column - facetW.value = Math.round(clamp(startFacet + (ev.clientX - startX), clamps.facetW)) + facetW.value = Math.round(clamp(startFacet + xSign * (ev.clientX - startX), clamps.facetW)) customized.value = true } else if (axis === 'rail') { // right rail's LEFT edge → dragging left widens the rail column - railW.value = Math.round(clamp(startRail - (ev.clientX - startX), clamps.railW)) + railW.value = Math.round(clamp(startRail - xSign * (ev.clientX - startX), clamps.railW)) customized.value = true } else if (isTablePageScroll) { // page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is @@ -519,7 +542,16 @@ export function useDashboardLayout(options = {}) { const K = e.key let dir = 0 if (axis === 'table' || axis === 'shell') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 } - else { if (K === 'ArrowLeft') dir = -1; else if (K === 'ArrowRight') dir = 1 } + else { + // ★ AND THE ARROWS FOLLOW THE SAME SIGN (go-int). The pointer path now mirrors under rtl; if + // the keyboard did not, the two input methods would disagree about which way widens — and a + // keyboard user in Arabic would find the arrows inverted relative to the drag they just + // watched. aria-valuenow reports the same ref for both, so a disagreement here would also + // make the announced value move the wrong way. + let kSign = 1 + try { if (getComputedStyle(unref(shellRef)).direction === 'rtl') kSign = -1 } catch (_) { /* no element */ } + if (K === 'ArrowLeft') dir = -1 * kSign; else if (K === 'ArrowRight') dir = 1 * kSign + } if (dir) { e.preventDefault(); stepResize(axis, dir); return } if (K === 'Home' || K === 'End') { e.preventDefault() diff --git a/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs b/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs index 4bc057fe7..2967b8197 100644 --- a/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs +++ b/plugin-designs/research-dashboard/scripts/check-layout-key-namespaced.mjs @@ -81,6 +81,44 @@ if (!existsSync(ENGINE)) { console.log(`${TAG}: SKIP — no ${ENGINE.replace(ROO } } +// ── D. the drag mirrors under RTL, and the keyboard agrees with it ─────────────────────────── +// go-int, 2026-09-27: the x delta was computed once, in LTR. Under dir=rtl the grid mirrors — the +// facet rail sits on the RIGHT — so dragging its handle toward the map moves clientX the other way, +// and in Arabic the facet handle shrank the CHARTS rail. It reaches Big Picture in ar, not only a +// new consumer. The keyboard path is asserted too: if only the pointer mirrored, the two input +// methods would disagree about which way widens, and aria-valuenow would announce the wrong +// direction to someone who cannot see the drag. +{ + const src = readFileSync(ENGINE, 'utf8') + const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n') + if (!/direction === 'rtl'/.test(code)) { + fails.push('D useDashboardLayout never reads the computed direction, so the drag cannot mirror: in ar the facet handle moves the wrong rail.') + } else { + if (!/startFacet \+ xSign \* \(ev\.clientX - startX\)/.test(code) || !/startRail - xSign \* \(ev\.clientX - startX\)/.test(code)) { + fails.push('D the pointer drag does not apply the direction sign to BOTH widths — mirroring one and not the other is worse than mirroring neither, because the two handles then disagree.') + } + if (!/kSign/.test(code)) { + fails.push('D the arrow keys do not follow the direction sign, so keyboard and pointer disagree about which way widens under rtl.') + } + if (!fails.some((f) => f.startsWith('D'))) notes.push('drag: mirrors under rtl, pointer and arrows share one sign') + } +} + +// ── E. the legacy read-through applies ONLY to the default key ─────────────────────────────── +// My own regression from kit landing 2: I added the read-through so namespacing would not discard a +// saved layout, and applied it unconditionally — so a caller passing its OWN key, which is the whole +// point of namespacing, inherited Big Picture's widths. The migration meant to protect one +// dashboard's layout was handing it to every other dashboard. +{ + const src = readFileSync(ENGINE, 'utf8') + const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n') + if (/getItem\(storageKey\) \|\| localStorage\.getItem\(LEGACY_SHARED_KEY\)/.test(code)) { + fails.push('E the legacy key is read even when the caller passed its own storageKey, so a second dashboard inherits the first one\'s layout. Gate it on storageKey === DEFAULT_STORAGE_KEY.') + } else if (!/storageKey === DEFAULT_STORAGE_KEY/.test(code)) { + fails.push('E the legacy read-through is not gated on the default key — either it is gone (a reader loses their layout) or it is gated on something this gate cannot see.') + } else notes.push('legacy read-through: only for the default key') +} + for (const n of notes) console.log(' · ' + n) if (fails.length) { console.error(`\n${TAG}: FAIL`)