diff --git a/.github/workflows/e2e_plano_tests.yml b/.github/workflows/e2e_plano_tests.yml index 7e8a5142..c6b8b281 100644 --- a/.github/workflows/e2e_plano_tests.yml +++ b/.github/workflows/e2e_plano_tests.yml @@ -30,7 +30,7 @@ jobs: - name: build arch docker image run: | - cd ../../ && docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3 -t katanemo/plano:latest + cd ../../ && docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4 -t katanemo/plano:latest - name: start plano env: diff --git a/.github/workflows/e2e_test_currency_convert.yml b/.github/workflows/e2e_test_currency_convert.yml index 508f284d..de0408db 100644 --- a/.github/workflows/e2e_test_currency_convert.yml +++ b/.github/workflows/e2e_test_currency_convert.yml @@ -24,7 +24,7 @@ jobs: - name: build plano docker image run: | - docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3 + docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4 - name: install uv run: curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/.github/workflows/e2e_test_preference_based_routing.yml b/.github/workflows/e2e_test_preference_based_routing.yml index 42347f0a..aaab4bd2 100644 --- a/.github/workflows/e2e_test_preference_based_routing.yml +++ b/.github/workflows/e2e_test_preference_based_routing.yml @@ -24,7 +24,7 @@ jobs: - name: build arch docker image run: | - docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3 + docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4 - name: install uv run: curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index f40b94ba..15e35e37 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -19,9 +19,8 @@ jobs: # Build and run the Docker container to generate the documentation - name: Build documentation using Docker run: | - cd ./docs - chmod +x build_docs.sh - ./build_docs.sh + chmod +x docs/build_docs.sh + sh docs/build_docs.sh - name: Copy CNAME to HTML Build Directory run: cp docs/CNAME docs/build/html/CNAME diff --git a/.github/workflows/validate_arch_config.yml b/.github/workflows/validate_arch_config.yml index 5085ea0c..9a95fc53 100644 --- a/.github/workflows/validate_arch_config.yml +++ b/.github/workflows/validate_arch_config.yml @@ -24,7 +24,7 @@ jobs: - name: build arch docker image run: | - docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3 + docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4 - name: validate arch config run: | diff --git a/.gitignore b/.gitignore index 00735b6c..c17af8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ apps/*/dist/ *.logs .cursor/ +.agents diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18ca8541..9f9b0b9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,8 +18,8 @@ Fork the repository to create your own version of **Plano**: Once you've forked the repository, clone it to your local machine (replace `` with your GitHub username): ```bash -$ git clone git@github.com:/plano.git -$ cd plano +git clone git@github.com:/plano.git +cd plano ``` ### 3. Add Upstream Remote @@ -27,15 +27,15 @@ $ cd plano Add the original repository as an upstream remote so you can keep your fork in sync: ```bash -$ git remote add upstream git@github.com:katanemo/plano.git +git remote add upstream git@github.com:katanemo/plano.git ``` To sync your fork with the latest changes from the main repository: ```bash -$ git fetch upstream -$ git checkout main -$ git merge upstream/main +git fetch upstream +git checkout main +git merge upstream/main ``` ### 4. Install Prerequisites @@ -43,7 +43,7 @@ $ git merge upstream/main **Install uv** (Python package manager for the planoai CLI): ```bash -$ curl -LsSf https://astral.sh/uv/install.sh | sh +curl -LsSf https://astral.sh/uv/install.sh | sh ``` **Install pre-commit hooks:** @@ -51,8 +51,8 @@ $ curl -LsSf https://astral.sh/uv/install.sh | sh Pre-commit hooks help maintain code quality by running automated checks before each commit. Install them with: ```bash -$ pip install pre-commit -$ pre-commit install +pip install pre-commit +pre-commit install ``` The pre-commit hooks will automatically run: @@ -66,18 +66,12 @@ The pre-commit hooks will automatically run: The planoai CLI is used to build, run, and manage Plano locally: ```bash -$ cd cli -$ uv sync +cd cli +uv sync ``` This creates a virtual environment in `.venv` and installs all dependencies. -Optionally, install planoai globally in editable mode: - -```bash -$ uv tool install --editable . -``` - Now you can use `planoai` commands from anywhere, or use `uv run planoai` from the `cli` directory. ### 6. Create a Branch @@ -85,7 +79,7 @@ Now you can use `planoai` commands from anywhere, or use `uv run planoai` from t Use a descriptive name for your branch (e.g., fix-bug-123, add-feature-x). ```bash -$ git checkout -b +git checkout -b ``` ### 7. Make Your Changes @@ -97,25 +91,25 @@ Make your changes in the relevant files. If you're adding new features or fixing **Run Rust tests:** ```bash -$ cd crates -$ cargo test +cd crates +cargo test ``` For library tests only: ```bash -$ cargo test --lib +cargo test --lib ``` **Run Python CLI tests:** ```bash -$ cd cli -$ uv run pytest +cd cli +uv run pytest ``` Or with verbose output: ```bash -$ uv run pytest -v +uv run pytest -v ``` **Run pre-commit checks manually:** @@ -123,18 +117,10 @@ $ uv run pytest -v Before committing, you can run all pre-commit checks manually: ```bash -$ pre-commit run --all-files +pre-commit run --all-files ``` -This ensures your code passes all checks before you commit. - -### 9. Push Changes and Create a Pull Request - -Once your changes are tested and committed: - -```bash -$ git push origin -``` +### 9. Push changes, and create a Pull request Go back to the original Plano repository, and you should see a "Compare & pull request" button. Click that to submit a Pull Request (PR). In your PR description, clearly explain the changes you made and why they are necessary. diff --git a/Dockerfile b/Dockerfile index 8a0ab81e..b3313856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # build docker image for arch gateway -FROM rust:1.92.0 AS builder +FROM rust:1.93.0 AS builder RUN rustup -v target add wasm32-wasip1 WORKDIR /arch COPY crates . diff --git a/apps/katanemo-www/.gitignore b/apps/katanemo-www/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/apps/katanemo-www/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/katanemo-www/biome.json b/apps/katanemo-www/biome.json new file mode 100644 index 00000000..41b3b952 --- /dev/null +++ b/apps/katanemo-www/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!node_modules", "!.next", "!dist", "!build"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noUnknownAtRules": "off" + } + }, + "domains": { + "next": "recommended", + "react": "recommended" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/apps/katanemo-www/next.config.ts b/apps/katanemo-www/next.config.ts new file mode 100644 index 00000000..3d092246 --- /dev/null +++ b/apps/katanemo-www/next.config.ts @@ -0,0 +1,33 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: [ + "@katanemo/ui", + "@katanemo/shared-styles", + "@katanemo/tailwind-config", + "@katanemo/tsconfig", + ], + experimental: { + externalDir: true, + }, + webpack: (config, { isServer }) => { + config.resolve.modules = [ + ...(config.resolve.modules || []), + "node_modules", + "../../node_modules", + ]; + + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + }; + } + return config; + }, + turbopack: { + resolveAlias: {}, + }, +}; + +export default nextConfig; diff --git a/apps/katanemo-www/package.json b/apps/katanemo-www/package.json new file mode 100644 index 00000000..af7b8cb6 --- /dev/null +++ b/apps/katanemo-www/package.json @@ -0,0 +1,33 @@ +{ + "name": "@katanemo/katanemo-www", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "biome check", + "format": "biome format --write", + "typecheck": "tsc --noEmit", + "clean": "rm -rf .next" + }, + "dependencies": { + "@katanemo/shared-styles": "*", + "@katanemo/ui": "*", + "next": "^16.1.6", + "react": "19.2.0", + "react-dom": "19.2.0" + }, + "devDependencies": { + "@biomejs/biome": "2.2.0", + "@katanemo/tailwind-config": "*", + "@katanemo/tsconfig": "*", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5" + } +} diff --git a/apps/katanemo-www/postcss.config.mjs b/apps/katanemo-www/postcss.config.mjs new file mode 100644 index 00000000..61e36849 --- /dev/null +++ b/apps/katanemo-www/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/apps/katanemo-www/public/KatanemoLogo.svg b/apps/katanemo-www/public/KatanemoLogo.svg new file mode 100644 index 00000000..59123685 --- /dev/null +++ b/apps/katanemo-www/public/KatanemoLogo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/apps/katanemo-www/public/logos/chase.svg b/apps/katanemo-www/public/logos/chase.svg new file mode 100644 index 00000000..40d2ae76 --- /dev/null +++ b/apps/katanemo-www/public/logos/chase.svg @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/katanemo-www/public/logos/hp.svg b/apps/katanemo-www/public/logos/hp.svg new file mode 100644 index 00000000..e53232c8 --- /dev/null +++ b/apps/katanemo-www/public/logos/hp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/katanemo-www/public/logos/huggingface.svg b/apps/katanemo-www/public/logos/huggingface.svg new file mode 100644 index 00000000..7e254084 --- /dev/null +++ b/apps/katanemo-www/public/logos/huggingface.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/katanemo-www/public/logos/sandisk.svg b/apps/katanemo-www/public/logos/sandisk.svg new file mode 100644 index 00000000..d5fe1092 --- /dev/null +++ b/apps/katanemo-www/public/logos/sandisk.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/katanemo-www/public/logos/tmobile.svg b/apps/katanemo-www/public/logos/tmobile.svg new file mode 100644 index 00000000..6616be40 --- /dev/null +++ b/apps/katanemo-www/public/logos/tmobile.svg @@ -0,0 +1,165 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/katanemo-www/src/app/globals.css b/apps/katanemo-www/src/app/globals.css new file mode 100644 index 00000000..235d1dcd --- /dev/null +++ b/apps/katanemo-www/src/app/globals.css @@ -0,0 +1,19 @@ +:root { + --katanemo-bg-start: #04171a; + --katanemo-bg-end: #0a292e; + --font-sans: var(--font-ibm-plex-sans); +} + +html, +body { + background: linear-gradient( + to bottom, + var(--katanemo-bg-start), + var(--katanemo-bg-end) + ); + min-height: 100%; +} + +body { + font-family: var(--font-ibm-plex-sans, var(--font-sans)); +} diff --git a/apps/katanemo-www/src/app/layout.tsx b/apps/katanemo-www/src/app/layout.tsx new file mode 100644 index 00000000..3b3ad84e --- /dev/null +++ b/apps/katanemo-www/src/app/layout.tsx @@ -0,0 +1,87 @@ +import type { Metadata } from "next"; +import Script from "next/script"; +import localFont from "next/font/local"; +import { siteConfig } from "../lib/metadata"; +import "@katanemo/shared-styles/globals.css"; +import "./globals.css"; + +const ibmPlexSans = localFont({ + src: [ + { + path: "../../../www/public/fonts/IBMPlexSans-VariableFont_wdth,wght.ttf", + weight: "100 700", + style: "normal", + }, + { + path: "../../../www/public/fonts/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf", + weight: "100 700", + style: "italic", + }, + ], + display: "swap", + variable: "--font-ibm-plex-sans", +}); + +const baseUrl = new URL(siteConfig.url); + +export const metadata: Metadata = { + title: `${siteConfig.name} - ${siteConfig.tagline}`, + description: siteConfig.description, + keywords: siteConfig.keywords, + metadataBase: baseUrl, + authors: siteConfig.authors, + creator: siteConfig.creator, + icons: { + icon: "/KatanemoLogo.svg", + }, + openGraph: { + type: "website", + locale: "en_US", + url: siteConfig.url, + title: `${siteConfig.name} - ${siteConfig.tagline}`, + description: siteConfig.description, + siteName: siteConfig.name, + images: [ + { + url: siteConfig.ogImage, + width: 1200, + height: 630, + alt: `${siteConfig.name} - ${siteConfig.tagline}`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: `${siteConfig.name} - ${siteConfig.tagline}`, + description: siteConfig.description, + images: [siteConfig.ogImage], + creator: "@katanemo", + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {/* Google tag (gtag.js) */} + +
{children}
+ + + ); +} diff --git a/apps/katanemo-www/src/app/page.tsx b/apps/katanemo-www/src/app/page.tsx new file mode 100644 index 00000000..eb3aa532 --- /dev/null +++ b/apps/katanemo-www/src/app/page.tsx @@ -0,0 +1,81 @@ +import Image from "next/image"; +import Link from "next/link"; +import LogoSlider from "../components/LogoSlider"; + +export default function HomePage() { + return ( +
+
+
+ Katanemo Logo +
+
+

+ Forward-deployed AI infrastructure engineers. +

+

+ Bringing industry-leading research and open-source technologies to + accelerate the development of AI agents. +

+

+ Trusted by leading companies to deliver agents to production. +

+ +
+ + Models Research + + ↗ + + + + Plano - Open Source Agent Infrastructure + + ↗ + + +
+
+
+ Move faster and more reliably by letting Katanemo do the + heavy-lifting. +
+ + Contact Us + +
+
+ © 2026 Katanemo Labs, Inc. +
+
+
+
+ Katanemo Logo +
+
+
+ ); +} diff --git a/apps/katanemo-www/src/components/LogoSlider.tsx b/apps/katanemo-www/src/components/LogoSlider.tsx new file mode 100644 index 00000000..bd267333 --- /dev/null +++ b/apps/katanemo-www/src/components/LogoSlider.tsx @@ -0,0 +1,608 @@ +"use client"; + +import Image from "next/image"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +export type LogoItem = + | { + node: React.ReactNode; + href?: string; + title?: string; + ariaLabel?: string; + } + | { + src: string; + alt?: string; + href?: string; + title?: string; + srcSet?: string; + sizes?: string; + width?: number; + height?: number; + }; + +export interface LogoLoopProps { + logos: LogoItem[]; + speed?: number; + direction?: "left" | "right" | "up" | "down"; + width?: number | string; + logoHeight?: number; + gap?: number; + pauseOnHover?: boolean; + hoverSpeed?: number; + fadeOut?: boolean; + fadeOutColor?: string; + scaleOnHover?: boolean; + renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode; + ariaLabel?: string; + className?: string; + style?: React.CSSProperties; +} + +const ANIMATION_CONFIG = { + SMOOTH_TAU: 0.25, + MIN_COPIES: 2, + COPY_HEADROOM: 2, +} as const; + +const toCssLength = (value?: number | string): string | undefined => + typeof value === "number" ? `${value}px` : (value ?? undefined); + +const cx = (...parts: Array) => + parts.filter(Boolean).join(" "); + +const isNodeItem = ( + item: LogoItem, +): item is Extract => "node" in item; + +const isImageItem = ( + item: LogoItem, +): item is Extract => "src" in item; + +const useResizeObserver = ( + callback: () => void, + elements: Array>, + dependencies: React.DependencyList, +) => { + useEffect(() => { + if (!window.ResizeObserver) { + const handleResize = () => callback(); + window.addEventListener("resize", handleResize); + callback(); + return () => window.removeEventListener("resize", handleResize); + } + + const observers: Array = []; + for (const ref of elements) { + if (!ref.current) { + observers.push(null); + continue; + } + const observer = new ResizeObserver(callback); + observer.observe(ref.current); + observers.push(observer); + } + + callback(); + + return () => { + for (const observer of observers) { + observer?.disconnect(); + } + }; + }, [callback, elements, ...elements, ...dependencies]); +}; + +const useImageLoader = ( + seqRef: React.RefObject, + onLoad: () => void, + dependencies: React.DependencyList, +) => { + useEffect(() => { + const images = seqRef.current?.querySelectorAll("img") ?? []; + + if (images.length === 0) { + onLoad(); + return; + } + + let remainingImages = images.length; + const handleImageLoad = () => { + remainingImages -= 1; + if (remainingImages === 0) { + onLoad(); + } + }; + + images.forEach((img) => { + const htmlImg = img as HTMLImageElement; + if (htmlImg.complete) { + handleImageLoad(); + } else { + htmlImg.addEventListener("load", handleImageLoad, { once: true }); + htmlImg.addEventListener("error", handleImageLoad, { once: true }); + } + }); + + return () => { + images.forEach((img) => { + img.removeEventListener("load", handleImageLoad); + img.removeEventListener("error", handleImageLoad); + }); + }; + }, [onLoad, seqRef, ...dependencies]); +}; + +const useAnimationLoop = ( + trackRef: React.RefObject, + targetVelocity: number, + seqWidth: number, + seqHeight: number, + isHovered: boolean, + hoverSpeed: number | undefined, + isVertical: boolean, +) => { + const rafRef = useRef(null); + const lastTimestampRef = useRef(null); + const offsetRef = useRef(0); + const velocityRef = useRef(0); + + useEffect(() => { + const track = trackRef.current; + if (!track) return; + + const prefersReduced = + typeof window !== "undefined" && + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + if (prefersReduced) { + track.style.transform = isVertical + ? "translate3d(0, 0, 0)" + : "translate3d(0, 0, 0)"; + return () => { + lastTimestampRef.current = null; + }; + } + + const animate = (timestamp: number) => { + if (lastTimestampRef.current === null) { + lastTimestampRef.current = timestamp; + } + + const deltaTime = + Math.max(0, timestamp - lastTimestampRef.current) / 1000; + lastTimestampRef.current = timestamp; + + const target = + isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; + + const easingFactor = + 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); + velocityRef.current += (target - velocityRef.current) * easingFactor; + + if (seqSize > 0) { + let nextOffset = offsetRef.current + velocityRef.current * deltaTime; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; + offsetRef.current = nextOffset; + + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + lastTimestampRef.current = null; + }; + }, [ + trackRef, + targetVelocity, + seqWidth, + seqHeight, + isHovered, + hoverSpeed, + isVertical, + ]); +}; + +export const LogoLoop = React.memo( + ({ + logos, + speed = 120, + direction = "left", + width = "100%", + logoHeight = 28, + gap = 32, + pauseOnHover, + hoverSpeed, + fadeOut = false, + fadeOutColor, + scaleOnHover = false, + renderItem, + ariaLabel = "Partner logos", + className, + style, + }) => { + const containerRef = useRef(null); + const trackRef = useRef(null); + const seqRef = useRef(null); + + const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); + const [copyCount, setCopyCount] = useState( + ANIMATION_CONFIG.MIN_COPIES, + ); + const [isHovered, setIsHovered] = useState(false); + + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === "up" || direction === "down"; + + const targetVelocity = useMemo(() => { + const magnitude = Math.abs(speed); + let directionMultiplier: number; + if (isVertical) { + directionMultiplier = direction === "up" ? 1 : -1; + } else { + directionMultiplier = direction === "left" ? 1 : -1; + } + const speedMultiplier = speed < 0 ? -1 : 1; + return magnitude * directionMultiplier * speedMultiplier; + }, [speed, direction, isVertical]); + + const updateDimensions = useCallback(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + if (isVertical) { + const parentHeight = + containerRef.current?.parentElement?.clientHeight ?? 0; + if (containerRef.current && parentHeight > 0) { + const targetHeight = Math.ceil(parentHeight); + if (containerRef.current.style.height !== `${targetHeight}px`) + containerRef.current.style.height = `${targetHeight}px`; + } + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const viewport = + containerRef.current?.clientHeight ?? + parentHeight ?? + sequenceHeight; + const copiesNeeded = + Math.ceil(viewport / sequenceHeight) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = + Math.ceil(containerWidth / sequenceWidth) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + }, [isVertical]); + + useResizeObserver( + updateDimensions, + [containerRef, seqRef], + [logos, gap, logoHeight, isVertical], + ); + + useImageLoader(seqRef, updateDimensions, [ + logos, + gap, + logoHeight, + isVertical, + ]); + + useAnimationLoop( + trackRef, + targetVelocity, + seqWidth, + seqHeight, + isHovered, + effectiveHoverSpeed, + isVertical, + ); + + const cssVariables = useMemo( + () => + ({ + "--logoloop-gap": `${gap}px`, + "--logoloop-logoHeight": `${logoHeight}px`, + ...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }), + }) as React.CSSProperties, + [gap, logoHeight, fadeOutColor], + ); + + const rootClasses = useMemo( + () => + cx( + "relative group", + isVertical + ? "overflow-hidden h-full inline-block" + : "overflow-x-hidden", + "[--logoloop-gap:32px]", + "[--logoloop-logoHeight:28px]", + "[--logoloop-fadeColorAuto:#ffffff]", + "dark:[--logoloop-fadeColorAuto:#0b0b0b]", + scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]", + className, + ), + [isVertical, scaleOnHover, className], + ); + + const handleMouseEnter = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); + const handleMouseLeave = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); + + const renderLogoItem = useCallback( + (item: LogoItem, key: React.Key) => { + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + const content = isNodeItem(item) ? ( + + {item.node} + + ) : ( + {item.alt + ); + + const itemAriaLabel = isNodeItem(item) + ? (item.ariaLabel ?? item.title) + : (item.alt ?? item.title); + + const inner = item.href ? ( + + {content} + + ) : ( + content + ); + + return ( +
  • + {inner} +
  • + ); + }, + [isVertical, scaleOnHover, renderItem], + ); + + const logoLists = useMemo( + () => + Array.from({ length: copyCount }, (_, copyIndex) => ( +
      0} + ref={copyIndex === 0 ? seqRef : undefined} + > + {logos.map((item, itemIndex) => + renderLogoItem(item, `${copyIndex}-${itemIndex}`), + )} +
    + )), + [copyCount, logos, renderLogoItem, isVertical], + ); + + const containerStyle = useMemo( + (): React.CSSProperties => ({ + width: isVertical + ? toCssLength(width) === "100%" + ? undefined + : toCssLength(width) + : (toCssLength(width) ?? "100%"), + ...cssVariables, + ...style, + }), + [width, cssVariables, style, isVertical], + ); + + return ( +
    + {fadeOut && + (isVertical ? ( + <> +
    +
    + + ) : ( + <> +
    +
    + + ))} + +
    + {logoLists} +
    +
    + ); + }, +); + +LogoLoop.displayName = "LogoLoop"; + +const logos: LogoItem[] = [ + { src: "/logos/chase.svg", alt: "Chase" }, + { src: "/logos/hp.svg", alt: "HP" }, + { src: "/logos/huggingface.svg", alt: "Hugging Face" }, + { src: "/logos/sandisk.svg", alt: "SanDisk" }, + { src: "/logos/tmobile.svg", alt: "T-Mobile" }, +]; + +export default function LogoSlider() { + return ( +
    + { + if (isImageItem(item)) { + return ( + {item.alt + ); + } + + return {item.node}; + }} + className="relative" + style={{ + WebkitMaskImage: + "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", + maskImage: + "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", + }} + /> +
    + ); +} diff --git a/apps/katanemo-www/src/lib/metadata.ts b/apps/katanemo-www/src/lib/metadata.ts new file mode 100644 index 00000000..d2d2ca51 --- /dev/null +++ b/apps/katanemo-www/src/lib/metadata.ts @@ -0,0 +1,48 @@ +const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://katanemo.com"; + +export const siteConfig = { + name: "Katanemo", + tagline: "Forward-deployed AI infrastructure engineers", + description: + "Forward-deployed AI infrastructure engineers delivering industry-leading research and open-source technologies for agentic AI development efforts.", + url: BASE_URL, + ogImage: `${BASE_URL}/KatanemoLogo.svg`, + links: { + docs: "https://docs.katanemo.com", + github: "https://github.com/katanemo/plano", + discord: "https://discord.gg/pGZf2gcwEc", + huggingface: "https://huggingface.co/katanemo", + }, + keywords: [ + "Katanemo AI", + "Katanemo", + "Katanemo Labs", + "forward-deployed AI engineers", + "forward deployed AI infrastructure", + "AI infrastructure engineers", + "embedded AI engineers", + "on-site AI engineers", + "model training", + "AI model research", + "LLM model training", + "machine learning model development", + "custom AI model training", + "open source agentic AI", + "agentic AI stack", + "AI agent infrastructure", + "open source agent framework", + "agentic AI development", + "AI agent platform", + "Plano agent infrastructure", + "LLM infrastructure", + "AI infrastructure platform", + "agentic AI tools", + "AI agent orchestration", + "open source AI stack", + "enterprise AI infrastructure", + "production AI systems", + "AI deployment infrastructure", + ], + authors: [{ name: "Katanemo", url: "https://github.com/katanemo/plano" }], + creator: "Katanemo", +}; diff --git a/apps/katanemo-www/tsconfig.json b/apps/katanemo-www/tsconfig.json new file mode 100644 index 00000000..7e8be6bf --- /dev/null +++ b/apps/katanemo-www/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "@katanemo/tsconfig/nextjs.json", + "compilerOptions": { + "jsx": "react-jsx", + "esModuleInterop": true, + "paths": { + "@/*": ["./src/*"], + "@katanemo/ui": ["../../packages/ui/src"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/apps/www/package.json b/apps/www/package.json index 8492b36f..849ac112 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -21,18 +21,25 @@ "@portabletext/react": "^5.0.0", "@portabletext/types": "^3.0.0", "@sanity/client": "^7.13.0", + "@sanity/code-input": "^6.0.4", "@sanity/image-url": "^1.2.0", + "@sanity/table": "^2.0.1", "@vercel/analytics": "^1.5.0", "csv-parse": "^6.1.0", + "easymde": "^2.20.0", "framer-motion": "^12.23.24", "jsdom": "^27.2.0", - "next": "^16.0.7", + "next": "^16.1.6", "next-sanity": "^11.6.9", "papaparse": "^5.5.3", "react": "19.2.0", "react-dom": "19.2.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.0", + "remark-gfm": "^4.0.1", "resend": "^6.6.0", "sanity": "^4.18.0", + "sanity-plugin-markdown": "^7.0.4", "styled-components": "^6.1.19" }, "devDependencies": { @@ -44,6 +51,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-syntax-highlighter": "^15.5.13", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" diff --git a/apps/www/sanity.config.ts b/apps/www/sanity.config.ts index 4db703f5..812e1ee7 100644 --- a/apps/www/sanity.config.ts +++ b/apps/www/sanity.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from "sanity"; +import { codeInput } from "@sanity/code-input"; +import { table } from "@sanity/table"; +import { markdownSchema } from "sanity-plugin-markdown"; import { structureTool } from "sanity/structure"; import { schemaTypes } from "./schemaTypes"; @@ -11,7 +14,7 @@ export default defineConfig({ basePath: "/studio", - plugins: [structureTool()], + plugins: [structureTool(), codeInput(), table(), markdownSchema()], schema: { types: schemaTypes, diff --git a/apps/www/schemaTypes/blogType.ts b/apps/www/schemaTypes/blogType.ts index 90a33870..286ed843 100644 --- a/apps/www/schemaTypes/blogType.ts +++ b/apps/www/schemaTypes/blogType.ts @@ -35,6 +35,45 @@ export const blogType = defineType({ { type: "block", }, + { + type: "code", + options: { + language: "typescript", + languageAlternatives: [ + { title: "TypeScript", value: "typescript" }, + { title: "JavaScript", value: "javascript" }, + { title: "HTML", value: "html" }, + { title: "CSS", value: "css" }, + { title: "Bash", value: "sh" }, + { title: "Python", value: "python" }, + { title: "Markdown", value: "markdown" }, + { title: "YAML", value: "yaml" }, + { title: "JSON", value: "json" }, + { title: "XML", value: "xml" }, + { title: "SQL", value: "sql" }, + { title: "Shell", value: "shell" }, + { title: "PowerShell", value: "powershell" }, + { title: "Batch", value: "batch" }, + ], + withFilename: true, + }, + }, + { + type: "object", + name: "markdownBlock", + title: "Markdown", + fields: [ + { + name: "markdown", + title: "Markdown", + type: "markdown", + description: "Markdown content with preview and image uploads", + }, + ], + }, + { + type: "table", + }, { type: "image", fields: [ diff --git a/apps/www/src/app/api/contact/route.ts b/apps/www/src/app/api/contact/route.ts index 6c80bf6f..cdb8484c 100644 --- a/apps/www/src/app/api/contact/route.ts +++ b/apps/www/src/app/api/contact/route.ts @@ -1,10 +1,10 @@ -import { Resend } from 'resend'; -import { NextResponse } from 'next/server'; +import { Resend } from "resend"; +import { NextResponse } from "next/server"; function getResendClient() { const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { - throw new Error('RESEND_API_KEY environment variable is not set'); + throw new Error("RESEND_API_KEY environment variable is not set"); } return new Resend(apiKey); } @@ -17,18 +17,24 @@ interface ContactPayload { lookingFor: string; } -function buildProperties(company?: string, lookingFor?: string): Record | undefined { +function buildProperties( + company?: string, + lookingFor?: string, +): Record | undefined { const properties: Record = {}; if (company) properties.company_name = company; if (lookingFor) properties.looking_for = lookingFor; return Object.keys(properties).length > 0 ? properties : undefined; } -function isDuplicateError(error: { message?: string; statusCode?: number | null }): boolean { - const errorMessage = error.message?.toLowerCase() || ''; +function isDuplicateError(error: { + message?: string; + statusCode?: number | null; +}): boolean { + const errorMessage = error.message?.toLowerCase() || ""; return ( - errorMessage.includes('already exists') || - errorMessage.includes('duplicate') || + errorMessage.includes("already exists") || + errorMessage.includes("duplicate") || error.statusCode === 409 ); } @@ -38,7 +44,7 @@ function createContactPayload( firstName: string, lastName: string, company?: string, - lookingFor?: string + lookingFor?: string, ) { const properties = buildProperties(company, lookingFor); return { @@ -53,50 +59,56 @@ function createContactPayload( export async function POST(req: Request) { try { const body = await req.json(); - const { firstName, lastName, email, company, lookingFor }: ContactPayload = body; + const { firstName, lastName, email, company, lookingFor }: ContactPayload = + body; if (!email || !firstName || !lastName || !lookingFor) { return NextResponse.json( - { error: 'Missing required fields' }, - { status: 400 } + { error: "Missing required fields" }, + { status: 400 }, ); } - const contactPayload = createContactPayload(email, firstName, lastName, company, lookingFor); + const contactPayload = createContactPayload( + email, + firstName, + lastName, + company, + lookingFor, + ); const resend = getResendClient(); const { data, error } = await resend.contacts.create(contactPayload); if (error) { if (isDuplicateError(error)) { - const { data: updateData, error: updateError } = await resend.contacts.update( - contactPayload - ); + const { data: updateData, error: updateError } = + await resend.contacts.update(contactPayload); if (updateError) { - console.error('Resend update error:', updateError); + console.error("Resend update error:", updateError); return NextResponse.json( - { error: updateError.message || 'Failed to update contact' }, - { status: 500 } + { error: updateError.message || "Failed to update contact" }, + { status: 500 }, ); } return NextResponse.json({ success: true, data: updateData }); } - console.error('Resend create error:', error); + console.error("Resend create error:", error); return NextResponse.json( - { error: error.message || 'Failed to create contact' }, - { status: error.statusCode || 500 } + { error: error.message || "Failed to create contact" }, + { status: error.statusCode || 500 }, ); } return NextResponse.json({ success: true, data }); } catch (error) { - console.error('Unexpected error:', error); + console.error("Unexpected error:", error); return NextResponse.json( - { error: error instanceof Error ? error.message : 'Unknown error' }, - { status: 500 } + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, ); } } diff --git a/apps/www/src/app/blog/[slug]/page.tsx b/apps/www/src/app/blog/[slug]/page.tsx index d2a53dc5..49c55955 100644 --- a/apps/www/src/app/blog/[slug]/page.tsx +++ b/apps/www/src/app/blog/[slug]/page.tsx @@ -1,9 +1,11 @@ import { client, urlFor } from "@/lib/sanity"; +import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import { PortableText } from "@/components/PortableText"; import { notFound } from "next/navigation"; import { UnlockPotentialSection } from "@/components/UnlockPotentialSection"; +import { createBlogPostMetadata } from "@/lib/metadata"; interface BlogPost { _id: string; @@ -67,6 +69,30 @@ export async function generateStaticParams() { return slugs.map((slug) => ({ slug })); } +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = await getBlogPost(slug); + + if (!post) { + return { + title: "Post Not Found | Plano Blog", + description: "The requested blog post could not be found.", + }; + } + + return createBlogPostMetadata({ + title: post.title, + description: post.summary, + slug: post.slug.current, + publishedAt: post.publishedAt, + author: post.author?.name, + }); +} + export default async function BlogPostPage({ params, }: { diff --git a/apps/www/src/app/blog/page.tsx b/apps/www/src/app/blog/page.tsx index 63d586bc..9b13c5e5 100644 --- a/apps/www/src/app/blog/page.tsx +++ b/apps/www/src/app/blog/page.tsx @@ -5,10 +5,9 @@ import { BlogHeader } from "@/components/BlogHeader"; import { FeaturedBlogCard } from "@/components/FeaturedBlogCard"; import { BlogCard } from "@/components/BlogCard"; import { BlogSectionHeader } from "@/components/BlogSectionHeader"; -export const metadata: Metadata = { - title: "Blog - Plano", - description: "Latest insights, updates, and stories from Plano", -}; +import { pageMetadata } from "@/lib/metadata"; + +export const metadata: Metadata = pageMetadata.blog; interface BlogPost { _id: string; diff --git a/apps/www/src/app/contact/ContactPageClient.tsx b/apps/www/src/app/contact/ContactPageClient.tsx new file mode 100644 index 00000000..45117f98 --- /dev/null +++ b/apps/www/src/app/contact/ContactPageClient.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@katanemo/ui"; +import { MessageSquare, Building2, MessagesSquare } from "lucide-react"; + +export default function ContactPageClient() { + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + company: "", + lookingFor: "", + message: "", + }); + const [status, setStatus] = useState< + "idle" | "submitting" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + + const handleChange = ( + e: React.ChangeEvent, + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus("submitting"); + setErrorMessage(""); + + try { + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Something went wrong"); + } + + setStatus("success"); + setFormData({ + firstName: "", + lastName: "", + email: "", + company: "", + lookingFor: "", + message: "", + }); + } catch (error) { + setStatus("error"); + setErrorMessage( + error instanceof Error ? error.message : "Failed to submit form", + ); + } + }; + + return ( +
    + {/* Hero / Header Section */} +
    +
    +

    + Let's start a + + conversation + +

    +

    + Whether you're an enterprise looking for a custom solution or a + developer building cool agents, we'd love to hear from you. +

    +
    +
    + + {/* Main Content - Split Layout */} +
    +
    +
    + {/* Left Side: Community (Discord) */} +
    + {/* Background icon */} +
    + +
    + +
    +
    +
    + + Community +
    +

    + Join Our Discord +

    +
    +

    + Connect with other developers, ask questions, share what + you're building, and stay updated on the latest features by + joining our Discord server. +

    +
    + + +
    + + {/* Right Side: Enterprise Contact */} +
    + {/* Subtle background pattern */} +
    + + {/* Background icon */} +
    + +
    + +
    +
    + + Enterprise +
    +

    + Contact Us +

    +
    + +
    + {status === "success" ? ( +
    +
    + + + +
    +
    + Message Sent! +
    +

    + Thank you for reaching out. We'll be in touch shortly. +

    + +
    + ) : ( +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + +