my website at ewancroft.uk

Compare changes

Choose any two refs to compare.

+1
.cspell.json
··· 138 138 "svelte", 139 139 "timemachine", 140 140 "ttfb", 141 + "unsub", 141 142 "urlset", 142 143 "Varepsilon", 143 144 "vercel",
+25
.env.example
··· 50 50 # Use "*" to allow all origins (not recommended for production) 51 51 # Example: https://example.com,https://app.example.com 52 52 PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com" 53 + 54 + # Cache TTL Configuration (optional) 55 + # Configure how long different types of data are cached (in minutes) 56 + # Longer TTLs reduce API calls and prevent timeouts, but data may be less fresh 57 + # Leave empty to use defaults (optimized for production) 58 + # Profile data (default: 5 min dev, 60 min prod) 59 + # CACHE_TTL_PROFILE=60 60 + # Site info (default: 5 min dev, 120 min prod) 61 + # CACHE_TTL_SITE_INFO=120 62 + # Links (default: 5 min dev, 60 min prod) 63 + # CACHE_TTL_LINKS=60 64 + # Music status (default: 2 min dev, 10 min prod) 65 + # CACHE_TTL_MUSIC_STATUS=10 66 + # Kibun status (default: 2 min dev, 15 min prod) 67 + # CACHE_TTL_KIBUN_STATUS=15 68 + # Tangled repos (default: 5 min dev, 60 min prod) 69 + # CACHE_TTL_TANGLED_REPOS=60 70 + # Blog posts (default: 5 min dev, 30 min prod) 71 + # CACHE_TTL_BLOG_POSTS=30 72 + # Publications (default: 5 min dev, 60 min prod) 73 + # CACHE_TTL_PUBLICATIONS=60 74 + # Individual posts (default: 5 min dev, 60 min prod) 75 + # CACHE_TTL_INDIVIDUAL_POST=60 76 + # Identity resolution (default: 30 min dev, 1440 min/24h prod) 77 + # CACHE_TTL_IDENTITY=1440
-767
CONFIGURATION.md
··· 1 - # Configuration Guide 2 - 3 - This guide will walk you through configuring your AT Protocol-powered personal website. Follow these steps in order to set up your site correctly. 4 - 5 - ## Table of Contents 6 - 7 - 1. [Prerequisites](#prerequisites) 8 - 2. [Environment Configuration](#environment-configuration) 9 - 3. [Publication Slug Mapping](#publication-slug-mapping) 10 - 4. [Static File Customization](#static-file-customization) 11 - 5. [Optional Features](#optional-features) 12 - 6. [Advanced Configuration](#advanced-configuration) 13 - 7. [Verification](#verification) 14 - 8. [Troubleshooting](#troubleshooting) 15 - 16 - --- 17 - 18 - ## Prerequisites 19 - 20 - Before you begin configuration, ensure you have: 21 - 22 - - **Node.js 18+** installed 23 - - **npm** package manager 24 - - An **AT Protocol DID** (Decentralized Identifier) from Bluesky 25 - - Basic knowledge of environment variables and JSON configuration 26 - 27 - ### Finding Your DID 28 - 29 - Your DID is your unique identifier in the AT Protocol network. 30 - 31 - #### Using PDSls (Recommended) 32 - 33 - 1. Visit [PDSls](https://pdsls.dev/) 34 - 2. Enter your Bluesky handle (e.g., `username.bsky.social`) 35 - 3. Look for the `Repository` field - your DID will be in the format `did:plc:...` or `did:web:...` 36 - 4. Click the arrow to the right if the full DID is not visible 37 - 38 - **Example DID**: `did:plc:abcdef123456xyz` 39 - 40 - --- 41 - 42 - ## Environment Configuration 43 - 44 - ### Step 1: Create Your Environment File 45 - 46 - Copy the example environment file: 47 - 48 - ```bash 49 - cp .env.example .env.local 50 - ``` 51 - 52 - **Important**: Use `.env.local` for your personal configuration. This file is ignored by git and keeps your settings private. 53 - 54 - ### Step 2: Configure Required Variables 55 - 56 - Edit `.env.local` and set these **required** values: 57 - 58 - ```ini 59 - # Your AT Protocol DID (Required) 60 - PUBLIC_ATPROTO_DID=did:plc:your-actual-did-here 61 - 62 - # Site Metadata (Required) 63 - PUBLIC_SITE_TITLE="Your Site Name" 64 - PUBLIC_SITE_DESCRIPTION="A brief description of your website" 65 - PUBLIC_SITE_KEYWORDS="keywords, about, your, site" 66 - PUBLIC_SITE_URL="https://yourdomain.com" 67 - ``` 68 - 69 - **Critical**: Replace `your-actual-did-here` with your actual DID from the Prerequisites section. 70 - 71 - ### Step 3: Configure Optional Variables 72 - 73 - Add these optional settings based on your needs: 74 - 75 - ```ini 76 - # WhiteWind Support (Optional, default: false) 77 - # Set to "true" only if you use WhiteWind for blogging 78 - PUBLIC_ENABLE_WHITEWIND=false 79 - 80 - # Blog Fallback URL (Optional) 81 - # Where to redirect if a blog post isn't found 82 - # Leave empty to show a 404 error instead 83 - PUBLIC_BLOG_FALLBACK_URL="" 84 - 85 - # Slingshot Configuration (Optional) 86 - # For development with local Slingshot instance 87 - PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" 88 - PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" 89 - 90 - # CORS Configuration (Optional, but recommended) 91 - # Comma-separated list of domains allowed to access your API 92 - # Use "*" for development only (not secure for production) 93 - PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com" 94 - ``` 95 - 96 - ### Environment Variable Reference 97 - 98 - | Variable | Required | Default | Purpose | 99 - |----------|----------|---------|---------| 100 - | `PUBLIC_ATPROTO_DID` | โœ… Yes | - | Your AT Protocol identifier | 101 - | `PUBLIC_SITE_TITLE` | โœ… Yes | - | Website title for SEO | 102 - | `PUBLIC_SITE_DESCRIPTION` | โœ… Yes | - | Website description for SEO | 103 - | `PUBLIC_SITE_KEYWORDS` | โœ… Yes | - | SEO keywords | 104 - | `PUBLIC_SITE_URL` | โœ… Yes | - | Your website's URL | 105 - | `PUBLIC_ENABLE_WHITEWIND` | โŒ No | `false` | Enable WhiteWind blog support | 106 - | `PUBLIC_BLOG_FALLBACK_URL` | โŒ No | `""` | Fallback URL for missing posts | 107 - | `PUBLIC_LOCAL_SLINGSHOT_URL` | โŒ No | `""` | Local Slingshot instance URL | 108 - | `PUBLIC_SLINGSHOT_URL` | โŒ No | Public URL | Public Slingshot instance | 109 - | `PUBLIC_CORS_ALLOWED_ORIGINS` | โŒ No | `"*"` | CORS allowed origins | 110 - 111 - --- 112 - 113 - ## Publication Slug Mapping 114 - 115 - The slug mapping system allows you to access your Leaflet publications via friendly URLs. 116 - 117 - ### Understanding Slugs 118 - 119 - - **Slug**: A friendly URL segment (e.g., `blog`, `essays`, `notes`) 120 - - **Publication Rkey**: The unique identifier of your Leaflet publication 121 - - **URL Format**: Your publications will be accessible at `https://yoursite.com/{slug}` 122 - 123 - ### Step 1: Find Your Publication Rkeys 124 - 125 - 1. Visit your Leaflet publication on [leaflet.pub](https://leaflet.pub/) 126 - 2. Look at the URL format: `https://leaflet.pub/lish/{did}/{publication-rkey}` 127 - 3. Copy the `{publication-rkey}` portion (e.g., `3m3x4bgbsh22k`) 128 - 129 - **Example URL**: `https://leaflet.pub/lish/did:plc:abc123/3m3x4bgbsh22k` 130 - 131 - - **Publication Rkey**: `3m3x4bgbsh22k` 132 - 133 - ### Step 2: Configure Slugs 134 - 135 - Edit `src/lib/config/slugs.ts`: 136 - 137 - ```typescript 138 - import type { SlugMapping } from '$lib/services/atproto'; 139 - 140 - /** 141 - * Maps friendly URL slugs to Leaflet publication rkeys 142 - * 143 - * Example usage: 144 - * - { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' } 145 - * Accessible at: /blog 146 - * - { slug: 'essays', publicationRkey: 'xyz789abc' } 147 - * Accessible at: /essays 148 - */ 149 - export const slugMappings: SlugMapping[] = [ 150 - { 151 - slug: 'blog', 152 - publicationRkey: '3m3x4bgbsh22k' // Replace with your actual rkey 153 - } 154 - // Add more mappings as needed: 155 - // { 156 - // slug: 'essays', 157 - // publicationRkey: 'your-essays-rkey' 158 - // }, 159 - // { 160 - // slug: 'notes', 161 - // publicationRkey: 'your-notes-rkey' 162 - // } 163 - ]; 164 - ``` 165 - 166 - ### Step 3: Understand URL Structure 167 - 168 - Once configured, your publications are accessible via: 169 - 170 - - **Publication Homepage**: `/{slug}` โ†’ Redirects to Leaflet publication 171 - - **Individual Posts**: `/{slug}/{post-rkey}` โ†’ Redirects to specific post 172 - - **RSS Feed**: `/{slug}/rss` โ†’ RSS feed for the publication 173 - 174 - **Example**: 175 - 176 - - Configuration: `{ slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }` 177 - - Homepage: `https://yoursite.com/blog` 178 - - Post: `https://yoursite.com/blog/3abc789xyz` 179 - - RSS: `https://yoursite.com/blog/rss` 180 - 181 - ### Multiple Publications Example 182 - 183 - ```typescript 184 - export const slugMappings: SlugMapping[] = [ 185 - { 186 - slug: 'blog', // Main blog 187 - publicationRkey: '3m3x4bgbsh22k' 188 - }, 189 - { 190 - slug: 'tech', // Tech articles 191 - publicationRkey: 'xyz789tech' 192 - }, 193 - { 194 - slug: 'personal', // Personal writing 195 - publicationRkey: 'abc456personal' 196 - } 197 - ]; 198 - ``` 199 - 200 - --- 201 - 202 - ## Static File Customization 203 - 204 - Several static files need to be customized for your site. 205 - 206 - ### Files to Update 207 - 208 - | File | Purpose | Action Required | 209 - |------|---------|-----------------| 210 - | `static/robots.txt` | SEO crawling rules | Update sitemap URL | 211 - | `static/sitemap.xml` | Site structure for SEO | Update with your pages | 212 - | `static/.well-known/*` | Domain verification | Replace or remove | 213 - | `static/favicon/*` | Site icons | Replace with your branding | 214 - 215 - ### Step 1: Update robots.txt 216 - 217 - Edit `static/robots.txt`: 218 - 219 - ```text 220 - User-agent: * 221 - Allow: / 222 - 223 - # Update this line with your actual domain 224 - Sitemap: https://yourdomain.com/sitemap.xml 225 - ``` 226 - 227 - ### Step 2: Update sitemap.xml 228 - 229 - Edit `static/sitemap.xml`: 230 - 231 - ```xml 232 - <?xml version="1.0" encoding="UTF-8"?> 233 - <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 234 - <!-- Homepage --> 235 - <url> 236 - <loc>https://yourdomain.com/</loc> 237 - <changefreq>daily</changefreq> 238 - <priority>1.0</priority> 239 - </url> 240 - 241 - <!-- Add your publication slugs --> 242 - <url> 243 - <loc>https://yourdomain.com/blog</loc> 244 - <changefreq>weekly</changefreq> 245 - <priority>0.8</priority> 246 - </url> 247 - 248 - <!-- Add other important pages --> 249 - <url> 250 - <loc>https://yourdomain.com/site/meta</loc> 251 - <changefreq>monthly</changefreq> 252 - <priority>0.5</priority> 253 - </url> 254 - </urlset> 255 - ``` 256 - 257 - ### Step 3: Update Favicon 258 - 259 - Replace files in `static/favicon/`: 260 - 261 - 1. Generate favicons using [RealFaviconGenerator](https://realfavicongenerator.net/) 262 - 2. Replace all files in `static/favicon/` with your generated icons 263 - 3. Ensure these files are present: 264 - - `favicon.ico` 265 - - `apple-touch-icon.png` 266 - - `favicon-16x16.png` 267 - - `favicon-32x32.png` 268 - - `site.webmanifest` 269 - 270 - ### Step 4: Update or Remove .well-known Files 271 - 272 - The `static/.well-known/` directory contains domain verification files. 273 - 274 - #### Option A: Replace with your own 275 - 276 - ```bash 277 - rm -rf static/.well-known/* 278 - # Add your own verification files here 279 - ``` 280 - 281 - #### Option B: Remove entirely (if you don't need verification) 282 - 283 - ```bash 284 - rm -rf static/.well-known/ 285 - ``` 286 - 287 - Common `.well-known` files: 288 - 289 - - `atproto-did` - AT Protocol domain verification 290 - - `security.txt` - Security contact information 291 - - Domain verification files for various services 292 - 293 - --- 294 - 295 - ## Optional Features 296 - 297 - ### WhiteWind Blog Support 298 - 299 - **When to enable**: If you publish blog posts on WhiteWind (`com.whtwnd.blog.entry` records). 300 - 301 - **Configuration**: 302 - 303 - ```ini 304 - # In .env.local 305 - PUBLIC_ENABLE_WHITEWIND=true 306 - ``` 307 - 308 - **Behavior**: 309 - 310 - - With WhiteWind **disabled** (default): 311 - - Only Leaflet posts are fetched and displayed 312 - - RSS feeds redirect to Leaflet's native feeds 313 - - Post redirects only check Leaflet 314 - 315 - - With WhiteWind **enabled**: 316 - - Both Leaflet and WhiteWind posts are displayed 317 - - RSS feeds include links to WhiteWind posts 318 - - Post redirects check Leaflet first, then WhiteWind 319 - - Draft and non-public WhiteWind posts are filtered out 320 - 321 - **Note**: Most users should keep WhiteWind disabled unless they specifically use it. 322 - 323 - ### Custom Blog Fallback 324 - 325 - Redirect users to an archive or external blog when posts aren't found. 326 - 327 - ```ini 328 - # In .env.local 329 - PUBLIC_BLOG_FALLBACK_URL="https://archive.yourdomain.com" 330 - ``` 331 - 332 - **Behavior**: 333 - 334 - - If a post isn't found on Leaflet (or WhiteWind) 335 - - AND `PUBLIC_BLOG_FALLBACK_URL` is set 336 - - Then redirect to: `{FALLBACK_URL}/{slug}/{rkey}` 337 - 338 - **Example**: 339 - 340 - - Missing post: `/blog/3abc789` 341 - - Redirects to: `https://archive.yourdomain.com/blog/3abc789` 342 - 343 - ### CORS Configuration 344 - 345 - Control which domains can access your API endpoints. 346 - 347 - **Development** (allow all): 348 - 349 - ```ini 350 - PUBLIC_CORS_ALLOWED_ORIGINS="*" 351 - ``` 352 - 353 - **Production** (specific domains): 354 - 355 - ```ini 356 - # Single domain 357 - PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com" 358 - 359 - # Multiple domains 360 - PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com,https://app.yourdomain.com,https://www.yourdomain.com" 361 - ``` 362 - 363 - **Security Note**: Always use specific domain lists in production, never use `*`. 364 - 365 - --- 366 - 367 - ## Advanced Configuration 368 - 369 - ### Custom Lexicon Support 370 - 371 - The site automatically displays data from these AT Protocol lexicons: 372 - 373 - #### Site Information (`uk.ewancroft.site.info`) 374 - 375 - - Technology stack 376 - - Privacy statements 377 - - Credits and licenses 378 - - No configuration needed - automatically fetched 379 - 380 - #### Music Status (`fm.teal.alpha.*`) 381 - 382 - - Current playing status via teal.fm 383 - - Automatic album artwork from MusicBrainz 384 - - Scrobbles from Last.fm, Spotify, etc. 385 - - No configuration needed 386 - 387 - #### Mood Status (`social.kibun.status`) 388 - 389 - - Current mood/feeling via kibun.social 390 - - Emoji and text display 391 - - No configuration needed 392 - 393 - #### Link Board (`blue.linkat.board`) 394 - 395 - - Curated link collections from Linkat 396 - - Emoji icons for each link 397 - - No configuration needed 398 - 399 - #### Tangled Repositories (`sh.tangled.repo`) 400 - 401 - - Code repository display 402 - - Descriptions, labels, creation dates 403 - - No configuration needed 404 - 405 - **All lexicons are automatically fetched using your `PUBLIC_ATPROTO_DID`** 406 - 407 - ### Slingshot Configuration 408 - 409 - Slingshot is an AT Protocol data aggregator for faster queries. 410 - 411 - ```ini 412 - # Local development instance (optional) 413 - PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" 414 - 415 - # Public instance (default fallback) 416 - PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" 417 - ``` 418 - 419 - **Default Behavior**: 420 - 421 - 1. Try local Slingshot (if URL is set and reachable) 422 - 2. Fallback to public Slingshot 423 - 3. Fallback to user's PDS 424 - 4. Fallback to Bluesky public API 425 - 426 - **Note**: Most users can leave these at their defaults. 427 - 428 - ### Theme Customization 429 - 430 - The site uses Tailwind CSS with custom semantic colors. To customize: 431 - 432 - 1. Edit `src/app.css` for global color scheme: 433 - 434 - ```css 435 - @theme { 436 - --color-canvas: /* Background color */; 437 - --color-ink: /* Text color */; 438 - --color-primary: /* Accent color */; 439 - } 440 - ``` 441 - 442 - 1. Dark mode colors are automatically adjusted via Tailwind's `dark:` variants 443 - 444 - 1. Wolf mode and theme toggle work automatically with any color scheme 445 - 446 - --- 447 - 448 - ## Verification 449 - 450 - After configuration, verify everything works: 451 - 452 - ### Step 1: Install Dependencies 453 - 454 - ```bash 455 - npm install 456 - ``` 457 - 458 - ### Step 2: Start Development Server 459 - 460 - ```bash 461 - npm run dev 462 - ``` 463 - 464 - Visit `http://localhost:5173` 465 - 466 - ### Step 3: Check Core Features 467 - 468 - Verify these elements appear correctly: 469 - 470 - - [ ] **Profile Card**: Shows your Bluesky profile information 471 - - Avatar and banner image 472 - - Display name and handle 473 - - Bio text 474 - - Follower/following counts 475 - 476 - - [ ] **Site Metadata**: Check `http://localhost:5173/site/meta` 477 - - Site information loads correctly 478 - - Credits, tech stack, privacy info display 479 - 480 - - [ ] **Blog Access**: Test your slug configuration 481 - - Visit `http://localhost:5173/{your-slug}` 482 - - Should redirect to your Leaflet publication 483 - - RSS feed works at `http://localhost:5173/{your-slug}/rss` 484 - 485 - - [ ] **Optional Features** (if enabled): 486 - - Music status card (if you use teal.fm) 487 - - Mood status card (if you use kibun.social) 488 - - Link board (if you use Linkat) 489 - - Repositories (if you use Tangled) 490 - - Latest Bluesky post 491 - 492 - ### Step 4: Check Browser Console 493 - 494 - Open browser DevTools (F12) and check for: 495 - 496 - - โœ… No error messages in Console tab 497 - - โœ… Successful API responses in Network tab 498 - - โœ… No 404 errors for static files 499 - 500 - ### Step 5: Test Responsive Design 501 - 502 - Check the site at different screen sizes: 503 - 504 - - Mobile (375px width) 505 - - Tablet (768px width) 506 - - Desktop (1280px+ width) 507 - 508 - ### Step 6: Verify SEO Metadata 509 - 510 - View page source and check for: 511 - 512 - - `<title>` tag with your site title 513 - - `<meta name="description">` with your description 514 - - Open Graph tags (`og:title`, `og:description`, etc.) 515 - - Twitter Card tags (`twitter:card`, `twitter:title`, etc.) 516 - 517 - --- 518 - 519 - ## Troubleshooting 520 - 521 - ### Profile Data Not Loading 522 - 523 - **Symptom**: Profile card shows "Profile not found" or loading state persists 524 - 525 - **Solutions**: 526 - 527 - 1. Verify `PUBLIC_ATPROTO_DID` is correct in `.env.local` 528 - 2. Check your DID format: should be `did:plc:...` or `did:web:...` 529 - 3. Ensure your Bluesky account is active and public 530 - 4. Check browser console for specific error messages 531 - 5. Clear cache and hard refresh (Ctrl+Shift+R / Cmd+Shift+R) 532 - 533 - ### Publications Not Found 534 - 535 - **Symptom**: Blog pages show 404 or "Not Found" errors 536 - 537 - **Solutions**: 538 - 539 - 1. Verify publication rkey in `src/lib/config/slugs.ts` matches your Leaflet publication 540 - 2. Visit your Leaflet publication URL and confirm the rkey is correct 541 - 3. Ensure the publication is public (not draft/private) 542 - 4. Check if documents exist in the publication 543 - 5. If using WhiteWind, verify `PUBLIC_ENABLE_WHITEWIND=true` if needed 544 - 545 - ### Music Status Not Showing 546 - 547 - **Symptom**: Music card doesn't appear or shows no data 548 - 549 - **Solutions**: 550 - 551 - 1. Verify you have teal.fm configured with your Bluesky account 552 - 2. Check if you have any scrobbles in your teal.fm history 553 - 3. Ensure your scrobbler (e.g., piper) is running and connected 554 - 4. Album artwork requires MusicBrainz IDs or blob storage 555 - 5. Check browser console for MusicBrainz API errors 556 - 557 - ### RSS Feeds Not Working 558 - 559 - **Symptom**: RSS feed shows errors or no posts 560 - 561 - **Solutions**: 562 - 563 - 1. Check slug configuration in `src/lib/config/slugs.ts` 564 - 2. Verify publication has published documents (not drafts) 565 - 3. If using WhiteWind: 566 - - Ensure `PUBLIC_ENABLE_WHITEWIND=true` 567 - - Verify you have published WhiteWind posts 568 - 4. Test feed URL directly: `http://localhost:5173/{slug}/rss` 569 - 5. Check Content-Type header is `application/rss+xml` 570 - 571 - ### Environment Variables Not Applied 572 - 573 - **Symptom**: Changes to `.env.local` don't take effect 574 - 575 - **Solutions**: 576 - 577 - 1. Restart the development server (`npm run dev`) 578 - 2. Verify variable names start with `PUBLIC_` for client-side access 579 - 3. Check for typos in variable names 580 - 4. Ensure `.env.local` is in the project root directory 581 - 5. Clear `.svelte-kit` cache: `rm -rf .svelte-kit && npm run dev` 582 - 583 - ### Build Errors 584 - 585 - **Symptom**: `npm run build` fails with errors 586 - 587 - **Solutions**: 588 - 589 - ```bash 590 - # Clean build artifacts 591 - rm -rf .svelte-kit node_modules package-lock.json 592 - 593 - # Reinstall dependencies 594 - npm install 595 - 596 - # Try building again 597 - npm run build 598 - ``` 599 - 600 - ### CORS Errors in Production 601 - 602 - **Symptom**: API requests fail with CORS errors 603 - 604 - **Solutions**: 605 - 606 - 1. Add your production domain to `PUBLIC_CORS_ALLOWED_ORIGINS` 607 - 2. Ensure the domain includes the protocol (`https://`) 608 - 3. For multiple domains, separate with commas (no spaces) 609 - 4. Avoid using `*` in production for security 610 - 5. Check that the origin header matches exactly (including www or non-www) 611 - 612 - ### TypeScript Errors 613 - 614 - **Symptom**: Type errors in development 615 - 616 - **Solutions**: 617 - 618 - ```bash 619 - # Run type checking 620 - npm run check 621 - 622 - # Watch mode for continuous checking 623 - npm run check:watch 624 - 625 - # Clear and rebuild 626 - rm -rf .svelte-kit && npm run dev 627 - ``` 628 - 629 - ### Dark Mode Not Working 630 - 631 - **Symptom**: Dark mode toggle doesn't change theme 632 - 633 - **Solutions**: 634 - 635 - 1. Check if browser supports `prefers-color-scheme` 636 - 2. Clear browser localStorage: `localStorage.clear()` in console 637 - 3. Verify Tailwind's dark mode is configured in `tailwind.config.js` 638 - 4. Check that dark mode classes are present in HTML (inspect element) 639 - 640 - ### Wolf Mode Issues 641 - 642 - **Symptom**: Wolf mode toggle doesn't transform text 643 - 644 - **Solutions**: 645 - 646 - 1. Ensure JavaScript is enabled in browser 647 - 2. Check browser console for errors 648 - 3. Verify the wolf mode store is imported correctly 649 - 4. Test on different text elements to confirm it's working 650 - 5. Remember: numbers and navigation are intentionally preserved 651 - 652 - --- 653 - 654 - ## Getting Help 655 - 656 - If you encounter issues not covered here: 657 - 658 - 1. **Check Browser Console**: Press F12 and look for error messages 659 - 2. **Review README**: See [README.md](./README.md) for detailed feature documentation 660 - 3. **GitHub Issues**: Search existing issues or create a new one 661 - 4. **AT Protocol Docs**: Visit [atproto.com](https://atproto.com/) for protocol details 662 - 5. **SvelteKit Docs**: Check [kit.svelte.dev](https://kit.svelte.dev/) for framework help 663 - 664 - ### Useful Debugging Commands 665 - 666 - ```bash 667 - # Check environment variables are loaded 668 - npm run dev -- --debug 669 - 670 - # View detailed build output 671 - npm run build -- --verbose 672 - 673 - # Type-check without building 674 - npm run check 675 - 676 - # Format code (may fix some issues) 677 - npm run format 678 - ``` 679 - 680 - ### Log Collection for Bug Reports 681 - 682 - When reporting issues, include: 683 - 684 - 1. Browser console errors (F12 โ†’ Console tab) 685 - 2. Network tab showing failed requests (F12 โ†’ Network tab) 686 - 3. Your `.env.local` configuration (remove sensitive data like DIDs) 687 - 4. Node.js and npm versions: `node --version && npm --version` 688 - 5. Operating system and browser version 689 - 690 - --- 691 - 692 - ## Next Steps 693 - 694 - After completing configuration: 695 - 696 - 1. **Customize Content**: 697 - - Update your Bluesky profile bio and banner 698 - - Publish posts to your Leaflet publications 699 - - Add site information via AT Protocol records 700 - 701 - 2. **Deploy Your Site**: 702 - - See [README.md](./README.md#-deployment) for deployment options 703 - - Choose a platform (Vercel, Netlify, Cloudflare Pages, etc.) 704 - - Configure production environment variables 705 - - Set up custom domain 706 - 707 - 3. **Enhance Your Site**: 708 - - Add custom styling in `src/app.css` 709 - - Create new components in `src/lib/components/` 710 - - Extend functionality with new AT Protocol lexicons 711 - - Customize layouts and pages 712 - 713 - 4. **Monitor and Maintain**: 714 - - Check RSS feeds regularly 715 - - Update dependencies: `npm update` 716 - - Monitor browser console for errors 717 - - Keep AT Protocol records up to date 718 - 719 - --- 720 - 721 - ## Configuration Checklist 722 - 723 - Use this checklist to track your configuration progress: 724 - 725 - ### Required Configuration 726 - 727 - - [ ] Set `PUBLIC_ATPROTO_DID` in `.env.local` 728 - - [ ] Set `PUBLIC_SITE_TITLE` in `.env.local` 729 - - [ ] Set `PUBLIC_SITE_DESCRIPTION` in `.env.local` 730 - - [ ] Set `PUBLIC_SITE_KEYWORDS` in `.env.local` 731 - - [ ] Set `PUBLIC_SITE_URL` in `.env.local` 732 - - [ ] Configure slug mappings in `src/lib/config/slugs.ts` 733 - - [ ] Update `static/robots.txt` with your domain 734 - - [ ] Update `static/sitemap.xml` with your pages 735 - 736 - ### Optional Configuration 737 - 738 - - [ ] Enable WhiteWind support (if needed) 739 - - [ ] Configure blog fallback URL (if desired) 740 - - [ ] Set CORS allowed origins for production 741 - - [ ] Replace favicon files with your branding 742 - - [ ] Update or remove `.well-known` files 743 - - [ ] Configure Slingshot URLs (if using local instance) 744 - 745 - ### Verification (Checklist) 746 - 747 - - [ ] Development server starts without errors 748 - - [ ] Profile card loads correctly 749 - - [ ] Blog slug redirects work 750 - - [ ] RSS feeds generate successfully 751 - - [ ] Optional features display (if enabled) 752 - - [ ] SEO metadata is correct in page source 753 - - [ ] Site works on mobile, tablet, and desktop 754 - - [ ] Dark mode and wolf mode toggles work 755 - 756 - ### Deployment Preparation 757 - 758 - - [ ] Test production build: `npm run build` 759 - - [ ] Preview production build: `npm run preview` 760 - - [ ] Configure production environment variables 761 - - [ ] Choose and configure deployment platform 762 - - [ ] Set up custom domain (if applicable) 763 - - [ ] Configure SSL certificate (handled by most platforms) 764 - 765 - --- 766 - 767 - **Configuration complete!** Your AT Protocol-powered personal website is ready to use. For detailed feature documentation, see [README.md](./README.md).
+3 -3
README.md
··· 2 2 3 3 A modern, feature-rich personal website powered by AT Protocol, built with SvelteKit 2 and Tailwind CSS 4. 4 4 5 - > **Note**: This repository contains the source code for [Ewan's Corner](https://ewancroft.uk). The current configuration (environment variables, slug mappings, static files) is specific to that website, but the codebase is designed to be easily adapted for your own AT Protocol-powered site. See [CONFIGURATION.md](./CONFIGURATION.md) for detailed setup instructions. 5 + > **Note**: This repository contains the source code for [Ewan's Corner](https://ewancroft.uk). The current configuration (environment variables, slug mappings, static files) is specific to that website, but the codebase is designed to be easily adapted for your own AT Protocol-powered site. See [Configuration Guide](./docs/configuration.md) for detailed setup instructions. 6 6 7 7 ## ๐ŸŒŸ Features 8 8 ··· 95 95 96 96 ## ๐Ÿ“‹ Configuration 97 97 98 - For detailed configuration instructions, see [CONFIGURATION.md](./CONFIGURATION.md). 98 + For detailed configuration instructions, see the [Configuration Guide](./docs/configuration.md). 99 99 100 100 Quick start: 101 101 ··· 132 132 cp .env .env.local 133 133 ``` 134 134 135 - Edit `.env.local` with your settings (see [CONFIGURATION.md](./CONFIGURATION.md) for details) 135 + Edit `.env.local` with your settings (see [Configuration Guide](./docs/configuration.md) for details) 136 136 137 137 4. **Configure publication slugs** in `src/lib/config/slugs.ts` 138 138
+60
docs/README.md
··· 1 + # Documentation 2 + 3 + Welcome to the project documentation! This directory contains all technical documentation for the AT Protocol-powered personal website. 4 + 5 + ## ๐Ÿ“š Available Documentation 6 + 7 + ### [Configuration Guide](./configuration.md) 8 + 9 + Complete setup and configuration guide for your personal website. Covers: 10 + 11 + - Environment variables setup 12 + - Publication slug mapping 13 + - Static file customization 14 + - Optional features (WhiteWind, CORS, etc.) 15 + - Troubleshooting common issues 16 + 17 + **Start here if you're setting up the site for the first time.** 18 + 19 + ### [Theme System](./theme-system.md) 20 + 21 + Documentation for the centralized color theme system. Learn how to: 22 + 23 + - Add new Colour Themes 24 + - Customize existing themes 25 + - Understand the theme architecture 26 + - Use the theme configuration API 27 + 28 + **Read this if you want to customize or add Colour Themes.** 29 + 30 + ## ๐Ÿš€ Quick Links 31 + 32 + - [Main README](../README.md) - Project overview and features 33 + - [Environment Example](../.env.example) - Environment variable template 34 + - [Theme Config](../src/lib/config/themes.config.ts) - Central theme configuration 35 + 36 + ## ๐Ÿ“– Documentation Structure 37 + 38 + ```plaintext 39 + docs/ 40 + โ”œโ”€โ”€ README.md # This file - documentation index 41 + โ”œโ”€โ”€ configuration.md # Setup and configuration guide 42 + โ””โ”€โ”€ theme-system.md # Theme system documentation 43 + ``` 44 + 45 + ## ๐Ÿ’ก Contributing to Documentation 46 + 47 + When adding new documentation: 48 + 49 + 1. Create a new `.md` file in this directory 50 + 2. Add it to the "Available Documentation" section above 51 + 3. Use clear headings and examples 52 + 4. Include a table of contents for longer documents 53 + 5. Link to related documentation where relevant 54 + 55 + ## ๐Ÿ”— External Resources 56 + 57 + - [AT Protocol Documentation](https://atproto.com/) 58 + - [SvelteKit Documentation](https://kit.svelte.dev/) 59 + - [Tailwind CSS Documentation](https://tailwindcss.com/) 60 + - [Bluesky](https://bsky.app/)
+767
docs/configuration.md
··· 1 + # Configuration Guide 2 + 3 + This guide will walk you through configuring your AT Protocol-powered personal website. Follow these steps in order to set up your site correctly. 4 + 5 + ## Table of Contents 6 + 7 + 1. [Prerequisites](#prerequisites) 8 + 2. [Environment Configuration](#environment-configuration) 9 + 3. [Publication Slug Mapping](#publication-slug-mapping) 10 + 4. [Static File Customization](#static-file-customization) 11 + 5. [Optional Features](#optional-features) 12 + 6. [Advanced Configuration](#advanced-configuration) 13 + 7. [Verification](#verification) 14 + 8. [Troubleshooting](#troubleshooting) 15 + 16 + --- 17 + 18 + ## Prerequisites 19 + 20 + Before you begin configuration, ensure you have: 21 + 22 + - **Node.js 18+** installed 23 + - **npm** package manager 24 + - An **AT Protocol DID** (Decentralized Identifier) from Bluesky 25 + - Basic knowledge of environment variables and JSON configuration 26 + 27 + ### Finding Your DID 28 + 29 + Your DID is your unique identifier in the AT Protocol network. 30 + 31 + #### Using PDSls (Recommended) 32 + 33 + 1. Visit [PDSls](https://pdsls.dev/) 34 + 2. Enter your Bluesky handle (e.g., `username.bsky.social`) 35 + 3. Look for the `Repository` field - your DID will be in the format `did:plc:...` or `did:web:...` 36 + 4. Click the arrow to the right if the full DID is not visible 37 + 38 + **Example DID**: `did:plc:abcdef123456xyz` 39 + 40 + --- 41 + 42 + ## Environment Configuration 43 + 44 + ### Step 1: Create Your Environment File 45 + 46 + Copy the example environment file: 47 + 48 + ```bash 49 + cp .env.example .env.local 50 + ``` 51 + 52 + **Important**: Use `.env.local` for your personal configuration. This file is ignored by git and keeps your settings private. 53 + 54 + ### Step 2: Configure Required Variables 55 + 56 + Edit `.env.local` and set these **required** values: 57 + 58 + ```ini 59 + # Your AT Protocol DID (Required) 60 + PUBLIC_ATPROTO_DID=did:plc:your-actual-did-here 61 + 62 + # Site Metadata (Required) 63 + PUBLIC_SITE_TITLE="Your Site Name" 64 + PUBLIC_SITE_DESCRIPTION="A brief description of your website" 65 + PUBLIC_SITE_KEYWORDS="keywords, about, your, site" 66 + PUBLIC_SITE_URL="https://yourdomain.com" 67 + ``` 68 + 69 + **Critical**: Replace `your-actual-did-here` with your actual DID from the Prerequisites section. 70 + 71 + ### Step 3: Configure Optional Variables 72 + 73 + Add these optional settings based on your needs: 74 + 75 + ```ini 76 + # WhiteWind Support (Optional, default: false) 77 + # Set to "true" only if you use WhiteWind for blogging 78 + PUBLIC_ENABLE_WHITEWIND=false 79 + 80 + # Blog Fallback URL (Optional) 81 + # Where to redirect if a blog post isn't found 82 + # Leave empty to show a 404 error instead 83 + PUBLIC_BLOG_FALLBACK_URL="" 84 + 85 + # Slingshot Configuration (Optional) 86 + # For development with local Slingshot instance 87 + PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" 88 + PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" 89 + 90 + # CORS Configuration (Optional, but recommended) 91 + # Comma-separated list of domains allowed to access your API 92 + # Use "*" for development only (not secure for production) 93 + PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com" 94 + ``` 95 + 96 + ### Environment Variable Reference 97 + 98 + | Variable | Required | Default | Purpose | 99 + |----------|----------|---------|---------| 100 + | `PUBLIC_ATPROTO_DID` | โœ… Yes | - | Your AT Protocol identifier | 101 + | `PUBLIC_SITE_TITLE` | โœ… Yes | - | Website title for SEO | 102 + | `PUBLIC_SITE_DESCRIPTION` | โœ… Yes | - | Website description for SEO | 103 + | `PUBLIC_SITE_KEYWORDS` | โœ… Yes | - | SEO keywords | 104 + | `PUBLIC_SITE_URL` | โœ… Yes | - | Your website's URL | 105 + | `PUBLIC_ENABLE_WHITEWIND` | โŒ No | `false` | Enable WhiteWind blog support | 106 + | `PUBLIC_BLOG_FALLBACK_URL` | โŒ No | `""` | Fallback URL for missing posts | 107 + | `PUBLIC_LOCAL_SLINGSHOT_URL` | โŒ No | `""` | Local Slingshot instance URL | 108 + | `PUBLIC_SLINGSHOT_URL` | โŒ No | Public URL | Public Slingshot instance | 109 + | `PUBLIC_CORS_ALLOWED_ORIGINS` | โŒ No | `"*"` | CORS allowed origins | 110 + 111 + --- 112 + 113 + ## Publication Slug Mapping 114 + 115 + The slug mapping system allows you to access your Leaflet publications via friendly URLs. 116 + 117 + ### Understanding Slugs 118 + 119 + - **Slug**: A friendly URL segment (e.g., `blog`, `essays`, `notes`) 120 + - **Publication Rkey**: The unique identifier of your Leaflet publication 121 + - **URL Format**: Your publications will be accessible at `https://yoursite.com/{slug}` 122 + 123 + ### Step 1: Find Your Publication Rkeys 124 + 125 + 1. Visit your Leaflet publication on [leaflet.pub](https://leaflet.pub/) 126 + 2. Look at the URL format: `https://leaflet.pub/lish/{did}/{publication-rkey}` 127 + 3. Copy the `{publication-rkey}` portion (e.g., `3m3x4bgbsh22k`) 128 + 129 + **Example URL**: `https://leaflet.pub/lish/did:plc:abc123/3m3x4bgbsh22k` 130 + 131 + - **Publication Rkey**: `3m3x4bgbsh22k` 132 + 133 + ### Step 2: Configure Slugs 134 + 135 + Edit `src/lib/config/slugs.ts`: 136 + 137 + ```typescript 138 + import type { SlugMapping } from '$lib/services/atproto'; 139 + 140 + /** 141 + * Maps friendly URL slugs to Leaflet publication rkeys 142 + * 143 + * Example usage: 144 + * - { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' } 145 + * Accessible at: /blog 146 + * - { slug: 'essays', publicationRkey: 'xyz789abc' } 147 + * Accessible at: /essays 148 + */ 149 + export const slugMappings: SlugMapping[] = [ 150 + { 151 + slug: 'blog', 152 + publicationRkey: '3m3x4bgbsh22k' // Replace with your actual rkey 153 + } 154 + // Add more mappings as needed: 155 + // { 156 + // slug: 'essays', 157 + // publicationRkey: 'your-essays-rkey' 158 + // }, 159 + // { 160 + // slug: 'notes', 161 + // publicationRkey: 'your-notes-rkey' 162 + // } 163 + ]; 164 + ``` 165 + 166 + ### Step 3: Understand URL Structure 167 + 168 + Once configured, your publications are accessible via: 169 + 170 + - **Publication Homepage**: `/{slug}` โ†’ Redirects to Leaflet publication 171 + - **Individual Posts**: `/{slug}/{post-rkey}` โ†’ Redirects to specific post 172 + - **RSS Feed**: `/{slug}/rss` โ†’ RSS feed for the publication 173 + 174 + **Example**: 175 + 176 + - Configuration: `{ slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }` 177 + - Homepage: `https://yoursite.com/blog` 178 + - Post: `https://yoursite.com/blog/3abc789xyz` 179 + - RSS: `https://yoursite.com/blog/rss` 180 + 181 + ### Multiple Publications Example 182 + 183 + ```typescript 184 + export const slugMappings: SlugMapping[] = [ 185 + { 186 + slug: 'blog', // Main blog 187 + publicationRkey: '3m3x4bgbsh22k' 188 + }, 189 + { 190 + slug: 'tech', // Tech articles 191 + publicationRkey: 'xyz789tech' 192 + }, 193 + { 194 + slug: 'personal', // Personal writing 195 + publicationRkey: 'abc456personal' 196 + } 197 + ]; 198 + ``` 199 + 200 + --- 201 + 202 + ## Static File Customization 203 + 204 + Several static files need to be customized for your site. 205 + 206 + ### Files to Update 207 + 208 + | File | Purpose | Action Required | 209 + |------|---------|-----------------| 210 + | `static/robots.txt` | SEO crawling rules | Update sitemap URL | 211 + | `static/sitemap.xml` | Site structure for SEO | Update with your pages | 212 + | `static/.well-known/*` | Domain verification | Replace or remove | 213 + | `static/favicon/*` | Site icons | Replace with your branding | 214 + 215 + ### Step 1: Update robots.txt 216 + 217 + Edit `static/robots.txt`: 218 + 219 + ```text 220 + User-agent: * 221 + Allow: / 222 + 223 + # Update this line with your actual domain 224 + Sitemap: https://yourdomain.com/sitemap.xml 225 + ``` 226 + 227 + ### Step 2: Update sitemap.xml 228 + 229 + Edit `static/sitemap.xml`: 230 + 231 + ```xml 232 + <?xml version="1.0" encoding="UTF-8"?> 233 + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 234 + <!-- Homepage --> 235 + <url> 236 + <loc>https://yourdomain.com/</loc> 237 + <changefreq>daily</changefreq> 238 + <priority>1.0</priority> 239 + </url> 240 + 241 + <!-- Add your publication slugs --> 242 + <url> 243 + <loc>https://yourdomain.com/blog</loc> 244 + <changefreq>weekly</changefreq> 245 + <priority>0.8</priority> 246 + </url> 247 + 248 + <!-- Add other important pages --> 249 + <url> 250 + <loc>https://yourdomain.com/site/meta</loc> 251 + <changefreq>monthly</changefreq> 252 + <priority>0.5</priority> 253 + </url> 254 + </urlset> 255 + ``` 256 + 257 + ### Step 3: Update Favicon 258 + 259 + Replace files in `static/favicon/`: 260 + 261 + 1. Generate favicons using [RealFaviconGenerator](https://realfavicongenerator.net/) 262 + 2. Replace all files in `static/favicon/` with your generated icons 263 + 3. Ensure these files are present: 264 + - `favicon.ico` 265 + - `apple-touch-icon.png` 266 + - `favicon-16x16.png` 267 + - `favicon-32x32.png` 268 + - `site.webmanifest` 269 + 270 + ### Step 4: Update or Remove .well-known Files 271 + 272 + The `static/.well-known/` directory contains domain verification files. 273 + 274 + #### Option A: Replace with your own 275 + 276 + ```bash 277 + rm -rf static/.well-known/* 278 + # Add your own verification files here 279 + ``` 280 + 281 + #### Option B: Remove entirely (if you don't need verification) 282 + 283 + ```bash 284 + rm -rf static/.well-known/ 285 + ``` 286 + 287 + Common `.well-known` files: 288 + 289 + - `atproto-did` - AT Protocol domain verification 290 + - `security.txt` - Security contact information 291 + - Domain verification files for various services 292 + 293 + --- 294 + 295 + ## Optional Features 296 + 297 + ### WhiteWind Blog Support 298 + 299 + **When to enable**: If you publish blog posts on WhiteWind (`com.whtwnd.blog.entry` records). 300 + 301 + **Configuration**: 302 + 303 + ```ini 304 + # In .env.local 305 + PUBLIC_ENABLE_WHITEWIND=true 306 + ``` 307 + 308 + **Behavior**: 309 + 310 + - With WhiteWind **disabled** (default): 311 + - Only Leaflet posts are fetched and displayed 312 + - RSS feeds redirect to Leaflet's native feeds 313 + - Post redirects only check Leaflet 314 + 315 + - With WhiteWind **enabled**: 316 + - Both Leaflet and WhiteWind posts are displayed 317 + - RSS feeds include links to WhiteWind posts 318 + - Post redirects check Leaflet first, then WhiteWind 319 + - Draft and non-public WhiteWind posts are filtered out 320 + 321 + **Note**: Most users should keep WhiteWind disabled unless they specifically use it. 322 + 323 + ### Custom Blog Fallback 324 + 325 + Redirect users to an archive or external blog when posts aren't found. 326 + 327 + ```ini 328 + # In .env.local 329 + PUBLIC_BLOG_FALLBACK_URL="https://archive.yourdomain.com" 330 + ``` 331 + 332 + **Behavior**: 333 + 334 + - If a post isn't found on Leaflet (or WhiteWind) 335 + - AND `PUBLIC_BLOG_FALLBACK_URL` is set 336 + - Then redirect to: `{FALLBACK_URL}/{slug}/{rkey}` 337 + 338 + **Example**: 339 + 340 + - Missing post: `/blog/3abc789` 341 + - Redirects to: `https://archive.yourdomain.com/blog/3abc789` 342 + 343 + ### CORS Configuration 344 + 345 + Control which domains can access your API endpoints. 346 + 347 + **Development** (allow all): 348 + 349 + ```ini 350 + PUBLIC_CORS_ALLOWED_ORIGINS="*" 351 + ``` 352 + 353 + **Production** (specific domains): 354 + 355 + ```ini 356 + # Single domain 357 + PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com" 358 + 359 + # Multiple domains 360 + PUBLIC_CORS_ALLOWED_ORIGINS="https://yourdomain.com,https://app.yourdomain.com,https://www.yourdomain.com" 361 + ``` 362 + 363 + **Security Note**: Always use specific domain lists in production, never use `*`. 364 + 365 + --- 366 + 367 + ## Advanced Configuration 368 + 369 + ### Custom Lexicon Support 370 + 371 + The site automatically displays data from these AT Protocol lexicons: 372 + 373 + #### Site Information (`uk.ewancroft.site.info`) 374 + 375 + - Technology stack 376 + - Privacy statements 377 + - Credits and licenses 378 + - No configuration needed - automatically fetched 379 + 380 + #### Music Status (`fm.teal.alpha.*`) 381 + 382 + - Current playing status via teal.fm 383 + - Automatic album artwork from MusicBrainz 384 + - Scrobbles from Last.fm, Spotify, etc. 385 + - No configuration needed 386 + 387 + #### Mood Status (`social.kibun.status`) 388 + 389 + - Current mood/feeling via kibun.social 390 + - Emoji and text display 391 + - No configuration needed 392 + 393 + #### Link Board (`blue.linkat.board`) 394 + 395 + - Curated link collections from Linkat 396 + - Emoji icons for each link 397 + - No configuration needed 398 + 399 + #### Tangled Repositories (`sh.tangled.repo`) 400 + 401 + - Code repository display 402 + - Descriptions, labels, creation dates 403 + - No configuration needed 404 + 405 + **All lexicons are automatically fetched using your `PUBLIC_ATPROTO_DID`** 406 + 407 + ### Slingshot Configuration 408 + 409 + Slingshot is an AT Protocol data aggregator for faster queries. 410 + 411 + ```ini 412 + # Local development instance (optional) 413 + PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000" 414 + 415 + # Public instance (default fallback) 416 + PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue" 417 + ``` 418 + 419 + **Default Behavior**: 420 + 421 + 1. Try local Slingshot (if URL is set and reachable) 422 + 2. Fallback to public Slingshot 423 + 3. Fallback to user's PDS 424 + 4. Fallback to Bluesky public API 425 + 426 + **Note**: Most users can leave these at their defaults. 427 + 428 + ### Theme Customization 429 + 430 + The site uses Tailwind CSS with custom semantic colors. To customize: 431 + 432 + 1. Edit `src/app.css` for global color scheme: 433 + 434 + ```css 435 + @theme { 436 + --color-canvas: /* Background color */; 437 + --color-ink: /* Text color */; 438 + --color-primary: /* Accent color */; 439 + } 440 + ``` 441 + 442 + 1. Dark mode colors are automatically adjusted via Tailwind's `dark:` variants 443 + 444 + 1. Wolf mode and theme toggle work automatically with any color scheme 445 + 446 + --- 447 + 448 + ## Verification 449 + 450 + After configuration, verify everything works: 451 + 452 + ### Step 1: Install Dependencies 453 + 454 + ```bash 455 + npm install 456 + ``` 457 + 458 + ### Step 2: Start Development Server 459 + 460 + ```bash 461 + npm run dev 462 + ``` 463 + 464 + Visit `http://localhost:5173` 465 + 466 + ### Step 3: Check Core Features 467 + 468 + Verify these elements appear correctly: 469 + 470 + - [ ] **Profile Card**: Shows your Bluesky profile information 471 + - Avatar and banner image 472 + - Display name and handle 473 + - Bio text 474 + - Follower/following counts 475 + 476 + - [ ] **Site Metadata**: Check `http://localhost:5173/site/meta` 477 + - Site information loads correctly 478 + - Credits, tech stack, privacy info display 479 + 480 + - [ ] **Blog Access**: Test your slug configuration 481 + - Visit `http://localhost:5173/{your-slug}` 482 + - Should redirect to your Leaflet publication 483 + - RSS feed works at `http://localhost:5173/{your-slug}/rss` 484 + 485 + - [ ] **Optional Features** (if enabled): 486 + - Music status card (if you use teal.fm) 487 + - Mood status card (if you use kibun.social) 488 + - Link board (if you use Linkat) 489 + - Repositories (if you use Tangled) 490 + - Latest Bluesky post 491 + 492 + ### Step 4: Check Browser Console 493 + 494 + Open browser DevTools (F12) and check for: 495 + 496 + - โœ… No error messages in Console tab 497 + - โœ… Successful API responses in Network tab 498 + - โœ… No 404 errors for static files 499 + 500 + ### Step 5: Test Responsive Design 501 + 502 + Check the site at different screen sizes: 503 + 504 + - Mobile (375px width) 505 + - Tablet (768px width) 506 + - Desktop (1280px+ width) 507 + 508 + ### Step 6: Verify SEO Metadata 509 + 510 + View page source and check for: 511 + 512 + - `<title>` tag with your site title 513 + - `<meta name="description">` with your description 514 + - Open Graph tags (`og:title`, `og:description`, etc.) 515 + - Twitter Card tags (`twitter:card`, `twitter:title`, etc.) 516 + 517 + --- 518 + 519 + ## Troubleshooting 520 + 521 + ### Profile Data Not Loading 522 + 523 + **Symptom**: Profile card shows "Profile not found" or loading state persists 524 + 525 + **Solutions**: 526 + 527 + 1. Verify `PUBLIC_ATPROTO_DID` is correct in `.env.local` 528 + 2. Check your DID format: should be `did:plc:...` or `did:web:...` 529 + 3. Ensure your Bluesky account is active and public 530 + 4. Check browser console for specific error messages 531 + 5. Clear cache and hard refresh (Ctrl+Shift+R / Cmd+Shift+R) 532 + 533 + ### Publications Not Found 534 + 535 + **Symptom**: Blog pages show 404 or "Not Found" errors 536 + 537 + **Solutions**: 538 + 539 + 1. Verify publication rkey in `src/lib/config/slugs.ts` matches your Leaflet publication 540 + 2. Visit your Leaflet publication URL and confirm the rkey is correct 541 + 3. Ensure the publication is public (not draft/private) 542 + 4. Check if documents exist in the publication 543 + 5. If using WhiteWind, verify `PUBLIC_ENABLE_WHITEWIND=true` if needed 544 + 545 + ### Music Status Not Showing 546 + 547 + **Symptom**: Music card doesn't appear or shows no data 548 + 549 + **Solutions**: 550 + 551 + 1. Verify you have teal.fm configured with your Bluesky account 552 + 2. Check if you have any scrobbles in your teal.fm history 553 + 3. Ensure your scrobbler (e.g., piper) is running and connected 554 + 4. Album artwork requires MusicBrainz IDs or blob storage 555 + 5. Check browser console for MusicBrainz API errors 556 + 557 + ### RSS Feeds Not Working 558 + 559 + **Symptom**: RSS feed shows errors or no posts 560 + 561 + **Solutions**: 562 + 563 + 1. Check slug configuration in `src/lib/config/slugs.ts` 564 + 2. Verify publication has published documents (not drafts) 565 + 3. If using WhiteWind: 566 + - Ensure `PUBLIC_ENABLE_WHITEWIND=true` 567 + - Verify you have published WhiteWind posts 568 + 4. Test feed URL directly: `http://localhost:5173/{slug}/rss` 569 + 5. Check Content-Type header is `application/rss+xml` 570 + 571 + ### Environment Variables Not Applied 572 + 573 + **Symptom**: Changes to `.env.local` don't take effect 574 + 575 + **Solutions**: 576 + 577 + 1. Restart the development server (`npm run dev`) 578 + 2. Verify variable names start with `PUBLIC_` for client-side access 579 + 3. Check for typos in variable names 580 + 4. Ensure `.env.local` is in the project root directory 581 + 5. Clear `.svelte-kit` cache: `rm -rf .svelte-kit && npm run dev` 582 + 583 + ### Build Errors 584 + 585 + **Symptom**: `npm run build` fails with errors 586 + 587 + **Solutions**: 588 + 589 + ```bash 590 + # Clean build artifacts 591 + rm -rf .svelte-kit node_modules package-lock.json 592 + 593 + # Reinstall dependencies 594 + npm install 595 + 596 + # Try building again 597 + npm run build 598 + ``` 599 + 600 + ### CORS Errors in Production 601 + 602 + **Symptom**: API requests fail with CORS errors 603 + 604 + **Solutions**: 605 + 606 + 1. Add your production domain to `PUBLIC_CORS_ALLOWED_ORIGINS` 607 + 2. Ensure the domain includes the protocol (`https://`) 608 + 3. For multiple domains, separate with commas (no spaces) 609 + 4. Avoid using `*` in production for security 610 + 5. Check that the origin header matches exactly (including www or non-www) 611 + 612 + ### TypeScript Errors 613 + 614 + **Symptom**: Type errors in development 615 + 616 + **Solutions**: 617 + 618 + ```bash 619 + # Run type checking 620 + npm run check 621 + 622 + # Watch mode for continuous checking 623 + npm run check:watch 624 + 625 + # Clear and rebuild 626 + rm -rf .svelte-kit && npm run dev 627 + ``` 628 + 629 + ### Dark Mode Not Working 630 + 631 + **Symptom**: Dark mode toggle doesn't change theme 632 + 633 + **Solutions**: 634 + 635 + 1. Check if browser supports `prefers-color-scheme` 636 + 2. Clear browser localStorage: `localStorage.clear()` in console 637 + 3. Verify Tailwind's dark mode is configured in `tailwind.config.js` 638 + 4. Check that dark mode classes are present in HTML (inspect element) 639 + 640 + ### Wolf Mode Issues 641 + 642 + **Symptom**: Wolf mode toggle doesn't transform text 643 + 644 + **Solutions**: 645 + 646 + 1. Ensure JavaScript is enabled in browser 647 + 2. Check browser console for errors 648 + 3. Verify the wolf mode store is imported correctly 649 + 4. Test on different text elements to confirm it's working 650 + 5. Remember: numbers and navigation are intentionally preserved 651 + 652 + --- 653 + 654 + ## Getting Help 655 + 656 + If you encounter issues not covered here: 657 + 658 + 1. **Check Browser Console**: Press F12 and look for error messages 659 + 2. **Review README**: See [README.md](../README.md) for detailed feature documentation 660 + 3. **GitHub Issues**: Search existing issues or create a new one 661 + 4. **AT Protocol Docs**: Visit [atproto.com](https://atproto.com/) for protocol details 662 + 5. **SvelteKit Docs**: Check [kit.svelte.dev](https://kit.svelte.dev/) for framework help 663 + 664 + ### Useful Debugging Commands 665 + 666 + ```bash 667 + # Check environment variables are loaded 668 + npm run dev -- --debug 669 + 670 + # View detailed build output 671 + npm run build -- --verbose 672 + 673 + # Type-check without building 674 + npm run check 675 + 676 + # Format code (may fix some issues) 677 + npm run format 678 + ``` 679 + 680 + ### Log Collection for Bug Reports 681 + 682 + When reporting issues, include: 683 + 684 + 1. Browser console errors (F12 โ†’ Console tab) 685 + 2. Network tab showing failed requests (F12 โ†’ Network tab) 686 + 3. Your `.env.local` configuration (remove sensitive data like DIDs) 687 + 4. Node.js and npm versions: `node --version && npm --version` 688 + 5. Operating system and browser version 689 + 690 + --- 691 + 692 + ## Next Steps 693 + 694 + After completing configuration: 695 + 696 + 1. **Customize Content**: 697 + - Update your Bluesky profile bio and banner 698 + - Publish posts to your Leaflet publications 699 + - Add site information via AT Protocol records 700 + 701 + 2. **Deploy Your Site**: 702 + - See [README.md](../README.md#-deployment) for deployment options 703 + - Choose a platform (Vercel, Netlify, Cloudflare Pages, etc.) 704 + - Configure production environment variables 705 + - Set up custom domain 706 + 707 + 3. **Enhance Your Site**: 708 + - Add custom styling in `src/app.css` 709 + - Create new components in `src/lib/components/` 710 + - Extend functionality with new AT Protocol lexicons 711 + - Customize layouts and pages 712 + 713 + 4. **Monitor and Maintain**: 714 + - Check RSS feeds regularly 715 + - Update dependencies: `npm update` 716 + - Monitor browser console for errors 717 + - Keep AT Protocol records up to date 718 + 719 + --- 720 + 721 + ## Configuration Checklist 722 + 723 + Use this checklist to track your configuration progress: 724 + 725 + ### Required Configuration 726 + 727 + - [ ] Set `PUBLIC_ATPROTO_DID` in `.env.local` 728 + - [ ] Set `PUBLIC_SITE_TITLE` in `.env.local` 729 + - [ ] Set `PUBLIC_SITE_DESCRIPTION` in `.env.local` 730 + - [ ] Set `PUBLIC_SITE_KEYWORDS` in `.env.local` 731 + - [ ] Set `PUBLIC_SITE_URL` in `.env.local` 732 + - [ ] Configure slug mappings in `src/lib/config/slugs.ts` 733 + - [ ] Update `static/robots.txt` with your domain 734 + - [ ] Update `static/sitemap.xml` with your pages 735 + 736 + ### Optional Configuration 737 + 738 + - [ ] Enable WhiteWind support (if needed) 739 + - [ ] Configure blog fallback URL (if desired) 740 + - [ ] Set CORS allowed origins for production 741 + - [ ] Replace favicon files with your branding 742 + - [ ] Update or remove `.well-known` files 743 + - [ ] Configure Slingshot URLs (if using local instance) 744 + 745 + ### Verification (Checklist) 746 + 747 + - [ ] Development server starts without errors 748 + - [ ] Profile card loads correctly 749 + - [ ] Blog slug redirects work 750 + - [ ] RSS feeds generate successfully 751 + - [ ] Optional features display (if enabled) 752 + - [ ] SEO metadata is correct in page source 753 + - [ ] Site works on mobile, tablet, and desktop 754 + - [ ] Dark mode and wolf mode toggles work 755 + 756 + ### Deployment Preparation 757 + 758 + - [ ] Test production build: `npm run build` 759 + - [ ] Preview production build: `npm run preview` 760 + - [ ] Configure production environment variables 761 + - [ ] Choose and configure deployment platform 762 + - [ ] Set up custom domain (if applicable) 763 + - [ ] Configure SSL certificate (handled by most platforms) 764 + 765 + --- 766 + 767 + **Configuration complete!** Your AT Protocol-powered personal website is ready to use. For detailed feature documentation, see [README.md](../README.md).
+113
docs/theme-system.md
··· 1 + # Theme System Documentation 2 + 3 + The color theme system is now centralized and easy to extend. All theme definitions are managed through a single configuration file. 4 + 5 + ## Architecture 6 + 7 + - **`/src/lib/config/themes.config.ts`** - Central theme configuration (add new themes here) 8 + - **`/src/lib/stores/colorTheme.ts`** - Theme state management 9 + - **`/src/lib/components/layout/ColorThemeToggle.svelte`** - Theme picker UI 10 + - **`/src/lib/styles/themes/*.css`** - Individual theme CSS files 11 + - **`/src/lib/styles/themes.css`** - Theme CSS imports 12 + 13 + ## Adding a New Theme 14 + 15 + To add a new theme, follow these steps: 16 + 17 + ### 1. Add Theme Definition to Config 18 + 19 + Edit `/src/lib/config/themes.config.ts` and add your theme to the `THEMES` array: 20 + 21 + ```typescript 22 + { 23 + value: 'midnight', // Unique identifier (used in CSS and localStorage) 24 + label: 'Midnight', // Display name in dropdown 25 + description: 'Deep night', // Short description 26 + color: 'oklch(20% 0.05 240)', // Preview color (shown in dropdown) 27 + category: 'cool' // 'neutral' | 'warm' | 'cool' | 'vibrant' 28 + } 29 + ``` 30 + 31 + ### 2. Create Theme CSS File 32 + 33 + Create `/src/lib/styles/themes/midnight.css` with your color definitions: 34 + 35 + ```css 36 + /* ============================================================================ 37 + MIDNIGHT THEME - Deep night 38 + Primary: Dark blue 39 + Secondary: Navy 40 + Accent: Steel 41 + Hue: 240ยฐ (blue) 42 + ============================================================================ */ 43 + [data-color-theme='midnight'] { 44 + /* Define your CSS custom properties here */ 45 + --color-primary-500: oklch(20% 0.05 240); 46 + /* ... other color definitions ... */ 47 + } 48 + ``` 49 + 50 + ### 3. Import Theme CSS 51 + 52 + Add the import to `/src/lib/styles/themes.css`: 53 + 54 + ```css 55 + @import './themes/midnight.css'; 56 + ``` 57 + 58 + ## That's It! 59 + 60 + The theme will automatically: 61 + - โœ… Appear in the color theme dropdown 62 + - โœ… Be type-safe in TypeScript 63 + - โœ… Work with the theme switcher 64 + - โœ… Persist in localStorage 65 + 66 + ## Configuration API 67 + 68 + ### `THEMES` 69 + Array of all available themes. Each theme has: 70 + - `value`: Unique identifier (string) 71 + - `label`: Display name (string) 72 + - `description`: Short description (string) 73 + - `color`: Preview color in OKLCH format (string) 74 + - `category`: Theme category (string) 75 + 76 + ### `ColorTheme` 77 + TypeScript type automatically generated from theme values. 78 + 79 + ### `DEFAULT_THEME` 80 + The default theme used when no preference is stored. 81 + 82 + ### `getThemesByCategory()` 83 + Returns themes organized by category for UI rendering. 84 + 85 + ### `getTheme(value)` 86 + Get a specific theme definition by its value. 87 + 88 + ## Example: Adding Multiple Themes 89 + 90 + ```typescript 91 + // In themes.config.ts 92 + export const THEMES: readonly ThemeDefinition[] = [ 93 + // ... existing themes ... 94 + 95 + // New themes 96 + { 97 + value: 'midnight', 98 + label: 'Midnight', 99 + description: 'Deep night', 100 + color: 'oklch(20% 0.05 240)', 101 + category: 'cool' 102 + }, 103 + { 104 + value: 'sunrise', 105 + label: 'Sunrise', 106 + description: 'Morning glow', 107 + color: 'oklch(75% 0.15 50)', 108 + category: 'warm' 109 + } 110 + ] as const; 111 + ``` 112 + 113 + Then create `midnight.css` and `sunrise.css` in the themes folder, and import them in `themes.css`.
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "website", 3 - "version": "10.3.0", 3 + "version": "10.5.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "website", 9 - "version": "10.3.0", 9 + "version": "10.5.0", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.18.1", 12 12 "@lucide/svelte": "^0.554.0",
+1 -1
package.json
··· 1 1 { 2 2 "name": "website", 3 3 "private": true, 4 - "version": "10.3.0", 4 + "version": "10.5.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev",
+60 -60
src/app.css
··· 8 8 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 9 9 'Segoe UI Symbol', 'Noto Color Emoji'; 10 10 11 - /* Ink - Text colors (adjusted for WCAG AA compliance) */ 12 - --color-ink-50: light-dark(oklch(17.39% 0.023 124.58), oklch(97.31% 0.015 123.04)); 13 - --color-ink-100: light-dark(oklch(24.9% 0.042 126.8), oklch(93% 0.032 124.47)); 14 - --color-ink-200: light-dark(oklch(38.03% 0.07 126.15), oklch(85% 0.061 123.88)); 15 - --color-ink-300: light-dark(oklch(50.28% 0.098 126.82), oklch(75% 0.093 124.99)); 16 - --color-ink-400: light-dark(oklch(61.88% 0.124 126.72), oklch(65% 0.123 125.63)); 17 - --color-ink-500: light-dark(oklch(72.9% 0.149 127.03), oklch(55% 0.149 127.03)); 18 - --color-ink-600: light-dark(oklch(78.19% 0.123 125.63), oklch(45% 0.124 126.72)); 19 - --color-ink-700: light-dark(oklch(83.5% 0.093 124.99), oklch(35% 0.098 126.82)); 20 - --color-ink-800: light-dark(oklch(88.94% 0.061 123.88), oklch(25% 0.07 126.15)); 21 - --color-ink-900: light-dark(oklch(94.52% 0.032 124.47), oklch(18% 0.042 126.8)); 22 - --color-ink-950: light-dark(oklch(97.31% 0.015 123.04), oklch(12% 0.023 124.58)); 11 + /* Ink - Slate-tinted text (230ยฐ) */ 12 + --color-ink-50: light-dark(oklch(17.5% 0.012 230), oklch(97.6% 0.008 230)); 13 + --color-ink-100: light-dark(oklch(25% 0.022 230), oklch(93.2% 0.017 230)); 14 + --color-ink-200: light-dark(oklch(38.5% 0.037 230), oklch(85.2% 0.032 230)); 15 + --color-ink-300: light-dark(oklch(50.5% 0.052 230), oklch(75.2% 0.048 230)); 16 + --color-ink-400: light-dark(oklch(62% 0.065 230), oklch(65.2% 0.062 230)); 17 + --color-ink-500: light-dark(oklch(73% 0.078 230), oklch(55.2% 0.078 230)); 18 + --color-ink-600: light-dark(oklch(78% 0.062 230), oklch(45.2% 0.065 230)); 19 + --color-ink-700: light-dark(oklch(83.5% 0.048 230), oklch(35.2% 0.052 230)); 20 + --color-ink-800: light-dark(oklch(89% 0.032 230), oklch(25.2% 0.037 230)); 21 + --color-ink-900: light-dark(oklch(94.5% 0.017 230), oklch(18.2% 0.022 230)); 22 + --color-ink-950: light-dark(oklch(97.6% 0.008 230), oklch(12.5% 0.012 230)); 23 23 24 - /* Canvas - Background colors (adjusted for better contrast) */ 25 - --color-canvas-50: light-dark(oklch(17.69% 0.027 125.57), oklch(98.5% 0.01 123.97)); 26 - --color-canvas-100: light-dark(oklch(25.56% 0.047 126.44), oklch(96.5% 0.02 123.69)); 27 - --color-canvas-200: light-dark(oklch(39.36% 0.083 127.85), oklch(92% 0.045 125.14)); 28 - --color-canvas-300: light-dark(oklch(51.84% 0.112 127.68), oklch(86% 0.075 125.55)); 29 - --color-canvas-400: light-dark(oklch(63.78% 0.141 128.14), oklch(80% 0.105 126.87)); 30 - --color-canvas-500: light-dark(oklch(75.25% 0.169 128.13), oklch(75.25% 0.135 128.13)); 31 - --color-canvas-600: light-dark(oklch(80% 0.105 126.87), oklch(63.78% 0.141 128.14)); 32 - --color-canvas-700: light-dark(oklch(86% 0.075 125.55), oklch(51.84% 0.112 127.68)); 33 - --color-canvas-800: light-dark(oklch(92% 0.045 125.14), oklch(39.36% 0.083 127.85)); 34 - --color-canvas-900: light-dark(oklch(96.5% 0.02 123.69), oklch(25.56% 0.047 126.44)); 35 - --color-canvas-950: light-dark(oklch(98.5% 0.01 123.97), oklch(17.69% 0.027 125.57)); 24 + /* Canvas - Slate-tinted backgrounds (230ยฐ) */ 25 + --color-canvas-50: light-dark(oklch(17.8% 0.014 230), oklch(98.6% 0.005 230)); 26 + --color-canvas-100: light-dark(oklch(25.8% 0.025 230), oklch(96.6% 0.011 230)); 27 + --color-canvas-200: light-dark(oklch(39.5% 0.042 230), oklch(92.5% 0.024 230)); 28 + --color-canvas-300: light-dark(oklch(52% 0.058 230), oklch(86.5% 0.038 230)); 29 + --color-canvas-400: light-dark(oklch(64% 0.072 230), oklch(80.5% 0.055 230)); 30 + --color-canvas-500: light-dark(oklch(75.5% 0.085 230), oklch(75.5% 0.068 230)); 31 + --color-canvas-600: light-dark(oklch(80.5% 0.055 230), oklch(64% 0.072 230)); 32 + --color-canvas-700: light-dark(oklch(86.5% 0.038 230), oklch(52% 0.058 230)); 33 + --color-canvas-800: light-dark(oklch(92.5% 0.024 230), oklch(39.5% 0.042 230)); 34 + --color-canvas-900: light-dark(oklch(96.6% 0.011 230), oklch(25.8% 0.025 230)); 35 + --color-canvas-950: light-dark(oklch(98.6% 0.005 230), oklch(17.8% 0.014 230)); 36 36 37 - /* Sage - Primary colors (adjusted for WCAG AA compliance) */ 38 - --color-primary-50: light-dark(oklch(18.09% 0.031 123.74), oklch(97.73% 0.02 121.83)); 39 - --color-primary-100: light-dark(oklch(26.23% 0.053 126.29), oklch(94% 0.042 123.12)); 40 - --color-primary-200: light-dark(oklch(40.39% 0.088 126.72), oklch(88% 0.082 123.68)); 41 - --color-primary-300: light-dark(oklch(53.63% 0.122 127.17), oklch(78% 0.122 124.71)); 42 - --color-primary-400: light-dark(oklch(65.86% 0.152 127.23), oklch(68% 0.155 125.79)); 43 - --color-primary-500: light-dark(oklch(77.77% 0.182 127.42), oklch(58% 0.182 127.42)); 44 - --color-primary-600: light-dark(oklch(81.83% 0.155 125.79), oklch(48% 0.152 127.23)); 45 - --color-primary-700: light-dark(oklch(86.28% 0.122 124.71), oklch(38% 0.122 127.17)); 46 - --color-primary-800: light-dark(oklch(90.67% 0.082 123.68), oklch(28% 0.088 126.72)); 47 - --color-primary-900: light-dark(oklch(95.38% 0.042 123.12), oklch(20% 0.053 126.29)); 48 - --color-primary-950: light-dark(oklch(97.73% 0.02 121.83), oklch(14% 0.031 123.74)); 37 + /* Slate - Primary colors (230ยฐ) */ 38 + --color-primary-50: light-dark(oklch(18.2% 0.018 230), oklch(97.8% 0.012 230)); 39 + --color-primary-100: light-dark(oklch(26.5% 0.030 230), oklch(94.8% 0.022 230)); 40 + --color-primary-200: light-dark(oklch(40.5% 0.048 230), oklch(89.5% 0.042 230)); 41 + --color-primary-300: light-dark(oklch(54% 0.065 230), oklch(79.5% 0.062 230)); 42 + --color-primary-400: light-dark(oklch(66.5% 0.080 230), oklch(69.5% 0.078 230)); 43 + --color-primary-500: light-dark(oklch(78.5% 0.095 230), oklch(59.5% 0.095 230)); 44 + --color-primary-600: light-dark(oklch(82.2% 0.078 230), oklch(49.5% 0.080 230)); 45 + --color-primary-700: light-dark(oklch(86.5% 0.062 230), oklch(39.5% 0.065 230)); 46 + --color-primary-800: light-dark(oklch(91% 0.042 230), oklch(29.5% 0.048 230)); 47 + --color-primary-900: light-dark(oklch(95.8% 0.022 230), oklch(21.5% 0.030 230)); 48 + --color-primary-950: light-dark(oklch(98% 0.012 230), oklch(15.2% 0.018 230)); 49 49 50 - /* Mint - Secondary colors (adjusted for WCAG AA compliance) */ 51 - --color-secondary-50: light-dark(oklch(18.72% 0.037 126.2), oklch(97.87% 0.024 121.9)); 52 - --color-secondary-100: light-dark(oklch(26.82% 0.058 127.38), oklch(94.5% 0.048 123.9)); 53 - --color-secondary-200: light-dark(oklch(42.08% 0.101 128.02), oklch(89% 0.097 124.41)); 54 - --color-secondary-300: light-dark(oklch(55.72% 0.137 128.49), oklch(80% 0.141 125.62)); 55 - --color-secondary-400: light-dark(oklch(68.58% 0.171 128.75), oklch(70% 0.178 127.04)); 56 - --color-secondary-500: light-dark(oklch(81.09% 0.205 129.04), oklch(60% 0.205 129.04)); 57 - --color-secondary-600: light-dark(oklch(84.3% 0.178 127.04), oklch(50% 0.171 128.75)); 58 - --color-secondary-700: light-dark(oklch(87.99% 0.141 125.62), oklch(40% 0.137 128.49)); 59 - --color-secondary-800: light-dark(oklch(91.89% 0.097 124.41), oklch(30% 0.101 128.02)); 60 - --color-secondary-900: light-dark(oklch(95.73% 0.048 123.9), oklch(22% 0.058 127.38)); 61 - --color-secondary-950: light-dark(oklch(97.87% 0.024 121.9), oklch(15% 0.037 126.2)); 50 + /* Steel Grey - Secondary colors (215ยฐ) */ 51 + --color-secondary-50: light-dark(oklch(18.5% 0.020 215), oklch(97.9% 0.013 215)); 52 + --color-secondary-100: light-dark(oklch(26.8% 0.033 215), oklch(95% 0.024 215)); 53 + --color-secondary-200: light-dark(oklch(41% 0.052 215), oklch(89.8% 0.045 215)); 54 + --color-secondary-300: light-dark(oklch(54.5% 0.070 215), oklch(80.2% 0.065 215)); 55 + --color-secondary-400: light-dark(oklch(67% 0.087 215), oklch(70.2% 0.082 215)); 56 + --color-secondary-500: light-dark(oklch(79% 0.103 215), oklch(60.2% 0.103 215)); 57 + --color-secondary-600: light-dark(oklch(82.8% 0.082 215), oklch(50.2% 0.087 215)); 58 + --color-secondary-700: light-dark(oklch(87% 0.065 215), oklch(40.2% 0.070 215)); 59 + --color-secondary-800: light-dark(oklch(91.5% 0.045 215), oklch(30.5% 0.052 215)); 60 + --color-secondary-900: light-dark(oklch(96% 0.024 215), oklch(22.2% 0.033 215)); 61 + --color-secondary-950: light-dark(oklch(98.2% 0.013 215), oklch(15.8% 0.020 215)); 62 62 63 - /* Jade - Accent colors (adjusted for WCAG AA compliance) */ 64 - --color-accent-50: light-dark(oklch(19.03% 0.041 126.73), oklch(98.05% 0.027 122.65)); 65 - --color-accent-100: light-dark(oklch(27.78% 0.066 127.71), oklch(95% 0.056 123.8)); 66 - --color-accent-200: light-dark(oklch(43.51% 0.11 128.91), oklch(90% 0.11 124.83)); 67 - --color-accent-300: light-dark(oklch(57.9% 0.149 129.35), oklch(82% 0.159 126.06)); 68 - --color-accent-400: light-dark(oklch(71.44% 0.186 129.59), oklch(72% 0.198 127.63)); 69 - --color-accent-500: light-dark(oklch(84.36% 0.221 129.75), oklch(62% 0.221 129.75)); 70 - --color-accent-600: light-dark(oklch(86.93% 0.198 127.63), oklch(52% 0.186 129.59)); 71 - --color-accent-700: light-dark(oklch(89.79% 0.159 126.06), oklch(42% 0.149 129.35)); 72 - --color-accent-800: light-dark(oklch(92.93% 0.11 124.83), oklch(32% 0.11 128.91)); 73 - --color-accent-900: light-dark(oklch(96.35% 0.056 123.8), oklch(23% 0.066 127.71)); 74 - --color-accent-950: light-dark(oklch(98.05% 0.027 122.65), oklch(16% 0.041 126.73)); 63 + /* Charcoal - Accent colors (240ยฐ) */ 64 + --color-accent-50: light-dark(oklch(18.5% 0.022 240), oklch(98% 0.014 240)); 65 + --color-accent-100: light-dark(oklch(26.8% 0.036 240), oklch(95.2% 0.026 240)); 66 + --color-accent-200: light-dark(oklch(41% 0.058 240), oklch(90% 0.048 240)); 67 + --color-accent-300: light-dark(oklch(54.5% 0.078 240), oklch(80.8% 0.072 240)); 68 + --color-accent-400: light-dark(oklch(67% 0.097 240), oklch(71% 0.092 240)); 69 + --color-accent-500: light-dark(oklch(79% 0.115 240), oklch(61% 0.115 240)); 70 + --color-accent-600: light-dark(oklch(82.8% 0.092 240), oklch(51% 0.097 240)); 71 + --color-accent-700: light-dark(oklch(87% 0.072 240), oklch(41% 0.078 240)); 72 + --color-accent-800: light-dark(oklch(91.5% 0.048 240), oklch(31% 0.058 240)); 73 + --color-accent-900: light-dark(oklch(96% 0.026 240), oklch(22.5% 0.036 240)); 74 + --color-accent-950: light-dark(oklch(98.2% 0.014 240), oklch(16.2% 0.022 240)); 75 75 } 76 76 77 77 @layer base {
+23 -1
src/hooks.server.ts
··· 1 1 import type { Handle } from '@sveltejs/kit'; 2 2 import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public'; 3 + import { HTTP_CACHE_HEADERS } from '$lib/config/cache.config'; 3 4 4 5 /** 5 6 * Global request handler with CORS support ··· 31 32 32 33 const response = await resolve(event, { 33 34 filterSerializedResponseHeaders: (name) => { 34 - return name === 'content-type' || name.startsWith('x-'); 35 + return name === 'content-type' || name === 'cache-control' || name.startsWith('x-'); 35 36 } 36 37 }); 38 + 39 + // Add HTTP caching headers for better performance and reduced timeouts 40 + // Layout data (root route) is cached aggressively since profile/site info changes infrequently 41 + if (!event.url.pathname.startsWith('/api/')) { 42 + // Root layout loads profile and site info - cache aggressively 43 + if (event.url.pathname === '/' || event.url.pathname === '') { 44 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 45 + } 46 + // Blog listing pages 47 + else if (event.url.pathname.startsWith('/blog') || event.url.pathname.startsWith('/archive')) { 48 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_LISTING); 49 + } 50 + // Individual blog post pages 51 + else if (event.url.pathname.match(/^\/[a-z0-9-]+$/)) { 52 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.BLOG_POST); 53 + } 54 + // Other pages get moderate caching 55 + else { 56 + response.headers.set('Cache-Control', HTTP_CACHE_HEADERS.LAYOUT); 57 + } 58 + } 37 59 38 60 // Add CORS headers for API routes 39 61 if (event.url.pathname.startsWith('/api/')) {
+171
src/lib/components/HappyMacEasterEgg.svelte
··· 1 + <script lang="ts"> 2 + import { happyMacStore } from '$lib/stores'; 3 + 4 + let isVisible = $state(false); 5 + let position = $state(-100); 6 + 7 + // Watch the store for when it's triggered (24 clicks) 8 + $effect(() => { 9 + const state = $happyMacStore; 10 + if (state.isTriggered && !isVisible) { 11 + startAnimation(); 12 + } 13 + }); 14 + 15 + function playBeep() { 16 + try { 17 + const audioContext = new AudioContext(); 18 + const now = audioContext.currentTime; 19 + 20 + // Tributary recreation of the classic Mac startup chord 21 + // This is NOT the original sound - it's an approximation using Web Audio API 22 + // The original Mac beep was a major chord: F4, A4, C5 23 + // Frequencies: ~349 Hz, ~440 Hz, ~523 Hz 24 + const frequencies = [349, 440, 523]; 25 + const masterGain = audioContext.createGain(); 26 + masterGain.connect(audioContext.destination); 27 + masterGain.gain.value = 0.15; 28 + 29 + // Create three oscillators for the chord 30 + frequencies.forEach((freq) => { 31 + const oscillator = audioContext.createOscillator(); 32 + const gainNode = audioContext.createGain(); 33 + 34 + oscillator.type = 'sine'; // Original Mac used sine waves 35 + oscillator.frequency.value = freq; 36 + 37 + // ADSR envelope for a more authentic sound 38 + gainNode.gain.setValueAtTime(0, now); 39 + gainNode.gain.linearRampToValueAtTime(0.3, now + 0.02); // Attack 40 + gainNode.gain.exponentialRampToValueAtTime(0.01, now + 1.0); // Decay 41 + 42 + oscillator.connect(gainNode); 43 + gainNode.connect(masterGain); 44 + 45 + oscillator.start(now); 46 + oscillator.stop(now + 1.0); 47 + }); 48 + } catch (e) { 49 + // Fail silently if audio context isn't available 50 + console.log('Audio playback not available'); 51 + } 52 + } 53 + 54 + function startAnimation() { 55 + // Play the beep first 56 + playBeep(); 57 + 58 + isVisible = true; 59 + position = -100; 60 + 61 + // Animate across screen (takes about 15 seconds) 62 + const duration = 15000; 63 + const startTime = Date.now(); 64 + 65 + function animate() { 66 + const elapsed = Date.now() - startTime; 67 + const progress = Math.min(elapsed / duration, 1); 68 + 69 + // Move from -100 to window width + 100 70 + position = -100 + (window.innerWidth + 200) * progress; 71 + 72 + if (progress < 1) { 73 + requestAnimationFrame(animate); 74 + } else { 75 + isVisible = false; 76 + // Reset the store so it can be triggered again 77 + happyMacStore.reset(); 78 + } 79 + } 80 + 81 + requestAnimationFrame(animate); 82 + } 83 + </script> 84 + 85 + {#if isVisible} 86 + <div 87 + class="happy-mac" 88 + style="left: {position}px" 89 + > 90 + <!-- 91 + Happy Mac SVG 92 + Original by NiloGlock at Italian Wikipedia 93 + License: CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/) 94 + Source: https://commons.wikimedia.org/wiki/File:Happy_Mac.svg 95 + --> 96 + <svg 97 + width="60" 98 + height="78" 99 + viewBox="0 0 8.4710464 10.9614" 100 + xmlns="http://www.w3.org/2000/svg" 101 + class="mac-icon" 102 + > 103 + <g transform="translate(-5.3090212,-4.3002038)"> 104 + <g transform="matrix(0.06455006,0,0,0.06455006,7.6050574,7.0900779)"> 105 + <path d="m -30.937651,99.78759 h 122 v 26.80449 h -122 z" style="fill:#000000;fill-opacity:1;stroke-width:2.38412714"/> 106 + <g transform="translate(-56.456402,-31.41017)"> 107 + <path style="fill:#555555;fill-opacity:1;stroke:none;stroke-width:0.17674622" d="m 33.668747,136.75006 v 4.69998 h 31.950504 v -4.69998 z m 41.740088,4.69998 V 146.15 h 11.145573 v -4.69996 z M 91.152059,146.15 v 6.29987 H 102.47075 V 146.15 Z"/> 108 + <path style="fill:#444444;fill-opacity:1;stroke:none;stroke-width:0.15800072" d="m 65.619251,136.75006 v 4.69998 H 86.554408 V 146.15 h 15.916342 v 6.29987 h 20.86023 V 146.15 h -15.87449 v -4.69996 H 91.152059 v -4.69998 z"/> 109 + <path style="fill:#222222;fill-opacity:1;stroke:none;stroke-width:0.21712606" d="m 91.152059,136.75006 v 4.69998 H 107.45649 V 146.15 h 15.87449 v 6.29987 h 16.03777 v -6.29987 -4.69996 -4.69998 z"/> 110 + <path style="fill:#777777;fill-opacity:1;stroke:none;stroke-width:0.20201708" d="M 33.668747,141.45004 V 146.15 h 41.740088 v -4.69996 z M 75.408835,146.15 v 6.29987 H 91.152059 V 146.15 Z"/> 111 + <path d="m 33.668823,146.14999 h 41.74001 v 6.3 h -41.74001 z" style="fill:#888888;fill-opacity:1;stroke:none;stroke-width:0.23388879"/> 112 + </g> 113 + <path d="M -30.969854,-37.120319 H 91.062349 V 99.787579 H -30.969854 Z" style="fill:#cccccc;fill-opacity:1;stroke-width:0.26458332"/> 114 + <path d="M -15.075892,-21.040775 H 74.98512 v 67.75 h -90.061012 z" style="fill:#ccccff;fill-opacity:1;stroke-width:0.26458332"/> 115 + <path transform="scale(0.26458333)" d="M 102.17383,-23.402344 V 59.882812 H 83.148438 V 78.779297 H 102.17383 120 120.0508 V -23.402344 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.93718952"/> 116 + <path d="M -30.969856,-43.220318 H 91.062347 v 6.1 H -30.969856 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.13749063"/> 117 + <path d="M -15.075892,-27.140776 H 74.98512 v 6.1 h -90.061012 z" style="fill:#444444;fill-opacity:1;stroke-width:0.97719014"/> 118 + <path d="m -21.040775,15.075892 h 67.75 v 6.1 h -67.75 z" style="fill:#444444;fill-opacity:1;stroke-width:0.84755003" transform="rotate(90)"/> 119 + <path d="m -21.040775,-81.085121 h 67.75 v 6.1 h -67.75 z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.84755009" transform="rotate(90)"/> 120 + <path d="m -15.07589,46.709225 h 90.061013 v 6.1 H -15.07589 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.9771902"/> 121 + <path d="m 31.655506,73.81324 h 43.400002 v 5 H 31.655506 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26445001"/> 122 + <path d="m 31.655506,78.81324 h 43.400005 v 6 H 31.655506 Z" style="fill:#ffffff;fill-opacity:1;stroke-width:0.28969046"/> 123 + <path d="m -21.133041,73.785721 h 11.060395 v 5 h -11.060395 z" style="fill:#00bb00;fill-opacity:1;stroke-width:0.13350084"/> 124 + <path d="m -21.133041,78.785721 h 11.060396 v 6 h -11.060396 z" style="fill:#dd0000;fill-opacity:1;stroke-width:0.14624284"/> 125 + <path d="M 5.8799295,-6.1919641 H 10.87993 V 5.0080357 H 5.8799295 Z" style="fill:#000000;fill-opacity:1;stroke-width:0.26576424"/> 126 + <path d="m 47.880306,-6.1919641 h 6.1 V 5.0080357 h -6.1 z" style="fill:#000000;fill-opacity:1;stroke-width:0.29354623"/> 127 + <path d="m 10.8871,25.947487 h 5 v 6 h -5 z" style="fill:#000000;fill-opacity:1;stroke-width:0.19451953"/> 128 + <path d="m 38.149635,25.944651 h 4.75 v 6.002836 h -4.75 z" style="fill:#000000;fill-opacity:1;stroke-width:0.18963902"/> 129 + <path d="m 15.8871,31.947487 h 22.262533 v 5.011021 H 15.8871 Z" style="fill:#000000;fill-opacity:1;stroke-width:11.12128639"/> 130 + <path d="M -37.120319,30.969854 H 99.787579 v 4.6 H -37.120319 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 131 + <path d="M -37.120331,-95.662346 H 99.787582 v 4.6 H -37.120331 Z" style="fill:#000000;fill-opacity:1;stroke-width:1.04625833" transform="rotate(90)"/> 132 + </g> 133 + </g> 134 + </svg> 135 + </div> 136 + {/if} 137 + 138 + <style> 139 + .happy-mac { 140 + position: fixed; 141 + bottom: 0; 142 + z-index: 9999; 143 + pointer-events: none; 144 + animation: hop 0.6s ease-in-out infinite; 145 + } 146 + 147 + .mac-icon { 148 + filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3)); 149 + } 150 + 151 + @keyframes hop { 152 + 0%, 153 + 100% { 154 + transform: translateY(0) rotate(0deg) scaleY(1) scaleX(1); 155 + } 156 + 25% { 157 + transform: translateY(-10px) rotate(2deg) scaleY(1.15) scaleX(0.9); 158 + } 159 + 50% { 160 + transform: translateY(-20px) rotate(5deg) scaleY(1) scaleX(1); 161 + } 162 + 75% { 163 + transform: translateY(-10px) rotate(2deg) scaleY(0.85) scaleX(1.1); 164 + } 165 + } 166 + 167 + /* Add a little tilt alternation */ 168 + .happy-mac:hover { 169 + animation: hop 0.3s ease-in-out infinite; 170 + } 171 + </style>
+42 -131
src/lib/components/layout/ColorThemeToggle.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { Palette, Check } from '@lucide/svelte'; 4 4 import { colorTheme, type ColorTheme } from '$lib/stores/colorTheme'; 5 + import { colorThemeDropdownOpen } from '$lib/stores/dropdownState'; 6 + import { 7 + getThemesByCategory, 8 + CATEGORY_LABELS, 9 + type ThemeDefinition 10 + } from '$lib/config/themes.config'; 5 11 6 12 let isOpen = $state(false); 7 13 let mounted = $state(false); 8 - let currentTheme = $state<ColorTheme>('sage'); 14 + let currentTheme = $state<ColorTheme>('slate'); 9 15 10 - interface ThemeDefinition { 11 - value: ColorTheme; 12 - label: string; 13 - description: string; 14 - color: string; 15 - category: 'neutral' | 'warm' | 'cool' | 'vibrant'; 16 - } 17 - 18 - const themes: ThemeDefinition[] = [ 19 - // Neutral themes 20 - { 21 - value: 'sage', 22 - label: 'Sage', 23 - description: 'Calm green-blue', 24 - color: 'oklch(77.77% 0.182 127.42)', 25 - category: 'neutral' 26 - }, 27 - { 28 - value: 'monochrome', 29 - label: 'Monochrome', 30 - description: 'Pure greyscale', 31 - color: 'oklch(78% 0 0)', 32 - category: 'neutral' 33 - }, 34 - { 35 - value: 'slate', 36 - label: 'Slate', 37 - description: 'Blue-grey', 38 - color: 'oklch(78.5% 0.095 230)', 39 - category: 'neutral' 40 - }, 41 - // Warm themes 42 - { 43 - value: 'ruby', 44 - label: 'Ruby', 45 - description: 'Bold red', 46 - color: 'oklch(81.5% 0.228 10)', 47 - category: 'warm' 48 - }, 49 - { 50 - value: 'coral', 51 - label: 'Coral', 52 - description: 'Orange-pink', 53 - color: 'oklch(81.8% 0.212 20)', 54 - category: 'warm' 55 - }, 56 - { 57 - value: 'sunset', 58 - label: 'Sunset', 59 - description: 'Warm orange', 60 - color: 'oklch(80.5% 0.208 45)', 61 - category: 'warm' 62 - }, 63 - { 64 - value: 'amber', 65 - label: 'Amber', 66 - description: 'Bright yellow', 67 - color: 'oklch(82.8% 0.195 85)', 68 - category: 'warm' 69 - }, 70 - // Cool themes 71 - { 72 - value: 'forest', 73 - label: 'Forest', 74 - description: 'Natural green', 75 - color: 'oklch(79.5% 0.195 145)', 76 - category: 'cool' 77 - }, 78 - { 79 - value: 'teal', 80 - label: 'Teal', 81 - description: 'Blue-green', 82 - color: 'oklch(79% 0.205 195)', 83 - category: 'cool' 84 - }, 85 - { 86 - value: 'ocean', 87 - label: 'Ocean', 88 - description: 'Deep blue', 89 - color: 'oklch(78.2% 0.188 240)', 90 - category: 'cool' 91 - }, 92 - // Vibrant themes 93 - { 94 - value: 'lavender', 95 - label: 'Lavender', 96 - description: 'Soft purple', 97 - color: 'oklch(82% 0.215 295)', 98 - category: 'vibrant' 99 - }, 100 - { 101 - value: 'rose', 102 - label: 'Rose', 103 - description: 'Pink-red', 104 - color: 'oklch(83.5% 0.230 350)', 105 - category: 'vibrant' 106 - } 107 - ]; 108 - 109 - // Group themes by category 110 - const themesByCategory = { 111 - neutral: themes.filter((t) => t.category === 'neutral'), 112 - warm: themes.filter((t) => t.category === 'warm'), 113 - cool: themes.filter((t) => t.category === 'cool'), 114 - vibrant: themes.filter((t) => t.category === 'vibrant') 115 - }; 116 - 117 - const categoryLabels = { 118 - neutral: 'Neutral', 119 - warm: 'Warm', 120 - cool: 'Cool', 121 - vibrant: 'Vibrant' 122 - }; 16 + // Get themes organized by category 17 + const themesByCategory = getThemesByCategory(); 18 + type Category = keyof typeof CATEGORY_LABELS; 123 19 124 20 onMount(() => { 125 21 colorTheme.init(); ··· 129 25 mounted = state.mounted; 130 26 }); 131 27 132 - // Close dropdown when clicking outside 28 + // Subscribe to dropdown state 29 + const unsubDropdown = colorThemeDropdownOpen.subscribe((open) => { 30 + isOpen = open; 31 + }); 32 + 33 + // Close dropdown when clicking outside (desktop only) 133 34 const handleClickOutside = (e: MouseEvent) => { 134 - if (isOpen) { 35 + if (isOpen && window.innerWidth >= 768) { 135 36 const target = e.target as HTMLElement; 136 37 if (!target.closest('.color-theme-dropdown')) { 137 - isOpen = false; 38 + colorThemeDropdownOpen.set(false); 138 39 } 139 40 } 140 41 }; 141 42 document.addEventListener('click', handleClickOutside); 142 43 143 - // Close on Escape key 44 + // Close on Escape key (desktop only, mobile handled by Header) 144 45 const handleEscape = (e: KeyboardEvent) => { 145 - if (e.key === 'Escape' && isOpen) { 146 - isOpen = false; 46 + if (e.key === 'Escape' && isOpen && window.innerWidth >= 768) { 47 + colorThemeDropdownOpen.set(false); 147 48 } 148 49 }; 149 50 document.addEventListener('keydown', handleEscape); 150 51 151 52 return () => { 152 53 unsubscribe(); 54 + unsubDropdown(); 153 55 document.removeEventListener('click', handleClickOutside); 154 56 document.removeEventListener('keydown', handleEscape); 155 57 }; 156 58 }); 157 59 158 60 function toggleDropdown() { 159 - isOpen = !isOpen; 61 + colorThemeDropdownOpen.set(!isOpen); 160 62 } 161 63 162 64 function selectTheme(theme: ColorTheme) { 163 65 colorTheme.setTheme(theme); 164 - isOpen = false; 66 + colorThemeDropdownOpen.set(false); 165 67 } 166 68 </script> 167 69 168 70 <div class="color-theme-dropdown relative"> 169 71 <button 170 72 onclick={toggleDropdown} 171 - class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 172 - aria-label="Change color theme" 73 + class="relative flex h-10 w-10 items-center justify-center rounded-lg bg-canvas-200 text-ink-900 transition-all hover:bg-canvas-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700" 74 + aria-label="Change colour theme" 173 75 aria-expanded={isOpen} 174 76 aria-controls="color-theme-menu" 175 77 type="button" ··· 182 84 </button> 183 85 184 86 {#if isOpen} 87 + <!-- Desktop ONLY: Dropdown menu --> 185 88 <div 186 89 id="color-theme-menu" 187 - class="absolute right-0 top-full z-50 mt-2 w-72 rounded-lg border border-canvas-200 bg-canvas-50 shadow-xl dark:border-canvas-800 dark:bg-canvas-950" 90 + class="absolute right-0 top-full z-50 mt-2 hidden w-72 rounded-lg border border-canvas-200 bg-canvas-50 shadow-xl md:block dark:border-canvas-800 dark:bg-canvas-950" 188 91 role="menu" 92 + aria-label="Colour theme menu" 189 93 > 190 - <div class="max-h-[32rem] overflow-y-auto p-2"> 94 + <div class="max-h-128 overflow-y-auto p-2"> 191 95 <div class="mb-2 px-3 py-2 text-xs font-semibold uppercase text-ink-600 dark:text-ink-400"> 192 - Color Themes 96 + Colour Themes 193 97 </div> 194 98 195 99 {#each Object.entries(themesByCategory) as [category, categoryThemes]} 196 100 <div class="mb-3"> 197 101 <div class="mb-1.5 px-3 text-xs font-medium text-ink-500 dark:text-ink-500"> 198 - {categoryLabels[category]} 102 + {CATEGORY_LABELS[category as Category]} 199 103 </div> 200 104 <div class="space-y-1"> 201 105 {#each categoryThemes as theme} 202 106 <button 203 - onclick={() => selectTheme(theme.value)} 204 - class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-canvas-100 focus-visible:bg-canvas-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900" 107 + onclick={() => selectTheme(theme.value as ColorTheme)} 108 + class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 109 + {currentTheme === theme.value 110 + ? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300' 111 + : 'text-ink-700 hover:bg-canvas-100 focus-visible:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900'}" 205 112 role="menuitem" 206 113 aria-current={currentTheme === theme.value ? 'true' : undefined} 207 114 > ··· 211 118 aria-hidden="true" 212 119 ></div> 213 120 <div class="flex-1 min-w-0"> 214 - <div class="font-medium text-ink-900 dark:text-ink-50">{theme.label}</div> 121 + <div 122 + class="font-medium {currentTheme === theme.value ? '' : 'text-ink-900 dark:text-ink-50'}" 123 + > 124 + {theme.label} 125 + </div> 215 126 <div class="text-xs text-ink-600 dark:text-ink-400">{theme.description}</div> 216 127 </div> 217 128 {#if currentTheme === theme.value}
+43 -22
src/lib/components/layout/Footer.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 2 4 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; 3 5 import DecimalClock from './DecimalClock.svelte'; 6 + import { happyMacStore } from '$lib/stores'; 4 7 5 - export let profile: ProfileData | null = null; 6 - export let siteInfo: SiteInfoData | null = null; 7 - let loading = false; 8 - let error: string | null = null; 9 - let copyrightText: string; 8 + let profile: ProfileData | null = $state(null); 9 + let siteInfo: SiteInfoData | null = $state(null); 10 + let loading = $state(true); 11 + let error: string | null = $state(null); 10 12 11 13 const currentYear = new Date().getFullYear(); 12 14 13 - $: { 14 - console.log('[Footer] Reactive: siteInfo updated:', siteInfo); 15 + // Show click count hint after 3 clicks 16 + let showHint = $derived($happyMacStore.clickCount >= 3 && $happyMacStore.clickCount < 24); 17 + 18 + // Compute copyright text reactively 19 + let copyrightText = $derived.by(() => { 15 20 const birthYear = siteInfo?.additionalInfo?.websiteBirthYear; 16 - console.log('[Footer] Current year:', currentYear); 17 - console.log('[Footer] Birth year:', birthYear); 18 - console.log('[Footer] Birth year type:', typeof birthYear); 19 21 20 22 if (!birthYear || typeof birthYear !== 'number') { 21 - console.log('[Footer] Using current year (invalid/missing birth year)'); 22 - copyrightText = `${currentYear}`; 23 + return `${currentYear}`; 23 24 } else if (birthYear > currentYear) { 24 - console.log('[Footer] Using current year (birth year in future)'); 25 - copyrightText = `${currentYear}`; 25 + return `${currentYear}`; 26 26 } else if (birthYear === currentYear) { 27 - console.log('[Footer] Using current year (birth year equals current)'); 28 - copyrightText = `${currentYear}`; 27 + return `${currentYear}`; 29 28 } else { 30 - console.log('[Footer] Using year range'); 31 - copyrightText = `${birthYear} - ${currentYear}`; 29 + return `${birthYear} - ${currentYear}`; 32 30 } 33 - } 31 + }); 34 32 35 - // Data is provided by layout load; no client-side fetch here to avoid using window.fetch during navigation. 33 + // Fetch data client-side for non-blocking layout 34 + onMount(async () => { 35 + try { 36 + // Fetch both in parallel 37 + const [profileData, siteInfoData] = await Promise.all([ 38 + fetchProfile().catch(() => null), 39 + fetchSiteInfo().catch(() => null) 40 + ]); 41 + profile = profileData; 42 + siteInfo = siteInfoData; 43 + } catch (err) { 44 + error = err instanceof Error ? err.message : 'Failed to load footer data'; 45 + } finally { 46 + loading = false; 47 + } 48 + }); 36 49 </script> 37 50 38 51 <footer ··· 79 92 class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400" 80 93 aria-label="View source code on GitHub">code</a 81 94 > 82 - <!-- Line 3: Version number --> 83 - <span aria-label="Version 10.3.0">v10.3.0</span> 95 + <!-- Line 3: Version number (click 24 times for easter egg!) --> 96 + <button 97 + type="button" 98 + onclick={() => happyMacStore.incrementClick()} 99 + class="cursor-default select-none transition-colors hover:text-ink-600 dark:hover:text-ink-300" 100 + aria-label="Version 10.5.0{showHint ? ` - ${$happyMacStore.clickCount} of 24 clicks` : ''}" 101 + title={showHint ? `${$happyMacStore.clickCount}/24` : ''} 102 + > 103 + v10.5.0{#if showHint}<span class="ml-1 text-xs opacity-60">({$happyMacStore.clickCount}/24)</span>{/if} 104 + </button> 84 105 </div> 85 106 </div> 86 107
+107 -15
src/lib/components/layout/Header.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { getStores } from '$app/stores'; 4 - import { Menu, X } from '@lucide/svelte'; 4 + import { Menu, X, Check } from '@lucide/svelte'; 5 5 import * as LucideIcons from '@lucide/svelte'; 6 6 import ThemeToggle from './ThemeToggle.svelte'; 7 7 import WolfToggle from './WolfToggle.svelte'; ··· 9 9 import { navItems } from '$lib/data/navItems'; 10 10 import { fetchProfile, type ProfileData } from '$lib/services/atproto'; 11 11 import { defaultSiteMeta, createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 12 + import { colorThemeDropdownOpen } from '$lib/stores/dropdownState'; 13 + import { colorTheme, type ColorTheme } from '$lib/stores/colorTheme'; 14 + import { 15 + getThemesByCategory, 16 + CATEGORY_LABELS 17 + } from '$lib/config/themes.config'; 12 18 13 19 const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta); 14 20 const { page } = getStores(); 15 21 16 - let profile: ProfileData | null = null; 17 - let loading = true; 18 - let error: string | null = null; 19 - let imageLoaded = false; 20 - let mobileMenuOpen = false; 22 + let profile = $state<ProfileData | null>(null); 23 + let loading = $state(true); 24 + let error = $state<string | null>(null); 25 + let imageLoaded = $state(false); 26 + let mobileMenuOpen = $state(false); 27 + let colorThemeOpen = $state(false); 28 + let currentTheme = $state<ColorTheme>('slate'); 29 + 30 + // Get themes organized by category 31 + const themesByCategory = getThemesByCategory(); 32 + type Category = keyof typeof CATEGORY_LABELS; 21 33 22 34 // Map of icon names to Lucide components 23 35 let iconComponents: Record<string, any> = {}; ··· 30 42 31 43 function toggleMobileMenu() { 32 44 mobileMenuOpen = !mobileMenuOpen; 33 - // Trap focus when mobile menu opens 45 + // Close color theme dropdown when opening mobile menu 34 46 if (mobileMenuOpen) { 35 - document.body.style.overflow = 'hidden'; 36 - } else { 37 - document.body.style.overflow = ''; 47 + colorThemeDropdownOpen.set(false); 38 48 } 39 49 } 40 50 41 51 function closeMobileMenu() { 42 52 mobileMenuOpen = false; 43 - document.body.style.overflow = ''; 53 + } 54 + 55 + function closeColorThemeDropdown() { 56 + colorThemeDropdownOpen.set(false); 57 + } 58 + 59 + function selectTheme(theme: ColorTheme) { 60 + colorTheme.setTheme(theme); 61 + closeColorThemeDropdown(); 44 62 } 45 63 46 64 function isActive(href: string) { ··· 48 66 } 49 67 50 68 onMount(() => { 69 + // Subscribe to color theme state 70 + const unsubTheme = colorTheme.subscribe((state) => { 71 + currentTheme = state.current; 72 + }); 73 + 74 + // Subscribe to color theme dropdown state 75 + const unsubDropdown = colorThemeDropdownOpen.subscribe((open) => { 76 + colorThemeOpen = open; 77 + // Close mobile menu when opening color theme dropdown 78 + if (open) { 79 + mobileMenuOpen = false; 80 + } 81 + }); 82 + 51 83 // Fetch profile 52 84 fetchProfile() 53 85 .then((data) => { ··· 60 92 loading = false; 61 93 }); 62 94 63 - // Close mobile menu on Escape key 95 + // Close mobile menus on Escape key 64 96 const handleEscape = (e: KeyboardEvent) => { 65 - if (e.key === 'Escape' && mobileMenuOpen) { 66 - closeMobileMenu(); 97 + if (e.key === 'Escape') { 98 + if (mobileMenuOpen) { 99 + closeMobileMenu(); 100 + } 101 + if (colorThemeOpen && window.innerWidth < 768) { 102 + closeColorThemeDropdown(); 103 + } 67 104 } 68 105 }; 69 106 document.addEventListener('keydown', handleEscape); 70 107 71 108 return () => { 109 + unsubTheme(); 110 + unsubDropdown(); 72 111 document.removeEventListener('keydown', handleEscape); 73 - document.body.style.overflow = ''; 74 112 }; 75 113 }); 76 114 </script> ··· 219 257 </li> 220 258 {/each} 221 259 </ul> 260 + </nav> 261 + {/if} 262 + 263 + <!-- Mobile Colour Theme Dropdown --> 264 + {#if colorThemeOpen} 265 + <nav 266 + id="color-theme-menu" 267 + class="border-t border-canvas-200 bg-canvas-50 md:hidden dark:border-canvas-800 dark:bg-canvas-950" 268 + aria-label="Colour theme menu" 269 + > 270 + <div class="container mx-auto flex flex-col px-3 py-2"> 271 + {#each Object.entries(themesByCategory) as [category, categoryThemes]} 272 + <div class="mb-4 last:mb-0"> 273 + <div class="mb-2 px-3 text-xs font-semibold uppercase tracking-wide text-ink-600 dark:text-ink-400"> 274 + {CATEGORY_LABELS[category as Category]} 275 + </div> 276 + <div class="space-y-1"> 277 + {#each categoryThemes as theme} 278 + <button 279 + onclick={() => selectTheme(theme.value as ColorTheme)} 280 + class="flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 281 + {currentTheme === theme.value 282 + ? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300' 283 + : 'text-ink-700 hover:bg-canvas-100 focus-visible:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900'}" 284 + role="menuitem" 285 + aria-current={currentTheme === theme.value ? 'true' : undefined} 286 + > 287 + <div 288 + class="h-7 w-7 shrink-0 rounded-md border border-canvas-300 shadow-sm dark:border-canvas-700" 289 + style="background-color: {theme.color}" 290 + aria-hidden="true" 291 + ></div> 292 + <div class="min-w-0 flex-1"> 293 + <div 294 + class="font-medium {currentTheme === theme.value ? '' : 'text-ink-900 dark:text-ink-50'}" 295 + > 296 + {theme.label} 297 + </div> 298 + <div class="text-sm text-ink-600 dark:text-ink-400"> 299 + {theme.description} 300 + </div> 301 + </div> 302 + {#if currentTheme === theme.value} 303 + <Check 304 + class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" 305 + aria-hidden="true" 306 + /> 307 + {/if} 308 + </button> 309 + {/each} 310 + </div> 311 + </div> 312 + {/each} 313 + </div> 222 314 </nav> 223 315 {/if} 224 316 </header>
+1 -1
src/lib/components/layout/main/card/LinkCard.svelte
··· 28 28 } 29 29 } 30 30 31 - const displayDescription = description || getDomain(url); 31 + let displayDescription = $derived(description || getDomain(url)); 32 32 </script> 33 33 34 34 {#if variant === 'button'}
+3
src/lib/components/layout/main/card/ProfileCard.svelte
··· 112 112 {safeProfile.displayName || safeProfile.handle} 113 113 </h2> 114 114 <p class="font-medium text-ink-700 dark:text-ink-200">@{safeProfile.handle}</p> 115 + {#if safeProfile.pronouns} 116 + <p class="text-sm italic text-ink-600 dark:text-ink-300">{safeProfile.pronouns}</p> 117 + {/if} 115 118 116 119 {#if safeProfile.description} 117 120 <p
+3 -3
src/lib/components/ui/Card.svelte
··· 60 60 }: Props = $props(); 61 61 62 62 // Determine if card should be a link 63 - const isLink = !!href; 63 + let isLink = $derived(!!href); 64 64 65 65 // Base classes 66 66 const baseClasses = 'rounded-xl transition-all duration-300'; ··· 85 85 }; 86 86 87 87 // Interactive classes (hover effects) 88 - const interactiveClasses = interactive || isLink ? 'cursor-pointer' : ''; 88 + let interactiveClasses = $derived(interactive || isLink ? 'cursor-pointer' : ''); 89 89 90 90 // Combine all classes 91 - const cardClasses = `${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${interactiveClasses} ${customClass}`; 91 + let cardClasses = $derived(`${baseClasses} ${variantClasses[variant]} ${paddingClasses[padding]} ${interactiveClasses} ${customClass}`); 92 92 93 93 /** 94 94 * Get badge styling classes based on color and variant
+1 -1
src/lib/components/ui/InternalCard.svelte
··· 30 30 31 31 const baseClasses = 32 32 'flex items-start gap-3 rounded-lg bg-canvas-200 p-4 transition-colors hover:bg-canvas-300 dark:bg-canvas-800 dark:hover:bg-canvas-700 self-start'; 33 - const combinedClasses = `${baseClasses} ${customClass}`; 33 + let combinedClasses = $derived(`${baseClasses} ${customClass}`); 34 34 </script> 35 35 36 36 {#if href}
+1 -1
src/lib/components/ui/PostsGroupedView.svelte
··· 12 12 13 13 let { posts, locale, filterYear }: Props = $props(); 14 14 15 - const userLocale = locale || getUserLocale(); 15 + let userLocale = $derived(locale || getUserLocale()); 16 16 17 17 // Group posts by date 18 18 const groupedPosts = $derived(groupPostsByDate(posts, userLocale));
+95
src/lib/config/cache.config.ts
··· 1 + import { dev } from '$app/environment'; 2 + 3 + /** 4 + * Cache configuration with environment-aware TTL values 5 + * 6 + * Development: Shorter TTLs for faster iteration 7 + * Production: Longer TTLs to reduce API calls and prevent timeouts 8 + */ 9 + 10 + // Parse environment variable or use default (in milliseconds) 11 + const getEnvTTL = (key: string, defaultMinutes: number): number => { 12 + if (typeof process !== 'undefined' && process.env?.[key]) { 13 + const minutes = parseInt(process.env[key], 10); 14 + return isNaN(minutes) ? defaultMinutes * 60 * 1000 : minutes * 60 * 1000; 15 + } 16 + return defaultMinutes * 60 * 1000; 17 + }; 18 + 19 + /** 20 + * Default TTL values (in minutes) for different data types 21 + * 22 + * Profile data changes infrequently, so we can cache it longer 23 + * Music and Kibun statuses change frequently, so shorter cache 24 + */ 25 + const DEFAULT_TTL = { 26 + // Profile data: 60 minutes (changes infrequently) 27 + PROFILE: dev ? 5 : 60, 28 + 29 + // Site info: 120 minutes (rarely changes) 30 + SITE_INFO: dev ? 5 : 120, 31 + 32 + // Links: 60 minutes (changes occasionally) 33 + LINKS: dev ? 5 : 60, 34 + 35 + // Music status: 10 minutes (changes frequently) 36 + MUSIC_STATUS: dev ? 2 : 10, 37 + 38 + // Kibun status: 15 minutes (changes occasionally) 39 + KIBUN_STATUS: dev ? 2 : 15, 40 + 41 + // Tangled repos: 60 minutes (changes occasionally) 42 + TANGLED_REPOS: dev ? 5 : 60, 43 + 44 + // Blog posts: 30 minutes (balance between freshness and performance) 45 + BLOG_POSTS: dev ? 5 : 30, 46 + 47 + // Publications: 60 minutes (rarely changes) 48 + PUBLICATIONS: dev ? 5 : 60, 49 + 50 + // Individual posts: 60 minutes (content doesn't change) 51 + INDIVIDUAL_POST: dev ? 5 : 60, 52 + 53 + // Identity resolution: 1440 minutes (24 hours - DIDs are stable) 54 + IDENTITY: dev ? 30 : 1440 55 + }; 56 + 57 + /** 58 + * Cache TTL configuration 59 + * Values are loaded from environment variables with fallbacks to defaults 60 + */ 61 + export const CACHE_TTL = { 62 + PROFILE: getEnvTTL('CACHE_TTL_PROFILE', DEFAULT_TTL.PROFILE), 63 + SITE_INFO: getEnvTTL('CACHE_TTL_SITE_INFO', DEFAULT_TTL.SITE_INFO), 64 + LINKS: getEnvTTL('CACHE_TTL_LINKS', DEFAULT_TTL.LINKS), 65 + MUSIC_STATUS: getEnvTTL('CACHE_TTL_MUSIC_STATUS', DEFAULT_TTL.MUSIC_STATUS), 66 + KIBUN_STATUS: getEnvTTL('CACHE_TTL_KIBUN_STATUS', DEFAULT_TTL.KIBUN_STATUS), 67 + TANGLED_REPOS: getEnvTTL('CACHE_TTL_TANGLED_REPOS', DEFAULT_TTL.TANGLED_REPOS), 68 + BLOG_POSTS: getEnvTTL('CACHE_TTL_BLOG_POSTS', DEFAULT_TTL.BLOG_POSTS), 69 + PUBLICATIONS: getEnvTTL('CACHE_TTL_PUBLICATIONS', DEFAULT_TTL.PUBLICATIONS), 70 + INDIVIDUAL_POST: getEnvTTL('CACHE_TTL_INDIVIDUAL_POST', DEFAULT_TTL.INDIVIDUAL_POST), 71 + IDENTITY: getEnvTTL('CACHE_TTL_IDENTITY', DEFAULT_TTL.IDENTITY) 72 + } as const; 73 + 74 + /** 75 + * HTTP Cache-Control header values for different routes 76 + * These tell browsers and CDNs how long to cache responses 77 + * 78 + * Format: max-age=X (browser cache), s-maxage=Y (CDN cache), stale-while-revalidate=Z 79 + */ 80 + export const HTTP_CACHE_HEADERS = { 81 + // Layout data (profile, site info) - cache aggressively 82 + LAYOUT: `public, max-age=${CACHE_TTL.PROFILE / 1000}, s-maxage=${CACHE_TTL.PROFILE / 1000}, stale-while-revalidate=${CACHE_TTL.PROFILE / 1000}`, 83 + 84 + // Blog posts listing - moderate caching 85 + BLOG_LISTING: `public, max-age=${CACHE_TTL.BLOG_POSTS / 1000}, s-maxage=${CACHE_TTL.BLOG_POSTS / 1000}, stale-while-revalidate=${CACHE_TTL.BLOG_POSTS / 1000}`, 86 + 87 + // Individual blog post - cache aggressively (content doesn't change) 88 + BLOG_POST: `public, max-age=${CACHE_TTL.INDIVIDUAL_POST / 1000}, s-maxage=${CACHE_TTL.INDIVIDUAL_POST / 1000}, stale-while-revalidate=${CACHE_TTL.INDIVIDUAL_POST / 1000}`, 89 + 90 + // Music status - short cache (changes frequently) 91 + MUSIC_STATUS: `public, max-age=${CACHE_TTL.MUSIC_STATUS / 1000}, s-maxage=${CACHE_TTL.MUSIC_STATUS / 1000}, stale-while-revalidate=${CACHE_TTL.MUSIC_STATUS / 1000}`, 92 + 93 + // API endpoints - moderate caching 94 + API: `public, max-age=300, s-maxage=300, stale-while-revalidate=600` 95 + } as const;
+1
src/lib/config/index.ts
··· 1 1 export * from './slugs'; 2 + export * from './cache.config';
+138
src/lib/config/themes.config.ts
··· 1 + /** 2 + * Central theme configuration 3 + * Add new themes here and they'll automatically appear in the dropdown and type system 4 + */ 5 + 6 + export interface ThemeDefinition { 7 + value: string; 8 + label: string; 9 + description: string; 10 + color: string; 11 + category: 'neutral' | 'warm' | 'cool' | 'vibrant'; 12 + } 13 + 14 + export const THEMES: readonly ThemeDefinition[] = [ 15 + // Neutral themes 16 + { 17 + value: 'sage', 18 + label: 'Sage', 19 + description: 'Calm green-blue', 20 + color: 'oklch(77.77% 0.182 127.42)', 21 + category: 'neutral' 22 + }, 23 + { 24 + value: 'monochrome', 25 + label: 'Monochrome', 26 + description: 'Pure greyscale', 27 + color: 'oklch(78% 0 0)', 28 + category: 'neutral' 29 + }, 30 + { 31 + value: 'slate', 32 + label: 'Slate', 33 + description: 'Blue-grey', 34 + color: 'oklch(78.5% 0.095 230)', 35 + category: 'neutral' 36 + }, 37 + // Warm themes 38 + { 39 + value: 'ruby', 40 + label: 'Ruby', 41 + description: 'Bold red', 42 + color: 'oklch(81.5% 0.228 10)', 43 + category: 'warm' 44 + }, 45 + { 46 + value: 'coral', 47 + label: 'Coral', 48 + description: 'Orange-pink', 49 + color: 'oklch(81.8% 0.212 20)', 50 + category: 'warm' 51 + }, 52 + { 53 + value: 'sunset', 54 + label: 'Sunset', 55 + description: 'Warm orange', 56 + color: 'oklch(80.5% 0.208 45)', 57 + category: 'warm' 58 + }, 59 + { 60 + value: 'amber', 61 + label: 'Amber', 62 + description: 'Bright yellow', 63 + color: 'oklch(82.8% 0.195 85)', 64 + category: 'warm' 65 + }, 66 + // Cool themes 67 + { 68 + value: 'forest', 69 + label: 'Forest', 70 + description: 'Natural green', 71 + color: 'oklch(79.5% 0.195 145)', 72 + category: 'cool' 73 + }, 74 + { 75 + value: 'teal', 76 + label: 'Teal', 77 + description: 'Blue-green', 78 + color: 'oklch(79% 0.205 195)', 79 + category: 'cool' 80 + }, 81 + { 82 + value: 'ocean', 83 + label: 'Ocean', 84 + description: 'Deep blue', 85 + color: 'oklch(78.2% 0.188 240)', 86 + category: 'cool' 87 + }, 88 + // Vibrant themes 89 + { 90 + value: 'lavender', 91 + label: 'Lavender', 92 + description: 'Soft purple', 93 + color: 'oklch(82% 0.215 295)', 94 + category: 'vibrant' 95 + }, 96 + { 97 + value: 'rose', 98 + label: 'Rose', 99 + description: 'Pink-red', 100 + color: 'oklch(83.5% 0.230 350)', 101 + category: 'vibrant' 102 + } 103 + ] as const; 104 + 105 + // Extract theme values for type safety 106 + export type ColorTheme = (typeof THEMES)[number]['value']; 107 + 108 + // Default theme 109 + export const DEFAULT_THEME: ColorTheme = 'slate'; 110 + 111 + // Category labels 112 + export const CATEGORY_LABELS = { 113 + neutral: 'Neutral', 114 + warm: 'Warm', 115 + cool: 'Cool', 116 + vibrant: 'Vibrant' 117 + } as const; 118 + 119 + // Group themes by category (for UI organization) 120 + export const getThemesByCategory = () => { 121 + const grouped: Record<ThemeDefinition['category'], ThemeDefinition[]> = { 122 + neutral: [], 123 + warm: [], 124 + cool: [], 125 + vibrant: [] 126 + }; 127 + 128 + THEMES.forEach((theme) => { 129 + grouped[theme.category].push(theme); 130 + }); 131 + 132 + return grouped; 133 + }; 134 + 135 + // Utility to get a specific theme by value 136 + export const getTheme = (value: string): ThemeDefinition | undefined => { 137 + return THEMES.find((theme) => theme.value === value); 138 + };
+14
src/lib/services/atproto/agents.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { ResolvedIdentity } from './types'; 3 + import { cache } from './cache'; 3 4 4 5 /** 5 6 * Creates an AtpAgent with optional fetch function injection ··· 46 47 47 48 /** 48 49 * Resolves a DID to find its PDS endpoint using Slingshot. 50 + * Results are cached to reduce resolution calls. 49 51 */ 50 52 export async function resolveIdentity( 51 53 did: string, ··· 53 55 ): Promise<ResolvedIdentity> { 54 56 console.info(`[Identity] Resolving DID: ${did}`); 55 57 58 + // Check cache first 59 + const cacheKey = `identity:${did}`; 60 + const cached = cache.get<ResolvedIdentity>(cacheKey); 61 + if (cached) { 62 + console.info('[Identity] Using cached identity resolution'); 63 + return cached; 64 + } 65 + 56 66 // Prefer an injected fetch (from SvelteKit load), fall back to global fetch 57 67 const _fetch = fetchFn ?? globalThis.fetch; 58 68 ··· 84 94 if (!data.did || !data.pds) { 85 95 throw new Error('Invalid response from identity resolver'); 86 96 } 97 + 98 + // Cache the resolved identity 99 + console.info('[Identity] Caching resolved identity'); 100 + cache.set(cacheKey, data); 87 101 88 102 return data; 89 103 }
+35 -9
src/lib/services/atproto/cache.ts
··· 1 1 import type { CacheEntry } from './types'; 2 + import { CACHE_TTL } from '$lib/config/cache.config'; 2 3 3 4 /** 4 - * Simple in-memory cache with TTL support 5 + * Simple in-memory cache with configurable TTL support 6 + * 7 + * TTL values are configured per data type in cache.config.ts 8 + * and can be overridden via environment variables 5 9 */ 6 10 export class ATProtoCache { 7 11 private cache = new Map<string, CacheEntry<any>>(); 8 - private readonly TTL = 5 * 60 * 1000; // 5 minutes 12 + 13 + /** 14 + * Get TTL for a cache key based on its prefix 15 + */ 16 + private getTTL(key: string): number { 17 + if (key.startsWith('profile:')) return CACHE_TTL.PROFILE; 18 + if (key.startsWith('siteinfo:')) return CACHE_TTL.SITE_INFO; 19 + if (key.startsWith('links:')) return CACHE_TTL.LINKS; 20 + if (key.startsWith('music-status:')) return CACHE_TTL.MUSIC_STATUS; 21 + if (key.startsWith('kibun-status:')) return CACHE_TTL.KIBUN_STATUS; 22 + if (key.startsWith('tangled:')) return CACHE_TTL.TANGLED_REPOS; 23 + if (key.startsWith('blog-posts:')) return CACHE_TTL.BLOG_POSTS; 24 + if (key.startsWith('publications:')) return CACHE_TTL.PUBLICATIONS; 25 + if (key.startsWith('post:')) return CACHE_TTL.INDIVIDUAL_POST; 26 + if (key.startsWith('identity:')) return CACHE_TTL.IDENTITY; 27 + 28 + // Default fallback (30 minutes) 29 + return 30 * 60 * 1000; 30 + } 9 31 10 32 get<T>(key: string): T | null { 11 - console.debug(`[Cache] Getting key: ${key}`); 33 + console.info(`[Cache] Getting key: ${key}`); 12 34 const entry = this.cache.get(key); 13 35 if (!entry) { 14 - console.debug(`[Cache] Cache miss for key: ${key}`); 36 + console.info(`[Cache] Cache miss for key: ${key}`); 15 37 return null; 16 38 } 17 39 18 - if (Date.now() - entry.timestamp > this.TTL) { 19 - console.debug(`[Cache] Entry expired for key: ${key}`); 40 + const ttl = this.getTTL(key); 41 + const age = Date.now() - entry.timestamp; 42 + 43 + if (age > ttl) { 44 + console.info(`[Cache] Entry expired for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 20 45 this.cache.delete(key); 21 46 return null; 22 47 } 23 48 24 - console.debug(`[Cache] Cache hit for key: ${key}`); 49 + console.info(`[Cache] Cache hit for key: ${key} (age: ${Math.round(age / 1000)}s, ttl: ${Math.round(ttl / 1000)}s)`); 25 50 return entry.data; 26 51 } 27 52 28 53 set<T>(key: string, data: T): void { 29 - console.debug(`[Cache] Setting key: ${key}`, data); 54 + const ttl = this.getTTL(key); 55 + console.info(`[Cache] Setting key: ${key} (ttl: ${Math.round(ttl / 1000)}s)`); 30 56 this.cache.set(key, { 31 57 data, 32 58 timestamp: Date.now() ··· 34 60 } 35 61 36 62 delete(key: string): void { 37 - console.debug(`[Cache] Deleting key: ${key}`); 63 + console.info(`[Cache] Deleting key: ${key}`); 38 64 this.cache.delete(key); 39 65 } 40 66
+27 -1
src/lib/services/atproto/fetch.ts
··· 40 40 fetchFn 41 41 ); 42 42 43 + // Fetch the actual profile record to get pronouns and other fields 44 + // The profile view doesn't include pronouns, so we need the record 45 + let pronouns: string | undefined; 46 + try { 47 + console.debug('[Profile] Attempting to fetch profile record for pronouns'); 48 + const recordResponse = await withFallback( 49 + PUBLIC_ATPROTO_DID, 50 + async (agent) => { 51 + const response = await agent.com.atproto.repo.getRecord({ 52 + repo: PUBLIC_ATPROTO_DID, 53 + collection: 'app.bsky.actor.profile', 54 + rkey: 'self' 55 + }); 56 + return response.data; 57 + }, 58 + false, 59 + fetchFn 60 + ); 61 + pronouns = (recordResponse.value as any).pronouns; 62 + console.debug('[Profile] Successfully fetched pronouns:', pronouns); 63 + } catch (error) { 64 + console.debug('[Profile] Could not fetch profile record for pronouns:', error); 65 + // Continue without pronouns if record fetch fails 66 + } 67 + 43 68 const data: ProfileData = { 44 69 did: profile.did, 45 70 handle: profile.handle, ··· 49 74 banner: profile.banner, 50 75 followersCount: profile.followersCount, 51 76 followsCount: profile.followsCount, 52 - postsCount: profile.postsCount 77 + postsCount: profile.postsCount, 78 + pronouns: pronouns 53 79 }; 54 80 55 81 console.info('[Profile] Successfully fetched profile data');
+2
src/lib/services/atproto/types.ts
··· 12 12 followersCount?: number; 13 13 followsCount?: number; 14 14 postsCount?: number; 15 + pronouns?: string; 15 16 } 16 17 17 18 export interface StatusData { ··· 150 151 handle: string; 151 152 displayName?: string; 152 153 avatar?: string; 154 + pronouns?: string; 153 155 } 154 156 155 157 export interface BlueskyPost {
+8 -18
src/lib/stores/colorTheme.ts
··· 1 1 import { writable } from 'svelte/store'; 2 2 import { browser } from '$app/environment'; 3 - 4 - export type ColorTheme = 5 - | 'sage' // Default (existing) 6 - | 'monochrome' // Greyscale 7 - // Rainbow spectrum 8 - | 'ruby' // Red 9 - | 'sunset' // Orange 10 - | 'amber' // Yellow 11 - | 'forest' // Green 12 - | 'ocean' // Blue 13 - | 'lavender' // Purple 14 - | 'rose' // Pink 15 - // Additional variations 16 - | 'teal' // Blue-green 17 - | 'coral' // Orange-pink 18 - | 'slate'; // Blue-grey 3 + import { DEFAULT_THEME, type ColorTheme } from '$lib/config/themes.config'; 19 4 20 5 interface ColorThemeState { 21 6 current: ColorTheme; ··· 23 8 } 24 9 25 10 const STORAGE_KEY = 'color-theme'; 26 - const DEFAULT_THEME: ColorTheme = 'forest'; 27 11 28 12 function createColorThemeStore() { 29 13 const { subscribe, set, update } = writable<ColorThemeState>({ ··· 40 24 const theme = stored || DEFAULT_THEME; 41 25 42 26 update((state) => ({ ...state, current: theme, mounted: true })); 43 - applyTheme(theme); 27 + 28 + // Only apply theme if not already applied (to prevent flash) 29 + const currentTheme = document.documentElement.getAttribute('data-color-theme'); 30 + if (currentTheme !== theme) { 31 + applyTheme(theme); 32 + } 44 33 }, 45 34 setTheme: (theme: ColorTheme) => { 46 35 if (!browser) return; ··· 60 49 } 61 50 62 51 export const colorTheme = createColorThemeStore(); 52 + export type { ColorTheme };
+3
src/lib/stores/dropdownState.ts
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + export const colorThemeDropdownOpen = writable(false);
+29
src/lib/stores/happyMac.ts
··· 1 + import { writable } from 'svelte/store'; 2 + 3 + interface HappyMacState { 4 + clickCount: number; 5 + isTriggered: boolean; 6 + } 7 + 8 + function createHappyMacStore() { 9 + const { subscribe, set, update } = writable<HappyMacState>({ 10 + clickCount: 0, 11 + isTriggered: false 12 + }); 13 + 14 + return { 15 + subscribe, 16 + incrementClick: () => 17 + update((state) => { 18 + const newCount = state.clickCount + 1; 19 + // Trigger when reaching 24 clicks (Mac announcement date: 24/01/1984) 20 + if (newCount === 24) { 21 + return { clickCount: newCount, isTriggered: true }; 22 + } 23 + return { ...state, clickCount: newCount }; 24 + }), 25 + reset: () => set({ clickCount: 0, isTriggered: false }) 26 + }; 27 + } 28 + 29 + export const happyMacStore = createHappyMacStore();
+2
src/lib/stores/index.ts
··· 1 1 export { wolfMode } from './wolfMode'; 2 + export { colorThemeDropdownOpen } from './dropdownState'; 3 + export { happyMacStore } from './happyMac';
+9 -1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import { Header, Footer, ScrollToTop } from '$lib/components/layout'; 4 + import HappyMacEasterEgg from '$lib/components/HappyMacEasterEgg.svelte'; 4 5 import { MetaTags } from '$lib/components/seo'; 5 6 import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta'; 6 7 import type { ProfileData, SiteInfoData } from '$lib/services/atproto'; ··· 66 67 htmlElement.classList.remove('dark'); 67 68 htmlElement.style.colorScheme = 'light'; 68 69 } 70 + 71 + // Apply color theme to prevent flash 72 + const colorTheme = localStorage.getItem('color-theme') || 'slate'; 73 + htmlElement.setAttribute('data-color-theme', colorTheme); 69 74 })(); 70 75 </script> 71 76 <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" /> ··· 88 93 {@render children()} 89 94 </main> 90 95 91 - <Footer profile={data.profile} siteInfo={data.siteInfo} /> 96 + <Footer /> 97 + 98 + <!-- Easter egg: Happy Mac walks across the screen (click version number 24 times!) --> 99 + <HappyMacEasterEgg /> 92 100 </div>
+13 -20
src/routes/+layout.ts
··· 1 1 import type { LayoutLoad } from './$types'; 2 2 import { createSiteMeta, type SiteMetadata, defaultSiteMeta } from '$lib/helper/siteMeta'; 3 - import { fetchProfile, fetchSiteInfo } from '$lib/services/atproto'; 4 3 5 - export const load: LayoutLoad = async ({ url, fetch }) => { 4 + /** 5 + * Non-blocking layout load 6 + * Returns immediately with default site metadata 7 + * All data fetching happens client-side in components for faster initial page load 8 + */ 9 + export const load: LayoutLoad = async ({ url }) => { 6 10 // Provide the default site metadata 7 11 const siteMeta: SiteMetadata = createSiteMeta({ 8 12 title: defaultSiteMeta.title, ··· 10 14 url: url.href // Include current URL for proper OG tags 11 15 }); 12 16 13 - // Fetch lightweight public data for layout using injected fetch 14 - let profile = null; 15 - let siteInfo = null; 16 - 17 - try { 18 - profile = await fetchProfile(fetch); 19 - } catch (err) { 20 - // Non-fatal: layout should still render even if profile fails 21 - console.warn('Layout: failed to fetch profile in load', err); 22 - } 23 - 24 - try { 25 - siteInfo = await fetchSiteInfo(fetch); 26 - } catch (err) { 27 - console.warn('Layout: failed to fetch siteInfo in load', err); 28 - } 29 - 30 - return { siteMeta, profile, siteInfo }; 17 + // Return immediately - no blocking data fetches 18 + // Components will fetch their own data client-side with skeletons 19 + return { 20 + siteMeta, 21 + profile: null, 22 + siteInfo: null 23 + }; 31 24 };