glytch/fktry/src/ui/buildbar.ts
type-two 7e24eab171 [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>
2026-07-18 14:11:39 +10:00

189 lines
6.1 KiB
TypeScript

/**
* LANE-UI — build bar. Kind tabs, `[`/`]` to page, 1-9 within the active page.
* Click or hotkey selects for placement; main.ts does the ghost and the actual place.
*/
import type { GameData, MachineDef, SimSnapshot } from '../contracts';
import { attrs, cls, el, panel, text } from './dom';
import { paginate, pageOf, type BuildPage } from './pages';
import { createAccentLookup, machineChassis } from './palette';
import { gateFor, indexTech, type TechIndex } from './research';
import type { BuildSelection } from './selection';
import { COPY, DIR_NAME } from './voice';
export interface BuildBar {
el: HTMLElement;
/** Select slot `n` (1-based) on the active page. */
selectSlot(n: number): void;
pageBy(delta: number): void;
sync(): void;
/** Re-read the lock state; cheap enough to call per frame. */
update(snap: SimSnapshot): void;
}
export function createBuildBar(data: GameData, sel: BuildSelection): BuildBar {
const pages: BuildPage[] = paginate(data.machines);
const accentOf = createAccentLookup(data);
const techIndex: TechIndex = indexTech(data);
/** Machine ids currently gated by unfinished research. */
let locked = new Set<string>();
const root = panel();
root.id = 'fk-build';
const tabRow = el('div', 'fk-tabs');
const row = el('div', 'fk-build-row');
const hint = el('div');
hint.id = 'fk-build-hint';
root.append(tabRow, row, hint);
let active = 0;
const tabs = pages.map((p, i) => {
const tab = el('button', 'fk-tab', [p.label]);
attrs(tab, { type: 'button' });
tab.addEventListener('click', () => {
active = i;
render();
});
tabRow.append(tab);
return tab;
});
// REMOVE lives beside the tabs, not on a page: it is a mode, not a machine, and it
// must be reachable whatever page you are on.
const removeBtn = el('button', 'fk-tab fk-tab-remove', [
COPY.removeLabel,
el('span', 'fk-tab-key', [COPY.removeKey]),
]);
attrs(removeBtn, { type: 'button', title: 'REMOVE MODE (X)' });
removeBtn.addEventListener('click', () => sel.toggleRemove());
tabRow.append(removeBtn);
/** Buttons for the active page only; rebuilt on page change (nine at most). */
let buttons: Array<{ def: string; btn: HTMLButtonElement; lock: HTMLElement }> = [];
function buildButtons(page: BuildPage) {
row.replaceChildren();
buttons = page.machines.map((m: MachineDef, i: number) => {
const btn = el('button', 'fk-build-btn');
// Two-material rule (codex §8): grimy body, one impossible element.
const ico = el('div', 'fk-build-ico');
ico.style.background = machineChassis(m);
const accent = el('div', 'fk-build-accent');
accent.style.background = accentOf(m);
accent.style.boxShadow = `0 0 5px ${accentOf(m)}`;
ico.append(accent);
const lock = el('span', 'fk-build-lock', ['\u{1F512}']);
ico.append(lock);
btn.append(ico);
const key = el('span', 'fk-build-key');
key.textContent = String(i + 1); // every slot on every page has a hotkey now
btn.append(key);
btn.append(el('span', 'fk-build-name', [m.name]));
btn.addEventListener('click', () => select(m.id));
row.append(btn);
return { def: m.id, btn, lock };
});
applyLocks();
}
/** A locked machine can't be picked — by click or by hotkey. */
function select(defId: string) {
if (locked.has(defId)) return;
sel.select(defId);
}
function applyLocks() {
for (const b of buttons) {
const isLocked = locked.has(b.def);
cls(b.btn, 'is-locked', isLocked);
cls(b.lock, 'is-on', isLocked);
const m = data.machines.find((x) => x.id === b.def);
const gate = isLocked ? techIndex.gatedBy.get(b.def) : null;
attrs(b.btn, {
title: gate
? `${m?.name ?? b.def}${COPY.requires} ${gate.id.replace(/-/g, ' ').toUpperCase()}`
: `${m?.name ?? b.def}${m?.kind ?? ''}`,
});
}
}
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();
}
function sync() {
const cur = sel.get();
const removing = sel.isRemoving();
for (const b of buttons) cls(b.btn, 'is-sel', cur?.def === b.def);
cls(removeBtn, 'is-armed', removing);
if (removing) {
text(hint, COPY.removeHint);
} else if (cur) {
const def = data.machines.find((m) => m.id === cur.def);
text(hint, `${def?.name ?? cur.def} · FACING ${DIR_NAME[cur.dir]} · ${COPY.placingHint}`);
} else {
text(hint, COPY.idleHint);
}
cls(hint, 'is-placing', !!cur);
cls(hint, 'is-removing', removing);
}
// A selection made from anywhere (including a future hotkey we don't own) pulls the
// bar to the page that contains it, so the hot button is always the visible one.
sel.onChange(() => {
const cur = sel.get();
if (cur) {
const p = pageOf(pages, cur.def);
if (p >= 0 && p !== active) {
active = p;
render();
return;
}
}
sync();
});
render();
return {
el: root,
update(snap) {
// Recompute only when the unlocked set actually changes — the padlocks then fall
// off in the same frame the `researched` event lands.
const next = new Set<string>();
for (const m of data.machines) {
if (gateFor(techIndex, snap.research, m.id)) next.add(m.id);
}
if (next.size === locked.size && [...next].every((d) => locked.has(d))) return;
locked = next;
applyLocks();
// Never leave the player holding something they can no longer build.
const cur = sel.get();
if (cur && locked.has(cur.def)) sel.clear();
},
selectSlot(n) {
const b = buttons[n - 1];
if (b) select(b.def);
},
pageBy(delta) {
if (pages.length === 0) return;
active = (active + delta + pages.length) % pages.length;
render();
},
sync,
};
}