+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`.
+52
-52
package-lock.json
+52
-52
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",
···
29
29
}
30
30
},
31
31
"node_modules/@atproto/api": {
32
-
"version": "0.18.3",
33
-
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.3.tgz",
34
-
"integrity": "sha512-CBqyZfkcKYsr348KP4CKb9plMlZ5A96HwA/DnYscPBl6fvMZkAezAjniZX+xUILASHQJg5c+NaNw9xP8ZuyyDQ==",
32
+
"version": "0.18.4",
33
+
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.4.tgz",
34
+
"integrity": "sha512-+kSxto/GRFXRFFlGwfERrwEKnC6OqTgK34BUToer/Fv08q4WMR+GYPRabbWlnDoJWu3owcQfeYdcblQ88vi16g==",
35
35
"license": "MIT",
36
36
"dependencies": {
37
-
"@atproto/common-web": "^0.4.5",
37
+
"@atproto/common-web": "^0.4.6",
38
38
"@atproto/lexicon": "^0.5.2",
39
-
"@atproto/syntax": "^0.4.1",
39
+
"@atproto/syntax": "^0.4.2",
40
40
"@atproto/xrpc": "^0.7.6",
41
41
"await-lock": "^2.2.2",
42
42
"multiformats": "^9.9.0",
···
45
45
}
46
46
},
47
47
"node_modules/@atproto/common-web": {
48
-
"version": "0.4.5",
49
-
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.5.tgz",
50
-
"integrity": "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA==",
48
+
"version": "0.4.6",
49
+
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.6.tgz",
50
+
"integrity": "sha512-+2mG/1oBcB/ZmYIU1ltrFMIiuy9aByKAkb2Fos/0eTdczcLBaH17k0KoxMGvhfsujN2r62XlanOAMzysa7lv1g==",
51
51
"license": "MIT",
52
52
"dependencies": {
53
-
"@atproto/lex-data": "0.0.1",
54
-
"@atproto/lex-json": "0.0.1",
53
+
"@atproto/lex-data": "0.0.2",
54
+
"@atproto/lex-json": "0.0.2",
55
55
"zod": "^3.23.8"
56
56
}
57
57
},
58
58
"node_modules/@atproto/lex-data": {
59
-
"version": "0.0.1",
60
-
"resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.1.tgz",
61
-
"integrity": "sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA==",
59
+
"version": "0.0.2",
60
+
"resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.2.tgz",
61
+
"integrity": "sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg==",
62
62
"license": "MIT",
63
63
"dependencies": {
64
-
"@atproto/syntax": "0.4.1",
64
+
"@atproto/syntax": "0.4.2",
65
65
"multiformats": "^9.9.0",
66
66
"tslib": "^2.8.1",
67
67
"uint8arrays": "3.0.0",
···
69
69
}
70
70
},
71
71
"node_modules/@atproto/lex-json": {
72
-
"version": "0.0.1",
73
-
"resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.1.tgz",
74
-
"integrity": "sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg==",
72
+
"version": "0.0.2",
73
+
"resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.2.tgz",
74
+
"integrity": "sha512-Pd72lO+l2rhOTutnf11omh9ZkoB/elbzE3HSmn2wuZlyH1mRhTYvoH8BOGokWQwbZkCE8LL3nOqMT3gHCD2l7g==",
75
75
"license": "MIT",
76
76
"dependencies": {
77
-
"@atproto/lex-data": "0.0.1",
77
+
"@atproto/lex-data": "0.0.2",
78
78
"tslib": "^2.8.1"
79
79
}
80
80
},
···
92
92
}
93
93
},
94
94
"node_modules/@atproto/syntax": {
95
-
"version": "0.4.1",
96
-
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
97
-
"integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==",
95
+
"version": "0.4.2",
96
+
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz",
97
+
"integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==",
98
98
"license": "MIT"
99
99
},
100
100
"node_modules/@atproto/xrpc": {
···
1007
1007
"license": "MIT"
1008
1008
},
1009
1009
"node_modules/@sveltejs/acorn-typescript": {
1010
-
"version": "1.0.7",
1011
-
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
1012
-
"integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==",
1010
+
"version": "1.0.8",
1011
+
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
1012
+
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
1013
1013
"license": "MIT",
1014
1014
"peerDependencies": {
1015
1015
"acorn": "^8.9.0"
···
1033
1033
}
1034
1034
},
1035
1035
"node_modules/@sveltejs/kit": {
1036
-
"version": "2.49.0",
1037
-
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz",
1038
-
"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
1036
+
"version": "2.49.1",
1037
+
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.1.tgz",
1038
+
"integrity": "sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==",
1039
1039
"dev": true,
1040
1040
"license": "MIT",
1041
1041
"peer": true,
···
1696
1696
"license": "MIT"
1697
1697
},
1698
1698
"node_modules/esrap": {
1699
-
"version": "2.2.0",
1700
-
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.0.tgz",
1701
-
"integrity": "sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==",
1699
+
"version": "2.2.1",
1700
+
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
1701
+
"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
1702
1702
"license": "MIT",
1703
1703
"dependencies": {
1704
1704
"@jridgewell/sourcemap-codec": "^1.4.15"
···
2099
2099
"license": "MIT"
2100
2100
},
2101
2101
"node_modules/lru-cache": {
2102
-
"version": "11.2.2",
2103
-
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
2104
-
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
2102
+
"version": "11.2.4",
2103
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
2104
+
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
2105
2105
"dev": true,
2106
-
"license": "ISC",
2106
+
"license": "BlueOak-1.0.0",
2107
2107
"engines": {
2108
2108
"node": "20 || >=22"
2109
2109
}
···
2338
2338
}
2339
2339
},
2340
2340
"node_modules/prettier": {
2341
-
"version": "3.7.2",
2342
-
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.2.tgz",
2343
-
"integrity": "sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==",
2341
+
"version": "3.7.4",
2342
+
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
2343
+
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
2344
2344
"dev": true,
2345
2345
"license": "MIT",
2346
2346
"peer": true,
···
2367
2367
}
2368
2368
},
2369
2369
"node_modules/prettier-plugin-tailwindcss": {
2370
-
"version": "0.7.1",
2371
-
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz",
2372
-
"integrity": "sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==",
2370
+
"version": "0.7.2",
2371
+
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz",
2372
+
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
2373
2373
"dev": true,
2374
2374
"license": "MIT",
2375
2375
"engines": {
···
2571
2571
}
2572
2572
},
2573
2573
"node_modules/svelte": {
2574
-
"version": "5.45.2",
2575
-
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.2.tgz",
2576
-
"integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==",
2574
+
"version": "5.45.6",
2575
+
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz",
2576
+
"integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==",
2577
2577
"license": "MIT",
2578
2578
"peer": true,
2579
2579
"dependencies": {
···
2587
2587
"clsx": "^2.1.1",
2588
2588
"devalue": "^5.5.0",
2589
2589
"esm-env": "^1.2.1",
2590
-
"esrap": "^2.2.0",
2590
+
"esrap": "^2.2.1",
2591
2591
"is-reference": "^3.0.3",
2592
2592
"locate-character": "^3.0.0",
2593
2593
"magic-string": "^0.30.11",
···
2734
2734
}
2735
2735
},
2736
2736
"node_modules/unicode-segmenter": {
2737
-
"version": "0.14.0",
2738
-
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz",
2739
-
"integrity": "sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg==",
2737
+
"version": "0.14.1",
2738
+
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.1.tgz",
2739
+
"integrity": "sha512-yHedxlEpUyD+u1UE8qAuCMXVdMLn7yUdlmd8WN7FGmO1ICnpE7LJfnmuXBB+T0zkie3qHsy8fSucqceI/MylOg==",
2740
2740
"license": "MIT"
2741
2741
},
2742
2742
"node_modules/util-deprecate": {
···
2747
2747
"license": "MIT"
2748
2748
},
2749
2749
"node_modules/vite": {
2750
-
"version": "7.2.4",
2751
-
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
2752
-
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
2750
+
"version": "7.2.6",
2751
+
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
2752
+
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
2753
2753
"dev": true,
2754
2754
"license": "MIT",
2755
2755
"peer": true,
+1
-1
package.json
+1
-1
package.json
+128
-62
src/app.css
+128
-62
src/app.css
···
1
1
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
2
2
@import 'tailwindcss';
3
+
@import './lib/styles/themes.css';
3
4
4
5
@theme {
5
6
/* Font Family */
···
7
8
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
8
9
'Segoe UI Symbol', 'Noto Color Emoji';
9
10
10
-
/* Ink - Text colors (adjusted for WCAG AA compliance) */
11
-
--color-ink-50: light-dark(oklch(97.31% 0.015 123.04), oklch(17.39% 0.023 124.58));
12
-
--color-ink-100: light-dark(oklch(93% 0.032 124.47), oklch(24.9% 0.042 126.8));
13
-
--color-ink-200: light-dark(oklch(85% 0.061 123.88), oklch(38.03% 0.07 126.15));
14
-
--color-ink-300: light-dark(oklch(75% 0.093 124.99), oklch(50.28% 0.098 126.82));
15
-
--color-ink-400: light-dark(oklch(65% 0.123 125.63), oklch(61.88% 0.124 126.72));
16
-
--color-ink-500: light-dark(oklch(55% 0.149 127.03), oklch(72.9% 0.149 127.03));
17
-
--color-ink-600: light-dark(oklch(45% 0.124 126.72), oklch(78.19% 0.123 125.63));
18
-
--color-ink-700: light-dark(oklch(35% 0.098 126.82), oklch(83.5% 0.093 124.99));
19
-
--color-ink-800: light-dark(oklch(25% 0.07 126.15), oklch(88.94% 0.061 123.88));
20
-
--color-ink-900: light-dark(oklch(18% 0.042 126.8), oklch(94.52% 0.032 124.47));
21
-
--color-ink-950: light-dark(oklch(12% 0.023 124.58), oklch(97.31% 0.015 123.04));
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));
22
23
23
-
/* Canvas - Background colors (adjusted for better contrast) */
24
-
--color-canvas-50: light-dark(oklch(98.5% 0.01 123.97), oklch(17.69% 0.027 125.57));
25
-
--color-canvas-100: light-dark(oklch(96.5% 0.02 123.69), oklch(25.56% 0.047 126.44));
26
-
--color-canvas-200: light-dark(oklch(92% 0.045 125.14), oklch(39.36% 0.083 127.85));
27
-
--color-canvas-300: light-dark(oklch(86% 0.075 125.55), oklch(51.84% 0.112 127.68));
28
-
--color-canvas-400: light-dark(oklch(80% 0.105 126.87), oklch(63.78% 0.141 128.14));
29
-
--color-canvas-500: light-dark(oklch(75.25% 0.135 128.13), oklch(75.25% 0.169 128.13));
30
-
--color-canvas-600: light-dark(oklch(63.78% 0.141 128.14), oklch(80% 0.105 126.87));
31
-
--color-canvas-700: light-dark(oklch(51.84% 0.112 127.68), oklch(86% 0.075 125.55));
32
-
--color-canvas-800: light-dark(oklch(39.36% 0.083 127.85), oklch(92% 0.045 125.14));
33
-
--color-canvas-900: light-dark(oklch(25.56% 0.047 126.44), oklch(96.5% 0.02 123.69));
34
-
--color-canvas-950: light-dark(oklch(17.69% 0.027 125.57), oklch(98.5% 0.01 123.97));
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));
35
36
36
-
/* Sage - Primary colors (adjusted for WCAG AA compliance) */
37
-
--color-primary-50: light-dark(oklch(97.73% 0.02 121.83), oklch(18.09% 0.031 123.74));
38
-
--color-primary-100: light-dark(oklch(94% 0.042 123.12), oklch(26.23% 0.053 126.29));
39
-
--color-primary-200: light-dark(oklch(88% 0.082 123.68), oklch(40.39% 0.088 126.72));
40
-
--color-primary-300: light-dark(oklch(78% 0.122 124.71), oklch(53.63% 0.122 127.17));
41
-
--color-primary-400: light-dark(oklch(68% 0.155 125.79), oklch(65.86% 0.152 127.23));
42
-
--color-primary-500: light-dark(oklch(58% 0.182 127.42), oklch(77.77% 0.182 127.42));
43
-
--color-primary-600: light-dark(oklch(48% 0.152 127.23), oklch(81.83% 0.155 125.79));
44
-
--color-primary-700: light-dark(oklch(38% 0.122 127.17), oklch(86.28% 0.122 124.71));
45
-
--color-primary-800: light-dark(oklch(28% 0.088 126.72), oklch(90.67% 0.082 123.68));
46
-
--color-primary-900: light-dark(oklch(20% 0.053 126.29), oklch(95.38% 0.042 123.12));
47
-
--color-primary-950: light-dark(oklch(14% 0.031 123.74), oklch(97.73% 0.02 121.83));
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));
48
49
49
-
/* Mint - Secondary colors (adjusted for WCAG AA compliance) */
50
-
--color-secondary-50: light-dark(oklch(97.87% 0.024 121.9), oklch(18.72% 0.037 126.2));
51
-
--color-secondary-100: light-dark(oklch(94.5% 0.048 123.9), oklch(26.82% 0.058 127.38));
52
-
--color-secondary-200: light-dark(oklch(89% 0.097 124.41), oklch(42.08% 0.101 128.02));
53
-
--color-secondary-300: light-dark(oklch(80% 0.141 125.62), oklch(55.72% 0.137 128.49));
54
-
--color-secondary-400: light-dark(oklch(70% 0.178 127.04), oklch(68.58% 0.171 128.75));
55
-
--color-secondary-500: light-dark(oklch(60% 0.205 129.04), oklch(81.09% 0.205 129.04));
56
-
--color-secondary-600: light-dark(oklch(50% 0.171 128.75), oklch(84.3% 0.178 127.04));
57
-
--color-secondary-700: light-dark(oklch(40% 0.137 128.49), oklch(87.99% 0.141 125.62));
58
-
--color-secondary-800: light-dark(oklch(30% 0.101 128.02), oklch(91.89% 0.097 124.41));
59
-
--color-secondary-900: light-dark(oklch(22% 0.058 127.38), oklch(95.73% 0.048 123.9));
60
-
--color-secondary-950: light-dark(oklch(15% 0.037 126.2), oklch(97.87% 0.024 121.9));
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));
61
62
62
-
/* Jade - Accent colors (adjusted for WCAG AA compliance) */
63
-
--color-accent-50: light-dark(oklch(98.05% 0.027 122.65), oklch(19.03% 0.041 126.73));
64
-
--color-accent-100: light-dark(oklch(95% 0.056 123.8), oklch(27.78% 0.066 127.71));
65
-
--color-accent-200: light-dark(oklch(90% 0.11 124.83), oklch(43.51% 0.11 128.91));
66
-
--color-accent-300: light-dark(oklch(82% 0.159 126.06), oklch(57.9% 0.149 129.35));
67
-
--color-accent-400: light-dark(oklch(72% 0.198 127.63), oklch(71.44% 0.186 129.59));
68
-
--color-accent-500: light-dark(oklch(62% 0.221 129.75), oklch(84.36% 0.221 129.75));
69
-
--color-accent-600: light-dark(oklch(52% 0.186 129.59), oklch(86.93% 0.198 127.63));
70
-
--color-accent-700: light-dark(oklch(42% 0.149 129.35), oklch(89.79% 0.159 126.06));
71
-
--color-accent-800: light-dark(oklch(32% 0.11 128.91), oklch(92.93% 0.11 124.83));
72
-
--color-accent-900: light-dark(oklch(23% 0.066 127.71), oklch(96.35% 0.056 123.8));
73
-
--color-accent-950: light-dark(oklch(16% 0.041 126.73), oklch(98.05% 0.027 122.65));
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));
74
75
}
75
76
76
77
@layer base {
···
81
82
width: 100%;
82
83
}
83
84
85
+
@media (prefers-reduced-motion: reduce) {
86
+
html {
87
+
scroll-behavior: auto;
88
+
}
89
+
90
+
*,
91
+
*::before,
92
+
*::after {
93
+
animation-duration: 0.01ms !important;
94
+
animation-iteration-count: 1 !important;
95
+
transition-duration: 0.01ms !important;
96
+
}
97
+
}
98
+
84
99
body {
85
100
font-family: var(--font-family-sans);
86
101
text-rendering: optimizeLegibility;
···
91
106
max-width: 100vw;
92
107
}
93
108
94
-
/* Focus visible styles for accessibility */
109
+
/* Skip to content link for keyboard navigation */
110
+
.skip-to-content {
111
+
position: absolute;
112
+
left: -9999px;
113
+
z-index: 999;
114
+
padding: 1rem 1.5rem;
115
+
background-color: var(--color-primary-600);
116
+
color: white;
117
+
font-weight: 600;
118
+
text-decoration: none;
119
+
border-radius: 0.5rem;
120
+
}
121
+
122
+
.skip-to-content:focus {
123
+
left: 1rem;
124
+
top: 1rem;
125
+
outline: 2px solid var(--color-primary-800);
126
+
outline-offset: 2px;
127
+
}
128
+
129
+
/* Focus visible styles for accessibility - Enhanced for better visibility */
95
130
*:focus-visible {
96
-
outline: 2px solid var(--color-primary-600);
131
+
outline: 3px solid var(--color-primary-600);
97
132
outline-offset: 2px;
133
+
border-radius: 0.25rem;
134
+
}
135
+
136
+
/* High contrast mode support */
137
+
@media (prefers-contrast: high) {
138
+
*:focus-visible {
139
+
outline-width: 4px;
140
+
}
98
141
}
99
142
100
143
/* Ensure all elements stay within viewport */
···
109
152
object {
110
153
max-width: 100%;
111
154
height: auto;
155
+
}
156
+
157
+
/* Improve link accessibility */
158
+
a {
159
+
text-decoration-skip-ink: auto;
160
+
}
161
+
162
+
/* Better button accessibility */
163
+
button:disabled {
164
+
cursor: not-allowed;
165
+
}
166
+
167
+
/* Screen reader only utility */
168
+
.sr-only {
169
+
position: absolute;
170
+
width: 1px;
171
+
height: 1px;
172
+
padding: 0;
173
+
margin: -1px;
174
+
overflow: hidden;
175
+
clip: rect(0, 0, 0, 0);
176
+
white-space: nowrap;
177
+
border-width: 0;
112
178
}
113
179
}
114
180
+2
src/app.html
+2
src/app.html
···
10
10
/>
11
11
<meta charset="utf-8" />
12
12
<meta name="viewport" content="width=device-width, initial-scale=1" />
13
+
<meta name="theme-color" content="#10b981" />
13
14
%sveltekit.head%
14
15
</head>
15
16
<body data-sveltekit-preload-data="hover">
17
+
<a href="#main-content" class="skip-to-content">Skip to main content</a>
16
18
<div style="display: contents">%sveltekit.body%</div>
17
19
</body>
18
20
</html>
+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>
+142
src/lib/components/layout/ColorThemeToggle.svelte
+142
src/lib/components/layout/ColorThemeToggle.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte';
3
+
import { Palette, Check } from '@lucide/svelte';
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';
11
+
12
+
let isOpen = $state(false);
13
+
let mounted = $state(false);
14
+
let currentTheme = $state<ColorTheme>('slate');
15
+
16
+
// Get themes organized by category
17
+
const themesByCategory = getThemesByCategory();
18
+
type Category = keyof typeof CATEGORY_LABELS;
19
+
20
+
onMount(() => {
21
+
colorTheme.init();
22
+
23
+
const unsubscribe = colorTheme.subscribe((state) => {
24
+
currentTheme = state.current;
25
+
mounted = state.mounted;
26
+
});
27
+
28
+
// Subscribe to dropdown state
29
+
const unsubDropdown = colorThemeDropdownOpen.subscribe((open) => {
30
+
isOpen = open;
31
+
});
32
+
33
+
// Close dropdown when clicking outside (desktop only)
34
+
const handleClickOutside = (e: MouseEvent) => {
35
+
if (isOpen && window.innerWidth >= 768) {
36
+
const target = e.target as HTMLElement;
37
+
if (!target.closest('.color-theme-dropdown')) {
38
+
colorThemeDropdownOpen.set(false);
39
+
}
40
+
}
41
+
};
42
+
document.addEventListener('click', handleClickOutside);
43
+
44
+
// Close on Escape key (desktop only, mobile handled by Header)
45
+
const handleEscape = (e: KeyboardEvent) => {
46
+
if (e.key === 'Escape' && isOpen && window.innerWidth >= 768) {
47
+
colorThemeDropdownOpen.set(false);
48
+
}
49
+
};
50
+
document.addEventListener('keydown', handleEscape);
51
+
52
+
return () => {
53
+
unsubscribe();
54
+
unsubDropdown();
55
+
document.removeEventListener('click', handleClickOutside);
56
+
document.removeEventListener('keydown', handleEscape);
57
+
};
58
+
});
59
+
60
+
function toggleDropdown() {
61
+
colorThemeDropdownOpen.set(!isOpen);
62
+
}
63
+
64
+
function selectTheme(theme: ColorTheme) {
65
+
colorTheme.setTheme(theme);
66
+
colorThemeDropdownOpen.set(false);
67
+
}
68
+
</script>
69
+
70
+
<div class="color-theme-dropdown relative">
71
+
<button
72
+
onclick={toggleDropdown}
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"
75
+
aria-expanded={isOpen}
76
+
aria-controls="color-theme-menu"
77
+
type="button"
78
+
>
79
+
{#if mounted}
80
+
<Palette class="h-5 w-5" aria-hidden="true" />
81
+
{:else}
82
+
<div class="h-5 w-5 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div>
83
+
{/if}
84
+
</button>
85
+
86
+
{#if isOpen}
87
+
<!-- Desktop ONLY: Dropdown menu -->
88
+
<div
89
+
id="color-theme-menu"
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"
91
+
role="menu"
92
+
aria-label="Colour theme menu"
93
+
>
94
+
<div class="max-h-128 overflow-y-auto p-2">
95
+
<div class="mb-2 px-3 py-2 text-xs font-semibold uppercase text-ink-600 dark:text-ink-400">
96
+
Colour Themes
97
+
</div>
98
+
99
+
{#each Object.entries(themesByCategory) as [category, categoryThemes]}
100
+
<div class="mb-3">
101
+
<div class="mb-1.5 px-3 text-xs font-medium text-ink-500 dark:text-ink-500">
102
+
{CATEGORY_LABELS[category as Category]}
103
+
</div>
104
+
<div class="space-y-1">
105
+
{#each categoryThemes as theme}
106
+
<button
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'}"
112
+
role="menuitem"
113
+
aria-current={currentTheme === theme.value ? 'true' : undefined}
114
+
>
115
+
<div
116
+
class="h-6 w-6 shrink-0 rounded-md border border-canvas-300 shadow-sm dark:border-canvas-700"
117
+
style="background-color: {theme.color}"
118
+
aria-hidden="true"
119
+
></div>
120
+
<div class="flex-1 min-w-0">
121
+
<div
122
+
class="font-medium {currentTheme === theme.value ? '' : 'text-ink-900 dark:text-ink-50'}"
123
+
>
124
+
{theme.label}
125
+
</div>
126
+
<div class="text-xs text-ink-600 dark:text-ink-400">{theme.description}</div>
127
+
</div>
128
+
{#if currentTheme === theme.value}
129
+
<Check
130
+
class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400"
131
+
aria-hidden="true"
132
+
/>
133
+
{/if}
134
+
</button>
135
+
{/each}
136
+
</div>
137
+
</div>
138
+
{/each}
139
+
</div>
140
+
</div>
141
+
{/if}
142
+
</div>
+54
-28
src/lib/components/layout/DecimalClock.svelte
+54
-28
src/lib/components/layout/DecimalClock.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte';
3
+
import { Clock } from '@lucide/svelte';
3
4
import DecimalClockInfoBox from './DecimalClockInfoBox.svelte';
4
5
5
-
let decimalTime = $state({ hours: '00', minutes: '00', seconds: '00' });
6
-
let traditionalTime = $state('00:00:00');
6
+
let decimalTime = $state({ hours: '00', minutes: '00' });
7
7
let mounted = $state(false);
8
8
let showInfoBox = $state(false);
9
+
let intervalId: ReturnType<typeof setInterval> | null = null;
10
+
let isVisible = $state(false);
9
11
10
12
function updateDecimalTime() {
11
13
const now = new Date();
···
15
17
// French Revolutionary decimal time:
16
18
// Day divided into 10 hours (0-9)
17
19
// Each hour divided into 100 minutes (0-99)
18
-
// Each minute divided into 100 seconds (0-99)
19
20
const dayProgress = totalMilliseconds / 86400000;
20
21
21
22
// Decimal hours (0-9)
···
26
27
const minuteProgress = (decimalHours % 1) * 100;
27
28
const minutes = Math.floor(minuteProgress).toString().padStart(2, '0');
28
29
29
-
// Decimal seconds (0-99)
30
-
const secondProgress = (minuteProgress % 1) * 100;
31
-
const seconds = Math.floor(secondProgress).toString().padStart(2, '0');
30
+
decimalTime = { hours, minutes };
31
+
}
32
32
33
-
decimalTime = { hours, minutes, seconds };
33
+
function startInterval() {
34
+
if (!intervalId) {
35
+
intervalId = setInterval(updateDecimalTime, 100);
36
+
}
37
+
}
34
38
35
-
// Traditional time
36
-
const h = now.getHours().toString().padStart(2, '0');
37
-
const m = now.getMinutes().toString().padStart(2, '0');
38
-
const s = now.getSeconds().toString().padStart(2, '0');
39
-
traditionalTime = `${h}:${m}:${s}`;
39
+
function stopInterval() {
40
+
if (intervalId) {
41
+
clearInterval(intervalId);
42
+
intervalId = null;
43
+
}
40
44
}
41
45
42
46
onMount(() => {
43
47
updateDecimalTime();
44
48
mounted = true;
45
49
46
-
const interval = setInterval(updateDecimalTime, 100);
50
+
// Use IntersectionObserver to detect when clock is visible
51
+
const clockElement = document.querySelector('[data-decimal-clock]');
52
+
if (clockElement) {
53
+
const observer = new IntersectionObserver(
54
+
(entries) => {
55
+
entries.forEach((entry) => {
56
+
isVisible = entry.isIntersecting;
57
+
if (entry.isIntersecting) {
58
+
updateDecimalTime();
59
+
startInterval();
60
+
} else {
61
+
stopInterval();
62
+
}
63
+
});
64
+
},
65
+
{ threshold: 0 }
66
+
);
67
+
68
+
observer.observe(clockElement);
69
+
70
+
return () => {
71
+
observer.disconnect();
72
+
stopInterval();
73
+
};
74
+
}
47
75
48
76
return () => {
49
-
clearInterval(interval);
77
+
stopInterval();
50
78
};
51
79
});
52
80
···
61
89
62
90
<button
63
91
type="button"
92
+
data-decimal-clock
64
93
onclick={toggleInfoBox}
65
94
class="hidden items-center gap-2 rounded-lg bg-canvas-200 px-3 py-2 text-ink-900 transition-colors hover:bg-canvas-300 md:flex dark:bg-canvas-800 dark:text-ink-50 dark:hover:bg-canvas-700"
66
-
title="French Revolutionary Decimal Time with Traditional Time - Click for info"
67
-
aria-label="Decimal clock showing hour {decimalTime.hours}, {decimalTime.minutes} minutes, {decimalTime.seconds} seconds. Traditional time: {traditionalTime}. Click to learn more."
95
+
title="French Revolutionary Decimal Time - Click for info"
96
+
aria-label="Decimal clock showing {decimalTime.hours} hours and {decimalTime.minutes} minutes. Click to learn more."
68
97
>
98
+
<div class="flex items-center gap-0.5" aria-hidden="true">
99
+
<Clock class="h-4 w-4 shrink-0" />
100
+
<span class="text-xs font-bold text-primary-600 dark:text-primary-400">10</span>
101
+
</div>
69
102
{#if mounted}
70
-
<div class="flex flex-col">
71
-
<div class="flex items-baseline gap-1 font-mono text-sm font-medium leading-tight">
72
-
<span class="tabular-nums">{decimalTime.hours}</span>
73
-
<span class="text-ink-600 dark:text-ink-400">:</span>
74
-
<span class="tabular-nums">{decimalTime.minutes}</span>
75
-
<span class="text-ink-600 dark:text-ink-400">:</span>
76
-
<span class="tabular-nums">{decimalTime.seconds}</span>
77
-
</div>
78
-
<div class="font-mono text-[10px] leading-tight text-ink-600 dark:text-ink-400">
79
-
{traditionalTime}
80
-
</div>
103
+
<div class="flex items-baseline gap-1 font-mono text-sm font-medium">
104
+
<span class="tabular-nums">{decimalTime.hours}</span>
105
+
<span class="text-ink-600 dark:text-ink-400">:</span>
106
+
<span class="tabular-nums">{decimalTime.minutes}</span>
81
107
</div>
82
108
{:else}
83
-
<div class="h-9 w-24 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div>
109
+
<div class="h-5 w-16 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div>
84
110
{/if}
85
111
</button>
86
112
+65
-23
src/lib/components/layout/DecimalClockInfoBox.svelte
+65
-23
src/lib/components/layout/DecimalClockInfoBox.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte';
3
-
import { X } from '@lucide/svelte';
3
+
import { X, Clock } from '@lucide/svelte';
4
4
import Card from '$lib/components/ui/Card.svelte';
5
5
6
6
interface Props {
···
9
9
}
10
10
11
11
let { show, onClose }: Props = $props();
12
-
let ref = $state<HTMLDivElement>();
12
+
let mounted = $state(false);
13
+
let currentTime = $state('00:00');
14
+
let intervalId: ReturnType<typeof setInterval> | null = null;
13
15
14
-
onMount(() => {
15
-
// Move the modal to body to escape stacking context issues
16
-
if (ref) {
17
-
document.body.appendChild(ref);
16
+
// Update current traditional time for the info box
17
+
function updateCurrentTime() {
18
+
const now = new Date();
19
+
const h = now.getHours().toString().padStart(2, '0');
20
+
const m = now.getMinutes().toString().padStart(2, '0');
21
+
currentTime = `${h}:${m}`;
22
+
}
23
+
24
+
function startInterval() {
25
+
if (!intervalId) {
26
+
updateCurrentTime();
27
+
intervalId = setInterval(updateCurrentTime, 1000);
18
28
}
29
+
}
19
30
31
+
function stopInterval() {
32
+
if (intervalId) {
33
+
clearInterval(intervalId);
34
+
intervalId = null;
35
+
}
36
+
}
37
+
38
+
// Watch for show changes
39
+
$effect(() => {
40
+
if (show) {
41
+
startInterval();
42
+
} else {
43
+
stopInterval();
44
+
}
45
+
});
46
+
47
+
onMount(() => {
48
+
mounted = true;
20
49
return () => {
21
-
// Cleanup
22
-
if (ref && document.body.contains(ref)) {
23
-
document.body.removeChild(ref);
24
-
}
50
+
stopInterval();
25
51
};
26
52
});
27
53
</script>
28
54
29
-
{#if show}
55
+
{#if show && mounted}
30
56
<div
31
-
bind:this={ref}
32
-
class="fixed inset-0 z-9999 flex items-center justify-center bg-black/70 p-4"
57
+
class="fixed left-0 top-0 z-9999 flex h-screen w-screen items-center justify-center bg-black/70 p-4"
58
+
style="position: fixed; margin: 0;"
33
59
onclick={onClose}
34
60
onkeydown={(e) => e.key === 'Escape' && onClose()}
35
61
role="button"
···
45
71
tabindex="-1"
46
72
class="w-full max-w-2xl"
47
73
>
48
-
<Card
49
-
variant="elevated"
50
-
padding="lg"
51
-
class="relative max-h-[90vh] overflow-y-auto"
52
-
>
74
+
<Card variant="elevated" padding="lg" class="relative max-h-[90vh] overflow-y-auto">
53
75
{#snippet children()}
54
76
<!-- Close button -->
55
77
<button
56
78
type="button"
57
79
onclick={onClose}
58
-
class="absolute top-4 right-4 rounded-lg p-2 text-ink-600 transition-colors hover:bg-canvas-200 dark:text-ink-400 dark:hover:bg-canvas-800"
80
+
class="absolute top-4 right-4 rounded-lg p-2 text-ink-600 transition-colors hover:bg-canvas-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:text-ink-400 dark:hover:bg-canvas-800"
59
81
aria-label="Close"
60
82
>
61
83
<X class="h-6 w-6" />
···
63
85
64
86
<!-- Content -->
65
87
<div class="space-y-4">
66
-
<h2 id="decimal-time-title" class="text-2xl font-bold text-ink-900 dark:text-ink-50">
88
+
<h2
89
+
id="decimal-time-title"
90
+
class="text-2xl font-bold text-ink-900 dark:text-ink-50"
91
+
>
67
92
French Revolutionary Decimal Time
68
93
</h2>
69
94
70
95
<div class="space-y-3 text-ink-700 dark:text-ink-200">
71
96
<p>
72
-
Decimal time was introduced during the French Revolution as part of the metric
73
-
system. Instead of dividing the day into 24 hours, it uses a base-10 system:
97
+
Decimal time was introduced during the French Revolution as part of the
98
+
metric system. Instead of dividing the day into 24 hours, it uses a base-10
99
+
system:
74
100
</p>
75
101
76
102
<ul class="list-disc space-y-2 pl-6">
···
94
120
<li>1 decimal minute โ 1.44 traditional minutes (86.4 seconds)</li>
95
121
<li>1 decimal second โ 0.864 traditional seconds</li>
96
122
</ul>
123
+
<div
124
+
class="mt-3 flex items-center gap-2 border-t border-canvas-300 pt-3 dark:border-canvas-700"
125
+
>
126
+
<div class="flex items-center gap-0.5" aria-hidden="true">
127
+
<Clock class="h-4 w-4 shrink-0 text-ink-600 dark:text-ink-400" />
128
+
<span class="text-xs font-bold text-secondary-600 dark:text-secondary-400"
129
+
>24</span
130
+
>
131
+
</div>
132
+
<p class="text-xs font-medium text-ink-600 dark:text-ink-400">
133
+
Current traditional time: <span
134
+
class="font-mono font-semibold text-ink-900 dark:text-ink-50"
135
+
>{currentTime}</span
136
+
>
137
+
</p>
138
+
</div>
97
139
{/snippet}
98
140
</Card>
99
141
···
108
150
href="https://www.youtube.com/watch?v=Ax7AbXfhftE"
109
151
target="_blank"
110
152
rel="noopener noreferrer"
111
-
class="underline hover:text-primary-500 dark:hover:text-primary-400"
153
+
class="underline hover:text-primary-500 focus-visible:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:hover:text-primary-400 dark:focus-visible:text-primary-400"
112
154
>"The Longest Softlock in Portal" by Marblr on YouTube</a
113
155
>.
114
156
</p>
+166
-38
src/lib/components/layout/Header.svelte
+166
-38
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';
8
-
import DecimalClock from './DecimalClock.svelte';
8
+
import ColorThemeToggle from './ColorThemeToggle.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;
45
+
// Close color theme dropdown when opening mobile menu
46
+
if (mobileMenuOpen) {
47
+
colorThemeDropdownOpen.set(false);
48
+
}
33
49
}
34
50
35
51
function closeMobileMenu() {
36
52
mobileMenuOpen = false;
53
+
}
54
+
55
+
function closeColorThemeDropdown() {
56
+
colorThemeDropdownOpen.set(false);
57
+
}
58
+
59
+
function selectTheme(theme: ColorTheme) {
60
+
colorTheme.setTheme(theme);
61
+
closeColorThemeDropdown();
37
62
}
38
63
39
64
function isActive(href: string) {
40
65
return $page.url.pathname === href;
41
66
}
42
67
43
-
onMount(async () => {
44
-
try {
45
-
profile = await fetchProfile();
46
-
} catch (err) {
47
-
error = err instanceof Error ? err.message : 'Failed to load profile';
48
-
} finally {
49
-
loading = false;
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
+
83
+
// Fetch profile
84
+
fetchProfile()
85
+
.then((data) => {
86
+
profile = data;
87
+
})
88
+
.catch((err) => {
89
+
error = err instanceof Error ? err.message : 'Failed to load profile';
90
+
})
91
+
.finally(() => {
92
+
loading = false;
93
+
});
94
+
95
+
// Close mobile menus on Escape key
96
+
const handleEscape = (e: KeyboardEvent) => {
97
+
if (e.key === 'Escape') {
98
+
if (mobileMenuOpen) {
99
+
closeMobileMenu();
100
+
}
101
+
if (colorThemeOpen && window.innerWidth < 768) {
102
+
closeColorThemeDropdown();
103
+
}
104
+
}
105
+
};
106
+
document.addEventListener('keydown', handleEscape);
107
+
108
+
return () => {
109
+
unsubTheme();
110
+
unsubDropdown();
111
+
document.removeEventListener('keydown', handleEscape);
112
+
};
51
113
});
52
114
</script>
53
115
···
61
123
<!-- Logo/Avatar with hover title -->
62
124
<a
63
125
href="/"
64
-
class="group relative flex min-w-0 shrink items-center gap-2"
126
+
class="group relative flex min-w-0 shrink items-center"
65
127
onclick={closeMobileMenu}
128
+
aria-label="Home - {siteMeta.title}"
66
129
>
67
130
<div class="relative flex items-center">
68
131
{#if profile?.avatar}
69
132
<img
70
133
src={profile.avatar}
71
-
alt={profile.displayName || profile.handle}
134
+
alt=""
72
135
class="h-10 w-10 rounded-full object-cover"
73
136
onload={() => (imageLoaded = true)}
74
137
/>
75
138
{:else if profile}
76
139
<div
77
140
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-200 font-bold text-primary-800 dark:bg-primary-800 dark:text-primary-200"
141
+
role="img"
142
+
aria-label="{profile.displayName || profile.handle} avatar"
78
143
>
79
144
{(profile.displayName || profile.handle).charAt(0).toUpperCase()}
80
145
</div>
81
146
{:else}
82
-
<div class="h-10 w-10 animate-pulse rounded-full bg-canvas-300 dark:bg-canvas-700"></div>
147
+
<div
148
+
class="h-10 w-10 animate-pulse rounded-full bg-canvas-300 dark:bg-canvas-700"
149
+
role="status"
150
+
aria-label="Loading profile"
151
+
></div>
83
152
{/if}
84
153
85
-
<!-- Site title revealed on hover -->
86
-
<span
87
-
class="absolute top-1/2 left-full ml-2 -translate-y-1/2 truncate text-lg font-bold text-ink-900 opacity-0 transition-all duration-300 group-hover:opacity-100 sm:ml-3 dark:text-ink-50"
88
-
>
89
-
{siteMeta.title}
90
-
</span>
91
154
</div>
155
+
<!-- Site title revealed on hover -->
156
+
<span
157
+
class="ml-2 truncate text-lg font-bold text-ink-900 opacity-0 transition-all duration-300 group-hover:opacity-100 sm:ml-3 dark:text-ink-50"
158
+
aria-hidden="true"
159
+
>
160
+
{siteMeta.title}
161
+
</span>
92
162
</a>
93
163
94
-
<!-- Desktop Navigation -->
164
+
<!-- Right side: Navigation + Toggles -->
95
165
<div class="hidden items-center gap-4 md:flex">
96
-
<ul class="flex items-center gap-6">
166
+
<ul class="flex items-center gap-6" role="list">
97
167
{#each navItems as item}
98
168
{@const IconComponent = iconComponents[item.href]}
99
169
<li>
···
101
171
href={item.href}
102
172
class="group flex items-center gap-2 font-medium transition-colors
103
173
{isActive(item.href) ? 'text-primary-600 dark:text-primary-400' : 'text-ink-700 dark:text-ink-200'}
104
-
hover:text-primary-500"
174
+
hover:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
105
175
aria-current={isActive(item.href) ? 'page' : undefined}
106
176
title={item.label}
107
177
>
···
120
190
</li>
121
191
{/each}
122
192
</ul>
123
-
<DecimalClock />
193
+
194
+
<!-- Desktop Toggles -->
124
195
<div class="flex items-center gap-2">
196
+
<ColorThemeToggle />
125
197
<WolfToggle />
126
198
<ThemeToggle />
127
199
</div>
128
200
</div>
129
201
130
-
<!-- Mobile Menu Button -->
202
+
<!-- Mobile Menu Button + Toggles -->
131
203
<div class="flex items-center gap-2 md:hidden">
204
+
<ColorThemeToggle />
132
205
<WolfToggle />
133
206
<ThemeToggle />
134
207
<button
135
208
onclick={toggleMobileMenu}
136
-
class="flex h-9 w-9 items-center justify-center rounded-lg text-ink-700 transition-colors hover:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900"
209
+
class="flex h-9 w-9 items-center justify-center rounded-lg text-ink-700 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:text-ink-200 dark:hover:bg-canvas-900 dark:focus-visible:bg-canvas-900"
137
210
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
138
211
aria-expanded={mobileMenuOpen}
212
+
aria-controls="mobile-menu"
139
213
>
140
214
{#if mobileMenuOpen}
141
215
<X class="h-6 w-6" aria-hidden="true" />
···
148
222
149
223
<!-- Mobile Menu Dropdown -->
150
224
{#if mobileMenuOpen}
151
-
<div
225
+
<nav
226
+
id="mobile-menu"
152
227
class="border-t border-canvas-200 bg-canvas-50 md:hidden dark:border-canvas-800 dark:bg-canvas-950"
153
-
role="menu"
228
+
aria-label="Mobile navigation"
154
229
>
155
-
<ul class="container mx-auto flex flex-col px-3 py-2">
230
+
<ul class="container mx-auto flex flex-col px-3 py-2" role="list">
156
231
{#each navItems as item}
157
232
{@const IconComponent = iconComponents[item.href]}
158
-
<li role="none">
233
+
<li>
159
234
<a
160
235
href={item.href}
161
236
onclick={closeMobileMenu}
162
-
class="flex items-center gap-3 rounded-lg px-3 py-3 font-medium transition-colors
237
+
class="flex items-center gap-3 rounded-lg px-3 py-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600
163
238
{isActive(item.href)
164
239
? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300'
165
-
: 'text-ink-700 hover:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900'}"
166
-
role="menuitem"
240
+
: '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'}"
167
241
aria-current={isActive(item.href) ? 'page' : undefined}
168
242
>
169
243
{#if IconComponent}
···
183
257
</li>
184
258
{/each}
185
259
</ul>
186
-
</div>
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>
314
+
</nav>
187
315
{/if}
188
316
</header>
+12
-12
src/lib/components/layout/ThemeToggle.svelte
+12
-12
src/lib/components/layout/ThemeToggle.svelte
···
10
10
const stored = localStorage.getItem('theme');
11
11
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
12
12
13
-
isDark = stored === 'dark' || (!stored && prefersDark);
13
+
isDark = stored === 'light' || (!stored && !prefersDark);
14
14
updateTheme();
15
15
mounted = true;
16
16
···
33
33
const htmlElement = document.documentElement;
34
34
35
35
if (isDark) {
36
-
htmlElement.classList.add('dark');
37
-
htmlElement.style.colorScheme = 'dark';
38
-
} else {
39
36
htmlElement.classList.remove('dark');
40
37
htmlElement.style.colorScheme = 'light';
38
+
} else {
39
+
htmlElement.classList.add('dark');
40
+
htmlElement.style.colorScheme = 'dark';
41
41
}
42
42
}
43
43
44
44
function toggleTheme() {
45
45
isDark = !isDark;
46
-
localStorage.setItem('theme', isDark ? 'dark' : 'light');
46
+
localStorage.setItem('theme', isDark ? 'light' : 'dark');
47
47
updateTheme();
48
48
}
49
49
</script>
···
51
51
<button
52
52
onclick={toggleTheme}
53
53
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"
54
-
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
54
+
aria-label={isDark ? 'Switch to dark mode' : 'Switch to light mode'}
55
55
type="button"
56
56
>
57
57
{#if mounted}
58
58
<div class="relative h-5 w-5">
59
-
<Sun
59
+
<Moon
60
60
class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark
61
-
? 'scale-0 rotate-90 opacity-0'
62
-
: 'scale-100 rotate-0 opacity-100'}"
61
+
? 'scale-100 rotate-0 opacity-100'
62
+
: 'scale-0 rotate-90 opacity-0'}"
63
63
aria-hidden="true"
64
64
/>
65
-
<Moon
65
+
<Sun
66
66
class="absolute inset-0 h-5 w-5 transition-all duration-300 {isDark
67
-
? 'scale-100 rotate-0 opacity-100'
68
-
: 'scale-0 -rotate-90 opacity-0'}"
67
+
? 'scale-0 -rotate-90 opacity-0'
68
+
: 'scale-100 rotate-0 opacity-100'}"
69
69
aria-hidden="true"
70
70
/>
71
71
</div>
+1
-1
src/lib/components/layout/main/card/LinkCard.svelte
+1
-1
src/lib/components/layout/main/card/LinkCard.svelte
+17
-8
src/lib/components/layout/main/card/ProfileCard.svelte
+17
-8
src/lib/components/layout/main/card/ProfileCard.svelte
···
58
58
<Card error={true} errorMessage={error} />
59
59
{:else if profile}
60
60
{@const safeProfile = profile}
61
-
<Card variant="elevated" padding="none">
61
+
<Card variant="elevated" padding="none" ariaLabel="Profile information">
62
62
{#snippet children()}
63
63
<!-- Banner -->
64
64
<div class="relative h-32 w-full overflow-hidden rounded-t-xl">
65
65
{#if safeProfile.banner}
66
66
<img
67
67
src={safeProfile.banner}
68
-
alt="Profile banner"
68
+
alt=""
69
69
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
70
70
class:opacity-100={bannerLoaded}
71
71
onload={() => (bannerLoaded = true)}
72
72
loading="lazy"
73
+
role="presentation"
73
74
/>
74
75
{:else}
75
-
<div class="h-full w-full bg-linear-to-r from-primary-400 to-secondary-400"></div>
76
+
<div
77
+
class="h-full w-full bg-linear-to-r from-primary-400 to-secondary-400"
78
+
role="presentation"
79
+
></div>
76
80
{/if}
77
81
</div>
78
82
···
84
88
{#if safeProfile.avatar}
85
89
<img
86
90
src={safeProfile.avatar}
87
-
alt={safeProfile.displayName || safeProfile.handle}
91
+
alt="{safeProfile.displayName || safeProfile.handle}'s profile picture"
88
92
class="h-full w-full object-cover opacity-0 transition-opacity duration-300"
89
93
class:opacity-100={imageLoaded}
90
94
onload={() => (imageLoaded = true)}
···
93
97
{:else}
94
98
<div
95
99
class="flex h-full w-full items-center justify-center bg-primary-200 text-3xl font-bold text-primary-800 dark:bg-primary-800 dark:text-primary-200"
100
+
role="img"
101
+
aria-label="{safeProfile.displayName || safeProfile.handle}'s avatar initials"
96
102
>
97
103
{(safeProfile.displayName || safeProfile.handle).charAt(0).toUpperCase()}
98
104
</div>
···
106
112
{safeProfile.displayName || safeProfile.handle}
107
113
</h2>
108
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}
109
118
110
119
{#if safeProfile.description}
111
120
<p
···
115
124
</p>
116
125
{/if}
117
126
118
-
<div class="flex gap-6 text-sm font-medium">
119
-
<div class="flex items-center gap-1">
127
+
<div class="flex gap-6 text-sm font-medium" role="list" aria-label="Profile statistics">
128
+
<div class="flex items-center gap-1" role="listitem">
120
129
<span class="font-bold text-ink-900 dark:text-ink-50">
121
130
{formatCompactNumber(safeProfile.postsCount, locale)}
122
131
</span>
123
132
<span class="text-ink-700 dark:text-ink-200">Posts</span>
124
133
</div>
125
-
<div class="flex items-center gap-1">
134
+
<div class="flex items-center gap-1" role="listitem">
126
135
<span class="font-bold text-ink-900 dark:text-ink-50">
127
136
{formatCompactNumber(safeProfile.followersCount, locale)}
128
137
</span>
129
138
<span class="text-ink-700 dark:text-ink-200">Followers</span>
130
139
</div>
131
-
<div class="flex items-center gap-1">
140
+
<div class="flex items-center gap-1" role="listitem">
132
141
<span class="font-bold text-ink-900 dark:text-ink-50">
133
142
{formatCompactNumber(safeProfile.followsCount, locale)}
134
143
</span>
+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
+13
-4
src/lib/components/ui/Dropdown.svelte
+13
-4
src/lib/components/ui/Dropdown.svelte
···
11
11
value: string;
12
12
label?: string;
13
13
placeholder?: string;
14
+
id?: string;
14
15
}
15
16
16
-
let { options, value = $bindable(), label, placeholder = 'Select...' }: Props = $props();
17
+
let {
18
+
options,
19
+
value = $bindable(),
20
+
label,
21
+
placeholder = 'Select...',
22
+
id = 'dropdown'
23
+
}: Props = $props();
17
24
</script>
18
25
19
26
<div class="relative">
20
27
{#if label}
21
-
<label for="dropdown" class="mb-2 block text-sm font-medium text-ink-700 dark:text-ink-200">
28
+
<label for={id} class="mb-2 block text-sm font-medium text-ink-700 dark:text-ink-200">
22
29
{label}
23
30
</label>
24
31
{/if}
25
32
<div class="relative">
26
33
<select
27
-
id="dropdown"
34
+
{id}
28
35
bind:value
29
36
class="w-full appearance-none rounded-lg border-2 border-canvas-300 bg-canvas-100 py-2 pr-10 pl-3 text-ink-900 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-50 dark:focus:border-primary-400"
37
+
aria-label={label || 'Select an option'}
30
38
>
31
-
<option value="">{placeholder}</option>
39
+
<option value="" disabled>{placeholder}</option>
32
40
{#each options as option}
33
41
<option value={option.value}>{option.label}</option>
34
42
{/each}
35
43
</select>
36
44
<div
37
45
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-ink-500 dark:text-ink-400"
46
+
aria-hidden="true"
38
47
>
39
48
<ChevronDown class="h-5 w-5" />
40
49
</div>
+2
-2
src/lib/components/ui/InternalCard.svelte
+2
-2
src/lib/components/ui/InternalCard.svelte
···
29
29
}: Props = $props();
30
30
31
31
const baseClasses =
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';
33
-
const combinedClasses = `${baseClasses} ${customClass}`;
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
+
let combinedClasses = $derived(`${baseClasses} ${customClass}`);
34
34
</script>
35
35
36
36
{#if href}
+14
-14
src/lib/components/ui/Pagination.svelte
+14
-14
src/lib/components/ui/Pagination.svelte
···
49
49
</script>
50
50
51
51
{#if totalPages > 1}
52
-
<div class="mt-12">
53
-
<div class="flex items-center justify-center gap-2">
52
+
<nav class="mt-12" aria-label="Pagination navigation">
53
+
<div class="flex items-center justify-center gap-2" role="navigation">
54
54
<!-- Previous Button -->
55
55
<button
56
56
onclick={() => currentPage > 1 && onPageChange(currentPage - 1)}
57
57
disabled={currentPage === 1}
58
-
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-canvas-100 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800 dark:disabled:hover:bg-canvas-900"
59
-
aria-label="Previous page"
58
+
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 focus-visible:bg-canvas-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-canvas-100 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800 dark:focus-visible:bg-canvas-800 dark:disabled:hover:bg-canvas-900"
59
+
aria-label="Go to previous page"
60
60
>
61
-
<ChevronLeft class="h-5 w-5" />
61
+
<ChevronLeft class="h-5 w-5" aria-hidden="true" />
62
62
</button>
63
63
64
64
<!-- Page Numbers -->
65
65
{#each pageNumbers as page}
66
66
{#if page === '...'}
67
-
<span class="px-2 text-ink-500 dark:text-ink-400">...</span>
67
+
<span class="px-2 text-ink-500 dark:text-ink-400" aria-hidden="true">...</span>
68
68
{:else}
69
69
<button
70
70
onclick={() => onPageChange(page as number)}
71
-
class="flex h-10 min-w-[2.5rem] items-center justify-center rounded-lg border-2 px-3 font-medium transition-colors {currentPage ===
71
+
class="flex h-10 min-w-[2.5rem] items-center justify-center rounded-lg border-2 px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 {currentPage ===
72
72
page
73
73
? 'border-primary-500 bg-primary-500 text-white dark:border-primary-400 dark:bg-primary-400'
74
-
: 'border-canvas-300 bg-canvas-100 text-ink-700 hover:bg-canvas-200 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800'}"
75
-
aria-label="Page {page}"
74
+
: 'border-canvas-300 bg-canvas-100 text-ink-700 hover:bg-canvas-200 focus-visible:bg-canvas-200 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800 dark:focus-visible:bg-canvas-800'}"
75
+
aria-label="Go to page {page}"
76
76
aria-current={currentPage === page ? 'page' : undefined}
77
77
>
78
78
{page}
···
84
84
<button
85
85
onclick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
86
86
disabled={currentPage === totalPages}
87
-
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-canvas-100 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800 dark:disabled:hover:bg-canvas-900"
88
-
aria-label="Next page"
87
+
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 border-canvas-300 bg-canvas-100 text-ink-700 transition-colors hover:bg-canvas-200 focus-visible:bg-canvas-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-canvas-100 dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-200 dark:hover:bg-canvas-800 dark:focus-visible:bg-canvas-800 dark:disabled:hover:bg-canvas-900"
88
+
aria-label="Go to next page"
89
89
>
90
-
<ChevronRight class="h-5 w-5" />
90
+
<ChevronRight class="h-5 w-5" aria-hidden="true" />
91
91
</button>
92
92
</div>
93
93
94
94
<!-- Page Info -->
95
-
<p class="mt-4 text-center text-sm text-ink-600 dark:text-ink-300">
95
+
<p class="mt-4 text-center text-sm text-ink-600 dark:text-ink-300" role="status" aria-live="polite" aria-atomic="true">
96
96
Page {currentPage} of {totalPages} · Showing {startItem}โ{endItem} of {totalItems}
97
97
{totalItems === 1 ? 'item' : 'items'}
98
98
</p>
99
-
</div>
99
+
</nav>
100
100
{/if}
+1
-1
src/lib/components/ui/PostsGroupedView.svelte
+1
-1
src/lib/components/ui/PostsGroupedView.svelte
+6
-3
src/lib/components/ui/SearchBar.svelte
+6
-3
src/lib/components/ui/SearchBar.svelte
···
10
10
let { value = $bindable(), placeholder = 'Search...', resultCount }: Props = $props();
11
11
</script>
12
12
13
-
<div>
13
+
<div role="search">
14
+
<label for="search-input" class="sr-only">Search</label>
14
15
<div class="relative">
15
16
<Search
16
17
class="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 text-ink-500 dark:text-ink-400"
17
18
aria-hidden="true"
18
19
/>
19
20
<input
20
-
type="text"
21
+
id="search-input"
22
+
type="search"
21
23
{placeholder}
22
24
bind:value
23
25
class="w-full rounded-lg border-2 border-canvas-300 bg-canvas-100 py-3 pr-4 pl-11 text-ink-900 placeholder-ink-500 transition-colors focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none dark:border-canvas-700 dark:bg-canvas-900 dark:text-ink-50 dark:placeholder-ink-400 dark:focus:border-primary-400"
24
26
aria-label="Search"
27
+
autocomplete="off"
25
28
/>
26
29
</div>
27
30
{#if value && resultCount !== undefined}
28
-
<p class="mt-2 text-sm text-ink-600 dark:text-ink-300">
31
+
<p class="mt-2 text-sm text-ink-600 dark:text-ink-300" role="status" aria-live="polite">
29
32
Found {resultCount}
30
33
{resultCount === 1 ? 'result' : 'results'}
31
34
</p>
+19
-12
src/lib/components/ui/Tabs.svelte
+19
-12
src/lib/components/ui/Tabs.svelte
···
13
13
let { tabs, activeTab, onTabChange }: Props = $props();
14
14
</script>
15
15
16
-
<div class="mb-8 flex flex-wrap gap-2">
17
-
{#each tabs as tab}
18
-
<button
19
-
onclick={() => onTabChange(tab.id)}
20
-
class="rounded-full px-4 py-2 text-sm font-medium transition-all {activeTab === tab.id
21
-
? 'bg-primary-500 text-white shadow-md dark:bg-primary-400'
22
-
: 'bg-canvas-200 text-ink-700 hover:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-200 dark:hover:bg-canvas-700'}"
23
-
aria-current={activeTab === tab.id ? 'page' : undefined}
24
-
>
25
-
{tab.label}
26
-
</button>
27
-
{/each}
16
+
<div class="mb-8" role="tablist" aria-label="Content tabs">
17
+
<div class="flex flex-wrap gap-2">
18
+
{#each tabs as tab, index}
19
+
<button
20
+
onclick={() => onTabChange(tab.id)}
21
+
role="tab"
22
+
aria-selected={activeTab === tab.id}
23
+
aria-controls="{tab.id}-panel"
24
+
id="{tab.id}-tab"
25
+
tabindex={activeTab === tab.id ? 0 : -1}
26
+
class="rounded-full px-4 py-2 text-sm font-medium transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 {activeTab ===
27
+
tab.id
28
+
? 'bg-primary-500 text-white shadow-md dark:bg-primary-400'
29
+
: 'bg-canvas-200 text-ink-700 hover:bg-canvas-300 focus-visible:bg-canvas-300 dark:bg-canvas-800 dark:text-ink-200 dark:hover:bg-canvas-700 dark:focus-visible:bg-canvas-700'}"
30
+
>
31
+
{tab.label}
32
+
</button>
33
+
{/each}
34
+
</div>
28
35
</div>
+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 {
+52
src/lib/stores/colorTheme.ts
+52
src/lib/stores/colorTheme.ts
···
1
+
import { writable } from 'svelte/store';
2
+
import { browser } from '$app/environment';
3
+
import { DEFAULT_THEME, type ColorTheme } from '$lib/config/themes.config';
4
+
5
+
interface ColorThemeState {
6
+
current: ColorTheme;
7
+
mounted: boolean;
8
+
}
9
+
10
+
const STORAGE_KEY = 'color-theme';
11
+
12
+
function createColorThemeStore() {
13
+
const { subscribe, set, update } = writable<ColorThemeState>({
14
+
current: DEFAULT_THEME,
15
+
mounted: false
16
+
});
17
+
18
+
return {
19
+
subscribe,
20
+
init: () => {
21
+
if (!browser) return;
22
+
23
+
const stored = localStorage.getItem(STORAGE_KEY) as ColorTheme | null;
24
+
const theme = stored || DEFAULT_THEME;
25
+
26
+
update((state) => ({ ...state, current: theme, mounted: true }));
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
+
}
33
+
},
34
+
setTheme: (theme: ColorTheme) => {
35
+
if (!browser) return;
36
+
37
+
localStorage.setItem(STORAGE_KEY, theme);
38
+
update((state) => ({ ...state, current: theme }));
39
+
applyTheme(theme);
40
+
}
41
+
};
42
+
}
43
+
44
+
function applyTheme(theme: ColorTheme) {
45
+
if (!browser) return;
46
+
47
+
const root = document.documentElement;
48
+
root.setAttribute('data-color-theme', theme);
49
+
}
50
+
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
+73
src/lib/styles/themes/amber.css
+73
src/lib/styles/themes/amber.css
···
1
+
/* ============================================================================
2
+
AMBER THEME - Yellow
3
+
Primary: Bright yellow
4
+
Secondary: Lime green
5
+
Accent: Gold
6
+
Hue: 85ยฐ (yellow-green, warmer yellow)
7
+
============================================================================ */
8
+
[data-color-theme='amber'] {
9
+
/* Primary - Yellow (85ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19.5% 0.035 85), oklch(97.9% 0.023 85));
11
+
--color-primary-100: light-dark(oklch(28.2% 0.058 85), oklch(95% 0.045 85));
12
+
--color-primary-200: light-dark(oklch(43.5% 0.098 85), oklch(90% 0.088 85));
13
+
--color-primary-300: light-dark(oklch(57.8% 0.132 85), oklch(81.5% 0.128 85));
14
+
--color-primary-400: light-dark(oklch(70.8% 0.165 85), oklch(72.5% 0.162 85));
15
+
--color-primary-500: light-dark(oklch(82.8% 0.195 85), oklch(63.5% 0.195 85));
16
+
--color-primary-600: light-dark(oklch(85.5% 0.162 85), oklch(53.5% 0.165 85));
17
+
--color-primary-700: light-dark(oklch(88.5% 0.128 85), oklch(43.5% 0.132 85));
18
+
--color-primary-800: light-dark(oklch(92% 0.088 85), oklch(33.5% 0.098 85));
19
+
--color-primary-900: light-dark(oklch(96% 0.045 85), oklch(24.5% 0.058 85));
20
+
--color-primary-950: light-dark(oklch(98.2% 0.023 85), oklch(17% 0.035 85));
21
+
22
+
/* Ink - Yellow-tinted text (85ยฐ) */
23
+
--color-ink-50: light-dark(oklch(18% 0.023 85), oklch(97.8% 0.015 85));
24
+
--color-ink-100: light-dark(oklch(26% 0.042 85), oklch(93.5% 0.032 85));
25
+
--color-ink-200: light-dark(oklch(39.5% 0.072 85), oklch(85.5% 0.062 85));
26
+
--color-ink-300: light-dark(oklch(51.5% 0.100 85), oklch(75.5% 0.092 85));
27
+
--color-ink-400: light-dark(oklch(63% 0.125 85), oklch(65.5% 0.120 85));
28
+
--color-ink-500: light-dark(oklch(74% 0.150 85), oklch(55.5% 0.150 85));
29
+
--color-ink-600: light-dark(oklch(78.8% 0.120 85), oklch(45.5% 0.125 85));
30
+
--color-ink-700: light-dark(oklch(84% 0.092 85), oklch(35.5% 0.100 85));
31
+
--color-ink-800: light-dark(oklch(89.5% 0.062 85), oklch(25.5% 0.072 85));
32
+
--color-ink-900: light-dark(oklch(94.8% 0.032 85), oklch(18.5% 0.042 85));
33
+
--color-ink-950: light-dark(oklch(97.8% 0.015 85), oklch(12.5% 0.023 85));
34
+
35
+
/* Canvas - Yellow-tinted backgrounds (85ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18.2% 0.026 85), oklch(98.6% 0.009 85));
37
+
--color-canvas-100: light-dark(oklch(26.2% 0.047 85), oklch(96.8% 0.020 85));
38
+
--color-canvas-200: light-dark(oklch(40% 0.082 85), oklch(92.5% 0.045 85));
39
+
--color-canvas-300: light-dark(oklch(52.8% 0.110 85), oklch(86.5% 0.072 85));
40
+
--color-canvas-400: light-dark(oklch(65% 0.138 85), oklch(80.5% 0.102 85));
41
+
--color-canvas-500: light-dark(oklch(76.5% 0.165 85), oklch(76.5% 0.128 85));
42
+
--color-canvas-600: light-dark(oklch(80.5% 0.102 85), oklch(65% 0.138 85));
43
+
--color-canvas-700: light-dark(oklch(86.5% 0.072 85), oklch(52.8% 0.110 85));
44
+
--color-canvas-800: light-dark(oklch(92.5% 0.045 85), oklch(40% 0.082 85));
45
+
--color-canvas-900: light-dark(oklch(96.8% 0.020 85), oklch(26.2% 0.047 85));
46
+
--color-canvas-950: light-dark(oklch(98.6% 0.009 85), oklch(18.2% 0.026 85));
47
+
48
+
/* Secondary - Lime Green (115ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19% 0.038 115), oklch(97.9% 0.025 115));
50
+
--color-secondary-100: light-dark(oklch(27.5% 0.062 115), oklch(94.8% 0.048 115));
51
+
--color-secondary-200: light-dark(oklch(42.5% 0.105 115), oklch(89.8% 0.095 115));
52
+
--color-secondary-300: light-dark(oklch(56.5% 0.142 115), oklch(81% 0.138 115));
53
+
--color-secondary-400: light-dark(oklch(69.5% 0.175 115), oklch(71.5% 0.172 115));
54
+
--color-secondary-500: light-dark(oklch(81.5% 0.208 115), oklch(62% 0.208 115));
55
+
--color-secondary-600: light-dark(oklch(84.5% 0.172 115), oklch(51.5% 0.175 115));
56
+
--color-secondary-700: light-dark(oklch(88% 0.138 115), oklch(41.5% 0.142 115));
57
+
--color-secondary-800: light-dark(oklch(91.8% 0.095 115), oklch(31.5% 0.105 115));
58
+
--color-secondary-900: light-dark(oklch(95.8% 0.048 115), oklch(23% 0.062 115));
59
+
--color-secondary-950: light-dark(oklch(98.2% 0.025 115), oklch(16.2% 0.038 115));
60
+
61
+
/* Accent - Gold (60ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19.3% 0.037 60), oklch(98% 0.024 60));
63
+
--color-accent-100: light-dark(oklch(28% 0.060 60), oklch(95.2% 0.046 60));
64
+
--color-accent-200: light-dark(oklch(43% 0.102 60), oklch(90.2% 0.092 60));
65
+
--color-accent-300: light-dark(oklch(57.2% 0.138 60), oklch(81.8% 0.132 60));
66
+
--color-accent-400: light-dark(oklch(70% 0.172 60), oklch(72.5% 0.168 60));
67
+
--color-accent-500: light-dark(oklch(82% 0.205 60), oklch(63.2% 0.205 60));
68
+
--color-accent-600: light-dark(oklch(85% 0.168 60), oklch(53% 0.172 60));
69
+
--color-accent-700: light-dark(oklch(88.5% 0.132 60), oklch(43% 0.138 60));
70
+
--color-accent-800: light-dark(oklch(92.5% 0.092 60), oklch(33% 0.102 60));
71
+
--color-accent-900: light-dark(oklch(96.2% 0.046 60), oklch(24.2% 0.060 60));
72
+
--color-accent-950: light-dark(oklch(98.5% 0.024 60), oklch(17% 0.037 60));
73
+
}
+73
src/lib/styles/themes/coral.css
+73
src/lib/styles/themes/coral.css
···
1
+
/* ============================================================================
2
+
CORAL THEME - Orange-pink
3
+
Primary: Vibrant coral
4
+
Secondary: Peach
5
+
Accent: Salmon
6
+
Hue: 20ยฐ (coral/salmon)
7
+
============================================================================ */
8
+
[data-color-theme='coral'] {
9
+
/* Primary - Coral (20ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19.2% 0.040 20), oklch(97.9% 0.027 20));
11
+
--color-primary-100: light-dark(oklch(28% 0.065 20), oklch(94.8% 0.050 20));
12
+
--color-primary-200: light-dark(oklch(43% 0.108 20), oklch(89.5% 0.098 20));
13
+
--color-primary-300: light-dark(oklch(57% 0.145 20), oklch(80.8% 0.142 20));
14
+
--color-primary-400: light-dark(oklch(69.8% 0.180 20), oklch(71.5% 0.178 20));
15
+
--color-primary-500: light-dark(oklch(81.8% 0.212 20), oklch(62% 0.212 20));
16
+
--color-primary-600: light-dark(oklch(84.8% 0.178 20), oklch(51.5% 0.180 20));
17
+
--color-primary-700: light-dark(oklch(88.2% 0.142 20), oklch(41.5% 0.145 20));
18
+
--color-primary-800: light-dark(oklch(92% 0.098 20), oklch(31.5% 0.108 20));
19
+
--color-primary-900: light-dark(oklch(96% 0.050 20), oklch(23% 0.065 20));
20
+
--color-primary-950: light-dark(oklch(98.2% 0.027 20), oklch(16.2% 0.040 20));
21
+
22
+
/* Ink - Coral-tinted text (20ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.8% 0.027 20), oklch(97.6% 0.018 20));
24
+
--color-ink-100: light-dark(oklch(25.5% 0.048 20), oklch(93.2% 0.037 20));
25
+
--color-ink-200: light-dark(oklch(39% 0.082 20), oklch(85.2% 0.070 20));
26
+
--color-ink-300: light-dark(oklch(51% 0.115 20), oklch(75.2% 0.102 20));
27
+
--color-ink-400: light-dark(oklch(62.5% 0.145 20), oklch(65.2% 0.132 20));
28
+
--color-ink-500: light-dark(oklch(73.5% 0.175 20), oklch(55.2% 0.175 20));
29
+
--color-ink-600: light-dark(oklch(78.5% 0.132 20), oklch(45.2% 0.145 20));
30
+
--color-ink-700: light-dark(oklch(83.8% 0.102 20), oklch(35.2% 0.115 20));
31
+
--color-ink-800: light-dark(oklch(89.2% 0.070 20), oklch(25.2% 0.082 20));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.037 20), oklch(18.2% 0.048 20));
33
+
--color-ink-950: light-dark(oklch(97.6% 0.018 20), oklch(12.5% 0.027 20));
34
+
35
+
/* Canvas - Coral-tinted backgrounds (20ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18% 0.030 20), oklch(98.5% 0.011 20));
37
+
--color-canvas-100: light-dark(oklch(26% 0.053 20), oklch(96.5% 0.024 20));
38
+
--color-canvas-200: light-dark(oklch(39.8% 0.092 20), oklch(92% 0.050 20));
39
+
--color-canvas-300: light-dark(oklch(52.5% 0.125 20), oklch(86% 0.082 20));
40
+
--color-canvas-400: light-dark(oklch(64.5% 0.155 20), oklch(80% 0.115 20));
41
+
--color-canvas-500: light-dark(oklch(76% 0.185 20), oklch(76% 0.145 20));
42
+
--color-canvas-600: light-dark(oklch(80% 0.115 20), oklch(64.5% 0.155 20));
43
+
--color-canvas-700: light-dark(oklch(86% 0.082 20), oklch(52.5% 0.125 20));
44
+
--color-canvas-800: light-dark(oklch(92% 0.050 20), oklch(39.8% 0.092 20));
45
+
--color-canvas-900: light-dark(oklch(96.5% 0.024 20), oklch(26% 0.053 20));
46
+
--color-canvas-950: light-dark(oklch(98.5% 0.011 20), oklch(18% 0.030 20));
47
+
48
+
/* Secondary - Peach (35ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.3% 0.038 35), oklch(98% 0.025 35));
50
+
--color-secondary-100: light-dark(oklch(28% 0.062 35), oklch(95% 0.048 35));
51
+
--color-secondary-200: light-dark(oklch(43% 0.105 35), oklch(90% 0.095 35));
52
+
--color-secondary-300: light-dark(oklch(57.2% 0.142 35), oklch(81.5% 0.138 35));
53
+
--color-secondary-400: light-dark(oklch(70% 0.175 35), oklch(72% 0.172 35));
54
+
--color-secondary-500: light-dark(oklch(82% 0.208 35), oklch(62.5% 0.208 35));
55
+
--color-secondary-600: light-dark(oklch(85% 0.172 35), oklch(52% 0.175 35));
56
+
--color-secondary-700: light-dark(oklch(88.5% 0.138 35), oklch(42% 0.142 35));
57
+
--color-secondary-800: light-dark(oklch(92.5% 0.095 35), oklch(32% 0.105 35));
58
+
--color-secondary-900: light-dark(oklch(96.2% 0.048 35), oklch(23.5% 0.062 35));
59
+
--color-secondary-950: light-dark(oklch(98.5% 0.025 35), oklch(16.8% 0.038 35));
60
+
61
+
/* Accent - Salmon (10ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19% 0.042 10), oklch(97.8% 0.028 10));
63
+
--color-accent-100: light-dark(oklch(27.5% 0.068 10), oklch(94.5% 0.052 10));
64
+
--color-accent-200: light-dark(oklch(42.5% 0.115 10), oklch(89.5% 0.105 10));
65
+
--color-accent-300: light-dark(oklch(56.5% 0.155 10), oklch(80.5% 0.148 10));
66
+
--color-accent-400: light-dark(oklch(69.5% 0.192 10), oklch(71% 0.185 10));
67
+
--color-accent-500: light-dark(oklch(81.5% 0.228 10), oklch(61.5% 0.228 10));
68
+
--color-accent-600: light-dark(oklch(84.5% 0.185 10), oklch(51% 0.192 10));
69
+
--color-accent-700: light-dark(oklch(88% 0.148 10), oklch(41% 0.155 10));
70
+
--color-accent-800: light-dark(oklch(91.8% 0.105 10), oklch(31% 0.115 10));
71
+
--color-accent-900: light-dark(oklch(95.8% 0.052 10), oklch(22.5% 0.068 10));
72
+
--color-accent-950: light-dark(oklch(98% 0.028 10), oklch(16% 0.042 10));
73
+
}
+73
src/lib/styles/themes/forest.css
+73
src/lib/styles/themes/forest.css
···
1
+
/* ============================================================================
2
+
FOREST THEME - Green
3
+
Primary: Natural green
4
+
Secondary: Yellow-green
5
+
Accent: Deep emerald
6
+
Hue: 145ยฐ (green)
7
+
============================================================================ */
8
+
[data-color-theme='forest'] {
9
+
/* Primary - Green (145ยฐ) */
10
+
--color-primary-50: light-dark(oklch(18.8% 0.036 145), oklch(97.6% 0.024 145));
11
+
--color-primary-100: light-dark(oklch(27.2% 0.060 145), oklch(94.3% 0.046 145));
12
+
--color-primary-200: light-dark(oklch(41.8% 0.098 145), oklch(88.8% 0.090 145));
13
+
--color-primary-300: light-dark(oklch(55.5% 0.132 145), oklch(79.2% 0.130 145));
14
+
--color-primary-400: light-dark(oklch(67.8% 0.165 145), oklch(69.5% 0.168 145));
15
+
--color-primary-500: light-dark(oklch(79.5% 0.195 145), oklch(59.8% 0.195 145));
16
+
--color-primary-600: light-dark(oklch(82.8% 0.168 145), oklch(49.2% 0.165 145));
17
+
--color-primary-700: light-dark(oklch(86.8% 0.130 145), oklch(39.5% 0.132 145));
18
+
--color-primary-800: light-dark(oklch(91% 0.090 145), oklch(29.8% 0.098 145));
19
+
--color-primary-900: light-dark(oklch(95.5% 0.046 145), oklch(21.5% 0.060 145));
20
+
--color-primary-950: light-dark(oklch(97.9% 0.024 145), oklch(15.2% 0.036 145));
21
+
22
+
/* Ink - Green-tinted text (145ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.6% 0.024 145), oklch(97.4% 0.016 145));
24
+
--color-ink-100: light-dark(oklch(25.2% 0.044 145), oklch(93% 0.034 145));
25
+
--color-ink-200: light-dark(oklch(38.5% 0.075 145), oklch(85% 0.065 145));
26
+
--color-ink-300: light-dark(oklch(50.8% 0.105 145), oklch(75% 0.095 145));
27
+
--color-ink-400: light-dark(oklch(62.5% 0.132 145), oklch(65% 0.125 145));
28
+
--color-ink-500: light-dark(oklch(73.5% 0.158 145), oklch(55% 0.158 145));
29
+
--color-ink-600: light-dark(oklch(78.5% 0.125 145), oklch(45% 0.132 145));
30
+
--color-ink-700: light-dark(oklch(83.8% 0.095 145), oklch(35% 0.105 145));
31
+
--color-ink-800: light-dark(oklch(89.2% 0.065 145), oklch(25% 0.075 145));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.034 145), oklch(18% 0.044 145));
33
+
--color-ink-950: light-dark(oklch(97.4% 0.016 145), oklch(12% 0.024 145));
34
+
35
+
/* Canvas - Green-tinted backgrounds (145ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(17.9% 0.028 145), oklch(98.4% 0.010 145));
37
+
--color-canvas-100: light-dark(oklch(25.9% 0.050 145), oklch(96.4% 0.022 145));
38
+
--color-canvas-200: light-dark(oklch(39.8% 0.088 145), oklch(92% 0.048 145));
39
+
--color-canvas-300: light-dark(oklch(52.5% 0.118 145), oklch(86% 0.078 145));
40
+
--color-canvas-400: light-dark(oklch(64.5% 0.148 145), oklch(80% 0.108 145));
41
+
--color-canvas-500: light-dark(oklch(76% 0.178 145), oklch(76% 0.135 145));
42
+
--color-canvas-600: light-dark(oklch(80% 0.108 145), oklch(64.5% 0.148 145));
43
+
--color-canvas-700: light-dark(oklch(86% 0.078 145), oklch(52.5% 0.118 145));
44
+
--color-canvas-800: light-dark(oklch(92% 0.048 145), oklch(39.8% 0.088 145));
45
+
--color-canvas-900: light-dark(oklch(96.4% 0.022 145), oklch(25.9% 0.050 145));
46
+
--color-canvas-950: light-dark(oklch(98.4% 0.010 145), oklch(17.9% 0.028 145));
47
+
48
+
/* Secondary - Yellow-Green (125ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.2% 0.038 125), oklch(97.8% 0.025 125));
50
+
--color-secondary-100: light-dark(oklch(27.8% 0.062 125), oklch(94.5% 0.048 125));
51
+
--color-secondary-200: light-dark(oklch(42.8% 0.105 125), oklch(89.2% 0.095 125));
52
+
--color-secondary-300: light-dark(oklch(56.8% 0.142 125), oklch(80.2% 0.138 125));
53
+
--color-secondary-400: light-dark(oklch(69.8% 0.175 125), oklch(70.5% 0.172 125));
54
+
--color-secondary-500: light-dark(oklch(81.8% 0.208 125), oklch(60.8% 0.208 125));
55
+
--color-secondary-600: light-dark(oklch(84.8% 0.172 125), oklch(50.2% 0.175 125));
56
+
--color-secondary-700: light-dark(oklch(88.2% 0.138 125), oklch(40.2% 0.142 125));
57
+
--color-secondary-800: light-dark(oklch(92% 0.095 125), oklch(30.5% 0.105 125));
58
+
--color-secondary-900: light-dark(oklch(96% 0.048 125), oklch(22.2% 0.062 125));
59
+
--color-secondary-950: light-dark(oklch(98.2% 0.025 125), oklch(15.8% 0.038 125));
60
+
61
+
/* Accent - Deep Emerald (160ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19% 0.040 160), oklch(97.8% 0.027 160));
63
+
--color-accent-100: light-dark(oklch(27.5% 0.065 160), oklch(94.5% 0.050 160));
64
+
--color-accent-200: light-dark(oklch(42.5% 0.110 160), oklch(89.5% 0.098 160));
65
+
--color-accent-300: light-dark(oklch(56.5% 0.148 160), oklch(80.5% 0.142 160));
66
+
--color-accent-400: light-dark(oklch(69.5% 0.185 160), oklch(70.5% 0.178 160));
67
+
--color-accent-500: light-dark(oklch(81.5% 0.220 160), oklch(61% 0.220 160));
68
+
--color-accent-600: light-dark(oklch(84.5% 0.178 160), oklch(50.5% 0.185 160));
69
+
--color-accent-700: light-dark(oklch(88% 0.142 160), oklch(40.5% 0.148 160));
70
+
--color-accent-800: light-dark(oklch(91.8% 0.098 160), oklch(30.5% 0.110 160));
71
+
--color-accent-900: light-dark(oklch(95.8% 0.050 160), oklch(22.5% 0.065 160));
72
+
--color-accent-950: light-dark(oklch(98% 0.027 160), oklch(16% 0.040 160));
73
+
}
+73
src/lib/styles/themes/lavender.css
+73
src/lib/styles/themes/lavender.css
···
1
+
/* ============================================================================
2
+
LAVENDER THEME - Purple
3
+
Primary: Soft purple
4
+
Secondary: Violet
5
+
Accent: Deep plum
6
+
Hue: 295ยฐ (purple/violet)
7
+
============================================================================ */
8
+
[data-color-theme='lavender'] {
9
+
/* Primary - Lavender (295ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19.5% 0.042 295), oklch(98% 0.028 295));
11
+
--color-primary-100: light-dark(oklch(28.2% 0.068 295), oklch(95% 0.052 295));
12
+
--color-primary-200: light-dark(oklch(43.5% 0.112 295), oklch(90% 0.098 295));
13
+
--color-primary-300: light-dark(oklch(57.5% 0.148 295), oklch(81.5% 0.142 295));
14
+
--color-primary-400: light-dark(oklch(70.2% 0.182 295), oklch(72% 0.178 295));
15
+
--color-primary-500: light-dark(oklch(82% 0.215 295), oklch(62.5% 0.215 295));
16
+
--color-primary-600: light-dark(oklch(85% 0.178 295), oklch(52% 0.182 295));
17
+
--color-primary-700: light-dark(oklch(88.2% 0.142 295), oklch(42% 0.148 295));
18
+
--color-primary-800: light-dark(oklch(92% 0.098 295), oklch(32% 0.112 295));
19
+
--color-primary-900: light-dark(oklch(96% 0.052 295), oklch(23.5% 0.068 295));
20
+
--color-primary-950: light-dark(oklch(98.2% 0.028 295), oklch(16.5% 0.042 295));
21
+
22
+
/* Ink - Purple-tinted text (295ยฐ) */
23
+
--color-ink-50: light-dark(oklch(18% 0.028 295), oklch(97.6% 0.018 295));
24
+
--color-ink-100: light-dark(oklch(26% 0.050 295), oklch(93.2% 0.038 295));
25
+
--color-ink-200: light-dark(oklch(39.5% 0.085 295), oklch(85.2% 0.072 295));
26
+
--color-ink-300: light-dark(oklch(51.5% 0.118 295), oklch(75.2% 0.105 295));
27
+
--color-ink-400: light-dark(oklch(63% 0.148 295), oklch(65.2% 0.135 295));
28
+
--color-ink-500: light-dark(oklch(74% 0.178 295), oklch(55.2% 0.178 295));
29
+
--color-ink-600: light-dark(oklch(78.8% 0.135 295), oklch(45.2% 0.148 295));
30
+
--color-ink-700: light-dark(oklch(84% 0.105 295), oklch(35.2% 0.118 295));
31
+
--color-ink-800: light-dark(oklch(89.5% 0.072 295), oklch(25.2% 0.085 295));
32
+
--color-ink-900: light-dark(oklch(94.8% 0.038 295), oklch(18.2% 0.050 295));
33
+
--color-ink-950: light-dark(oklch(97.6% 0.018 295), oklch(12.5% 0.028 295));
34
+
35
+
/* Canvas - Purple-tinted backgrounds (295ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18.2% 0.031 295), oklch(98.6% 0.011 295));
37
+
--color-canvas-100: light-dark(oklch(26.2% 0.055 295), oklch(96.6% 0.024 295));
38
+
--color-canvas-200: light-dark(oklch(40% 0.095 295), oklch(92.5% 0.052 295));
39
+
--color-canvas-300: light-dark(oklch(52.8% 0.128 295), oklch(86.5% 0.085 295));
40
+
--color-canvas-400: light-dark(oklch(65% 0.162 295), oklch(80.5% 0.118 295));
41
+
--color-canvas-500: light-dark(oklch(76.5% 0.195 295), oklch(76.5% 0.148 295));
42
+
--color-canvas-600: light-dark(oklch(80.5% 0.118 295), oklch(65% 0.162 295));
43
+
--color-canvas-700: light-dark(oklch(86.5% 0.085 295), oklch(52.8% 0.128 295));
44
+
--color-canvas-800: light-dark(oklch(92.5% 0.052 295), oklch(40% 0.095 295));
45
+
--color-canvas-900: light-dark(oklch(96.6% 0.024 295), oklch(26.2% 0.055 295));
46
+
--color-canvas-950: light-dark(oklch(98.6% 0.011 295), oklch(18.2% 0.031 295));
47
+
48
+
/* Secondary - Violet (280ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.2% 0.041 280), oklch(97.9% 0.027 280));
50
+
--color-secondary-100: light-dark(oklch(27.8% 0.066 280), oklch(94.8% 0.051 280));
51
+
--color-secondary-200: light-dark(oklch(42.8% 0.112 280), oklch(89.8% 0.100 280));
52
+
--color-secondary-300: light-dark(oklch(56.8% 0.151 280), oklch(81% 0.145 280));
53
+
--color-secondary-400: light-dark(oklch(69.8% 0.188 280), oklch(71.5% 0.182 280));
54
+
--color-secondary-500: light-dark(oklch(81.8% 0.224 280), oklch(62% 0.224 280));
55
+
--color-secondary-600: light-dark(oklch(84.8% 0.182 280), oklch(51.5% 0.188 280));
56
+
--color-secondary-700: light-dark(oklch(88.2% 0.145 280), oklch(41.5% 0.151 280));
57
+
--color-secondary-800: light-dark(oklch(92% 0.100 280), oklch(31.5% 0.112 280));
58
+
--color-secondary-900: light-dark(oklch(96% 0.051 280), oklch(23% 0.066 280));
59
+
--color-secondary-950: light-dark(oklch(98.2% 0.027 280), oklch(16.2% 0.041 280));
60
+
61
+
/* Accent - Deep Plum (310ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19.5% 0.044 310), oklch(98.1% 0.029 310));
63
+
--color-accent-100: light-dark(oklch(28.2% 0.071 310), oklch(95.2% 0.054 310));
64
+
--color-accent-200: light-dark(oklch(43.5% 0.120 310), oklch(90.2% 0.105 310));
65
+
--color-accent-300: light-dark(oklch(57.8% 0.162 310), oklch(82% 0.152 310));
66
+
--color-accent-400: light-dark(oklch(71% 0.202 310), oklch(72.5% 0.192 310));
67
+
--color-accent-500: light-dark(oklch(83.5% 0.238 310), oklch(63.2% 0.238 310));
68
+
--color-accent-600: light-dark(oklch(86.5% 0.192 310), oklch(52.5% 0.202 310));
69
+
--color-accent-700: light-dark(oklch(89.5% 0.152 310), oklch(42.5% 0.162 310));
70
+
--color-accent-800: light-dark(oklch(92.8% 0.105 310), oklch(32.5% 0.120 310));
71
+
--color-accent-900: light-dark(oklch(96.5% 0.054 310), oklch(24% 0.071 310));
72
+
--color-accent-950: light-dark(oklch(98.5% 0.029 310), oklch(17% 0.044 310));
73
+
}
+71
src/lib/styles/themes/monochrome.css
+71
src/lib/styles/themes/monochrome.css
···
1
+
/* ============================================================================
2
+
MONOCHROME THEME - Pure greyscale
3
+
Neutral, professional, accessible
4
+
All colors desaturated to greyscale
5
+
============================================================================ */
6
+
[data-color-theme='monochrome'] {
7
+
/* Primary - Greyscale */
8
+
--color-primary-50: light-dark(oklch(18% 0 0), oklch(98% 0 0));
9
+
--color-primary-100: light-dark(oklch(26% 0 0), oklch(94.5% 0 0));
10
+
--color-primary-200: light-dark(oklch(40% 0 0), oklch(89% 0 0));
11
+
--color-primary-300: light-dark(oklch(54% 0 0), oklch(79% 0 0));
12
+
--color-primary-400: light-dark(oklch(66% 0 0), oklch(69% 0 0));
13
+
--color-primary-500: light-dark(oklch(78% 0 0), oklch(59% 0 0));
14
+
--color-primary-600: light-dark(oklch(82% 0 0), oklch(49% 0 0));
15
+
--color-primary-700: light-dark(oklch(86.5% 0 0), oklch(39% 0 0));
16
+
--color-primary-800: light-dark(oklch(91% 0 0), oklch(29% 0 0));
17
+
--color-primary-900: light-dark(oklch(95.5% 0 0), oklch(21% 0 0));
18
+
--color-primary-950: light-dark(oklch(98% 0 0), oklch(15% 0 0));
19
+
20
+
/* Ink - Greyscale text */
21
+
--color-ink-50: light-dark(oklch(17% 0 0), oklch(97.5% 0 0));
22
+
--color-ink-100: light-dark(oklch(25% 0 0), oklch(93% 0 0));
23
+
--color-ink-200: light-dark(oklch(38% 0 0), oklch(85% 0 0));
24
+
--color-ink-300: light-dark(oklch(50% 0 0), oklch(75% 0 0));
25
+
--color-ink-400: light-dark(oklch(62% 0 0), oklch(65% 0 0));
26
+
--color-ink-500: light-dark(oklch(73% 0 0), oklch(55% 0 0));
27
+
--color-ink-600: light-dark(oklch(78% 0 0), oklch(45% 0 0));
28
+
--color-ink-700: light-dark(oklch(83.5% 0 0), oklch(35% 0 0));
29
+
--color-ink-800: light-dark(oklch(89% 0 0), oklch(25% 0 0));
30
+
--color-ink-900: light-dark(oklch(94.5% 0 0), oklch(18% 0 0));
31
+
--color-ink-950: light-dark(oklch(97.5% 0 0), oklch(12% 0 0));
32
+
33
+
/* Canvas - Greyscale backgrounds */
34
+
--color-canvas-50: light-dark(oklch(17.5% 0 0), oklch(98.5% 0 0));
35
+
--color-canvas-100: light-dark(oklch(25.5% 0 0), oklch(96.5% 0 0));
36
+
--color-canvas-200: light-dark(oklch(39.5% 0 0), oklch(92% 0 0));
37
+
--color-canvas-300: light-dark(oklch(52% 0 0), oklch(86% 0 0));
38
+
--color-canvas-400: light-dark(oklch(64% 0 0), oklch(80% 0 0));
39
+
--color-canvas-500: light-dark(oklch(75.5% 0 0), oklch(75.5% 0 0));
40
+
--color-canvas-600: light-dark(oklch(80% 0 0), oklch(64% 0 0));
41
+
--color-canvas-700: light-dark(oklch(86% 0 0), oklch(52% 0 0));
42
+
--color-canvas-800: light-dark(oklch(92% 0 0), oklch(39.5% 0 0));
43
+
--color-canvas-900: light-dark(oklch(96.5% 0 0), oklch(25.5% 0 0));
44
+
--color-canvas-950: light-dark(oklch(98.5% 0 0), oklch(17.5% 0 0));
45
+
46
+
/* Secondary - Slightly lighter greyscale */
47
+
--color-secondary-50: light-dark(oklch(19% 0 0), oklch(98% 0 0));
48
+
--color-secondary-100: light-dark(oklch(27% 0 0), oklch(95% 0 0));
49
+
--color-secondary-200: light-dark(oklch(42% 0 0), oklch(89.5% 0 0));
50
+
--color-secondary-300: light-dark(oklch(56% 0 0), oklch(80.5% 0 0));
51
+
--color-secondary-400: light-dark(oklch(69% 0 0), oklch(70.5% 0 0));
52
+
--color-secondary-500: light-dark(oklch(81% 0 0), oklch(60.5% 0 0));
53
+
--color-secondary-600: light-dark(oklch(84.5% 0 0), oklch(50.5% 0 0));
54
+
--color-secondary-700: light-dark(oklch(88% 0 0), oklch(40.5% 0 0));
55
+
--color-secondary-800: light-dark(oklch(92% 0 0), oklch(30.5% 0 0));
56
+
--color-secondary-900: light-dark(oklch(96% 0 0), oklch(22% 0 0));
57
+
--color-secondary-950: light-dark(oklch(98% 0 0), oklch(15.5% 0 0));
58
+
59
+
/* Accent - Darker greyscale */
60
+
--color-accent-50: light-dark(oklch(19.5% 0 0), oklch(98.2% 0 0));
61
+
--color-accent-100: light-dark(oklch(28% 0 0), oklch(95.5% 0 0));
62
+
--color-accent-200: light-dark(oklch(43.5% 0 0), oklch(90.5% 0 0));
63
+
--color-accent-300: light-dark(oklch(58% 0 0), oklch(82.5% 0 0));
64
+
--color-accent-400: light-dark(oklch(71.5% 0 0), oklch(73% 0 0));
65
+
--color-accent-500: light-dark(oklch(84.5% 0 0), oklch(63.5% 0 0));
66
+
--color-accent-600: light-dark(oklch(87% 0 0), oklch(53.5% 0 0));
67
+
--color-accent-700: light-dark(oklch(90% 0 0), oklch(43.5% 0 0));
68
+
--color-accent-800: light-dark(oklch(93% 0 0), oklch(33.5% 0 0));
69
+
--color-accent-900: light-dark(oklch(96.5% 0 0), oklch(24.5% 0 0));
70
+
--color-accent-950: light-dark(oklch(98.2% 0 0), oklch(17.2% 0 0));
71
+
}
+73
src/lib/styles/themes/ocean.css
+73
src/lib/styles/themes/ocean.css
···
1
+
/* ============================================================================
2
+
OCEAN THEME - Blue
3
+
Primary: Deep blue
4
+
Secondary: Sky blue
5
+
Accent: Navy
6
+
Hue: 240ยฐ (blue)
7
+
============================================================================ */
8
+
[data-color-theme='ocean'] {
9
+
/* Primary - Blue (240ยฐ) */
10
+
--color-primary-50: light-dark(oklch(18.5% 0.035 240), oklch(97.5% 0.022 240));
11
+
--color-primary-100: light-dark(oklch(26.5% 0.058 240), oklch(94.2% 0.045 240));
12
+
--color-primary-200: light-dark(oklch(40.8% 0.095 240), oklch(88.5% 0.088 240));
13
+
--color-primary-300: light-dark(oklch(54.2% 0.128 240), oklch(78.5% 0.128 240));
14
+
--color-primary-400: light-dark(oklch(66.5% 0.158 240), oklch(68.5% 0.162 240));
15
+
--color-primary-500: light-dark(oklch(78.2% 0.188 240), oklch(58.5% 0.188 240));
16
+
--color-primary-600: light-dark(oklch(82.1% 0.162 240), oklch(48.5% 0.158 240));
17
+
--color-primary-700: light-dark(oklch(86.5% 0.128 240), oklch(38.5% 0.128 240));
18
+
--color-primary-800: light-dark(oklch(90.8% 0.088 240), oklch(28.5% 0.095 240));
19
+
--color-primary-900: light-dark(oklch(95.5% 0.045 240), oklch(20.5% 0.058 240));
20
+
--color-primary-950: light-dark(oklch(97.8% 0.022 240), oklch(14.5% 0.035 240));
21
+
22
+
/* Ink - Blue-tinted text (240ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.6% 0.023 240), oklch(97.4% 0.015 240));
24
+
--color-ink-100: light-dark(oklch(25.2% 0.043 240), oklch(93% 0.033 240));
25
+
--color-ink-200: light-dark(oklch(38.5% 0.073 240), oklch(85% 0.063 240));
26
+
--color-ink-300: light-dark(oklch(50.8% 0.100 240), oklch(75% 0.093 240));
27
+
--color-ink-400: light-dark(oklch(62.5% 0.125 240), oklch(65% 0.120 240));
28
+
--color-ink-500: light-dark(oklch(73.5% 0.150 240), oklch(55% 0.150 240));
29
+
--color-ink-600: light-dark(oklch(78.5% 0.120 240), oklch(45% 0.125 240));
30
+
--color-ink-700: light-dark(oklch(83.8% 0.093 240), oklch(35% 0.100 240));
31
+
--color-ink-800: light-dark(oklch(89.2% 0.063 240), oklch(25% 0.073 240));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.033 240), oklch(18% 0.043 240));
33
+
--color-ink-950: light-dark(oklch(97.4% 0.015 240), oklch(12% 0.023 240));
34
+
35
+
/* Canvas - Blue-tinted backgrounds (240ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(17.9% 0.026 240), oklch(98.4% 0.009 240));
37
+
--color-canvas-100: light-dark(oklch(25.9% 0.047 240), oklch(96.4% 0.020 240));
38
+
--color-canvas-200: light-dark(oklch(39.8% 0.082 240), oklch(92% 0.045 240));
39
+
--color-canvas-300: light-dark(oklch(52.5% 0.110 240), oklch(86% 0.072 240));
40
+
--color-canvas-400: light-dark(oklch(64.5% 0.138 240), oklch(80% 0.102 240));
41
+
--color-canvas-500: light-dark(oklch(76% 0.165 240), oklch(76% 0.128 240));
42
+
--color-canvas-600: light-dark(oklch(80% 0.102 240), oklch(64.5% 0.138 240));
43
+
--color-canvas-700: light-dark(oklch(86% 0.072 240), oklch(52.5% 0.110 240));
44
+
--color-canvas-800: light-dark(oklch(92% 0.045 240), oklch(39.8% 0.082 240));
45
+
--color-canvas-900: light-dark(oklch(96.4% 0.020 240), oklch(25.9% 0.047 240));
46
+
--color-canvas-950: light-dark(oklch(98.4% 0.009 240), oklch(17.9% 0.026 240));
47
+
48
+
/* Secondary - Sky Blue (220ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19% 0.037 220), oklch(97.8% 0.024 220));
50
+
--color-secondary-100: light-dark(oklch(27.5% 0.060 220), oklch(94.5% 0.046 220));
51
+
--color-secondary-200: light-dark(oklch(42.5% 0.102 220), oklch(89.5% 0.092 220));
52
+
--color-secondary-300: light-dark(oklch(56.5% 0.138 220), oklch(80.5% 0.132 220));
53
+
--color-secondary-400: light-dark(oklch(69.5% 0.172 220), oklch(70.5% 0.168 220));
54
+
--color-secondary-500: light-dark(oklch(81.5% 0.205 220), oklch(61% 0.205 220));
55
+
--color-secondary-600: light-dark(oklch(84.5% 0.168 220), oklch(50.5% 0.172 220));
56
+
--color-secondary-700: light-dark(oklch(88% 0.132 220), oklch(40.5% 0.138 220));
57
+
--color-secondary-800: light-dark(oklch(91.8% 0.092 220), oklch(30.5% 0.102 220));
58
+
--color-secondary-900: light-dark(oklch(95.8% 0.046 220), oklch(22.5% 0.060 220));
59
+
--color-secondary-950: light-dark(oklch(98% 0.024 220), oklch(16% 0.037 220));
60
+
61
+
/* Accent - Navy (255ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19% 0.040 255), oklch(97.9% 0.027 255));
63
+
--color-accent-100: light-dark(oklch(27.5% 0.065 255), oklch(94.8% 0.050 255));
64
+
--color-accent-200: light-dark(oklch(42.5% 0.110 255), oklch(89.8% 0.098 255));
65
+
--color-accent-300: light-dark(oklch(56.5% 0.148 255), oklch(81% 0.142 255));
66
+
--color-accent-400: light-dark(oklch(69.5% 0.185 255), oklch(71.5% 0.178 255));
67
+
--color-accent-500: light-dark(oklch(81.5% 0.220 255), oklch(62% 0.220 255));
68
+
--color-accent-600: light-dark(oklch(84.8% 0.178 255), oklch(51.5% 0.185 255));
69
+
--color-accent-700: light-dark(oklch(88.2% 0.142 255), oklch(41.5% 0.148 255));
70
+
--color-accent-800: light-dark(oklch(92% 0.098 255), oklch(31.5% 0.110 255));
71
+
--color-accent-900: light-dark(oklch(96% 0.050 255), oklch(23% 0.065 255));
72
+
--color-accent-950: light-dark(oklch(98.2% 0.027 255), oklch(16.2% 0.040 255));
73
+
}
+73
src/lib/styles/themes/rose.css
+73
src/lib/styles/themes/rose.css
···
1
+
/* ============================================================================
2
+
ROSE THEME - Pink
3
+
Primary: Soft pink
4
+
Secondary: Magenta
5
+
Accent: Deep rose
6
+
Hue: 350ยฐ (pink-red)
7
+
============================================================================ */
8
+
[data-color-theme='rose'] {
9
+
/* Primary - Rose (350ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19.8% 0.045 350), oklch(98.2% 0.030 350));
11
+
--color-primary-100: light-dark(oklch(28.8% 0.072 350), oklch(95.5% 0.055 350));
12
+
--color-primary-200: light-dark(oklch(44.2% 0.118 350), oklch(90.5% 0.105 350));
13
+
--color-primary-300: light-dark(oklch(58.5% 0.158 350), oklch(82.2% 0.152 350));
14
+
--color-primary-400: light-dark(oklch(71.5% 0.195 350), oklch(73% 0.188 350));
15
+
--color-primary-500: light-dark(oklch(83.5% 0.230 350), oklch(63.5% 0.230 350));
16
+
--color-primary-600: light-dark(oklch(86.2% 0.188 350), oklch(53% 0.195 350));
17
+
--color-primary-700: light-dark(oklch(89.5% 0.152 350), oklch(43% 0.158 350));
18
+
--color-primary-800: light-dark(oklch(92.8% 0.105 350), oklch(33% 0.118 350));
19
+
--color-primary-900: light-dark(oklch(96.5% 0.055 350), oklch(24.5% 0.072 350));
20
+
--color-primary-950: light-dark(oklch(98.5% 0.030 350), oklch(17.2% 0.045 350));
21
+
22
+
/* Ink - Pink-tinted text (350ยฐ) */
23
+
--color-ink-50: light-dark(oklch(18.2% 0.030 350), oklch(97.7% 0.020 350));
24
+
--color-ink-100: light-dark(oklch(26.2% 0.053 350), oklch(93.5% 0.040 350));
25
+
--color-ink-200: light-dark(oklch(39.8% 0.090 350), oklch(85.5% 0.075 350));
26
+
--color-ink-300: light-dark(oklch(51.8% 0.125 350), oklch(75.5% 0.110 350));
27
+
--color-ink-400: light-dark(oklch(63.5% 0.158 350), oklch(65.5% 0.142 350));
28
+
--color-ink-500: light-dark(oklch(74.5% 0.190 350), oklch(55.5% 0.190 350));
29
+
--color-ink-600: light-dark(oklch(79.2% 0.142 350), oklch(45.5% 0.158 350));
30
+
--color-ink-700: light-dark(oklch(84.2% 0.110 350), oklch(35.5% 0.125 350));
31
+
--color-ink-800: light-dark(oklch(89.6% 0.075 350), oklch(25.5% 0.090 350));
32
+
--color-ink-900: light-dark(oklch(94.9% 0.040 350), oklch(18.5% 0.053 350));
33
+
--color-ink-950: light-dark(oklch(97.7% 0.020 350), oklch(12.8% 0.030 350));
34
+
35
+
/* Canvas - Pink-tinted backgrounds (350ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18.4% 0.033 350), oklch(98.7% 0.012 350));
37
+
--color-canvas-100: light-dark(oklch(26.4% 0.058 350), oklch(96.7% 0.026 350));
38
+
--color-canvas-200: light-dark(oklch(40.2% 0.100 350), oklch(92.8% 0.055 350));
39
+
--color-canvas-300: light-dark(oklch(53% 0.135 350), oklch(86.8% 0.088 350));
40
+
--color-canvas-400: light-dark(oklch(65.2% 0.168 350), oklch(80.8% 0.122 350));
41
+
--color-canvas-500: light-dark(oklch(76.8% 0.202 350), oklch(76.8% 0.155 350));
42
+
--color-canvas-600: light-dark(oklch(80.8% 0.122 350), oklch(65.2% 0.168 350));
43
+
--color-canvas-700: light-dark(oklch(86.8% 0.088 350), oklch(53% 0.135 350));
44
+
--color-canvas-800: light-dark(oklch(92.8% 0.055 350), oklch(40.2% 0.100 350));
45
+
--color-canvas-900: light-dark(oklch(96.7% 0.026 350), oklch(26.4% 0.058 350));
46
+
--color-canvas-950: light-dark(oklch(98.7% 0.012 350), oklch(18.4% 0.033 350));
47
+
48
+
/* Secondary - Magenta (330ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.5% 0.043 330), oklch(98% 0.029 330));
50
+
--color-secondary-100: light-dark(oklch(28.2% 0.069 330), oklch(95.2% 0.053 330));
51
+
--color-secondary-200: light-dark(oklch(43.5% 0.116 330), oklch(90.2% 0.103 330));
52
+
--color-secondary-300: light-dark(oklch(57.8% 0.156 330), oklch(82% 0.148 330));
53
+
--color-secondary-400: light-dark(oklch(71% 0.195 330), oklch(72.5% 0.185 330));
54
+
--color-secondary-500: light-dark(oklch(83.5% 0.232 330), oklch(63.2% 0.232 330));
55
+
--color-secondary-600: light-dark(oklch(86.5% 0.185 330), oklch(52.5% 0.195 330));
56
+
--color-secondary-700: light-dark(oklch(89.5% 0.148 330), oklch(42.5% 0.156 330));
57
+
--color-secondary-800: light-dark(oklch(92.8% 0.103 330), oklch(32.5% 0.116 330));
58
+
--color-secondary-900: light-dark(oklch(96.5% 0.053 330), oklch(24% 0.069 330));
59
+
--color-secondary-950: light-dark(oklch(98.5% 0.029 330), oklch(17% 0.043 330));
60
+
61
+
/* Accent - Deep Rose (5ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19.2% 0.043 5), oklch(97.9% 0.029 5));
63
+
--color-accent-100: light-dark(oklch(27.8% 0.069 5), oklch(94.8% 0.053 5));
64
+
--color-accent-200: light-dark(oklch(42.8% 0.118 5), oklch(89.8% 0.105 5));
65
+
--color-accent-300: light-dark(oklch(56.8% 0.158 5), oklch(81% 0.150 5));
66
+
--color-accent-400: light-dark(oklch(69.8% 0.198 5), oklch(71.5% 0.188 5));
67
+
--color-accent-500: light-dark(oklch(81.8% 0.235 5), oklch(62% 0.235 5));
68
+
--color-accent-600: light-dark(oklch(84.8% 0.188 5), oklch(51.5% 0.198 5));
69
+
--color-accent-700: light-dark(oklch(88.2% 0.150 5), oklch(41.5% 0.158 5));
70
+
--color-accent-800: light-dark(oklch(92% 0.105 5), oklch(31.5% 0.118 5));
71
+
--color-accent-900: light-dark(oklch(96% 0.053 5), oklch(23% 0.069 5));
72
+
--color-accent-950: light-dark(oklch(98.2% 0.029 5), oklch(16.2% 0.043 5));
73
+
}
+73
src/lib/styles/themes/ruby.css
+73
src/lib/styles/themes/ruby.css
···
1
+
/* ============================================================================
2
+
RUBY THEME - Pure red
3
+
Primary: Bold red
4
+
Secondary: Orange-red complement
5
+
Accent: Deep crimson
6
+
Hue: 10ยฐ (red with slight orange warmth)
7
+
============================================================================ */
8
+
[data-color-theme='ruby'] {
9
+
/* Primary - Ruby Red (10ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19% 0.042 10), oklch(97.8% 0.028 10));
11
+
--color-primary-100: light-dark(oklch(27.5% 0.068 10), oklch(94.5% 0.052 10));
12
+
--color-primary-200: light-dark(oklch(42.5% 0.115 10), oklch(89.5% 0.105 10));
13
+
--color-primary-300: light-dark(oklch(56.5% 0.155 10), oklch(80.5% 0.148 10));
14
+
--color-primary-400: light-dark(oklch(69.5% 0.192 10), oklch(71% 0.185 10));
15
+
--color-primary-500: light-dark(oklch(81.5% 0.228 10), oklch(61.5% 0.228 10));
16
+
--color-primary-600: light-dark(oklch(84.5% 0.185 10), oklch(51.5% 0.192 10));
17
+
--color-primary-700: light-dark(oklch(88% 0.148 10), oklch(41.5% 0.155 10));
18
+
--color-primary-800: light-dark(oklch(91.8% 0.105 10), oklch(31.5% 0.115 10));
19
+
--color-primary-900: light-dark(oklch(95.8% 0.052 10), oklch(23% 0.068 10));
20
+
--color-primary-950: light-dark(oklch(98% 0.028 10), oklch(16.5% 0.042 10));
21
+
22
+
/* Ink - Red-tinted text (10ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.5% 0.028 10), oklch(97.5% 0.018 10));
24
+
--color-ink-100: light-dark(oklch(25% 0.048 10), oklch(93% 0.038 10));
25
+
--color-ink-200: light-dark(oklch(38.5% 0.082 10), oklch(85% 0.072 10));
26
+
--color-ink-300: light-dark(oklch(50.5% 0.115 10), oklch(75% 0.105 10));
27
+
--color-ink-400: light-dark(oklch(62% 0.145 10), oklch(65% 0.135 10));
28
+
--color-ink-500: light-dark(oklch(73% 0.175 10), oklch(55% 0.175 10));
29
+
--color-ink-600: light-dark(oklch(78% 0.135 10), oklch(45% 0.145 10));
30
+
--color-ink-700: light-dark(oklch(83.5% 0.105 10), oklch(35% 0.115 10));
31
+
--color-ink-800: light-dark(oklch(89% 0.072 10), oklch(25% 0.082 10));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.038 10), oklch(18% 0.048 10));
33
+
--color-ink-950: light-dark(oklch(97.5% 0.018 10), oklch(12% 0.028 10));
34
+
35
+
/* Canvas - Red-tinted backgrounds (10ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(17.8% 0.032 10), oklch(98.5% 0.012 10));
37
+
--color-canvas-100: light-dark(oklch(25.8% 0.055 10), oklch(96.5% 0.025 10));
38
+
--color-canvas-200: light-dark(oklch(39.5% 0.095 10), oklch(92% 0.052 10));
39
+
--color-canvas-300: light-dark(oklch(52% 0.128 10), oklch(86% 0.085 10));
40
+
--color-canvas-400: light-dark(oklch(64% 0.162 10), oklch(80% 0.118 10));
41
+
--color-canvas-500: light-dark(oklch(75.5% 0.195 10), oklch(75.5% 0.148 10));
42
+
--color-canvas-600: light-dark(oklch(80% 0.118 10), oklch(64% 0.162 10));
43
+
--color-canvas-700: light-dark(oklch(86% 0.085 10), oklch(52% 0.128 10));
44
+
--color-canvas-800: light-dark(oklch(92% 0.052 10), oklch(39.5% 0.095 10));
45
+
--color-canvas-900: light-dark(oklch(96.5% 0.025 10), oklch(25.8% 0.055 10));
46
+
--color-canvas-950: light-dark(oklch(98.5% 0.012 10), oklch(17.8% 0.032 10));
47
+
48
+
/* Secondary - Orange-Red (30ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.2% 0.040 30), oklch(97.9% 0.027 30));
50
+
--color-secondary-100: light-dark(oklch(27.8% 0.065 30), oklch(94.8% 0.050 30));
51
+
--color-secondary-200: light-dark(oklch(42.8% 0.110 30), oklch(89.8% 0.098 30));
52
+
--color-secondary-300: light-dark(oklch(56.8% 0.148 30), oklch(81% 0.140 30));
53
+
--color-secondary-400: light-dark(oklch(69.8% 0.185 30), oklch(71.5% 0.178 30));
54
+
--color-secondary-500: light-dark(oklch(81.8% 0.220 30), oklch(62% 0.220 30));
55
+
--color-secondary-600: light-dark(oklch(84.8% 0.178 30), oklch(51.5% 0.185 30));
56
+
--color-secondary-700: light-dark(oklch(88.2% 0.140 30), oklch(41.5% 0.148 30));
57
+
--color-secondary-800: light-dark(oklch(92% 0.098 30), oklch(31.5% 0.110 30));
58
+
--color-secondary-900: light-dark(oklch(96% 0.050 30), oklch(23% 0.065 30));
59
+
--color-secondary-950: light-dark(oklch(98.2% 0.027 30), oklch(16.2% 0.040 30));
60
+
61
+
/* Accent - Deep Crimson (355ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19.5% 0.045 355), oklch(98% 0.030 355));
63
+
--color-accent-100: light-dark(oklch(28.2% 0.072 355), oklch(95.2% 0.055 355));
64
+
--color-accent-200: light-dark(oklch(43.5% 0.122 355), oklch(90.2% 0.108 355));
65
+
--color-accent-300: light-dark(oklch(57.8% 0.165 355), oklch(82% 0.155 355));
66
+
--color-accent-400: light-dark(oklch(71% 0.205 355), oklch(72.5% 0.195 355));
67
+
--color-accent-500: light-dark(oklch(83.5% 0.242 355), oklch(63% 0.242 355));
68
+
--color-accent-600: light-dark(oklch(86.5% 0.195 355), oklch(52.5% 0.205 355));
69
+
--color-accent-700: light-dark(oklch(89.5% 0.155 355), oklch(42.5% 0.165 355));
70
+
--color-accent-800: light-dark(oklch(92.8% 0.108 355), oklch(32.5% 0.122 355));
71
+
--color-accent-900: light-dark(oklch(96.5% 0.055 355), oklch(24% 0.072 355));
72
+
--color-accent-950: light-dark(oklch(98.5% 0.030 355), oklch(17% 0.045 355));
73
+
}
+72
src/lib/styles/themes/sage.css
+72
src/lib/styles/themes/sage.css
···
1
+
/* ============================================================================
2
+
SAGE THEME (Default - matches existing colors)
3
+
Primary: Green-blue, calm and balanced
4
+
Secondary: Mint, fresh complement
5
+
Accent: Jade, vibrant highlight
6
+
============================================================================ */
7
+
[data-color-theme='sage'] {
8
+
/* Primary - Sage (Green-blue) */
9
+
--color-primary-50: light-dark(oklch(18.09% 0.031 123.74), oklch(97.73% 0.02 121.83));
10
+
--color-primary-100: light-dark(oklch(26.23% 0.053 126.29), oklch(94% 0.042 123.12));
11
+
--color-primary-200: light-dark(oklch(40.39% 0.088 126.72), oklch(88% 0.082 123.68));
12
+
--color-primary-300: light-dark(oklch(53.63% 0.122 127.17), oklch(78% 0.122 124.71));
13
+
--color-primary-400: light-dark(oklch(65.86% 0.152 127.23), oklch(68% 0.155 125.79));
14
+
--color-primary-500: light-dark(oklch(77.77% 0.182 127.42), oklch(58% 0.182 127.42));
15
+
--color-primary-600: light-dark(oklch(81.83% 0.155 125.79), oklch(48% 0.152 127.23));
16
+
--color-primary-700: light-dark(oklch(86.28% 0.122 124.71), oklch(38% 0.122 127.17));
17
+
--color-primary-800: light-dark(oklch(90.67% 0.082 123.68), oklch(28% 0.088 126.72));
18
+
--color-primary-900: light-dark(oklch(95.38% 0.042 123.12), oklch(20% 0.053 126.29));
19
+
--color-primary-950: light-dark(oklch(97.73% 0.02 121.83), oklch(14% 0.031 123.74));
20
+
21
+
/* Ink - Text colors (same as default) */
22
+
--color-ink-50: light-dark(oklch(17.39% 0.023 124.58), oklch(97.31% 0.015 123.04));
23
+
--color-ink-100: light-dark(oklch(24.9% 0.042 126.8), oklch(93% 0.032 124.47));
24
+
--color-ink-200: light-dark(oklch(38.03% 0.07 126.15), oklch(85% 0.061 123.88));
25
+
--color-ink-300: light-dark(oklch(50.28% 0.098 126.82), oklch(75% 0.093 124.99));
26
+
--color-ink-400: light-dark(oklch(61.88% 0.124 126.72), oklch(65% 0.123 125.63));
27
+
--color-ink-500: light-dark(oklch(72.9% 0.149 127.03), oklch(55% 0.149 127.03));
28
+
--color-ink-600: light-dark(oklch(78.19% 0.123 125.63), oklch(45% 0.124 126.72));
29
+
--color-ink-700: light-dark(oklch(83.5% 0.093 124.99), oklch(35% 0.098 126.82));
30
+
--color-ink-800: light-dark(oklch(88.94% 0.061 123.88), oklch(25% 0.07 126.15));
31
+
--color-ink-900: light-dark(oklch(94.52% 0.032 124.47), oklch(18% 0.042 126.8));
32
+
--color-ink-950: light-dark(oklch(97.31% 0.015 123.04), oklch(12% 0.023 124.58));
33
+
34
+
/* Canvas - Background colors (same as default) */
35
+
--color-canvas-50: light-dark(oklch(17.69% 0.027 125.57), oklch(98.5% 0.01 123.97));
36
+
--color-canvas-100: light-dark(oklch(25.56% 0.047 126.44), oklch(96.5% 0.02 123.69));
37
+
--color-canvas-200: light-dark(oklch(39.36% 0.083 127.85), oklch(92% 0.045 125.14));
38
+
--color-canvas-300: light-dark(oklch(51.84% 0.112 127.68), oklch(86% 0.075 125.55));
39
+
--color-canvas-400: light-dark(oklch(63.78% 0.141 128.14), oklch(80% 0.105 126.87));
40
+
--color-canvas-500: light-dark(oklch(75.25% 0.169 128.13), oklch(75.25% 0.135 128.13));
41
+
--color-canvas-600: light-dark(oklch(80% 0.105 126.87), oklch(63.78% 0.141 128.14));
42
+
--color-canvas-700: light-dark(oklch(86% 0.075 125.55), oklch(51.84% 0.112 127.68));
43
+
--color-canvas-800: light-dark(oklch(92% 0.045 125.14), oklch(39.36% 0.083 127.85));
44
+
--color-canvas-900: light-dark(oklch(96.5% 0.02 123.69), oklch(25.56% 0.047 126.44));
45
+
--color-canvas-950: light-dark(oklch(98.5% 0.01 123.97), oklch(17.69% 0.027 125.57));
46
+
47
+
/* Secondary - Mint (same as default) */
48
+
--color-secondary-50: light-dark(oklch(18.72% 0.037 126.2), oklch(97.87% 0.024 121.9));
49
+
--color-secondary-100: light-dark(oklch(26.82% 0.058 127.38), oklch(94.5% 0.048 123.9));
50
+
--color-secondary-200: light-dark(oklch(42.08% 0.101 128.02), oklch(89% 0.097 124.41));
51
+
--color-secondary-300: light-dark(oklch(55.72% 0.137 128.49), oklch(80% 0.141 125.62));
52
+
--color-secondary-400: light-dark(oklch(68.58% 0.171 128.75), oklch(70% 0.178 127.04));
53
+
--color-secondary-500: light-dark(oklch(81.09% 0.205 129.04), oklch(60% 0.205 129.04));
54
+
--color-secondary-600: light-dark(oklch(84.3% 0.178 127.04), oklch(50% 0.171 128.75));
55
+
--color-secondary-700: light-dark(oklch(87.99% 0.141 125.62), oklch(40% 0.137 128.49));
56
+
--color-secondary-800: light-dark(oklch(91.89% 0.097 124.41), oklch(30% 0.101 128.02));
57
+
--color-secondary-900: light-dark(oklch(95.73% 0.048 123.9), oklch(22% 0.058 127.38));
58
+
--color-secondary-950: light-dark(oklch(97.87% 0.024 121.9), oklch(15% 0.037 126.2));
59
+
60
+
/* Accent - Jade (same as default) */
61
+
--color-accent-50: light-dark(oklch(19.03% 0.041 126.73), oklch(98.05% 0.027 122.65));
62
+
--color-accent-100: light-dark(oklch(27.78% 0.066 127.71), oklch(95% 0.056 123.8));
63
+
--color-accent-200: light-dark(oklch(43.51% 0.11 128.91), oklch(90% 0.11 124.83));
64
+
--color-accent-300: light-dark(oklch(57.9% 0.149 129.35), oklch(82% 0.159 126.06));
65
+
--color-accent-400: light-dark(oklch(71.44% 0.186 129.59), oklch(72% 0.198 127.63));
66
+
--color-accent-500: light-dark(oklch(84.36% 0.221 129.75), oklch(62% 0.221 129.75));
67
+
--color-accent-600: light-dark(oklch(86.93% 0.198 127.63), oklch(52% 0.186 129.59));
68
+
--color-accent-700: light-dark(oklch(89.79% 0.159 126.06), oklch(42% 0.149 129.35));
69
+
--color-accent-800: light-dark(oklch(92.93% 0.11 124.83), oklch(32% 0.11 128.91));
70
+
--color-accent-900: light-dark(oklch(96.35% 0.056 123.8), oklch(23% 0.066 127.71));
71
+
--color-accent-950: light-dark(oklch(98.05% 0.027 122.65), oklch(16% 0.041 126.73));
72
+
}
+73
src/lib/styles/themes/slate.css
+73
src/lib/styles/themes/slate.css
···
1
+
/* ============================================================================
2
+
SLATE THEME - Blue-grey
3
+
Primary: Sophisticated slate
4
+
Secondary: Steel grey
5
+
Accent: Charcoal
6
+
Hue: 230ยฐ (blue-grey)
7
+
============================================================================ */
8
+
[data-color-theme='slate'] {
9
+
/* Primary - Slate (230ยฐ) */
10
+
--color-primary-50: light-dark(oklch(18.2% 0.018 230), oklch(97.8% 0.012 230));
11
+
--color-primary-100: light-dark(oklch(26.5% 0.030 230), oklch(94.8% 0.022 230));
12
+
--color-primary-200: light-dark(oklch(40.5% 0.048 230), oklch(89.5% 0.042 230));
13
+
--color-primary-300: light-dark(oklch(54% 0.065 230), oklch(79.5% 0.062 230));
14
+
--color-primary-400: light-dark(oklch(66.5% 0.080 230), oklch(69.5% 0.078 230));
15
+
--color-primary-500: light-dark(oklch(78.5% 0.095 230), oklch(59.5% 0.095 230));
16
+
--color-primary-600: light-dark(oklch(82.2% 0.078 230), oklch(49.5% 0.080 230));
17
+
--color-primary-700: light-dark(oklch(86.5% 0.062 230), oklch(39.5% 0.065 230));
18
+
--color-primary-800: light-dark(oklch(91% 0.042 230), oklch(29.5% 0.048 230));
19
+
--color-primary-900: light-dark(oklch(95.8% 0.022 230), oklch(21.5% 0.030 230));
20
+
--color-primary-950: light-dark(oklch(98% 0.012 230), oklch(15.2% 0.018 230));
21
+
22
+
/* Ink - Slate-tinted text (230ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.5% 0.012 230), oklch(97.6% 0.008 230));
24
+
--color-ink-100: light-dark(oklch(25% 0.022 230), oklch(93.2% 0.017 230));
25
+
--color-ink-200: light-dark(oklch(38.5% 0.037 230), oklch(85.2% 0.032 230));
26
+
--color-ink-300: light-dark(oklch(50.5% 0.052 230), oklch(75.2% 0.048 230));
27
+
--color-ink-400: light-dark(oklch(62% 0.065 230), oklch(65.2% 0.062 230));
28
+
--color-ink-500: light-dark(oklch(73% 0.078 230), oklch(55.2% 0.078 230));
29
+
--color-ink-600: light-dark(oklch(78% 0.062 230), oklch(45.2% 0.065 230));
30
+
--color-ink-700: light-dark(oklch(83.5% 0.048 230), oklch(35.2% 0.052 230));
31
+
--color-ink-800: light-dark(oklch(89% 0.032 230), oklch(25.2% 0.037 230));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.017 230), oklch(18.2% 0.022 230));
33
+
--color-ink-950: light-dark(oklch(97.6% 0.008 230), oklch(12.5% 0.012 230));
34
+
35
+
/* Canvas - Slate-tinted backgrounds (230ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(17.8% 0.014 230), oklch(98.6% 0.005 230));
37
+
--color-canvas-100: light-dark(oklch(25.8% 0.025 230), oklch(96.6% 0.011 230));
38
+
--color-canvas-200: light-dark(oklch(39.5% 0.042 230), oklch(92.5% 0.024 230));
39
+
--color-canvas-300: light-dark(oklch(52% 0.058 230), oklch(86.5% 0.038 230));
40
+
--color-canvas-400: light-dark(oklch(64% 0.072 230), oklch(80.5% 0.055 230));
41
+
--color-canvas-500: light-dark(oklch(75.5% 0.085 230), oklch(75.5% 0.068 230));
42
+
--color-canvas-600: light-dark(oklch(80.5% 0.055 230), oklch(64% 0.072 230));
43
+
--color-canvas-700: light-dark(oklch(86.5% 0.038 230), oklch(52% 0.058 230));
44
+
--color-canvas-800: light-dark(oklch(92.5% 0.024 230), oklch(39.5% 0.042 230));
45
+
--color-canvas-900: light-dark(oklch(96.6% 0.011 230), oklch(25.8% 0.025 230));
46
+
--color-canvas-950: light-dark(oklch(98.6% 0.005 230), oklch(17.8% 0.014 230));
47
+
48
+
/* Secondary - Steel Grey (215ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(18.5% 0.020 215), oklch(97.9% 0.013 215));
50
+
--color-secondary-100: light-dark(oklch(26.8% 0.033 215), oklch(95% 0.024 215));
51
+
--color-secondary-200: light-dark(oklch(41% 0.052 215), oklch(89.8% 0.045 215));
52
+
--color-secondary-300: light-dark(oklch(54.5% 0.070 215), oklch(80.2% 0.065 215));
53
+
--color-secondary-400: light-dark(oklch(67% 0.087 215), oklch(70.2% 0.082 215));
54
+
--color-secondary-500: light-dark(oklch(79% 0.103 215), oklch(60.2% 0.103 215));
55
+
--color-secondary-600: light-dark(oklch(82.8% 0.082 215), oklch(50.2% 0.087 215));
56
+
--color-secondary-700: light-dark(oklch(87% 0.065 215), oklch(40.2% 0.070 215));
57
+
--color-secondary-800: light-dark(oklch(91.5% 0.045 215), oklch(30.5% 0.052 215));
58
+
--color-secondary-900: light-dark(oklch(96% 0.024 215), oklch(22.2% 0.033 215));
59
+
--color-secondary-950: light-dark(oklch(98.2% 0.013 215), oklch(15.8% 0.020 215));
60
+
61
+
/* Accent - Charcoal (240ยฐ) */
62
+
--color-accent-50: light-dark(oklch(18.5% 0.022 240), oklch(98% 0.014 240));
63
+
--color-accent-100: light-dark(oklch(26.8% 0.036 240), oklch(95.2% 0.026 240));
64
+
--color-accent-200: light-dark(oklch(41% 0.058 240), oklch(90% 0.048 240));
65
+
--color-accent-300: light-dark(oklch(54.5% 0.078 240), oklch(80.8% 0.072 240));
66
+
--color-accent-400: light-dark(oklch(67% 0.097 240), oklch(71% 0.092 240));
67
+
--color-accent-500: light-dark(oklch(79% 0.115 240), oklch(61% 0.115 240));
68
+
--color-accent-600: light-dark(oklch(82.8% 0.092 240), oklch(51% 0.097 240));
69
+
--color-accent-700: light-dark(oklch(87% 0.072 240), oklch(41% 0.078 240));
70
+
--color-accent-800: light-dark(oklch(91.5% 0.048 240), oklch(31% 0.058 240));
71
+
--color-accent-900: light-dark(oklch(96% 0.026 240), oklch(22.5% 0.036 240));
72
+
--color-accent-950: light-dark(oklch(98.2% 0.014 240), oklch(16.2% 0.022 240));
73
+
}
+73
src/lib/styles/themes/sunset.css
+73
src/lib/styles/themes/sunset.css
···
1
+
/* ============================================================================
2
+
SUNSET THEME - Orange
3
+
Primary: Warm orange
4
+
Secondary: Golden yellow
5
+
Accent: Deep amber
6
+
Hue: 45ยฐ (orange)
7
+
============================================================================ */
8
+
[data-color-theme='sunset'] {
9
+
/* Primary - Orange (45ยฐ) */
10
+
--color-primary-50: light-dark(oklch(19.2% 0.038 45), oklch(97.8% 0.025 45));
11
+
--color-primary-100: light-dark(oklch(27.8% 0.062 45), oklch(94.5% 0.048 45));
12
+
--color-primary-200: light-dark(oklch(42.5% 0.105 45), oklch(89.2% 0.095 45));
13
+
--color-primary-300: light-dark(oklch(56.2% 0.142 45), oklch(80.2% 0.138 45));
14
+
--color-primary-400: light-dark(oklch(68.8% 0.175 45), oklch(70.5% 0.172 45));
15
+
--color-primary-500: light-dark(oklch(80.5% 0.208 45), oklch(60.8% 0.208 45));
16
+
--color-primary-600: light-dark(oklch(83.8% 0.172 45), oklch(50.2% 0.175 45));
17
+
--color-primary-700: light-dark(oklch(87.5% 0.138 45), oklch(40.2% 0.142 45));
18
+
--color-primary-800: light-dark(oklch(91.5% 0.095 45), oklch(30.5% 0.105 45));
19
+
--color-primary-900: light-dark(oklch(95.8% 0.048 45), oklch(22.2% 0.062 45));
20
+
--color-primary-950: light-dark(oklch(98% 0.025 45), oklch(15.8% 0.038 45));
21
+
22
+
/* Ink - Orange-tinted text (45ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.8% 0.025 45), oklch(97.5% 0.016 45));
24
+
--color-ink-100: light-dark(oklch(25.5% 0.045 45), oklch(93% 0.035 45));
25
+
--color-ink-200: light-dark(oklch(39% 0.078 45), oklch(85% 0.068 45));
26
+
--color-ink-300: light-dark(oklch(51% 0.108 45), oklch(75% 0.098 45));
27
+
--color-ink-400: light-dark(oklch(62.5% 0.135 45), oklch(65% 0.128 45));
28
+
--color-ink-500: light-dark(oklch(73.5% 0.162 45), oklch(55% 0.162 45));
29
+
--color-ink-600: light-dark(oklch(78.5% 0.128 45), oklch(45% 0.135 45));
30
+
--color-ink-700: light-dark(oklch(83.8% 0.098 45), oklch(35% 0.108 45));
31
+
--color-ink-800: light-dark(oklch(89.2% 0.068 45), oklch(25% 0.078 45));
32
+
--color-ink-900: light-dark(oklch(94.5% 0.035 45), oklch(18% 0.045 45));
33
+
--color-ink-950: light-dark(oklch(97.5% 0.016 45), oklch(12% 0.025 45));
34
+
35
+
/* Canvas - Orange-tinted backgrounds (45ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18% 0.028 45), oklch(98.5% 0.010 45));
37
+
--color-canvas-100: light-dark(oklch(26% 0.050 45), oklch(96.5% 0.022 45));
38
+
--color-canvas-200: light-dark(oklch(39.8% 0.088 45), oklch(92% 0.048 45));
39
+
--color-canvas-300: light-dark(oklch(52.5% 0.118 45), oklch(86% 0.078 45));
40
+
--color-canvas-400: light-dark(oklch(64.5% 0.148 45), oklch(80% 0.108 45));
41
+
--color-canvas-500: light-dark(oklch(76% 0.178 45), oklch(76% 0.135 45));
42
+
--color-canvas-600: light-dark(oklch(80% 0.108 45), oklch(64.5% 0.148 45));
43
+
--color-canvas-700: light-dark(oklch(86% 0.078 45), oklch(52.5% 0.118 45));
44
+
--color-canvas-800: light-dark(oklch(92% 0.048 45), oklch(39.8% 0.088 45));
45
+
--color-canvas-900: light-dark(oklch(96.5% 0.022 45), oklch(26% 0.050 45));
46
+
--color-canvas-950: light-dark(oklch(98.5% 0.010 45), oklch(18% 0.028 45));
47
+
48
+
/* Secondary - Golden Yellow (75ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19.5% 0.035 75), oklch(98% 0.023 75));
50
+
--color-secondary-100: light-dark(oklch(28.2% 0.058 75), oklch(95.2% 0.045 75));
51
+
--color-secondary-200: light-dark(oklch(43.5% 0.098 75), oklch(90.2% 0.088 75));
52
+
--color-secondary-300: light-dark(oklch(57.8% 0.132 75), oklch(81.8% 0.128 75));
53
+
--color-secondary-400: light-dark(oklch(70.8% 0.165 75), oklch(72.8% 0.162 75));
54
+
--color-secondary-500: light-dark(oklch(82.8% 0.195 75), oklch(63.8% 0.195 75));
55
+
--color-secondary-600: light-dark(oklch(85.5% 0.162 75), oklch(53.8% 0.165 75));
56
+
--color-secondary-700: light-dark(oklch(88.8% 0.128 75), oklch(43.8% 0.132 75));
57
+
--color-secondary-800: light-dark(oklch(92.5% 0.088 75), oklch(33.8% 0.098 75));
58
+
--color-secondary-900: light-dark(oklch(96.2% 0.045 75), oklch(24.8% 0.058 75));
59
+
--color-secondary-950: light-dark(oklch(98.5% 0.023 75), oklch(17.5% 0.035 75));
60
+
61
+
/* Accent - Deep Amber (25ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19% 0.042 25), oklch(97.8% 0.028 25));
63
+
--color-accent-100: light-dark(oklch(27.5% 0.068 25), oklch(94.8% 0.052 25));
64
+
--color-accent-200: light-dark(oklch(42.5% 0.115 25), oklch(89.8% 0.105 25));
65
+
--color-accent-300: light-dark(oklch(56.5% 0.155 25), oklch(81% 0.148 25));
66
+
--color-accent-400: light-dark(oklch(69.5% 0.192 25), oklch(71.5% 0.185 25));
67
+
--color-accent-500: light-dark(oklch(81.5% 0.228 25), oklch(62% 0.228 25));
68
+
--color-accent-600: light-dark(oklch(84.8% 0.185 25), oklch(51.5% 0.192 25));
69
+
--color-accent-700: light-dark(oklch(88.2% 0.148 25), oklch(41.5% 0.155 25));
70
+
--color-accent-800: light-dark(oklch(92% 0.105 25), oklch(31.5% 0.115 25));
71
+
--color-accent-900: light-dark(oklch(96% 0.052 25), oklch(23% 0.068 25));
72
+
--color-accent-950: light-dark(oklch(98.2% 0.028 25), oklch(16.5% 0.042 25));
73
+
}
+73
src/lib/styles/themes/teal.css
+73
src/lib/styles/themes/teal.css
···
1
+
/* ============================================================================
2
+
TEAL THEME - Blue-green (Cyan)
3
+
Primary: Cool teal
4
+
Secondary: Aqua
5
+
Accent: Deep turquoise
6
+
Hue: 195ยฐ (cyan/teal)
7
+
============================================================================ */
8
+
[data-color-theme='teal'] {
9
+
/* Primary - Teal (195ยฐ) */
10
+
--color-primary-50: light-dark(oklch(18.6% 0.038 195), oklch(97.7% 0.025 195));
11
+
--color-primary-100: light-dark(oklch(26.8% 0.062 195), oklch(94.4% 0.048 195));
12
+
--color-primary-200: light-dark(oklch(41.2% 0.102 195), oklch(89% 0.095 195));
13
+
--color-primary-300: light-dark(oklch(54.8% 0.138 195), oklch(79.8% 0.135 195));
14
+
--color-primary-400: light-dark(oklch(67.2% 0.172 195), oklch(70.2% 0.175 195));
15
+
--color-primary-500: light-dark(oklch(79% 0.205 195), oklch(60.5% 0.205 195));
16
+
--color-primary-600: light-dark(oklch(82.5% 0.175 195), oklch(50.5% 0.172 195));
17
+
--color-primary-700: light-dark(oklch(86.5% 0.135 195), oklch(40.5% 0.138 195));
18
+
--color-primary-800: light-dark(oklch(91% 0.095 195), oklch(30.5% 0.102 195));
19
+
--color-primary-900: light-dark(oklch(95.5% 0.048 195), oklch(22% 0.062 195));
20
+
--color-primary-950: light-dark(oklch(98% 0.025 195), oklch(15.5% 0.038 195));
21
+
22
+
/* Ink - Teal-tinted text (195ยฐ) */
23
+
--color-ink-50: light-dark(oklch(17.7% 0.025 195), oklch(97.5% 0.016 195));
24
+
--color-ink-100: light-dark(oklch(25.4% 0.045 195), oklch(93% 0.035 195));
25
+
--color-ink-200: light-dark(oklch(38.8% 0.078 195), oklch(85% 0.068 195));
26
+
--color-ink-300: light-dark(oklch(51.2% 0.108 195), oklch(75% 0.098 195));
27
+
--color-ink-400: light-dark(oklch(62.8% 0.135 195), oklch(65% 0.128 195));
28
+
--color-ink-500: light-dark(oklch(73.8% 0.162 195), oklch(55% 0.162 195));
29
+
--color-ink-600: light-dark(oklch(78.8% 0.128 195), oklch(45% 0.135 195));
30
+
--color-ink-700: light-dark(oklch(84% 0.098 195), oklch(35% 0.108 195));
31
+
--color-ink-800: light-dark(oklch(89.4% 0.068 195), oklch(25% 0.078 195));
32
+
--color-ink-900: light-dark(oklch(94.6% 0.035 195), oklch(18% 0.045 195));
33
+
--color-ink-950: light-dark(oklch(97.5% 0.016 195), oklch(12% 0.025 195));
34
+
35
+
/* Canvas - Teal-tinted backgrounds (195ยฐ) */
36
+
--color-canvas-50: light-dark(oklch(18% 0.028 195), oklch(98.5% 0.010 195));
37
+
--color-canvas-100: light-dark(oklch(26% 0.050 195), oklch(96.5% 0.022 195));
38
+
--color-canvas-200: light-dark(oklch(39.8% 0.088 195), oklch(92% 0.048 195));
39
+
--color-canvas-300: light-dark(oklch(52.5% 0.118 195), oklch(86% 0.078 195));
40
+
--color-canvas-400: light-dark(oklch(64.5% 0.148 195), oklch(80% 0.108 195));
41
+
--color-canvas-500: light-dark(oklch(76% 0.178 195), oklch(76% 0.135 195));
42
+
--color-canvas-600: light-dark(oklch(80% 0.108 195), oklch(64.5% 0.148 195));
43
+
--color-canvas-700: light-dark(oklch(86% 0.078 195), oklch(52.5% 0.118 195));
44
+
--color-canvas-800: light-dark(oklch(92% 0.048 195), oklch(39.8% 0.088 195));
45
+
--color-canvas-900: light-dark(oklch(96.5% 0.022 195), oklch(26% 0.050 195));
46
+
--color-canvas-950: light-dark(oklch(98.5% 0.010 195), oklch(18% 0.028 195));
47
+
48
+
/* Secondary - Aqua (180ยฐ) */
49
+
--color-secondary-50: light-dark(oklch(19% 0.039 180), oklch(97.8% 0.026 180));
50
+
--color-secondary-100: light-dark(oklch(27.5% 0.063 180), oklch(94.5% 0.049 180));
51
+
--color-secondary-200: light-dark(oklch(42.5% 0.105 180), oklch(89.5% 0.098 180));
52
+
--color-secondary-300: light-dark(oklch(56.5% 0.142 180), oklch(80.5% 0.138 180));
53
+
--color-secondary-400: light-dark(oklch(69.5% 0.178 180), oklch(70.5% 0.175 180));
54
+
--color-secondary-500: light-dark(oklch(81.5% 0.212 180), oklch(61% 0.212 180));
55
+
--color-secondary-600: light-dark(oklch(84.5% 0.175 180), oklch(50.5% 0.178 180));
56
+
--color-secondary-700: light-dark(oklch(88% 0.138 180), oklch(40.5% 0.142 180));
57
+
--color-secondary-800: light-dark(oklch(91.8% 0.098 180), oklch(30.5% 0.105 180));
58
+
--color-secondary-900: light-dark(oklch(95.8% 0.049 180), oklch(22.5% 0.063 180));
59
+
--color-secondary-950: light-dark(oklch(98% 0.026 180), oklch(16% 0.039 180));
60
+
61
+
/* Accent - Deep Turquoise (210ยฐ) */
62
+
--color-accent-50: light-dark(oklch(19% 0.040 210), oklch(97.9% 0.027 210));
63
+
--color-accent-100: light-dark(oklch(27.5% 0.065 210), oklch(94.8% 0.050 210));
64
+
--color-accent-200: light-dark(oklch(42.5% 0.110 210), oklch(89.8% 0.098 210));
65
+
--color-accent-300: light-dark(oklch(56.5% 0.148 210), oklch(81% 0.142 210));
66
+
--color-accent-400: light-dark(oklch(69.5% 0.185 210), oklch(71.5% 0.178 210));
67
+
--color-accent-500: light-dark(oklch(81.5% 0.220 210), oklch(62% 0.220 210));
68
+
--color-accent-600: light-dark(oklch(84.8% 0.178 210), oklch(51.5% 0.185 210));
69
+
--color-accent-700: light-dark(oklch(88.2% 0.142 210), oklch(41.5% 0.148 210));
70
+
--color-accent-800: light-dark(oklch(92% 0.098 210), oklch(31.5% 0.110 210));
71
+
--color-accent-900: light-dark(oklch(96% 0.050 210), oklch(23% 0.065 210));
72
+
--color-accent-950: light-dark(oklch(98.2% 0.027 210), oklch(16.2% 0.040 210));
73
+
}
+15
src/lib/styles/themes.css
+15
src/lib/styles/themes.css
···
1
+
/* Color Theme System - Modular Theme Imports */
2
+
/* Each theme is defined in its own file for better organization */
3
+
4
+
@import './themes/sage.css';
5
+
@import './themes/monochrome.css';
6
+
@import './themes/ruby.css';
7
+
@import './themes/sunset.css';
8
+
@import './themes/amber.css';
9
+
@import './themes/forest.css';
10
+
@import './themes/teal.css';
11
+
@import './themes/ocean.css';
12
+
@import './themes/lavender.css';
13
+
@import './themes/rose.css';
14
+
@import './themes/coral.css';
15
+
@import './themes/slate.css';
+10
-2
src/routes/+layout.svelte
+10
-2
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" />
···
83
88
>
84
89
<Header />
85
90
86
-
<main class="container mx-auto grow px-4 py-8">
91
+
<main id="main-content" class="container mx-auto grow px-4 py-8" tabindex="-1">
87
92
<ScrollToTop />
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
};