Add HandDrawn component with dark mode support, apply to homepage and collection pages

Port hand-drawn visual effects (highlight, underline, circle variants)
with dark mode fixes: CSS variable-based text color, reduced highlighter
opacity in dark mode, and proper static text fallback for non-animated
instances. Applied orange underline to hero text, teal/green circles to
stats, yellow highlight to "Heritage", and underlines on collection headers.
This commit is contained in:
Ryan Malloy 2026-02-13 07:31:25 -07:00
parent 015a664893
commit 9674f215f8
5 changed files with 392 additions and 6 deletions

View File

@ -0,0 +1,381 @@
---
/**
* HandDrawn - Reusable hand-drawn effects for emphasis with animations
*
* Variants:
* - highlight: Highlighter blob behind text (dark text, colored background)
* - underline: Squiggly underline beneath text
* - circle: Hand-drawn circle/oval around text
*
* Colors:
* - yellow (default): Classic yellow highlighter
* - pink: Pink/magenta emphasis
* - green: Green emphasis
* - blue: Blue emphasis
* - orange: Warm orange emphasis
* - teal: Teal/cyan emphasis
*
* Animation:
* - animate: Enable draw-on/fade-in animations (default: true)
* - trigger: 'load' (immediate) or 'visible' (when scrolled into view)
* - delay: Animation delay in ms (default: 0)
* - duration: Animation duration in ms (default: 600 for draw, 400 for highlight)
*/
interface Props {
variant?: 'highlight' | 'underline' | 'circle';
color?: 'yellow' | 'pink' | 'green' | 'blue' | 'orange' | 'teal';
animate?: boolean;
trigger?: 'load' | 'visible';
delay?: number;
duration?: number;
class?: string;
}
const {
variant = 'highlight',
color = 'yellow',
animate = true,
trigger = 'visible',
delay = 0,
duration = variant === 'highlight' ? 400 : 600,
class: className = '',
} = Astro.props;
// Color mappings for realistic highlighter colors (Staedtler Textsurfer Classic inspired)
// Uses custom CSS classes for authentic fluorescent marker look
const colorMap = {
yellow: {
highlight: { primary: 'hd-fill-yellow', secondary: 'hd-fill-yellow-light' },
underline: 'hd-stroke-yellow',
circle: 'hd-stroke-yellow',
},
pink: {
highlight: { primary: 'hd-fill-pink', secondary: 'hd-fill-pink-light' },
underline: 'hd-stroke-pink',
circle: 'hd-stroke-pink',
},
green: {
highlight: { primary: 'hd-fill-green', secondary: 'hd-fill-green-light' },
underline: 'hd-stroke-green',
circle: 'hd-stroke-green',
},
blue: {
highlight: { primary: 'hd-fill-blue', secondary: 'hd-fill-blue-light' },
underline: 'hd-stroke-blue',
circle: 'hd-stroke-blue',
},
orange: {
highlight: { primary: 'hd-fill-orange', secondary: 'hd-fill-orange-light' },
underline: 'hd-stroke-orange',
circle: 'hd-stroke-orange',
},
teal: {
highlight: { primary: 'hd-fill-teal', secondary: 'hd-fill-teal-light' },
underline: 'hd-stroke-teal',
circle: 'hd-stroke-teal',
},
};
const colors = colorMap[color];
// Generate unique ID for this instance
const id = `hd-${Math.random().toString(36).slice(2, 9)}`;
// Animation classes
const animateClass = animate ? 'hand-drawn-animate' : '';
const triggerClass = trigger === 'visible' ? 'hand-drawn-observe' : 'hand-drawn-immediate';
// Circle-specific randomization for organic feel
const circleJitterScale = 0.8 + Math.random() * 0.4;
const circleSpeedMultiplier = 0.9 + Math.random() * 0.4;
const circleStrokeLength = 85 + Math.floor(Math.random() * 8);
---
{variant === 'highlight' && (
<span
class:list={['relative inline-block hand-drawn-wrapper', animateClass, triggerClass, className]}
data-hand-drawn={id}
style={`--hd-delay: ${delay}ms; --hd-duration: ${duration}ms;`}
>
<span class:list={["relative z-10 px-1", animate ? 'hand-drawn-text' : 'hand-drawn-text-static']}>
<slot />
</span>
<svg
class="absolute -inset-x-3 -top-1 -bottom-3 w-[calc(100%+24px)] h-[calc(100%+16px)] -rotate-1 hand-drawn-highlight"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M 2,8 Q 5,2 15,5 L 50,3 Q 60,1 75,4 L 92,6 Q 98,8 97,15 L 99,45 Q 100,55 98,70 L 96,88 Q 94,97 85,96 L 50,98 Q 30,99 15,97 L 5,95 Q 1,93 2,85 L 1,50 Q 0,30 2,8 Z"
class:list={[colors.highlight.primary, 'hand-drawn-highlight-outer']}
/>
<path
d="M 8,12 Q 12,6 25,9 L 55,7 Q 65,5 78,8 L 88,10 Q 94,14 92,25 L 94,50 Q 95,65 93,78 L 90,88 Q 86,94 75,92 L 45,94 Q 28,95 15,93 L 8,90 Q 4,86 5,75 L 4,45 Q 3,28 8,12 Z"
class:list={[colors.highlight.secondary, 'hand-drawn-highlight-inner']}
/>
</svg>
</span>
)}
{variant === 'underline' && (
<span
class:list={['relative inline-block hand-drawn-wrapper', animateClass, triggerClass, className]}
data-hand-drawn={id}
style={`--hd-delay: ${delay}ms; --hd-duration: ${duration}ms;`}
>
<slot />
<svg
class="absolute -bottom-1 left-0 w-full h-4 overflow-visible"
viewBox="0 0 200 16"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M 2,7 C 15,8 35,10 60,9 C 100,7 140,8 175,10 Q 190,11 198,9"
fill="none"
class:list={[colors.underline, 'hand-drawn-stroke']}
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="100"
/>
</svg>
</span>
)}
{variant === 'circle' && (
<span
class:list={['relative inline-block hand-drawn-wrapper hand-drawn-circle', animateClass, triggerClass, className]}
data-hand-drawn={id}
style={`--hd-delay: ${delay}ms; --hd-duration: ${Math.round(duration * 1.8 * circleSpeedMultiplier)}ms; --hd-jitter-scale: ${circleJitterScale.toFixed(2)}; --hd-stroke-length: ${circleStrokeLength};`}
>
<span class="px-2"><slot /></span>
<svg
class="absolute -inset-y-4 -inset-x-3 w-[calc(100%+24px)] h-[calc(100%+32px)] hand-drawn-circle-svg"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d="M 50,8 Q 80,5 95,25 Q 100,50 95,75 Q 80,95 50,93 Q 20,95 5,75 Q 0,50 5,25 Q 20,5 48,9"
fill="none"
class:list={[colors.circle, 'hand-drawn-stroke hand-drawn-circle-stroke']}
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="100"
/>
</svg>
</span>
)}
<style is:global>
/* === Hand-drawn highlight fill colors (Staedtler Textsurfer Classic inspired) === */
.hd-fill-yellow { fill: rgba(255, 247, 0, 0.55); }
.hd-fill-yellow-light { fill: rgba(255, 251, 120, 0.30); }
.hd-fill-pink { fill: rgba(255, 105, 180, 0.50); }
.hd-fill-pink-light { fill: rgba(255, 182, 213, 0.28); }
.hd-fill-green { fill: rgba(0, 255, 127, 0.45); }
.hd-fill-green-light { fill: rgba(144, 255, 190, 0.25); }
.hd-fill-blue { fill: rgba(0, 191, 255, 0.45); }
.hd-fill-blue-light { fill: rgba(135, 206, 250, 0.28); }
.hd-fill-orange { fill: rgba(255, 165, 0, 0.55); }
.hd-fill-orange-light { fill: rgba(255, 200, 100, 0.30); }
.hd-fill-teal { fill: rgba(20, 184, 166, 0.45); }
.hd-fill-teal-light { fill: rgba(153, 246, 228, 0.28); }
/* === Hand-drawn stroke colors === */
.hd-stroke-yellow { stroke: rgba(255, 200, 0, 0.85); }
.hd-stroke-pink { stroke: rgba(255, 80, 160, 0.85); }
.hd-stroke-green { stroke: rgba(0, 200, 100, 0.85); }
.hd-stroke-blue { stroke: rgba(0, 150, 255, 0.85); }
.hd-stroke-orange { stroke: rgba(255, 140, 0, 0.85); }
.hd-stroke-teal { stroke: rgba(20, 184, 166, 0.85); }
/* === Highlight animations === */
.hand-drawn-highlight {
pointer-events: none;
}
.hand-drawn-highlight-outer,
.hand-drawn-highlight-inner {
transform-origin: center;
}
/* Text reveal for highlight variant */
.hand-drawn-text {
color: transparent;
}
/* Static text color for non-animated highlight */
.hand-drawn-text-static {
color: var(--hd-text-color, rgb(15 23 42));
}
.hand-drawn-animate.hand-drawn-active .hand-drawn-text {
animation: hand-drawn-text-reveal calc(var(--hd-duration) * 0.6) ease-out calc(var(--hd-delay) + var(--hd-duration) * 0.5) forwards;
}
@keyframes hand-drawn-text-reveal {
0% { color: transparent; }
100% { color: var(--hd-text-color, rgb(15 23 42)); }
}
/* Highlight blob animation */
.hand-drawn-animate .hand-drawn-highlight-outer {
opacity: 0;
transform: scale(0.3) rotate(-5deg);
}
.hand-drawn-animate.hand-drawn-active .hand-drawn-highlight-outer {
animation: hand-drawn-blob-outer var(--hd-duration) cubic-bezier(0.34, 1.56, 0.64, 1) var(--hd-delay) forwards;
}
@keyframes hand-drawn-blob-outer {
0% {
opacity: 0;
transform: scale(0.3) rotate(-5deg);
}
40% {
opacity: 0.8;
transform: scale(1.05) rotate(0.5deg);
}
70% {
opacity: 1;
transform: scale(0.98) rotate(-0.3deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
.hand-drawn-animate .hand-drawn-highlight-inner {
opacity: 0;
transform: scale(0.5) rotate(3deg);
}
.hand-drawn-animate.hand-drawn-active .hand-drawn-highlight-inner {
animation: hand-drawn-blob-inner var(--hd-duration) cubic-bezier(0.34, 1.56, 0.64, 1) calc(var(--hd-delay) + 80ms) forwards;
}
@keyframes hand-drawn-blob-inner {
0% {
opacity: 0;
transform: scale(0.5) rotate(3deg);
}
50% {
opacity: 0.7;
transform: scale(1.03) rotate(-0.5deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
/* === Underline / Circle stroke animations === */
.hand-drawn-animate .hand-drawn-stroke {
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.hand-drawn-animate.hand-drawn-active .hand-drawn-stroke {
animation: hand-drawn-draw var(--hd-duration) cubic-bezier(0.65, 0, 0.35, 1) var(--hd-delay) forwards;
}
@keyframes hand-drawn-draw {
0% { stroke-dashoffset: 100; }
100% { stroke-dashoffset: 0; }
}
/* Circle variant: slightly imperfect/organic stroke */
.hand-drawn-circle-stroke {
stroke-dasharray: var(--hd-stroke-length, 90);
stroke-dashoffset: var(--hd-stroke-length, 90);
}
.hand-drawn-animate.hand-drawn-active .hand-drawn-circle-stroke {
animation: hand-drawn-circle-draw var(--hd-duration) cubic-bezier(0.45, 0.05, 0.55, 0.95) var(--hd-delay) forwards;
}
@keyframes hand-drawn-circle-draw {
0% { stroke-dashoffset: var(--hd-stroke-length, 90); }
100% { stroke-dashoffset: 0; }
}
.hand-drawn-circle-svg {
transform: scale(var(--hd-jitter-scale, 1));
}
/* === Reduced motion === */
@media (prefers-reduced-motion: reduce) {
.hand-drawn-animate .hand-drawn-text {
color: var(--hd-text-color, rgb(15 23 42));
animation: none !important;
}
.hand-drawn-animate .hand-drawn-highlight-outer,
.hand-drawn-animate .hand-drawn-highlight-inner {
opacity: 1;
transform: scale(1) rotate(0deg);
animation: none !important;
}
.hand-drawn-animate .hand-drawn-stroke {
stroke-dashoffset: 0;
animation: none !important;
}
.hand-drawn-animate .hand-drawn-circle-stroke {
stroke-dashoffset: 0;
animation: none !important;
}
}
/* Dark mode: reduce highlighter fill opacity for readability */
.dark .hd-fill-yellow { fill: rgba(255, 247, 0, 0.45); }
.dark .hd-fill-yellow-light { fill: rgba(255, 251, 120, 0.25); }
.dark .hd-fill-pink { fill: rgba(255, 105, 180, 0.40); }
.dark .hd-fill-pink-light { fill: rgba(255, 182, 213, 0.22); }
.dark .hd-fill-green { fill: rgba(0, 255, 127, 0.35); }
.dark .hd-fill-green-light { fill: rgba(144, 255, 190, 0.20); }
.dark .hd-fill-blue { fill: rgba(0, 191, 255, 0.35); }
.dark .hd-fill-blue-light { fill: rgba(135, 206, 250, 0.22); }
.dark .hd-fill-orange { fill: rgba(255, 165, 0, 0.45); }
.dark .hd-fill-orange-light { fill: rgba(255, 200, 100, 0.25); }
.dark .hd-fill-teal { fill: rgba(20, 184, 166, 0.35); }
.dark .hd-fill-teal-light { fill: rgba(153, 246, 228, 0.22); }
</style>
<script>
// Intersection Observer for triggering animations when visible
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('hand-drawn-active');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.3 }
);
function initHandDrawn() {
// Observe elements that trigger on visibility
document.querySelectorAll('.hand-drawn-observe').forEach((el) => {
observer.observe(el);
});
// Immediately activate elements that trigger on load
document.querySelectorAll('.hand-drawn-immediate').forEach((el) => {
el.classList.add('hand-drawn-active');
});
}
// Run on initial load
initHandDrawn();
// Re-run on Astro page transitions
document.addEventListener('astro:page-load', initHandDrawn);
</script>

View File

@ -1,6 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro'; import Layout from '@/layouts/Layout.astro';
import BookGrid from '@/components/BookGrid.astro'; import BookGrid from '@/components/BookGrid.astro';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
const allBooks = await getCollection('books'); const allBooks = await getCollection('books');
@ -29,7 +30,7 @@ const totalBooks = allBooks.length;
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold title-accent max-w-4xl mx-auto leading-tight"> <h1 class="text-4xl md:text-5xl lg:text-6xl font-bold title-accent max-w-4xl mx-auto leading-tight">
The Hand-Drawn References That The Hand-Drawn References That
<span class="text-primary">Taught Generations</span> <HandDrawn variant="underline" color="orange"><span class="text-primary">Taught Generations</span></HandDrawn>
</h1> </h1>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto"> <p class="text-lg text-muted-foreground max-w-2xl mx-auto">
@ -75,11 +76,11 @@ const totalBooks = allBooks.length;
<div class="text-sm text-muted-foreground">Collections</div> <div class="text-sm text-muted-foreground">Collections</div>
</div> </div>
<div class="text-center p-6 rounded-lg bg-card border border-border"> <div class="text-center p-6 rounded-lg bg-card border border-border">
<div class="text-3xl font-bold text-primary">100+</div> <div class="text-3xl font-bold text-primary"><HandDrawn variant="circle" color="teal">100+</HandDrawn></div>
<div class="text-sm text-muted-foreground">Circuit Projects</div> <div class="text-sm text-muted-foreground">Circuit Projects</div>
</div> </div>
<div class="text-center p-6 rounded-lg bg-card border border-border"> <div class="text-center p-6 rounded-lg bg-card border border-border">
<div class="text-3xl font-bold text-primary">Free</div> <div class="text-3xl font-bold text-primary"><HandDrawn variant="circle" color="green">Free</HandDrawn></div>
<div class="text-sm text-muted-foreground">PDF Downloads</div> <div class="text-sm text-muted-foreground">PDF Downloads</div>
</div> </div>
</section> </section>
@ -183,7 +184,7 @@ const totalBooks = allBooks.length;
<section class="py-12 mt-8 border-t border-border"> <section class="py-12 mt-8 border-t border-border">
<div class="grid md:grid-cols-2 gap-8 items-center"> <div class="grid md:grid-cols-2 gap-8 items-center">
<div class="space-y-4"> <div class="space-y-4">
<h2 class="text-2xl font-bold title-accent">Preserving Electronics Heritage</h2> <h2 class="text-2xl font-bold title-accent">Preserving Electronics <HandDrawn variant="highlight" color="yellow">Heritage</HandDrawn></h2>
<p class="text-muted-foreground leading-relaxed"> <p class="text-muted-foreground leading-relaxed">
These references represent the golden age of hands-on electronics education. Forrest M. Mims III, These references represent the golden age of hands-on electronics education. Forrest M. Mims III,
a Texas A&M graduate and one of the most widely read electronics authors in history, taught millions a Texas A&M graduate and one of the most widely read electronics authors in history, taught millions

View File

@ -1,6 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro'; import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid'; import FilterableBookGrid from '@/components/FilterableBookGrid';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types'; import { serializeBook } from '@/lib/types';
@ -32,7 +33,7 @@ const serializedBooks = mimsBooks.map(serializeBook);
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h1 class="text-3xl md:text-4xl font-bold title-accent"> <h1 class="text-3xl md:text-4xl font-bold title-accent">
Forrest Mims Mini-Notebooks Forrest Mims <HandDrawn variant="underline" color="orange">Mini-Notebooks</HandDrawn>
</h1> </h1>
<p class="text-muted-foreground mt-2 max-w-2xl"> <p class="text-muted-foreground mt-2 max-w-2xl">
The complete Radio Shack Engineer's Mini-Notebook series. Hand-illustrated electronics The complete Radio Shack Engineer's Mini-Notebook series. Hand-illustrated electronics

View File

@ -1,6 +1,7 @@
--- ---
import Layout from '@/layouts/Layout.astro'; import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid'; import FilterableBookGrid from '@/components/FilterableBookGrid';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types'; import { serializeBook } from '@/lib/types';
@ -32,7 +33,7 @@ const serializedBooks = uglysBooks.map(serializeBook);
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div> <div>
<h1 class="text-3xl md:text-4xl font-bold title-accent"> <h1 class="text-3xl md:text-4xl font-bold title-accent">
Ugly's Electrical References Ugly's <HandDrawn variant="underline" color="blue">Electrical References</HandDrawn>
</h1> </h1>
<p class="text-muted-foreground mt-2 max-w-2xl"> <p class="text-muted-foreground mt-2 max-w-2xl">
The pocket-sized bible for electricians. Packed with tables, formulas, wiring diagrams, The pocket-sized bible for electricians. Packed with tables, formulas, wiring diagrams,

View File

@ -84,6 +84,7 @@
--sidebar-accent-foreground: oklch(0.30 0.03 250); --sidebar-accent-foreground: oklch(0.30 0.03 250);
--sidebar-border: oklch(0.85 0.04 230); --sidebar-border: oklch(0.85 0.04 230);
--sidebar-ring: oklch(0.55 0.12 250); --sidebar-ring: oklch(0.55 0.12 250);
--hd-text-color: rgb(15 23 42);
} }
.dark { .dark {
@ -118,6 +119,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
--hd-text-color: rgb(248 250 252);
} }
@layer base { @layer base {