Pay the agent discovery tax once, in the repo
Some checks are pending
build-gate / build (push) Waiting to run

MEASURED: agents produced 21 report docs totalling 2,079 lines of prose and 27
scratch probe files against a handful of product commits. The cause is the spawn
prompts, which is to say me: each carried ~15 environment rules, several cited
lessons, a three-clause invariant, a mandatory screenshot read and a
what-I-could-not-verify section. Per report that is 7-12 headings and 10-16
verification mentions. The prompt made PROVING the job and BUILDING the side
effect.

Two costs compounded. Every agent re-derived the same environment from scratch
-- which port, is the tree settled, does stale SRI block the script, does this
box have WebGL. And several spent that discovery budget, hit a build queue they
could not control, and died on a session limit before committing; their work
survived only because it was found untracked (one 511-line gate was a `git
clean` away from being lost).

This file pays that tax once. Spawn prompts can now be short: what to build,
which files you own, and the one assertion that would falsify it.

It also states the rule that was missing: ship a commit and PUSH, then write two
sentences -- not the reverse. A long honest report attached to no commit is the
failure mode. And it sizes verification to blast radius explicitly, because
over-verifying is a real cost rather than a virtue.

check:doc-scripts passes, so every command quoted here exists in package.json.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 16:05:47 +00:00
parent 6d9ace4cc7
commit 873a99d1a6
3 changed files with 277 additions and 24 deletions

105
AGENT-BRIEF.md Normal file
View file

@ -0,0 +1,105 @@
# AGENT BRIEF — read this once, then build
> **If you were dispatched to this repo, read this file first.** It replaces the wall of environment
> rules that used to be pasted into every spawn prompt. Your prompt should now be short: what to
> build, which files you own, and the one assertion that would prove it.
>
> **Why this file exists:** measured 2026-09-23 — agents produced 21 report docs totalling 2,079
> lines and 27 scratch probes against a handful of product commits, because each spawn prompt made
> *proving* the job and *building* the side effect. Every agent also re-derived the same environment
> from scratch. That discovery tax is now paid once, here.
## The rule that matters most
**Ship a commit, then write two sentences. Not the reverse.** The artifact is the diff. A long,
honest report attached to no commit is the failure mode this file exists to end.
**Push it.** A local commit is invisible — the Driver runs a laptop watcher against the Forgejo repo
and sees changes in his browser *from that repo*. Work that stops at `git commit` is not delivered.
```sh
TOKEN="$(pass show vivarium/forgejo-certified-sandbox | head -1)"
git -c http.postBuffer=524288000 \
push "https://oauth2:${TOKEN}@git.nuxtbase.app/doxa-life/doxa-maps.git" HEAD:doxa-maps-plugin-studio \
2>&1 | sed "s/${TOKEN}/[REDACTED]/g"
unset TOKEN
```
Never echo the token; always filter output through `sed`; never let it reach `.git/config`.
## Do not run the full build
`bun run build` takes minutes, and its first step deletes `public/doxa-maps/` — which wipes the
output every other agent is using. **Use the dev server; it hot-reloads.**
```sh
bun x vite --port <YOUR OWN PORT> --strictPort # e.g. 5299, 5399, 5499 …
BUNDLE=<name> bun x vite build # one bundle's compiled output; does NOT clean
```
`--strictPort` is **mandatory**. Vite silently moves to the next free port otherwise, and five gates
have `5173` hardcoded (`check-facet-changes-map`, `plugin-gallery-smoke`, `smoke-studio`,
`generate-gallery`, `check-scrim-covers-viewport`) — so a drifted server makes a gate talk to
somebody else's page and report a confident wrong answer with no error.
## Before you believe a measurement
| Symptom | Check this first |
|---|---|
| "it didn't mount" / "0 elements" | `bun run check:sri` — a single-bundle build leaves stale SRI in leaf pages and the browser then **refuses to execute the script**. Looks identical to a mount failure. |
| bundles missing, files deleted, counts wrong | `bun run check:settled` — graded **by exit code**. It reads the build *lock*, which knows the *queue*; `pgrep` samples an instant and will tell you 0 builders while one is queued. |
| a gate "failed" | Re-run it un-suppressed. A command killed by a short `timeout` is not a failure, and these gates print teaching prose *after* the verdict, so `| tail -1` inverts the signal. **Grade by exit code.** |
| a selector finds nothing in a composite | Shadow DOM does not pierce. Use a recursive `shadowRoot` walker, not `document.querySelector`. |
## Facts that are easy to get wrong
- **This box HAS working WebGL** via SwiftShader — `verify:map-paint` passes 11/11 with live GL at
1280×800. Any instruction saying "this VPS has NO WebGL" is **stale**; you may assert painted
canvases and real zoom. Launch flags are in `internal/verify-map-paint.mjs`.
- **Playwright must run from inside this repo** — `node_modules` is here; a script in `/tmp` cannot
resolve it.
- **`serve-strict` takes `--directory`, not `--root`**, and returns **404 for a directory URL** by
design (production parity). Ask for `/index.html` explicitly.
- **`pray.doxa.life` needs a browser User-Agent** from scripts; a default script UA gets 403.
- **`root-dx-life` is a READ-ONLY clone** and not even a git repo — zero edits, ever, no safety net.
- **Scratch files go in `_qa-cycle/`**, never in this repo.
## House style
- **Material LIGHT only:** `#f3f3f1` surface · `#fff` · `#3b463d` text · `#92b195` accent ·
`#d1d4cd` border · `#6d766e` muted. No dark mode, no `prefers-color-scheme: dark`.
- **No backticks inside shadow-CSS template literals** — this broke the build twice. Use string
concatenation: `'.x{display:block}' + '.y{…}'`.
- Keep both synced CSS copies byte-identical (`check:synced-css`).
- Every visible button needs an accessible name (`check:a11y-button-names`).
- **Imitate before innovate.** Where DX-life's designer already made a decision — radii, spacing,
type scale, colours — port it faithfully rather than substituting our tokens.
- Commit messages are product-only: no AI or authorship trailers.
## How much verification is enough
**Size it to the blast radius.** This is a judgement, and over-verifying is a real cost, not a
virtue:
| Change | Prove it with |
|---|---|
| one bundle's own CSS / copy | the dev server + a screenshot you actually read |
| one bundle's logic | a unit-style assertion on the function, or one browser check |
| anything in `library/` | name every consumer (`grep`) and re-check at least two of them — a change here reaches every bundle |
| layout / scroll / wheel behaviour | the three-clause conjunction, via `bun run check:fits-screen` |
State the **one assertion that would falsify your change**, run it, and report the number. You do not
owe a complete audit.
★ **Read the image when the deliverable is visual.** Numbers have certified visually broken output
in this repo more than once — a green generator is not a look-at-it.
## Where the hard-won knowledge lives
Read these only when they bear on your task; do not read them all:
- `_qa-cycle/02-builder-cycle/lessons-learned.json` — stories with costs attached
- `_qa-cycle/02-builder-cycle/anti-patterns.json` — smells to avoid
- `_qa-cycle/02-builder-cycle/best-practices.json` — practices that proved themselves
- `_qa-cycle/01-ideation-research/VISION-the-seven-plugins-are-one-argument.md` — what the product is
*for*, and why the dashboard is deliberately shorter than a viewport

View file

@ -166,14 +166,19 @@ 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 } = useDashboardLayout({
const { startFacetResize, startRailResize, startTableResize, onResizeKeydown, facetW, railW, tableH, mapH } = useDashboardLayout({
shellRef: shell,
bus,
isNarrow,
// ★ BUG-TABLE-RESIZE-HANDLE-DOES-NOTHING: in page-scroll mode --table-h is force-set to `auto`
// by the stylesheet and no longer drives any layout (see the pageScroll note above); the
// composable needs to know so the table-axis handle can drive --rd-map-h instead.
pageScroll,
storageKey: 'rd-dashboard-layout',
// MUST mirror the `:host` --facet-w/--rail-w defaults, or reset() would snap the rails to a
// different balance than a fresh load shows. Tree wider than charts — see the rail-balance note.
defaults: { facetW: 296, railW: 264, tableH: '34%' },
// mapH mirrors the page-scroll CSS fallback `58svh` (~522px at a 900px viewport).
defaults: { facetW: 296, railW: 264, tableH: '34%', mapH: 522 },
})
// KPI strip — generic formatting per kind (mirrors the original inline strip).
@ -303,6 +308,10 @@ useShadowStyles(`
.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); }
/* Keyboard focus ring (tabindex="0" makes these real operable widgets, not just a mouse
affordance) — same accent as hover/active so keyboard and mouse read as one visual language. */
.rd-rz:focus-visible{ outline:2px solid var(--rd-accent,#e67e22); outline-offset:1px; }
.rd-rz:focus-visible::after{ background:var(--rd-accent,#e67e22); }
/* ★ BAND 4 OF THE CENTRED STACK — the PROGRESS header (UPGs / scope / population / reach /
countries / unreached / unadopted). Two changes, both from the one-centred-stack card:
- padding now resolves --rd-edge instead of --s4, so its gutter matches the topbar and the
@ -346,6 +355,24 @@ useShadowStyles(`
inside the table, which is correct and expected for a scrollable panel — the difference is the
panel is now large enough that a user reads it deliberately rather than falling into it. */
.rd-shell.rd-page-scroll .rd-grid{ max-height:var(--rd-table-max-h, 62svh); }
/* ── 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
height) but resolves to INTRINSIC content height once page-scroll sets --table-h:auto — the
grid's rows are no longer a fixed sum, so "100%" cascades into "as tall as my content wants".
MEASURED at 1440x900 with all 5 tree sections open: .fr-body clientHeight === scrollHeight
=== 2117px — its OWN overflow-y:auto never engaged, because it was never actually
constrained, and the whole .rd-shell measured 2325px. Collapsing sections by default (see
DashboardFacetRail's open state) hides the symptom on first paint; re-opening even two or
three trees reproduces the same unbounded growth, because nothing upstream of the scroller
was ever fixed. This rule is that fix: give the two side columns a real cap so their own
internal scrollbars do the job they were built for, exactly like the table row above them.
Reuses --rd-map-h (the same explicit height the map row already pins in this mode) rather
than inventing a third height variable — the three regions read as one bounded row. This
selector is scoped to .rd-page-scroll only; the fixed-viewport grid already has a bounded
row via 1fr/34% and never reaches it. */
.rd-shell.rd-page-scroll > [data-region="facets"],
.rd-shell.rd-page-scroll > [data-region="rail"]{ max-height:var(--rd-map-h, 58svh); overflow:hidden; }
.rd-shell.rd-narrow{ grid-template-columns:1fr; grid-template-rows:52px auto 42px minmax(0,1fr) 46px;
grid-template-areas:"topbar" "filters" "stats" "body" "seg"; }
@ -419,16 +446,33 @@ useShadowStyles(`
<DashboardChartsRail />
</aside>
<!-- Desktop drag-resize handles — never on narrow/mobile; the map/table divider also hides
while the table is collapsed (nothing to resize). @pointerdown starts the drag. -->
while the table is collapsed (nothing to resize). @pointerdown starts the drag.
★ KEYBOARD A11Y: `title` is a TOOLTIP, not an accessible name (this repo's own
check:a11y-button-names comment says so), and a role="separator" with no tabindex is not
focusable — so none of these three were keyboard-operable. tabindex="0" + aria-label +
@keydown (Arrow keys step, Home/End jump to the clamp extremes) makes each one a real
operable widget; aria-valuenow/min/max report the LIVE ref the pointer-drag path already
owns, so the two input methods can never disagree about the current value. -->
<div v-show="!isNarrow && !facetCollapsed" class="rd-rz rd-rz-facet" data-resize="facet"
role="separator" aria-orientation="vertical" title="Drag to resize the filters rail"
@pointerdown="startFacetResize"></div>
role="separator" aria-orientation="vertical" tabindex="0"
title="Drag or use arrow keys to resize the filters rail" aria-label="Resize filters rail"
:aria-valuenow="facetW" aria-valuemin="160" aria-valuemax="420"
@pointerdown="startFacetResize" @keydown="onResizeKeydown('facet', $event)"></div>
<div v-show="!isNarrow && !railCollapsed" class="rd-rz rd-rz-rail" data-resize="rail"
role="separator" aria-orientation="vertical" title="Drag to resize the charts rail"
@pointerdown="startRailResize"></div>
role="separator" aria-orientation="vertical" tabindex="0"
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. -->
<div v-show="!isNarrow && !tableCollapsed" class="rd-rz rd-rz-table" data-resize="table"
role="separator" aria-orientation="horizontal" title="Drag to resize the table"
@pointerdown="startTableResize"></div>
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'"
:aria-valuenow="pageScroll ? mapH : (parseFloat(tableH) || 34)"
:aria-valuemin="pageScroll ? 220 : 12" :aria-valuemax="pageScroll ? 1400 : 70"
@pointerdown="startTableResize" @keydown="onResizeKeydown('table', $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

@ -36,23 +36,31 @@
* @param {import('vue').Ref<HTMLElement|null>} options.shellRef ref holding the .rd-shell root el
* @param {object=} options.bus selection bus; when given, listens bus.on('resetLayout', reset)
* @param {import('vue').Ref<boolean>|boolean=} options.isNarrow desktop gate (true = mobile/narrow)
* @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}=} options.defaults default track sizes
* @param {{facetW?:[number,number], railW?:[number,number], tableH?:[number,number]}=} options.clamps
* min/max — facetW/railW in px, tableH in percent-of-shell-height
* @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
*
* @returns {{
* facetW: import('vue').Ref<number>, railW: import('vue').Ref<number>, tableH: import('vue').Ref<string>,
* mapH: 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>
* 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
* }}
*/
import { ref, unref, onMounted, onBeforeUnmount } from 'vue'
// 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.
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%' }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70] }
// mapH mirrors the CSS fallback `58svh` (~522px at a 900px viewport — see the page-scroll note
// below); it is only ever WRITTEN once the user drags, so an untouched embed stays on the
// viewport-relative svh default and keeps resizing with the window, exactly like every other var.
const DEFAULTS = { facetW: 296, railW: 264, tableH: '34%', mapH: 522 }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400] }
const clamp = (v, [lo, hi]) => Math.min(hi, Math.max(lo, v))
const pctNum = (v, fallback) => {
@ -65,6 +73,7 @@ export function useDashboardLayout(options = {}) {
shellRef,
bus = null,
isNarrow = false,
pageScroll = false,
storageKey = 'rd-dashboard-layout',
} = options
@ -74,17 +83,31 @@ export function useDashboardLayout(options = {}) {
const facetW = ref(defaults.facetW) // px
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 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 narrow = () => !!unref(isNarrow)
const inPageScroll = () => !!unref(pageScroll)
// ── write / clear the three inline CSS custom properties on the shell root ──
// ── write / clear the inline CSS custom properties on the shell root ──
// --rd-map-h is applied independently of `customized` (grid-mode's facet/rail/table-h gate):
// page-scroll is a different layout entirely, with its own untouched-vs-dragged state, so
// gating it on the grid vars' flag would either apply it before any page-scroll drag ever
// happened, or (worse) suppress a real page-scroll drag because the grid vars were untouched.
function applyVars() {
const el = shellRef && shellRef.value
if (!el || !customized.value) return
el.style.setProperty('--facet-w', facetW.value + 'px')
el.style.setProperty('--rail-w', railW.value + 'px')
el.style.setProperty('--table-h', tableH.value)
if (!el) return
if (customized.value) {
el.style.setProperty('--facet-w', facetW.value + 'px')
el.style.setProperty('--rail-w', railW.value + 'px')
el.style.setProperty('--table-h', tableH.value)
}
if (mapHCustomized.value) {
el.style.setProperty('--rd-map-h', mapH.value + 'px')
}
}
function clearVars() {
const el = shellRef && shellRef.value
@ -92,13 +115,14 @@ export function useDashboardLayout(options = {}) {
el.style.removeProperty('--facet-w')
el.style.removeProperty('--rail-w')
el.style.removeProperty('--table-h')
el.style.removeProperty('--rd-map-h')
}
// ── persistence ──
function persist() {
try {
localStorage.setItem(storageKey, JSON.stringify({
facetW: facetW.value, railW: railW.value, tableH: tableH.value,
facetW: facetW.value, railW: railW.value, tableH: tableH.value, mapH: mapH.value,
}))
} catch (_) { /* storage blocked / SSR — layout still works this session */ }
}
@ -111,6 +135,7 @@ 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
if (Number.isFinite(s.mapH)) { mapH.value = clamp(s.mapH, clamps.mapH); mapHCustomized.value = true }
return true
} catch (_) { return false }
}
@ -121,7 +146,9 @@ export function useDashboardLayout(options = {}) {
facetW.value = defaults.facetW
railW.value = defaults.railW
tableH.value = defaults.tableH
mapH.value = defaults.mapH
customized.value = false
mapHCustomized.value = false
clearVars()
}
@ -159,6 +186,24 @@ export function useDashboardLayout(options = {}) {
const startFacet = facetW.value
const startRail = railW.value
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
// shell is `height:auto` and grows with its content, so `rect` above is NOT one screen tall
// — `rect.bottom` can sit thousands of px below the viewport. --table-h is force-set to
// `auto` by the .rd-page-scroll stylesheet rule and no longer drives ANY layout: the map's
// height instead comes from --rd-map-h (default 58svh; see the CSS note beside it), and the
// table's own max-height comes from --rd-table-max-h. Writing tableH in this mode is a
// complete no-op — MEASURED: map height 522px before and after a real 200px drag, every
// 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.
const isTablePageScroll = axis === 'table' && inPageScroll()
let startMapTop = 0
if (isTablePageScroll) {
const mapEl = el.querySelector('[data-region="map"]')
const mapRect = mapEl ? mapEl.getBoundingClientRect() : null
startMapTop = mapRect ? mapRect.top : (e.clientY - mapH.value)
}
try { handleEl && handleEl.setPointerCapture && handleEl.setPointerCapture(e.pointerId) } catch (_) {}
// ── A11Y / PHOTOSENSITIVITY (BUG-RESIZE-STROBE): mark the drag as LIVE ──
@ -183,15 +228,26 @@ export function useDashboardLayout(options = {}) {
if (axis === 'facet') {
// left facet edge → dragging right widens the facet column
facetW.value = Math.round(clamp(startFacet + (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))
customized.value = true
} else if (isTablePageScroll) {
// page-scroll: the handle sits pinned to the MAP's own bottom edge (.rd-rz-table is
// align-self:end on the map grid-area — see the CSS below), whose top never moves. The
// pointer's new Y position IS the map's new bottom edge, so the new height is simply the
// 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))
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
}
customized.value = true
applyVars()
}
function move(ev) {
@ -218,6 +274,54 @@ export function useDashboardLayout(options = {}) {
const startRailResize = (e) => startResize('rail', e)
const startTableResize = (e) => startResize('table', 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 %)
// `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) {
if (narrow()) return
if (axis === 'facet') {
facetW.value = toExtreme ? (dir < 0 ? clamps.facetW[0] : clamps.facetW[1])
: Math.round(clamp(facetW.value + dir * STEP.facet, clamps.facetW))
customized.value = true
} else if (axis === 'rail') {
// Same "left edge, dragging left widens" sense as the pointer path: ArrowLeft (dir -1)
// widens the rail, so the rail's step direction is inverted relative to facet's.
railW.value = toExtreme ? (dir < 0 ? clamps.railW[1] : clamps.railW[0])
: 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))
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
}
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).
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 }
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') {
e.preventDefault()
stepResize(axis, K === 'Home' ? -1 : 1, true)
}
}
// ── lifecycle: restore + apply after the shell el exists; wire the reset bus event ──
let offReset = null
onMounted(() => {
@ -232,8 +336,8 @@ export function useDashboardLayout(options = {}) {
})
return {
facetW, railW, tableH, customized, resizing,
facetW, railW, tableH, mapH, customized, resizing,
startFacetResize, startRailResize, startTableResize,
startResize, reset, applyVars,
startResize, reset, applyVars, stepResize, onResizeKeydown,
}
}