+19
-3
.cspell.json
+19
-3
.cspell.json
···
3
3
"language": "en",
4
4
"words": [
5
5
"ACTIVITYPUB",
6
+
"afhzlxt",
6
7
"apdisk",
7
8
"apos",
8
9
"ardenivanov",
···
23
24
"Caligraphic",
24
25
"CASL",
25
26
"Centralised",
27
+
"changefreq",
26
28
"colour",
27
29
"colours",
28
30
"Containerisation",
···
124
126
"rknight",
125
127
"Sanitise",
126
128
"scrobbler",
129
+
"Scrobbles",
127
130
"scrobbling",
128
131
"searchi",
129
132
"shapeshifting",
···
135
138
"svelte",
136
139
"timemachine",
137
140
"ttfb",
141
+
"unsub",
142
+
"urlset",
138
143
"Varepsilon",
139
144
"vercel",
140
145
"vercelignore",
···
142
147
"vuepress",
143
148
"vurl",
144
149
"WCAG",
150
+
"webp",
145
151
"wght",
146
152
"whitebreeze",
147
153
"WhiteWind",
···
151
157
"xrpc"
152
158
],
153
159
"flagWords": [],
154
-
"ignorePaths": ["node_modules", "package-lock.json", "dist", "build"],
155
-
"ignoreRegExpList": ["/(\\w+)'s/g"],
160
+
"ignorePaths": [
161
+
"node_modules",
162
+
"package-lock.json",
163
+
"dist",
164
+
"build"
165
+
],
166
+
"ignoreRegExpList": [
167
+
"/(\\w+)'s/g"
168
+
],
156
169
"overrides": [
157
170
{
158
171
"filename": "**/*.svelte",
159
-
"ignoreRegExpList": ["/>.*</", "/(\\w+)'s/g"]
172
+
"ignoreRegExpList": [
173
+
"/>.*</",
174
+
"/(\\w+)'s/g"
175
+
]
160
176
}
161
177
]
162
178
}
+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
+35
.vercelignore
+35
.vercelignore
···
1
+
# Dependencies
2
+
node_modules
3
+
npm-debug.log
4
+
.pnpm-debug.log
5
+
yarn-error.log
6
+
7
+
# Build outputs
8
+
.svelte-kit
9
+
build
10
+
dist
11
+
.vercel_build_output
12
+
13
+
# Environment files (keep .env.example)
14
+
.env
15
+
.env.*
16
+
!.env.example
17
+
18
+
# IDE files
19
+
.vscode
20
+
.idea
21
+
*.swp
22
+
*.swo
23
+
*~
24
+
25
+
# OS files
26
+
.DS_Store
27
+
Thumbs.db
28
+
29
+
# Testing
30
+
coverage
31
+
.nyc_output
32
+
33
+
# Misc
34
+
*.log
35
+
.cache
+13
-57
README.md
+13
-57
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 the [Configuration](#configuration) section below for details on personalising it for your use.
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
-
Before using this template, you'll need to update several configuration files with your own information:
99
-
100
-
### Environment Variables (`.env`)
101
-
102
-
Create a `.env.local` file with your configuration:
103
-
104
-
```ini
105
-
# Required: Your AT Protocol DID
106
-
PUBLIC_ATPROTO_DID=did:plc:your-did-here
107
-
108
-
# Optional: Enable WhiteWind blog support (default: false)
109
-
PUBLIC_ENABLE_WHITEWIND=false
110
-
111
-
# Optional: Custom domain for Leaflet publications
112
-
PUBLIC_LEAFLET_BASE_PATH=https://blog.example.com
113
-
114
-
# Optional: Fallback URL for missing blog posts
115
-
PUBLIC_BLOG_FALLBACK_URL=https://archive.example.com/blog
116
-
117
-
# Site metadata
118
-
PUBLIC_SITE_TITLE="Your Site Title"
119
-
PUBLIC_SITE_DESCRIPTION="Your site description"
120
-
PUBLIC_SITE_KEYWORDS="keywords, separated, by, commas"
121
-
PUBLIC_SITE_URL="https://example.com"
122
-
123
-
# CORS Configuration (for API endpoints)
124
-
# Comma-separated list of allowed origins for CORS
125
-
# Use "*" to allow all origins (not recommended for production)
126
-
# Example: https://example.com,https://app.example.com
127
-
PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com"
128
-
```
129
-
130
-
### Publication Slug Mappings (`src/lib/config/slugs.ts`)
98
+
For detailed configuration instructions, see the [Configuration Guide](./docs/configuration.md).
131
99
132
-
Map friendly URLs to your Leaflet publications:
100
+
Quick start:
133
101
134
-
```typescript
135
-
export const slugMappings: SlugMapping[] = [
136
-
{ slug: 'blog', publicationRkey: '3m3x4bgbsh22k' },
137
-
{ slug: 'essays', publicationRkey: 'abc123xyz' },
138
-
{ slug: 'notes', publicationRkey: 'def456uvw' }
139
-
];
140
-
```
141
-
142
-
### Static Files
143
-
144
-
Update or remove these files that are specific to the example site:
145
-
146
-
- `static/robots.txt` - Update the sitemap URL
147
-
- `static/sitemap.xml` - Update with your domain and pages
148
-
- `static/.well-known/*` - Replace with your own well-known files
149
-
- `static/favicon/` - Replace with your branding
102
+
1. Copy `.env.example` to `.env.local` and add your AT Protocol DID
103
+
2. Configure publication slugs in `src/lib/config/slugs.ts`
104
+
3. Update static files (robots.txt, sitemap.xml, favicons)
105
+
4. Run `npm install && npm run dev`
150
106
151
107
## ๐ Getting Started
152
108
···
176
132
cp .env .env.local
177
133
```
178
134
179
-
Edit `.env.local` with your settings (see Configuration section above)
135
+
Edit `.env.local` with your settings (see [Configuration Guide](./docs/configuration.md) for details)
180
136
181
137
4. **Configure publication slugs** in `src/lib/config/slugs.ts`
182
138
···
293
249
```typescript
294
250
export const slugMappings: SlugMapping[] = [
295
251
{
296
-
slug: 'blog', // Access via /blog
297
-
publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey
252
+
slug: 'blog', // Access via /blog
253
+
publicationRkey: '3m3x4bgbsh22k' // Leaflet publication rkey
298
254
},
299
255
{
300
-
slug: 'notes', // Access via /notes
301
-
publicationRkey: 'xyz123abc'
256
+
slug: 'notes', // Access via /notes
257
+
publicationRkey: 'xyz123abc'
302
258
}
303
259
];
304
260
```
···
551
507
- [teal.fm](https://teal.fm/)
552
508
- [kibun.social](https://kibun.social/)
553
509
- [MusicBrainz](https://musicbrainz.org/)
554
-
- [Tangled](https://tangled.sh/)
510
+
- [Tangled](https://tangled.org/)
555
511
- [Linkat](https://linkat.blue/)
556
512
557
513
## ๐ก Tips & Troubleshooting
+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`.
+507
-61
package-lock.json
+507
-61
package-lock.json
···
1
1
{
2
2
"name": "website",
3
-
"version": "0.0.1",
3
+
"version": "10.5.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
8
"name": "website",
9
-
"version": "0.0.1",
9
+
"version": "10.5.0",
10
10
"dependencies": {
11
11
"@atproto/api": "^0.18.1",
12
12
"@lucide/svelte": "^0.554.0",
13
13
"hls.js": "^1.6.15"
14
14
},
15
15
"devDependencies": {
16
-
"@sveltejs/adapter-auto": "^7.0.0",
16
+
"@sveltejs/adapter-vercel": "^6.2.0",
17
17
"@sveltejs/kit": "^2.49.0",
18
18
"@sveltejs/vite-plugin-svelte": "^6.2.1",
19
19
"@tailwindcss/typography": "^0.5.19",
···
29
29
}
30
30
},
31
31
"node_modules/@atproto/api": {
32
-
"version": "0.18.1",
33
-
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.1.tgz",
34
-
"integrity": "sha512-eK8Us3kRfK+KjxEq/abF3XL4qtqxh7a5GbKHaUGQqPxNGmLiIdFn4Ve4PkpP/OsDfcRMZF5CK47Jr7SARc7ttg==",
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.3",
38
-
"@atproto/lexicon": "^0.5.1",
39
-
"@atproto/syntax": "^0.4.1",
40
-
"@atproto/xrpc": "^0.7.5",
37
+
"@atproto/common-web": "^0.4.6",
38
+
"@atproto/lexicon": "^0.5.2",
39
+
"@atproto/syntax": "^0.4.2",
40
+
"@atproto/xrpc": "^0.7.6",
41
41
"await-lock": "^2.2.2",
42
42
"multiformats": "^9.9.0",
43
43
"tlds": "^1.234.0",
···
45
45
}
46
46
},
47
47
"node_modules/@atproto/common-web": {
48
-
"version": "0.4.3",
49
-
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz",
50
-
"integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==",
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
-
"graphemer": "^1.4.0",
53
+
"@atproto/lex-data": "0.0.2",
54
+
"@atproto/lex-json": "0.0.2",
55
+
"zod": "^3.23.8"
56
+
}
57
+
},
58
+
"node_modules/@atproto/lex-data": {
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
+
"license": "MIT",
63
+
"dependencies": {
64
+
"@atproto/syntax": "0.4.2",
54
65
"multiformats": "^9.9.0",
66
+
"tslib": "^2.8.1",
55
67
"uint8arrays": "3.0.0",
56
-
"zod": "^3.23.8"
68
+
"unicode-segmenter": "^0.14.0"
69
+
}
70
+
},
71
+
"node_modules/@atproto/lex-json": {
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
+
"license": "MIT",
76
+
"dependencies": {
77
+
"@atproto/lex-data": "0.0.2",
78
+
"tslib": "^2.8.1"
57
79
}
58
80
},
59
81
"node_modules/@atproto/lexicon": {
60
-
"version": "0.5.1",
61
-
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz",
62
-
"integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==",
82
+
"version": "0.5.2",
83
+
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.2.tgz",
84
+
"integrity": "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ==",
63
85
"license": "MIT",
64
86
"dependencies": {
65
-
"@atproto/common-web": "^0.4.3",
87
+
"@atproto/common-web": "^0.4.4",
66
88
"@atproto/syntax": "^0.4.1",
67
89
"iso-datestring-validator": "^2.2.2",
68
90
"multiformats": "^9.9.0",
···
70
92
}
71
93
},
72
94
"node_modules/@atproto/syntax": {
73
-
"version": "0.4.1",
74
-
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
75
-
"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==",
76
98
"license": "MIT"
77
99
},
78
100
"node_modules/@atproto/xrpc": {
79
-
"version": "0.7.5",
80
-
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz",
81
-
"integrity": "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==",
101
+
"version": "0.7.6",
102
+
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.6.tgz",
103
+
"integrity": "sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA==",
82
104
"license": "MIT",
83
105
"dependencies": {
84
-
"@atproto/lexicon": "^0.5.1",
106
+
"@atproto/lexicon": "^0.5.2",
85
107
"zod": "^3.23.8"
86
108
}
87
109
},
···
527
549
"node": ">=18"
528
550
}
529
551
},
552
+
"node_modules/@isaacs/balanced-match": {
553
+
"version": "4.0.1",
554
+
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
555
+
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
556
+
"dev": true,
557
+
"license": "MIT",
558
+
"engines": {
559
+
"node": "20 || >=22"
560
+
}
561
+
},
562
+
"node_modules/@isaacs/brace-expansion": {
563
+
"version": "5.0.0",
564
+
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
565
+
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
566
+
"dev": true,
567
+
"license": "MIT",
568
+
"dependencies": {
569
+
"@isaacs/balanced-match": "^4.0.1"
570
+
},
571
+
"engines": {
572
+
"node": "20 || >=22"
573
+
}
574
+
},
575
+
"node_modules/@isaacs/fs-minipass": {
576
+
"version": "4.0.1",
577
+
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
578
+
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
579
+
"dev": true,
580
+
"license": "ISC",
581
+
"dependencies": {
582
+
"minipass": "^7.0.4"
583
+
},
584
+
"engines": {
585
+
"node": ">=18.0.0"
586
+
}
587
+
},
530
588
"node_modules/@jridgewell/gen-mapping": {
531
589
"version": "0.3.13",
532
590
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
···
581
639
"svelte": "^5"
582
640
}
583
641
},
642
+
"node_modules/@mapbox/node-pre-gyp": {
643
+
"version": "2.0.3",
644
+
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
645
+
"integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==",
646
+
"dev": true,
647
+
"license": "BSD-3-Clause",
648
+
"dependencies": {
649
+
"consola": "^3.2.3",
650
+
"detect-libc": "^2.0.0",
651
+
"https-proxy-agent": "^7.0.5",
652
+
"node-fetch": "^2.6.7",
653
+
"nopt": "^8.0.0",
654
+
"semver": "^7.5.3",
655
+
"tar": "^7.4.0"
656
+
},
657
+
"bin": {
658
+
"node-pre-gyp": "bin/node-pre-gyp"
659
+
},
660
+
"engines": {
661
+
"node": ">=18"
662
+
}
663
+
},
584
664
"node_modules/@polka/url": {
585
665
"version": "1.0.0-next.29",
586
666
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
···
588
668
"dev": true,
589
669
"license": "MIT"
590
670
},
671
+
"node_modules/@rollup/pluginutils": {
672
+
"version": "5.3.0",
673
+
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
674
+
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
675
+
"dev": true,
676
+
"license": "MIT",
677
+
"dependencies": {
678
+
"@types/estree": "^1.0.0",
679
+
"estree-walker": "^2.0.2",
680
+
"picomatch": "^4.0.2"
681
+
},
682
+
"engines": {
683
+
"node": ">=14.0.0"
684
+
},
685
+
"peerDependencies": {
686
+
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
687
+
},
688
+
"peerDependenciesMeta": {
689
+
"rollup": {
690
+
"optional": true
691
+
}
692
+
}
693
+
},
591
694
"node_modules/@rollup/rollup-android-arm-eabi": {
592
695
"version": "4.53.3",
593
696
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
···
904
1007
"license": "MIT"
905
1008
},
906
1009
"node_modules/@sveltejs/acorn-typescript": {
907
-
"version": "1.0.7",
908
-
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
909
-
"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==",
910
1013
"license": "MIT",
911
1014
"peerDependencies": {
912
1015
"acorn": "^8.9.0"
913
1016
}
914
1017
},
915
-
"node_modules/@sveltejs/adapter-auto": {
916
-
"version": "7.0.0",
917
-
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz",
918
-
"integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==",
1018
+
"node_modules/@sveltejs/adapter-vercel": {
1019
+
"version": "6.2.0",
1020
+
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-vercel/-/adapter-vercel-6.2.0.tgz",
1021
+
"integrity": "sha512-JojC+3dcxNKxO6ixoHq7k1QRL2KCX7RzwfXp1vwbLZkKZrPc5KvhbutVYYiIe0C3aky7VJU6kWp1k9a4b1mgoA==",
919
1022
"dev": true,
920
1023
"license": "MIT",
1024
+
"dependencies": {
1025
+
"@vercel/nft": "^1.0.0",
1026
+
"esbuild": "^0.25.4"
1027
+
},
1028
+
"engines": {
1029
+
"node": ">=20.0"
1030
+
},
921
1031
"peerDependencies": {
922
-
"@sveltejs/kit": "^2.0.0"
1032
+
"@sveltejs/kit": "^2.4.0"
923
1033
}
924
1034
},
925
1035
"node_modules/@sveltejs/kit": {
926
-
"version": "2.49.0",
927
-
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz",
928
-
"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==",
929
1039
"dev": true,
930
1040
"license": "MIT",
931
1041
"peer": true,
···
1300
1410
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1301
1411
"license": "MIT"
1302
1412
},
1413
+
"node_modules/@vercel/nft": {
1414
+
"version": "1.1.1",
1415
+
"resolved": "https://registry.npmjs.org/@vercel/nft/-/nft-1.1.1.tgz",
1416
+
"integrity": "sha512-mKMGa7CEUcXU75474kOeqHbtvK1kAcu4wiahhmlUenB5JbTQB8wVlDI8CyHR3rpGo0qlzoRWqcDzI41FUoBJCA==",
1417
+
"dev": true,
1418
+
"license": "MIT",
1419
+
"dependencies": {
1420
+
"@mapbox/node-pre-gyp": "^2.0.0",
1421
+
"@rollup/pluginutils": "^5.1.3",
1422
+
"acorn": "^8.6.0",
1423
+
"acorn-import-attributes": "^1.9.5",
1424
+
"async-sema": "^3.1.1",
1425
+
"bindings": "^1.4.0",
1426
+
"estree-walker": "2.0.2",
1427
+
"glob": "^13.0.0",
1428
+
"graceful-fs": "^4.2.9",
1429
+
"node-gyp-build": "^4.2.2",
1430
+
"picomatch": "^4.0.2",
1431
+
"resolve-from": "^5.0.0"
1432
+
},
1433
+
"bin": {
1434
+
"nft": "out/cli.js"
1435
+
},
1436
+
"engines": {
1437
+
"node": ">=20"
1438
+
}
1439
+
},
1440
+
"node_modules/abbrev": {
1441
+
"version": "3.0.1",
1442
+
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
1443
+
"integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==",
1444
+
"dev": true,
1445
+
"license": "ISC",
1446
+
"engines": {
1447
+
"node": "^18.17.0 || >=20.5.0"
1448
+
}
1449
+
},
1303
1450
"node_modules/acorn": {
1304
1451
"version": "8.15.0",
1305
1452
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
···
1313
1460
"node": ">=0.4.0"
1314
1461
}
1315
1462
},
1463
+
"node_modules/acorn-import-attributes": {
1464
+
"version": "1.9.5",
1465
+
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
1466
+
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
1467
+
"dev": true,
1468
+
"license": "MIT",
1469
+
"peerDependencies": {
1470
+
"acorn": "^8"
1471
+
}
1472
+
},
1473
+
"node_modules/agent-base": {
1474
+
"version": "7.1.4",
1475
+
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
1476
+
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
1477
+
"dev": true,
1478
+
"license": "MIT",
1479
+
"engines": {
1480
+
"node": ">= 14"
1481
+
}
1482
+
},
1316
1483
"node_modules/aria-query": {
1317
1484
"version": "5.3.2",
1318
1485
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
···
1322
1489
"node": ">= 0.4"
1323
1490
}
1324
1491
},
1492
+
"node_modules/async-sema": {
1493
+
"version": "3.1.1",
1494
+
"resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz",
1495
+
"integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==",
1496
+
"dev": true,
1497
+
"license": "MIT"
1498
+
},
1325
1499
"node_modules/await-lock": {
1326
1500
"version": "2.2.2",
1327
1501
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
···
1337
1511
"node": ">= 0.4"
1338
1512
}
1339
1513
},
1514
+
"node_modules/bindings": {
1515
+
"version": "1.5.0",
1516
+
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
1517
+
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
1518
+
"dev": true,
1519
+
"license": "MIT",
1520
+
"dependencies": {
1521
+
"file-uri-to-path": "1.0.0"
1522
+
}
1523
+
},
1340
1524
"node_modules/chokidar": {
1341
1525
"version": "4.0.3",
1342
1526
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
···
1353
1537
"url": "https://paulmillr.com/funding/"
1354
1538
}
1355
1539
},
1540
+
"node_modules/chownr": {
1541
+
"version": "3.0.0",
1542
+
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
1543
+
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
1544
+
"dev": true,
1545
+
"license": "BlueOak-1.0.0",
1546
+
"engines": {
1547
+
"node": ">=18"
1548
+
}
1549
+
},
1356
1550
"node_modules/clsx": {
1357
1551
"version": "2.1.1",
1358
1552
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
···
1362
1556
"node": ">=6"
1363
1557
}
1364
1558
},
1559
+
"node_modules/consola": {
1560
+
"version": "3.4.2",
1561
+
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
1562
+
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
1563
+
"dev": true,
1564
+
"license": "MIT",
1565
+
"engines": {
1566
+
"node": "^14.18.0 || >=16.10.0"
1567
+
}
1568
+
},
1365
1569
"node_modules/cookie": {
1366
1570
"version": "0.6.0",
1367
1571
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
···
1427
1631
"version": "5.5.0",
1428
1632
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
1429
1633
"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
1430
-
"dev": true,
1431
1634
"license": "MIT"
1432
1635
},
1433
1636
"node_modules/enhanced-resolve": {
···
1493
1696
"license": "MIT"
1494
1697
},
1495
1698
"node_modules/esrap": {
1496
-
"version": "2.1.3",
1497
-
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz",
1498
-
"integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==",
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==",
1499
1702
"license": "MIT",
1500
1703
"dependencies": {
1501
1704
"@jridgewell/sourcemap-codec": "^1.4.15"
1502
1705
}
1706
+
},
1707
+
"node_modules/estree-walker": {
1708
+
"version": "2.0.2",
1709
+
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
1710
+
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
1711
+
"dev": true,
1712
+
"license": "MIT"
1503
1713
},
1504
1714
"node_modules/fdir": {
1505
1715
"version": "6.5.0",
···
1519
1729
}
1520
1730
}
1521
1731
},
1732
+
"node_modules/file-uri-to-path": {
1733
+
"version": "1.0.0",
1734
+
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
1735
+
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
1736
+
"dev": true,
1737
+
"license": "MIT"
1738
+
},
1522
1739
"node_modules/fsevents": {
1523
1740
"version": "2.3.3",
1524
1741
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
···
1534
1751
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1535
1752
}
1536
1753
},
1754
+
"node_modules/glob": {
1755
+
"version": "13.0.0",
1756
+
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
1757
+
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
1758
+
"dev": true,
1759
+
"license": "BlueOak-1.0.0",
1760
+
"dependencies": {
1761
+
"minimatch": "^10.1.1",
1762
+
"minipass": "^7.1.2",
1763
+
"path-scurry": "^2.0.0"
1764
+
},
1765
+
"engines": {
1766
+
"node": "20 || >=22"
1767
+
},
1768
+
"funding": {
1769
+
"url": "https://github.com/sponsors/isaacs"
1770
+
}
1771
+
},
1537
1772
"node_modules/graceful-fs": {
1538
1773
"version": "4.2.11",
1539
1774
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
···
1541
1776
"dev": true,
1542
1777
"license": "ISC"
1543
1778
},
1544
-
"node_modules/graphemer": {
1545
-
"version": "1.4.0",
1546
-
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
1547
-
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
1548
-
"license": "MIT"
1549
-
},
1550
1779
"node_modules/hls.js": {
1551
1780
"version": "1.6.15",
1552
1781
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
1553
1782
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
1554
1783
"license": "Apache-2.0"
1784
+
},
1785
+
"node_modules/https-proxy-agent": {
1786
+
"version": "7.0.6",
1787
+
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
1788
+
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
1789
+
"dev": true,
1790
+
"license": "MIT",
1791
+
"dependencies": {
1792
+
"agent-base": "^7.1.2",
1793
+
"debug": "4"
1794
+
},
1795
+
"engines": {
1796
+
"node": ">= 14"
1797
+
}
1555
1798
},
1556
1799
"node_modules/is-reference": {
1557
1800
"version": "3.0.3",
···
1855
2098
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
1856
2099
"license": "MIT"
1857
2100
},
2101
+
"node_modules/lru-cache": {
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
+
"dev": true,
2106
+
"license": "BlueOak-1.0.0",
2107
+
"engines": {
2108
+
"node": "20 || >=22"
2109
+
}
2110
+
},
1858
2111
"node_modules/magic-string": {
1859
2112
"version": "0.30.21",
1860
2113
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
···
1864
2117
"@jridgewell/sourcemap-codec": "^1.5.5"
1865
2118
}
1866
2119
},
2120
+
"node_modules/minimatch": {
2121
+
"version": "10.1.1",
2122
+
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
2123
+
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
2124
+
"dev": true,
2125
+
"license": "BlueOak-1.0.0",
2126
+
"dependencies": {
2127
+
"@isaacs/brace-expansion": "^5.0.0"
2128
+
},
2129
+
"engines": {
2130
+
"node": "20 || >=22"
2131
+
},
2132
+
"funding": {
2133
+
"url": "https://github.com/sponsors/isaacs"
2134
+
}
2135
+
},
2136
+
"node_modules/minipass": {
2137
+
"version": "7.1.2",
2138
+
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
2139
+
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
2140
+
"dev": true,
2141
+
"license": "ISC",
2142
+
"engines": {
2143
+
"node": ">=16 || 14 >=14.17"
2144
+
}
2145
+
},
2146
+
"node_modules/minizlib": {
2147
+
"version": "3.1.0",
2148
+
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
2149
+
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
2150
+
"dev": true,
2151
+
"license": "MIT",
2152
+
"dependencies": {
2153
+
"minipass": "^7.1.2"
2154
+
},
2155
+
"engines": {
2156
+
"node": ">= 18"
2157
+
}
2158
+
},
1867
2159
"node_modules/mri": {
1868
2160
"version": "1.2.0",
1869
2161
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
···
1916
2208
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1917
2209
}
1918
2210
},
2211
+
"node_modules/node-fetch": {
2212
+
"version": "2.7.0",
2213
+
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
2214
+
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
2215
+
"dev": true,
2216
+
"license": "MIT",
2217
+
"dependencies": {
2218
+
"whatwg-url": "^5.0.0"
2219
+
},
2220
+
"engines": {
2221
+
"node": "4.x || >=6.0.0"
2222
+
},
2223
+
"peerDependencies": {
2224
+
"encoding": "^0.1.0"
2225
+
},
2226
+
"peerDependenciesMeta": {
2227
+
"encoding": {
2228
+
"optional": true
2229
+
}
2230
+
}
2231
+
},
2232
+
"node_modules/node-gyp-build": {
2233
+
"version": "4.8.4",
2234
+
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
2235
+
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
2236
+
"dev": true,
2237
+
"license": "MIT",
2238
+
"bin": {
2239
+
"node-gyp-build": "bin.js",
2240
+
"node-gyp-build-optional": "optional.js",
2241
+
"node-gyp-build-test": "build-test.js"
2242
+
}
2243
+
},
2244
+
"node_modules/nopt": {
2245
+
"version": "8.1.0",
2246
+
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
2247
+
"integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==",
2248
+
"dev": true,
2249
+
"license": "ISC",
2250
+
"dependencies": {
2251
+
"abbrev": "^3.0.0"
2252
+
},
2253
+
"bin": {
2254
+
"nopt": "bin/nopt.js"
2255
+
},
2256
+
"engines": {
2257
+
"node": "^18.17.0 || >=20.5.0"
2258
+
}
2259
+
},
2260
+
"node_modules/path-scurry": {
2261
+
"version": "2.0.1",
2262
+
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
2263
+
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
2264
+
"dev": true,
2265
+
"license": "BlueOak-1.0.0",
2266
+
"dependencies": {
2267
+
"lru-cache": "^11.0.0",
2268
+
"minipass": "^7.1.2"
2269
+
},
2270
+
"engines": {
2271
+
"node": "20 || >=22"
2272
+
},
2273
+
"funding": {
2274
+
"url": "https://github.com/sponsors/isaacs"
2275
+
}
2276
+
},
1919
2277
"node_modules/picocolors": {
1920
2278
"version": "1.1.1",
1921
2279
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
···
1929
2287
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
1930
2288
"dev": true,
1931
2289
"license": "MIT",
1932
-
"peer": true,
1933
2290
"engines": {
1934
2291
"node": ">=12"
1935
2292
},
···
1981
2338
}
1982
2339
},
1983
2340
"node_modules/prettier": {
1984
-
"version": "3.6.2",
1985
-
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
1986
-
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
2341
+
"version": "3.7.4",
2342
+
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
2343
+
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
1987
2344
"dev": true,
1988
2345
"license": "MIT",
1989
2346
"peer": true,
···
2010
2367
}
2011
2368
},
2012
2369
"node_modules/prettier-plugin-tailwindcss": {
2013
-
"version": "0.7.1",
2014
-
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.1.tgz",
2015
-
"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==",
2016
2373
"dev": true,
2017
2374
"license": "MIT",
2018
2375
"engines": {
···
2102
2459
"url": "https://paulmillr.com/funding/"
2103
2460
}
2104
2461
},
2462
+
"node_modules/resolve-from": {
2463
+
"version": "5.0.0",
2464
+
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
2465
+
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
2466
+
"dev": true,
2467
+
"license": "MIT",
2468
+
"engines": {
2469
+
"node": ">=8"
2470
+
}
2471
+
},
2105
2472
"node_modules/rollup": {
2106
2473
"version": "4.53.3",
2107
2474
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
2108
2475
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
2109
2476
"dev": true,
2110
2477
"license": "MIT",
2478
+
"peer": true,
2111
2479
"dependencies": {
2112
2480
"@types/estree": "1.0.8"
2113
2481
},
···
2157
2525
"node": ">=6"
2158
2526
}
2159
2527
},
2528
+
"node_modules/semver": {
2529
+
"version": "7.7.3",
2530
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
2531
+
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
2532
+
"dev": true,
2533
+
"license": "ISC",
2534
+
"bin": {
2535
+
"semver": "bin/semver.js"
2536
+
},
2537
+
"engines": {
2538
+
"node": ">=10"
2539
+
}
2540
+
},
2160
2541
"node_modules/set-cookie-parser": {
2161
2542
"version": "2.7.2",
2162
2543
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
···
2190
2571
}
2191
2572
},
2192
2573
"node_modules/svelte": {
2193
-
"version": "5.43.14",
2194
-
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.14.tgz",
2195
-
"integrity": "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ==",
2574
+
"version": "5.45.6",
2575
+
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz",
2576
+
"integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==",
2196
2577
"license": "MIT",
2197
2578
"peer": true,
2198
2579
"dependencies": {
···
2204
2585
"aria-query": "^5.3.1",
2205
2586
"axobject-query": "^4.1.0",
2206
2587
"clsx": "^2.1.1",
2588
+
"devalue": "^5.5.0",
2207
2589
"esm-env": "^1.2.1",
2208
-
"esrap": "^2.1.0",
2590
+
"esrap": "^2.2.1",
2209
2591
"is-reference": "^3.0.3",
2210
2592
"locate-character": "^3.0.0",
2211
2593
"magic-string": "^0.30.11",
···
2259
2641
"funding": {
2260
2642
"type": "opencollective",
2261
2643
"url": "https://opencollective.com/webpack"
2644
+
}
2645
+
},
2646
+
"node_modules/tar": {
2647
+
"version": "7.5.2",
2648
+
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
2649
+
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
2650
+
"dev": true,
2651
+
"license": "BlueOak-1.0.0",
2652
+
"dependencies": {
2653
+
"@isaacs/fs-minipass": "^4.0.0",
2654
+
"chownr": "^3.0.0",
2655
+
"minipass": "^7.1.2",
2656
+
"minizlib": "^3.1.0",
2657
+
"yallist": "^5.0.0"
2658
+
},
2659
+
"engines": {
2660
+
"node": ">=18"
2262
2661
}
2263
2662
},
2264
2663
"node_modules/tinyglobby": {
···
2297
2696
"node": ">=6"
2298
2697
}
2299
2698
},
2699
+
"node_modules/tr46": {
2700
+
"version": "0.0.3",
2701
+
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
2702
+
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
2703
+
"dev": true,
2704
+
"license": "MIT"
2705
+
},
2706
+
"node_modules/tslib": {
2707
+
"version": "2.8.1",
2708
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
2709
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
2710
+
"license": "0BSD"
2711
+
},
2300
2712
"node_modules/typescript": {
2301
2713
"version": "5.9.3",
2302
2714
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
···
2321
2733
"multiformats": "^9.4.2"
2322
2734
}
2323
2735
},
2736
+
"node_modules/unicode-segmenter": {
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
+
"license": "MIT"
2741
+
},
2324
2742
"node_modules/util-deprecate": {
2325
2743
"version": "1.0.2",
2326
2744
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
···
2329
2747
"license": "MIT"
2330
2748
},
2331
2749
"node_modules/vite": {
2332
-
"version": "7.2.4",
2333
-
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
2334
-
"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==",
2335
2753
"dev": true,
2336
2754
"license": "MIT",
2337
2755
"peer": true,
···
2422
2840
"vite": {
2423
2841
"optional": true
2424
2842
}
2843
+
}
2844
+
},
2845
+
"node_modules/webidl-conversions": {
2846
+
"version": "3.0.1",
2847
+
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
2848
+
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
2849
+
"dev": true,
2850
+
"license": "BSD-2-Clause"
2851
+
},
2852
+
"node_modules/whatwg-url": {
2853
+
"version": "5.0.0",
2854
+
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
2855
+
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
2856
+
"dev": true,
2857
+
"license": "MIT",
2858
+
"dependencies": {
2859
+
"tr46": "~0.0.3",
2860
+
"webidl-conversions": "^3.0.0"
2861
+
}
2862
+
},
2863
+
"node_modules/yallist": {
2864
+
"version": "5.0.0",
2865
+
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
2866
+
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
2867
+
"dev": true,
2868
+
"license": "BlueOak-1.0.0",
2869
+
"engines": {
2870
+
"node": ">=18"
2425
2871
}
2426
2872
},
2427
2873
"node_modules/zimmerframe": {
+2
-2
package.json
+2
-2
package.json
···
1
1
{
2
2
"name": "website",
3
3
"private": true,
4
-
"version": "0.0.1",
4
+
"version": "10.5.0",
5
5
"type": "module",
6
6
"scripts": {
7
7
"dev": "vite dev",
···
14
14
"lint": "prettier --check ."
15
15
},
16
16
"devDependencies": {
17
-
"@sveltejs/adapter-auto": "^7.0.0",
17
+
"@sveltejs/adapter-vercel": "^6.2.0",
18
18
"@sveltejs/kit": "^2.49.0",
19
19
"@sveltejs/vite-plugin-svelte": "^6.2.1",
20
20
"@tailwindcss/typography": "^0.5.19",
+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>
+113
src/lib/components/layout/DecimalClock.svelte
+113
src/lib/components/layout/DecimalClock.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte';
3
+
import { Clock } from '@lucide/svelte';
4
+
import DecimalClockInfoBox from './DecimalClockInfoBox.svelte';
5
+
6
+
let decimalTime = $state({ hours: '00', minutes: '00' });
7
+
let mounted = $state(false);
8
+
let showInfoBox = $state(false);
9
+
let intervalId: ReturnType<typeof setInterval> | null = null;
10
+
let isVisible = $state(false);
11
+
12
+
function updateDecimalTime() {
13
+
const now = new Date();
14
+
const totalSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
15
+
const totalMilliseconds = totalSeconds * 1000 + now.getMilliseconds();
16
+
17
+
// French Revolutionary decimal time:
18
+
// Day divided into 10 hours (0-9)
19
+
// Each hour divided into 100 minutes (0-99)
20
+
const dayProgress = totalMilliseconds / 86400000;
21
+
22
+
// Decimal hours (0-9)
23
+
const decimalHours = dayProgress * 10;
24
+
const hours = Math.floor(decimalHours).toString().padStart(2, '0');
25
+
26
+
// Decimal minutes (0-99)
27
+
const minuteProgress = (decimalHours % 1) * 100;
28
+
const minutes = Math.floor(minuteProgress).toString().padStart(2, '0');
29
+
30
+
decimalTime = { hours, minutes };
31
+
}
32
+
33
+
function startInterval() {
34
+
if (!intervalId) {
35
+
intervalId = setInterval(updateDecimalTime, 100);
36
+
}
37
+
}
38
+
39
+
function stopInterval() {
40
+
if (intervalId) {
41
+
clearInterval(intervalId);
42
+
intervalId = null;
43
+
}
44
+
}
45
+
46
+
onMount(() => {
47
+
updateDecimalTime();
48
+
mounted = true;
49
+
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
+
}
75
+
76
+
return () => {
77
+
stopInterval();
78
+
};
79
+
});
80
+
81
+
function toggleInfoBox() {
82
+
showInfoBox = !showInfoBox;
83
+
}
84
+
85
+
function closeInfoBox() {
86
+
showInfoBox = false;
87
+
}
88
+
</script>
89
+
90
+
<button
91
+
type="button"
92
+
data-decimal-clock
93
+
onclick={toggleInfoBox}
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"
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."
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>
102
+
{#if mounted}
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>
107
+
</div>
108
+
{:else}
109
+
<div class="h-5 w-16 animate-pulse rounded bg-canvas-300 dark:bg-canvas-700"></div>
110
+
{/if}
111
+
</button>
112
+
113
+
<DecimalClockInfoBox show={showInfoBox} onClose={closeInfoBox} />
+163
src/lib/components/layout/DecimalClockInfoBox.svelte
+163
src/lib/components/layout/DecimalClockInfoBox.svelte
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte';
3
+
import { X, Clock } from '@lucide/svelte';
4
+
import Card from '$lib/components/ui/Card.svelte';
5
+
6
+
interface Props {
7
+
show: boolean;
8
+
onClose: () => void;
9
+
}
10
+
11
+
let { show, onClose }: Props = $props();
12
+
let mounted = $state(false);
13
+
let currentTime = $state('00:00');
14
+
let intervalId: ReturnType<typeof setInterval> | null = null;
15
+
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);
28
+
}
29
+
}
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;
49
+
return () => {
50
+
stopInterval();
51
+
};
52
+
});
53
+
</script>
54
+
55
+
{#if show && mounted}
56
+
<div
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;"
59
+
onclick={onClose}
60
+
onkeydown={(e) => e.key === 'Escape' && onClose()}
61
+
role="button"
62
+
tabindex="0"
63
+
aria-label="Close decimal time info"
64
+
>
65
+
<div
66
+
onclick={(e) => e.stopPropagation()}
67
+
onkeydown={(e) => e.stopPropagation()}
68
+
role="dialog"
69
+
aria-labelledby="decimal-time-title"
70
+
aria-modal="true"
71
+
tabindex="-1"
72
+
class="w-full max-w-2xl"
73
+
>
74
+
<Card variant="elevated" padding="lg" class="relative max-h-[90vh] overflow-y-auto">
75
+
{#snippet children()}
76
+
<!-- Close button -->
77
+
<button
78
+
type="button"
79
+
onclick={onClose}
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"
81
+
aria-label="Close"
82
+
>
83
+
<X class="h-6 w-6" />
84
+
</button>
85
+
86
+
<!-- Content -->
87
+
<div class="space-y-4">
88
+
<h2
89
+
id="decimal-time-title"
90
+
class="text-2xl font-bold text-ink-900 dark:text-ink-50"
91
+
>
92
+
French Revolutionary Decimal Time
93
+
</h2>
94
+
95
+
<div class="space-y-3 text-ink-700 dark:text-ink-200">
96
+
<p>
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:
100
+
</p>
101
+
102
+
<ul class="list-disc space-y-2 pl-6">
103
+
<li><strong>1 day</strong> = 10 decimal hours</li>
104
+
<li><strong>1 decimal hour</strong> = 100 decimal minutes</li>
105
+
<li><strong>1 decimal minute</strong> = 100 decimal seconds</li>
106
+
</ul>
107
+
108
+
<p>
109
+
This means a decimal day has 10 hours, 1,000 minutes, and 100,000 seconds
110
+
total.
111
+
</p>
112
+
113
+
<Card variant="flat" padding="md" class="bg-canvas-200 dark:bg-canvas-800">
114
+
{#snippet children()}
115
+
<h3 class="mb-2 font-semibold text-ink-900 dark:text-ink-50">
116
+
Conversions:
117
+
</h3>
118
+
<ul class="space-y-1 text-sm">
119
+
<li>1 decimal hour โ 2.4 traditional hours (2h 24m)</li>
120
+
<li>1 decimal minute โ 1.44 traditional minutes (86.4 seconds)</li>
121
+
<li>1 decimal second โ 0.864 traditional seconds</li>
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>
139
+
{/snippet}
140
+
</Card>
141
+
142
+
<p class="text-sm text-ink-600 dark:text-ink-400">
143
+
While decimal time was officially adopted in France from 1793-1795, it never
144
+
gained widespread acceptance and was eventually abandoned in favor of the
145
+
traditional 24-hour system.
146
+
</p>
147
+
148
+
<p class="text-sm text-ink-600 dark:text-ink-400">
149
+
I just found it interesting, I learnt about it from <a
150
+
href="https://www.youtube.com/watch?v=Ax7AbXfhftE"
151
+
target="_blank"
152
+
rel="noopener noreferrer"
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"
154
+
>"The Longest Softlock in Portal" by Marblr on YouTube</a
155
+
>.
156
+
</p>
157
+
</div>
158
+
</div>
159
+
{/snippet}
160
+
</Card>
161
+
</div>
162
+
</div>
163
+
{/if}
+166
-36
src/lib/components/layout/Header.svelte
+166
-36
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 ColorThemeToggle from './ColorThemeToggle.svelte';
8
9
import { navItems } from '$lib/data/navItems';
9
10
import { fetchProfile, type ProfileData } from '$lib/services/atproto';
10
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';
11
18
12
19
const siteMeta: SiteMetadata = createSiteMeta(defaultSiteMeta);
13
20
const { page } = getStores();
14
21
15
-
let profile: ProfileData | null = null;
16
-
let loading = true;
17
-
let error: string | null = null;
18
-
let imageLoaded = false;
19
-
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;
20
33
21
34
// Map of icon names to Lucide components
22
35
let iconComponents: Record<string, any> = {};
···
29
42
30
43
function toggleMobileMenu() {
31
44
mobileMenuOpen = !mobileMenuOpen;
45
+
// Close color theme dropdown when opening mobile menu
46
+
if (mobileMenuOpen) {
47
+
colorThemeDropdownOpen.set(false);
48
+
}
32
49
}
33
50
34
51
function closeMobileMenu() {
35
52
mobileMenuOpen = false;
36
53
}
37
54
55
+
function closeColorThemeDropdown() {
56
+
colorThemeDropdownOpen.set(false);
57
+
}
58
+
59
+
function selectTheme(theme: ColorTheme) {
60
+
colorTheme.setTheme(theme);
61
+
closeColorThemeDropdown();
62
+
}
63
+
38
64
function isActive(href: string) {
39
65
return $page.url.pathname === href;
40
66
}
41
67
42
-
onMount(async () => {
43
-
try {
44
-
profile = await fetchProfile();
45
-
} catch (err) {
46
-
error = err instanceof Error ? err.message : 'Failed to load profile';
47
-
} finally {
48
-
loading = false;
49
-
}
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
+
};
50
113
});
51
114
</script>
52
115
···
60
123
<!-- Logo/Avatar with hover title -->
61
124
<a
62
125
href="/"
63
-
class="group relative flex min-w-0 shrink items-center gap-2"
126
+
class="group relative flex min-w-0 shrink items-center"
64
127
onclick={closeMobileMenu}
128
+
aria-label="Home - {siteMeta.title}"
65
129
>
66
130
<div class="relative flex items-center">
67
131
{#if profile?.avatar}
68
132
<img
69
133
src={profile.avatar}
70
-
alt={profile.displayName || profile.handle}
134
+
alt=""
71
135
class="h-10 w-10 rounded-full object-cover"
72
136
onload={() => (imageLoaded = true)}
73
137
/>
74
138
{:else if profile}
75
139
<div
76
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"
77
143
>
78
144
{(profile.displayName || profile.handle).charAt(0).toUpperCase()}
79
145
</div>
80
146
{:else}
81
-
<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>
82
152
{/if}
83
153
84
-
<!-- Site title revealed on hover -->
85
-
<span
86
-
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"
87
-
>
88
-
{siteMeta.title}
89
-
</span>
90
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>
91
162
</a>
92
163
93
-
<!-- Desktop Navigation -->
164
+
<!-- Right side: Navigation + Toggles -->
94
165
<div class="hidden items-center gap-4 md:flex">
95
-
<ul class="flex items-center gap-6">
166
+
<ul class="flex items-center gap-6" role="list">
96
167
{#each navItems as item}
97
168
{@const IconComponent = iconComponents[item.href]}
98
169
<li>
···
100
171
href={item.href}
101
172
class="group flex items-center gap-2 font-medium transition-colors
102
173
{isActive(item.href) ? 'text-primary-600 dark:text-primary-400' : 'text-ink-700 dark:text-ink-200'}
103
-
hover:text-primary-500"
174
+
hover:text-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
104
175
aria-current={isActive(item.href) ? 'page' : undefined}
105
176
title={item.label}
106
177
>
···
119
190
</li>
120
191
{/each}
121
192
</ul>
193
+
194
+
<!-- Desktop Toggles -->
122
195
<div class="flex items-center gap-2">
196
+
<ColorThemeToggle />
123
197
<WolfToggle />
124
198
<ThemeToggle />
125
199
</div>
126
200
</div>
127
201
128
-
<!-- Mobile Menu Button -->
202
+
<!-- Mobile Menu Button + Toggles -->
129
203
<div class="flex items-center gap-2 md:hidden">
204
+
<ColorThemeToggle />
130
205
<WolfToggle />
131
206
<ThemeToggle />
132
207
<button
133
208
onclick={toggleMobileMenu}
134
-
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"
135
210
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
136
211
aria-expanded={mobileMenuOpen}
212
+
aria-controls="mobile-menu"
137
213
>
138
214
{#if mobileMenuOpen}
139
215
<X class="h-6 w-6" aria-hidden="true" />
···
146
222
147
223
<!-- Mobile Menu Dropdown -->
148
224
{#if mobileMenuOpen}
149
-
<div
225
+
<nav
226
+
id="mobile-menu"
150
227
class="border-t border-canvas-200 bg-canvas-50 md:hidden dark:border-canvas-800 dark:bg-canvas-950"
151
-
role="menu"
228
+
aria-label="Mobile navigation"
152
229
>
153
-
<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">
154
231
{#each navItems as item}
155
232
{@const IconComponent = iconComponents[item.href]}
156
-
<li role="none">
233
+
<li>
157
234
<a
158
235
href={item.href}
159
236
onclick={closeMobileMenu}
160
-
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
161
238
{isActive(item.href)
162
239
? 'bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300'
163
-
: 'text-ink-700 hover:bg-canvas-100 dark:text-ink-200 dark:hover:bg-canvas-900'}"
164
-
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'}"
165
241
aria-current={isActive(item.href) ? 'page' : undefined}
166
242
>
167
243
{#if IconComponent}
···
181
257
</li>
182
258
{/each}
183
259
</ul>
184
-
</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>
185
315
{/if}
186
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
src/lib/components/layout/index.ts
-1
src/lib/components/layout/index.ts
···
5
5
export { default as LinkCard } from './main/card/LinkCard.svelte';
6
6
export { default as ProfileCard } from './main/card/ProfileCard.svelte';
7
7
export { default as DynamicLinks } from './main/DynamicLinks.svelte';
8
-
export { default as TangledRepos } from './main/TangledRepos.svelte';
9
8
export { default as ScrollToTop } from './main/ScrollToTop.svelte';
+16
-1
src/lib/components/layout/main/DynamicLinks.svelte
+16
-1
src/lib/components/layout/main/DynamicLinks.svelte
···
23
23
{#if loading}
24
24
<Card loading={true} variant="elevated" padding="md">
25
25
{#snippet skeleton()}
26
+
<!-- Title -->
26
27
<div class="mb-4 h-6 w-20 rounded bg-canvas-300 dark:bg-canvas-700"></div>
28
+
<!-- Link cards grid -->
27
29
<div class="grid gap-3 sm:grid-cols-2">
28
30
{#each Array(4) as _}
29
-
<div class="h-16 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div>
31
+
<div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800">
32
+
<div class="flex items-start justify-between gap-3">
33
+
<div class="min-w-0 flex-1 space-y-2">
34
+
<!-- Emoji -->
35
+
<div class="h-5 w-5 rounded bg-canvas-300 dark:bg-canvas-700"></div>
36
+
<!-- Title -->
37
+
<div class="h-5 w-3/4 rounded bg-canvas-300 dark:bg-canvas-700"></div>
38
+
<!-- Description -->
39
+
<div class="h-4 w-1/2 rounded bg-canvas-300 dark:bg-canvas-700"></div>
40
+
</div>
41
+
<!-- Icon -->
42
+
<div class="h-4 w-4 shrink-0 rounded bg-canvas-300 dark:bg-canvas-700"></div>
43
+
</div>
44
+
</div>
30
45
{/each}
31
46
</div>
32
47
{/snippet}
-73
src/lib/components/layout/main/TangledRepos.svelte
-73
src/lib/components/layout/main/TangledRepos.svelte
···
1
-
<script lang="ts">
2
-
import { onMount } from 'svelte';
3
-
import { Card } from '$lib/components/ui';
4
-
import { TangledRepoCard } from '$lib/components/layout/main/card';
5
-
import { fetchTangledRepos, type TangledReposData, fetchProfile } from '$lib/services/atproto';
6
-
7
-
let repos: TangledReposData | null = null;
8
-
let handle: string | null = null;
9
-
let loading = true;
10
-
let error: string | null = null;
11
-
12
-
onMount(async () => {
13
-
try {
14
-
const [reposData, profile] = await Promise.all([fetchTangledRepos(), fetchProfile()]);
15
-
repos = reposData;
16
-
handle = profile.handle;
17
-
} catch (err) {
18
-
error = err instanceof Error ? err.message : 'Failed to load Tangled repositories';
19
-
} finally {
20
-
loading = false;
21
-
}
22
-
});
23
-
</script>
24
-
25
-
<div class="mx-auto w-full max-w-2xl">
26
-
{#if loading}
27
-
<Card loading={true} variant="elevated" padding="md">
28
-
{#snippet skeleton()}
29
-
<div class="mb-4 h-6 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div>
30
-
<div class="space-y-3">
31
-
{#each Array(3) as _}
32
-
<div class="h-24 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div>
33
-
{/each}
34
-
</div>
35
-
{/snippet}
36
-
</Card>
37
-
{:else if error}
38
-
<Card error={true} errorMessage={error} />
39
-
{:else if repos && repos.repos.length > 0}
40
-
{@const safeRepos = repos}
41
-
<Card variant="elevated" padding="md">
42
-
{#snippet children()}
43
-
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2>
44
-
<div class="space-y-3">
45
-
{#each safeRepos.repos as repo}
46
-
<TangledRepoCard {repo} {handle} />
47
-
{/each}
48
-
</div>
49
-
{/snippet}
50
-
</Card>
51
-
{:else}
52
-
<Card variant="flat" padding="lg">
53
-
{#snippet children()}
54
-
<div class="text-center">
55
-
<p class="text-ink-700 dark:text-ink-300">
56
-
No Tangled repositories found. Create a <code
57
-
class="rounded bg-canvas-200 px-1 dark:bg-canvas-800">sh.tangled.repo</code
58
-
> record to display your repositories here.
59
-
</p>
60
-
<p class="mt-2 text-sm text-ink-600 dark:text-ink-400">
61
-
Learn more about Tangled at
62
-
<a
63
-
href="https://tangled.sh/"
64
-
class="text-primary-600 hover:underline dark:text-primary-400"
65
-
target="_blank"
66
-
rel="noopener noreferrer">https://tangled.org/</a
67
-
>
68
-
</p>
69
-
</div>
70
-
{/snippet}
71
-
</Card>
72
-
{/if}
73
-
</div>
+2
-2
src/lib/components/layout/main/card/LinkCard.svelte
+2
-2
src/lib/components/layout/main/card/LinkCard.svelte
···
28
28
}
29
29
}
30
30
31
-
const displayDescription = description || getDomain(url);
31
+
let displayDescription = $derived(description || getDomain(url));
32
32
</script>
33
33
34
34
{#if variant === 'button'}
35
-
<InternalCard href={url} class="!flex-row !items-center !justify-center !gap-2">
35
+
<InternalCard href={url} class="flex-row! items-center! justify-center! gap-2!">
36
36
{#snippet children()}
37
37
<span class="font-medium">{title}</span>
38
38
<ExternalLink class="h-4 w-4 shrink-0" aria-hidden="true" />
+3
-3
src/lib/components/layout/main/card/MusicStatusCard.svelte
+3
-3
src/lib/components/layout/main/card/MusicStatusCard.svelte
···
56
56
<Card loading={true} variant="elevated" padding="md">
57
57
{#snippet skeleton()}
58
58
<div class="mb-3 flex items-start gap-4">
59
-
<div class="h-20 w-20 flex-shrink-0 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div>
59
+
<div class="h-20 w-20 shrink-0 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div>
60
60
<div class="flex-1">
61
61
<div class="mb-2 flex items-center gap-2">
62
62
<div class="h-4 w-4 rounded bg-canvas-300 dark:bg-canvas-700"></div>
···
129
129
<p
130
130
class="mt-1 flex max-w-full items-start gap-1.5 text-base wrap-break-word whitespace-normal text-ink-800 dark:text-ink-100"
131
131
>
132
-
<Users class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-600 dark:text-ink-300" />
132
+
<Users class="mt-0.5 h-4 w-4 shrink-0 text-ink-600 dark:text-ink-300" />
133
133
{formatArtists(safeMusicStatus.artists)}
134
134
</p>
135
135
···
138
138
<p
139
139
class="mt-1 flex max-w-full items-start gap-1.5 text-sm wrap-break-word whitespace-normal text-ink-700 dark:text-ink-200"
140
140
>
141
-
<Album class="mt-0.5 h-4 w-4 flex-shrink-0 text-ink-500 dark:text-ink-400" />
141
+
<Album class="mt-0.5 h-4 w-4 shrink-0 text-ink-500 dark:text-ink-400" />
142
142
<span>
143
143
{safeMusicStatus.releaseName}
144
144
+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>
+99
-33
src/lib/components/layout/main/card/TangledRepoCard.svelte
+99
-33
src/lib/components/layout/main/card/TangledRepoCard.svelte
···
1
1
<script lang="ts">
2
+
import { onMount } from 'svelte';
2
3
import { ExternalLink, GitBranch, Server, User } from '@lucide/svelte';
3
-
import { InternalCard } from '$lib/components/ui';
4
-
import type { TangledRepo } from '$lib/services/atproto';
4
+
import { Card, InternalCard } from '$lib/components/ui';
5
+
import { fetchTangledRepos, type TangledReposData, fetchProfile } from '$lib/services/atproto';
5
6
import { PUBLIC_ATPROTO_DID } from '$env/static/public';
6
7
7
-
interface Props {
8
-
repo: TangledRepo;
9
-
handle: string | null;
10
-
}
8
+
let repos: TangledReposData | null = $state(null);
9
+
let handle: string | null = $state(null);
10
+
let loading = $state(true);
11
+
let error: string | null = $state(null);
11
12
12
-
let { repo, handle }: Props = $props();
13
+
onMount(async () => {
14
+
try {
15
+
const [reposData, profile] = await Promise.all([fetchTangledRepos(), fetchProfile()]);
16
+
repos = reposData;
17
+
handle = profile.handle;
18
+
} catch (err) {
19
+
error = err instanceof Error ? err.message : 'Failed to load Tangled repositories';
20
+
} finally {
21
+
loading = false;
22
+
}
23
+
});
13
24
14
25
// Build the tangled.org URL: tangled.org/[handle or did]/[repo]
15
26
// Prefer handle if available, otherwise use DID
16
-
const identifier = $derived(handle || PUBLIC_ATPROTO_DID);
17
-
const repoUrl = $derived(`https://tangled.org/${identifier}/${repo.name}`);
27
+
function buildRepoUrl(repoName: string): string {
28
+
const identifier = handle || PUBLIC_ATPROTO_DID;
29
+
return `https://tangled.org/${identifier}/${repoName}`;
30
+
}
18
31
19
32
// Extract knot server name from DID or URL
20
33
function getKnotServerName(knot: string): string {
···
30
43
}
31
44
</script>
32
45
33
-
<InternalCard href={repoUrl}>
34
-
{#snippet children()}
35
-
<GitBranch class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" aria-hidden="true" />
36
-
<div class="min-w-0 flex-1 space-y-2">
37
-
<h3
38
-
class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50"
39
-
>
40
-
{repo.name}
41
-
</h3>
42
-
<div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200">
43
-
<div class="flex min-w-0 items-center gap-1">
44
-
<Server class="h-3 w-3 shrink-0" aria-hidden="true" />
45
-
<span class="truncate">{getKnotServerName(repo.knot)}</span>
46
+
<div class="mx-auto w-full max-w-2xl">
47
+
{#if loading}
48
+
<Card loading={true} variant="elevated" padding="md">
49
+
{#snippet skeleton()}
50
+
<div class="mb-4 h-6 w-32 rounded bg-canvas-300 dark:bg-canvas-700"></div>
51
+
<div class="space-y-3">
52
+
{#each Array(3) as _}
53
+
<div class="h-24 rounded-lg bg-canvas-300 dark:bg-canvas-700"></div>
54
+
{/each}
46
55
</div>
47
-
<div class="flex min-w-0 items-center gap-1">
48
-
<User class="h-3 w-3 shrink-0" aria-hidden="true" />
49
-
<span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span>
56
+
{/snippet}
57
+
</Card>
58
+
{:else if error}
59
+
<Card error={true} errorMessage={error} />
60
+
{:else if repos && repos.repos.length > 0}
61
+
{@const safeRepos = repos}
62
+
<Card variant="elevated" padding="md">
63
+
{#snippet children()}
64
+
<h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Tangled Repositories</h2>
65
+
<div class="space-y-3">
66
+
{#each safeRepos.repos as repo}
67
+
<InternalCard href={buildRepoUrl(repo.name)}>
68
+
{#snippet children()}
69
+
<GitBranch
70
+
class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400"
71
+
aria-hidden="true"
72
+
/>
73
+
<div class="min-w-0 flex-1 space-y-2">
74
+
<h3
75
+
class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50"
76
+
>
77
+
{repo.name}
78
+
</h3>
79
+
<div
80
+
class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200"
81
+
>
82
+
<div class="flex min-w-0 items-center gap-1">
83
+
<Server class="h-3 w-3 shrink-0" aria-hidden="true" />
84
+
<span class="truncate">{getKnotServerName(repo.knot)}</span>
85
+
</div>
86
+
<div class="flex min-w-0 items-center gap-1">
87
+
<User class="h-3 w-3 shrink-0" aria-hidden="true" />
88
+
<span class="truncate">{handle || PUBLIC_ATPROTO_DID}</span>
89
+
</div>
90
+
</div>
91
+
</div>
92
+
<ExternalLink
93
+
class="h-4 w-4 shrink-0 text-ink-700 transition-colors dark:text-ink-200"
94
+
aria-hidden="true"
95
+
/>
96
+
{/snippet}
97
+
</InternalCard>
98
+
{/each}
50
99
</div>
51
-
</div>
52
-
</div>
53
-
<ExternalLink
54
-
class="h-4 w-4 shrink-0 text-ink-700 transition-colors dark:text-ink-200"
55
-
aria-hidden="true"
56
-
/>
57
-
{/snippet}
58
-
</InternalCard>
100
+
{/snippet}
101
+
</Card>
102
+
{:else}
103
+
<Card variant="flat" padding="lg">
104
+
{#snippet children()}
105
+
<div class="text-center">
106
+
<p class="text-ink-700 dark:text-ink-300">
107
+
No Tangled repositories found. Create a <code
108
+
class="rounded bg-canvas-200 px-1 dark:bg-canvas-800">sh.tangled.repo</code
109
+
> record to display your repositories here.
110
+
</p>
111
+
<p class="mt-2 text-sm text-ink-600 dark:text-ink-400">
112
+
Learn more about Tangled at
113
+
<a
114
+
href="https://tangled.sh/"
115
+
class="text-primary-600 hover:underline dark:text-primary-400"
116
+
target="_blank"
117
+
rel="noopener noreferrer">https://tangled.org/</a
118
+
>
119
+
</p>
120
+
</div>
121
+
{/snippet}
122
+
</Card>
123
+
{/if}
124
+
</div>
+1
-1
src/lib/components/layout/main/index.ts
+1
-1
src/lib/components/layout/main/index.ts
+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
+
};
+4
src/lib/data/slug-mappings.ts
+4
src/lib/data/slug-mappings.ts
···
34
34
{
35
35
slug: 'cailean',
36
36
publicationRkey: '3m4222fxc3k2q' // Cailean Uen's publication rkey for his journal
37
+
},
38
+
{
39
+
slug: 'creativity',
40
+
publicationRkey: '3m6afhzlxt22p' // my creativity dump publication rkey
37
41
}
38
42
// Add more mappings as needed:
39
43
// { slug: 'notes', publicationRkey: 'xyz123abc' },
+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
+82
-2
src/lib/services/atproto/fetch.ts
+82
-2
src/lib/services/atproto/fetch.ts
···
7
7
SiteInfoData,
8
8
LinkData,
9
9
MusicStatusData,
10
-
KibunStatusData
10
+
KibunStatusData,
11
+
TangledRepo,
12
+
TangledReposData
11
13
} from './types';
12
14
import { buildPdsBlobUrl } from './media';
13
15
import { findArtwork } from './musicbrainz';
···
38
40
fetchFn
39
41
);
40
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
+
41
68
const data: ProfileData = {
42
69
did: profile.did,
43
70
handle: profile.handle,
···
47
74
banner: profile.banner,
48
75
followersCount: profile.followersCount,
49
76
followsCount: profile.followsCount,
50
-
postsCount: profile.postsCount
77
+
postsCount: profile.postsCount,
78
+
pronouns: pronouns
51
79
};
52
80
53
81
console.info('[Profile] Successfully fetched profile data');
···
398
426
return null;
399
427
}
400
428
}
429
+
430
+
/**
431
+
* Fetches Tangled repositories from AT Protocol
432
+
*/
433
+
export async function fetchTangledRepos(fetchFn?: typeof fetch): Promise<TangledReposData | null> {
434
+
const cacheKey = `tangled:${PUBLIC_ATPROTO_DID}`;
435
+
const cached = cache.get<TangledReposData>(cacheKey);
436
+
if (cached) return cached;
437
+
438
+
try {
439
+
// Custom collection, prefer PDS first
440
+
const records = await withFallback(
441
+
PUBLIC_ATPROTO_DID,
442
+
async (agent) => {
443
+
const response = await agent.com.atproto.repo.listRecords({
444
+
repo: PUBLIC_ATPROTO_DID,
445
+
collection: 'sh.tangled.repo',
446
+
limit: 100
447
+
});
448
+
return response.data.records;
449
+
},
450
+
true,
451
+
fetchFn
452
+
); // usePDSFirst = true
453
+
454
+
if (records.length === 0) return null;
455
+
456
+
const repos: TangledRepo[] = records.map((record) => {
457
+
const value = record.value as any;
458
+
return {
459
+
uri: record.uri,
460
+
name: value.name,
461
+
description: value.description,
462
+
knot: value.knot,
463
+
createdAt: value.createdAt,
464
+
labels: value.labels,
465
+
source: value.source,
466
+
spindle: value.spindle
467
+
};
468
+
});
469
+
470
+
// Sort by creation date, newest first
471
+
repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
472
+
473
+
const data: TangledReposData = { repos };
474
+
cache.set(cacheKey, data);
475
+
return data;
476
+
} catch (error) {
477
+
console.error('Failed to fetch Tangled repos from all sources:', error);
478
+
return null;
479
+
}
480
+
}
+5
-6
src/lib/services/atproto/index.ts
+5
-6
src/lib/services/atproto/index.ts
···
30
30
CacheEntry,
31
31
MusicStatusData,
32
32
MusicArtist,
33
-
KibunStatusData
33
+
KibunStatusData,
34
+
TangledRepo,
35
+
TangledReposData
34
36
} from './types';
35
-
36
-
export type { TangledRepo, TangledReposData } from './tangled';
37
37
38
38
// Export fetch functions
39
39
export {
···
41
41
fetchSiteInfo,
42
42
fetchLinks,
43
43
fetchMusicStatus,
44
-
fetchKibunStatus
44
+
fetchKibunStatus,
45
+
fetchTangledRepos
45
46
} from './fetch';
46
-
47
-
export { fetchTangledRepos } from './tangled';
48
47
49
48
export {
50
49
fetchBlogPosts,
+5
-6
src/lib/services/atproto/posts.ts
+5
-6
src/lib/services/atproto/posts.ts
···
163
163
const publicationUri = value.publication;
164
164
const publication = publicationsMap.get(publicationUri);
165
165
166
-
// Determine URL based on priority: publication base_path โ Leaflet /lish format
166
+
// Determine URL based on priority: publication base_path โ Leaflet /p/[DID]/[rkey] format
167
167
let url: string;
168
-
const publicationRkey = publicationUri ? publicationUri.split('/').pop() : '';
168
+
const publicationRkey = publicationUri ? publicationUri.split('/').pop() : undefined;
169
169
170
170
if (publication?.basePath) {
171
171
// Ensure basePath is a complete URL
···
173
173
? publication.basePath
174
174
: `https://${publication.basePath}`;
175
175
url = `${basePath}/${rkey}`;
176
-
} else if (publicationRkey) {
177
-
url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`;
178
176
} else {
179
-
url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`;
177
+
// Fallback format: https://leaflet.pub/p/[DID]/[rkey]
178
+
url = `https://leaflet.pub/p/${PUBLIC_ATPROTO_DID}/${rkey}`;
180
179
}
181
180
182
181
posts.push({
···
187
186
description: value.description,
188
187
rkey,
189
188
publicationName: publication?.name,
190
-
publicationRkey: publicationRkey || undefined
189
+
publicationRkey
191
190
});
192
191
}
193
192
} catch (error) {
-69
src/lib/services/atproto/tangled.ts
-69
src/lib/services/atproto/tangled.ts
···
1
-
import { PUBLIC_ATPROTO_DID } from '$env/static/public';
2
-
import { cache } from './cache';
3
-
import { withFallback } from './agents';
4
-
5
-
export interface TangledRepo {
6
-
uri: string;
7
-
name: string;
8
-
description?: string;
9
-
knot: string;
10
-
createdAt: string;
11
-
labels?: string[];
12
-
source?: string;
13
-
spindle?: string;
14
-
}
15
-
16
-
export interface TangledReposData {
17
-
repos: TangledRepo[];
18
-
}
19
-
20
-
/**
21
-
* Fetches Tangled repositories from AT Protocol
22
-
*/
23
-
export async function fetchTangledRepos(): Promise<TangledReposData | null> {
24
-
const cacheKey = `tangled:${PUBLIC_ATPROTO_DID}`;
25
-
const cached = cache.get<TangledReposData>(cacheKey);
26
-
if (cached) return cached;
27
-
28
-
try {
29
-
// Custom collection, prefer PDS first
30
-
const records = await withFallback(
31
-
PUBLIC_ATPROTO_DID,
32
-
async (agent) => {
33
-
const response = await agent.com.atproto.repo.listRecords({
34
-
repo: PUBLIC_ATPROTO_DID,
35
-
collection: 'sh.tangled.repo',
36
-
limit: 100
37
-
});
38
-
return response.data.records;
39
-
},
40
-
true
41
-
); // usePDSFirst = true
42
-
43
-
if (records.length === 0) return null;
44
-
45
-
const repos: TangledRepo[] = records.map((record) => {
46
-
const value = record.value as any;
47
-
return {
48
-
uri: record.uri,
49
-
name: value.name,
50
-
description: value.description,
51
-
knot: value.knot,
52
-
createdAt: value.createdAt,
53
-
labels: value.labels,
54
-
source: value.source,
55
-
spindle: value.spindle
56
-
};
57
-
});
58
-
59
-
// Sort by creation date, newest first
60
-
repos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
61
-
62
-
const data: TangledReposData = { repos };
63
-
cache.set(cacheKey, data);
64
-
return data;
65
-
} catch (error) {
66
-
console.error('Failed to fetch Tangled repos from all sources:', error);
67
-
return null;
68
-
}
69
-
}
+17
src/lib/services/atproto/types.ts
+17
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 {
···
223
225
createdAt: string;
224
226
$type: 'social.kibun.status';
225
227
}
228
+
229
+
export interface TangledRepo {
230
+
uri: string;
231
+
name: string;
232
+
description?: string;
233
+
knot: string;
234
+
createdAt: string;
235
+
labels?: string[];
236
+
source?: string;
237
+
spindle?: string;
238
+
}
239
+
240
+
export interface TangledReposData {
241
+
repos: TangledRepo[];
242
+
}
+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 flex-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
};
+8
-3
src/routes/+page.svelte
+8
-3
src/routes/+page.svelte
···
1
1
<script lang="ts">
2
-
import { DynamicLinks, TangledRepos } from '$lib/components/layout';
2
+
import { DynamicLinks } from '$lib/components/layout';
3
3
import {
4
4
ProfileCard,
5
5
PostCard,
6
6
BlueskyPostCard,
7
-
MusicStatusCard
7
+
MusicStatusCard,
8
+
KibunStatusCard,
9
+
TangledRepoCard
8
10
} from '$lib/components/layout/main/card';
9
11
import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta';
10
12
···
40
42
<ProfileCard />
41
43
</div>
42
44
<div class="mb-6 break-inside-avoid">
45
+
<KibunStatusCard />
46
+
</div>
47
+
<div class="mb-6 break-inside-avoid">
43
48
<MusicStatusCard />
44
49
</div>
45
50
<div class="mb-6 break-inside-avoid">
···
52
57
<PostCard />
53
58
</div>
54
59
<div class="mb-6 break-inside-avoid">
55
-
<TangledRepos />
60
+
<TangledRepoCard />
56
61
</div>
57
62
</div>
58
63
</div>
+47
-7
svelte.config.js
+47
-7
svelte.config.js
···
1
-
import adapter from '@sveltejs/adapter-auto';
1
+
import adapter from '@sveltejs/adapter-vercel';
2
2
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3
3
4
4
/** @type {import('@sveltejs/kit').Config} */
5
5
const config = {
6
-
// Consult https://svelte.dev/docs/kit/integrations
7
-
// for more information about preprocessors
8
6
preprocess: vitePreprocess(),
7
+
9
8
kit: {
10
-
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
11
-
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
12
-
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
13
-
adapter: adapter()
9
+
adapter: adapter({
10
+
// Vercel adapter configuration
11
+
runtime: 'nodejs20.x',
12
+
regions: ['iad1'], // Default to US East (adjust based on your target audience)
13
+
split: false, // Set to true to deploy routes as individual functions
14
+
15
+
// Edge runtime configuration (uncomment to use Edge Functions)
16
+
// runtime: 'edge',
17
+
// regions: 'all', // Deploy to all edge regions
18
+
19
+
// Memory and execution limits
20
+
memory: 1024, // MB (256, 512, 1024, 3008)
21
+
maxDuration: 10 // seconds (max execution time)
22
+
}),
23
+
24
+
// Alias configuration for cleaner imports
25
+
alias: {
26
+
$components: 'src/lib/components',
27
+
$lib: 'src/lib',
28
+
$utils: 'src/lib/utils',
29
+
$services: 'src/lib/services',
30
+
$helper: 'src/lib/helper'
31
+
},
32
+
33
+
// Prerender configuration
34
+
prerender: {
35
+
handleHttpError: 'warn',
36
+
handleMissingId: 'warn',
37
+
entries: ['*'] // Prerender all discoverable pages
38
+
},
39
+
40
+
// CSP configuration for security
41
+
csp: {
42
+
mode: 'auto',
43
+
directives: {
44
+
'default-src': ['self'],
45
+
'script-src': ['self', 'unsafe-inline'],
46
+
'style-src': ['self', 'unsafe-inline', 'https://fonts.googleapis.com'],
47
+
'style-src-elem': ['self', 'unsafe-inline', 'https://fonts.googleapis.com'],
48
+
'img-src': ['self', 'data:', 'https:'],
49
+
'font-src': ['self', 'data:', 'https://fonts.gstatic.com'],
50
+
'connect-src': ['self', 'https:'],
51
+
'media-src': ['self', 'https:']
52
+
}
53
+
}
14
54
}
15
55
};
16
56
+56
vercel.json
+56
vercel.json
···
1
+
{
2
+
"$schema": "https://openapi.vercel.sh/vercel.json",
3
+
"buildCommand": "npm run build",
4
+
"devCommand": "npm run dev",
5
+
"installCommand": "npm install",
6
+
"framework": "sveltekit",
7
+
"git": {
8
+
"deploymentEnabled": {
9
+
"main": true
10
+
}
11
+
},
12
+
"headers": [
13
+
{
14
+
"source": "/fonts/:path*",
15
+
"headers": [
16
+
{
17
+
"key": "Cache-Control",
18
+
"value": "public, max-age=31536000, immutable"
19
+
}
20
+
]
21
+
},
22
+
{
23
+
"source": "/_app/immutable/:path*",
24
+
"headers": [
25
+
{
26
+
"key": "Cache-Control",
27
+
"value": "public, max-age=31536000, immutable"
28
+
}
29
+
]
30
+
},
31
+
{
32
+
"source": "/favicon.ico",
33
+
"headers": [
34
+
{
35
+
"key": "Cache-Control",
36
+
"value": "public, max-age=86400"
37
+
}
38
+
]
39
+
},
40
+
{
41
+
"source": "/:path*.(jpg|jpeg|png|gif|ico|svg|webp|avif)",
42
+
"headers": [
43
+
{
44
+
"key": "Cache-Control",
45
+
"value": "public, max-age=31536000, immutable"
46
+
}
47
+
]
48
+
}
49
+
],
50
+
"rewrites": [
51
+
{
52
+
"source": "/(.*)",
53
+
"destination": "/"
54
+
}
55
+
]
56
+
}
+50
-1
vite.config.ts
+50
-1
vite.config.ts
···
3
3
import { defineConfig } from 'vite';
4
4
5
5
export default defineConfig({
6
-
plugins: [tailwindcss(), sveltekit()]
6
+
plugins: [tailwindcss(), sveltekit()],
7
+
8
+
build: {
9
+
// Optimize chunk splitting for better caching
10
+
rollupOptions: {
11
+
output: {
12
+
manualChunks: (id) => {
13
+
// Only chunk client-side code, not SSR externals
14
+
if (id.includes('node_modules')) {
15
+
// Lucide icons - client-side only
16
+
if (id.includes('@lucide/svelte')) {
17
+
return 'lucide';
18
+
}
19
+
// HLS.js - client-side only
20
+
if (id.includes('hls.js')) {
21
+
return 'hls';
22
+
}
23
+
// Other vendor code
24
+
return 'vendor';
25
+
}
26
+
}
27
+
}
28
+
},
29
+
// Target modern browsers for smaller bundle size
30
+
target: 'es2022',
31
+
// Enable minification
32
+
minify: 'esbuild',
33
+
// Source maps for production debugging (set to false to reduce bundle size)
34
+
sourcemap: false,
35
+
// CSS code splitting
36
+
cssCodeSplit: true,
37
+
// Chunk size warnings
38
+
chunkSizeWarningLimit: 1000
39
+
},
40
+
41
+
optimizeDeps: {
42
+
include: ['@lucide/svelte', 'hls.js', '@atproto/api']
43
+
},
44
+
45
+
server: {
46
+
// Development server configuration
47
+
fs: {
48
+
strict: true
49
+
}
50
+
},
51
+
52
+
ssr: {
53
+
// Don't externalize these in SSR
54
+
noExternal: []
55
+
}
7
56
});