feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import type { MascotScene } from "./scene";
import styles from "./styles.module.css";
declare global {
interface Window {
Kapa?: ((method: "open", options: { mode: "ai" }) => void) & {
open?: (options: { mode: "ai" }) => void;
};
}
}
export default function DocsMascot() {
const host = useRef<HTMLSpanElement>(null);
const scene = useRef<MascotScene | null>(null);
const drag = useRef({ x: 0, y: 0, active: false, moved: false });
const [ready, setReady] = useState(false);
const [message, setMessage] = useState("");
const logoUrl = "/img/mascot/homarr.svg";
const fallbackUrl = "/img/logo.png";
useEffect(() => {
const controller = new AbortController();
let dispose: (() => void) | undefined;
async function load() {
try {
const { createMascotScene } = await import("./scene");
const response = await fetch(logoUrl, { signal: controller.signal });
if (!response.ok) throw new Error("Logo unavailable");
const svg = await response.text();
if (controller.signal.aborted || !host.current) return;
const model = createMascotScene(host.current, svg);
scene.current = model;
dispose = model.dispose;
setReady(true);
} catch {
// The static logo remains a working chat launcher without WebGL.
}
}
void load();
return () => {
controller.abort();
dispose?.();
scene.current = null;
};
}, [logoUrl]);
function openChat() {
if (drag.current.moved) {
drag.current.moved = false;
return;
}
scene.current?.wink();
if (!window.Kapa || !window.Kapa.open) {
setMessage("Chat is still loading. Try again in a moment.");
return;
}
setMessage("");
window.Kapa("open", { mode: "ai" });
}
function startDrag(event: PointerEvent<HTMLButtonElement>) {
if (event.button !== 0) return;
drag.current = { x: event.clientX, y: event.clientY, active: true, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
}
function moveDrag(event: PointerEvent<HTMLButtonElement>) {
if (!drag.current.active) return;
const dx = event.clientX - drag.current.x;
const dy = event.clientY - drag.current.y;
if (!drag.current.moved && Math.hypot(dx, dy) < 5) return;
drag.current.moved = true;
scene.current?.rotate(dx * 0.018, dy * 0.012);
drag.current.x = event.clientX;
drag.current.y = event.clientY;
}
function keyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "Enter" || event.key === " ") drag.current.moved = false;
const model = scene.current;
if (!model) return;
switch (event.key) {
case "ArrowLeft":
model.rotate(-0.25, 0);
break;
case "ArrowRight":
model.rotate(0.25, 0);
break;
case "ArrowUp":
model.rotate(0, -0.15);
break;
case "ArrowDown":
model.rotate(0, 0.15);
break;
case "w":
model.wink();
break;
case "r":
model.reset();
break;
default:
return;
}
event.preventDefault();
}
return (
<div className={styles.mascot}>
<button
type="button"
className={styles.launcher}
aria-label="Ask AI"
onClick={openChat}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerEnter={() => scene.current?.setHovered(true)}
onPointerLeave={() => scene.current?.setHovered(false)}
onPointerUp={() => {
drag.current.active = false;
}}
onPointerCancel={() => {
drag.current.active = false;
}}
onLostPointerCapture={() => {
drag.current.active = false;
}}
onKeyDown={keyDown}
>
<span className={styles.model} ref={host} aria-hidden="true">
{!ready && <img src={fallbackUrl} alt="" draggable={false} />}
</span>
<span className={styles.label}>Ask AI</span>
</button>
{message && <output className={styles.status}>{message}</output>}
</div>
);
}
@@ -0,0 +1,177 @@
import * as THREE from "three";
import { initialConfig } from "../../../../../tools/motion-reel/three/lab-types";
import { extrudeSvg } from "../../../../../tools/motion-reel/three/logo-rig";
export interface MascotScene {
rotate: (x: number, y: number) => void;
wink: () => void;
setHovered: (hovered: boolean) => void;
reset: () => void;
dispose: () => void;
}
export function createMascotScene(host: HTMLElement, svg: string): MascotScene {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 0, 12);
scene.add(new THREE.HemisphereLight(0xffffff, 0x57343e, 2.4));
const key = new THREE.DirectionalLight(0xfff1e7, 4);
key.position.set(-3, 5, 7);
scene.add(key);
const rim = new THREE.DirectionalLight(0xb3caff, 3);
rim.position.set(4, 1, -3);
scene.add(rim);
const { model, rig } = extrudeSvg(svg, initialConfig);
const pivot = new THREE.Group();
pivot.add(model);
scene.add(pivot);
host.appendChild(renderer.domElement);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let visible = true;
let disposed = false;
let frame = 0;
let lastRender = 0;
let yaw = -0.28;
let pitch = -0.1;
let hovered = false;
let greetingStarted = -1000;
let winkStarted = -1000;
let nextWink = performance.now() + 6500;
function draw(now: number) {
frame = 0;
if (disposed || document.hidden || !visible) return;
if (now - lastRender >= 32 || reducedMotion.matches) {
const delta = Math.min((now - lastRender) / 1000, 0.1);
lastRender = now;
let wink = 1;
if (!reducedMotion.matches) {
if (!hovered) yaw += delta * 0.28;
if (now > nextWink) {
winkStarted = now;
nextWink = now + 8000 + Math.random() * 6000;
}
const progress = (now - winkStarted) / 420;
if (progress >= 0 && progress <= 1) wink = 1 - Math.sin(progress * Math.PI) * 0.94;
}
pivot.rotation.set(pitch, yaw, 0);
const eye = rig.eyes[1];
if (eye) eye.scale.y = wink;
// Reuse the closing-claw and 3D antenna motion from the reel's poseLobster loop.
let motionStrength = 0.15;
const greetingProgress = (now - greetingStarted) / 700;
let greeting = 0;
if (greetingProgress >= 0 && greetingProgress <= 1) greeting = Math.sin(greetingProgress * Math.PI);
if (reducedMotion.matches) {
motionStrength = 0;
greeting = 0;
}
const t = now / 1000;
for (const claw of rig.claws) {
let offset = 0;
if (claw.side === 1) offset = 0.55;
const cycle = (1 - Math.cos(t * initialConfig.clawSpeed * initialConfig.clawFrequency * 2.2 + offset)) / 2;
const closure =
Math.pow(cycle, 1.8) * 0.6 * initialConfig.clawStrength * 1.25 * motionStrength + greeting * 0.025;
claw.middle.rotation.z = -claw.side * closure * 2.1;
claw.tip.rotation.z = -claw.side * closure;
claw.shoulder.rotation.z = claw.side * (Math.sin(t * 1.4 + offset) * 0.06 * motionStrength + greeting * 0.04);
}
for (const antenna of rig.antennae) {
let offset = 0;
if (antenna.side === 1) offset = 0.55;
const strength = initialConfig.antennaStrength * 1.4 * motionStrength;
const phase = t * initialConfig.antennaSpeed * 3.4 + offset;
antenna.middle.rotation.z = Math.sin(phase) * strength * 0.7;
antenna.tip.rotation.z = Math.sin(phase - 0.85) * strength * 1.25;
antenna.middle.rotation.x = Math.sin(phase * 0.72) * strength * 0.2;
antenna.tip.rotation.x = Math.sin(phase * 0.72 - 0.65) * strength * 0.45;
}
renderer.render(scene, camera);
}
if (!reducedMotion.matches) frame = requestAnimationFrame(draw);
}
function wake() {
if (disposed || frame || document.hidden || !visible) return;
lastRender = 0;
frame = requestAnimationFrame(draw);
}
function pause() {
cancelAnimationFrame(frame);
frame = 0;
}
function onVisibility() {
if (document.hidden) pause();
else wake();
}
const resize = new ResizeObserver(() => {
renderer.setSize(host.clientWidth, host.clientHeight, false);
camera.aspect = host.clientWidth / Math.max(host.clientHeight, 1);
camera.updateProjectionMatrix();
wake();
});
resize.observe(host);
const intersection = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) wake();
else pause();
});
intersection.observe(host);
document.addEventListener("visibilitychange", onVisibility);
reducedMotion.addEventListener("change", wake);
wake();
return {
rotate(x, y) {
yaw += x;
pitch += y;
wake();
},
setHovered(value) {
if (value && !hovered && !reducedMotion.matches) {
greetingStarted = performance.now();
winkStarted = greetingStarted;
nextWink = greetingStarted + 10000;
}
hovered = value;
wake();
},
wink() {
if (reducedMotion.matches) return;
winkStarted = performance.now();
wake();
},
reset() {
yaw = -0.28;
pitch = -0.1;
wake();
},
dispose() {
disposed = true;
pause();
resize.disconnect();
intersection.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
reducedMotion.removeEventListener("change", wake);
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.geometry.dispose();
const materials = [object.material].flat();
for (const material of materials) material.dispose();
if (object instanceof THREE.SkinnedMesh) object.skeleton.dispose();
});
renderer.dispose();
renderer.domElement.remove();
},
};
}
@@ -0,0 +1,81 @@
.mascot {
position: fixed;
bottom: max(6px, env(safe-area-inset-bottom));
right: max(6px, env(safe-area-inset-right));
z-index: 40;
}
.launcher {
display: flex;
flex-direction: column;
align-items: center;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--color-fd-foreground);
font: inherit;
cursor: pointer;
touch-action: none;
user-select: none;
}
.launcher:active {
cursor: grabbing;
}
.launcher:focus-visible {
outline: 2px solid var(--color-fd-primary);
outline-offset: 2px;
}
.model {
display: block;
width: 68px;
height: 68px;
}
.model canvas {
display: block;
width: 100%;
height: 100%;
}
.model img {
width: 100%;
height: 100%;
padding: 8px;
object-fit: contain;
}
.label {
margin-top: -5px;
font-size: 10px;
font-weight: 600;
line-height: 16px;
padding: 2px 9px;
border: 1px solid var(--color-fd-border);
border-radius: 999px;
background: var(--color-fd-background);
}
.status {
position: absolute;
bottom: 4px;
right: calc(100% + 6px);
width: max-content;
max-width: min(240px, calc(100vw - 90px));
padding: 6px 8px;
border: 1px solid var(--color-fd-border);
border-radius: 8px;
background: var(--color-fd-popover);
color: var(--color-fd-popover-foreground);
font-size: 11px;
}
.status {
bottom: calc(100% + 4px);
right: 0;
}
@media (max-width: 600px) {
.model {
width: 56px;
height: 56px;
}
}