+1
.cspell.json
+1
.cspell.json
+25
.env.example
+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
-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
+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
+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
+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
+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
+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
package.json
+60
-60
src/app.css
+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
+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
+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
+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}
+107
-15
src/lib/components/layout/Header.svelte
+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
+1
-1
src/lib/components/layout/main/card/LinkCard.svelte
+3
src/lib/components/layout/main/card/ProfileCard.svelte
+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
+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
+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
+1
-1
src/lib/components/ui/PostsGroupedView.svelte
+95
src/lib/config/cache.config.ts
+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;
+138
src/lib/config/themes.config.ts
+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
+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
+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
+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
+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
+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
+3
src/lib/stores/dropdownState.ts
+29
src/lib/stores/happyMac.ts
+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
+2
src/lib/stores/index.ts
+9
-1
src/routes/+layout.svelte
+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
+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
};