diff --git a/apps/web/app/_components/ActivityFeed.tsx b/apps/web/app/_components/ActivityFeed.tsx new file mode 100644 index 0000000..b400e95 --- /dev/null +++ b/apps/web/app/_components/ActivityFeed.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import Link from "next/link"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; +import { ease, cn } from "@konjoai/ui"; + +type FeedEvent = { + id: number; + slug: string; + glyph: string; + message: string; + label: string; + toneClass: string; + dotColor: string; + age: number; // seconds since fired +}; + +const EVENT_POOL: Omit[] = [ + { slug: "squish", glyph: "◐", message: "Decoded 512 tokens in 12.2 ms (42 tok/s)", label: "throughput", toneClass: "text-konjo-accent", dotColor: "var(--color-konjo-accent)" }, + { slug: "kyro", glyph: "✸", message: "Hybrid retrieval completed — NDCG@10 = 0.911", label: "retrieval", toneClass: "text-konjo-good", dotColor: "var(--color-konjo-good)" }, + { slug: "vectro", glyph: "◇", message: "Compressed 2 048 vectors at 32× ratio (87% recall)", label: "codec", toneClass: "text-konjo-good", dotColor: "var(--color-konjo-good)" }, + { slug: "kairu", glyph: "▲", message: "p99 latency = 8.2 ms — within SLO budget", label: "latency", toneClass: "text-konjo-warm", dotColor: "var(--color-konjo-warm)" }, + { slug: "miru", glyph: "◉", message: "Vision trace complete — 76% attention coverage", label: "attention", toneClass: "text-konjo-accent", dotColor: "var(--color-konjo-accent)" }, + { slug: "toki", glyph: "✕", message: "Blocked 1 jailbreak probe (DAN v4.3)", label: "safety", toneClass: "text-konjo-hot", dotColor: "var(--color-konjo-hot)" }, + { slug: "kohaku", glyph: "❖", message: "Recalled 3 episodic memories (half-life: 4.2 h)", label: "recall", toneClass: "text-konjo-violet", dotColor: "var(--color-konjo-violet)" }, + { slug: "lopi", glyph: "⌬", message: "Agent task completed in 2 branches — merged", label: "agent", toneClass: "text-konjo-good", dotColor: "var(--color-konjo-good)" }, + { slug: "drex", glyph: "✦", message: "Training loss = 0.072 — epoch 14/40 done", label: "training", toneClass: "text-konjo-accent", dotColor: "var(--color-konjo-accent)" }, + { slug: "squish", glyph: "◐", message: "Prefill 1 024 tokens: 18 ms (Metal path)", label: "prefill", toneClass: "text-konjo-accent", dotColor: "var(--color-konjo-accent)" }, + { slug: "kyro", glyph: "✸", message: "Semantic cache hit — latency 2.1 ms vs 68 ms", label: "cache", toneClass: "text-konjo-cool", dotColor: "var(--color-konjo-cool)" }, + { slug: "vectro", glyph: "◇", message: "INT8 codec: 87 recall / 91 baseline — shipped", label: "quality", toneClass: "text-konjo-good", dotColor: "var(--color-konjo-good)" }, +]; + +const MAX_EVENTS = 7; +let nextId = 0; + +/** Unique product slugs + glyphs from the pool, for the filter row. */ +const POOL_PRODUCTS = [ + ...new Map(EVENT_POOL.map((e) => [e.slug, { slug: e.slug, glyph: e.glyph }])).values(), +]; + +/** + * Simulated real-time product activity feed. + * New events trickle in every 2-4 s; old ones slide out once MAX_EVENTS is reached. + */ +export function ActivityFeed() { + const reduce = useReducedMotion(); + const [events, setEvents] = useState([]); + const [poolIdx, setPoolIdx] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [paused, setPaused] = useState(false); + const [filterSlug, setFilterSlug] = useState(null); + const pausedRef = useRef(false); + + useEffect(() => { pausedRef.current = paused; }, [paused]); + + const pushEvent = useCallback(() => { + setEvents((prev) => { + const template = EVENT_POOL[poolIdx % EVENT_POOL.length]; + const next: FeedEvent = { ...template, id: nextId++, age: 0 }; + return [next, ...prev].slice(0, MAX_EVENTS); + }); + setPoolIdx((i) => i + 1); + setTotalCount((n) => n + 1); + }, [poolIdx]); + + // Initial batch + recurring trickle — pauses when hovering + useEffect(() => { + pushEvent(); + const id = setInterval(() => { + if (pausedRef.current) return; + setEvents((prev) => prev.map((e) => ({ ...e, age: e.age + 1 }))); + if (Math.random() > 0.3) pushEvent(); + }, 2600); + return () => clearInterval(id); + }, [pushEvent]); + + return ( +
+ +

+ Live activity +

+ + + Stream + + {paused && ( + + paused + + )} + {filterSlug && ( + + · {filterSlug} + + )} + {totalCount > 0 && ( + + {totalCount} event{totalCount !== 1 ? "s" : ""} + + )} +
+ + {/* Product filter chips */} +
+ {POOL_PRODUCTS.map(({ slug, glyph }) => { + const active = filterSlug === slug; + return ( + + ); + })} +
+ +
    setPaused(true)} + onMouseLeave={() => setPaused(false)} + > + + {events.filter((ev) => !filterSlug || ev.slug === filterSlug).map((ev) => ( + + {/* Colored left accent border */} +
    + + + {ev.glyph} + + +
    +
    + + {ev.slug} + + + {ev.message} + +
    +
    + +
    + + {ev.label} + + + {ev.age === 0 ? "now" : `${ev.age}s ago`} + +
    + + + → + + + {/* Freshness decay bar — shrinks from full to empty over 10 s */} + {!reduce && ( + = 10 ? 0 : 0.4 }} + transition={{ duration: 0.6, ease: "linear" }} + aria-hidden + /> + )} + + ))} + +
+
+ ); +} diff --git a/apps/web/app/_components/AgentFlow.tsx b/apps/web/app/_components/AgentFlow.tsx new file mode 100644 index 0000000..ad2be91 --- /dev/null +++ b/apps/web/app/_components/AgentFlow.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; +import { ease } from "@konjoai/ui"; +import { ScrambleText } from "./ScrambleText"; + +type TaskStatus = "pending" | "running" | "done"; +type Phase = "idle" | "planning" | "parallel" | "composing" | "generating" | "done"; + +type SubTask = { + id: string; + product: string; + glyph: string; + color: string; + action: string; + detail: string; + durationMs: number; +}; + +const INPUT_TASK = + "Build an enhanced RAG pipeline: retrieve context, compress vectors, recall memory, stream response."; + +const PARALLEL: SubTask[] = [ + { + id: "kyro", + product: "kyro", + glyph: "✸", + color: "var(--color-konjo-accent)", + action: "Hybrid retrieval", + detail: "BM25 + dense · ColBERT rerank · 5 chunks · NDCG 0.94", + durationMs: 370, + }, + { + id: "vectro", + product: "vectro", + glyph: "◇", + color: "var(--color-konjo-violet)", + action: "Compress vectors", + detail: "768-dim → 192-dim · VQZ codec · 4× compression · fidelity 99.1%", + durationMs: 260, + }, + { + id: "kohaku", + product: "kohaku", + glyph: "❖", + color: "var(--color-konjo-good)", + action: "Episodic recall", + detail: "3 memories · HDC 89% recall rate · half-life 4.2 h", + durationMs: 190, + }, +]; + +const SEQUENTIAL: SubTask = { + id: "squish", + product: "squish", + glyph: "◐", + color: "var(--color-konjo-brand)", + action: "Stream generation", + detail: "42 tok/s · MLX Metal backend · OpenAI-compatible SSE", + durationMs: 540, +}; + +const OUTPUT_LINES = [ + "Context assembled: 5 chunks · 192-dim vectors · 3 episodic memories", + 'Streaming: "A production RAG pipeline should start with hybrid…"', + "Complete · 184 tokens · 42 tok/s · round-trip 1.24 s", +]; + +const STAGGER = 100; +const T_PLAN = 700; +const T_STARTS = PARALLEL.map((_, i) => T_PLAN + i * STAGGER); +const T_ENDS = PARALLEL.map((t, i) => T_STARTS[i] + t.durationMs); +const T_ALL_DONE = Math.max(...T_ENDS); +const T_COMPOSE = T_ALL_DONE + 240; +const T_GEN = T_COMPOSE + 320; +const T_GEN_END = T_GEN + SEQUENTIAL.durationMs; +const T_LINES = OUTPUT_LINES.map((_, i) => T_GEN_END + 120 + i * 220); +const T_DONE = T_LINES[T_LINES.length - 1] + 280; + +function StatusDot({ status, color }: { status: TaskStatus; color: string }) { + if (status === "pending") + return ; + if (status === "running") + return ( + + ); + return ( + + ); +} + +function TaskCard({ + task, + status, + reduce, + animDelay, +}: { + task: SubTask; + status: TaskStatus; + reduce: boolean | null; + animDelay: number; +}) { + const active = status !== "pending"; + return ( + +
+ + {task.glyph} + +
+
+ + {task.product} + + +
+

+ {task.action} +

+
+
+ +
+ +
+ + + {status === "done" && ( + + {task.detail} + + )} + +
+ ); +} + +function FlowConnector({ label, visible }: { label: string; visible: boolean }) { + return ( + + {visible && ( + +
+ + {label} + +
+ + )} + + ); +} + +/** + * Animated lopi agent orchestration demo — shows a task decomposed into parallel + * sub-agents (kyro, vectro, kohaku) then composed for sequential generation (squish). + * Auto-plays when scrolled into view; replay button on completion. + */ +export function AgentFlow() { + const reduce = useReducedMotion(); + const [phase, setPhase] = useState("idle"); + const [statuses, setStatuses] = useState>({}); + const [lines, setLines] = useState([]); + const [inView, setInView] = useState(false); + const sectionRef = useRef(null); + const runRef = useRef(0); + const timerIds = useRef[]>([]); + + useEffect(() => { + return () => { timerIds.current.forEach(clearTimeout); }; + }, []); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) setInView(true); }, + { threshold: 0.12 }, + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + const startRun = useCallback(() => { + timerIds.current.forEach(clearTimeout); + timerIds.current = []; + const run = ++runRef.current; + + function at(ms: number, fn: () => void) { + const id = setTimeout(() => { + if (runRef.current !== run) return; + fn(); + }, reduce ? 0 : ms); + timerIds.current.push(id); + } + + setPhase("planning"); + setStatuses({}); + setLines([]); + + if (reduce) { + setPhase("done"); + setStatuses({ kyro: "done", vectro: "done", kohaku: "done", squish: "done" }); + setLines(OUTPUT_LINES); + return; + } + + PARALLEL.forEach((task, i) => { + at(T_STARTS[i], () => { + setPhase("parallel"); + setStatuses((prev) => ({ ...prev, [task.id]: "running" })); + }); + at(T_ENDS[i], () => + setStatuses((prev) => ({ ...prev, [task.id]: "done" })), + ); + }); + + at(T_COMPOSE, () => setPhase("composing")); + at(T_GEN, () => { + setPhase("generating"); + setStatuses((prev) => ({ ...prev, squish: "running" })); + }); + at(T_GEN_END, () => + setStatuses((prev) => ({ ...prev, squish: "done" })), + ); + + OUTPUT_LINES.forEach((_, i) => { + at(T_LINES[i], () => + setLines((prev) => [...prev, OUTPUT_LINES[i]]), + ); + }); + + at(T_DONE, () => setPhase("done")); + }, [reduce]); + + useEffect(() => { + if (inView && phase === "idle") startRun(); + }, [inView, phase, startRun]); + + const status = (id: string): TaskStatus => statuses[id] ?? "pending"; + const showPlan = phase !== "idle" && phase !== "planning"; + const showCompose = phase === "composing" || phase === "generating" || phase === "done"; + const showGen = phase === "generating" || phase === "done"; + + const headerColor = + phase === "planning" + ? "var(--color-konjo-warm)" + : phase === "done" + ? "var(--color-konjo-good)" + : "var(--color-konjo-brand)"; + + const headerLabel = + phase === "idle" + ? "standby" + : phase === "planning" + ? "analyzing…" + : phase === "composing" + ? "composing…" + : phase === "done" + ? "complete" + : "running"; + + return ( +
+ +
+

+ lopi · agent orchestrator · parallel dispatch +

+ +

+ lopi decomposes the task and fans out to kyro, vectro, and kohaku in + parallel — then routes the assembled context to squish for streaming + generation. Zero coordination overhead. +

+
+ + {phase === "done" && ( + + )} +
+ + + {/* Header bar */} +
+ + + lopi · {headerLabel} + + + ⌬ agent orchestrator + +
+ +
+ {/* Input task */} +
+

+ task input +

+

+ {INPUT_TASK} +

+ {phase === "planning" && ( + + ↺ lopi planning decomposition… + + )} +
+ + + + + {showPlan && ( + + {PARALLEL.map((task, i) => ( + + ))} + + )} + + + + + + {showGen && ( + + )} + + + + {lines.length > 0 && ( + +

+ ✓ output +

+ {lines.map((line, i) => ( + + {line} + + ))} +
+ )} +
+
+ + {/* Footer legend */} +
+ {[...PARALLEL, SEQUENTIAL].map((t) => ( + + {t.glyph} {t.product} + + ))} + + simulated · real latencies shown + +
+
+
+ ); +} diff --git a/apps/web/app/_components/AnimatedMiniSparkline.tsx b/apps/web/app/_components/AnimatedMiniSparkline.tsx new file mode 100644 index 0000000..e85e3c1 --- /dev/null +++ b/apps/web/app/_components/AnimatedMiniSparkline.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; + +function seed(slug: string): number { + return slug.split("").reduce((n, c) => n + c.charCodeAt(0), 0); +} + +interface AnimatedMiniSparklineProps { + slug: string; + width?: number; + height?: number; + color?: string; + hovered?: boolean; +} + +/** + * Client-side sparkline that draws its path into view on mount and re-draws + * on card hover — pathLength 0→1 with a spring easing for a premium feel. + */ +export function AnimatedMiniSparkline({ + slug, + width = 72, + height = 28, + color = "var(--color-konjo-good)", + hovered = false, +}: AnimatedMiniSparklineProps) { + const reduce = useReducedMotion(); + const s = seed(slug); + const pts = Array.from({ length: 8 }, (_, i) => { + const v = 97.5 + Math.sin((s + i * 2.7) * 0.6) * 2.1 + Math.cos((s * 0.3 + i) * 0.9) * 1.1; + return Math.max(92, Math.min(100, v)); + }); + const lo = Math.min(...pts); + const hi = Math.max(...pts); + const range = hi - lo || 1; + const pad = 3; + const coords = pts.map((v, i) => [ + (i / (pts.length - 1)) * width, + height - pad - ((v - lo) / range) * (height - pad * 2), + ] as const); + const d = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`).join(" "); + + if (reduce) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/apps/web/app/_components/AnimatedSection.tsx b/apps/web/app/_components/AnimatedSection.tsx index 8458690..3e0571a 100644 --- a/apps/web/app/_components/AnimatedSection.tsx +++ b/apps/web/app/_components/AnimatedSection.tsx @@ -6,6 +6,8 @@ import { ease } from "@konjoai/ui"; interface AnimatedSectionProps { children: React.ReactNode; className?: string; + /** HTML id for anchor / scroll-spy targets. */ + id?: string; /** Extra entrance delay in seconds. */ delay?: number; /** Render as a div instead of section — use when already inside a section. */ @@ -19,6 +21,7 @@ interface AnimatedSectionProps { export function AnimatedSection({ children, className, + id, delay = 0, as = "section", }: AnimatedSectionProps) { @@ -27,6 +30,7 @@ export function AnimatedSection({ return ( { + if (!inView) return; + if (reduce) { setGainDisplay(baseline.gainValue); return; } + const ctrl = animate(0, baseline.gainValue, { + duration: 0.9, + ease: "easeOut", + delay: delay + 0.15, + onUpdate: (v) => + setGainDisplay( + isFloat ? Math.round(v * 10) / 10 : Math.round(v), + ), + }); + return () => ctrl.stop(); + }, [inView, baseline.gainValue, reduce, delay, isFloat]); + + const gainStr = `${baseline.gainPrefix}${gainDisplay}${baseline.gainSuffix}`; + + return ( + +
+
+ + {row.glyph} + +
+ + {row.slug} + + + {row.metric} + +
+
+
+ + {row.konjoDisplay} + + + + {gainStr} + + +
+
+ + {/* Konjo bar */} +
+ +
+ + {/* Baseline bar */} +
+ + + {baseline.display} + +
+ + ); +} + +/** Competitive benchmark bars — four products with switchable comparison baseline. */ +export function BenchmarkSection() { + const reduce = useReducedMotion(); + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-60px" }); + const [mode, setMode] = useState("cpu"); + + return ( +
+ +
+

+ benchmarks · measured on M2 Pro +

+ +

+ Four headline metrics against two industry baselines — toggle to compare. +

+
+ + {/* Baseline toggle */} +
+ {(["cpu", "gpt4o"] as const).map((m) => ( + + ))} +
+
+ + + {ROWS.map((row, i) => ( + + ))} + +
+ ); +} diff --git a/apps/web/app/_components/Breadcrumbs.tsx b/apps/web/app/_components/Breadcrumbs.tsx index a211fdd..8217f5b 100644 --- a/apps/web/app/_components/Breadcrumbs.tsx +++ b/apps/web/app/_components/Breadcrumbs.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { motion, useReducedMotion } from "motion/react"; import { ease } from "@konjoai/ui"; @@ -23,12 +24,12 @@ export function Breadcrumbs({ trail }: { trail: Crumb[] }) { transition={{ duration: 0.3, ease: ease.kanjo, delay: i * 0.07 }} > {c.href && !last ? ( - {c.label} - + ) : ( {c.label} )} diff --git a/apps/web/app/_components/CTASection.tsx b/apps/web/app/_components/CTASection.tsx new file mode 100644 index 0000000..2d54320 --- /dev/null +++ b/apps/web/app/_components/CTASection.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { ease } from "@konjoai/ui"; +import { PRODUCTS } from "@/lib/products"; +import { ScrambleText } from "./ScrambleText"; + +/** + * Pre-footer call-to-action — aurora burst, large scramble headline, + * two CTAs, and an animated product glyph roll. + */ +export function CTASection() { + const reduce = useReducedMotion(); + + return ( +
+ + {/* Aurora orbs */} + {!reduce && ( + <> +
+
+ + )} + + + Nine products · one design system · zero compromises + + + + + + Inference, retrieval, compression, memory, agents — every product + ships a CLI, a benchmark suite, and a CLAUDE.md. + + + + + Explore the constellation + + + GitHub → + + + + {/* Product glyph roll */} + + {PRODUCTS.map((p, i) => ( + + + {p.glyph} + + {p.slug} + + ))} + + +
+ ); +} diff --git a/apps/web/app/_components/ChangelogFeed.tsx b/apps/web/app/_components/ChangelogFeed.tsx new file mode 100644 index 0000000..ec2f544 --- /dev/null +++ b/apps/web/app/_components/ChangelogFeed.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; +import { ease } from "@konjoai/ui"; +import { ScrambleText } from "./ScrambleText"; + +type ChangelogEntry = { + date: string; + product: string; + glyph: string; + color: string; + type: "feat" | "fix" | "perf" | "chore"; + title: string; + detail?: string; + version?: string; +}; + +const TYPE_LABEL: Record = { + feat: "feat", + fix: "fix", + perf: "perf", + chore: "chore", +}; + +const TYPE_COLOR: Record = { + feat: "var(--color-konjo-accent)", + fix: "var(--color-konjo-warm)", + perf: "var(--color-konjo-good)", + chore: "var(--color-konjo-fg-faint)", +}; + +const ENTRIES: ChangelogEntry[] = [ + { + date: "2026-06-12", + product: "squish", + glyph: "◐", + color: "var(--color-konjo-brand)", + type: "feat", + title: "INT4 AWQ gate: Qwen2.5-1.5B ships only at ≥ 70.6% arc_easy", + detail: "SQINT2 path blocked until accuracy gate passes CI.", + version: "v9.14.0", + }, + { + date: "2026-06-11", + product: "kyro", + glyph: "✸", + color: "var(--color-konjo-accent)", + type: "perf", + title: "Redis semantic cache reduces repeat query latency from 68 ms → 2 ms", + version: "v1.2.0", + }, + { + date: "2026-06-10", + product: "lopi", + glyph: "⌬", + color: "var(--color-konjo-cool)", + type: "feat", + title: "Telegram + WhatsApp remote control via teloxide", + detail: "Control running agents from mobile — pause, branch, or abort tasks.", + version: "v0.1.0", + }, + { + date: "2026-06-09", + product: "vectro", + glyph: "◇", + color: "var(--color-konjo-violet)", + type: "perf", + title: "Mojo SIMD path: VQZ compression 2.4× faster on SIMD-friendly hardware", + version: "v8.0.0", + }, + { + date: "2026-06-08", + product: "kohaku", + glyph: "❖", + color: "var(--color-konjo-good)", + type: "feat", + title: "OpenAI-compatible memory middleware — drop-in for /v1/chat/completions", + version: "v0.4.0", + }, + { + date: "2026-06-07", + product: "kairu", + glyph: "▲", + color: "var(--color-konjo-warm)", + type: "perf", + title: "Speculative decoding: draft accept rate 0.74 — p99 falls from 12 → 8.2 ms", + version: "v0.6.0", + }, + { + date: "2026-06-06", + product: "toki", + glyph: "✕", + color: "var(--color-konjo-hot)", + type: "fix", + title: "DAN v4.3 jailbreak now blocked by classifier + fine-tune — 89% robustness", + version: "v0.5.0", + }, + { + date: "2026-06-05", + product: "drex", + glyph: "✦", + color: "var(--color-konjo-fg-muted)", + type: "feat", + title: "KAN readout layer integrated — 136 tests green across oxidizr + blazr crates", + version: "v0.2", + }, +]; + +const SHOW_INITIAL = 4; + +function EntryRow({ entry, index, reduce }: { entry: ChangelogEntry; index: number; reduce: boolean | null }) { + const [expanded, setExpanded] = useState(false); + + return ( + + {/* Timeline rail */} +
+
+
+
+ + {/* Content */} +
+
+ {/* Product badge */} + + {entry.glyph} + {entry.product} + + + {/* Type badge */} + + {TYPE_LABEL[entry.type]} + + + {/* Version */} + {entry.version && ( + + {entry.version} + + )} + + {/* Date — right-aligned on wider screens */} + + {entry.date} + +
+ +

{entry.title}

+ + {entry.detail && ( + <> + + + {expanded && ( + + {entry.detail} + + )} + + + )} +
+ + ); +} + +/** + * Vertical timeline of recent commits / releases across the KonjoAI portfolio. + * Expandable entries; "Show more" reveals the full log. + */ +export function ChangelogFeed() { + const reduce = useReducedMotion(); + const [showAll, setShowAll] = useState(false); + + const visible = showAll ? ENTRIES : ENTRIES.slice(0, SHOW_INITIAL); + + return ( +
+ +
+

+ sprint log · all nine products +

+ +

+ Every change passes typecheck, tests, and a konjo_review adversarial audit + before merging. The log proves it. +

+
+ + + github.com/konjoai ↗ + +
+ +
+ + {visible.map((entry, i) => ( + + ))} + + + {!showAll && ENTRIES.length > SHOW_INITIAL && ( + setShowAll(true)} + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + className="text-konjo-mono mt-1 ml-6 text-xs text-konjo-fg-faint transition-colors hover:text-konjo-fg focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-konjo-accent rounded" + > + Show {ENTRIES.length - SHOW_INITIAL} more entries ↓ + + )} +
+
+ ); +} diff --git a/apps/web/app/_components/CommandPalette.tsx b/apps/web/app/_components/CommandPalette.tsx index 029bd52..3aec402 100644 --- a/apps/web/app/_components/CommandPalette.tsx +++ b/apps/web/app/_components/CommandPalette.tsx @@ -3,9 +3,141 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { cn, ease, StatusBadge } from "@konjoai/ui"; +import { cn, ease, StatusBadge, severity as sevColor } from "@konjoai/ui"; import { PRODUCTS } from "@/lib/products"; +type RouterCtx = ReturnType; + +/** A global action item (non-product) in the palette. */ +type PaletteAction = { + id: string; + icon: string; + label: string; + description: string; + onRun: (ctx: { router: RouterCtx; close: () => void }) => void; +}; + +const GLOBAL_ACTIONS: PaletteAction[] = [ + { + id: "status", + icon: "◉", + label: "System status", + description: "Live health of all nine products", + onRun: ({ router, close }) => { close(); router.push("/status"); }, + }, + { + id: "keyboard", + icon: "⌨", + label: "Keyboard shortcuts", + description: "Open the shortcuts reference panel", + onRun: ({ close }) => { + close(); + document.dispatchEvent(new CustomEvent("konjo:open-keyboard")); + }, + }, + { + id: "portfolio", + icon: "↓", + label: "Scroll to portfolio", + description: "Jump to the nine-product grid", + onRun: ({ close }) => { + close(); + document.getElementById("projects")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "heatmap", + icon: "▦", + label: "Inference heatmap", + description: "9×24 live request-density grid", + onRun: ({ close }) => { + close(); + document.getElementById("heatmap")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "velocity", + icon: "⚡", + label: "Token velocity", + description: "Live tok/s per product with flip digits", + onRun: ({ close }) => { + close(); + document.getElementById("velocity")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "leaderboard", + icon: "◈", + label: "Live leaderboard", + description: "Product rankings that shift in real time", + onRun: ({ close }) => { + close(); + document.getElementById("leaderboard")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "signals", + icon: "〰", + label: "Signal monitor", + description: "Nine oscilloscope waveform channels", + onRun: ({ close }) => { + close(); + document.getElementById("signals")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "trace", + icon: "→", + label: "Pipeline trace", + description: "Live request flowing through 5 products", + onRun: ({ close }) => { + close(); + document.getElementById("trace")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "chat", + icon: "❖", + label: "Chat demo", + description: "Multi-turn conversation with memory + retrieval", + onRun: ({ close }) => { + close(); + document.getElementById("chat")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "agents", + icon: "⌬", + label: "Agent flow", + description: "lopi orchestrating four products in parallel and sequence", + onRun: ({ close }) => { + close(); + document.getElementById("agents")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "changelog", + icon: "◎", + label: "Changelog", + description: "Recent commits and releases across all nine products", + onRun: ({ close }) => { + close(); + document.getElementById("changelog")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + }, + { + id: "github", + icon: "↗", + label: "KonjoAI on GitHub", + description: "Open github.com/konjoai in a new tab", + onRun: ({ close }) => { close(); window.open("https://github.com/konjoai", "_blank", "noreferrer"); }, + }, +]; + +type PaletteItem = + | { kind: "product"; data: (typeof PRODUCTS)[number] } + | { kind: "action"; data: PaletteAction }; + /** Wraps the first occurrence of `query` inside `text` with an accent highlight span. */ function Highlight({ text, query }: { text: string; query: string }) { if (!query) return <>{text}; @@ -22,25 +154,50 @@ function Highlight({ text, query }: { text: string; query: string }) { /** * Keyboard-driven command palette — Cmd+K / Ctrl+K opens, Esc closes. - * Also responds to the "konjo:open-palette" custom DOM event so SiteNav - * can trigger it without shared state. + * Supports product navigation and global actions. Responds to the + * "konjo:open-palette" custom DOM event so SiteNav can trigger it + * without shared state. */ export function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [cursor, setCursor] = useState(0); + const [recent, setRecent] = useState([]); const inputRef = useRef(null); + const activeItemRef = useRef(null); const reduce = useReducedMotion(); const router = useRouter(); - const filtered = PRODUCTS.filter( - (p) => - query === "" || - p.name.toLowerCase().includes(query.toLowerCase()) || - p.tagline.toLowerCase().includes(query.toLowerCase()), + useEffect(() => { + if (!open) return; + try { + const raw = localStorage.getItem("konjo:recent"); + if (raw) setRecent(JSON.parse(raw) as string[]); + } catch { /* storage unavailable */ } + }, [open]); + + const q = query.toLowerCase(); + const filteredProducts = PRODUCTS.filter( + (p) => !q || p.name.toLowerCase().includes(q) || p.tagline.toLowerCase().includes(q), ); + const filteredActions = GLOBAL_ACTIONS.filter( + (a) => !q || a.label.toLowerCase().includes(q) || a.description.toLowerCase().includes(q), + ); + + const showRecent = !query && recent.length > 0; + const recentProducts = recent + .map((slug) => PRODUCTS.find((p) => p.slug === slug)) + .filter((p): p is (typeof PRODUCTS)[number] => !!p); + + // Flat list for keyboard cursor: recent products first (if visible), then filtered products, then actions + const displayList: PaletteItem[] = [ + ...(showRecent ? recentProducts : []).map((p): PaletteItem => ({ kind: "product", data: p })), + ...(showRecent ? PRODUCTS : filteredProducts).map((p): PaletteItem => ({ kind: "product", data: p })), + ...filteredActions.map((a): PaletteItem => ({ kind: "action", data: a })), + ]; useEffect(() => { setCursor(0); }, [query]); + useEffect(() => { activeItemRef.current?.scrollIntoView({ block: "nearest" }); }, [cursor]); useEffect(() => { function onKey(e: KeyboardEvent) { @@ -63,23 +220,39 @@ export function CommandPalette() { if (open) requestAnimationFrame(() => inputRef.current?.focus()); }, [open]); - function navigate(slug: string) { - setOpen(false); - router.push(`/products/${slug}`); + function close() { setOpen(false); } + + function runItem(item: PaletteItem) { + if (item.kind === "product") { + close(); + router.push(`/products/${item.data.slug}`); + } else { + item.data.onRun({ router, close }); + } } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); - setCursor((i) => Math.min(i + 1, filtered.length - 1)); + setCursor((i) => Math.min(i + 1, displayList.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setCursor((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter" && filtered[cursor]) { - navigate(filtered[cursor].slug); + } else if (e.key === "Enter" && displayList[cursor]) { + runItem(displayList[cursor]); } } + // Split display list back into sections for rendering (with labels) + const recentSection = showRecent ? recentProducts : []; + const productSection = showRecent ? PRODUCTS : filteredProducts; + const actionSection = filteredActions; + + // Base cursor offset for each section + const recentOffset = 0; + const productOffset = recentSection.length; + const actionOffset = productOffset + productSection.length; + return ( {open && ( @@ -98,7 +271,7 @@ export function CommandPalette() { setQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Search products…" + placeholder="Search products and actions…" className="text-konjo-mono min-w-0 flex-1 bg-transparent text-sm text-konjo-fg placeholder:text-konjo-fg-faint focus:outline-none" - aria-label="Search products" + aria-label="Search products and actions" aria-autocomplete="list" aria-controls="palette-list" /> + {query && ( + + {filteredProducts.length + filteredActions.length} + + )} Esc @@ -133,64 +315,150 @@ export function CommandPalette() {
    - {filtered.length === 0 ? ( + {/* Recently viewed */} + {recentSection.length > 0 && ( + <> +
  • + + Recently viewed + +
  • + {recentSection.map((p, i) => { + const idx = recentOffset + i; + return ( +
  • + +
  • + ); + })} +
  • +
  • + All products +
  • + + )} + + {/* Products */} + {productSection.length === 0 && actionSection.length === 0 ? (
  • - No products match "{query}" + No results for “{query}”
  • ) : ( - filtered.map((p, i) => ( -
  • - + {p.glyph} +
    +

    + +

    +

    + +

    +
    +
    + + {Number.isInteger(p.metric.value) ? p.metric.value : p.metric.value.toFixed(1)}{p.metric.unit} + + +
    + +
  • + ); + }) + )} + + {/* Actions */} + {actionSection.length > 0 && ( + <> +
  • +
  • + Actions
  • - )) + {actionSection.map((a, i) => { + const idx = actionOffset + i; + return ( +
  • + +
  • + ); + })} + )}
{/* Keyboard hints */}
- - ↑↓{" "} - navigate - - - {" "} - open - - - Esc{" "} - close - + ↑↓{" "}navigate + {" "}run + Esc{" "}close
diff --git a/apps/web/app/_components/ConstellationMap.tsx b/apps/web/app/_components/ConstellationMap.tsx index 1c726fd..83562d7 100644 --- a/apps/web/app/_components/ConstellationMap.tsx +++ b/apps/web/app/_components/ConstellationMap.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; -import { motion, useReducedMotion } from "motion/react"; +import { useState, useEffect } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; import { ease, severity as sevColor } from "@konjoai/ui"; import { PRODUCTS } from "@/lib/products"; +import { ScrambleText } from "./ScrambleText"; // ─── layout constants ───────────────────────────────────────────────────────── @@ -48,9 +49,30 @@ const EDGES: [number, number][] = [ /** Network graph of the nine KonjoAI products — hover to highlight connections. */ export function ConstellationMap() { const [active, setActive] = useState(null); + const [autoSlug, setAutoSlug] = useState(null); const reduce = useReducedMotion(); - const activeIdx = active ? SLUG_ORDER.indexOf(active) : -1; + // Auto-cycle through products when no node is being hovered + useEffect(() => { + if (reduce) return; + let i = 0; + const id = setInterval(() => { + if (!active) { + setAutoSlug(SLUG_ORDER[i % SLUG_ORDER.length]); + i++; + } + }, 2600); + return () => clearInterval(id); + }, [active, reduce]); + + // The "display" active node — user hover takes priority over auto-cycle + const displaySlug = active ?? autoSlug; + + const activeIdx = displaySlug ? SLUG_ORDER.indexOf(displaySlug) : -1; + // Use the active product's severity color for edges + particles + const activeCol = displaySlug + ? sevColor[PRODUCT_MAP[displaySlug].metric.severity] + : "rgba(124,58,237,0.6)"; const connectedIdx = new Set( activeIdx >= 0 ? EDGES.filter(([a, b]) => a === activeIdx || b === activeIdx).flatMap(([a, b]) => [a, b]) @@ -59,6 +81,7 @@ export function ConstellationMap() { return (
@@ -72,9 +95,12 @@ export function ConstellationMap() {

Nine products · one design system

-

- The constellation -

+

Hover any node to trace its connections. Click to dive in.

@@ -105,26 +131,103 @@ export function ConstellationMap() { {EDGES.map(([a, b], i) => { const na = NODES[a]; const nb = NODES[b]; - const lit = - activeIdx >= 0 && (a === activeIdx || b === activeIdx); + const lit = activeIdx >= 0 && (a === activeIdx || b === activeIdx); + const edgePath = `M${na.x.toFixed(1)},${na.y.toFixed(1)}L${nb.x.toFixed(1)},${nb.y.toFixed(1)}`; return ( - + + + {/* Data-packet particles race along each lit edge */} + {lit && !reduce && [0, 0.38, 0.72].map((offset) => ( + + + + ))} + ); })} + {/* Center hub — shows portfolio stats at rest, product info on hover */} + + {/* Slow rotating outer ring */} + {!reduce && ( + + )} + + + {/* Hub label — foreignObject for smooth AnimatePresence crossfade */} + + + {displaySlug ? ( + + + {PRODUCT_MAP[displaySlug].glyph} + + + {PRODUCT_MAP[displaySlug].metric.value}{PRODUCT_MAP[displaySlug].metric.unit} + + + {PRODUCT_MAP[displaySlug].metric.label.toUpperCase()} + + + ) : ( + + KonjoAI + 9 products + + )} + + + + {/* Nodes */} {NODES.map(({ slug, x, y, product }, i) => { const col = sevColor[product.metric.severity]; - const isActive = slug === active; + const isActive = slug === displaySlug; const isConnected = connectedIdx.has(i); - const dim = activeIdx >= 0 && !isActive && !isConnected; + // Only dim when the user is actively hovering (not during auto-cycle) + const dim = !!active && !isActive && !isConnected; return ( setActive(slug as SlugKey)} onMouseLeave={() => setActive(null)} > - + + {/* Gentle breathing ring when idle */} + {!isActive && !reduce && ( + + )} {/* Outer glow ring when active */} {isActive && ( { + if (reduce) return; + function onMove(e: MouseEvent) { + rawX.set(e.clientX); + rawY.set(e.clientY); + } + function onLeave() { + rawX.set(-800); + rawY.set(-800); + } + document.addEventListener("mousemove", onMove, { passive: true }); + document.addEventListener("mouseleave", onLeave, { passive: true }); + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseleave", onLeave); + }; + }, [reduce, rawX, rawY]); + + if (reduce) return null; + + return ( + + + + ); +} diff --git a/apps/web/app/_components/DemoChat.tsx b/apps/web/app/_components/DemoChat.tsx new file mode 100644 index 0000000..7450b17 --- /dev/null +++ b/apps/web/app/_components/DemoChat.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; +import { ease } from "@konjoai/ui"; +import { ScrambleText } from "./ScrambleText"; + +type Role = "user" | "assistant"; + +type ContextTag = { + source: "kohaku" | "kyro"; + text: string; +}; + +type Message = { + id: number; + role: Role; + content: string; + streaming?: boolean; + contextTags?: ContextTag[]; +}; + +const GLYPH_SOURCE: Record = { + kohaku: { glyph: "❖", color: "var(--color-konjo-good)", label: "memory" }, + kyro: { glyph: "✸", color: "var(--color-konjo-accent)", label: "retrieval" }, +}; + +type Turn = { + user: string; + assistantLines: string[]; + contextTags: ContextTag[]; + delayMs: number; +}; + +const SCRIPT: Turn[] = [ + { + user: "How does attention work in transformers?", + contextTags: [ + { source: "kyro", text: "Vaswani 2017 §3.2 — Scaled dot-product attention" }, + { source: "kohaku", text: "Recalled: user prefers concise technical answers" }, + ], + assistantLines: [ + "Attention computes a weighted sum of values, where weights come from comparing a query to keys.", + " The scaled dot-product: softmax(QK^T / √d_k)V.", + " Multi-head attention runs this h times in parallel — each head attends to different subspaces.", + ], + delayMs: 55, + }, + { + user: "What makes the scaling by √d_k important?", + contextTags: [ + { source: "kyro", text: "Vaswani 2017 §3.2.1 — Motivation for scaling factor" }, + { source: "kohaku", text: "Context: previous turn discussed dot-product attention" }, + ], + assistantLines: [ + "Without it, dot products grow large in high dimensions — the softmax saturates and gradients vanish.", + " Dividing by √d_k keeps values in a range where softmax returns meaningful gradients.", + ], + delayMs: 50, + }, + { + user: "Show me the squish implementation", + contextTags: [ + { source: "kyro", text: "squish README §Attention — MLX-native implementation" }, + { source: "kohaku", text: "Recalled: user is on Apple Silicon, prefers MLX path" }, + ], + assistantLines: [ + "In squish (MLX path):", + "\n scores = (q @ k.T) / math.sqrt(q.shape[-1])", + "\n attn = mx.softmax(scores, axis=-1)", + "\n out = attn @ v", + "\n\nThe Metal backend fuses the softmax and matmul for ~2× throughput vs eager mode.", + ], + delayMs: 42, + }, +]; + +const CHAR_MS_NORMAL = 22; + +let msgId = 0; + +function ContextBadge({ tag }: { tag: ContextTag }) { + const src = GLYPH_SOURCE[tag.source]; + return ( +
+ + {src.glyph} + +
+ + {src.label} + + + {tag.text} + +
+
+ ); +} + +function ChatMessage({ msg, reduce }: { msg: Message; reduce: boolean | null }) { + const isUser = msg.role === "user"; + return ( + + {/* Context tags — above assistant messages */} + {!isUser && msg.contextTags && msg.contextTags.length > 0 && ( +
+ {msg.contextTags.map((tag, i) => ( + + ))} +
+ )} + +
+ + {isUser ? "you" : "squish · mlx-4"} + + {msg.content} + {msg.streaming && ( + + )} +
+
+ ); +} + +/** + * Multi-turn chat demo showing squish inference + kohaku memory + kyro retrieval + * composing together. Context tags reveal which product contributed to each response. + */ +export function DemoChat() { + const reduce = useReducedMotion(); + const [messages, setMessages] = useState([]); + const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle"); + const [turnIdx, setTurnIdx] = useState(0); + const bottomRef = useRef(null); + const runRef = useRef(0); + const [inView, setInView] = useState(false); + const sectionRef = useRef(null); + + useEffect(() => { + const el = sectionRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { if (entry.isIntersecting) setInView(true); }, + { threshold: 0.15 }, + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + const streamAssistant = useCallback(( + turn: Turn, + run: number, + onDone: () => void, + ) => { + const id = ++msgId; + const charMs = reduce ? 0 : turn.delayMs; + const full = turn.assistantLines.join(""); + + setMessages((prev) => [...prev, { + id, role: "assistant", content: "", streaming: true, contextTags: turn.contextTags, + }]); + + if (reduce) { + setMessages((prev) => prev.map((m) => + m.id === id ? { ...m, content: full, streaming: false } : m, + )); + setTimeout(onDone, 80); + return; + } + + let charIdx = 0; + const tick = setInterval(() => { + if (runRef.current !== run) { clearInterval(tick); return; } + charIdx++; + setMessages((prev) => prev.map((m) => + m.id === id + ? { ...m, content: full.slice(0, charIdx), streaming: charIdx < full.length } + : m, + )); + if (charIdx >= full.length) { + clearInterval(tick); + setTimeout(onDone, 600); + } + }, charMs); + }, [reduce]); + + const playTurn = useCallback((idx: number, run: number) => { + if (idx >= SCRIPT.length || runRef.current !== run) return; + const turn = SCRIPT[idx]; + + // Add user message + setMessages((prev) => [...prev, { id: ++msgId, role: "user", content: turn.user }]); + setTurnIdx(idx); + + setTimeout(() => { + if (runRef.current !== run) return; + streamAssistant(turn, run, () => { + if (idx + 1 < SCRIPT.length) { + setTimeout(() => playTurn(idx + 1, run), 800); + } else { + setPhase("done"); + } + }); + }, 600); + }, [streamAssistant]); + + useEffect(() => { + if (!inView || phase !== "idle") return; + const run = ++runRef.current; + setPhase("playing"); + setTimeout(() => playTurn(0, run), 500); + }, [inView, phase, playTurn]); + + // Auto-scroll to bottom + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, [messages]); + + function replay() { + const run = ++runRef.current; + setMessages([]); + setTurnIdx(0); + setPhase("playing"); + setTimeout(() => playTurn(0, run), 300); + } + + return ( +
+ +
+

+ squish + kohaku + kyro · composed +

+ +

+ Every response is enriched: kyro retrieves relevant papers; kohaku injects + what it remembers about you. squish generates the answer. +

+
+ + {phase === "done" && ( + + )} +
+ + + {/* Header */} +
+ + + squish · mlx-4 · 42 tok/s + + + Turn {Math.min(turnIdx + 1, SCRIPT.length)} / {SCRIPT.length} + +
+ + {/* Chat body */} +
+ + {messages.map((msg) => ( + + ))} + +
+
+ + {/* Legend */} +
+ {Object.entries(GLYPH_SOURCE).map(([src, { glyph, color, label }]) => ( + + {glyph} {label} + + ))} + + tags show which product contributed + +
+ +
+ ); +} diff --git a/apps/web/app/_components/DesignPreview.tsx b/apps/web/app/_components/DesignPreview.tsx index 4d7c1a0..1710b52 100644 --- a/apps/web/app/_components/DesignPreview.tsx +++ b/apps/web/app/_components/DesignPreview.tsx @@ -1,12 +1,15 @@ "use client"; -import { motion, useReducedMotion } from "motion/react"; -import { ease } from "@konjoai/ui"; +import { useState, useRef, useCallback } from "react"; +import { motion, AnimatePresence, useReducedMotion, useMotionValue, useSpring } from "motion/react"; +import { ease, cn } from "@konjoai/ui"; import { StreamSection } from "./showcase/StreamSection"; import { MetricsSection } from "./showcase/MetricsSection"; import { ComplianceSection } from "./showcase/ComplianceSection"; import { RankingsSection } from "./showcase/RankingsSection"; import { ShellSection } from "./showcase/ShellSection"; +import { TokenPalette } from "./showcase/TokenPalette"; +import { ScrambleText } from "./ScrambleText"; type Block = { id: string; @@ -37,7 +40,7 @@ const BLOCKS: Block[] = [ { id: "compliance", title: "Compliance Monitor", - description: "EU AI Act article grid, cycling RiskRing re-assessments, score vs. threshold.", + description: "EU AI Act article grid, RiskRing re-assessments, score vs. threshold.", tag: "squash", live: true, Section: ComplianceSection, @@ -53,17 +56,62 @@ const BLOCKS: Block[] = [ { id: "shell", title: "Shell & Layout", - description: "Status indicators, feature tiles, and product hero — used across all nine product pages.", + description: "Status indicators, feature tiles, and product hero across all nine pages.", tag: "all products", live: false, Section: ShellSection, }, + { + id: "tokens", + title: "Design Tokens", + description: "The full color token palette — click any swatch to copy its CSS variable.", + tag: "@konjoai/ui · tokens", + live: false, + Section: TokenPalette, + }, ]; +/** Interactive tabbed showcase of the @konjoai/ui design system. */ export function DesignPreview() { const reduce = useReducedMotion(); + const [activeId, setActiveId] = useState("stream"); + const active = BLOCKS.find((b) => b.id === activeId) ?? BLOCKS[0]; + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + const panelRef = useRef(null); + const spotX = useMotionValue(0); + const spotY = useMotionValue(0); + const smoothSpotX = useSpring(spotX, { stiffness: 80, damping: 20 }); + const smoothSpotY = useSpring(spotY, { stiffness: 80, damping: 20 }); + + function handlePanelMouseMove(e: React.MouseEvent) { + if (reduce) return; + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + spotX.set(e.clientX - rect.left); + spotY.set(e.clientY - rect.top); + } + + function handlePanelMouseLeave() { + spotX.set(-999); + spotY.set(-999); + } + + const handleTabKeyDown = useCallback((e: React.KeyboardEvent, idx: number) => { + let nextIdx: number | null = null; + if (e.key === "ArrowRight") nextIdx = (idx + 1) % BLOCKS.length; + else if (e.key === "ArrowLeft") nextIdx = (idx - 1 + BLOCKS.length) % BLOCKS.length; + else if (e.key === "Home") nextIdx = 0; + else if (e.key === "End") nextIdx = BLOCKS.length - 1; + if (nextIdx !== null) { + e.preventDefault(); + setActiveId(BLOCKS[nextIdx].id); + tabRefs.current[nextIdx]?.focus(); + } + }, []); + return (
@@ -72,11 +120,11 @@ export function DesignPreview() { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6, ease: ease.kanjo }} - className="mb-14" + className="mb-8" > -
+

- @konjoai/ui · 14 components · 5 sections · all live + @konjoai/ui · 14 components · 6 sections · all live

-

- Live design system -

+

- Every primitive, alive. Five sections streaming real-time data — the shared visual language powering nine KonjoAI products. + Every primitive, alive. Five sections streaming real-time data — the shared visual language powering all nine products.

-
- {BLOCKS.map(({ id, title, description, tag, live, Section }, i) => ( + {/* Tab bar */} + + {BLOCKS.map((block, idx) => { + const isActive = block.id === activeId; + return ( + + ); + })} + + + {/* Active section description */} +
+

{active.description}

+ + {active.tag} + +
+ + {/* Tabpanel */} + + -
-
-
-

- {title} -

- {live && ( - - )} -
-

{description}

-
- - {tag} - -
- -
- + {/* Cursor spotlight overlay */} + {!reduce && ( + + )} + - ))} -
+ +
); } diff --git a/apps/web/app/_components/FloatingDock.tsx b/apps/web/app/_components/FloatingDock.tsx new file mode 100644 index 0000000..3779598 --- /dev/null +++ b/apps/web/app/_components/FloatingDock.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence, useReducedMotion } from "motion/react"; +import { ease } from "@konjoai/ui"; +import { PRODUCTS } from "@/lib/products"; + +const DOCK_ITEMS = [ + { id: "palette", label: "⌘", tooltip: "Command palette ⌘K" }, + { id: "random", label: "⚄", tooltip: "Surprise me" }, + { id: "top", label: "↑", tooltip: "Back to top" }, +] as const; + +type DockId = typeof DOCK_ITEMS[number]["id"]; + +interface DockButtonProps { + label: string; + tooltip: string; + onClick: () => void; + onMouseEnter: () => void; + magnified: boolean; + adjacent: boolean; +} + +/** Single dock button with tooltip, hover magnification, and ambient glow. */ +function DockButton({ label, tooltip, onClick, onMouseEnter, magnified, adjacent }: DockButtonProps) { + const scale = magnified ? 1.45 : adjacent ? 1.22 : 1; + return ( +
+ + {tooltip} + + + {label} + +
+ ); +} + +/** + * Floating action dock — appears after 400 px of scroll. + * Provides quick access to the command palette, a random product, and scroll-to-top. + * Buttons magnify on hover in a macOS-dock-style cascade. + */ +export function FloatingDock() { + const reduce = useReducedMotion(); + const [visible, setVisible] = useState(false); + const [hovered, setHovered] = useState(null); + const router = useRouter(); + + useEffect(() => { + const handler = () => setVisible(window.scrollY > 400); + window.addEventListener("scroll", handler, { passive: true }); + handler(); + return () => window.removeEventListener("scroll", handler); + }, []); + + function scrollToTop() { + window.scrollTo({ top: 0, behavior: reduce ? "instant" : "smooth" }); + } + function openPalette() { + document.dispatchEvent(new CustomEvent("konjo:open-palette")); + } + function randomProduct() { + const idx = Math.floor(Math.random() * PRODUCTS.length); + router.push(`/products/${PRODUCTS[idx].slug}`); + } + + const handlers: Record void> = { + palette: openPalette, + random: randomProduct, + top: scrollToTop, + }; + + const ids = DOCK_ITEMS.map((d) => d.id); + + return ( + + {visible && ( + setHovered(null)} + > + {DOCK_ITEMS.map((item) => { + const idx = ids.indexOf(item.id); + const hIdx = hovered ? ids.indexOf(hovered) : -1; + const dist = hIdx >= 0 ? Math.abs(idx - hIdx) : 999; + return ( + setHovered(item.id)} + /> + ); + })} + + )} + + ); +} diff --git a/apps/web/app/_components/Footer.tsx b/apps/web/app/_components/Footer.tsx index 680502c..a3ab0cc 100644 --- a/apps/web/app/_components/Footer.tsx +++ b/apps/web/app/_components/Footer.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { severity as sevColor } from "@konjoai/ui"; import { PRODUCTS } from "@/lib/products"; @@ -14,7 +15,20 @@ const STATUS_COLOR: Record = { export function Footer() { const year = new Date().getFullYear(); return ( -