Fix keyboard nav, add inverted selector, big knockout canvas labels

Replace broken flatIndex counter with stable indexOf lookup so desktop
keyboard navigation works. Menu selector now uses full-row colour
inversion (cyan bg, black text) instead of a > prefix. "kamil banc"
heading is selectable at index 0 and links to /about. Selector persists
after executing commands so you can keep browsing.

Canvas labels for menu items (CLAIMS, STRATEGY, etc.) render as large
block letters using a 5x7 bitmap font with auto-scaling. Text punches
black knockout holes in the fluid simulation instead of glowing cyan.
Oracle responses and SHOW YOURSELF still use single-line bright overlay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kbanc85 2026-02-15 14:29:04 -05:00
parent d910a32083
commit 313a3514ca
3 changed files with 220 additions and 73 deletions

View File

@ -150,6 +150,103 @@ export function renderDensity(
}
}
/* ── Bitmap font: 5×7 per character, stored as 7 rows of 5-bit numbers ── */
const BITMAP_FONT: Record<string, number[]> = {
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
I: [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
N: [0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001],
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
S: [0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b01010, 0b00100],
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
};
/**
* Build a big-text overlay using the bitmap font.
* Each "on" pixel becomes a scale×scale block of █ cells,
* auto-scaled to fill ~80% of grid width.
*/
export function createBigTextOverlay(
text: string,
gridWidth: number,
gridHeight: number,
): TextOverlayState {
const chars = text.toUpperCase().split("");
// Calculate bitmap dimensions (5 wide per char + 1 gap, minus trailing gap)
const charW = 5;
const charH = 7;
const gap = 1;
const bitmapWidth = chars.length * (charW + gap) - gap;
// Auto-scale to fill ~80% of grid width, minimum 1
const scale = Math.max(1, Math.floor((gridWidth * 0.8) / bitmapWidth));
const totalW = bitmapWidth * scale;
const totalH = charH * scale;
const offsetX = Math.floor((gridWidth - totalW) / 2);
const offsetY = Math.floor((gridHeight - totalH) / 2);
const cells: TextOverlayCell[] = [];
const cellDelays: number[] = [];
for (let ci = 0; ci < chars.length; ci++) {
const glyph = BITMAP_FONT[chars[ci]];
if (!glyph) continue;
const charOffsetX = ci * (charW + gap);
for (let row = 0; row < charH; row++) {
const bits = glyph[row];
for (let col = 0; col < charW; col++) {
// Check if this pixel is "on" (MSB-first: bit 4 = leftmost)
if (bits & (1 << (charW - 1 - col))) {
// Expand to scale×scale block
const delay = Math.random();
for (let sy = 0; sy < scale; sy++) {
for (let sx = 0; sx < scale; sx++) {
const gx = offsetX + (charOffsetX + col) * scale + sx;
const gy = offsetY + row * scale + sy;
if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) continue;
const spanIdx = gy * gridWidth + gx;
cells.push({
spanIdx,
char: "\u2588", // █
gx: gx + 1, // 1-indexed for fluid grid
gy: gy + 1,
});
cellDelays.push(delay);
}
}
}
}
}
}
return { startTime: performance.now(), cells, cellDelays, dark: true };
}
/* ── Text overlay: stamps text into the simulation grid ── */
const SCRAMBLE_CHARS = "!@#$%^&*<>{}[]|/\\?~+=";
@ -165,6 +262,7 @@ export interface TextOverlayState {
startTime: number;
cells: TextOverlayCell[];
cellDelays: number[];
dark?: boolean;
}
/**
@ -207,6 +305,8 @@ export function renderTextOverlay(
assembleProgress: number,
dissolveProgress: number,
): void {
const dark = overlay.dark ?? false;
for (let i = 0; i < overlay.cells.length; i++) {
const { spanIdx, char } = overlay.cells[i];
if (char === " ") continue;
@ -216,32 +316,58 @@ export function renderTextOverlay(
const delay = overlay.cellDelays[i];
if (dissolveProgress > 0) {
// Dissolve: each cell scrambles then vanishes (staggered by delay)
const cellProg = dissolveProgress / (1 - delay * 0.4);
if (cellProg >= 1) continue; // fully dissolved, fluid takes over
if (cellProg > 0.3) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,80%,45%)";
if (dark) {
// Dark/knockout mode: letters punch black holes in the fluid
if (dissolveProgress > 0) {
const cellProg = dissolveProgress / (1 - delay * 0.4);
if (cellProg >= 1) continue;
if (cellProg > 0.3) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,15%,18%)";
} else {
span.textContent = " ";
span.style.color = "";
}
} else if (assembleProgress < 1) {
const cellProg = (assembleProgress - delay * 0.4) / 0.6;
if (cellProg <= 0) continue;
if (cellProg < 0.6) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,15%,18%)";
} else {
span.textContent = " ";
span.style.color = "";
}
} else {
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
}
} else if (assembleProgress < 1) {
// Assemble: each cell scrambles then locks (staggered by delay)
const cellProg = (assembleProgress - delay * 0.4) / 0.6;
if (cellProg <= 0) continue; // not started, fluid still showing
if (cellProg < 0.6) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,80%,45%)";
} else {
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
span.textContent = " ";
span.style.color = "";
}
} else {
// Hold: show text clearly
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
// Bright mode: cyan text overlay
if (dissolveProgress > 0) {
const cellProg = dissolveProgress / (1 - delay * 0.4);
if (cellProg >= 1) continue;
if (cellProg > 0.3) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,80%,45%)";
} else {
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
}
} else if (assembleProgress < 1) {
const cellProg = (assembleProgress - delay * 0.4) / 0.6;
if (cellProg <= 0) continue;
if (cellProg < 0.6) {
span.textContent = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
span.style.color = "hsl(190,80%,45%)";
} else {
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
}
} else {
span.textContent = char;
span.style.color = "hsl(190,90%,75%)";
}
}
}
}

View File

@ -123,21 +123,30 @@ function MenuItem({
return (
<button
onClick={onClick}
className={`block w-full text-left text-sm transition-all ${
className={`block w-full text-left text-sm px-1 -mx-1 transition-all ${
isHighlighted
? "text-terminal-cyan glow-cyan"
? "bg-[hsl(var(--terminal-cyan))] text-black"
: "text-terminal-cyan hover:glow-cyan"
}`}
>
<span className={`inline-block w-4 ${isHighlighted ? "text-terminal-cyan glow-cyan" : "invisible"}`}>&gt;</span>
{entry.command}
{entry.hint && (
<span className="text-terminal-dim ml-4 text-xs">{entry.hint}</span>
<span className={`ml-4 text-xs ${isHighlighted ? "text-black/60" : "text-terminal-dim"}`}>{entry.hint}</span>
)}
</button>
);
}
// Name entry: always index 0 in the selectable list, navigates to /about
const NAME_ENTRY: CommandEntry = {
command: "about",
aliases: [],
href: "/about",
summary: "",
hint: "",
group: "pages",
};
// ─── Main component (inner, needs CanvasSceneProvider above it) ──
function TerminalHomepageInner({
@ -150,7 +159,7 @@ function TerminalHomepageInner({
const { pushScene, popScene } = useCanvasScene();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [inputValue, setInputValue] = useState("");
const [activeItemIndex, setActiveItemIndex] = useState(-1);
const [activeItemIndex, setActiveItemIndex] = useState(0);
const [streamingId, setStreamingId] = useState<number | null>(null);
const [completedIds, setCompletedIds] = useState<Set<number>>(new Set());
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
@ -160,15 +169,15 @@ function TerminalHomepageInner({
const inputRef = useRef<HTMLInputElement>(null);
// All selectable menu items in display order
const selectableItems = useMemo(() => COMMANDS, []);
// All selectable menu items: NAME_ENTRY at index 0, then COMMANDS
const selectableItems = useMemo(() => [NAME_ENTRY, ...COMMANDS], []);
const groups = useMemo(() => {
const g: Record<string, CommandEntry[]> = {};
for (const cmd of selectableItems) {
for (const cmd of COMMANDS) {
(g[cmd.group] ??= []).push(cmd);
}
return g;
}, [selectableItems]);
}, []);
// Autocomplete suggestion
const suggestion = useMemo(() => {
@ -446,7 +455,6 @@ function TerminalHomepageInner({
const value = inputValue.trim();
if (!value) return;
setInputValue("");
setActiveItemIndex(-1);
executeCommand(value);
},
[inputValue, streamingId, history, completedIds, executeCommand],
@ -469,18 +477,16 @@ function TerminalHomepageInner({
setActiveItemIndex((prev) => (prev >= selectableItems.length - 1 ? 0 : prev + 1));
return;
}
if (e.key === "Enter" && activeItemIndex >= 0) {
if (e.key === "Enter" && activeItemIndex >= 0 && !inputValue.trim()) {
e.preventDefault();
const item = selectableItems[activeItemIndex];
if (item) {
setInputValue("");
setActiveItemIndex(-1);
executeCommand(item.command);
}
return;
}
},
[suggestion, selectableItems, activeItemIndex, executeCommand],
[suggestion, selectableItems, activeItemIndex, inputValue, executeCommand],
);
const handleContainerClick = useCallback(() => {
@ -489,9 +495,6 @@ function TerminalHomepageInner({
const dynamicHint = `${claimCount} atomic claims, citation-ready`;
// Track flat index for highlight across all groups
let flatIndex = 0;
return (
// Black page background with padding around the terminal
<div className="h-screen bg-black flex items-center justify-center p-3 md:p-6 lg:p-8">
@ -506,9 +509,16 @@ function TerminalHomepageInner({
<div className="flex-1 overflow-y-auto p-4">
{/* Intro */}
<div className="mb-4">
<p className="text-foreground text-sm">
<button
onClick={() => { setActiveItemIndex(0); executeCommand("about"); }}
className={`block text-left text-sm px-1 -mx-1 transition-all ${
activeItemIndex === 0
? "bg-[hsl(var(--terminal-cyan))] text-black"
: "text-foreground"
}`}
>
kamil banc. i build AI cultures.
</p>
</button>
</div>
{/* Mobile canvas */}
@ -521,49 +531,48 @@ function TerminalHomepageInner({
<div className="space-y-3">
<MenuGroup label="projects">
{(groups["projects"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem
key={entry.command}
entry={{ ...entry, hint: entry.command === "claims-library" ? dynamicHint : entry.hint }}
isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
</MenuGroup>
<MenuGroup label="topics">
{(groups["topics"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
</MenuGroup>
<MenuGroup label="pages">
{(groups["pages"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
</MenuGroup>
<MenuGroup label="links">
{(groups["links"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<button key={entry.command}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
className={`block w-full text-left text-sm transition-all ${activeItemIndex === idx ? "glow-cyan" : ""}`}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
className={`block w-full text-left text-sm px-1 -mx-1 transition-all ${activeItemIndex === idx ? "bg-[hsl(var(--terminal-cyan))] text-black" : ""}`}
>
<span className={`inline-block w-4 ${activeItemIndex === idx ? "text-terminal-cyan glow-cyan" : "invisible"}`}>&gt;</span>
<span className="text-terminal-dim">{entry.command}</span>
<span className="text-terminal-dim mx-2">&rarr;</span>
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>{entry.hint}</span>
<span className={activeItemIndex === idx ? "text-black/60" : "text-terminal-dim"}>{entry.command}</span>
<span className={`mx-2 ${activeItemIndex === idx ? "text-black/40" : "text-terminal-dim"}`}>&rarr;</span>
<span className={activeItemIndex === idx ? "text-black" : "text-terminal-cyan hover:glow-cyan transition-all"}>{entry.hint}</span>
</button>
);
})}
@ -592,7 +601,7 @@ function TerminalHomepageInner({
{streamingId === null && <span className="terminal-cursor" aria-hidden="true">&#x2588;</span>}
<div className="relative flex-1">
<input ref={inputRef} type="text" value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} disabled={streamingId !== null}
className="bg-transparent border-none outline-none text-foreground placeholder:text-terminal-dim w-full font-mono disabled:opacity-50"
placeholder={history.length === 0 ? "type a command or ask a question..." : ""}
@ -611,15 +620,22 @@ function TerminalHomepageInner({
{/* Left pane: menu (fixed, always visible) */}
<div className="w-[320px] xl:w-[360px] flex-shrink-0 border-r border-border overflow-y-auto p-5">
<div className="mb-6">
<p className="text-foreground">
<button
onClick={() => { setActiveItemIndex(0); executeCommand("about"); }}
className={`block text-left px-1 -mx-1 transition-all ${
activeItemIndex === 0
? "bg-[hsl(var(--terminal-cyan))] text-black"
: "text-foreground"
}`}
>
kamil banc. i build AI cultures.
</p>
</button>
</div>
<div className="space-y-4">
<MenuGroup label="projects">
{(groups["projects"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem
key={entry.command}
@ -628,7 +644,7 @@ function TerminalHomepageInner({
hint: entry.command === "claims-library" ? dynamicHint : entry.hint,
}}
isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
@ -636,10 +652,10 @@ function TerminalHomepageInner({
<MenuGroup label="topics">
{(groups["topics"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
@ -647,10 +663,10 @@ function TerminalHomepageInner({
<MenuGroup label="pages">
{(groups["pages"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
/>
);
})}
@ -658,17 +674,16 @@ function TerminalHomepageInner({
<MenuGroup label="links">
{(groups["links"] ?? []).map((entry) => {
const idx = flatIndex++;
const idx = selectableItems.indexOf(entry);
return (
<button
key={entry.command}
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
className={`block w-full text-left text-sm transition-all ${activeItemIndex === idx ? "glow-cyan" : ""}`}
onClick={() => { setInputValue(""); setActiveItemIndex(idx); executeCommand(entry.command); }}
className={`block w-full text-left text-sm px-1 -mx-1 transition-all ${activeItemIndex === idx ? "bg-[hsl(var(--terminal-cyan))] text-black" : ""}`}
>
<span className={`inline-block w-4 ${activeItemIndex === idx ? "text-terminal-cyan glow-cyan" : "invisible"}`}>&gt;</span>
<span className="text-terminal-dim">{entry.command}</span>
<span className="text-terminal-dim mx-2">&rarr;</span>
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>
<span className={activeItemIndex === idx ? "text-black/60" : "text-terminal-dim"}>{entry.command}</span>
<span className={`mx-2 ${activeItemIndex === idx ? "text-black/40" : "text-terminal-dim"}`}>&rarr;</span>
<span className={activeItemIndex === idx ? "text-black" : "text-terminal-cyan hover:glow-cyan transition-all"}>
{entry.hint}
</span>
</button>
@ -696,7 +711,7 @@ function TerminalHomepageInner({
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streamingId !== null}
className="bg-transparent border-none outline-none text-foreground placeholder:text-terminal-dim w-full font-mono disabled:opacity-50"

View File

@ -6,6 +6,7 @@ import {
import {
renderDensity,
createTextOverlay,
createBigTextOverlay,
renderTextOverlay,
type RenderTarget,
type TextOverlayState,
@ -44,7 +45,12 @@ export function createOracleScene(opts: OracleSceneOptions): SceneDriver {
return {
enter(state: FluidState, target: RenderTarget) {
overlay = createTextOverlay(overlayText, target.width, target.height);
// Short uppercase labels (menu items) get big block letters;
// longer text (oracle responses, "SHOW YOURSELF") stays single-line
const isBigLabel = overlayText.length <= 10 && overlayText === overlayText.toUpperCase();
overlay = isBigLabel
? createBigTextOverlay(overlayText, target.width, target.height)
: createTextOverlay(overlayText, target.width, target.height);
done = false;
},