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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user