Skip to content
Merged
33 changes: 33 additions & 0 deletions apps/web/app/_components/AnimatedSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { motion } from "motion/react";
import { ease } from "@konjoai/ui";

interface AnimatedSectionProps {
children: React.ReactNode;
className?: string;
/** Extra entrance delay in seconds. */
delay?: number;
}

/**
* Transparent wrapper that animates children in as they enter the viewport.
* Renders as a `<section>` element; safe to use in server component pages.
*/
export function AnimatedSection({
children,
className,
delay = 0,
}: AnimatedSectionProps) {
return (
<motion.section
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.55, ease: ease.kanjo, delay }}
className={className}
>
{children}
</motion.section>
);
}
10 changes: 5 additions & 5 deletions apps/web/app/_components/DesignPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ const BLOCKS: Block[] = [
{
id: "compliance",
title: "Compliance Monitor",
description: "EU AI Act article grid, concentric risk arcs, score vs. threshold.",
description: "EU AI Act article grid, cycling RiskRing re-assessments, score vs. threshold.",
tag: "squash",
live: false,
live: true,
Section: ComplianceSection,
},
{
Expand Down Expand Up @@ -75,11 +75,11 @@ export function DesignPreview() {
>
<div className="flex items-center gap-3 mb-3">
<p className="text-konjo-mono text-xs uppercase tracking-[0.24em] text-konjo-accent">
@konjoai/ui · 14 components · 5 sections
@konjoai/ui · 14 components · 5 sections · all live
</p>
<span
className="text-konjo-mono inline-flex items-center gap-1.5 rounded-full border border-konjo-good/30 bg-konjo-good/10 px-2 py-0.5 text-[10px] uppercase tracking-widest text-konjo-good"
aria-label="Live data"
aria-label="All sections streaming live data"
>
<span className="konjo-pulse inline-block size-1.5 rounded-full bg-konjo-good" aria-hidden />
Live
Expand All @@ -89,7 +89,7 @@ export function DesignPreview() {
Live design system
</h2>
<p className="mt-2 max-w-xl text-sm text-konjo-fg-muted">
Every primitive, alive. The shared visual language powering eight KonjoAI products.
Every primitive, alive. Five sections streaming real-time data — the shared visual language powering nine KonjoAI products.
</p>
</motion.div>

Expand Down
15 changes: 15 additions & 0 deletions apps/web/app/_components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { PRODUCTS } from "@/lib/products";

const operational = PRODUCTS.filter((p) => p.status === "operational").length;

/** Site footer with portfolio stat and navigation links. */
export function Footer() {
const year = new Date().getFullYear();
return (
Expand All @@ -10,9 +15,19 @@ export function Footer() {
<p className="text-konjo-mono mt-1 text-xs text-konjo-fg-faint">
ቆንጆ · 根性 · 康宙 · 건조
</p>
<p className="text-konjo-mono mt-2 text-[11px] text-konjo-fg-faint">
<span className="text-konjo-good">{operational}</span>
<span className="text-konjo-fg-faint"> / {PRODUCTS.length} systems operational</span>
</p>
</div>

<nav className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<a
href="/#projects"
className="text-konjo-fg-muted transition-colors hover:text-konjo-fg"
>
Products
</a>
<a
href="/status"
className="text-konjo-fg-muted transition-colors hover:text-konjo-fg"
Expand Down
86 changes: 74 additions & 12 deletions apps/web/app/_components/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,83 @@
"use client";

import { motion } from "motion/react";
import { motion, animate, useInView, useReducedMotion } from "motion/react";
import { useState, useEffect, useRef } from "react";
import { ease } from "@konjoai/ui";

const STATS = [
{ value: "14", label: "components", color: "text-konjo-accent" },
{ value: "9", label: "products", color: "text-konjo-violet" },
{ value: "79", label: "tests", color: "text-konjo-good" },
{ value: "v0.2", label: "current", color: "text-konjo-warm" },
type Stat = { display: string; label: string; color: string; countTo?: number };

const STATS: Stat[] = [
{ display: "14", countTo: 14, label: "components", color: "text-konjo-accent" },
{ display: "9", countTo: 9, label: "products", color: "text-konjo-violet" },
{ display: "79", countTo: 79, label: "tests", color: "text-konjo-good" },
{ display: "v0.2", label: "current", color: "text-konjo-warm" },
];

/** Positions, durations, and delays are fixed so SSR and client agree. */
const GLYPH_CONFIG = [
{ glyph: "◐", x: "6%", top: "22%", dur: 3.8, delay: 0.0 },
{ glyph: "◇", x: "89%", top: "14%", dur: 4.5, delay: 0.5 },
{ glyph: "✸", x: "77%", top: "68%", dur: 4.2, delay: 0.9 },
{ glyph: "▲", x: "12%", top: "74%", dur: 3.6, delay: 1.4 },
{ glyph: "⬡", x: "48%", top: "88%", dur: 5.0, delay: 0.3 },
{ glyph: "◈", x: "93%", top: "44%", dur: 4.0, delay: 1.0 },
] as const;

/** Softly breathing product glyphs — constellation backdrop for the hero. */
function FloatingGlyphs() {
const reduce = useReducedMotion();
if (reduce) return null;

return (
<div aria-hidden className="pointer-events-none absolute inset-0 select-none overflow-hidden">
{GLYPH_CONFIG.map(({ glyph, x, top, dur, delay }) => (
<motion.span
key={glyph}
className="absolute text-2xl sm:text-3xl"
style={{ left: x, top, color: "var(--color-konjo-violet)" }}
initial={{ opacity: 0 }}
animate={{ y: [0, -14, 0], opacity: [0.07, 0.16, 0.07] }}
transition={{ duration: dur, delay, repeat: Infinity, ease: "easeInOut" }}
>
{glyph}
</motion.span>
))}
</div>
);
}

/** Counts from 0 to `stat.countTo` once the element enters the viewport. */
function AnimatedStat({ stat }: { stat: Stat }) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true });
const [display, setDisplay] = useState(stat.countTo !== undefined ? "0" : stat.display);

useEffect(() => {
if (!inView || stat.countTo === undefined) return;
const controls = animate(0, stat.countTo, {
duration: 1.2,
ease: "easeOut",
onUpdate: (v) => setDisplay(String(Math.round(v))),
});
return () => controls.stop();
}, [inView, stat.countTo]);

return (
<div className="flex items-baseline gap-1">
<span ref={ref} className={`font-semibold tabular-nums ${stat.color}`}>
{display}
</span>
<span className="text-konjo-fg-faint">{stat.label}</span>
</div>
);
}

/** Homepage hero — animated headline, count-up stats, floating glyph constellation. */
export function Hero() {
return (
<section className="relative mx-auto flex max-w-6xl flex-col items-center px-6 pt-28 pb-20 text-center sm:pt-36 sm:pb-28">
<FloatingGlyphs />

<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
Expand All @@ -31,7 +96,7 @@ export function Hero() {
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: ease.kanjo, delay: 0.05 }}
className="text-konjo-display text-konjo-gradient text-6xl font-semibold tracking-tight sm:text-8xl"
className="text-konjo-display text-konjo-gradient-flow text-6xl font-semibold tracking-tight sm:text-8xl"
>
KonjoAI
</motion.h1>
Expand Down Expand Up @@ -85,11 +150,8 @@ export function Hero() {
className="text-konjo-mono mt-10 flex flex-wrap items-center justify-center gap-6 text-xs"
aria-label="Design system stats"
>
{STATS.map((s, i) => (
<div key={i} className="flex items-baseline gap-1">
<span className={`font-semibold tabular-nums ${s.color}`}>{s.value}</span>
<span className="text-konjo-fg-faint">{s.label}</span>
</div>
{STATS.map((s) => (
<AnimatedStat key={s.label} stat={s} />
))}
</motion.dl>
</section>
Expand Down
60 changes: 51 additions & 9 deletions apps/web/app/_components/ProjectGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
"use client";

import { motion } from "motion/react";
import { useRef } from "react";
import { motion, useMotionValue, useSpring, useReducedMotion } from "motion/react";
import { ease, StatusBadge, severity as sevColor } from "@konjoai/ui";
import { PRODUCTS, type Product } from "@/lib/products";

/** Portfolio grid — nine animated product cards with 3-D tilt on hover. */
export function ProjectGrid() {
return (
<section id="projects" className="mx-auto max-w-6xl px-6 pb-24">
<div className="mb-10 flex items-end justify-between gap-6">
<motion.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.55, ease: ease.kanjo }}
className="mb-10 flex items-end justify-between gap-6"
>
<div>
<h2 className="text-konjo-display text-3xl font-semibold tracking-tight sm:text-4xl">
The portfolio
Expand All @@ -19,7 +27,7 @@ export function ProjectGrid() {
<div className="text-konjo-mono hidden text-xs text-konjo-fg-faint sm:block">
{PRODUCTS.length.toString().padStart(2, "0")} / {PRODUCTS.length.toString().padStart(2, "0")}
</div>
</div>
</motion.div>

<ul role="list" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PRODUCTS.map((p, i) => (
Expand All @@ -30,24 +38,55 @@ export function ProjectGrid() {
);
}

/** Single product card with entrance animation and 3-D perspective tilt on hover. */
function ProjectCard({ project, index }: { project: Product; index: number }) {
const reduce = useReducedMotion();
const cardRef = useRef<HTMLLIElement>(null);
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const rotateX = useSpring(rawX, { stiffness: 300, damping: 25 });
const rotateY = useSpring(rawY, { stiffness: 300, damping: 25 });

const metricColor = sevColor[project.metric.severity];
const metricDisplay =
Number.isInteger(project.metric.value)
? String(project.metric.value)
: project.metric.value.toFixed(1);
const metricDisplay = Number.isInteger(project.metric.value)
? String(project.metric.value)
: project.metric.value.toFixed(1);

function handleMouseMove(e: React.MouseEvent<HTMLLIElement>) {
if (reduce) return;
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return;
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
rawY.set(x * 8);
rawX.set(-y * 6);
}

function handleMouseLeave() {
rawX.set(0);
rawY.set(0);
}

return (
<motion.li
ref={cardRef}
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={reduce ? undefined : {
y: -4,
boxShadow: "0 0 0 1px rgba(124,58,237,0.35), 0 0 40px -6px rgba(124,58,237,0.18)",
transition: { duration: 0.2, ease: ease.nehan },
}}
viewport={{ once: true, margin: "-60px" }}
transition={{
duration: 0.45,
ease: ease.kanjo,
delay: Math.min(index * 0.04, 0.32),
}}
className="group glass-konjo rounded-konjo-lg relative overflow-hidden p-6 transition-all duration-300 hover:-translate-y-0.5"
style={{ rotateX, rotateY, transformPerspective: 800 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="group glass-konjo rounded-konjo-lg relative overflow-hidden p-6 transition-colors duration-300"
>
{/* Top shimmer on hover */}
<div
Expand Down Expand Up @@ -89,7 +128,10 @@ function ProjectCard({ project, index }: { project: Product; index: number }) {
</p>

{/* Headline metric */}
<div className="mt-4 flex items-baseline gap-1.5" aria-label={`${project.metric.label}: ${metricDisplay}${project.metric.unit}`}>
<div
className="mt-4 flex items-baseline gap-1.5"
aria-label={`${project.metric.label}: ${metricDisplay}${project.metric.unit}`}
>
<span
className="text-konjo-display text-2xl font-semibold tabular-nums leading-none"
style={{ color: metricColor }}
Expand Down
23 changes: 23 additions & 0 deletions apps/web/app/_components/ScrollProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useScroll, motion } from "motion/react";

/**
* Thin brand-gradient line fixed to the top of the viewport that fills
* left-to-right as the user scrolls down the page.
*/
export function ScrollProgressBar() {
const { scrollYProgress } = useScroll();

return (
<motion.div
className="pointer-events-none fixed top-0 left-0 right-0 z-50 h-[2px] origin-left"
style={{
scaleX: scrollYProgress,
background:
"linear-gradient(90deg, var(--color-konjo-brand), var(--color-konjo-violet))",
}}
aria-hidden
/>
);
}
Loading
Loading