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 ──────────────────────────────
|
// ─── 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({
|
function StreamingOutput({
|
||||||
entry,
|
entry,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
|
@ -136,8 +127,9 @@ function MenuItem({
|
||||||
isHighlighted
|
isHighlighted
|
||||||
? "text-terminal-cyan glow-cyan"
|
? "text-terminal-cyan glow-cyan"
|
||||||
: "text-terminal-cyan hover: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.command}
|
||||||
{entry.hint && (
|
{entry.hint && (
|
||||||
<span className="text-terminal-dim ml-4 text-xs">{entry.hint}</span>
|
<span className="text-terminal-dim ml-4 text-xs">{entry.hint}</span>
|
||||||
|
|
@ -407,6 +399,28 @@ function TerminalHomepageInner({
|
||||||
} else {
|
} else {
|
||||||
setCompletedIds((s) => new Set(s).add(id));
|
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],
|
[nextId, selectableItems, pushScene, popScene, cameraState],
|
||||||
);
|
);
|
||||||
|
|
@ -492,8 +506,7 @@ function TerminalHomepageInner({
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{/* Intro */}
|
{/* Intro */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TerminalPrompt command="whoami" />
|
<p className="text-foreground text-sm">
|
||||||
<p className="ml-5 text-foreground mt-1 text-sm">
|
|
||||||
kamil banc. i build AI cultures.
|
kamil banc. i build AI cultures.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -505,8 +518,7 @@ function TerminalHomepageInner({
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<TerminalPrompt command="ls" />
|
<div className="space-y-3">
|
||||||
<div className="ml-5 mt-2 space-y-3">
|
|
||||||
<MenuGroup label="projects">
|
<MenuGroup label="projects">
|
||||||
{(groups["projects"] ?? []).map((entry) => {
|
{(groups["projects"] ?? []).map((entry) => {
|
||||||
const idx = flatIndex++;
|
const idx = flatIndex++;
|
||||||
|
|
@ -540,31 +552,29 @@ function TerminalHomepageInner({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
<div className="space-y-0.5">
|
<MenuGroup label="links">
|
||||||
<div className="text-terminal-dim text-xs">links.txt</div>
|
{(groups["links"] ?? []).map((entry) => {
|
||||||
<div className="ml-2 space-y-0.5">
|
const idx = flatIndex++;
|
||||||
{(groups["links"] ?? []).map((entry) => {
|
return (
|
||||||
const idx = flatIndex++;
|
<button key={entry.command}
|
||||||
return (
|
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||||
<button key={entry.command}
|
className={`block w-full text-left text-sm transition-all ${activeItemIndex === idx ? "glow-cyan" : ""}`}
|
||||||
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={`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">{entry.command}</span>
|
<span className="text-terminal-dim mx-2">→</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>
|
||||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>{entry.hint}</span>
|
</button>
|
||||||
</button>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</MenuGroup>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* History (newest first) */}
|
{/* History (newest first) */}
|
||||||
{reversedHistory.map((entry) => (
|
{reversedHistory.map((entry) => (
|
||||||
<div key={entry.id} className="mb-3">
|
<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 ? (
|
{entry.isOracle ? (
|
||||||
<OracleOutput />
|
<OracleOutput />
|
||||||
) : streamingId === entry.id ? (
|
) : streamingId === entry.id ? (
|
||||||
|
|
@ -579,7 +589,7 @@ function TerminalHomepageInner({
|
||||||
{/* Mobile input */}
|
{/* Mobile input */}
|
||||||
<div className="flex-shrink-0 border-t border-border px-4 py-3">
|
<div className="flex-shrink-0 border-t border-border px-4 py-3">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
<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">
|
<div className="relative flex-1">
|
||||||
<input ref={inputRef} type="text" value={inputValue}
|
<input ref={inputRef} type="text" value={inputValue}
|
||||||
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
|
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>
|
<span className="absolute left-0 top-0 pointer-events-none text-terminal-dim/40 font-mono" aria-hidden="true">{suggestion}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{streamingId === null && <span className="terminal-cursor" aria-hidden="true">█</span>}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -601,82 +610,71 @@ function TerminalHomepageInner({
|
||||||
<div className="hidden lg:flex h-full">
|
<div className="hidden lg:flex h-full">
|
||||||
{/* Left pane: menu (fixed, always visible) */}
|
{/* 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="w-[320px] xl:w-[360px] flex-shrink-0 border-r border-border overflow-y-auto p-5">
|
||||||
{/* whoami */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<TerminalPrompt command="whoami" />
|
<p className="text-foreground">
|
||||||
<p className="ml-5 text-foreground mt-1">
|
|
||||||
kamil banc. i build AI cultures.
|
kamil banc. i build AI cultures.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ls */}
|
<div className="space-y-4">
|
||||||
<div>
|
<MenuGroup label="projects">
|
||||||
<TerminalPrompt command="ls" />
|
{(groups["projects"] ?? []).map((entry) => {
|
||||||
<div className="ml-5 mt-2 space-y-4">
|
const idx = flatIndex++;
|
||||||
<MenuGroup label="projects">
|
return (
|
||||||
{(groups["projects"] ?? []).map((entry) => {
|
<MenuItem
|
||||||
const idx = flatIndex++;
|
key={entry.command}
|
||||||
return (
|
entry={{
|
||||||
<MenuItem
|
...entry,
|
||||||
key={entry.command}
|
hint: entry.command === "claims-library" ? dynamicHint : entry.hint,
|
||||||
entry={{
|
}}
|
||||||
...entry,
|
isHighlighted={activeItemIndex === idx}
|
||||||
hint: entry.command === "claims-library" ? dynamicHint : entry.hint,
|
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||||
}}
|
/>
|
||||||
isHighlighted={activeItemIndex === idx}
|
);
|
||||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
})}
|
||||||
/>
|
</MenuGroup>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MenuGroup>
|
|
||||||
|
|
||||||
<MenuGroup label="topics">
|
<MenuGroup label="topics">
|
||||||
{(groups["topics"] ?? []).map((entry) => {
|
{(groups["topics"] ?? []).map((entry) => {
|
||||||
const idx = flatIndex++;
|
const idx = flatIndex++;
|
||||||
return (
|
return (
|
||||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
|
|
||||||
<MenuGroup label="pages">
|
<MenuGroup label="pages">
|
||||||
{(groups["pages"] ?? []).map((entry) => {
|
{(groups["pages"] ?? []).map((entry) => {
|
||||||
const idx = flatIndex++;
|
const idx = flatIndex++;
|
||||||
return (
|
return (
|
||||||
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
<MenuItem key={entry.command} entry={entry} isHighlighted={activeItemIndex === idx}
|
||||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
|
|
||||||
{/* links.txt */}
|
<MenuGroup label="links">
|
||||||
<div className="space-y-0.5">
|
{(groups["links"] ?? []).map((entry) => {
|
||||||
<div className="text-terminal-dim text-xs">links.txt</div>
|
const idx = flatIndex++;
|
||||||
<div className="ml-2 space-y-0.5">
|
return (
|
||||||
{(groups["links"] ?? []).map((entry) => {
|
<button
|
||||||
const idx = flatIndex++;
|
key={entry.command}
|
||||||
return (
|
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
||||||
<button
|
className={`block w-full text-left text-sm transition-all ${activeItemIndex === idx ? "glow-cyan" : ""}`}
|
||||||
key={entry.command}
|
>
|
||||||
onClick={() => { setInputValue(""); setActiveItemIndex(-1); executeCommand(entry.command); }}
|
<span className={`inline-block w-4 ${activeItemIndex === idx ? "text-terminal-cyan glow-cyan" : "invisible"}`}>></span>
|
||||||
className={`block w-full text-left text-sm transition-all ${
|
<span className="text-terminal-dim">{entry.command}</span>
|
||||||
activeItemIndex === idx ? "bg-terminal-green/5 -mx-1 px-1 rounded" : ""
|
<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 className="text-terminal-dim">{entry.command}</span>
|
</span>
|
||||||
<span className="text-terminal-dim mx-2">→</span>
|
</button>
|
||||||
<span className={`text-terminal-cyan ${activeItemIndex === idx ? "glow-cyan" : "hover:glow-cyan"} transition-all`}>
|
);
|
||||||
{entry.hint}
|
})}
|
||||||
</span>
|
</MenuGroup>
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -690,7 +688,9 @@ function TerminalHomepageInner({
|
||||||
{/* Input bar (directly under canvas) */}
|
{/* Input bar (directly under canvas) */}
|
||||||
<div className="flex-shrink-0 border-b border-border px-5 py-3">
|
<div className="flex-shrink-0 border-b border-border px-5 py-3">
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
<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">
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -711,9 +711,6 @@ function TerminalHomepageInner({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{streamingId === null && (
|
|
||||||
<span className="terminal-cursor" aria-hidden="true">█</span>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -727,7 +724,7 @@ function TerminalHomepageInner({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{reversedHistory.map((entry) => (
|
{reversedHistory.map((entry) => (
|
||||||
<div key={entry.id}>
|
<div key={entry.id}>
|
||||||
<TerminalPrompt command={entry.command} />
|
<div className="text-terminal-dim text-xs mb-0.5">{entry.command}</div>
|
||||||
{entry.isOracle ? (
|
{entry.isOracle ? (
|
||||||
<OracleOutput />
|
<OracleOutput />
|
||||||
) : streamingId === entry.id ? (
|
) : streamingId === entry.id ? (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export interface CommandEntry {
|
||||||
summary: string;
|
summary: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
group: "projects" | "topics" | "pages" | "links";
|
group: "projects" | "topics" | "pages" | "links";
|
||||||
|
canvasLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COMMANDS: CommandEntry[] = [
|
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.",
|
"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",
|
hint: "415 atomic claims, citation-ready",
|
||||||
group: "projects",
|
group: "projects",
|
||||||
|
canvasLabel: "CLAIMS",
|
||||||
},
|
},
|
||||||
|
|
||||||
// topics/
|
// 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.",
|
"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: "",
|
hint: "",
|
||||||
group: "topics",
|
group: "topics",
|
||||||
|
canvasLabel: "STRATEGY",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "ai-implementation",
|
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.",
|
"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: "",
|
hint: "",
|
||||||
group: "topics",
|
group: "topics",
|
||||||
|
canvasLabel: "IMPLEMENT",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "ai-tools",
|
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.'",
|
"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: "",
|
hint: "",
|
||||||
group: "topics",
|
group: "topics",
|
||||||
|
canvasLabel: "TOOLS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "ai-business-applications",
|
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.",
|
"Real-world business use cases across industries. How companies are using AI to solve actual problems, not just impress their board decks.",
|
||||||
hint: "",
|
hint: "",
|
||||||
group: "topics",
|
group: "topics",
|
||||||
|
canvasLabel: "BUSINESS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "measuring-ai-roi",
|
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.",
|
"Frameworks for measuring AI impact. Because 'we feel more productive' isn't a business case. Methods for quantifying what AI actually delivers.",
|
||||||
hint: "",
|
hint: "",
|
||||||
group: "topics",
|
group: "topics",
|
||||||
|
canvasLabel: "MEASURE",
|
||||||
},
|
},
|
||||||
|
|
||||||
// pages/
|
// 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.",
|
"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: "",
|
hint: "",
|
||||||
group: "pages",
|
group: "pages",
|
||||||
|
canvasLabel: "ABOUT",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "lab",
|
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.",
|
"Experimental playground. Where I test ideas, build prototypes, and break things on purpose. The fluid simulation you're looking at started here.",
|
||||||
hint: "",
|
hint: "",
|
||||||
group: "pages",
|
group: "pages",
|
||||||
|
canvasLabel: "LAB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "faq",
|
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.",
|
"Frequently asked questions about the claims library, the newsletter, consulting, and how this site works.",
|
||||||
hint: "",
|
hint: "",
|
||||||
group: "pages",
|
group: "pages",
|
||||||
|
canvasLabel: "FAQ",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: "how-i-built-this",
|
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.",
|
"The technical story behind this site. Next.js, fluid simulation in ASCII, terminal aesthetics, and why every design decision serves a purpose.",
|
||||||
hint: "",
|
hint: "",
|
||||||
group: "pages",
|
group: "pages",
|
||||||
|
canvasLabel: "COLOPHON",
|
||||||
},
|
},
|
||||||
|
|
||||||
// links
|
// links
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const CAM_MID = ["A", "D", "P", "T", "C", "R", "E", "{", "}", "|"];
|
||||||
const CAM_FULL = ["@", "#", "$", "%", "&", "W", "X", "8"];
|
const CAM_FULL = ["@", "#", "$", "%", "&", "W", "X", "8"];
|
||||||
|
|
||||||
function camHash(x: number, y: number, b: number): number {
|
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;
|
return ((x * 73856093) ^ (y * 19349663) ^ (bBits * 83492791)) >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,6 +129,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let brightness: Float32Array | null = null;
|
let brightness: Float32Array | null = null;
|
||||||
|
let prevBrightness: Float32Array | null = null;
|
||||||
|
|
||||||
let overlay: TextOverlayState | null = null;
|
let overlay: TextOverlayState | null = null;
|
||||||
let overlayDone = false;
|
let overlayDone = false;
|
||||||
|
|
@ -160,6 +161,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
|
||||||
brightness = new Float32Array(gridWidth * gridHeight);
|
brightness = new Float32Array(gridWidth * gridHeight);
|
||||||
|
prevBrightness = new Float32Array(gridWidth * gridHeight);
|
||||||
ready = true;
|
ready = true;
|
||||||
|
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
|
|
@ -189,6 +191,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
}
|
}
|
||||||
ctx = null;
|
ctx = null;
|
||||||
brightness = null;
|
brightness = null;
|
||||||
|
prevBrightness = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -197,7 +200,7 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
},
|
},
|
||||||
|
|
||||||
tick(_state: FluidState, target: RenderTarget, time: number, _dt: number): boolean {
|
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;
|
const { width, height, spans } = target;
|
||||||
|
|
||||||
|
|
@ -229,19 +232,22 @@ export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
const imageData = ctx.getImageData(0, 0, width, height);
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
const pixels = imageData.data;
|
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 y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const pIdx = (y * width + x) * 4;
|
const pIdx = (y * width + x) * 4;
|
||||||
const r = pixels[pIdx];
|
const r = pixels[pIdx];
|
||||||
const g = pixels[pIdx + 1];
|
const g = pixels[pIdx + 1];
|
||||||
const b = pixels[pIdx + 2];
|
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[idx];
|
||||||
const span = spans[spanIdx];
|
|
||||||
|
|
||||||
const char = brightnessToChar(br, x, y);
|
const char = brightnessToChar(br, x, y);
|
||||||
const color = brightnessToColor(br, x, y, time);
|
const color = brightnessToColor(br, x, y, time);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue