Give the dashboard its own height handle, separate from the map/table split
Some checks are pending
build-gate / build (push) Waiting to run

Dragging the map/table divider in page-scroll mode was also resizing the
whole element, because --rd-map-h alone answered both "how is the map/table
space split" and "how tall is the element" -- Driver: "you improperly bound
that control to the separator line between the map and the table... that's
not the request I was making to resize the height of the page."

--rd-shell-h is now the independent total-height budget; the new bottom
handle (.rd-rz-shell) is the only thing that writes it. The existing
map/table divider still drives --rd-map-h, but only as the map's share of
that fixed budget, so it redistributes between map and table without
changing the sum. Table/facets/rail max-height all re-derive from
--rd-shell-h so the sidebars keep spanning exactly map+table at every drag.

Also closes a 28-30px sidebar gap the re-derivation exposed: the table
budget only ever subtracted the header bar's height, never the summary
footer's, so facets/rail undershot the table's real bottom edge.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 18:16:30 +00:00
parent 5d87bd9db1
commit eaa2c35286
2 changed files with 176 additions and 32 deletions

View file

@ -166,7 +166,7 @@ const { shell, isNarrow, slimRail, slimFacet, mobileView, SEG, tableCollapsed, f
// TopBar's "Align Dashboard" button via bus.emit('resetLayout')) snaps back to the exact defaults.
// Defaults MUST match the CSS spine below (facet 248, rail 312, table 34%) so an untouched board is
// pixel-identical and the responsive slim-rail classes keep working until the user drags.
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH, mapH } = useDashboardLayout({
const { startFacetResize, startRailResize, startTableResize, startShellResize, onResizeKeydown, facetW, railW, tableH, mapH, shellH } = useDashboardLayout({
shellRef: shell,
bus,
isNarrow,
@ -180,7 +180,11 @@ const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, fa
// mapH mirrors the page-scroll CSS fallback 40svh (~360px at a 900px viewport — see
// BUG-DASHBOARD-BAND-GROWS-ENORMOUS: 58svh left the default band taller than one viewport;
// 40svh is the value that makes chrome(137px) + map(X) + table(X+32px bar) sum to <=900px).
defaults: { facetW: 296, railW: 264, tableH: '34%', mapH: 360 },
// shellH default 720 = the map+table combined budget that mapH:360 used to imply on its own
// (360 map + 360 table-with-bar) — see useDashboardLayout.js's DEFAULTS comment for the exact
// accounting. Must mirror --rd-shell-h's CSS fallback below for the same reset()-vs-fresh-load
// reason as the other three.
defaults: { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 },
})
// KPI strip — generic formatting per kind (mirrors the original inline strip).
@ -323,6 +327,18 @@ useShadowStyles(`
.rd-rz-table{ grid-area:map; align-self:end; justify-self:stretch; height:8px; margin-bottom:-4px; cursor:row-resize; }
.rd-rz-facet::after, .rd-rz-rail::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:3px; height:34px; }
.rd-rz-table::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:34px; height:3px; }
/* ── SHELL HANDLE (page-scroll mode only) — the boundary between the WHOLE research-dashboard
element and whatever a host stacks below it (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER: this is
the control the Driver actually asked for — "resize the height of the page" — which a previous
pass mistakenly bound to .rd-rz-table above instead). Positioned OUTSIDE the grid (the shell is
already position:relative) rather than given a grid-area, because it must span all THREE
columns (facets + map/table + rail) regardless of their widths, and no single grid-area does
that without also claiming cells .rd-rz-table already owns. Pinned to the shell's own bottom
edge — not the table cell's — so it tracks whichever row happens to be last. Grid-mode has no
concept of "the element's own height" (the shell IS the viewport there), so this handle only
ever renders in .rd-page-scroll — see the template's v-show. */
.rd-rz-shell{ position:absolute; left:0; right:0; bottom:0; height:10px; margin-bottom:-5px; cursor:row-resize; }
.rd-rz-shell::after{ top:50%; left:50%; transform:translate(-50%,-50%); width:48px; height:3px; }
.rd-rz:hover::after{ background:var(--rd-accent,#e67e22); }
.rd-rz:active{ background:color-mix(in srgb, var(--rd-accent,#e67e22) 18%, transparent); }
.rd-rz:active::after{ background:var(--rd-accent,#e67e22); }
@ -357,7 +373,18 @@ useShadowStyles(`
is the ORIGINAL defect the 88svh pin was introduced to avoid; giving
the map an explicit height keeps it while freeing the table.
The table's own overflow is released to visible in this mode, which is the actual fix: with
no internal scrollport under the cursor there is nothing to capture the wheel. */
no internal scrollport under the cursor there is nothing to capture the wheel.
★ --rd-shell-h vs --rd-map-h (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER). Two INDEPENDENT
variables, answering two different questions, where there used to be only one:
--rd-shell-h = the ELEMENT's own total height (map + table combined) — the NEW bottom
handle (.rd-rz-shell) is the only thing that writes this.
--rd-map-h = the map's SHARE of that total — the existing map/table divider
(.rd-rz-table) writes this, redistributing within --rd-shell-h without
changing it (useDashboardLayout.js clamps mapH so it can never exceed the
shell budget). Every derived measurement below (table budget, facets/rail
span) now roots in --rd-shell-h so the total stays the single source of
truth and the two handles cannot fight over the same pixels. */
.rd-shell.rd-page-scroll{ height:auto; --table-h:auto; }
.rd-shell.rd-page-scroll > [data-region="map"]{ height:var(--rd-map-h, 40svh); overflow:hidden; }
.rd-shell.rd-page-scroll > [data-region="table"]{ overflow:visible; }
@ -394,9 +421,27 @@ useShadowStyles(`
taller than where the gesture happens). 40svh (~360px at 900px) brings chrome + map(360) +
table(392) to ~889px, clearing all three clauses with an 11px margin. The resize handle
(useDashboardLayout's mapH drag) still lets a large-monitor user drag it back up afterward —
this only changes what a FIRST LOAD shows. */
this only changes what a FIRST LOAD shows.
★ RE-ROOTED IN --rd-shell-h (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER). The table's budget used
to be map-h minus the bar, which made the map/table divider ALSO the element's total-height
control (growing the map grew the total, since nothing capped it). Now the table's budget is
the SHELL's total minus whatever the map is currently taking minus the bar — so dragging the
map/table divider (which changes --rd-map-h, never --rd-shell-h) redistributes between the two
rows while their SUM stays exactly --rd-shell-h. Fallback 80svh mirrors useDashboardLayout.js's
shellH default (720px ≈ 2×40svh at 900px) — the map+table combined budget --rd-map-h alone used
to imply, so first paint is unchanged.
★ ALSO SUBTRACTS --rd-table-foot-h NOW — MEASURED, a real gap this exposed. The table cell has
THREE stacked children (.rd-table-bar, .rd-grid, .rd-foot — the "Σ" summary row,
DashboardDataTable.vue's height:28px), but the old formula only ever budgeted for the bar. That
was invisible before this card: with nothing else depending on map-h + table-cell summing to an
exact number, the extra 28px just meant the table cell was quietly 28px taller than the map row
beside it. It became visible the moment facets/rail needed to span EXACTLY map+table (below) —
without this term they undershot by the footer's height, leaving them 28-30px short of the
table's real bottom edge, i.e. the sidebar grey-gap bug this card is fixing. --rd-table-foot-h
is a var (not a literal) for the same reason --rd-table-bar-h is: DashboardDataTable can correct
it if the footer's height ever changes, without this rule going stale. */
.rd-shell.rd-page-scroll .rd-grid{
max-height:var(--rd-table-max-h, calc(var(--rd-map-h, 40svh) - var(--rd-table-bar-h, 32px))); }
max-height:var(--rd-table-max-h, calc(var(--rd-shell-h, 80svh) - var(--rd-map-h, 40svh) - var(--rd-table-bar-h, 32px) - var(--rd-table-foot-h, 28px))); }
/* ── FACETS/RAIL COLUMNS BOUNDED IN PAGE-SCROLL MODE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS) ──────
THE ROOT CAUSE, one level up from the table rule above. .rd-facets/.rd-rail are authored
height:100%, which is correct in the FIXED-VIEWPORT grid (rows sum to a real, bounded
@ -449,10 +494,16 @@ useShadowStyles(`
already owns) rather than map alone — that is what actually matches "span the map AND the
table". --rd-table-max-h already carries the table's real budget (line 399); reusing it here
keeps the two in lockstep by construction instead of a second hardcoded number that could drift.
+1px absorbs the 1px grid gap between the two rows this element spans. */
+1px absorbs the 1px grid gap between the two rows this element spans.
★ RE-ROOTED IN --rd-shell-h, SAME CARD AS THE TABLE RULE ABOVE. map-h + table-cell(bar +
grid-max-h) now sums to EXACTLY --rd-shell-h by construction (table-max-h is defined as
shell-h - map-h - bar-h, so bar-h + table-max-h = shell-h - map-h, and adding map-h back
cancels it) — so the two-row span the sidebars must cover is just the shell's own total, not a
re-derived sum of its two parts. This also means the sidebars automatically track the NEW
shell handle (.rd-rz-shell) the same tick it moves, with no separate wiring. */
.rd-shell.rd-page-scroll > [data-region="facets"],
.rd-shell.rd-page-scroll > [data-region="rail"]{ min-height:0; overflow:hidden;
max-height:calc(var(--rd-map-h, 40svh) + var(--rd-table-max-h, calc(var(--rd-map-h, 40svh) - var(--rd-table-bar-h, 32px))) + 1px); }
max-height:calc(var(--rd-shell-h, 80svh) + 1px); }
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:52px auto var(--rd-stats-h,30px) minmax(0,1fr) 46px;
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
@ -543,16 +594,32 @@ useShadowStyles(`
title="Drag or use arrow keys to resize the charts rail" aria-label="Resize charts rail"
:aria-valuenow="railW" aria-valuemin="200" aria-valuemax="520"
@pointerdown="startRailResize" @keydown="onResizeKeydown('rail', $event)"></div>
<!-- Title/label flip in page-scroll mode: this handle drives --rd-map-h there (the dashboard
band height, vs. the grid below it), not --table-h (force-set to `auto` and no longer
drives any layout) — see the PAGE-SCROLL AXIS SWITCH note in useDashboardLayout.js. -->
<!-- ★ FIXED (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER): this handle is the MAP/TABLE SPLIT
ONLY, in both modes — it must never change the element's overall height, which is now the
NEW .rd-rz-shell handle's job below. In page-scroll mode it still drives --rd-map-h (the
map's SHARE of --rd-shell-h, not the total — see the CSS note above .rd-shell.rd-page-scroll),
because --table-h is force-set to `auto` and no longer drives any layout — see the
PAGE-SCROLL AXIS SWITCH note in useDashboardLayout.js. -->
<div v-show="!isNarrow && !tableCollapsed" class="rd-rz rd-rz-table" data-resize="table"
role="separator" aria-orientation="horizontal" tabindex="0"
:title="pageScroll ? 'Drag or use arrow keys to resize the dashboard height' : 'Drag or use arrow keys to resize the table'"
:aria-label="pageScroll ? 'Resize dashboard height' : 'Resize table height'"
:title="pageScroll ? 'Drag or use arrow keys to resize the map/table split' : 'Drag or use arrow keys to resize the table'"
:aria-label="pageScroll ? 'Resize map/table split' : 'Resize table height'"
:aria-valuenow="pageScroll ? mapH : (parseFloat(tableH) || 34)"
:aria-valuemin="pageScroll ? 220 : 12" :aria-valuemax="pageScroll ? 1400 : 70"
@pointerdown="startTableResize" @keydown="onResizeKeydown('table', $event)"></div>
<!-- ★ THE NEW HANDLE (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER) — a bar at the SHELL's own
bottom edge, spanning all three columns, on the boundary between the whole
research-dashboard element and whatever a host stacks below it (e.g. the people-groups
grid in dashboard-plus-grid.vue). This is what actually answers "resize the height of the
page": it is the ONLY handle that writes --rd-shell-h. Page-scroll only — grid-mode has no
concept of the element's own height (the shell IS the viewport there, sized by its host
band), so there is nothing for this handle to do outside page-scroll. -->
<div v-show="!isNarrow && pageScroll" class="rd-rz rd-rz-shell" data-resize="shell"
role="separator" aria-orientation="horizontal" tabindex="0"
title="Drag or use arrow keys to resize the dashboard's height"
aria-label="Resize dashboard height"
:aria-valuenow="shellH" aria-valuemin="400" aria-valuemax="2600"
@pointerdown="startShellResize" @keydown="onResizeKeydown('shell', $event)"></div>
<div class="rd-seg" data-segmented @click="setView">
<button v-for="s in SEG" :key="s.k" :class="{ on: mobileView === s.k }" data-view-btn :data-view="s.k">{{ s.l }}</button>
</div>

View file

@ -12,6 +12,25 @@
* properties, PERSISTS the chosen layout to localStorage on every drag-end, RESTORES it on init,
* and offers reset() to clear the key and snap back to the exact defaults.
*
* SHELL vs. TABLE axis (page-scroll mode only — BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER):
* Two DIFFERENT questions look similar and must stay answered by two DIFFERENT handles.
* "table" axis = the MAP/TABLE SPLIT. In page-scroll mode it redistributes height BETWEEN
* the map and the table; the sum of the two (the element's total height)
* does NOT change. This is the EXISTING map/table divider
* (.rd-rz-table in research-dashboard.vue), unchanged in grid-mode.
* "shell" axis = the ELEMENT'S OWN HEIGHT (page-scroll mode only) — a bar at the shell's
* BOTTOM edge, the boundary between the whole research-dashboard element and
* whatever a host stacks below it (e.g. the people-groups grid in
* dashboard-plus-grid.vue). Dragging it changes --rd-shell-h, the total map+
* table budget; the map/table SPLIT within that budget is untouched.
* --rd-shell-h is the new independent variable; --rd-map-h is now the map's share of it, and
* the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before
* this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one
* knob answering two questions, which is why dragging the map/table divider used to resize the
* whole element as a side effect. Driver: "you improperly bound that control to the separator
* line between the map and the table... that's not the request I was making to resize the
* height of the page."
*
* COMPOSITION with the collapse-table fix (do NOT regress):
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
* `.rd-shell.rd-table-collapsed{ grid-template-rows:52px auto 42px 1fr 0 }` comes LATER in source
@ -39,17 +58,18 @@
* @param {import('vue').Ref<boolean>|boolean=} options.pageScroll when true, the table axis drives
* --rd-map-h (px) instead of --table-h — see the PAGE-SCROLL AXIS SWITCH note below.
* @param {string=} options.storageKey localStorage key (default 'rd-dashboard-layout')
* @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number}=} options.defaults default track sizes
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number]}=} options.clamps
* min/max — facetW/railW/mapH in px, tableH in percent-of-shell-height
* @param {{facetW?:number, railW?:number, tableH?:string, mapH?:number, shellH?:number}=} options.defaults default track sizes
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number], mapH?:[number,number], shellH?:[number,number]}=} options.clamps
* min/max — facetW/railW/mapH/shellH in px, tableH in percent-of-shell-height
*
* @returns {{
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
* mapH: import('vue').Ref<number>,
* mapH: import('vue').Ref<number>, shellH: import('vue').Ref<number>,
* startFacetResize:(e:PointerEvent)=>void, startRailResize:(e:PointerEvent)=>void,
* startTableResize:(e:PointerEvent)=>void, reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>,
* stepResize:(axis:'facet'|'rail'|'table', dir:-1|1, toExtreme?:boolean)=>void,
* onResizeKeydown:(axis:'facet'|'rail'|'table', e:KeyboardEvent)=>void
* startTableResize:(e:PointerEvent)=>void, startShellResize:(e:PointerEvent)=>void,
* reset:()=>void, applyVars:()=>void, customized:import('vue').Ref<boolean>,
* stepResize:(axis:'facet'|'rail'|'table'|'shell', dir:-1|1, toExtreme?:boolean)=>void,
* onResizeKeydown:(axis:'facet'|'rail'|'table'|'shell', e:KeyboardEvent)=>void
* }}
*/
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
@ -62,8 +82,18 @@ import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
// ★ 58svh (522px) was the ORIGINAL default and left the page-scroll band taller than one desktop
// viewport (BUG-DASHBOARD-BAND-GROWS-ENORMOUS: chrome 137px + map 522px + table 552px = 1211px
// against 900px). 40svh is the value that makes chrome + map(X) + table(X + bar-height) fit <=900px.
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%', mapH: 360 }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400] }
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%', mapH: 360, shellH: 720 }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
// Mirrors the CSS fallback --rd-table-bar-h (research-dashboard.vue's .rd-table-bar budget calc) —
// the table's header bar (count + Export CSV) sits ABOVE its scrollport and is not itself
// resizable, so the map/table split must always leave it at least this much room, or the divider
// could drag the table's usable grid to zero while its outer cell still looks non-empty.
const TABLE_BAR_H = 32
const TABLE_MIN_H = 120 // grid's own floor below the bar — a table smaller than this is unusable
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
const pctNum = (v, fallback) => {
@ -87,10 +117,14 @@ export function useDashboardLayout(options = {}) {
const railW = ref(defaults.railW) // px
const tableH = ref(defaults.tableH) // string, e.g. '34%'
const mapH = ref(defaults.mapH) // px — only meaningful/applied in page-scroll mode
const shellH = ref(defaults.shellH) // px — page-scroll ELEMENT total height (map+table budget)
const customized = ref(false) // has the user (or a restored layout) diverged from CSS defaults?
const mapHCustomized = ref(false) // page-scroll's own inline var is written independently of
// customized (below), since an untouched grid-mode drag
// must never suddenly pin --rd-map-h and vice versa.
const shellHCustomized = ref(false) // same independence, one level up: the shell handle is a
// separate drag from the map/table split and must not fire
// just because the OTHER page-scroll var was touched.
const narrow = () => !!unref(isNarrow)
const inPageScroll = () => !!unref(pageScroll)
@ -111,6 +145,9 @@ export function useDashboardLayout(options = {}) {
if (mapHCustomized.value) {
el.style.setProperty('--rd-map-h', mapH.value + 'px')
}
if (shellHCustomized.value) {
el.style.setProperty('--rd-shell-h', shellH.value + 'px')
}
}
function clearVars() {
const el = shellRef && shellRef.value
@ -119,13 +156,14 @@ export function useDashboardLayout(options = {}) {
el.style.removeProperty('--rail-w')
el.style.removeProperty('--table-h')
el.style.removeProperty('--rd-map-h')
el.style.removeProperty('--rd-shell-h')
}
// ── persistence ──
function persist() {
try {
localStorage.setItem(storageKey, JSON.stringify({
facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value,
facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value, shellH: shellH.value,
}))
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
}
@ -138,6 +176,9 @@ export function useDashboardLayout(options = {}) {
if (Number.isFinite(s.railW)) railW.value = clamp(s.railW, clamps.railW)
if (s.tableH != null) tableH.value = clamp(pctNum(s.tableH, pctNum(defaults.tableH, 34)), clamps.tableH) + '%'
customized.value = true
// shellH restores BEFORE mapH so the mapH clamp below (which bounds map to <= shellH, see
// startResize's isTablePageScroll branch) has the right ceiling on first paint too.
if (Number.isFinite(s.shellH)) { shellH.value = clamp(s.shellH, clamps.shellH); shellHCustomized.value = true }
if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true }
return true
} catch (_) { return false }
@ -150,8 +191,10 @@ export function useDashboardLayout(options = {}) {
railW.value = defaults.railW
tableH.value = defaults.tableH
mapH.value = defaults.mapH
shellH.value = defaults.shellH
customized.value = false
mapHCustomized.value = false
shellHCustomized.value = false
clearVars()
}
@ -201,13 +244,23 @@ export function useDashboardLayout(options = {}) {
// frame. The fix is to drive --rd-map-h directly, from the MAP ELEMENT's own top edge
// (not the unbounded shell rect), so growing the map is a plain function of where the
// pointer is relative to where the map starts.
//
// ★ THE map/table SPLIT vs. THE shell's OWN HEIGHT (BUG-SHELL-HANDLE-BOUND-TO-WRONG-DIVIDER)
// — this handle must move height BETWEEN map and table, never grow the total. mapH is now
// clamped to leave the table at least TABLE_BAR_H + TABLE_MIN_H of the FIXED shellH budget,
// so dragging this divider can only redistribute the two, exactly like resizing an internal
// pane split rather than the window itself.
const isTablePageScroll = axis === 'table' && inPageScroll()
const isShellResize = axis === 'shell' && inPageScroll()
let startMapTop = 0
let mapHCeiling = clamps.mapH[1]
if (isTablePageScroll) {
const mapEl = el.querySelector('[data-region="map"]')
const mapRect = mapEl ? mapEl.getBoundingClientRect() : null
startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value)
mapHCeiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
}
const startShellTop = isShellResize ? rect.top : 0
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
@ -244,13 +297,27 @@ export function useDashboardLayout(options = {}) {
// distance from the map's (fixed) top to the cursor — a direct 1:1 follow, exactly like
// dragging the corner of a window. Re-derived fresh every frame from startMapTop (not
// summed from a delta), so it can never drift from the cursor across a long drag.
mapH.value = Math.round(clamp(ev.clientY - startMapTop, clamps.mapH))
// Clamped to mapHCeiling (not the raw clamps.mapH max) — the ceiling reserves the table's
// bar + a usable minimum out of the FIXED shellH budget, so this drag only ever moves
// height between map and table and can never grow the element's total height. That is
// the shell axis's job (see isShellResize below), not this one's.
mapH.value = Math.round(clamp(ev.clientY - startMapTop, [clamps.mapH[0], mapHCeiling]))
mapHCustomized.value = true
} else if (axis === 'table') {
// map/table divider → dragging up grows the table (height measured from shell bottom)
const pct = ((rect.bottom - ev.clientY) / rect.height) * 100
tableH.value = Math.round(clamp(pct, clamps.tableH)) + '%'
customized.value = true
} else if (isShellResize) {
// The NEW shell handle — spans the whole element's bottom edge (research-dashboard.vue's
// .rd-rz-shell), the boundary with whatever a host stacks below (e.g. the people-groups
// grid in dashboard-plus-grid.vue). Same fixed-top / 1:1-follow pattern as the map handle
// above, rooted at the SHELL's own top instead of the map's: the pointer's Y position IS
// the shell's new bottom edge. This is the ONLY axis that changes --rd-shell-h, i.e. the
// only handle that changes the element's total height — the map/table divider above must
// never do this (see its own comment).
shellH.value = Math.round(clamp(ev.clientY - startShellTop, clamps.shellH))
shellHCustomized.value = true
}
applyVars()
}
@ -277,13 +344,15 @@ export function useDashboardLayout(options = {}) {
const startFacetResize = (e) => startResize('facet', e)
const startRailResize = (e) => startResize('rail', e)
const startTableResize = (e) => startResize('table', e)
const startShellResize = (e) => startResize('shell', e)
// ── keyboard step-resize (a11y: role="separator" needs an operable equivalent to the drag) ──
// Reuses the SAME refs/clamps/persist/applyVars as the pointer path — no parallel math. One
// discrete step per keypress (no rAF coalescing needed; a keydown can't fire faster than a frame).
// `table` axis mirrors startResize's own page-scroll branch: in page-scroll mode it steps mapH
// (the dashboard band height), otherwise it steps the grid-mode tableH percentage.
const STEP = { facet: 16, rail: 16, table: 24, mapH: 24 } // px per arrow-key press (tableH in %)
// (the map/table split within the FIXED shellH budget), otherwise it steps the grid-mode tableH
// percentage. `shell` steps shellH directly — the only axis that changes the element's total height.
const STEP = { facet: 16, rail: 16, table: 24, mapH: 24, shellH: 24 } // px per arrow-key press (tableH in %)
// `toExtreme` bypasses the per-step delta entirely and clamps straight to lo/hi — Home/End jump,
// not a long walk of individual steps (which would also mean 200 localStorage writes per keypress).
function stepResize(axis, dir, toExtreme) {
@ -299,25 +368,33 @@ export function useDashboardLayout(options = {}) {
: Math.round(clamp(railW.value - dir * STEP.rail, clamps.railW))
customized.value = true
} else if (axis === 'table' && inPageScroll()) {
mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : clamps.mapH[1])
: Math.round(clamp(mapH.value + dir * STEP.mapH, clamps.mapH))
// Same ceiling as the pointer path (startResize's mapHCeiling): reserve the table's bar +
// usable minimum out of the CURRENT shellH so a keyboard drag can redistribute but never
// grow the total either.
const ceiling = Math.max(clamps.mapH[0], shellH.value - (TABLE_BAR_H + TABLE_MIN_H))
mapH.value = toExtreme ? (dir < 0 ? clamps.mapH[0] : ceiling)
: Math.round(clamp(mapH.value + dir * STEP.mapH, [clamps.mapH[0], ceiling]))
mapHCustomized.value = true
} else if (axis === 'table') {
const pct = toExtreme ? (dir < 0 ? clamps.tableH[0] : clamps.tableH[1])
: clamp(pctNum(tableH.value, pctNum(defaults.tableH, 34)) - dir * STEP.table / 4, clamps.tableH)
tableH.value = Math.round(pct) + '%'
customized.value = true
} else if (axis === 'shell' && inPageScroll()) {
shellH.value = toExtreme ? (dir < 0 ? clamps.shellH[0] : clamps.shellH[1])
: Math.round(clamp(shellH.value + dir * STEP.shellH, clamps.shellH))
shellHCustomized.value = true
}
applyVars()
persist()
}
// ArrowUp/Left = grow toward the start edge, ArrowDown/Right = grow toward the end edge, matching
// each handle's drag SENSE (facet/rail are col-resize, table/map is row-resize). Home/End jump to
// the axis's clamp extremes in one write (not a walk of individual steps).
// each handle's drag SENSE (facet/rail are col-resize, table/map/shell are row-resize). Home/End
// jump to the axis's clamp extremes in one write (not a walk of individual steps).
function onResizeKeydown(axis, e) {
const K = e.key
let dir = 0
if (axis === 'table') { if (K === 'ArrowUp') dir = -1; else if (K === 'ArrowDown') dir = 1 }
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 }
if (dir) { e.preventDefault(); stepResize(axis, dir); return }
if (K === 'Home' || K === 'End') {
@ -340,8 +417,8 @@ export function useDashboardLayout(options = {}) {
})
return {
facetW, railW, tableH, mapH, customized, resizing,
startFacetResize, startRailResize, startTableResize,
facetW, railW, tableH, mapH, shellH, customized, resizing,
startFacetResize, startRailResize, startTableResize, startShellResize,
startResize, reset, applyVars, stepResize, onResizeKeydown,
}
}