feat(branding): implement hand-drawn Copic marker, strikethrough, and pen circle effects; redesign guarantee section with animated signature
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
ParticleNetwork,
|
||||
GridLines,
|
||||
} from "../../src/components/Landing";
|
||||
import { Signature } from "../../src/components/Signature";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
H1,
|
||||
@@ -115,10 +116,8 @@ export default function AboutPage() {
|
||||
Agenturen, Konzerne, Startups – ich habe die Branche von allen
|
||||
Seiten kennengelernt. Was hängen geblieben ist:{" "}
|
||||
<span className="text-slate-900">
|
||||
<Marker delay={0.2} color="rgba(148,163,184,0.15)">
|
||||
Ergebnisse zählen.
|
||||
</Marker>{" "}
|
||||
Nicht der Weg dorthin.
|
||||
<Marker delay={0.2}>Ergebnisse</Marker> zählen. Nicht der
|
||||
Weg dorthin.
|
||||
</span>
|
||||
</LeadText>
|
||||
<IconList className="space-y-4">
|
||||
@@ -228,53 +227,68 @@ export default function AboutPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 03: Philosophie – what drives me */}
|
||||
<Section number="03" title="Philosophie" borderTop>
|
||||
<div className="space-y-12 md:space-y-16">
|
||||
{/* Section 03: Garantie – The Pledge */}
|
||||
<Section number="03" title="Garantie" borderTop>
|
||||
<div className="relative">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Ich stehe für <br />
|
||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<div className="max-w-4xl text-left space-y-12 md:space-y-16 py-8 md:py-16">
|
||||
<H3 className="text-3xl md:text-6xl leading-tight">
|
||||
Ich stehe für <br />
|
||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||
</H3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-start">
|
||||
<div className="space-y-8 min-w-0">
|
||||
<Reveal delay={0.1}>
|
||||
<LeadText className="text-lg md:text-xl text-slate-400">
|
||||
Keine Hierarchien, keine Ausreden. Wenn etwas nicht passt,
|
||||
liegt die Verantwortung bei mir – und ich{" "}
|
||||
<span className="text-slate-900">
|
||||
<Marker color="rgba(255,235,59,0.5)">löse es.</Marker>
|
||||
<div className="prose prose-lg md:prose-2xl text-slate-500 leading-relaxed">
|
||||
<p>
|
||||
Keine Hierarchien. Keine Ausreden. Wenn etwas nicht passt,
|
||||
liegt die Verantwortung bei mir.
|
||||
</p>
|
||||
<p>
|
||||
Ich liefere nicht nur Code, sondern{" "}
|
||||
<span className="text-slate-900 font-medium relative inline-block">
|
||||
Ergebnisse
|
||||
<svg
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-blue-500/30"
|
||||
viewBox="0 0 100 10"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
d="M0 5 Q 50 10 100 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[
|
||||
"Vollständige Transparenz",
|
||||
"Ein Ansprechpartner",
|
||||
"Messbare Qualität",
|
||||
"Langfristige Partnerschaft",
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.2 + i * 0.05}>
|
||||
<div className="flex items-center gap-3 group">
|
||||
<div className="w-6 h-6 rounded-full bg-white border border-slate-200 flex items-center justify-center shrink-0 group-hover:bg-slate-900 group-hover:border-slate-900 group-hover:shadow-lg group-hover:shadow-blue-500/10 transition-all duration-300">
|
||||
<Check className="w-3 h-3 text-slate-400 group-hover:text-white transition-colors duration-300" />
|
||||
</div>
|
||||
<Label className="text-slate-900 text-xs md:text-sm">
|
||||
{item}
|
||||
</Label>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
, auf die Sie bauen können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl text-left">
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Fixpreis-Garantie
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Keine versteckten Kosten. Der vereinbarte Preis ist final.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Satisfaction Guarantee
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Wir gehen erst live, wenn Sie zu 100% zufrieden sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 flex flex-col items-start">
|
||||
<div className="w-64 md:w-80">
|
||||
<Signature delay={0.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative terminal */}
|
||||
<Reveal delay={0.3} className="min-w-0">
|
||||
<CodeSnippet variant="terminal" className="opacity-70" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -306,9 +320,7 @@ export default function AboutPage() {
|
||||
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
|
||||
Lassen Sie uns gemeinsam etwas bauen, das{" "}
|
||||
<span className="text-slate-900">
|
||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
||||
wirklich funktioniert.
|
||||
</Marker>
|
||||
wirklich <Marker delay={0.3}>funktioniert.</Marker>
|
||||
</span>
|
||||
</LeadText>
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function KLZCablesCaseStudy() {
|
||||
<div className="max-w-3xl border-l-[3px] border-slate-900 pl-6 md:pl-12">
|
||||
<LeadText className="text-lg md:text-4xl leading-tight text-slate-900 font-medium">
|
||||
Engineering eines <br className="hidden md:block" />
|
||||
<Marker delay={0.2}>B2B Commerce Systems.</Marker>
|
||||
<Marker delay={0.2}>Systems.</Marker>
|
||||
</LeadText>
|
||||
<BodyText className="mt-4 md:mt-6 text-base md:text-xl text-slate-500 max-w-xl leading-relaxed font-serif italic">
|
||||
Vom statischen Altsystem zum industriellen Standard. Ich
|
||||
@@ -172,8 +172,8 @@ export default function KLZCablesCaseStudy() {
|
||||
Attribute in einer zentralen relationalen Instanz. Durch die
|
||||
Implementierung nativer PHP-Services und den Verzicht auf
|
||||
volatile Drittanbieter-Plugins wurde ein System geschaffen,
|
||||
das keine technologischen Überraschungen zulässt.{" "}
|
||||
<Marker delay={0.5}>Stability by Design.</Marker>
|
||||
das keine technologischen Überraschungen zulässt. Stability by{" "}
|
||||
<Marker delay={0.5}>Design.</Marker>
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { IconList, IconListItem } from "../src/components/IconList";
|
||||
import { HeroSection } from "../src/components/HeroSection";
|
||||
import { GlitchText } from "../src/components/GlitchText";
|
||||
import { Marker } from "../src/components/Marker";
|
||||
import { PenCircle } from "../src/components/PenCircle";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
@@ -45,12 +46,7 @@ export default function LandingPage() {
|
||||
<Reveal>
|
||||
<H3 className="max-w-3xl">
|
||||
Kein Agentur-Zirkus. <br />
|
||||
<span className="text-slate-400">
|
||||
Nur{" "}
|
||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
||||
Ergebnisse.
|
||||
</Marker>
|
||||
</span>
|
||||
<Marker delay={0.3}>Ergebnisse.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
@@ -104,12 +100,7 @@ export default function LandingPage() {
|
||||
<Reveal>
|
||||
<H3 className="max-w-3xl">
|
||||
Ich arbeite für das Ergebnis, <br />
|
||||
<span className="text-slate-400">
|
||||
nicht gegen die{" "}
|
||||
<Marker delay={0.4} color="rgba(148,163,184,0.1)">
|
||||
Uhr.
|
||||
</Marker>
|
||||
</span>
|
||||
nicht gegen die <Marker delay={0.4}>Uhr.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
@@ -118,14 +109,24 @@ export default function LandingPage() {
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText="Schnelle Prototypen. Ergebnisse in Tagen, nicht Monaten."
|
||||
positiveText={
|
||||
<>
|
||||
Schnelle Prototypen. Ergebnisse in{" "}
|
||||
<PenCircle delay={0.5}>Tagen</PenCircle>, nicht Monaten.
|
||||
</>
|
||||
}
|
||||
delay={0.1}
|
||||
/>
|
||||
<ComparisonRow
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText="Fixpreise. Sie wissen von Anfang an, was es kostet."
|
||||
positiveText={
|
||||
<>
|
||||
<PenCircle delay={0.5}>Fixpreise.</PenCircle> Sie wissen von
|
||||
Anfang an, was es kostet.
|
||||
</>
|
||||
}
|
||||
reverse
|
||||
delay={0.2}
|
||||
/>
|
||||
@@ -290,7 +291,7 @@ export default function LandingPage() {
|
||||
<LeadText className="text-lg md:text-3xl text-slate-400">
|
||||
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
||||
<span className="text-slate-900 border-b-2 border-slate-900/10">
|
||||
<Marker color="rgba(255,235,59,0.5)">zeitnah</Marker>
|
||||
<Marker>zeitnah</Marker>
|
||||
</span>{" "}
|
||||
bei Ihnen.
|
||||
</LeadText>
|
||||
|
||||
@@ -42,11 +42,9 @@ export default function WebsitesPage() {
|
||||
SYSTEM ENGINEERING
|
||||
</MonoLabel>
|
||||
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
|
||||
Websites, die <br />
|
||||
Websites, die einfach <br />
|
||||
<span className="text-slate-400">
|
||||
<Marker color="rgba(255,235,59,0.5)">
|
||||
einfach funktionieren.
|
||||
</Marker>
|
||||
<Marker>funktionieren.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</div>
|
||||
@@ -121,9 +119,7 @@ export default function WebsitesPage() {
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Geschwindigkeit ist <br />
|
||||
<span className="text-slate-400">
|
||||
<Marker delay={0.3} color="rgba(148,163,184,0.15)">
|
||||
kein Extra. Sie ist Standard.
|
||||
</Marker>
|
||||
kein Extra. Sie ist <Marker delay={0.3}>Standard.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
@@ -59,19 +59,24 @@ const Node: React.FC<{
|
||||
);
|
||||
|
||||
const Connector: React.FC<{ active?: boolean }> = ({ active }) => (
|
||||
<div className="flex-1 w-px md:w-auto h-8 md:h-[1px] bg-slate-100 relative min-h-[20px] md:min-w-[40px] shrink-0">
|
||||
<div className="flex-1 w-px md:w-auto h-8 md:h-[1px] bg-slate-50 relative min-h-[24px] md:min-h-0 md:min-w-[40px] shrink-0">
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ scaleX: 0, scaleY: 0 }}
|
||||
animate={{ scaleX: 1, scaleY: 1 }}
|
||||
className="absolute inset-0 bg-blue-300 origin-top md:origin-left"
|
||||
className="absolute inset-0 bg-blue-300/50 origin-top md:origin-left mix-blend-multiply"
|
||||
style={{
|
||||
// Visual correction to prevent sub-pixel rendering thickening
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 h-1 rounded-full",
|
||||
active ? "bg-blue-300 animate-pulse" : "bg-slate-100",
|
||||
"w-1 h-1 rounded-full border border-white", // Added border white to make it look smaller/cleaner
|
||||
active ? "bg-blue-400 animate-pulse scale-75" : "bg-slate-100", // Reduced scale and deeper color
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Label, H3, LeadText } from "../Typography";
|
||||
import { Strikethrough } from "../Strikethrough";
|
||||
import { cn } from "../../utils/cn";
|
||||
|
||||
interface ComparisonRowProps {
|
||||
@@ -38,11 +39,11 @@ export const ComparisonRow: React.FC<ComparisonRowProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 p-8 md:p-10 bg-slate-50/50 rounded-2xl text-slate-400 border border-transparent w-full">
|
||||
<Label className="mb-4 line-through decoration-red-500">
|
||||
{negativeLabel}
|
||||
<Label className="mb-4">
|
||||
<Strikethrough delay={delay + 0.2}>{negativeLabel}</Strikethrough>
|
||||
</Label>
|
||||
<LeadText className="line-through decoration-red-500 leading-snug">
|
||||
{negativeText}
|
||||
<LeadText className="leading-snug">
|
||||
<Strikethrough delay={delay + 0.3}>{negativeText}</Strikethrough>
|
||||
</LeadText>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useId, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
/**
|
||||
* TECHNICAL MARKER COMPONENT
|
||||
* Implements the "hand-drawn marker" effect.
|
||||
* Animates in when entering the viewport.
|
||||
*/
|
||||
interface MarkerProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
@@ -16,74 +11,171 @@ interface MarkerProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copic Marker Component
|
||||
*
|
||||
* Uses hand-drawn filled polygon paths (NOT rects or strokes) for an
|
||||
* organic, human feel. Each highlight band is a wobbly shape with
|
||||
* bezier curves, not a perfect rectangle. Second strokes are offset
|
||||
* to simulate imperfect re-highlighting.
|
||||
*/
|
||||
|
||||
// Seeded PRNG for SSR-safe randomness
|
||||
function createRng(seed: string) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s & 0x7fffffff) / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hand-drawn highlight band path.
|
||||
* Instead of a rectangle, we trace an organic polygon with wobbly edges.
|
||||
*
|
||||
* The shape goes: left edge → top edge (left to right) → right edge → bottom edge (right to left)
|
||||
* Each edge has 1-2 control points for subtle curves.
|
||||
*
|
||||
* ViewBox: 0 0 100 100 with preserveAspectRatio="none"
|
||||
* So x=0..100 maps to text width, y=0..100 maps to text height.
|
||||
*/
|
||||
function generateBandPath(rng: () => number, bandIndex: number) {
|
||||
// Vertical position: center band in the text height
|
||||
// First band centered around y=50, second shifted up or down
|
||||
const yOffset = bandIndex === 0 ? 0 : (rng() - 0.5) * 20; // -10 to +10 shift
|
||||
const yCenter = 50 + yOffset;
|
||||
|
||||
// Band thickness: covers ~40-50% of text height
|
||||
const halfHeight = 20 + rng() * 8; // 20-28 units (40-56% of viewBox)
|
||||
const yTop = yCenter - halfHeight;
|
||||
const yBottom = yCenter + halfHeight;
|
||||
|
||||
// Horizontal extent: slight random overshoot/undershoot
|
||||
const xStart = -2 + rng() * 3; // -2 to 1
|
||||
const xEnd = 99 + rng() * 3; // 99 to 102
|
||||
|
||||
// Generate wobbly top edge points (left to right)
|
||||
const topWobble1 = yTop + (rng() - 0.5) * 6;
|
||||
const topWobble2 = yTop + (rng() - 0.5) * 6;
|
||||
const topWobble3 = yTop + (rng() - 0.5) * 6;
|
||||
|
||||
// Generate wobbly bottom edge points (right to left)
|
||||
const botWobble1 = yBottom + (rng() - 0.5) * 6;
|
||||
const botWobble2 = yBottom + (rng() - 0.5) * 6;
|
||||
const botWobble3 = yBottom + (rng() - 0.5) * 6;
|
||||
|
||||
// Slight angle on left/right edges
|
||||
const leftTopY = yTop + (rng() - 0.5) * 4;
|
||||
const leftBotY = yBottom + (rng() - 0.5) * 4;
|
||||
const rightTopY = yTop + (rng() - 0.5) * 4;
|
||||
const rightBotY = yBottom + (rng() - 0.5) * 4;
|
||||
|
||||
// Build the path:
|
||||
// Start at top-left, trace top edge with curves, down right edge,
|
||||
// trace bottom edge with curves back left, close.
|
||||
const path = [
|
||||
// Start top-left
|
||||
`M ${xStart},${leftTopY}`,
|
||||
// Top edge: 3 curve segments left→right
|
||||
`C ${xStart + 15},${topWobble1} ${xStart + 30},${topWobble1} ${33},${topWobble2}`,
|
||||
`C ${45},${topWobble2} ${55},${topWobble3} ${67},${topWobble3}`,
|
||||
`C ${78},${topWobble3} ${xEnd - 10},${rightTopY} ${xEnd},${rightTopY}`,
|
||||
// Right edge down
|
||||
`L ${xEnd + (rng() - 0.5) * 2},${rightBotY}`,
|
||||
// Bottom edge: 3 curve segments right→left
|
||||
`C ${xEnd - 10},${botWobble1} ${78},${botWobble1} ${67},${botWobble2}`,
|
||||
`C ${55},${botWobble2} ${45},${botWobble3} ${33},${botWobble3}`,
|
||||
`C ${xStart + 30},${botWobble3} ${xStart + 15},${leftBotY} ${xStart},${leftBotY}`,
|
||||
// Close
|
||||
`Z`,
|
||||
].join(" ");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export const Marker: React.FC<MarkerProps> = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
color = "rgba(255,235,59,0.7)",
|
||||
color = "rgba(250, 204, 21, 0.5)",
|
||||
}) => {
|
||||
const id = useId();
|
||||
const filterId = `marker-rough-${id.replace(/:/g, "")}`;
|
||||
|
||||
const bands = useMemo(() => {
|
||||
const rng = createRng(id);
|
||||
const numBands = rng() > 0.5 ? 2 : 1;
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < numBands; i++) {
|
||||
result.push({
|
||||
d: generateBandPath(rng, i),
|
||||
delay: delay + i * 0.15,
|
||||
duration: 0.4 + rng() * 0.15,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [id, delay]);
|
||||
|
||||
return (
|
||||
<span className={cn("relative inline px-1", className)}>
|
||||
<svg
|
||||
className="absolute inset-x-0 bottom-0 top-0 h-full w-full pointer-events-none z-[-1]"
|
||||
className="absolute inset-0 h-full w-full pointer-events-none z-[-1] overflow-visible mix-blend-multiply"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Organic Stroke 1: Main body */}
|
||||
<motion.path
|
||||
d="M 0,85 C 10,87 25,82 40,84 C 55,86 75,81 90,83 C 95,84 100,85 100,85"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
delay: delay + 0.1,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="60"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Organic Stroke 2: Variation for overlap */}
|
||||
<motion.path
|
||||
d="M 5,82 C 20,80 40,85 60,82 C 80,79 95,84 100,83"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 0.6 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
delay: delay + 0.3,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="35"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Organic Stroke 3: Rough edge details */}
|
||||
<motion.path
|
||||
d="M 0,88 C 15,90 35,85 55,87 C 75,89 90,84 100,86"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 0.4 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
delay: delay + 0.2,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="15"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<defs>
|
||||
<filter id={filterId} x="-5%" y="-5%" width="110%" height="110%">
|
||||
{/* Very subtle edge wobble — alcohol ink bleed, not brush fraying */}
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.04 0.3"
|
||||
numOctaves="2"
|
||||
seed={Math.abs(id.charCodeAt(1) || 42)}
|
||||
result="noise"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="noise"
|
||||
scale="1.5"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{bands.map((band, i) => (
|
||||
<motion.path
|
||||
key={i}
|
||||
d={band.d}
|
||||
fill={color}
|
||||
filter={`url(#${filterId})`}
|
||||
initial={{ scaleX: 0, opacity: 0 }}
|
||||
whileInView={{ scaleX: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{
|
||||
scaleX: {
|
||||
duration: band.duration,
|
||||
delay: band.delay,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.1,
|
||||
delay: band.delay,
|
||||
},
|
||||
}}
|
||||
style={{ transformOrigin: "left center" }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<span className="relative z-10 text-inherit">{children}</span>
|
||||
<span className="relative z-10 font-semibold" style={{ color: "#000" }}>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
124
apps/web/src/components/PenCircle.tsx
Normal file
124
apps/web/src/components/PenCircle.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useId, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
interface PenCircleProps {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ballpoint Pen Circle Component
|
||||
*
|
||||
* Draws a hand-drawn ellipse around children using SVG.
|
||||
* Key: uses preserveAspectRatio="none" so the ellipse stretches
|
||||
* to match the element dimensions regardless of text length.
|
||||
*/
|
||||
|
||||
function createRng(seed: string) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s & 0x7fffffff) / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function generateCirclePath(rng: () => number) {
|
||||
// We draw an ellipse in a 0-100 x 0-100 viewBox.
|
||||
// preserveAspectRatio="none" will stretch it to fit the element.
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
|
||||
// Radii in viewBox units — will be stretched by the element
|
||||
const rx = 50;
|
||||
const ry = 50;
|
||||
|
||||
// Wobble for organic feel
|
||||
const w = () => (rng() - 0.5) * 6;
|
||||
|
||||
const k = 0.5522847;
|
||||
const kx = rx * k;
|
||||
const ky = ry * k;
|
||||
|
||||
// 4 cardinal points with wobble
|
||||
const top = { x: cx + w(), y: cy - ry + w() * 0.5 };
|
||||
const right = { x: cx + rx + w() * 0.3, y: cy + w() };
|
||||
const bottom = { x: cx + w(), y: cy + ry + w() * 0.5 };
|
||||
const left = { x: cx - rx + w() * 0.3, y: cy + w() };
|
||||
|
||||
// End slightly offset from start for imperfect closure
|
||||
const endOffX = (rng() - 0.5) * 8;
|
||||
const endOffY = (rng() - 0.5) * 4;
|
||||
|
||||
return [
|
||||
`M ${top.x},${top.y}`,
|
||||
`C ${top.x + kx + w()},${top.y + w()} ${right.x + w()},${right.y - ky + w()} ${right.x},${right.y}`,
|
||||
`C ${right.x + w()},${right.y + ky + w()} ${bottom.x + kx + w()},${bottom.y + w()} ${bottom.x},${bottom.y}`,
|
||||
`C ${bottom.x - kx + w()},${bottom.y + w()} ${left.x + w()},${left.y + ky + w()} ${left.x},${left.y}`,
|
||||
`C ${left.x + w()},${left.y - ky + w()} ${top.x - kx + w()},${top.y + w()} ${top.x + endOffX},${top.y + endOffY}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export const PenCircle: React.FC<PenCircleProps> = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
color = "rgba(37, 99, 235, 0.65)", // Blue ballpoint pen
|
||||
}) => {
|
||||
const id = useId();
|
||||
|
||||
const path = useMemo(() => {
|
||||
const rng = createRng(id);
|
||||
return generateCirclePath(rng);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<span className={cn("relative inline-block px-2 py-1", className)}>
|
||||
<svg
|
||||
className="absolute pointer-events-none overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="-5 -5 110 110"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-8px",
|
||||
width: "calc(100% + 16px)",
|
||||
height: "calc(100% + 12px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<motion.path
|
||||
d={path}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
pathLength: {
|
||||
duration: 0.7,
|
||||
delay: delay,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.1,
|
||||
delay: delay,
|
||||
},
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/components/Signature.tsx
Normal file
65
apps/web/src/components/Signature.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
interface SignatureProps {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const Signature: React.FC<SignatureProps> = ({
|
||||
className,
|
||||
delay = 0,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn("relative text-slate-900", className)}>
|
||||
<svg
|
||||
width="200"
|
||||
height="100"
|
||||
viewBox="0 0 200 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-auto"
|
||||
>
|
||||
{/* M */}
|
||||
<motion.path
|
||||
d="M20 50 C 25 30, 30 20, 40 25 C 45 35, 30 60, 25 70 C 20 80, 40 50, 50 40 C 60 30, 70 30, 60 55 C 55 65, 50 75, 65 70"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: delay, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* a r c (scribble) */}
|
||||
<motion.path
|
||||
d="M65 70 C 75 65, 80 60, 90 70 C 95 75, 85 80, 80 75 C 75 70, 95 65, 100 70 C 105 75, 110 70, 120 65"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: delay + 0.6, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* Mintel (scribble + underline) */}
|
||||
<motion.path
|
||||
d="M130 50 C 135 40, 140 30, 150 40 C 160 50, 140 80, 130 90 C 120 100, 180 80, 190 75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: delay + 1.0, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="mt-2 font-serif italic text-sm text-slate-500">
|
||||
Marc Mintel
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
apps/web/src/components/Strikethrough.tsx
Normal file
140
apps/web/src/components/Strikethrough.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import React, { useId, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../utils/cn";
|
||||
|
||||
interface StrikethroughProps {
|
||||
children: string; // Enforce string children for splitting
|
||||
delay?: number;
|
||||
className?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-drawn Strikethrough Component
|
||||
*
|
||||
* NOW SUPPORTS MULTI-LINE TEXT!
|
||||
*
|
||||
* Strategy: Splits the text into individual words. Each word is wrapped
|
||||
* in a span with its own SVG strikethrough. This allows the text to
|
||||
* wrap naturally across lines, and each word carries its strikethrough
|
||||
* with it.
|
||||
*/
|
||||
|
||||
function createRng(seed: string) {
|
||||
let s = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
s = ((s << 5) - s + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s & 0x7fffffff) / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function generateStrikePath(rng: () => number) {
|
||||
// Simple approach: just a slightly wobbly line from left to right
|
||||
// through vertical center (y=50).
|
||||
const points: { x: number; y: number }[] = [];
|
||||
const numPoints = 4; // Fewer points for shorter word segments
|
||||
|
||||
for (let i = 0; i <= numPoints; i++) {
|
||||
const x = (i / numPoints) * 100;
|
||||
const y = 55 + (rng() - 0.5) * 15; // ±7.5 units wobble
|
||||
points.push({ x, y });
|
||||
}
|
||||
|
||||
// Build a smooth path through all points
|
||||
let d = `M ${points[0].x},${points[0].y}`;
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const midX = (points[i].x + points[i + 1].x) / 2;
|
||||
const midY = (points[i].y + points[i + 1].y) / 2;
|
||||
d += ` Q ${points[i].x},${points[i].y} ${midX},${midY}`;
|
||||
}
|
||||
const last = points[points.length - 1];
|
||||
d += ` L ${last.x},${last.y}`;
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
const WordStrike: React.FC<{
|
||||
word: string;
|
||||
index: number;
|
||||
baseDelay: number;
|
||||
color: string;
|
||||
seed: string;
|
||||
}> = ({ word, index, baseDelay, color, seed }) => {
|
||||
// Unique seed per word so they look different
|
||||
const uniqueSeed = `${seed}-${index}-${word}`;
|
||||
|
||||
const path = useMemo(() => {
|
||||
const rng = createRng(uniqueSeed);
|
||||
return generateStrikePath(rng);
|
||||
}, [uniqueSeed]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-block whitespace-nowrap">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<motion.path
|
||||
d={path}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
whileInView={{ pathLength: 1, opacity: 1 }}
|
||||
viewport={{ once: true, margin: "-5%" }}
|
||||
transition={{
|
||||
pathLength: {
|
||||
duration: 0.4,
|
||||
// Stagger words slightly
|
||||
delay: baseDelay + index * 0.05,
|
||||
ease: "easeOut",
|
||||
},
|
||||
opacity: { duration: 0.1, delay: baseDelay + index * 0.05 },
|
||||
}}
|
||||
stroke={color}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const Strikethrough: React.FC<StrikethroughProps> = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
color = "rgba(220, 50, 50, 0.8)",
|
||||
}) => {
|
||||
const id = useId();
|
||||
|
||||
// Split by spaces but preserve them as separate elements for spacing
|
||||
// Actually, we can just split by space and rejoin with non-struck spaces
|
||||
const words = children.split(" ");
|
||||
|
||||
return (
|
||||
<span className={cn("relative inline", className)}>
|
||||
{words.map((word, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<WordStrike
|
||||
word={word}
|
||||
index={i}
|
||||
baseDelay={delay}
|
||||
color={color}
|
||||
seed={id}
|
||||
/>
|
||||
{i < words.length - 1 && " "}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user