my website at ewancroft.uk

Compare changes

Choose any two refs to compare.

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