Compare commits

...

4 Commits

Author SHA1 Message Date
278946fb10 Merge feature/hand-drawn-effects: hand-drawn visual effects with dark mode 2026-02-13 07:33:47 -07:00
a77e4c4bd8 Merge feature/seo-infrastructure: sitemap, OG meta, JSON-LD, 404 page 2026-02-13 07:33:42 -07:00
b892189f21 Add SEO infrastructure: sitemap, OG meta, JSON-LD, custom 404
- Integrate @astrojs/sitemap for automatic sitemap generation
- Add robots.txt with sitemap reference
- Add Open Graph and Twitter Card meta tags to Layout
- Add canonical URL and structured data slot to Layout
- Add JSON-LD schema (WebSite, CollectionPage, Book, BreadcrumbList)
- Create custom 404 page with navigation links
- Create default OG image (SVG with graph-paper theme)
- Wire SITE_URL through Docker build args for production builds
- Update Caddyfile for proper 404 handling instead of SPA fallback
2026-02-13 07:32:57 -07:00
9674f215f8 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.
2026-02-13 07:31:25 -07:00
18 changed files with 709 additions and 12 deletions

View File

@ -10,3 +10,6 @@ CADDY_HOST=mims.localhost
# Dev mode: uncomment to mount src for hot reload
# DEV_MOUNT=./src
# Site URL for sitemap generation and OG meta tags
# SITE_URL=https://forrest.warehack.ing

View File

@ -3,7 +3,13 @@
file_server
# Handle clean URLs - try file, then directory, then .html extension
try_files {path} {path}/ {path}.html /index.html
try_files {path} {path}/ {path}.html
# Custom 404 page
handle_errors {
rewrite * /404.html
file_server
}
# Compression
encode gzip

View File

@ -20,6 +20,8 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
# Build stage for production
FROM base AS builder
WORKDIR /app
ARG SITE_URL=https://forrest.warehack.ing
ENV SITE_URL=${SITE_URL}
COPY package*.json ./
RUN npm ci
COPY . .

View File

@ -2,11 +2,12 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config
export default defineConfig({
integrations: [react()],
integrations: [react(), sitemap()],
// Production site URL for correct link generation
site: process.env.SITE_URL || 'https://mims.l.supported.systems',

View File

@ -4,6 +4,8 @@ services:
context: .
dockerfile: Dockerfile
target: ${MODE:-production}
args:
- SITE_URL=${SITE_URL:-https://forrest.warehack.ing}
container_name: mims-library
restart: unless-stopped
environment:

80
site/package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
@ -22,6 +23,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"schema-dts": "^1.1.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
@ -102,6 +104,17 @@
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@astrojs/sitemap": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.0.tgz",
"integrity": "sha512-+qxjUrz6Jcgh+D5VE1gKUJTA3pSthuPHe6Ao5JCxok794Lewx8hBFaWHtOnN0ntb2lfOf7gvOi9TefUswQ/ZVA==",
"license": "MIT",
"dependencies": {
"sitemap": "^8.0.2",
"stream-replace-string": "^2.0.0",
"zod": "^3.25.76"
}
},
"node_modules/@astrojs/telemetry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
@ -2846,6 +2859,15 @@
"@types/unist": "*"
}
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@ -2864,6 +2886,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -3007,6 +3038,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -6097,6 +6134,12 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/schema-dts": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz",
"integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==",
"license": "Apache-2.0"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -6176,6 +6219,31 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/sitemap": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-8.0.2.tgz",
"integrity": "sha512-LwktpJcyZDoa0IL6KT++lQ53pbSrx2c9ge41/SeLTyqy2XUNA6uR4+P9u5IVo5lPeL2arAcOKn1aZAxoYbCKlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^17.0.5",
"@types/sax": "^1.2.1",
"arg": "^5.0.0",
"sax": "^1.4.1"
},
"bin": {
"sitemap": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/sitemap/node_modules/@types/node": {
"version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz",
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==",
"license": "MIT"
},
"node_modules/smol-toml": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
@ -6207,6 +6275,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stream-replace-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
"integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@ -6438,6 +6512,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
@ -23,6 +24,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"schema-dts": "^1.1.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#f5f0e8"/>
<!-- Graph paper grid -->
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#d4cfc5" stroke-width="0.5" opacity="0.5"/>
</pattern>
</defs>
<rect width="1200" height="630" fill="url(#grid)"/>
<!-- Circuit trace decoration -->
<line x1="100" y1="530" x2="400" y2="530" stroke="#4a8c6f" stroke-width="3"/>
<circle cx="100" cy="530" r="6" fill="#4a8c6f"/>
<circle cx="400" cy="530" r="6" fill="#4a8c6f"/>
<line x1="800" y1="100" x2="1100" y2="100" stroke="#4a8c6f" stroke-width="3"/>
<circle cx="800" cy="100" r="6" fill="#4a8c6f"/>
<circle cx="1100" cy="100" r="6" fill="#4a8c6f"/>
<!-- Title -->
<text x="600" y="260" text-anchor="middle" font-family="Georgia, serif" font-size="52" fill="#1e293b" letter-spacing="-1">Electronics Reference Library</text>
<!-- Subtitle -->
<text x="600" y="330" text-anchor="middle" font-family="Georgia, serif" font-size="28" fill="#64748b">Forrest M. Mims III &amp; Classic References</text>
<!-- Tagline -->
<text x="600" y="400" text-anchor="middle" font-family="system-ui, sans-serif" font-size="20" fill="#94a3b8">Hand-drawn circuit notebooks preserved digitally</text>
<!-- Book icon hint -->
<rect x="555" y="440" width="90" height="70" rx="4" fill="none" stroke="#3b5998" stroke-width="2.5"/>
<line x1="600" y1="445" x2="600" y2="505" stroke="#3b5998" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

4
site/public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://forrest.warehack.ing/sitemap-index.xml

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

@ -8,9 +8,23 @@ import SearchDialog from '@/components/SearchDialog';
interface Props {
title: string;
description?: string;
ogImage?: string;
ogType?: string;
}
const { title, description = "Classic electronics reference notebooks from Forrest M. Mims III" } = Astro.props;
const {
title,
description = "Classic electronics reference notebooks from Forrest M. Mims III",
ogImage,
ogType = 'website'
} = Astro.props;
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const canonicalUrl = new URL(Astro.url.pathname, siteUrl).href;
const resolvedOgImage = ogImage
? new URL(ogImage, siteUrl).href
: new URL('/images/og-default.svg', siteUrl).href;
const fullTitle = `${title} | Electronics Reference Library`;
const allBooks = await getCollection('books');
const allBooksData = allBooks.map(serializeBook);
@ -22,12 +36,26 @@ const allBooksData = allBooks.map(serializeBook);
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<!-- Open Graph -->
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={resolvedOgImage} />
<meta property="og:site_name" content="Electronics Reference Library" />
<meta property="og:type" content={ogType} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={resolvedOgImage} />
<!-- Canonical -->
<link rel="canonical" href={canonicalUrl} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#3b5998" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>{title} | Electronics Reference Library</title>
<title>{fullTitle}</title>
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
@ -36,6 +64,7 @@ const allBooksData = allBooks.map(serializeBook);
}
})();
</script>
<slot name="structured-data" />
</head>
<body class="min-h-screen graph-paper-large">
<header class="border-b border-border bg-card/80 backdrop-blur-sm sticky top-0 z-50">

61
site/src/pages/404.astro Normal file
View File

@ -0,0 +1,61 @@
---
import Layout from '@/layouts/Layout.astro';
---
<Layout title="Page Not Found" description="The page you're looking for doesn't exist">
<div class="flex flex-col items-center justify-center py-20 text-center space-y-6">
<div class="w-20 h-20 rounded-full bg-muted flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground">
<circle cx="12" cy="12" r="10"/>
<path d="m15 9-6 6"/>
<path d="m9 9 6 6"/>
</svg>
</div>
<div class="space-y-2">
<h1 class="text-4xl font-bold title-accent">404</h1>
<p class="text-xl text-muted-foreground">The circuit you're tracing leads nowhere</p>
<p class="text-sm text-muted-foreground max-w-md">
This page doesn't exist. The component may have been desoldered, or the trace was never routed.
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-4 pt-4">
<a
href="/"
class="inline-flex items-center gap-2 px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Back to Home
</a>
<a
href="/mims"
class="inline-flex items-center gap-2 px-6 py-3 border border-border rounded-lg font-medium hover:bg-muted transition-colors"
>
Mims Collection
</a>
<a
href="/uglys"
class="inline-flex items-center gap-2 px-6 py-3 border border-border rounded-lg font-medium hover:bg-muted transition-colors"
>
Ugly's Collection
</a>
</div>
<!-- Circuit decoration -->
<div class="pt-8 opacity-30">
<svg width="200" height="40" viewBox="0 0 200 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="text-muted-foreground">
<line x1="10" y1="20" x2="60" y2="20" stroke="currentColor" stroke-width="2"/>
<circle cx="10" cy="20" r="4" fill="currentColor"/>
<circle cx="80" cy="20" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="88" y1="20" x2="112" y2="20" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4"/>
<circle cx="120" cy="20" r="8" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="128" y1="20" x2="190" y2="20" stroke="currentColor" stroke-width="2"/>
<circle cx="190" cy="20" r="4" fill="currentColor"/>
</svg>
</div>
</div>
</Layout>

View File

@ -1,7 +1,9 @@
---
import Layout from '@/layouts/Layout.astro';
import BookGrid from '@/components/BookGrid.astro';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content';
import type { WithContext, WebSite } from 'schema-dts';
const allBooks = await getCollection('books');
@ -15,9 +17,21 @@ const uglysBooks = allBooks
const featuredMimsBooks = mimsBooks.slice(0, 4);
const totalBooks = allBooks.length;
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<WebSite> = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Electronics Reference Library',
description: 'Classic electronics and electrical reference notebooks from Forrest M. Mims III, preserved digitally.',
url: siteUrl,
};
---
<Layout title="Home">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<!-- Hero Section -->
<section class="text-center py-12 space-y-6">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/20 text-accent-foreground text-sm font-medium">
@ -29,7 +43,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">
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>
<p class="text-lg text-muted-foreground max-w-2xl mx-auto">
@ -75,11 +89,11 @@ const totalBooks = allBooks.length;
<div class="text-sm text-muted-foreground">Collections</div>
</div>
<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>
<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>
</section>
@ -183,7 +197,7 @@ const totalBooks = allBooks.length;
<section class="py-12 mt-8 border-t border-border">
<div class="grid md:grid-cols-2 gap-8 items-center">
<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">
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

View File

@ -5,6 +5,7 @@ import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
import type { WithContext, Book, BreadcrumbList } from 'schema-dts';
export async function getStaticPaths() {
const books = await getCollection('books');
@ -33,9 +34,35 @@ const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null;
const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const bookSlug = book.slug.split('/').pop();
const bookJsonLd: WithContext<Book> = {
'@context': 'https://schema.org',
'@type': 'Book',
name: title,
description: description,
author: { '@type': 'Person', name: 'Forrest M. Mims III' },
url: `${siteUrl}/mims/${bookSlug}`,
...(coverImage && { image: new URL(coverImage, siteUrl).href }),
...(year && { datePublished: String(year) }),
};
const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: 'Mims Collection', item: `${siteUrl}/mims` },
{ '@type': 'ListItem', position: 3, name: shortTitle, item: `${siteUrl}/mims/${bookSlug}` },
],
};
---
<Layout title={shortTitle} description={description}>
<Layout title={shortTitle} description={description} ogImage={coverImage} ogType="book">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(bookJsonLd)} />
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -1,8 +1,10 @@
---
import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types';
import type { WithContext, CollectionPage } from 'schema-dts';
const allBooks = await getCollection('books');
const mimsBooks = allBooks
@ -12,12 +14,25 @@ const mimsBooks = allBooks
// Get unique topics for filtering
const allTopics = [...new Set(mimsBooks.flatMap(book => book.data.topics))].sort();
const serializedBooks = mimsBooks.map(serializeBook);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<CollectionPage> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Forrest Mims Mini-Notebooks',
description: "The complete collection of Forrest M. Mims III's Radio Shack Engineer's Mini-Notebooks",
url: `${siteUrl}/mims`,
numberOfItems: mimsBooks.length,
};
---
<Layout
title="Mims Mini-Notebooks"
description="The complete collection of Forrest M. Mims III's Radio Shack Engineer's Mini-Notebooks"
>
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Header -->
<div class="space-y-4">
@ -32,7 +47,7 @@ const serializedBooks = mimsBooks.map(serializeBook);
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<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>
<p class="text-muted-foreground mt-2 max-w-2xl">
The complete Radio Shack Engineer's Mini-Notebook series. Hand-illustrated electronics

View File

@ -5,6 +5,7 @@ import EBookReader from '@/components/EBookReader';
import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
import type { WithContext, Book, BreadcrumbList } from 'schema-dts';
export async function getStaticPaths() {
const books = await getCollection('books');
@ -33,9 +34,35 @@ const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug);
const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null;
const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null;
const relatedBooks = getRelatedBooks(book, allBooks);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const bookSlug = book.slug.split('/').pop();
const bookJsonLd: WithContext<Book> = {
'@context': 'https://schema.org',
'@type': 'Book',
name: title,
description: description,
author: { '@type': 'Person', name: "George V. Hart" },
url: `${siteUrl}/uglys/${bookSlug}`,
...(coverImage && { image: new URL(coverImage, siteUrl).href }),
...(year && { datePublished: String(year) }),
};
const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: "Ugly's Collection", item: `${siteUrl}/uglys` },
{ '@type': 'ListItem', position: 3, name: shortTitle, item: `${siteUrl}/uglys/${bookSlug}` },
],
};
---
<Layout title={shortTitle} description={description}>
<Layout title={shortTitle} description={description} ogImage={coverImage} ogType="book">
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(bookJsonLd)} />
<script type="application/ld+json" set:html={JSON.stringify(breadcrumbJsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">

View File

@ -1,8 +1,10 @@
---
import Layout from '@/layouts/Layout.astro';
import FilterableBookGrid from '@/components/FilterableBookGrid';
import HandDrawn from '@/components/HandDrawn.astro';
import { getCollection } from 'astro:content';
import { serializeBook } from '@/lib/types';
import type { WithContext, CollectionPage } from 'schema-dts';
const allBooks = await getCollection('books');
const uglysBooks = allBooks
@ -12,12 +14,25 @@ const uglysBooks = allBooks
// Get unique topics for filtering
const allTopics = [...new Set(uglysBooks.flatMap(book => book.data.topics))].sort();
const serializedBooks = uglysBooks.map(serializeBook);
const siteUrl = import.meta.env.SITE || 'https://forrest.warehack.ing';
const jsonLd: WithContext<CollectionPage> = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: "Ugly's Electrical References",
description: 'The essential pocket reference for electricians - tables, formulas, and NEC code references',
url: `${siteUrl}/uglys`,
numberOfItems: uglysBooks.length,
};
---
<Layout
title="Ugly's Electrical References"
description="The essential pocket reference for electricians - packed with tables, formulas, and NEC code references"
>
<Fragment slot="structured-data">
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
</Fragment>
<div class="space-y-8">
<!-- Header -->
<div class="space-y-4">
@ -32,7 +47,7 @@ const serializedBooks = uglysBooks.map(serializeBook);
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<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>
<p class="text-muted-foreground mt-2 max-w-2xl">
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-border: oklch(0.85 0.04 230);
--sidebar-ring: oklch(0.55 0.12 250);
--hd-text-color: rgb(15 23 42);
}
.dark {
@ -118,6 +119,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--hd-text-color: rgb(248 250 252);
}
@layer base {