Homepage polish: calm camera, canvas labels, cleaner UI
Reduce camera ASCII jitter with temporal smoothing (0.7/0.3 blend) and coarser brightness hash (8 levels instead of 1000). Add canvas label effects on menu item selection. Remove terminal jargon ($ whoami, $ ls), move cursor to left of input, replace highlight background with > prefix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61f52c76b8
commit
d910a32083
|
|
@ -29,15 +29,6 @@ interface HistoryEntry {
|
|||
|
||||
// ─── Sub-components ──────────────────────────────
|
||||
|
||||
function TerminalPrompt({ command }: { command: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-terminal-green select-none font-bold">$</span>
|
||||
<span className="text-foreground">{command}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingOutput({
|
||||
entry,
|
||||
onComplete,
|
||||
|
|
@ -136,8 +127,9 @@ function MenuItem({
|
|||
isHighlighted
|
||||
? "text-terminal-cyan glow-cyan"
|
||||
: "text-terminal-cyan hover:glow-cyan"
|
||||
} ${isHighlighted ? "bg-terminal-green/5 -mx-1 px-1 rounded" : ""}`}
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-4 ${isHighlighted ? "text-terminal-cyan glow-cyan" : "invisible"}`}>></span>
|
||||
{entry.command}
|
||||
{entry.hint && (
|
||||
<span className="text-terminal-dim ml-4 text-xs">{entry.hint}</span>
|
||||
|
|
@ -407,6 +399,28 @@ function TerminalHomepageInner({
|
|||
} else {
|
||||
setCompletedIds((s) => new Set(s).add(id));
|
||||
}
|
||||
|
||||
// Push canvas label effect (runs in parallel with summary streaming)
|
||||
if (cmd.canvasLabel) {
|
||||
if (cameraState === "active") {
|
||||
popScene();
|
||||
pushScene({
|
||||
type: "camera",
|
||||
overlayText: cmd.canvasLabel,
|
||||
onOverlayDone: () => {
|
||||
popScene();
|
||||
pushScene({ type: "camera" });
|
||||
},
|
||||
onCameraDenied: () => { popScene(); setCameraState("denied"); },
|
||||
});
|
||||
} else {
|
||||
pushScene({
|
||||
type: "oracle",
|
||||
overlayText: cmd.canvasLabel,
|
||||
onOverlayDone: () => popScene(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[nextId, selectableItems, pushScene, popScene, cameraState],
|
||||
);
|
||||
|
|
@ -492,8 +506,7 @@ function TerminalHomepageInner({
|
|||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Intro */}
|
||||
<div className="mb-4">
|
||||
<TerminalPrompt command="whoami" />
|
||||
<p className="ml-5 text-foreground mt-1 text-sm">
|
||||
<p className="text-foreground text-sm">
|
||||
kamil banc. i build AI cultures.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -505,8 +518,7 @@ function TerminalHomepageInner({
|
|||
|
||||
{/* Menu */}
|
||||
<div className="mb-4">
|
||||
<TerminalPrompt command="ls" />
|
||||
<div className="ml-5 mt-2 space-y-3">
|
||||
<div className="space-y-3">
|
||||
<MenuGroup label="projects">
|
||||
{(groups["projects"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
|
|
@ -540,31 +552,29 @@ function TerminalHomepageInner({
|
|||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-terminal-dim text-xs">links.txt</div>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{(groups["links"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<button key={entry.command}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
className={`block w-full text-left text-sm transition-all ${activeItemIndex === idx ? "bg-terminal-green/5 -mx-1 px-1 rounded" : ""}`}
|
||||
>
|
||||
<span className="text-terminal-dim">{entry.command}</span>
|
||||
<span className="text-terminal-dim mx-2">→</span>
|
||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>{entry.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MenuGroup label="links">
|
||||
{(groups["links"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
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" : ""}`}
|
||||
>
|
||||
<span className={`inline-block w-4 ${activeItemIndex === idx ? "text-terminal-cyan glow-cyan" : "invisible"}`}>></span>
|
||||
<span className="text-terminal-dim">{entry.command}</span>
|
||||
<span className="text-terminal-dim mx-2">→</span>
|
||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>{entry.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History (newest first) */}
|
||||
{reversedHistory.map((entry) => (
|
||||
<div key={entry.id} className="mb-3">
|
||||
<TerminalPrompt command={entry.command} />
|
||||
<div className="text-terminal-dim text-xs mb-0.5">{entry.command}</div>
|
||||
{entry.isOracle ? (
|
||||
<OracleOutput />
|
||||
) : streamingId === entry.id ? (
|
||||
|
|
@ -579,7 +589,7 @@ function TerminalHomepageInner({
|
|||
{/* Mobile input */}
|
||||
<div className="flex-shrink-0 border-t border-border px-4 py-3">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<span className="text-terminal-green select-none font-bold" aria-hidden="true">$</span>
|
||||
{streamingId === null && <span className="terminal-cursor" aria-hidden="true">█</span>}
|
||||
<div className="relative flex-1">
|
||||
<input ref={inputRef} type="text" value={inputValue}
|
||||
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
|
||||
|
|
@ -592,7 +602,6 @@ function TerminalHomepageInner({
|
|||
<span className="absolute left-0 top-0 pointer-events-none text-terminal-dim/40 font-mono" aria-hidden="true">{suggestion}</span>
|
||||
)}
|
||||
</div>
|
||||
{streamingId === null && <span className="terminal-cursor" aria-hidden="true">█</span>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -601,82 +610,71 @@ function TerminalHomepageInner({
|
|||
<div className="hidden lg:flex h-full">
|
||||
{/* 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">
|
||||
{/* whoami */}
|
||||
<div className="mb-6">
|
||||
<TerminalPrompt command="whoami" />
|
||||
<p className="ml-5 text-foreground mt-1">
|
||||
<p className="text-foreground">
|
||||
kamil banc. i build AI cultures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ls */}
|
||||
<div>
|
||||
<TerminalPrompt command="ls" />
|
||||
<div className="ml-5 mt-2 space-y-4">
|
||||
<MenuGroup label="projects">
|
||||
{(groups["projects"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
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); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<div className="space-y-4">
|
||||
<MenuGroup label="projects">
|
||||
{(groups["projects"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
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); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup label="topics">
|
||||
{(groups["topics"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<MenuGroup label="topics">
|
||||
{(groups["topics"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup label="pages">
|
||||
{(groups["pages"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
<MenuGroup label="pages">
|
||||
{(groups["pages"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
|
||||
{/* links.txt */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-terminal-dim text-xs">links.txt</div>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{(groups["links"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
return (
|
||||
<button
|
||||
key={entry.command}
|
||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||
className={`block w-full text-left text-sm transition-all ${
|
||||
activeItemIndex === idx ? "bg-terminal-green/5 -mx-1 px-1 rounded" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-terminal-dim">{entry.command}</span>
|
||||
<span className="text-terminal-dim mx-2">→</span>
|
||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>
|
||||
{entry.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MenuGroup label="links">
|
||||
{(groups["links"] ?? []).map((entry) => {
|
||||
const idx = flatIndex++;
|
||||
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" : ""}`}
|
||||
>
|
||||
<span className={`inline-block w-4 ${activeItemIndex === idx ? "text-terminal-cyan glow-cyan" : "invisible"}`}>></span>
|
||||
<span className="text-terminal-dim">{entry.command}</span>
|
||||
<span className="text-terminal-dim mx-2">→</span>
|
||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>
|
||||
{entry.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -690,7 +688,9 @@ function TerminalHomepageInner({
|
|||
{/* Input bar (directly under canvas) */}
|
||||
<div className="flex-shrink-0 border-b border-border px-5 py-3">
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<span className="text-terminal-green select-none font-bold" aria-hidden="true">$</span>
|
||||
{streamingId === null && (
|
||||
<span className="terminal-cursor" aria-hidden="true">█</span>
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
|
@ -711,9 +711,6 @@ function TerminalHomepageInner({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{streamingId === null && (
|
||||
<span className="terminal-cursor" aria-hidden="true">█</span>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
@ -727,7 +724,7 @@ function TerminalHomepageInner({
|
|||
<div className="space-y-4">
|
||||
{reversedHistory.map((entry) => (
|
||||
<div key={entry.id}>
|
||||
<TerminalPrompt command={entry.command} />
|
||||
<div className="text-terminal-dim text-xs mb-0.5">{entry.command}</div>
|
||||
{entry.isOracle ? (
|
||||
<OracleOutput />
|
||||
) : streamingId === entry.id ? (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface CommandEntry {
|
|||
summary: string;
|
||||
hint: string;
|
||||
group: "projects" | "topics" | "pages" | "links";
|
||||
canvasLabel?: string;
|
||||
}
|
||||
|
||||
export const COMMANDS: CommandEntry[] = [
|
||||
|
|
@ -27,6 +28,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"A searchable collection of atomic claims across 25 articles. Each claim is independently verifiable, designed to be cited by both humans and LLMs. Topics span AI strategy, implementation, tools, business applications, and measuring ROI.",
|
||||
hint: "415 atomic claims, citation-ready",
|
||||
group: "projects",
|
||||
canvasLabel: "CLAIMS",
|
||||
},
|
||||
|
||||
// topics/
|
||||
|
|
@ -38,6 +40,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Strategic frameworks for AI adoption. How to plan, prioritise, and execute AI initiatives that actually move the needle instead of generating slides nobody reads.",
|
||||
hint: "",
|
||||
group: "topics",
|
||||
canvasLabel: "STRATEGY",
|
||||
},
|
||||
{
|
||||
command: "ai-implementation",
|
||||
|
|
@ -47,6 +50,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Hands-on implementation patterns. The gap between buying an AI tool and getting value from it is where most organisations stall. These pieces bridge it.",
|
||||
hint: "",
|
||||
group: "topics",
|
||||
canvasLabel: "IMPLEMENT",
|
||||
},
|
||||
{
|
||||
command: "ai-tools",
|
||||
|
|
@ -56,6 +60,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Practical tool evaluations and comparisons. Not hype, not product placement. What works, what doesn't, and why the answer is usually 'it depends on your workflow.'",
|
||||
hint: "",
|
||||
group: "topics",
|
||||
canvasLabel: "TOOLS",
|
||||
},
|
||||
{
|
||||
command: "ai-business-applications",
|
||||
|
|
@ -65,6 +70,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Real-world business use cases across industries. How companies are using AI to solve actual problems, not just impress their board decks.",
|
||||
hint: "",
|
||||
group: "topics",
|
||||
canvasLabel: "BUSINESS",
|
||||
},
|
||||
{
|
||||
command: "measuring-ai-roi",
|
||||
|
|
@ -74,6 +80,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Frameworks for measuring AI impact. Because 'we feel more productive' isn't a business case. Methods for quantifying what AI actually delivers.",
|
||||
hint: "",
|
||||
group: "topics",
|
||||
canvasLabel: "MEASURE",
|
||||
},
|
||||
|
||||
// pages/
|
||||
|
|
@ -85,6 +92,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Who I am, what I do, and why I care about AI adoption more than AI hype. Background, approach, and the philosophy behind this site.",
|
||||
hint: "",
|
||||
group: "pages",
|
||||
canvasLabel: "ABOUT",
|
||||
},
|
||||
{
|
||||
command: "lab",
|
||||
|
|
@ -94,6 +102,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Experimental playground. Where I test ideas, build prototypes, and break things on purpose. The fluid simulation you're looking at started here.",
|
||||
hint: "",
|
||||
group: "pages",
|
||||
canvasLabel: "LAB",
|
||||
},
|
||||
{
|
||||
command: "faq",
|
||||
|
|
@ -103,6 +112,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"Frequently asked questions about the claims library, the newsletter, consulting, and how this site works.",
|
||||
hint: "",
|
||||
group: "pages",
|
||||
canvasLabel: "FAQ",
|
||||
},
|
||||
{
|
||||
command: "how-i-built-this",
|
||||
|
|
@ -112,6 +122,7 @@ export const COMMANDS: CommandEntry[] = [
|
|||
"The technical story behind this site. Next.js, fluid simulation in ASCII, terminal aesthetics, and why every design decision serves a purpose.",
|
||||
hint: "",
|
||||
group: "pages",
|
||||
canvasLabel: "COLOPHON",
|
||||
},
|
||||
|
||||
// links
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const CAM_MID = ["A", "D", "P", "T", "C", "R", "E", "{", "}", "|"];
|
|||
const CAM_FULL = ["@", "#", "$", "%", "&", "W", "X", "8"];
|
||||
|
||||
function camHash(x: number, y: number, b: number): number {
|
||||
const bBits = (b * 1000) | 0;
|
||||
const bBits = (b * 8) | 0;
|
||||
return ((x * 73856093) ^ (y * 19349663) ^ (bBits * 83492791)) >>> 0;
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +129,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
|||
let stream: MediaStream | null = null;
|
||||
let ready = false;
|
||||
let brightness: Float32Array | null = null;
|
||||
let prevBrightness: Float32Array | null = null;
|
||||
|
||||
let overlay: TextOverlayState | null = null;
|
||||
let overlayDone = false;
|
||||
|
|
@ -160,6 +161,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
|||
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
|
||||
brightness = new Float32Array(gridWidth * gridHeight);
|
||||
prevBrightness = new Float32Array(gridWidth * gridHeight);
|
||||
ready = true;
|
||||
|
||||
const track = stream.getVideoTracks()[0];
|
||||
|
|
@ -189,6 +191,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
|||
}
|
||||
ctx = null;
|
||||
brightness = null;
|
||||
prevBrightness = null;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -197,7 +200,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
|||
},
|
||||
|
||||
tick(_state: FluidState, target: RenderTarget, time: number, _dt: number): boolean {
|
||||
if (!ready || !video || !canvas || !ctx || !brightness) return false;
|
||||
if (!ready || !video || !canvas || !ctx || !brightness || !prevBrightness) return false;
|
||||
|
||||
const { width, height, spans } = target;
|
||||
|
||||
|
|
@ -229,19 +232,22 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
|||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
// Convert to brightness and render ASCII
|
||||
// Convert to brightness with temporal smoothing and render ASCII
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pIdx = (y * width + x) * 4;
|
||||
const r = pixels[pIdx];
|
||||
const g = pixels[pIdx + 1];
|
||||
const b = pixels[pIdx + 2];
|
||||
const br = (r + g + b) / 3 / 255;
|
||||
const raw = (r + g + b) / 3 / 255;
|
||||
|
||||
brightness[y * width + x] = br;
|
||||
// Temporal smoothing: blend with previous frame to reduce noise
|
||||
const idx = y * width + x;
|
||||
const br = prevBrightness[idx] * 0.7 + raw * 0.3;
|
||||
brightness[idx] = br;
|
||||
prevBrightness[idx] = br;
|
||||
|
||||
const spanIdx = y * width + x;
|
||||
const span = spans[spanIdx];
|
||||
const span = spans[idx];
|
||||
|
||||
const char = brightnessToChar(br, x, y);
|
||||
const color = brightnessToColor(br, x, y, time);
|
||||
|
|
|
|||
Loading…
Reference in New Issue