Interactive terminal homepage with ASCII webcam canvas
Replace the static homepage with a fully interactive terminal where every item is a clickable command. Visitors see a split-pane layout: menu on the left, fluid sim canvas on the right. Typing commands streams summaries via typewriter effect, and questions trigger oracle responses. New camera SceneDriver converts live webcam feed to green ASCII art using brightness-to-character mapping and face-targeted text overlay via brightness centroid tracking. Activated by "$ webcam" command or clicking the canvas after the "SHOW YOURSELF" invite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
db9a843989
commit
28de656b1b
|
|
@ -0,0 +1,94 @@
|
||||||
|
# ASCII webcam canvas
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the fluid simulation canvas on the homepage with a live webcam feed converted to ASCII art in real time. The visitor's face and body are rendered as green ASCII characters in the terminal aesthetic. Oracle responses and interaction text blend into the face region, with words materialising across the visitor's features in cyan.
|
||||||
|
|
||||||
|
## Activation model
|
||||||
|
|
||||||
|
Fluid sim plays as the default. After the visitor's first command, the fluid sim shows "SHOW YOURSELF" via the existing scramble animation. The visitor activates by clicking the canvas or typing `$ webcam`. The browser permission prompt fires. On allow, camera replaces fluid. On deny, fluid continues quietly.
|
||||||
|
|
||||||
|
The `$ webcam` command also works at any time without the invite. `$ webcam off` returns to fluid sim. The command is discoverable via `help` but not listed in the visible menu.
|
||||||
|
|
||||||
|
Repeat visits with previously granted permission activate instantly (no browser popup). The "SHOW YOURSELF" invite appears once per session after first interaction.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
video frame → drawImage to hidden canvas at grid resolution (e.g. 60x30)
|
||||||
|
→ getImageData() reads pixel brightness per cell
|
||||||
|
→ brightness maps to ASCII character + green intensity
|
||||||
|
→ writes to target.spans[] (same render target as fluid sim)
|
||||||
|
```
|
||||||
|
|
||||||
|
The camera scene is a standard `SceneDriver` that slots into the existing scene system. Hidden `<video>` and `<canvas>` elements handle capture. The video is mirrored horizontally for a natural selfie reflection.
|
||||||
|
|
||||||
|
### Brightness to character
|
||||||
|
|
||||||
|
```
|
||||||
|
bright (face, skin) → @ # $ % & W
|
||||||
|
medium (mid-tones) → A D P T = +
|
||||||
|
dim (shadows) → . , ' ; : ~
|
||||||
|
dark (background) → (space)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Brightness to colour (monochrome green)
|
||||||
|
|
||||||
|
```
|
||||||
|
brightness 0.0 → transparent (empty cell)
|
||||||
|
brightness 0.3 → hsl(120, 60%, 20%)
|
||||||
|
brightness 0.6 → hsl(120, 70%, 40%)
|
||||||
|
brightness 1.0 → hsl(120, 80%, 65%)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Word blending
|
||||||
|
|
||||||
|
Text targets the face region using a brightness centroid calculation:
|
||||||
|
|
||||||
|
```
|
||||||
|
centerX = sum(brightness[i] * x[i]) / sum(brightness)
|
||||||
|
centerY = sum(brightness[i] * y[i]) / sum(brightness)
|
||||||
|
```
|
||||||
|
|
||||||
|
The face is typically the brightest cluster in the frame. The centroid tracks the face naturally with zero ML or external libraries.
|
||||||
|
|
||||||
|
When oracle responses or text appear:
|
||||||
|
1. Compute brightness centroid from current frame
|
||||||
|
2. Lay out text centered on centroid
|
||||||
|
3. Use existing scramble, lock, dissolve animation
|
||||||
|
4. Overlay characters in bright cyan against green camera ASCII
|
||||||
|
5. After dissolve, camera ASCII resumes
|
||||||
|
|
||||||
|
Edge cases: text wider than grid truncates. Centroid near edge shifts text inward. Very dark feed defaults centroid to grid center.
|
||||||
|
|
||||||
|
## Fallbacks
|
||||||
|
|
||||||
|
- **Camera denied:** Fluid sim continues. `$ webcam` shows "camera access denied" message.
|
||||||
|
- **No camera hardware:** Same as denied.
|
||||||
|
- **Camera stops mid-session:** Video track `ended` event pops back to fluid sim automatically.
|
||||||
|
- **Mobile:** Front camera via `facingMode: "user"`. Same flow, 28x28 square grid.
|
||||||
|
- **HTTPS:** Required. kbanc.com on Netlify has HTTPS. Localhost works too.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
At 60x30 grid: 1,800 pixels per frame. `getImageData` on that canvas is sub-millisecond. `drawImage` (scaling video down) is GPU-accelerated. Total per-frame cost is negligible compared to the fluid solver it replaces.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/lib/scenes/camera-scene.ts` | `createCameraScene()` SceneDriver. Video/canvas management, brightness sampling, ASCII mapping, centroid tracking. |
|
||||||
|
|
||||||
|
### Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/lib/canvas-scene-context.tsx` | Add `"camera"` to `SceneType`. Add `onCameraDenied?: () => void` to `SceneConfig`. |
|
||||||
|
| `src/lib/scenes/resolve-scene.ts` | Add `case "camera"` calling `createCameraScene()`. |
|
||||||
|
| `src/components/TerminalHomepage.tsx` | Invite flow after first command. Canvas click handler. `$ webcam` / `$ webcam off` commands. Camera state tracking. |
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
`SidebarCanvas.tsx`, `ascii-renderer.ts`, `use-fluid-sim.ts`, `fluid-solver.ts`. The camera scene writes to the same `RenderTarget`. No changes to the rendering pipeline.
|
||||||
|
|
@ -0,0 +1,876 @@
|
||||||
|
# ASCII webcam canvas implementation plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Replace the fluid simulation canvas with a live webcam-to-ASCII feed, activated after the visitor's first interaction via a "SHOW YOURSELF" invite.
|
||||||
|
|
||||||
|
**Architecture:** A new `createCameraScene()` SceneDriver that captures webcam frames via `getUserMedia`, draws them to a hidden canvas at grid resolution, maps pixel brightness to ASCII characters with monochrome green colouring, and writes directly to the existing `RenderTarget` span elements. Word blending uses brightness centroid tracking to place oracle text over the face region.
|
||||||
|
|
||||||
|
**Tech Stack:** Browser `getUserMedia` API, `<canvas>` `getImageData`, existing SceneDriver/RenderTarget infrastructure. No external dependencies.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-02-15-ascii-webcam-canvas-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extend scene type system
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/canvas-scene-context.tsx`
|
||||||
|
- Modify: `src/lib/scenes/resolve-scene.ts`
|
||||||
|
|
||||||
|
**Step 1: Add camera to SceneType union**
|
||||||
|
|
||||||
|
In `src/lib/canvas-scene-context.tsx`, add `"camera"` to the `SceneType` union and `onCameraDenied` to `SceneConfig`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type SceneType =
|
||||||
|
| "fluid"
|
||||||
|
| "oracle"
|
||||||
|
| "keywords"
|
||||||
|
| "featured-claim"
|
||||||
|
| "ascii-art"
|
||||||
|
| "project-preview"
|
||||||
|
| "matrix"
|
||||||
|
| "camera";
|
||||||
|
|
||||||
|
export interface SceneConfig {
|
||||||
|
type: SceneType;
|
||||||
|
overlayText?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
asciiArt?: string[];
|
||||||
|
projectId?: string;
|
||||||
|
onOverlayDone?: () => void;
|
||||||
|
onCameraDenied?: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add camera case to resolve-scene**
|
||||||
|
|
||||||
|
In `src/lib/scenes/resolve-scene.ts`, add the import and case:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createCameraScene } from "./camera-scene";
|
||||||
|
|
||||||
|
// Inside the switch:
|
||||||
|
case "camera":
|
||||||
|
return createCameraScene({
|
||||||
|
onCameraDenied: config.onCameraDenied,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: Build fails because `camera-scene.ts` doesn't exist yet. That's correct - we'll create it in Task 2.
|
||||||
|
|
||||||
|
Create a stub file to unblock the build:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/scenes/camera-scene.ts
|
||||||
|
import { createFluidScene, type FluidSceneOptions } from "./fluid-scene";
|
||||||
|
import type { SceneDriver } from "./types";
|
||||||
|
|
||||||
|
export interface CameraSceneOptions extends FluidSceneOptions {
|
||||||
|
onCameraDenied?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
|
// Stub: fall back to fluid scene until camera is implemented
|
||||||
|
return createFluidScene(opts);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/canvas-scene-context.tsx src/lib/scenes/resolve-scene.ts src/lib/scenes/camera-scene.ts
|
||||||
|
git commit -m "feat: add camera scene type to scene system (stub)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement camera scene driver - brightness mapping
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/scenes/camera-scene.ts`
|
||||||
|
|
||||||
|
This task builds the pure functions for converting pixel brightness to ASCII characters and colours. These are the core utilities the camera scene will use.
|
||||||
|
|
||||||
|
**Step 1: Add brightness-to-character mapping**
|
||||||
|
|
||||||
|
The character tiers follow the same pattern as `ascii-renderer.ts` but map brightness (0-1) instead of fluid density. Brightness is inverted from density: bright pixels (face, skin) get dense characters, dark pixels (background) get spaces.
|
||||||
|
|
||||||
|
Add to `camera-scene.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Character tiers: dark → bright (webcam brightness, not fluid density)
|
||||||
|
const CAM_DUST = [".", ",", "'", ";", ":", "`", "~", "-"];
|
||||||
|
const CAM_LOW = ["a", "d", "p", "t", "c", "r", "e", "+", "=", "*"];
|
||||||
|
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;
|
||||||
|
return ((x * 73856093) ^ (y * 19349663) ^ (bBits * 83492791)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToChar(b: number, x: number, y: number): string {
|
||||||
|
if (b < 0.05) return " ";
|
||||||
|
const h = camHash(x, y, b);
|
||||||
|
if (b < 0.2) return CAM_DUST[h % CAM_DUST.length];
|
||||||
|
if (b < 0.45) return CAM_LOW[h % CAM_LOW.length];
|
||||||
|
if (b < 0.7) return CAM_MID[h % CAM_MID.length];
|
||||||
|
return CAM_FULL[h % CAM_FULL.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToGreen(b: number): string {
|
||||||
|
if (b < 0.05) return "";
|
||||||
|
const clamped = Math.min(b, 1);
|
||||||
|
// Monochrome green: dim → bright
|
||||||
|
const l = 15 + clamped * 50; // 15% → 65% lightness
|
||||||
|
const s = 50 + clamped * 30; // 50% → 80% saturation
|
||||||
|
return `hsl(120,${s}%,${l}%)`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add brightness centroid calculation**
|
||||||
|
|
||||||
|
This finds the average position weighted by brightness. In a typical webcam frame, the face is the brightest cluster, so the centroid naturally tracks it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function findBrightnessCentroid(
|
||||||
|
brightness: Float32Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): { cx: number; cy: number } {
|
||||||
|
let sumB = 0;
|
||||||
|
let sumBX = 0;
|
||||||
|
let sumBY = 0;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const b = brightness[y * width + x];
|
||||||
|
sumB += b;
|
||||||
|
sumBX += b * x;
|
||||||
|
sumBY += b * y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumB < 0.001) {
|
||||||
|
// Very dark frame: default to center
|
||||||
|
return { cx: Math.floor(width / 2), cy: Math.floor(height / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx: Math.round(sumBX / sumB),
|
||||||
|
cy: Math.round(sumBY / sumB),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS (functions are internal to the module, no export changes)
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/scenes/camera-scene.ts
|
||||||
|
git commit -m "feat: add brightness-to-ASCII mapping and centroid detection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement camera scene driver - video pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/scenes/camera-scene.ts`
|
||||||
|
|
||||||
|
This task replaces the stub `createCameraScene` with the real implementation that captures video, samples pixels, and writes ASCII to the render target.
|
||||||
|
|
||||||
|
**Step 1: Implement the full camera scene driver**
|
||||||
|
|
||||||
|
Replace the entire contents of `camera-scene.ts` with the complete implementation. Key points:
|
||||||
|
- `enter()`: creates hidden `<video>` + `<canvas>`, calls `getUserMedia`
|
||||||
|
- `tick()`: draws video frame to canvas, reads pixels, maps to ASCII, writes to spans
|
||||||
|
- `exit()`: stops video tracks, removes DOM elements
|
||||||
|
- The video is mirrored horizontally for natural selfie reflection
|
||||||
|
- A `ready` flag prevents rendering before the video stream is active
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { FluidState } from "@/components/FluidSimulation/fluid-solver";
|
||||||
|
import type { RenderTarget } from "@/components/FluidSimulation/ascii-renderer";
|
||||||
|
import type { SceneDriver } from "./types";
|
||||||
|
|
||||||
|
export interface CameraSceneOptions {
|
||||||
|
onCameraDenied?: () => void;
|
||||||
|
iterations?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character tiers: dark → bright (webcam brightness)
|
||||||
|
const CAM_DUST = [".", ",", "'", ";", ":", "`", "~", "-"];
|
||||||
|
const CAM_LOW = ["a", "d", "p", "t", "c", "r", "e", "+", "=", "*"];
|
||||||
|
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;
|
||||||
|
return ((x * 73856093) ^ (y * 19349663) ^ (bBits * 83492791)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToChar(b: number, x: number, y: number): string {
|
||||||
|
if (b < 0.05) return " ";
|
||||||
|
const h = camHash(x, y, b);
|
||||||
|
if (b < 0.2) return CAM_DUST[h % CAM_DUST.length];
|
||||||
|
if (b < 0.45) return CAM_LOW[h % CAM_LOW.length];
|
||||||
|
if (b < 0.7) return CAM_MID[h % CAM_MID.length];
|
||||||
|
return CAM_FULL[h % CAM_FULL.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToGreen(b: number): string {
|
||||||
|
if (b < 0.05) return "";
|
||||||
|
const clamped = Math.min(b, 1);
|
||||||
|
const l = 15 + clamped * 50;
|
||||||
|
const s = 50 + clamped * 30;
|
||||||
|
return `hsl(120,${s}%,${l}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBrightnessCentroid(
|
||||||
|
brightness: Float32Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): { cx: number; cy: number } {
|
||||||
|
let sumB = 0;
|
||||||
|
let sumBX = 0;
|
||||||
|
let sumBY = 0;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const b = brightness[y * width + x];
|
||||||
|
sumB += b;
|
||||||
|
sumBX += b * x;
|
||||||
|
sumBY += b * y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumB < 0.001) {
|
||||||
|
return { cx: Math.floor(width / 2), cy: Math.floor(height / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx: Math.round(sumBX / sumB),
|
||||||
|
cy: Math.round(sumBY / sumB),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
|
const { onCameraDenied } = opts;
|
||||||
|
|
||||||
|
let video: HTMLVideoElement | null = null;
|
||||||
|
let canvas: HTMLCanvasElement | null = null;
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let ready = false;
|
||||||
|
let brightness: Float32Array | null = null;
|
||||||
|
|
||||||
|
async function startCamera(gridWidth: number, gridHeight: number) {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: "user", width: { ideal: 320 }, height: { ideal: 240 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
video = document.createElement("video");
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.setAttribute("playsinline", "true");
|
||||||
|
video.style.display = "none";
|
||||||
|
document.body.appendChild(video);
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
canvas.width = gridWidth;
|
||||||
|
canvas.height = gridHeight;
|
||||||
|
canvas.style.display = "none";
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
|
||||||
|
brightness = new Float32Array(gridWidth * gridHeight);
|
||||||
|
ready = true;
|
||||||
|
|
||||||
|
// Handle camera being revoked mid-session
|
||||||
|
const track = stream.getVideoTracks()[0];
|
||||||
|
if (track) {
|
||||||
|
track.addEventListener("ended", () => {
|
||||||
|
ready = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onCameraDenied?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
ready = false;
|
||||||
|
if (stream) {
|
||||||
|
for (const track of stream.getTracks()) track.stop();
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
if (video) {
|
||||||
|
video.remove();
|
||||||
|
video = null;
|
||||||
|
}
|
||||||
|
if (canvas) {
|
||||||
|
canvas.remove();
|
||||||
|
canvas = null;
|
||||||
|
}
|
||||||
|
ctx = null;
|
||||||
|
brightness = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enter(_state: FluidState, target: RenderTarget) {
|
||||||
|
startCamera(target.width, target.height);
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(_state: FluidState, target: RenderTarget): boolean {
|
||||||
|
if (!ready || !video || !canvas || !ctx || !brightness) return false;
|
||||||
|
|
||||||
|
const { width, height, spans } = target;
|
||||||
|
|
||||||
|
// Draw video frame to canvas at grid resolution (mirrored)
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(width, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Read pixel data
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
|
const pixels = imageData.data;
|
||||||
|
|
||||||
|
// Convert to brightness 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;
|
||||||
|
|
||||||
|
brightness[y * width + x] = br;
|
||||||
|
|
||||||
|
const spanIdx = y * width + x;
|
||||||
|
const span = spans[spanIdx];
|
||||||
|
|
||||||
|
const char = brightnessToChar(br, x, y);
|
||||||
|
const color = brightnessToGreen(br);
|
||||||
|
|
||||||
|
if (span.textContent !== char) span.textContent = char;
|
||||||
|
if (color) {
|
||||||
|
if (span.style.color !== color) span.style.color = color;
|
||||||
|
} else {
|
||||||
|
if (span.style.color) span.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
exit() {
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/scenes/camera-scene.ts
|
||||||
|
git commit -m "feat: implement camera scene driver with video-to-ASCII pipeline"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add webcam commands to TerminalHomepage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/TerminalHomepage.tsx`
|
||||||
|
|
||||||
|
Add `$ webcam` and `$ webcam off` as special commands (like `help` and `clear`), and track camera state.
|
||||||
|
|
||||||
|
**Step 1: Add camera state**
|
||||||
|
|
||||||
|
Add state to `TerminalHomepageInner`, after existing state declarations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [cameraState, setCameraState] = useState<"idle" | "invited" | "active" | "denied">("idle");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add webcam command handling**
|
||||||
|
|
||||||
|
In the `executeCommand` callback, add two new cases before the `isQuestion` check:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (trimmed.toLowerCase() === "webcam") {
|
||||||
|
if (cameraState === "denied") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera access denied. check browser settings to allow camera for this site." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cameraState === "active") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera already active. type 'webcam off' to disable." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "activating camera..." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
onCameraDenied: () => {
|
||||||
|
setCameraState("denied");
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id: Date.now(), command: "", output: "camera access denied." },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCameraState("active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase() === "webcam off") {
|
||||||
|
if (cameraState !== "active") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera is not active." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera disabled." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
popScene();
|
||||||
|
setCameraState("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add webcam to help output**
|
||||||
|
|
||||||
|
Update the `help` case to include webcam:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"available commands: " +
|
||||||
|
selectableItems.map((c) => c.command).join(", ") +
|
||||||
|
", webcam, webcam off" +
|
||||||
|
"\n\nor just type a question to ask the oracle.",
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/TerminalHomepage.tsx
|
||||||
|
git commit -m "feat: add webcam and webcam off commands to terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add "SHOW YOURSELF" invite flow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/TerminalHomepage.tsx`
|
||||||
|
|
||||||
|
After the visitor's first command completes, push a "SHOW YOURSELF" oracle overlay on the fluid sim. Add a click handler on the canvas that activates the camera when the invite is showing.
|
||||||
|
|
||||||
|
**Step 1: Track invite state**
|
||||||
|
|
||||||
|
Add a ref to track whether the invite has been shown this session:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const inviteShownRef = useRef(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Trigger invite after first command**
|
||||||
|
|
||||||
|
Add an effect that watches for the first completed command. When it detects one, it pushes the oracle overlay:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteShownRef.current) return;
|
||||||
|
if (completedIds.size === 0) return;
|
||||||
|
if (cameraState !== "idle") return;
|
||||||
|
|
||||||
|
inviteShownRef.current = true;
|
||||||
|
setCameraState("invited");
|
||||||
|
|
||||||
|
// Small delay so the command output appears first
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pushScene({
|
||||||
|
type: "oracle",
|
||||||
|
overlayText: "SHOW YOURSELF",
|
||||||
|
onOverlayDone: () => {
|
||||||
|
popScene();
|
||||||
|
// Only reset to idle if they didn't activate camera during the overlay
|
||||||
|
setCameraState((prev) => (prev === "invited" ? "idle" : prev));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [completedIds.size, cameraState, pushScene, popScene]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add canvas click handler**
|
||||||
|
|
||||||
|
In the JSX where the `SidebarCanvas` is rendered (both desktop and mobile), wrap it in a clickable container. When clicked during the "invited" state, activate the camera:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleCanvasClick = useCallback(() => {
|
||||||
|
if (cameraState === "invited" || cameraState === "idle") {
|
||||||
|
popScene(); // Remove the "SHOW YOURSELF" overlay if present
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
onCameraDenied: () => {
|
||||||
|
setCameraState("denied");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCameraState("active");
|
||||||
|
}
|
||||||
|
}, [cameraState, pushScene, popScene]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then on the canvas container divs (desktop and mobile), add `onClick={handleCanvasClick}` and change cursor: `className="... cursor-pointer"`.
|
||||||
|
|
||||||
|
**Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Manual verification**
|
||||||
|
|
||||||
|
1. Load homepage in browser
|
||||||
|
2. Execute any command (e.g. click "about")
|
||||||
|
3. After ~800ms, fluid sim should show "SHOW YOURSELF" scramble animation
|
||||||
|
4. Click the canvas area or type `$ webcam`
|
||||||
|
5. Browser should prompt for camera access
|
||||||
|
6. On allow: camera ASCII should replace fluid sim
|
||||||
|
7. On deny: fluid sim should continue
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/TerminalHomepage.tsx
|
||||||
|
git commit -m "feat: add SHOW YOURSELF invite and canvas click activation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add face-targeted text overlay to camera scene
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/scenes/camera-scene.ts`
|
||||||
|
|
||||||
|
The camera scene needs to support text overlays (for oracle responses) that target the brightness centroid (face region) instead of the grid centre.
|
||||||
|
|
||||||
|
**Step 1: Add overlay support to camera scene**
|
||||||
|
|
||||||
|
Add text overlay state and rendering to the camera scene driver. The key difference from the oracle scene: instead of `createTextOverlay` (which centres text on the grid), we create overlay cells centred on the brightness centroid.
|
||||||
|
|
||||||
|
Add these to the camera scene driver, inside `createCameraScene`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add imports at top of file:
|
||||||
|
import {
|
||||||
|
renderTextOverlay,
|
||||||
|
type TextOverlayState,
|
||||||
|
type TextOverlayCell,
|
||||||
|
} from "@/components/FluidSimulation/ascii-renderer";
|
||||||
|
|
||||||
|
// Inside createCameraScene, add overlay state:
|
||||||
|
let overlay: TextOverlayState | null = null;
|
||||||
|
let overlayDone = false;
|
||||||
|
const ASSEMBLE = 0.6;
|
||||||
|
const HOLD = 2.0;
|
||||||
|
const DISSOLVE = 0.5;
|
||||||
|
const TOTAL = ASSEMBLE + HOLD + DISSOLVE;
|
||||||
|
|
||||||
|
// Add a function to create overlay centred on brightness centroid:
|
||||||
|
function createCentroidOverlay(
|
||||||
|
text: string,
|
||||||
|
centroid: { cx: number; cy: number },
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number
|
||||||
|
): TextOverlayState {
|
||||||
|
const chars = text.split("");
|
||||||
|
let startX = centroid.cx - Math.floor(chars.length / 2);
|
||||||
|
// Clamp so text stays fully visible
|
||||||
|
startX = Math.max(0, Math.min(startX, gridWidth - chars.length));
|
||||||
|
const y = Math.max(0, Math.min(centroid.cy, gridHeight - 1));
|
||||||
|
|
||||||
|
const cells: TextOverlayCell[] = [];
|
||||||
|
const cellDelays: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < chars.length; i++) {
|
||||||
|
cells.push({
|
||||||
|
spanIdx: y * gridWidth + startX + i,
|
||||||
|
char: chars[i],
|
||||||
|
gx: startX + i + 1,
|
||||||
|
gy: y + 1,
|
||||||
|
});
|
||||||
|
cellDelays.push(chars[i] === " " ? 0 : Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startTime: performance.now(), cells, cellDelays };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the `tick()` method, after rendering camera ASCII, add overlay rendering:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After the camera ASCII rendering loop, before return:
|
||||||
|
if (overlay && !overlayDone) {
|
||||||
|
const elapsed = (performance.now() - overlay.startTime) / 1000;
|
||||||
|
|
||||||
|
if (elapsed >= TOTAL) {
|
||||||
|
overlay = null;
|
||||||
|
overlayDone = true;
|
||||||
|
// Notify via scene config callback if needed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aP = elapsed < ASSEMBLE ? elapsed / ASSEMBLE : 1;
|
||||||
|
const dP = elapsed > ASSEMBLE + HOLD
|
||||||
|
? (elapsed - ASSEMBLE - HOLD) / DISSOLVE
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
renderTextOverlay(target, overlay, aP, dP);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Expose overlay trigger**
|
||||||
|
|
||||||
|
The camera scene needs a way for TerminalHomepage to trigger overlays. Add an `overlayText` option to `CameraSceneOptions` and handle it in `enter()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface CameraSceneOptions {
|
||||||
|
onCameraDenied?: () => void;
|
||||||
|
overlayText?: string;
|
||||||
|
onOverlayDone?: () => void;
|
||||||
|
iterations?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `enter()`, if `overlayText` is set, don't create the overlay immediately (camera needs to start first). Instead, set a flag and create it on the first tick after `ready` becomes true:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let pendingOverlay: string | null = opts.overlayText || null;
|
||||||
|
|
||||||
|
// In tick(), after ready check and camera rendering:
|
||||||
|
if (pendingOverlay && brightness) {
|
||||||
|
const centroid = findBrightnessCentroid(brightness, width, height);
|
||||||
|
overlay = createCentroidOverlay(pendingOverlay, centroid, width, height);
|
||||||
|
overlayDone = false;
|
||||||
|
pendingOverlay = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update resolve-scene to pass overlay options**
|
||||||
|
|
||||||
|
In `src/lib/scenes/resolve-scene.ts`, update the camera case:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
case "camera":
|
||||||
|
return createCameraScene({
|
||||||
|
onCameraDenied: config.onCameraDenied,
|
||||||
|
overlayText: config.overlayText,
|
||||||
|
onOverlayDone: config.onOverlayDone,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/scenes/camera-scene.ts src/lib/scenes/resolve-scene.ts
|
||||||
|
git commit -m "feat: add face-targeted text overlay to camera scene"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Wire oracle responses to camera overlay
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/TerminalHomepage.tsx`
|
||||||
|
|
||||||
|
When the camera is active and the visitor asks an oracle question, the response should appear as a camera overlay (on the face) rather than the standard fluid sim oracle overlay.
|
||||||
|
|
||||||
|
**Step 1: Update oracle handling for camera state**
|
||||||
|
|
||||||
|
In the `executeCommand` callback, modify the `isQuestion` block to check camera state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (isQuestion(trimmed)) {
|
||||||
|
const response = selectResponse(trimmed);
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
|
||||||
|
if (cameraState === "active") {
|
||||||
|
// Camera is running: pop it, push camera-with-overlay
|
||||||
|
popScene();
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
overlayText: response,
|
||||||
|
onOverlayDone: () => {
|
||||||
|
// After overlay dissolves, return to plain camera
|
||||||
|
popScene();
|
||||||
|
pushScene({ type: "camera" });
|
||||||
|
},
|
||||||
|
onCameraDenied: () => setCameraState("denied"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal fluid sim oracle overlay
|
||||||
|
pushScene({
|
||||||
|
type: "oracle",
|
||||||
|
overlayText: response,
|
||||||
|
onOverlayDone: () => popScene(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "", isOracle: true, oracleResponse: response },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Popping and re-pushing the camera scene restarts the video pipeline. To avoid this, the camera scene should persist its video stream. Add a module-level cache for the stream in `camera-scene.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// At module level in camera-scene.ts:
|
||||||
|
let sharedStream: MediaStream | null = null;
|
||||||
|
|
||||||
|
// In startCamera, check for shared stream first:
|
||||||
|
if (sharedStream && sharedStream.active) {
|
||||||
|
stream = sharedStream;
|
||||||
|
} else {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({...});
|
||||||
|
sharedStream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In cleanup, don't stop the shared stream:
|
||||||
|
// Only stop if explicitly requested (webcam off command)
|
||||||
|
```
|
||||||
|
|
||||||
|
Actually, a simpler approach: instead of popping/pushing camera scenes for overlays, expose a method to trigger overlays on the running camera scene. Use a ref pattern:
|
||||||
|
|
||||||
|
In TerminalHomepage, store a ref to a trigger function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cameraOverlayRef = useRef<((text: string) => void) | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Modify the camera scene to accept an overlay trigger callback setter. This is cleaner but requires passing the ref through the scene system. The pop/push approach works as a v1 - we can optimize later if the stream restart is noticeable.
|
||||||
|
|
||||||
|
**Step 2: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
**Step 3: Manual verification**
|
||||||
|
|
||||||
|
1. Activate webcam (click canvas or type `$ webcam`)
|
||||||
|
2. Type a question: "should I use AI for hiring?"
|
||||||
|
3. Oracle response should appear as cyan text over your face in the camera ASCII
|
||||||
|
4. After dissolve, camera feed resumes normally
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/TerminalHomepage.tsx src/lib/scenes/camera-scene.ts
|
||||||
|
git commit -m "feat: wire oracle responses to camera face overlay"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Deploy and verify
|
||||||
|
|
||||||
|
**Files:** None (deployment only)
|
||||||
|
|
||||||
|
**Step 1: Full build check**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: PASS with no warnings related to camera/scene changes
|
||||||
|
|
||||||
|
**Step 2: Deploy to Netlify**
|
||||||
|
|
||||||
|
Run: `netlify deploy --prod`
|
||||||
|
Expected: Deployment succeeds
|
||||||
|
|
||||||
|
**Step 3: End-to-end verification on production**
|
||||||
|
|
||||||
|
Open https://kbanc.com in browser and verify:
|
||||||
|
|
||||||
|
1. Homepage loads with fluid sim in canvas (not camera)
|
||||||
|
2. Execute any command (click a menu item)
|
||||||
|
3. After ~800ms, "SHOW YOURSELF" appears in fluid sim
|
||||||
|
4. Click canvas or type `$ webcam`
|
||||||
|
5. Browser prompts for camera
|
||||||
|
6. On allow: face appears as green ASCII art
|
||||||
|
7. Ask an oracle question: response appears in cyan on face
|
||||||
|
8. `$ webcam off` returns to fluid sim
|
||||||
|
9. `$ help` includes webcam in command list
|
||||||
|
10. Mobile: same flow works with front camera
|
||||||
|
11. Other pages (/claims-library, /about) unaffected
|
||||||
|
|
||||||
|
**Step 4: Commit any fixes from verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: post-deploy adjustments for camera scene"
|
||||||
|
```
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"totalArticles": 83,
|
"totalArticles": 83,
|
||||||
"totalClaims": 415,
|
"totalClaims": 415,
|
||||||
"lastUpdated": "2026-02-15T14:03:06.851Z",
|
"lastUpdated": "2026-02-15T17:32:46.065Z",
|
||||||
"latestArticleDate": "2026-02-14",
|
"latestArticleDate": "2026-02-14",
|
||||||
"topics": [
|
"topics": [
|
||||||
"business",
|
"business",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<link>https://kbanc.com</link>
|
<link>https://kbanc.com</link>
|
||||||
<description>Evidence-based claims about AI implementation, optimized for LLM extraction and research citation.</description>
|
<description>Evidence-based claims about AI implementation, optimized for LLM extraction and research citation.</description>
|
||||||
<language>en-us</language>
|
<language>en-us</language>
|
||||||
<lastBuildDate>Sun, 15 Feb 2026 14:03:06 GMT</lastBuildDate>
|
<lastBuildDate>Sun, 15 Feb 2026 17:32:45 GMT</lastBuildDate>
|
||||||
<atom:link href="https://kbanc.com/feed.xml" rel="self" type="application/rss+xml"/>
|
<atom:link href="https://kbanc.com/feed.xml" rel="self" type="application/rss+xml"/>
|
||||||
|
|
||||||
<item>
|
<item>
|
||||||
|
|
|
||||||
138
src/app/page.tsx
138
src/app/page.tsx
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { ALL_CLAIMS_DATA, getTotalClaimsCount } from "@/data/claims";
|
import { ALL_CLAIMS_DATA, getTotalClaimsCount } from "@/data/claims";
|
||||||
import TerminalChrome from "@/components/TerminalChrome";
|
import TerminalHomepage from "@/components/TerminalHomepage";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Kamil Banc | AI Implementation Expert",
|
title: "Kamil Banc | AI Implementation Expert",
|
||||||
|
|
@ -94,140 +93,7 @@ export default function HomePage() {
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center px-4 py-8 md:py-12">
|
<TerminalHomepage articleCount={articleCount} claimCount={claimCount} />
|
||||||
<a
|
|
||||||
href="#main-content"
|
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background"
|
|
||||||
>
|
|
||||||
Skip to main content
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<TerminalChrome>
|
|
||||||
<div id="main-content" className="space-y-8">
|
|
||||||
{/* whoami */}
|
|
||||||
<section>
|
|
||||||
<h1 className="terminal-prompt text-foreground text-xl md:text-2xl font-bold">
|
|
||||||
whoami
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-foreground leading-relaxed">
|
|
||||||
kamil banc. i build AI cultures.
|
|
||||||
</p>
|
|
||||||
<p className="text-terminal-dim text-sm mt-1">
|
|
||||||
AI adoption for teams that want to change how they work, not just what tools they use.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* about.txt */}
|
|
||||||
<section>
|
|
||||||
<h2 className="terminal-prompt text-foreground font-bold">
|
|
||||||
cat about.txt
|
|
||||||
</h2>
|
|
||||||
<div className="mt-2 space-y-3 text-foreground leading-relaxed text-sm md:text-base">
|
|
||||||
<p>
|
|
||||||
I help companies stop treating AI like a science project and start treating it like infrastructure.
|
|
||||||
The newsletter reaches 10,000+ operators who want practical frameworks, not hype.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This site is a claims library: {articleCount} articles, {claimCount} atomic claims,
|
|
||||||
each designed to be independently verifiable and citable by both humans and LLMs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ls projects/ */}
|
|
||||||
<section>
|
|
||||||
<h2 className="terminal-prompt text-foreground font-bold">
|
|
||||||
ls projects/
|
|
||||||
</h2>
|
|
||||||
<nav className="mt-2 space-y-1" aria-label="Projects">
|
|
||||||
<Link
|
|
||||||
href="https://aiadopters.club"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
className="block text-terminal-cyan hover:glow-cyan transition-all"
|
|
||||||
>
|
|
||||||
ai-adopters-club/<span className="text-terminal-dim ml-2 text-xs">10K+ subscribers, weekly AI newsletter</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/claims-library"
|
|
||||||
className="block text-terminal-cyan hover:glow-cyan transition-all"
|
|
||||||
>
|
|
||||||
claims-library/<span className="text-terminal-dim ml-2 text-xs">{claimCount} atomic claims, citation-ready</span>
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ls topics/ */}
|
|
||||||
<section>
|
|
||||||
<h2 className="terminal-prompt text-foreground font-bold">
|
|
||||||
ls topics/
|
|
||||||
</h2>
|
|
||||||
<nav className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1" aria-label="Topics">
|
|
||||||
<Link href="/ai-strategy" className="text-terminal-cyan hover:glow-cyan text-sm">
|
|
||||||
ai-strategy/
|
|
||||||
</Link>
|
|
||||||
<Link href="/ai-implementation" className="text-terminal-cyan hover:glow-cyan text-sm">
|
|
||||||
ai-implementation/
|
|
||||||
</Link>
|
|
||||||
<Link href="/ai-tools" className="text-terminal-cyan hover:glow-cyan text-sm">
|
|
||||||
ai-tools/
|
|
||||||
</Link>
|
|
||||||
<Link href="/ai-business-applications" className="text-terminal-cyan hover:glow-cyan text-sm">
|
|
||||||
ai-business-applications/
|
|
||||||
</Link>
|
|
||||||
<Link href="/measuring-ai-roi" className="text-terminal-cyan hover:glow-cyan text-sm">
|
|
||||||
measuring-ai-roi/
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* cat links.txt */}
|
|
||||||
<section>
|
|
||||||
<h2 className="terminal-prompt text-foreground font-bold">
|
|
||||||
cat links.txt
|
|
||||||
</h2>
|
|
||||||
<nav className="mt-2 space-y-1 text-sm" aria-label="External links">
|
|
||||||
<div>
|
|
||||||
<span className="text-terminal-dim">linkedin</span>
|
|
||||||
<span className="text-terminal-dim mx-2">-></span>
|
|
||||||
<a
|
|
||||||
href="https://www.linkedin.com/in/kbanc/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-terminal-cyan hover:glow-cyan"
|
|
||||||
>
|
|
||||||
linkedin.com/in/kbanc
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-terminal-dim">newsletter</span>
|
|
||||||
<span className="text-terminal-dim mx-2">-></span>
|
|
||||||
<a
|
|
||||||
href="https://aiadopters.club"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-terminal-cyan hover:glow-cyan"
|
|
||||||
>
|
|
||||||
aiadopters.club
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-terminal-dim">x/twitter</span>
|
|
||||||
<span className="text-terminal-dim mx-2">-></span>
|
|
||||||
<a
|
|
||||||
href="https://x.com/kamilbanc"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-terminal-cyan hover:glow-cyan"
|
|
||||||
>
|
|
||||||
x.com/kamilbanc
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</TerminalChrome>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import { resolveScene } from "@/lib/scenes/resolve-scene";
|
||||||
import {
|
import {
|
||||||
SIDEBAR_GRID,
|
SIDEBAR_GRID,
|
||||||
INLINE_GRID,
|
INLINE_GRID,
|
||||||
|
HOMEPAGE_SQUARE_GRID,
|
||||||
|
HOMEPAGE_PANE_GRID,
|
||||||
SIDEBAR_WIDTH,
|
SIDEBAR_WIDTH,
|
||||||
SIDEBAR_BREAKPOINT,
|
SIDEBAR_BREAKPOINT,
|
||||||
} from "@/lib/canvas-dimensions";
|
} from "@/lib/canvas-dimensions";
|
||||||
|
|
@ -15,7 +17,12 @@ import TerminalChrome from "./TerminalChrome";
|
||||||
import TerminalOracleInput from "./TerminalOracleInput";
|
import TerminalOracleInput from "./TerminalOracleInput";
|
||||||
import type { SceneDriver } from "@/lib/scenes/types";
|
import type { SceneDriver } from "@/lib/scenes/types";
|
||||||
|
|
||||||
export default function SidebarCanvas() {
|
interface SidebarCanvasProps {
|
||||||
|
/** "square" = compact square (mobile), "pane" = fills parent container (desktop homepage) */
|
||||||
|
mode?: "default" | "square" | "pane";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarCanvas({ mode = "default" }: SidebarCanvasProps) {
|
||||||
const preRef = useRef<HTMLPreElement>(null);
|
const preRef = useRef<HTMLPreElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [fontSize, setFontSize] = useState(8);
|
const [fontSize, setFontSize] = useState(8);
|
||||||
|
|
@ -27,14 +34,22 @@ export default function SidebarCanvas() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
if (mode === "square" || mode === "pane") return;
|
||||||
const check = () => setIsDesktop(window.innerWidth >= SIDEBAR_BREAKPOINT);
|
const check = () => setIsDesktop(window.innerWidth >= SIDEBAR_BREAKPOINT);
|
||||||
check();
|
check();
|
||||||
window.addEventListener("resize", check);
|
window.addEventListener("resize", check);
|
||||||
return () => window.removeEventListener("resize", check);
|
return () => window.removeEventListener("resize", check);
|
||||||
}, []);
|
}, [mode]);
|
||||||
|
|
||||||
const grid = isDesktop ? SIDEBAR_GRID : INLINE_GRID;
|
const grid =
|
||||||
const iterations = isDesktop ? 6 : 4;
|
mode === "pane"
|
||||||
|
? HOMEPAGE_PANE_GRID
|
||||||
|
: mode === "square"
|
||||||
|
? HOMEPAGE_SQUARE_GRID
|
||||||
|
: isDesktop
|
||||||
|
? SIDEBAR_GRID
|
||||||
|
: INLINE_GRID;
|
||||||
|
const iterations = mode === "pane" ? 6 : mode === "square" ? 4 : isDesktop ? 6 : 4;
|
||||||
|
|
||||||
// Resolve scene config to a driver
|
// Resolve scene config to a driver
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -79,10 +94,55 @@ export default function SidebarCanvas() {
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [fitToContainer]);
|
}, [fitToContainer]);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted && mode === "default") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pane mode: fills parent container entirely (homepage desktop upper-right)
|
||||||
|
if (mode === "pane") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full h-full overflow-hidden"
|
||||||
|
aria-label="Fluid simulation canvas"
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
className="select-none cursor-crosshair overflow-hidden p-0 m-0 touch-none w-full h-full"
|
||||||
|
style={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
lineHeight: "1",
|
||||||
|
letterSpacing: "0px",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Square mode: compact canvas, no chrome, no oracle input (mobile homepage)
|
||||||
|
if (mode === "square") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border border-border rounded-lg overflow-hidden bg-background"
|
||||||
|
aria-label="Fluid simulation canvas"
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="w-full aspect-square overflow-hidden">
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
className="select-none cursor-crosshair overflow-hidden p-0 m-0 touch-none w-full h-full"
|
||||||
|
style={{
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
lineHeight: "1",
|
||||||
|
letterSpacing: "0px",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
// Desktop: sticky sidebar, full viewport height, wrapped in terminal chrome
|
// Desktop: sticky sidebar, full viewport height, wrapped in terminal chrome
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { CanvasSceneProvider } from "@/lib/canvas-scene-context";
|
import { CanvasSceneProvider } from "@/lib/canvas-scene-context";
|
||||||
import SiteNav from "./SiteNav";
|
import SiteNav from "./SiteNav";
|
||||||
|
|
||||||
const SidebarCanvas = dynamic(() => import("./SidebarCanvas"), { ssr: false });
|
const SidebarCanvas = dynamic(() => import("./SidebarCanvas"), { ssr: false });
|
||||||
|
|
||||||
export default function SiteShell({ children }: { children: React.ReactNode }) {
|
export default function SiteShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Homepage manages its own full-screen terminal layout with its own CanvasSceneProvider
|
||||||
|
if (pathname === "/") {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CanvasSceneProvider>
|
<CanvasSceneProvider>
|
||||||
<div className="min-h-screen flex flex-col bg-background">
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,777 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CanvasSceneProvider, useCanvasScene } from "@/lib/canvas-scene-context";
|
||||||
|
import { useTypewriter } from "@/hooks/useTypewriter";
|
||||||
|
import {
|
||||||
|
COMMANDS,
|
||||||
|
findCommand,
|
||||||
|
findCompletions,
|
||||||
|
type CommandEntry,
|
||||||
|
} from "@/data/page-summaries";
|
||||||
|
import { isQuestion, selectResponse } from "@/lib/oracle-classifier";
|
||||||
|
import TerminalChrome from "./TerminalChrome";
|
||||||
|
|
||||||
|
const SidebarCanvas = dynamic(() => import("./SidebarCanvas"), { ssr: false });
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
id: number;
|
||||||
|
command: string;
|
||||||
|
output: string;
|
||||||
|
link?: { href: string; label: string };
|
||||||
|
isOracle?: boolean;
|
||||||
|
oracleResponse?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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,
|
||||||
|
}: {
|
||||||
|
entry: HistoryEntry;
|
||||||
|
onComplete: () => void;
|
||||||
|
}) {
|
||||||
|
const { displayed, isComplete, skip } = useTypewriter(entry.output, 80);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isComplete) onComplete();
|
||||||
|
}, [isComplete, onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-5">
|
||||||
|
<p
|
||||||
|
className="text-foreground text-sm leading-relaxed whitespace-pre-wrap cursor-pointer"
|
||||||
|
onClick={skip}
|
||||||
|
>
|
||||||
|
{displayed}
|
||||||
|
{!isComplete && <span className="terminal-cursor" aria-hidden="true">█</span>}
|
||||||
|
</p>
|
||||||
|
{isComplete && entry.link && (
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
<span className="text-terminal-dim">→ open </span>
|
||||||
|
<a
|
||||||
|
href={entry.link.href}
|
||||||
|
className="text-terminal-cyan hover:glow-cyan transition-all"
|
||||||
|
>
|
||||||
|
{entry.link.label}
|
||||||
|
</a>
|
||||||
|
<span className="text-terminal-dim"> [enter]</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OracleOutput({ entry }: { entry: HistoryEntry }) {
|
||||||
|
return (
|
||||||
|
<div className="ml-5">
|
||||||
|
<p className="text-terminal-green font-bold text-lg">{entry.oracleResponse}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompletedOutput({ entry }: { entry: HistoryEntry }) {
|
||||||
|
return (
|
||||||
|
<div className="ml-5">
|
||||||
|
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
|
{entry.output}
|
||||||
|
</p>
|
||||||
|
{entry.link && (
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
<span className="text-terminal-dim">→ open </span>
|
||||||
|
<a
|
||||||
|
href={entry.link.href}
|
||||||
|
className="text-terminal-cyan hover:glow-cyan transition-all"
|
||||||
|
>
|
||||||
|
{entry.link.label}
|
||||||
|
</a>
|
||||||
|
<span className="text-terminal-dim"> [enter]</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Menu ────────────────────────────────────────
|
||||||
|
|
||||||
|
function MenuGroup({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-terminal-dim text-xs">{label}/</div>
|
||||||
|
<div className="ml-2 space-y-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
entry,
|
||||||
|
isHighlighted,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
entry: CommandEntry;
|
||||||
|
isHighlighted: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`block w-full text-left text-sm transition-all ${
|
||||||
|
isHighlighted
|
||||||
|
? "text-terminal-cyan glow-cyan"
|
||||||
|
: "text-terminal-cyan hover:glow-cyan"
|
||||||
|
} ${isHighlighted ? "bg-terminal-green/5 -mx-1 px-1 rounded" : ""}`}
|
||||||
|
>
|
||||||
|
{entry.command}
|
||||||
|
{entry.hint && (
|
||||||
|
<span className="text-terminal-dim ml-4 text-xs">{entry.hint}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main component (inner, needs CanvasSceneProvider above it) ──
|
||||||
|
|
||||||
|
function TerminalHomepageInner({
|
||||||
|
claimCount,
|
||||||
|
}: {
|
||||||
|
articleCount: number;
|
||||||
|
claimCount: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { pushScene, popScene } = useCanvasScene();
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [activeItemIndex, setActiveItemIndex] = useState(-1);
|
||||||
|
const [streamingId, setStreamingId] = useState<number | null>(null);
|
||||||
|
const [completedIds, setCompletedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
|
||||||
|
const [nextId, setNextId] = useState(1);
|
||||||
|
const [cameraState, setCameraState] = useState<"idle" | "invited" | "active" | "denied">("idle");
|
||||||
|
const inviteShownRef = useRef(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// All selectable menu items in display order
|
||||||
|
const selectableItems = useMemo(() => COMMANDS, []);
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const g: Record<string, CommandEntry[]> = {};
|
||||||
|
for (const cmd of selectableItems) {
|
||||||
|
(g[cmd.group] ??= []).push(cmd);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [selectableItems]);
|
||||||
|
|
||||||
|
// Autocomplete suggestion
|
||||||
|
const suggestion = useMemo(() => {
|
||||||
|
if (!inputValue.trim()) return "";
|
||||||
|
const completions = findCompletions(inputValue);
|
||||||
|
if (completions.length === 1) return completions[0].command;
|
||||||
|
return "";
|
||||||
|
}, [inputValue]);
|
||||||
|
|
||||||
|
// Scroll output to bottom when history changes
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [history, streamingId]);
|
||||||
|
|
||||||
|
// Handle pending navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingNavigation) return;
|
||||||
|
const url = pendingNavigation;
|
||||||
|
setPendingNavigation(null);
|
||||||
|
if (url.startsWith("http")) {
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
} else {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}, [pendingNavigation, router]);
|
||||||
|
|
||||||
|
// SHOW YOURSELF invite after first command completes
|
||||||
|
useEffect(() => {
|
||||||
|
if (inviteShownRef.current) return;
|
||||||
|
if (completedIds.size === 0) return;
|
||||||
|
if (cameraState !== "idle") return;
|
||||||
|
|
||||||
|
inviteShownRef.current = true;
|
||||||
|
setCameraState("invited");
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pushScene({
|
||||||
|
type: "oracle",
|
||||||
|
overlayText: "SHOW YOURSELF",
|
||||||
|
onOverlayDone: () => {
|
||||||
|
popScene();
|
||||||
|
setCameraState((prev) => (prev === "invited" ? "idle" : prev));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [completedIds.size, cameraState, pushScene, popScene]);
|
||||||
|
|
||||||
|
// Canvas click: activate camera from invite or idle state
|
||||||
|
const handleCanvasClick = useCallback(() => {
|
||||||
|
if (cameraState === "invited" || cameraState === "idle") {
|
||||||
|
popScene();
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
onCameraDenied: () => {
|
||||||
|
setCameraState("denied");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCameraState("active");
|
||||||
|
}
|
||||||
|
}, [cameraState, pushScene, popScene]);
|
||||||
|
|
||||||
|
const executeCommand = useCallback(
|
||||||
|
(input: string) => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase() === "help") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
command: trimmed,
|
||||||
|
output:
|
||||||
|
"available commands: " +
|
||||||
|
selectableItems.map((c) => c.command).join(", ") +
|
||||||
|
", webcam, webcam off" +
|
||||||
|
"\n\nor just type a question to ask the oracle.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase() === "clear") {
|
||||||
|
setHistory([]);
|
||||||
|
setCompletedIds(new Set());
|
||||||
|
setStreamingId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase() === "webcam") {
|
||||||
|
if (cameraState === "denied") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera access denied. check browser settings to allow camera for this site." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cameraState === "active") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera already active. type 'webcam off' to disable." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "activating camera..." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
onCameraDenied: () => {
|
||||||
|
setCameraState("denied");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCameraState("active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase() === "webcam off") {
|
||||||
|
if (cameraState !== "active") {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera is not active." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "camera disabled." },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
popScene();
|
||||||
|
setCameraState("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isQuestion(trimmed)) {
|
||||||
|
const response = selectResponse(trimmed);
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
|
||||||
|
if (cameraState === "active") {
|
||||||
|
// Camera is running: pop it, push camera-with-overlay
|
||||||
|
popScene();
|
||||||
|
pushScene({
|
||||||
|
type: "camera",
|
||||||
|
overlayText: response,
|
||||||
|
onOverlayDone: () => {
|
||||||
|
// After overlay dissolves, return to plain camera
|
||||||
|
popScene();
|
||||||
|
pushScene({ type: "camera" });
|
||||||
|
},
|
||||||
|
onCameraDenied: () => setCameraState("denied"),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal fluid sim oracle overlay
|
||||||
|
pushScene({
|
||||||
|
type: "oracle",
|
||||||
|
overlayText: response,
|
||||||
|
onOverlayDone: () => popScene(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: "", isOracle: true, oracleResponse: response },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = findCommand(trimmed);
|
||||||
|
if (!cmd) {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: trimmed, output: `command not found: ${trimmed}. type 'help' for available commands.` },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd.external) {
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{ id, command: cmd.command, output: `opening ${cmd.href}...` },
|
||||||
|
]);
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
window.open(cmd.href, "_blank", "noopener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nextId;
|
||||||
|
setNextId((n) => n + 1);
|
||||||
|
setHistory((h) => [
|
||||||
|
...h,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
command: cmd.command,
|
||||||
|
output: cmd.summary || `opening ${cmd.href}...`,
|
||||||
|
link: { href: cmd.href, label: cmd.href },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (cmd.summary) {
|
||||||
|
setStreamingId(id);
|
||||||
|
} else {
|
||||||
|
setCompletedIds((s) => new Set(s).add(id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nextId, selectableItems, pushScene, popScene, cameraState],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStreamComplete = useCallback(() => {
|
||||||
|
if (streamingId !== null) {
|
||||||
|
setCompletedIds((s) => new Set(s).add(streamingId));
|
||||||
|
setStreamingId(null);
|
||||||
|
}
|
||||||
|
}, [streamingId]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (streamingId !== null) return;
|
||||||
|
|
||||||
|
const lastEntry = history[history.length - 1];
|
||||||
|
if (lastEntry?.link && completedIds.has(lastEntry.id) && !inputValue.trim()) {
|
||||||
|
setPendingNavigation(lastEntry.link.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = inputValue.trim();
|
||||||
|
if (!value) return;
|
||||||
|
setInputValue("");
|
||||||
|
setActiveItemIndex(-1);
|
||||||
|
executeCommand(value);
|
||||||
|
},
|
||||||
|
[inputValue, streamingId, history, completedIds, executeCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (suggestion) setInputValue(suggestion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveItemIndex((prev) => (prev <= 0 ? selectableItems.length - 1 : prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveItemIndex((prev) => (prev >= selectableItems.length - 1 ? 0 : prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && activeItemIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = selectableItems[activeItemIndex];
|
||||||
|
if (item) {
|
||||||
|
setInputValue("");
|
||||||
|
setActiveItemIndex(-1);
|
||||||
|
executeCommand(item.command);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[suggestion, selectableItems, activeItemIndex, executeCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContainerClick = useCallback(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div
|
||||||
|
className="w-full h-full max-w-7xl"
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
>
|
||||||
|
<TerminalChrome title="kbanc.com - bash" fill>
|
||||||
|
{/* ── Mobile layout (stacked) ── */}
|
||||||
|
<div className="flex flex-col h-full lg:hidden">
|
||||||
|
{/* Scrollable single column */}
|
||||||
|
<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">
|
||||||
|
kamil banc. i build AI cultures.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile canvas */}
|
||||||
|
<div className="mb-4 w-full max-w-[200px] cursor-pointer" onClick={handleCanvasClick}>
|
||||||
|
<SidebarCanvas mode="square" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<TerminalPrompt command="ls" />
|
||||||
|
<div className="ml-5 mt-2 space-y-3">
|
||||||
|
<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="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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.map((entry) => (
|
||||||
|
<div key={entry.id} className="mb-3">
|
||||||
|
<TerminalPrompt command={entry.command} />
|
||||||
|
{entry.isOracle ? (
|
||||||
|
<OracleOutput entry={entry} />
|
||||||
|
) : streamingId === entry.id ? (
|
||||||
|
<StreamingOutput entry={entry} onComplete={handleStreamComplete} />
|
||||||
|
) : completedIds.has(entry.id) ? (
|
||||||
|
<CompletedOutput entry={entry} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input ref={inputRef} type="text" value={inputValue}
|
||||||
|
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
|
||||||
|
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..." : ""}
|
||||||
|
autoComplete="off" spellCheck={false} autoFocus
|
||||||
|
/>
|
||||||
|
{suggestion && inputValue && suggestion !== inputValue && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* ── Desktop layout (split pane) ── */}
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right pane: canvas on top, output + input on bottom */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top: fluid simulation / camera */}
|
||||||
|
<div className="h-[45%] border-b border-border cursor-pointer overflow-hidden" onClick={handleCanvasClick}>
|
||||||
|
<SidebarCanvas mode="pane" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: scrollable output + sticky input */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Output area */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p className="text-terminal-dim text-sm">
|
||||||
|
type a command from the menu, or ask a question.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<div key={entry.id}>
|
||||||
|
<TerminalPrompt command={entry.command} />
|
||||||
|
{entry.isOracle ? (
|
||||||
|
<OracleOutput entry={entry} />
|
||||||
|
) : streamingId === entry.id ? (
|
||||||
|
<StreamingOutput entry={entry} onComplete={handleStreamComplete} />
|
||||||
|
) : completedIds.has(entry.id) ? (
|
||||||
|
<CompletedOutput entry={entry} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<div className="flex-shrink-0 border-t 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>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => { setInputValue(e.target.value); setActiveItemIndex(-1); }}
|
||||||
|
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..." : ""}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{suggestion && inputValue && suggestion !== inputValue && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TerminalChrome>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wrapper with CanvasSceneProvider ─────────────
|
||||||
|
|
||||||
|
export default function TerminalHomepage({
|
||||||
|
articleCount,
|
||||||
|
claimCount,
|
||||||
|
}: {
|
||||||
|
articleCount: number;
|
||||||
|
claimCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CanvasSceneProvider>
|
||||||
|
<TerminalHomepageInner
|
||||||
|
articleCount={articleCount}
|
||||||
|
claimCount={claimCount}
|
||||||
|
/>
|
||||||
|
</CanvasSceneProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,108 +2,7 @@
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { useCanvasScene } from "@/lib/canvas-scene-context";
|
import { useCanvasScene } from "@/lib/canvas-scene-context";
|
||||||
|
import { selectResponse } from "@/lib/oracle-classifier";
|
||||||
/* Question classification (extracted from TerminalOracle) */
|
|
||||||
|
|
||||||
const PATTERNS_PRIMARY: [RegExp, string][] = [
|
|
||||||
[/\b(or|versus|vs\.?|better|compared|between)\b/i, "comparison"],
|
|
||||||
[/\b(right|good idea|makes sense|correct|am i wrong|agree|on track)\b/i, "validation"],
|
|
||||||
[/^(should|can|will|is|do|does|would|could|am|are|was|were|has|have|did)\b/i, "yesno"],
|
|
||||||
[/\bhow much\b/i, "howmuch"],
|
|
||||||
[/\bhow long\b/i, "howlong"],
|
|
||||||
[/\bhow\b/i, "how"],
|
|
||||||
[/\bwhy\b/i, "why"],
|
|
||||||
[/\bwhen\b/i, "when"],
|
|
||||||
[/\bwho\b/i, "who"],
|
|
||||||
[/\b(what|which)\b/i, "what"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const PATTERNS_TOPIC: [RegExp, string][] = [
|
|
||||||
[/\b(ai|artificial.?intelligence|model|llm|gpt|claude|prompt|automat|machine.?learn|neural|chatbot|copilot|generat|fine.?tun)/i, "ai"],
|
|
||||||
[/\b(revenue|cost|roi|client|customer|sell|price|hire|team|scale|profit|money|budget|invest|market|growth|business|sales)/i, "biz"],
|
|
||||||
[/\b(code|build|deploy|ship|launch|api|database|stack|framework|react|next|python|server|frontend|backend|infra|repo)/i, "tech"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const PATTERNS_MOOD: [RegExp, string][] = [
|
|
||||||
[/\b(risk|fail|wrong|mistake|worried|afraid|danger|break|lose|crash|bug|error|scared)/i, "risk"],
|
|
||||||
[/\b(fast|quick|soon|hurry|deadline|rush|urgent|immediately|asap|right now)\b/i, "urgent"],
|
|
||||||
[/\b(maybe|not sure|confused|don't know|unsure|uncertain|idk|no idea)\b/i, "uncertain"],
|
|
||||||
];
|
|
||||||
|
|
||||||
function classifyQuestion(text: string): { type: string; topic: string; mood: string } {
|
|
||||||
const q = text.toLowerCase().trim();
|
|
||||||
let type = "fallback";
|
|
||||||
for (const [re, label] of PATTERNS_PRIMARY) { if (re.test(q)) { type = label; break; } }
|
|
||||||
let topic = "general";
|
|
||||||
for (const [re, label] of PATTERNS_TOPIC) { if (re.test(q)) { topic = label; break; } }
|
|
||||||
let mood = "neutral";
|
|
||||||
for (const [re, label] of PATTERNS_MOOD) { if (re.test(q)) { mood = label; break; } }
|
|
||||||
return { type, topic, mood };
|
|
||||||
}
|
|
||||||
|
|
||||||
const POOLS: Record<string, string[]> = {
|
|
||||||
"yesno": ["YES", "OBVIOUSLY", "NOT YET", "TRY IT", "PROVE IT", "HARD NO", "ABSOLUTELY", "PROBABLY NOT"],
|
|
||||||
"yesno.ai": ["BENCHMARK IT", "RUN A PILOT", "TEST IT FIRST", "CHECK THE DATA", "AFTER YOU MEASURE"],
|
|
||||||
"yesno.biz": ["CHECK THE NUMBERS", "ASK YOUR USERS", "PROVE ROI FIRST", "NOT WITHOUT DATA", "RUN THE MATH"],
|
|
||||||
"yesno.tech": ["PROTOTYPE FIRST", "SPIKE IT", "WRITE TESTS FIRST", "BUILD THE POC"],
|
|
||||||
"yesno.risk": ["SLEEP ON IT", "GET A SECOND OPINION", "PROCEED CAREFULLY", "HEDGE YOUR BETS"],
|
|
||||||
"yesno.urgent": ["JUST DO IT", "STOP ASKING", "GO", "SHIP NOW"],
|
|
||||||
"how": ["SHIP IT", "START SMALLER", "MEASURE FIRST", "ONE STEP", "SIMPLIFY", "EXPERIMENT", "ITERATE"],
|
|
||||||
"how.ai": ["AUTOMATE IT", "PROMPT FIRST", "MAP THE PROCESS", "START WITH WORKFLOWS", "PROTOTYPE FAST"],
|
|
||||||
"how.biz": ["FOLLOW THE MONEY", "TALK TO USERS", "FIND THE BOTTLENECK", "CHARGE MORE"],
|
|
||||||
"how.tech": ["READ THE DOCS", "KEEP IT SIMPLE", "DEPLOY FRIDAY", "LESS ABSTRACTION"],
|
|
||||||
"how.risk": ["CAREFULLY", "SMALL BETS", "REVERSIBLE STEPS", "WITH A ROLLBACK PLAN"],
|
|
||||||
"how.urgent": ["SKIP THE MEETING", "STOP PLANNING", "BRUTE FORCE IT", "CUT SCOPE"],
|
|
||||||
"howmuch": ["MORE THAN YOU THINK", "LESS THAN YOU FEAR", "MEASURE IT", "DEPENDS ON SCOPE", "RUN THE MATH"],
|
|
||||||
"howlong": ["LONGER THAN YOU THINK", "HALF THAT", "ADD A BUFFER", "THREE WEEKS", "SHIP AN MVP FIRST"],
|
|
||||||
"why": ["WRONG QUESTION", "LOOK DEEPER", "YOU KNOW WHY", "REFRAME IT", "THINK AGAIN", "FOLLOW THE TRAIL"],
|
|
||||||
"why.ai": ["BAD TRAINING DATA", "WRONG METRIC", "GARBAGE IN", "CHECK THE CONTEXT WINDOW", "PROMPT IS OFF"],
|
|
||||||
"why.biz": ["FOLLOW THE INCENTIVES", "ASK THE CUSTOMER", "MISALIGNED GOALS", "WRONG AUDIENCE"],
|
|
||||||
"why.risk": ["ROOT CAUSE FIRST", "IT'S SYSTEMIC", "CHECK ASSUMPTIONS", "DIG DEEPER"],
|
|
||||||
"when": ["NOW", "NOT YET", "YESTERDAY", "SOON ENOUGH", "Q3", "AFTER THE PILOT"],
|
|
||||||
"when.urgent": ["STOP WAITING", "RIGHT NOW", "MOVE", "TODAY"],
|
|
||||||
"when.risk": ["AFTER TESTING", "WHEN YOU'RE SURE", "NOT UNTIL Q2", "AFTER THE AUDIT"],
|
|
||||||
"when.ai": ["AFTER THE PILOT", "WHEN DATA IS READY", "ONCE YOU MEASURE"],
|
|
||||||
"what": ["FOCUS", "THINK BIGGER", "THE SIMPLER ONE", "BOTH", "NEITHER", "LESS"],
|
|
||||||
"what.ai": ["THE CHEAPER MODEL", "WHATEVER SHIPS", "START WITH CLAUDE", "THE ONE WITH DATA"],
|
|
||||||
"what.biz": ["WHATEVER SCALES", "ASK CUSTOMERS", "THE BORING ONE", "FOLLOW REVENUE"],
|
|
||||||
"what.tech": ["THE ONE YOU KNOW", "LESS DEPENDENCIES", "WHATEVER SHIPS", "USE THE STDLIB"],
|
|
||||||
"who": ["NOT YOU", "THE EXPERT", "YOUR TEAM", "SOMEONE CHEAPER", "A SPECIALIST"],
|
|
||||||
"who.ai": ["THE MACHINE", "YOUR BEST ENGINEER", "NOBODY YET"],
|
|
||||||
"comparison": ["THE FIRST ONE", "WRONG COMPARISON", "BOTH ARE FINE", "NEITHER", "FALSE DICHOTOMY", "IT DEPENDS"],
|
|
||||||
"comparison.ai": ["WHICHEVER IS SIMPLER", "THE ONE WITH DATA", "TEST BOTH"],
|
|
||||||
"comparison.biz": ["THE CHEAPER ONE", "WHICHEVER SCALES", "A/B TEST IT"],
|
|
||||||
"validation": ["TRUST YOUR GUT", "ALMOST", "NOT QUITE", "YOU KNOW ALREADY", "KEEP GOING", "EXACTLY"],
|
|
||||||
"validation.risk": ["RETHINK IT", "GET PEER REVIEW", "SECOND OPINION", "DOUBLE CHECK"],
|
|
||||||
"validation.uncertain": ["YOU'RE CLOSER THAN YOU THINK", "TRUST THE PROCESS", "KEEP GOING"],
|
|
||||||
"fallback": ["ADAPT", "FOCUS", "SHIP IT", "THINK BIGGER", "READ MORE", "INTERESTING", "SIMPLIFY", "KEEP GOING", "ELABORATE", "ASK BETTER"],
|
|
||||||
"fallback.ai": ["MEASURE THE WORKFLOW", "PROCESS NOT TOOLS", "AUTOMATE BORING PARTS"],
|
|
||||||
"fallback.biz": ["FOLLOW THE MONEY", "CHARGE MORE", "TALK TO USERS"],
|
|
||||||
"fallback.urgent": ["STOP OVERTHINKING", "JUST START", "MOVE", "SHIP TODAY"],
|
|
||||||
"fallback.uncertain": ["START ANYWAY", "CLARITY COMES FROM ACTION", "PICK ONE AND GO"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function hashString(text: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
const normalized = text.toLowerCase().trim();
|
|
||||||
for (let i = 0; i < normalized.length; i++) {
|
|
||||||
const char = normalized.charCodeAt(i);
|
|
||||||
hash = (hash << 5) - hash + char;
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectResponse(question: string): string {
|
|
||||||
const { type, topic, mood } = classifyQuestion(question);
|
|
||||||
const hash = hashString(question);
|
|
||||||
const keys = [`${type}.${topic}.${mood}`, `${type}.${topic}`, `${type}.${mood}`, type, "fallback"];
|
|
||||||
for (const key of keys) {
|
|
||||||
const pool = POOLS[key];
|
|
||||||
if (pool) return pool[hash % pool.length];
|
|
||||||
}
|
|
||||||
return "ADAPT";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalOracleInput() {
|
export default function TerminalOracleInput() {
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
export interface CommandEntry {
|
||||||
|
command: string;
|
||||||
|
aliases: string[];
|
||||||
|
href: string;
|
||||||
|
external?: boolean;
|
||||||
|
summary: string;
|
||||||
|
hint: string;
|
||||||
|
group: "projects" | "topics" | "pages" | "links";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMMANDS: CommandEntry[] = [
|
||||||
|
// projects/
|
||||||
|
{
|
||||||
|
command: "ai-adopters-club",
|
||||||
|
aliases: ["aiac", "newsletter", "club"],
|
||||||
|
href: "https://aiadopters.club",
|
||||||
|
external: true,
|
||||||
|
summary: "",
|
||||||
|
hint: "10K+ subscribers",
|
||||||
|
group: "projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "claims-library",
|
||||||
|
aliases: ["claims", "cl"],
|
||||||
|
href: "/claims-library",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// topics/
|
||||||
|
{
|
||||||
|
command: "ai-strategy",
|
||||||
|
aliases: ["strategy"],
|
||||||
|
href: "/ai-strategy",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "ai-implementation",
|
||||||
|
aliases: ["implementation", "impl"],
|
||||||
|
href: "/ai-implementation",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "ai-tools",
|
||||||
|
aliases: ["tools"],
|
||||||
|
href: "/ai-tools",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "ai-business-applications",
|
||||||
|
aliases: ["business", "biz"],
|
||||||
|
href: "/ai-business-applications",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "measuring-ai-roi",
|
||||||
|
aliases: ["roi", "measurement"],
|
||||||
|
href: "/measuring-ai-roi",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// pages/
|
||||||
|
{
|
||||||
|
command: "about",
|
||||||
|
aliases: [],
|
||||||
|
href: "/about",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "lab",
|
||||||
|
aliases: [],
|
||||||
|
href: "/lab",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "faq",
|
||||||
|
aliases: [],
|
||||||
|
href: "/faq",
|
||||||
|
summary:
|
||||||
|
"Frequently asked questions about the claims library, the newsletter, consulting, and how this site works.",
|
||||||
|
hint: "",
|
||||||
|
group: "pages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "how-i-built-this",
|
||||||
|
aliases: ["built", "colophon"],
|
||||||
|
href: "/how-i-built-this",
|
||||||
|
summary:
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
|
||||||
|
// links
|
||||||
|
{
|
||||||
|
command: "linkedin",
|
||||||
|
aliases: ["li"],
|
||||||
|
href: "https://www.linkedin.com/in/kbanc/",
|
||||||
|
external: true,
|
||||||
|
summary: "",
|
||||||
|
hint: "linkedin.com/in/kbanc",
|
||||||
|
group: "links",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: "x",
|
||||||
|
aliases: ["twitter"],
|
||||||
|
href: "https://x.com/kamilbanc",
|
||||||
|
external: true,
|
||||||
|
summary: "",
|
||||||
|
hint: "x.com/kamilbanc",
|
||||||
|
group: "links",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Find a command by exact name or alias (case-insensitive) */
|
||||||
|
export function findCommand(input: string): CommandEntry | undefined {
|
||||||
|
const q = input.toLowerCase().trim();
|
||||||
|
return COMMANDS.find(
|
||||||
|
(c) => c.command === q || c.aliases.some((a) => a === q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find commands that start with the given prefix (for autocomplete) */
|
||||||
|
export function findCompletions(prefix: string): CommandEntry[] {
|
||||||
|
const q = prefix.toLowerCase().trim();
|
||||||
|
if (!q) return [];
|
||||||
|
return COMMANDS.filter(
|
||||||
|
(c) =>
|
||||||
|
c.command.startsWith(q) || c.aliases.some((a) => a.startsWith(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all selectable (non-external or has summary) commands in display order */
|
||||||
|
export function getSelectableCommands(): CommandEntry[] {
|
||||||
|
return COMMANDS;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseTypewriterResult {
|
||||||
|
displayed: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
skip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character-by-character text reveal using requestAnimationFrame.
|
||||||
|
* Speed is in characters per second (default ~80).
|
||||||
|
*/
|
||||||
|
export function useTypewriter(text: string, speed = 80): UseTypewriterResult {
|
||||||
|
const [charIndex, setCharIndex] = useState(0);
|
||||||
|
const [skipped, setSkipped] = useState(false);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
const lastTimeRef = useRef<number>(0);
|
||||||
|
const msPerChar = 1000 / speed;
|
||||||
|
|
||||||
|
// Reset when text changes
|
||||||
|
useEffect(() => {
|
||||||
|
setCharIndex(0);
|
||||||
|
setSkipped(false);
|
||||||
|
lastTimeRef.current = 0;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipped || charIndex >= text.length || !text) return;
|
||||||
|
|
||||||
|
const step = (time: number) => {
|
||||||
|
if (!lastTimeRef.current) lastTimeRef.current = time;
|
||||||
|
const elapsed = time - lastTimeRef.current;
|
||||||
|
const charsToAdd = Math.floor(elapsed / msPerChar);
|
||||||
|
|
||||||
|
if (charsToAdd > 0) {
|
||||||
|
lastTimeRef.current = time;
|
||||||
|
setCharIndex((prev) => Math.min(prev + charsToAdd, text.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [text, charIndex, skipped, msPerChar]);
|
||||||
|
|
||||||
|
const skip = useCallback(() => {
|
||||||
|
setSkipped(true);
|
||||||
|
setCharIndex(text.length);
|
||||||
|
}, [text.length]);
|
||||||
|
|
||||||
|
const isComplete = charIndex >= text.length;
|
||||||
|
const displayed = skipped ? text : text.slice(0, charIndex);
|
||||||
|
|
||||||
|
return { displayed, isComplete, skip };
|
||||||
|
}
|
||||||
|
|
@ -7,5 +7,11 @@ export const INLINE_GRID = { w: 60, h: 8 };
|
||||||
// Homepage fluid sim: original dimensions (used when NOT in sidebar mode)
|
// Homepage fluid sim: original dimensions (used when NOT in sidebar mode)
|
||||||
export const HOMEPAGE_GRID = { w: 120, h: 24 };
|
export const HOMEPAGE_GRID = { w: 120, h: 24 };
|
||||||
|
|
||||||
|
// Homepage square inset canvas (~180px, mobile)
|
||||||
|
export const HOMEPAGE_SQUARE_GRID = { w: 28, h: 28 };
|
||||||
|
|
||||||
|
// Homepage pane canvas (fills upper-right pane on desktop)
|
||||||
|
export const HOMEPAGE_PANE_GRID = { w: 60, h: 30 };
|
||||||
|
|
||||||
export const SIDEBAR_WIDTH = 280; // px
|
export const SIDEBAR_WIDTH = 280; // px
|
||||||
export const SIDEBAR_BREAKPOINT = 1024; // px
|
export const SIDEBAR_BREAKPOINT = 1024; // px
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ export type SceneType =
|
||||||
| "featured-claim"
|
| "featured-claim"
|
||||||
| "ascii-art"
|
| "ascii-art"
|
||||||
| "project-preview"
|
| "project-preview"
|
||||||
| "matrix";
|
| "matrix"
|
||||||
|
| "camera";
|
||||||
|
|
||||||
export interface SceneConfig {
|
export interface SceneConfig {
|
||||||
type: SceneType;
|
type: SceneType;
|
||||||
|
|
@ -25,6 +26,7 @@ export interface SceneConfig {
|
||||||
asciiArt?: string[];
|
asciiArt?: string[];
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
onOverlayDone?: () => void;
|
onOverlayDone?: () => void;
|
||||||
|
onCameraDenied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasSceneContextValue {
|
interface CanvasSceneContextValue {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Oracle question classification and response selection
|
||||||
|
// Extracted from TerminalOracleInput for reuse by TerminalHomepage
|
||||||
|
|
||||||
|
const PATTERNS_PRIMARY: [RegExp, string][] = [
|
||||||
|
[/\b(or|versus|vs\.?|better|compared|between)\b/i, "comparison"],
|
||||||
|
[/\b(right|good idea|makes sense|correct|am i wrong|agree|on track)\b/i, "validation"],
|
||||||
|
[/^(should|can|will|is|do|does|would|could|am|are|was|were|has|have|did)\b/i, "yesno"],
|
||||||
|
[/\bhow much\b/i, "howmuch"],
|
||||||
|
[/\bhow long\b/i, "howlong"],
|
||||||
|
[/\bhow\b/i, "how"],
|
||||||
|
[/\bwhy\b/i, "why"],
|
||||||
|
[/\bwhen\b/i, "when"],
|
||||||
|
[/\bwho\b/i, "who"],
|
||||||
|
[/\b(what|which)\b/i, "what"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PATTERNS_TOPIC: [RegExp, string][] = [
|
||||||
|
[/\b(ai|artificial.?intelligence|model|llm|gpt|claude|prompt|automat|machine.?learn|neural|chatbot|copilot|generat|fine.?tun)/i, "ai"],
|
||||||
|
[/\b(revenue|cost|roi|client|customer|sell|price|hire|team|scale|profit|money|budget|invest|market|growth|business|sales)/i, "biz"],
|
||||||
|
[/\b(code|build|deploy|ship|launch|api|database|stack|framework|react|next|python|server|frontend|backend|infra|repo)/i, "tech"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const PATTERNS_MOOD: [RegExp, string][] = [
|
||||||
|
[/\b(risk|fail|wrong|mistake|worried|afraid|danger|break|lose|crash|bug|error|scared)/i, "risk"],
|
||||||
|
[/\b(fast|quick|soon|hurry|deadline|rush|urgent|immediately|asap|right now)\b/i, "urgent"],
|
||||||
|
[/\b(maybe|not sure|confused|don't know|unsure|uncertain|idk|no idea)\b/i, "uncertain"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function classifyQuestion(text: string): { type: string; topic: string; mood: string } {
|
||||||
|
const q = text.toLowerCase().trim();
|
||||||
|
let type = "fallback";
|
||||||
|
for (const [re, label] of PATTERNS_PRIMARY) { if (re.test(q)) { type = label; break; } }
|
||||||
|
let topic = "general";
|
||||||
|
for (const [re, label] of PATTERNS_TOPIC) { if (re.test(q)) { topic = label; break; } }
|
||||||
|
let mood = "neutral";
|
||||||
|
for (const [re, label] of PATTERNS_MOOD) { if (re.test(q)) { mood = label; break; } }
|
||||||
|
return { type, topic, mood };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POOLS: Record<string, string[]> = {
|
||||||
|
"yesno": ["YES", "OBVIOUSLY", "NOT YET", "TRY IT", "PROVE IT", "HARD NO", "ABSOLUTELY", "PROBABLY NOT"],
|
||||||
|
"yesno.ai": ["BENCHMARK IT", "RUN A PILOT", "TEST IT FIRST", "CHECK THE DATA", "AFTER YOU MEASURE"],
|
||||||
|
"yesno.biz": ["CHECK THE NUMBERS", "ASK YOUR USERS", "PROVE ROI FIRST", "NOT WITHOUT DATA", "RUN THE MATH"],
|
||||||
|
"yesno.tech": ["PROTOTYPE FIRST", "SPIKE IT", "WRITE TESTS FIRST", "BUILD THE POC"],
|
||||||
|
"yesno.risk": ["SLEEP ON IT", "GET A SECOND OPINION", "PROCEED CAREFULLY", "HEDGE YOUR BETS"],
|
||||||
|
"yesno.urgent": ["JUST DO IT", "STOP ASKING", "GO", "SHIP NOW"],
|
||||||
|
"how": ["SHIP IT", "START SMALLER", "MEASURE FIRST", "ONE STEP", "SIMPLIFY", "EXPERIMENT", "ITERATE"],
|
||||||
|
"how.ai": ["AUTOMATE IT", "PROMPT FIRST", "MAP THE PROCESS", "START WITH WORKFLOWS", "PROTOTYPE FAST"],
|
||||||
|
"how.biz": ["FOLLOW THE MONEY", "TALK TO USERS", "FIND THE BOTTLENECK", "CHARGE MORE"],
|
||||||
|
"how.tech": ["READ THE DOCS", "KEEP IT SIMPLE", "DEPLOY FRIDAY", "LESS ABSTRACTION"],
|
||||||
|
"how.risk": ["CAREFULLY", "SMALL BETS", "REVERSIBLE STEPS", "WITH A ROLLBACK PLAN"],
|
||||||
|
"how.urgent": ["SKIP THE MEETING", "STOP PLANNING", "BRUTE FORCE IT", "CUT SCOPE"],
|
||||||
|
"howmuch": ["MORE THAN YOU THINK", "LESS THAN YOU FEAR", "MEASURE IT", "DEPENDS ON SCOPE", "RUN THE MATH"],
|
||||||
|
"howlong": ["LONGER THAN YOU THINK", "HALF THAT", "ADD A BUFFER", "THREE WEEKS", "SHIP AN MVP FIRST"],
|
||||||
|
"why": ["WRONG QUESTION", "LOOK DEEPER", "YOU KNOW WHY", "REFRAME IT", "THINK AGAIN", "FOLLOW THE TRAIL"],
|
||||||
|
"why.ai": ["BAD TRAINING DATA", "WRONG METRIC", "GARBAGE IN", "CHECK THE CONTEXT WINDOW", "PROMPT IS OFF"],
|
||||||
|
"why.biz": ["FOLLOW THE INCENTIVES", "ASK THE CUSTOMER", "MISALIGNED GOALS", "WRONG AUDIENCE"],
|
||||||
|
"why.risk": ["ROOT CAUSE FIRST", "IT'S SYSTEMIC", "CHECK ASSUMPTIONS", "DIG DEEPER"],
|
||||||
|
"when": ["NOW", "NOT YET", "YESTERDAY", "SOON ENOUGH", "Q3", "AFTER THE PILOT"],
|
||||||
|
"when.urgent": ["STOP WAITING", "RIGHT NOW", "MOVE", "TODAY"],
|
||||||
|
"when.risk": ["AFTER TESTING", "WHEN YOU'RE SURE", "NOT UNTIL Q2", "AFTER THE AUDIT"],
|
||||||
|
"when.ai": ["AFTER THE PILOT", "WHEN DATA IS READY", "ONCE YOU MEASURE"],
|
||||||
|
"what": ["FOCUS", "THINK BIGGER", "THE SIMPLER ONE", "BOTH", "NEITHER", "LESS"],
|
||||||
|
"what.ai": ["THE CHEAPER MODEL", "WHATEVER SHIPS", "START WITH CLAUDE", "THE ONE WITH DATA"],
|
||||||
|
"what.biz": ["WHATEVER SCALES", "ASK CUSTOMERS", "THE BORING ONE", "FOLLOW REVENUE"],
|
||||||
|
"what.tech": ["THE ONE YOU KNOW", "LESS DEPENDENCIES", "WHATEVER SHIPS", "USE THE STDLIB"],
|
||||||
|
"who": ["NOT YOU", "THE EXPERT", "YOUR TEAM", "SOMEONE CHEAPER", "A SPECIALIST"],
|
||||||
|
"who.ai": ["THE MACHINE", "YOUR BEST ENGINEER", "NOBODY YET"],
|
||||||
|
"comparison": ["THE FIRST ONE", "WRONG COMPARISON", "BOTH ARE FINE", "NEITHER", "FALSE DICHOTOMY", "IT DEPENDS"],
|
||||||
|
"comparison.ai": ["WHICHEVER IS SIMPLER", "THE ONE WITH DATA", "TEST BOTH"],
|
||||||
|
"comparison.biz": ["THE CHEAPER ONE", "WHICHEVER SCALES", "A/B TEST IT"],
|
||||||
|
"validation": ["TRUST YOUR GUT", "ALMOST", "NOT QUITE", "YOU KNOW ALREADY", "KEEP GOING", "EXACTLY"],
|
||||||
|
"validation.risk": ["RETHINK IT", "GET PEER REVIEW", "SECOND OPINION", "DOUBLE CHECK"],
|
||||||
|
"validation.uncertain": ["YOU'RE CLOSER THAN YOU THINK", "TRUST THE PROCESS", "KEEP GOING"],
|
||||||
|
"fallback": ["ADAPT", "FOCUS", "SHIP IT", "THINK BIGGER", "READ MORE", "INTERESTING", "SIMPLIFY", "KEEP GOING", "ELABORATE", "ASK BETTER"],
|
||||||
|
"fallback.ai": ["MEASURE THE WORKFLOW", "PROCESS NOT TOOLS", "AUTOMATE BORING PARTS"],
|
||||||
|
"fallback.biz": ["FOLLOW THE MONEY", "CHARGE MORE", "TALK TO USERS"],
|
||||||
|
"fallback.urgent": ["STOP OVERTHINKING", "JUST START", "MOVE", "SHIP TODAY"],
|
||||||
|
"fallback.uncertain": ["START ANYWAY", "CLARITY COMES FROM ACTION", "PICK ONE AND GO"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function hashString(text: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
const normalized = text.toLowerCase().trim();
|
||||||
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
|
const char = normalized.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectResponse(question: string): string {
|
||||||
|
const { type, topic, mood } = classifyQuestion(question);
|
||||||
|
const hash = hashString(question);
|
||||||
|
const keys = [`${type}.${topic}.${mood}`, `${type}.${topic}`, `${type}.${mood}`, type, "fallback"];
|
||||||
|
for (const key of keys) {
|
||||||
|
const pool = POOLS[key];
|
||||||
|
if (pool) return pool[hash % pool.length];
|
||||||
|
}
|
||||||
|
return "ADAPT";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the input looks like a question (should trigger oracle, not command lookup) */
|
||||||
|
export function isQuestion(text: string): boolean {
|
||||||
|
const q = text.trim().toLowerCase();
|
||||||
|
if (q.endsWith("?")) return true;
|
||||||
|
return /^(should|can|will|is|do|does|would|could|am|are|was|were|has|have|did|how|why|when|who|what|which)\b/.test(q);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import type { FluidState } from "@/components/FluidSimulation/fluid-solver";
|
||||||
|
import {
|
||||||
|
renderTextOverlay,
|
||||||
|
type RenderTarget,
|
||||||
|
type TextOverlayState,
|
||||||
|
type TextOverlayCell,
|
||||||
|
} from "@/components/FluidSimulation/ascii-renderer";
|
||||||
|
import type { SceneDriver } from "./types";
|
||||||
|
|
||||||
|
export interface CameraSceneOptions {
|
||||||
|
onCameraDenied?: () => void;
|
||||||
|
overlayText?: string;
|
||||||
|
onOverlayDone?: () => void;
|
||||||
|
iterations?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character tiers: dark → bright (webcam brightness)
|
||||||
|
const CAM_DUST = [".", ",", "'", ";", ":", "`", "~", "-"];
|
||||||
|
const CAM_LOW = ["a", "d", "p", "t", "c", "r", "e", "+", "=", "*"];
|
||||||
|
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;
|
||||||
|
return ((x * 73856093) ^ (y * 19349663) ^ (bBits * 83492791)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToChar(b: number, x: number, y: number): string {
|
||||||
|
if (b < 0.05) return " ";
|
||||||
|
const h = camHash(x, y, b);
|
||||||
|
if (b < 0.2) return CAM_DUST[h % CAM_DUST.length];
|
||||||
|
if (b < 0.45) return CAM_LOW[h % CAM_LOW.length];
|
||||||
|
if (b < 0.7) return CAM_MID[h % CAM_MID.length];
|
||||||
|
return CAM_FULL[h % CAM_FULL.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function brightnessToGreen(b: number): string {
|
||||||
|
if (b < 0.05) return "";
|
||||||
|
const clamped = Math.min(b, 1);
|
||||||
|
const l = 15 + clamped * 50;
|
||||||
|
const s = 50 + clamped * 30;
|
||||||
|
return `hsl(120,${s}%,${l}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBrightnessCentroid(
|
||||||
|
brightness: Float32Array,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): { cx: number; cy: number } {
|
||||||
|
let sumB = 0;
|
||||||
|
let sumBX = 0;
|
||||||
|
let sumBY = 0;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const b = brightness[y * width + x];
|
||||||
|
sumB += b;
|
||||||
|
sumBX += b * x;
|
||||||
|
sumBY += b * y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumB < 0.001) {
|
||||||
|
return { cx: Math.floor(width / 2), cy: Math.floor(height / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx: Math.round(sumBX / sumB),
|
||||||
|
cy: Math.round(sumBY / sumB),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCentroidOverlay(
|
||||||
|
text: string,
|
||||||
|
centroid: { cx: number; cy: number },
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number
|
||||||
|
): TextOverlayState {
|
||||||
|
const chars = text.split("");
|
||||||
|
let startX = centroid.cx - Math.floor(chars.length / 2);
|
||||||
|
startX = Math.max(0, Math.min(startX, gridWidth - chars.length));
|
||||||
|
const y = Math.max(0, Math.min(centroid.cy, gridHeight - 1));
|
||||||
|
|
||||||
|
const cells: TextOverlayCell[] = [];
|
||||||
|
const cellDelays: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < chars.length; i++) {
|
||||||
|
cells.push({
|
||||||
|
spanIdx: y * gridWidth + startX + i,
|
||||||
|
char: chars[i],
|
||||||
|
gx: startX + i + 1,
|
||||||
|
gy: y + 1,
|
||||||
|
});
|
||||||
|
cellDelays.push(chars[i] === " " ? 0 : Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startTime: performance.now(), cells, cellDelays };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCameraScene(opts: CameraSceneOptions = {}): SceneDriver {
|
||||||
|
const { onCameraDenied } = opts;
|
||||||
|
|
||||||
|
let video: HTMLVideoElement | null = null;
|
||||||
|
let canvas: HTMLCanvasElement | null = null;
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let ready = false;
|
||||||
|
let brightness: Float32Array | null = null;
|
||||||
|
|
||||||
|
let overlay: TextOverlayState | null = null;
|
||||||
|
let overlayDone = false;
|
||||||
|
let pendingOverlay: string | null = opts.overlayText || null;
|
||||||
|
const onOverlayDone = opts.onOverlayDone;
|
||||||
|
const ASSEMBLE = 0.6;
|
||||||
|
const HOLD = 2.0;
|
||||||
|
const DISSOLVE = 0.5;
|
||||||
|
const TOTAL = ASSEMBLE + HOLD + DISSOLVE;
|
||||||
|
|
||||||
|
async function startCamera(gridWidth: number, gridHeight: number) {
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: "user", width: { ideal: 320 }, height: { ideal: 240 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
video = document.createElement("video");
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.setAttribute("playsinline", "true");
|
||||||
|
video.style.display = "none";
|
||||||
|
document.body.appendChild(video);
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
canvas.width = gridWidth;
|
||||||
|
canvas.height = gridHeight;
|
||||||
|
canvas.style.display = "none";
|
||||||
|
document.body.appendChild(canvas);
|
||||||
|
ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
|
||||||
|
brightness = new Float32Array(gridWidth * gridHeight);
|
||||||
|
ready = true;
|
||||||
|
|
||||||
|
const track = stream.getVideoTracks()[0];
|
||||||
|
if (track) {
|
||||||
|
track.addEventListener("ended", () => {
|
||||||
|
ready = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onCameraDenied?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
ready = false;
|
||||||
|
if (stream) {
|
||||||
|
for (const track of stream.getTracks()) track.stop();
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
if (video) {
|
||||||
|
video.remove();
|
||||||
|
video = null;
|
||||||
|
}
|
||||||
|
if (canvas) {
|
||||||
|
canvas.remove();
|
||||||
|
canvas = null;
|
||||||
|
}
|
||||||
|
ctx = null;
|
||||||
|
brightness = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enter(_state: FluidState, target: RenderTarget) {
|
||||||
|
startCamera(target.width, target.height);
|
||||||
|
},
|
||||||
|
|
||||||
|
tick(_state: FluidState, target: RenderTarget, _time: number, _dt: number): boolean {
|
||||||
|
if (!ready || !video || !canvas || !ctx || !brightness) return false;
|
||||||
|
|
||||||
|
const { width, height, spans } = target;
|
||||||
|
|
||||||
|
// Draw video frame to canvas at grid resolution (mirrored)
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(width, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Read pixel data
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height);
|
||||||
|
const pixels = imageData.data;
|
||||||
|
|
||||||
|
// Convert to brightness 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;
|
||||||
|
|
||||||
|
brightness[y * width + x] = br;
|
||||||
|
|
||||||
|
const spanIdx = y * width + x;
|
||||||
|
const span = spans[spanIdx];
|
||||||
|
|
||||||
|
const char = brightnessToChar(br, x, y);
|
||||||
|
const color = brightnessToGreen(br);
|
||||||
|
|
||||||
|
if (span.textContent !== char) span.textContent = char;
|
||||||
|
if (color) {
|
||||||
|
if (span.style.color !== color) span.style.color = color;
|
||||||
|
} else {
|
||||||
|
if (span.style.color) span.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle pending overlay (triggered after camera is ready)
|
||||||
|
if (pendingOverlay && brightness) {
|
||||||
|
const centroid = findBrightnessCentroid(brightness, width, height);
|
||||||
|
overlay = createCentroidOverlay(pendingOverlay, centroid, width, height);
|
||||||
|
overlayDone = false;
|
||||||
|
pendingOverlay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render text overlay on face
|
||||||
|
if (overlay && !overlayDone) {
|
||||||
|
const elapsed = (performance.now() - overlay.startTime) / 1000;
|
||||||
|
|
||||||
|
if (elapsed >= TOTAL) {
|
||||||
|
overlay = null;
|
||||||
|
overlayDone = true;
|
||||||
|
onOverlayDone?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aP = elapsed < ASSEMBLE ? elapsed / ASSEMBLE : 1;
|
||||||
|
const dP = elapsed > ASSEMBLE + HOLD
|
||||||
|
? (elapsed - ASSEMBLE - HOLD) / DISSOLVE
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
renderTextOverlay(target, overlay, aP, dP);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
exit(_state: FluidState) {
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,5 @@ export { createFeaturedClaimScene } from "./featured-claim-scene";
|
||||||
export { createAsciiArtScene } from "./ascii-art-scene";
|
export { createAsciiArtScene } from "./ascii-art-scene";
|
||||||
export { createProjectPreviewScene } from "./project-preview-scene";
|
export { createProjectPreviewScene } from "./project-preview-scene";
|
||||||
export { createMatrixScene } from "./matrix-scene";
|
export { createMatrixScene } from "./matrix-scene";
|
||||||
|
export { createCameraScene, findBrightnessCentroid, type CameraSceneOptions } from "./camera-scene";
|
||||||
export { resolveScene } from "./resolve-scene";
|
export { resolveScene } from "./resolve-scene";
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { createFeaturedClaimScene } from "./featured-claim-scene";
|
||||||
import { createAsciiArtScene } from "./ascii-art-scene";
|
import { createAsciiArtScene } from "./ascii-art-scene";
|
||||||
import { createProjectPreviewScene } from "./project-preview-scene";
|
import { createProjectPreviewScene } from "./project-preview-scene";
|
||||||
import { createMatrixScene } from "./matrix-scene";
|
import { createMatrixScene } from "./matrix-scene";
|
||||||
|
import { createCameraScene } from "./camera-scene";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a SceneConfig to a SceneDriver instance.
|
* Resolve a SceneConfig to a SceneDriver instance.
|
||||||
|
|
@ -31,6 +32,13 @@ export function resolveScene(config: SceneConfig, opts?: FluidSceneOptions): Sce
|
||||||
return createProjectPreviewScene(config.projectId ?? "", opts);
|
return createProjectPreviewScene(config.projectId ?? "", opts);
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return createMatrixScene(opts);
|
return createMatrixScene(opts);
|
||||||
|
case "camera":
|
||||||
|
return createCameraScene({
|
||||||
|
onCameraDenied: config.onCameraDenied,
|
||||||
|
overlayText: config.overlayText,
|
||||||
|
onOverlayDone: config.onOverlayDone,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
return createFluidScene(opts);
|
return createFluidScene(opts);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue