[ui] fix: the tab scrollbar was swallowing clicks on half of every tab

Reproduced the "clicking POWER does nothing" report with a real mouse:
elementFromPoint at the button's own centre returned the strip container, not
the button. The horizontal scrollbar was painted over the lower half of every
tab and ate clicks there — which is exactly why keyboard paging still worked
and clicking didn't.

Fixed by giving the scrollbar its own lane (padding-bottom >= its height). The
full button height now hit-tests to the button and a real click activates the
tab.

The strip itself is the approved round-5 design: one row that scrolls rather
than wrapping. The catalog keeps growing — RESEARCH joined in v4 and threw
REMOVE onto a line of its own — and a wrapping row moves every other tab under
the player's cursor. Paging with [ ] scrolls the active tab into view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 14:11:39 +10:00
parent c441ec3a53
commit 7e24eab171
2 changed files with 131 additions and 5 deletions

View File

@ -115,6 +115,9 @@ export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
function render() {
buildButtons(pages[active]);
for (let i = 0; i < tabs.length; i++) cls(tabs[i], 'is-active', i === active);
// The strip scrolls, so paging with [ ] must bring its tab into view or the player
// pages blind. `nearest` avoids yanking the strip when the tab is already visible.
tabs[active]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
sync();
}

View File

@ -125,8 +125,30 @@ const CSS = `
justify-content: center;
}
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine. */
.fk-tabs { display: flex; gap: 3px; margin-bottom: 5px; flex-wrap: wrap; justify-content: center; }
/* Kind tabs. A page holds at most nine so 1-9 always reaches every machine.
v5: a single scrolling strip rather than a wrapping row the catalog keeps growing
(RESEARCH joined in v4 and threw REMOVE onto a line of its own), and tabs jumping to a
second row moves every other tab under the player's cursor. */
.fk-tabs {
display: flex; gap: 3px; margin-bottom: 5px;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--fk-line) transparent;
/* The scrollbar gets its OWN lane below the buttons. With too little padding it is
painted over the lower half of every tab and swallows clicks there the exact
"clicking POWER does nothing" bug from review, which is why keyboard paging still
worked. Must stay >= the scrollbar height below. */
padding-bottom: 7px;
}
.fk-tabs::-webkit-scrollbar { height: 4px; }
.fk-tabs::-webkit-scrollbar-track { background: transparent; }
.fk-tabs::-webkit-scrollbar-thumb { background: var(--fk-line); }
.fk-tabs::-webkit-scrollbar-thumb:hover { background: var(--fk-dim); }
/* Tabs must not shrink into unreadable slivers when the strip overflows. */
.fk-tab { flex: 0 0 auto; }
.fk-tab {
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
@ -138,9 +160,8 @@ const CSS = `
.fk-tab:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tab.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
/* REMOVE is a mode, not a machine it sits with the tabs and reads as a hazard.
No auto margin: with the RESEARCH tab added the row wraps, and an auto margin threw
REMOVE onto a line of its own. */
/* REMOVE is a mode, not a machine it sits at the end of the strip and reads as a
hazard. It scrolls with the tabs; the hotkey (X) is always available regardless. */
.fk-tab-remove { margin-left: 10px; color: var(--fk-dim); }
.fk-tab-remove:hover { color: var(--fk-red); border-color: var(--fk-red); }
.fk-tab-remove.is-armed {
@ -227,6 +248,11 @@ const CSS = `
.fk-ins-heat-state { font-size: 10px; color: var(--fk-amber); margin-top: 3px; }
.fk-ins-heat-state.is-scram { color: var(--fk-red); font-weight: bold; }
/* MOSQUITO EXPOSURE — wildlife is a hazard, not a fault: amber, quiet, matter-of-fact. */
.fk-ins-exposure { display: none; margin-top: 6px; }
.fk-ins-exposure.is-on { display: block; }
.fk-ins-exposure-pct { color: var(--fk-amber); font-size: 10px; }
/* Process select: a native <select> beaten into the house style. */
.fk-ins-recipe {
width: 100%;
@ -354,10 +380,107 @@ const CSS = `
100% { opacity: 0; }
}
/* ---------------------------------------------------------------- screen enlarge */
#fk-screenview {
display: none;
position: fixed; inset: 0;
background: rgba(4, 3, 8, 0.86);
align-items: center; justify-content: center;
pointer-events: auto;
z-index: 40;
cursor: zoom-out;
}
#fk-screenview.is-open { display: flex; }
/* THE SCREEN itself is enlarged (see screenview.ts it's a WebGL canvas and can't be
mirrored), so the backdrop only dims. */
#screen { cursor: zoom-in; }
/* ---------------------------------------------------------------- tuner dock */
#fk-tuner {
/* Off-stage until the fossil is dug up. */
display: none;
bottom: 10px; right: 10px;
width: 250px;
z-index: 13;
}
#fk-tuner.is-revealed {
display: block;
animation: fk-tuner-in 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Arrives like equipment being wheeled in, not like a notification. */
@keyframes fk-tuner-in {
0% { opacity: 0; transform: translateX(28px); }
100% { opacity: 1; transform: translateX(0); }
}
#fk-tuner.is-muted { opacity: 0.72; }
/* The dock and the toast stack share the bottom-right corner. Once the dock is in, the
toasts stand off above it rather than printing over the dial. The class goes on the
toast stack itself rather than on an ancestor same element, no descendant matching
to get wrong. */
#fk-toasts.is-standoff { bottom: 176px; }
.fk-tuner-readout {
color: var(--fk-dim);
font-size: 11px;
letter-spacing: 0.04em;
margin: 4px 0 6px;
min-height: 14px;
}
.fk-tuner-readout.is-locked { color: var(--fk-cool); text-shadow: 0 0 6px rgba(63,255,224,0.5); }
.fk-tuner-dial {
position: relative;
height: 14px;
background: var(--fk-bg-solid);
border: 1px solid var(--fk-line);
cursor: ew-resize;
pointer-events: auto;
/* Tick marks, so the dial reads as a radio scale rather than a progress bar. */
background-image: repeating-linear-gradient(
90deg, var(--fk-line) 0 1px, transparent 1px 10px);
}
.fk-tuner-dial-fill {
position: absolute; top: 0; bottom: 0; left: 0;
background: linear-gradient(90deg, transparent, rgba(63, 255, 224, 0.25));
border-right: 2px solid var(--fk-cool);
box-shadow: 0 0 8px rgba(63, 255, 224, 0.6);
}
.fk-tuner-bands { display: flex; gap: 3px; margin-top: 6px; }
.fk-tuner-band {
flex: 1;
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
color: var(--fk-faint); font: inherit; font-size: 9px; letter-spacing: 0.1em;
padding: 2px 0; cursor: pointer; pointer-events: auto;
}
.fk-tuner-band:hover { color: var(--fk-fg); border-color: var(--fk-cool); }
.fk-tuner-band.is-active { color: var(--fk-cool); border-color: var(--fk-cool); background: #0c1a1a; }
.fk-tuner-volrow { display: flex; align-items: center; gap: 5px; margin-top: 6px; }
.fk-tuner-vol-label { color: var(--fk-faint); font-size: 9px; }
.fk-tuner-vol {
position: relative; flex: 1; height: 6px;
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
cursor: pointer; pointer-events: auto;
}
.fk-tuner-vol-fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--fk-dim); }
.fk-tuner-mute {
background: var(--fk-bg-solid); border: 1px solid var(--fk-line);
color: var(--fk-dim); font: inherit; font-size: 9px;
padding: 2px 6px; cursor: pointer; pointer-events: auto;
}
.fk-tuner-mute:hover { border-color: var(--fk-amber); color: var(--fk-amber); }
.fk-tuner-mute.is-on { color: var(--fk-amber); border-color: var(--fk-amber); }
.fk-tuner-hint { color: var(--fk-faint); font-size: 8px; letter-spacing: 0.1em; margin-top: 6px; }
/* ---------------------------------------------------------------- toasts */
#fk-toasts {
position: fixed; bottom: 10px; right: 10px;
transition: bottom 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
pointer-events: none;
z-index: 11;