diff --git a/src/content/til/2024-12-30-astrowind-foundation.md b/src/content/til/2024-12-30-astrowind-foundation.md
new file mode 100644
index 00000000..d0c2d92e
--- /dev/null
+++ b/src/content/til/2024-12-30-astrowind-foundation.md
@@ -0,0 +1,36 @@
+---
+title: 'Starting with AstroWind: The Foundation of Resonant Projects'
+date: 2024-12-30
+tags: ['astro', 'astrowind', 'tailwind', 'web-development']
+description: 'Today I learned how starting with a well-structured template like AstroWind can accelerate custom website development while still allowing complete customization.'
+draft: false
+---
+
+# Starting with AstroWind: The Foundation of Resonant Projects
+
+Building a custom website from scratch is rewarding, but starting with a solid foundation can save weeks of work. Today I began customizing the AstroWind template for Resonant Projects.
+
+## Why Start with a Template?
+
+AstroWind provides:
+
+- **Pre-built Astro components** with proper TypeScript types
+- **Tailwind CSS integration** with dark mode support out of the box
+- **SEO optimization** built into the layout components
+- **Performance optimizations** like image optimization and lazy loading
+
+## Initial Customization Steps
+
+The first changes I made were to themes and colors, establishing the visual identity:
+
+```typescript
+// Customizing the color scheme
+:root {
+ --aw-color-primary: /* your brand color */;
+ --aw-color-secondary: /* accent color */;
+}
+```
+
+## Key Takeaway
+
+Don't reinvent the wheel—but also don't be afraid to modify every aspect of a template. The goal is to use it as scaffolding, not a constraint. Within a few commits, the site was already distinctly "Resonant Projects" rather than generic AstroWind.
diff --git a/src/content/til/2025-03-23-vercel-speed-insights.md b/src/content/til/2025-03-23-vercel-speed-insights.md
new file mode 100644
index 00000000..2fdb85ea
--- /dev/null
+++ b/src/content/til/2025-03-23-vercel-speed-insights.md
@@ -0,0 +1,43 @@
+---
+title: 'Integrating Vercel Speed Insights for Real User Metrics'
+date: 2025-03-23
+tags: ['vercel', 'performance', 'monitoring', 'web-vitals']
+description: 'Today I learned how to integrate Vercel Speed Insights to get real-world performance metrics from actual users, not just Lighthouse synthetic tests.'
+draft: false
+---
+
+# Integrating Vercel Speed Insights for Real User Metrics
+
+Lighthouse scores are great, but they're synthetic. Today I integrated Vercel Speed Insights to get real user metrics (RUM).
+
+## Why Real User Metrics Matter
+
+Lighthouse runs in a controlled environment. Real users have:
+
+- Varying network conditions
+- Different device capabilities
+- Various geographic locations
+- Actual interaction patterns
+
+## Quick Integration
+
+```astro
+---
+import { SpeedInsights } from '@vercel/speed-insights/astro';
+---
+
+
+```
+
+That's it! Vercel automatically starts collecting:
+
+- **LCP** (Largest Contentful Paint)
+- **FID** (First Input Delay)
+- **CLS** (Cumulative Layout Shift)
+- **TTFB** (Time to First Byte)
+
+## Insights from Real Data
+
+After a week of data, I discovered that mobile users in certain regions were experiencing significantly slower TTFB than my Lighthouse tests suggested. This led to investigating edge caching strategies.
+
+The lesson: synthetic tests are a starting point, but real user metrics tell the true story.
diff --git a/src/content/til/2025-05-18-pagefind-search.md b/src/content/til/2025-05-18-pagefind-search.md
new file mode 100644
index 00000000..8da23e0f
--- /dev/null
+++ b/src/content/til/2025-05-18-pagefind-search.md
@@ -0,0 +1,53 @@
+---
+title: 'Adding Lightning-Fast Search with Pagefind'
+date: 2025-05-18
+tags: ['astro', 'search', 'pagefind', 'static-site']
+description: 'Today I learned how Pagefind provides instant client-side search for static sites without any backend infrastructure.'
+draft: false
+---
+
+# Adding Lightning-Fast Search with Pagefind
+
+Static sites traditionally struggle with search—you either need a backend service or rely on external providers. Today I integrated Pagefind, and it's a game-changer.
+
+## What Makes Pagefind Special
+
+Pagefind builds a search index at build time and includes a tiny client-side search that:
+
+- Works entirely client-side (no server needed)
+- Has a compressed index averaging ~100kB for most sites
+- Provides instant results with typo tolerance
+- Supports multiple languages
+
+## Integration in Astro
+
+```bash
+pnpm add pagefind
+```
+
+Then in your build script:
+
+```json
+{
+ "scripts": {
+ "postbuild": "pagefind --site dist"
+ }
+}
+```
+
+And in your search component:
+
+```astro
+
+
+```
+
+## Key Insight
+
+The search index is built at deploy time, so it's always in sync with your content. No more worrying about stale search results or webhook failures. When your site builds, your search is automatically updated.
+
+Perfect for content-heavy static sites where you want great search UX without operational complexity.
diff --git a/src/content/til/2025-05-27-notion-contact-form.md b/src/content/til/2025-05-27-notion-contact-form.md
new file mode 100644
index 00000000..d4f6ab68
--- /dev/null
+++ b/src/content/til/2025-05-27-notion-contact-form.md
@@ -0,0 +1,56 @@
+---
+title: 'Building a Notion-Powered Contact Form'
+date: 2025-05-27
+tags: ['notion', 'api', 'forms', 'astro']
+description: 'Today I learned how to submit form data directly to a Notion database, creating a serverless CRM-like system.'
+draft: false
+---
+
+# Building a Notion-Powered Contact Form
+
+Why pay for a form backend when Notion can be your database? Today I implemented a contact form that submits directly to Notion.
+
+## The Architecture
+
+```
+User submits form → Astro API route → Notion API → Database entry
+```
+
+## Setting Up the Notion Integration
+
+1. Create a Notion integration at notion.so/my-integrations
+2. Share your database with the integration
+3. Store the token securely in environment variables
+
+## The API Route
+
+```typescript
+// src/pages/api/submit-to-notion.ts
+import { Client } from '@notionhq/client';
+
+const notion = new Client({ auth: process.env.NOTION_TOKEN });
+
+export async function POST({ request }) {
+ const data = await request.formData();
+
+ await notion.pages.create({
+ parent: { database_id: process.env.NOTION_DATABASE_ID },
+ properties: {
+ Name: { title: [{ text: { content: data.get('name') } }] },
+ Email: { email: data.get('email') },
+ Message: { rich_text: [{ text: { content: data.get('message') } }] },
+ },
+ });
+
+ return redirect('/thank-you');
+}
+```
+
+## Benefits Over Traditional Forms
+
+- **No additional service costs** - Notion's API is free
+- **Built-in collaboration** - Team can see and respond to inquiries
+- **Flexible schema** - Add fields to your database anytime
+- **Views and filters** - Sort by date, filter by status, etc.
+
+The key insight: Notion databases are surprisingly powerful backends for simple data collection needs.
diff --git a/src/content/til/2025-05-27-react-email-welcome.md b/src/content/til/2025-05-27-react-email-welcome.md
new file mode 100644
index 00000000..ae4ea6e8
--- /dev/null
+++ b/src/content/til/2025-05-27-react-email-welcome.md
@@ -0,0 +1,52 @@
+---
+title: 'Sending Beautiful Welcome Emails with React Email'
+date: 2025-05-27
+tags: ['react-email', 'email', 'automation', 'typescript']
+description: 'Today I learned how React Email makes building and previewing HTML emails as easy as building React components.'
+draft: false
+---
+
+# Sending Beautiful Welcome Emails with React Email
+
+HTML emails are notoriously painful to build. Tables, inline styles, client quirks... Today I discovered React Email, and it changes everything.
+
+## Why React Email?
+
+- Write emails using React components
+- Live preview during development
+- Built-in components for common patterns
+- TypeScript support out of the box
+
+## Creating a Welcome Email
+
+```tsx
+// emails/WelcomeEmail.tsx
+import { Html, Head, Body, Container, Text, Button } from '@react-email/components';
+
+export const WelcomeEmail = ({ name }: { name: string }) => (
+
+
+
+ Welcome, {name}!
+ Thanks for reaching out to Resonant Projects.
+
+
+
+
+);
+```
+
+## Rendering and Sending
+
+```typescript
+import { render } from '@react-email/render';
+import { WelcomeEmail } from '../emails/WelcomeEmail';
+
+const html = render();
+// Send via your email provider (Resend, SendGrid, etc.)
+```
+
+## Key Insight
+
+The preview server (`pnpm email dev`) lets you iterate on email designs instantly. No more "send test email → check inbox → tweak → repeat" cycles. Build your emails like you build your UI.
diff --git a/src/content/til/2025-06-25-bot-protection.md b/src/content/til/2025-06-25-bot-protection.md
new file mode 100644
index 00000000..39675774
--- /dev/null
+++ b/src/content/til/2025-06-25-bot-protection.md
@@ -0,0 +1,63 @@
+---
+title: 'Protecting Forms with Vercel Bot Protection'
+date: 2025-06-25
+tags: ['security', 'vercel', 'forms', 'spam-prevention']
+description: 'Today I learned how to integrate Vercel bot protection to stop spam submissions without annoying CAPTCHAs.'
+draft: false
+---
+
+# Protecting Forms with Vercel Bot Protection
+
+Spam bots love contact forms. Traditional CAPTCHAs frustrate real users. Today I implemented Vercel's invisible bot protection.
+
+## The Problem with CAPTCHAs
+
+- Accessibility issues
+- User friction
+- Increasingly difficult to solve (even for humans!)
+- Bots are getting better at solving them anyway
+
+## Vercel's Approach
+
+Vercel's bot protection works invisibly by analyzing:
+
+- Request patterns
+- Browser fingerprints
+- Behavioral signals
+
+No user interaction required.
+
+## Implementation
+
+Add to your `vercel.json`:
+
+```json
+{
+ "firewall": {
+ "rules": [
+ {
+ "action": "challenge",
+ "source": "api/submit-to-notion"
+ }
+ ]
+ }
+}
+```
+
+On the server side, verify the bot check:
+
+```typescript
+export async function POST({ request }) {
+ const botVerification = request.headers.get('x-vercel-bot-protection');
+
+ if (botVerification !== 'verified') {
+ return new Response('Bot detected', { status: 403 });
+ }
+
+ // Process legitimate submission
+}
+```
+
+## Results
+
+After enabling bot protection, spam submissions dropped to nearly zero while legitimate submissions continued unaffected. The invisible nature means better UX with better security.
diff --git a/src/content/til/2025-06-25-cloudinary-integration.md b/src/content/til/2025-06-25-cloudinary-integration.md
new file mode 100644
index 00000000..f33cb397
--- /dev/null
+++ b/src/content/til/2025-06-25-cloudinary-integration.md
@@ -0,0 +1,50 @@
+---
+title: 'Dynamic Image Optimization with Cloudinary'
+date: 2025-06-25
+tags: ['cloudinary', 'images', 'optimization', 'cdn']
+description: 'Today I learned how Cloudinary can transform and optimize images on-the-fly, reducing the need for pre-processing.'
+draft: false
+---
+
+# Dynamic Image Optimization with Cloudinary
+
+Managing images for a website means dealing with multiple sizes, formats, and quality levels. Today I integrated Cloudinary for dynamic image transformation.
+
+## Why Not Just Use Astro's Image Optimization?
+
+Astro's built-in image optimization is great for static images, but Cloudinary excels when you need:
+
+- Dynamic transformations based on context
+- Images from external sources (like Notion)
+- Real-time cropping, resizing, and effects
+- Automatic format selection (WebP, AVIF)
+
+## Implementation
+
+```typescript
+// src/lib/cloudinary.ts
+import { Cloudinary } from '@cloudinary/url-gen';
+
+const cld = new Cloudinary({
+ cloud: { cloudName: process.env.CLOUDINARY_CLOUD_NAME },
+});
+
+export function getOptimizedUrl(publicId: string, width: number) {
+ return cld.image(publicId).format('auto').quality('auto').resize(fill().width(width)).toURL();
+}
+```
+
+## In Components
+
+```astro
+---
+import { getOptimizedUrl } from '@/lib/cloudinary';
+const heroUrl = getOptimizedUrl('hero-image', 1200);
+---
+
+
+```
+
+## Key Insight
+
+The URL-based transformation API means you can request exactly what you need. A 400px thumbnail? Change the URL parameter. Need a blurred placeholder? Add `e_blur:1000`. No build-time processing, just instant transformations at the CDN edge.
diff --git a/src/content/til/2025-06-27-accessibility-testing-ci.md b/src/content/til/2025-06-27-accessibility-testing-ci.md
new file mode 100644
index 00000000..5d51233a
--- /dev/null
+++ b/src/content/til/2025-06-27-accessibility-testing-ci.md
@@ -0,0 +1,64 @@
+---
+title: 'Automating Accessibility Testing in CI/CD'
+date: 2025-06-27
+tags: ['accessibility', 'a11y', 'testing', 'github-actions', 'ci-cd']
+description: 'Today I learned how to set up automated accessibility testing in GitHub Actions to catch issues before they reach production.'
+draft: false
+---
+
+# Automating Accessibility Testing in CI/CD
+
+Accessibility shouldn't be an afterthought. Today I added automated a11y testing to the CI pipeline so issues are caught before merge.
+
+## The Tools
+
+- **axe-core**: Industry-standard accessibility testing engine
+- **Playwright**: Browser automation for real page testing
+- **GitHub Actions**: CI/CD orchestration
+
+## The Workflow
+
+```yaml
+# .github/workflows/accessibility-testing.yml
+name: Accessibility Testing
+
+on: [push, pull_request]
+
+jobs:
+ a11y:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ - run: pnpm install
+ - run: pnpm build
+ - run: pnpm preview &
+ - name: Run accessibility tests
+ run: npx playwright test accessibility.spec.ts
+```
+
+## The Test
+
+```typescript
+// tests/accessibility.spec.ts
+import { test, expect } from '@playwright/test';
+import AxeBuilder from '@axe-core/playwright';
+
+test('homepage has no accessibility violations', async ({ page }) => {
+ await page.goto('/');
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations).toEqual([]);
+});
+```
+
+## What It Catches
+
+- Missing alt text
+- Color contrast issues
+- Missing form labels
+- Improper heading hierarchy
+- Keyboard navigation problems
+
+## Key Insight
+
+Running these tests on every PR creates accountability. When a violation fails the build, it can't be ignored. Accessibility becomes part of the definition of "done" rather than a nice-to-have.
diff --git a/src/content/til/2025-06-29-cache-control-strategy.md b/src/content/til/2025-06-29-cache-control-strategy.md
new file mode 100644
index 00000000..258d356f
--- /dev/null
+++ b/src/content/til/2025-06-29-cache-control-strategy.md
@@ -0,0 +1,53 @@
+---
+title: 'Fine-Tuning Cache-Control Headers for Astro Sites'
+date: 2025-06-29
+tags: ['caching', 'performance', 'vercel', 'http-headers']
+description: 'Today I learned how to configure Cache-Control headers strategically to balance performance with content freshness.'
+draft: false
+---
+
+# Fine-Tuning Cache-Control Headers for Astro Sites
+
+Not all content should be cached the same way. Today I implemented a nuanced caching strategy.
+
+## The Problem
+
+Default caching often means:
+
+- Static assets cached forever (good)
+- Dynamic pages not cached at all (sometimes too aggressive)
+- API routes returning stale data (bad)
+
+## Strategic Cache Headers
+
+```json
+// vercel.json
+{
+ "headers": [
+ {
+ "source": "/api/(.*)",
+ "headers": [{ "key": "Cache-Control", "value": "no-store, must-revalidate" }]
+ },
+ {
+ "source": "/_astro/(.*)",
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
+ },
+ {
+ "source": "/(.*)",
+ "headers": [
+ { "key": "Cache-Control", "value": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800" }
+ ]
+ }
+ ]
+}
+```
+
+## What This Achieves
+
+1. **API routes**: Never cached, always fresh
+2. **Static assets** (`/_astro/`): Cached for one year (immutable with content hashes)
+3. **HTML pages**: CDN caches for 1 day, serves stale while revalidating for up to 1 week
+
+## Key Insight
+
+`stale-while-revalidate` is the secret sauce. Users get instant responses from cache while the CDN fetches fresh content in the background. Best of both worlds: speed AND freshness.
diff --git a/src/content/til/2025-07-08-til-content-collection.md b/src/content/til/2025-07-08-til-content-collection.md
new file mode 100644
index 00000000..4eccbd08
--- /dev/null
+++ b/src/content/til/2025-07-08-til-content-collection.md
@@ -0,0 +1,77 @@
+---
+title: 'Building a TIL Content Collection in Astro'
+date: 2025-07-08
+tags: ['astro', 'content-collections', 'markdown', 'blog']
+description: 'Today I learned how to create a "Today I Learned" content collection in Astro with social feed and kanban views.'
+draft: false
+---
+
+# Building a TIL Content Collection in Astro
+
+Meta moment: I'm writing a TIL about building the TIL section! Today I implemented a "Today I Learned" feature with multiple view options.
+
+## Defining the Collection Schema
+
+```typescript
+// src/content/config.ts
+const tilCollection = defineCollection({
+ type: 'content',
+ schema: ({ image }) =>
+ z.object({
+ title: z.string(),
+ date: z.date(),
+ tags: z.array(z.string()),
+ description: z.string(),
+ draft: z.boolean().optional(),
+ image: image().optional(),
+ }),
+});
+```
+
+## Multiple Views
+
+I implemented two views for browsing TIL entries:
+
+### Social Feed View
+
+Chronological list, Twitter-style:
+
+```astro
+{
+ entries.map(entry => (
+
+
+
+ ))
+}
+```
+
+## Key Insight
+
+The view toggle is saved to localStorage, so users' preference persists. Small UX touches like this make the difference between a feature people use and one they forget about.
+
+Content collections in Astro make it trivial to add new content types—schema validation, TypeScript types, and query utilities all come free.
diff --git a/src/content/til/2025-08-13-notion-astro-loader.md b/src/content/til/2025-08-13-notion-astro-loader.md
new file mode 100644
index 00000000..9c83f4f9
--- /dev/null
+++ b/src/content/til/2025-08-13-notion-astro-loader.md
@@ -0,0 +1,65 @@
+---
+title: 'Building a Custom Notion Content Loader for Astro'
+date: 2025-08-13
+tags: ['notion', 'astro', 'content-loader', 'cms']
+description: 'Today I learned how to create a custom Astro content loader that fetches pages from a Notion database and treats them as a content collection.'
+draft: false
+---
+
+# Building a Custom Notion Content Loader for Astro
+
+Notion is a powerful content management system. Today I built a custom content loader to pull Notion database pages into Astro as a first-class content collection.
+
+## The Architecture
+
+Astro v5 introduced pluggable content loaders. This means you can load content from anywhere—not just local files.
+
+```typescript
+// vendor/notion-astro-loader/src/index.ts
+import { Client } from '@notionhq/client';
+
+export function notionLoader(options) {
+ return {
+ name: 'notion-loader',
+ async load({ store }) {
+ const notion = new Client({ auth: options.auth });
+
+ const response = await notion.databases.query({
+ database_id: options.database_id,
+ filter: options.filter,
+ });
+
+ for (const page of response.results) {
+ store.set({
+ id: page.id,
+ data: extractProperties(page),
+ body: await getPageContent(page.id),
+ });
+ }
+ },
+ };
+}
+```
+
+## Using It in Collections
+
+```typescript
+// src/content/config.ts
+const resources = defineCollection({
+ loader: notionLoader({
+ auth: process.env.NOTION_TOKEN,
+ database_id: process.env.NOTION_RESOURCES_DB,
+ filter: { property: 'Status', status: { equals: 'Published' } },
+ }),
+ schema: () =>
+ z.object({
+ Name: z.string(),
+ Category: z.array(z.string()),
+ // ... other properties
+ }),
+});
+```
+
+## Key Insight
+
+By vendoring the loader locally, I can customize it for specific needs like image caching, property flattening, and custom rendering. It's the best of both worlds: Notion's excellent editing experience with Astro's static site performance.
diff --git a/src/content/til/2025-08-15-internal-linking-seo.md b/src/content/til/2025-08-15-internal-linking-seo.md
new file mode 100644
index 00000000..d3c266bd
--- /dev/null
+++ b/src/content/til/2025-08-15-internal-linking-seo.md
@@ -0,0 +1,62 @@
+---
+title: 'Implementing an Internal Linking Strategy for SEO'
+date: 2025-08-15
+tags: ['seo', 'internal-linking', 'content', 'astro']
+description: 'Today I learned how to implement automatic internal linking to improve SEO and content discoverability.'
+draft: false
+---
+
+# Implementing an Internal Linking Strategy for SEO
+
+Internal links are one of the most underrated SEO techniques. Today I implemented an automated internal linking strategy.
+
+## Why Internal Links Matter
+
+- Help search engines discover and understand your content structure
+- Pass authority from high-ranking pages to newer content
+- Keep users engaged longer by surfacing relevant content
+- Establish topical relationships between pages
+
+## The Implementation
+
+```typescript
+// src/lib/internal-linking.ts
+const linkableTerms = [
+ { term: 'Astro', url: '/blog/category/astro' },
+ { term: 'web performance', url: '/services/performance' },
+ // ... more terms
+];
+
+export function addInternalLinks(content: string): string {
+ let result = content;
+
+ for (const { term, url } of linkableTerms) {
+ // Only link first occurrence, avoid over-optimization
+ const regex = new RegExp(`\\b(${term})\\b(?![^<]*>)`, 'i');
+ result = result.replace(regex, `$1`);
+ }
+
+ return result;
+}
+```
+
+## Content Readability Optimization
+
+I also added a readability analysis to ensure content is accessible:
+
+```typescript
+export function analyzeReadability(text: string) {
+ const sentences = text.split(/[.!?]+/);
+ const avgWordsPerSentence = countWords(text) / sentences.length;
+
+ return {
+ score: calculateFleschScore(text),
+ avgSentenceLength: avgWordsPerSentence,
+ suggestions: getImprovementSuggestions(text),
+ };
+}
+```
+
+## Key Insight
+
+Automation is key for consistency. Manually adding internal links across hundreds of pages is error-prone. A programmatic approach ensures every relevant term is linked, and adding a new linkable term instantly improves the entire site.
diff --git a/src/content/til/2025-10-05-starwind-pagination-breadcrumbs.md b/src/content/til/2025-10-05-starwind-pagination-breadcrumbs.md
new file mode 100644
index 00000000..41ad8de1
--- /dev/null
+++ b/src/content/til/2025-10-05-starwind-pagination-breadcrumbs.md
@@ -0,0 +1,69 @@
+---
+title: 'Adding Pagination and Breadcrumbs with Starwind UI'
+date: 2025-10-05
+tags: ['starwind', 'ui-components', 'accessibility', 'astro']
+description: 'Today I learned how to implement accessible pagination and breadcrumb components using Starwind UI in Astro.'
+draft: false
+---
+
+# Adding Pagination and Breadcrumbs with Starwind UI
+
+Navigation components are deceptively complex. Today I integrated Starwind UI's pagination and breadcrumb components for proper accessibility.
+
+## Why Not Build From Scratch?
+
+Accessible navigation requires:
+
+- Proper ARIA labels and roles
+- Keyboard navigation support
+- Screen reader announcements
+- Focus management
+
+Getting all of this right is non-trivial. Starwind provides battle-tested components.
+
+## Pagination Component
+
+```astro
+---
+import { Pagination } from '@/components/ui/Pagination';
+const { page, totalPages } = Astro.props;
+---
+
+
+```
+
+The component automatically handles:
+
+- Disabled states for first/last pages
+- Ellipsis for large page counts
+- `aria-current="page"` for the active page
+
+## Breadcrumb Component
+
+```astro
+---
+import { Breadcrumbs, Breadcrumb } from '@/components/ui/Breadcrumbs';
+---
+
+
+ Home
+ Blog
+ Current Post
+
+```
+
+Includes:
+
+- Proper `nav` landmark
+- Schema.org structured data support
+- Visual separators that are hidden from screen readers
+
+## Key Insight
+
+Accessibility is easier when you use components designed for it. The time saved not debugging ARIA issues is worth the dependency.
diff --git a/src/content/til/2025-10-09-url-sanitization-security.md b/src/content/til/2025-10-09-url-sanitization-security.md
new file mode 100644
index 00000000..9d31391f
--- /dev/null
+++ b/src/content/til/2025-10-09-url-sanitization-security.md
@@ -0,0 +1,65 @@
+---
+title: 'Fixing URL Substring Sanitization Vulnerabilities'
+date: 2025-10-09
+tags: ['security', 'url-sanitization', 'code-scanning', 'codeql']
+description: 'Today I learned about incomplete URL substring sanitization and how to properly validate URLs to prevent security vulnerabilities.'
+draft: false
+---
+
+# Fixing URL Substring Sanitization Vulnerabilities
+
+GitHub's code scanning flagged a potential security issue. Today I learned about URL substring sanitization attacks.
+
+## The Vulnerability
+
+CodeQL alert: "Incomplete URL substring sanitization"
+
+The problem code:
+
+```typescript
+// Vulnerable: uses substring check
+function isAllowedUrl(url: string): boolean {
+ return url.includes('mysite.com');
+}
+```
+
+Why it's dangerous:
+
+- `https://evil.com/?redirect=mysite.com` passes the check
+- `https://mysite.com.evil.com` passes the check
+- An attacker can craft URLs that include the substring but redirect elsewhere
+
+## The Fix
+
+Use proper URL parsing:
+
+```typescript
+function isAllowedUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ const allowedHosts = ['mysite.com', 'www.mysite.com'];
+ return allowedHosts.includes(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+```
+
+## Better Yet: Use a Whitelist
+
+```typescript
+const ALLOWED_ORIGINS = new Set(['https://mysite.com', 'https://www.mysite.com']);
+
+function isAllowedUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return ALLOWED_ORIGINS.has(parsed.origin);
+ } catch {
+ return false;
+ }
+}
+```
+
+## Key Insight
+
+String methods like `includes()`, `startsWith()`, or `indexOf()` are not sufficient for URL validation. Always parse URLs properly and check specific components (hostname, origin, protocol) rather than substring matching.
diff --git a/src/content/til/2025-12-23-accordion-ssr-flash.md b/src/content/til/2025-12-23-accordion-ssr-flash.md
new file mode 100644
index 00000000..a8f9e220
--- /dev/null
+++ b/src/content/til/2025-12-23-accordion-ssr-flash.md
@@ -0,0 +1,60 @@
+---
+title: 'Fixing SSR Flash on Accordion Components'
+date: 2025-12-23
+tags: ['astro', 'ssr', 'accordion', 'hydration', 'starwind']
+description: 'Today I learned how to prevent the flash of incorrect state when accordions hydrate with default-open items.'
+draft: false
+---
+
+# Fixing SSR Flash on Accordion Components
+
+Interactive components with default states can flash during hydration. Today I fixed an SSR flash issue on an accordion with a default-open item.
+
+## The Problem
+
+When an accordion has a default-open item:
+
+1. Server renders the closed state (HTML default)
+2. Page loads with closed accordion
+3. JavaScript hydrates and opens the default item
+4. User sees a jarring "flash" from closed to open
+
+## The Solution
+
+Render the correct initial state on the server:
+
+```astro
+---
+// FAQ.astro
+const defaultOpenId = 'faq-1';
+---
+
+
+ {
+ faqs.map((faq, i) => (
+
+ {faq.question}
+
{faq.answer}
+
+ ))
+ }
+
+```
+
+The key is using the native `` element with `open` attribute for SSR, then enhancing with JavaScript for smooth animations.
+
+## Progressive Enhancement Script
+
+```typescript
+// Only enhance, don't change initial state
+document.querySelectorAll('[data-accordion-item]').forEach(item => {
+ item.addEventListener('click', e => {
+ // Add smooth height animation
+ // Don't toggle open/closed - let browser handle that
+ });
+});
+```
+
+## Key Insight
+
+SSR flash happens when server and client render different initial states. The fix is ensuring they match—not by removing SSR, but by making the server render the correct initial state. Progressive enhancement means the page works without JS and improves with it.
diff --git a/src/content/til/2025-12-23-astro-actions-react-forms.md b/src/content/til/2025-12-23-astro-actions-react-forms.md
new file mode 100644
index 00000000..d359abbc
--- /dev/null
+++ b/src/content/til/2025-12-23-astro-actions-react-forms.md
@@ -0,0 +1,65 @@
+---
+title: 'Modern Form Handling with Astro Actions and React useActionState'
+date: 2025-12-23
+tags: ['astro', 'react', 'forms', 'actions', 'validation']
+description: 'Today I learned how to combine Astro Actions with React useActionState for type-safe, progressively enhanced forms.'
+draft: false
+---
+
+# Modern Form Handling with Astro Actions and React useActionState
+
+Forms are a solved problem... right? Today I upgraded the contact form to use Astro Actions with React's useActionState for the best of both worlds.
+
+## Why This Combo?
+
+- **Astro Actions**: Server-side validation, type safety, works without JS
+- **React useActionState**: Optimistic updates, loading states, error handling
+
+## Defining the Action
+
+```typescript
+// src/actions/contact.ts
+import { defineAction, z } from 'astro:actions';
+
+export const submitContact = defineAction({
+ input: z.object({
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+ email: z.string().email('Invalid email address'),
+ message: z.string().min(10, 'Message must be at least 10 characters'),
+ }),
+ handler: async ({ name, email, message }) => {
+ // Submit to Notion, send email, etc.
+ await notionClient.pages.create({
+ /* ... */
+ });
+ return { success: true };
+ },
+});
+```
+
+## The React Form Component
+
+```tsx
+// src/components/ContactForm.tsx
+import { useActionState } from 'react';
+import { actions } from 'astro:actions';
+
+export function ContactForm() {
+ const [state, action, pending] = useActionState(actions.submitContact, { success: false, errors: {} });
+
+ return (
+
+ );
+}
+```
+
+## Key Insight
+
+Astro Actions provide the progressive enhancement baseline (form works without JS), while useActionState adds the polish (loading states, inline errors, optimistic updates). Type safety flows from action definition to form inputs automatically.
diff --git a/src/content/til/2025-12-30-editorial-typography.md b/src/content/til/2025-12-30-editorial-typography.md
new file mode 100644
index 00000000..6eb35443
--- /dev/null
+++ b/src/content/til/2025-12-30-editorial-typography.md
@@ -0,0 +1,59 @@
+---
+title: 'Implementing an Editorial Typography System'
+date: 2025-12-30
+tags: ['typography', 'design-system', 'fonts', 'css']
+description: 'Today I learned how to implement a three-font typography system that balances personality with readability.'
+draft: false
+---
+
+# Implementing an Editorial Typography System
+
+Typography is the foundation of design. Today I implemented a three-font editorial system that gives the site a distinct personality while maintaining readability.
+
+## The Three-Font Strategy
+
+1. **Display Font**: Headlines and hero text (personality)
+2. **Body Font**: Paragraphs and long-form content (readability)
+3. **UI Font**: Navigation, buttons, labels (clarity)
+
+## Implementation in Starwind CSS
+
+```css
+/* starwind.css */
+:root {
+ --font-display: 'Playfair Display', serif;
+ --font-body: 'Source Sans Pro', sans-serif;
+ --font-ui: 'Inter', system-ui, sans-serif;
+}
+
+.heading-1,
+.heading-2 {
+ font-family: var(--font-display);
+ font-weight: 700;
+}
+
+.body-text,
+.prose {
+ font-family: var(--font-body);
+ line-height: 1.75;
+}
+
+.button,
+.nav-link,
+.label {
+ font-family: var(--font-ui);
+ font-weight: 500;
+}
+```
+
+## The Iteration Process
+
+Getting fonts right took several attempts:
+
+1. First try: Too many weights, slow load times
+2. Second try: Fonts clashed in personality
+3. Final: Complementary serifs and sans-serifs with limited weights
+
+## Key Insight
+
+Limiting font weights to what you actually use (typically 400, 500, 700) dramatically improves load times. Variable fonts help here—one file, all weights. But more importantly, a cohesive typography system creates visual harmony that users feel even if they can't articulate why the site "feels professional."
diff --git a/src/content/til/2025-12-31-brand-color-refresh.md b/src/content/til/2025-12-31-brand-color-refresh.md
new file mode 100644
index 00000000..c98f958d
--- /dev/null
+++ b/src/content/til/2025-12-31-brand-color-refresh.md
@@ -0,0 +1,64 @@
+---
+title: 'Refreshing a Brand Color Palette Systematically'
+date: 2025-12-31
+tags: ['design', 'color-theory', 'brand', 'css-variables']
+description: 'Today I learned how to systematically refresh a color palette while maintaining accessibility and consistency across light and dark modes.'
+draft: false
+---
+
+# Refreshing a Brand Color Palette Systematically
+
+A color refresh sounds simple—just pick new colors, right? Today I learned there's a systematic approach that ensures consistency and accessibility.
+
+## The Process
+
+### 1. Define Semantic Colors
+
+Don't think "blue button," think "primary action":
+
+```css
+:root {
+ --color-primary: /* main brand color */;
+ --color-secondary: /* supporting actions */;
+ --color-accent: /* highlights, CTAs */;
+ --color-muted: /* backgrounds, borders */;
+ --color-destructive: /* errors, deletions */;
+}
+```
+
+### 2. Generate a Scale
+
+Each color needs a scale from light to dark:
+
+```css
+--color-primary-50: /* lightest, backgrounds */;
+--color-primary-100: /* ... */;
+--color-primary-500: /* base color */;
+--color-primary-900: /* darkest, text */;
+```
+
+### 3. Ensure Accessibility
+
+Check contrast ratios for text/background combinations:
+
+```typescript
+function meetsWCAG(textColor: string, bgColor: string): boolean {
+ const ratio = getContrastRatio(textColor, bgColor);
+ return ratio >= 4.5; // AA standard for normal text
+}
+```
+
+### 4. Dark Mode Mapping
+
+Don't just invert—remap semantically:
+
+```css
+.dark {
+ --color-primary-500: var(--color-primary-400); /* slightly lighter in dark mode */
+ --color-surface: var(--color-gray-900);
+}
+```
+
+## Key Insight
+
+The "500" in a color scale should represent the brand color at its purest form. Everything else derives from it. When you change the primary color, the entire palette updates mathematically, ensuring consistency across hundreds of UI elements.
diff --git a/src/content/til/accessibility-ci-cd-pipeline.md b/src/content/til/accessibility-ci-cd-pipeline.md
new file mode 100644
index 00000000..762787c5
--- /dev/null
+++ b/src/content/til/accessibility-ci-cd-pipeline.md
@@ -0,0 +1,105 @@
+---
+title: 'Automated Accessibility Testing in CI/CD'
+date: 2025-01-04
+tags: ['accessibility', 'ci-cd', 'github-actions', 'testing']
+description: 'Today I learned how to build a comprehensive accessibility testing pipeline with axe-core, Lighthouse, and regression detection in GitHub Actions.'
+draft: false
+---
+
+# Automated Accessibility Testing in CI/CD
+
+Manual accessibility testing catches issues, but automation catches regressions. I built a GitHub Actions workflow that runs multiple accessibility tools on every push.
+
+## The Testing Stack
+
+Three complementary tools provide coverage:
+
+```yaml
+strategy:
+ matrix:
+ test-type: [axe-core, lighthouse, custom]
+```
+
+- **axe-core**: Detailed issue detection with severity levels
+- **Lighthouse**: Overall accessibility score with specific recommendations
+- **Custom tests**: Project-specific focus and keyboard navigation checks
+
+## Waiting for Dev Server
+
+The workflow needs the Astro dev server running. A robust wait loop:
+
+```yaml
+- name: Start astro dev
+ run: |
+ pnpm astro dev --port 4321 --host &
+ echo "PID=$!" >> "$GITHUB_OUTPUT"
+ for i in {1..30}; do
+ sleep 2
+ if curl -sf http://localhost:4321 > /dev/null; then
+ echo "✅ Server is up!"; break
+ fi
+ if [ $i -eq 30 ]; then
+ echo "❌ Server did not start" >&2
+ exit 1
+ fi
+ done
+```
+
+## Regression Detection
+
+Compare current branch to base branch for PR checks:
+
+```yaml
+accessibility-regression:
+ if: github.event_name == 'pull_request'
+ steps:
+ - name: Test current branch
+ run: pnpm test:accessibility:ci && cp -r reports current/
+
+ - name: Checkout base branch
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.base_ref }}
+
+ - name: Test base branch
+ run: pnpm test:accessibility:ci && cp -r reports base/
+
+ - name: Compare results
+ run: diff current/summary.json base/summary.json
+```
+
+## PR Comments with Results
+
+Automatically comment test results on PRs:
+
+```yaml
+- uses: actions/github-script@v8
+ with:
+ script: |
+ const results = require('./accessibility-reports/summary.json');
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: `## 🛡️ Accessibility Results\n\nScore: ${results.score}/100`,
+ });
+```
+
+## Mobile Testing
+
+Test accessibility on mobile viewports specifically:
+
+```yaml
+- name: Mobile accessibility test
+ run: |
+ npx lighthouse http://localhost:4321 \
+ --only-categories=accessibility \
+ --form-factor=mobile \
+ --screenEmulation.width=375 \
+ --screenEmulation.height=667 \
+ --output=json
+```
+
+## Key Insight
+
+The multi-tool approach catches different issue types. axe-core finds ARIA problems and color contrast issues. Lighthouse provides an overall score for tracking trends. Custom tests catch project-specific patterns like focus management. Run all three—they complement rather than duplicate each other.
diff --git a/src/content/til/accessibility-testing-pipeline-axe.md b/src/content/til/accessibility-testing-pipeline-axe.md
new file mode 100644
index 00000000..4e0ed1e5
--- /dev/null
+++ b/src/content/til/accessibility-testing-pipeline-axe.md
@@ -0,0 +1,93 @@
+---
+title: 'Building an Accessibility Testing Pipeline with Axe-Core'
+date: 2025-06-27
+tags: ['accessibility', 'testing', 'ci', 'wcag']
+description: 'Today I learned how to set up automated accessibility testing with axe-core and Lighthouse in a CI/CD pipeline.'
+draft: false
+---
+
+# Building an Accessibility Testing Pipeline with Axe-Core
+
+Manual accessibility audits don't scale. Automated testing catches regressions before they reach production.
+
+## The Testing Stack
+
+- **axe-core**: Automated WCAG violation detection
+- **Lighthouse**: Performance and a11y scores
+- **Puppeteer**: Headless browser automation
+
+## Configuration
+
+```javascript
+// accessibility.config.js
+export default {
+ axe: {
+ rules: {
+ 'color-contrast': { enabled: true },
+ 'valid-lang': { enabled: true },
+ 'landmark-one-main': { enabled: true },
+ },
+ exclude: [
+ '[data-a11y-ignore]', // Escape hatch for known issues
+ ],
+ },
+ lighthouse: {
+ categories: ['accessibility'],
+ thresholds: {
+ accessibility: 90,
+ },
+ },
+ urls: ['/', '/about', '/contact', '/services/rhythm'],
+};
+```
+
+## The Test Script
+
+```javascript
+// scripts/accessibility-test.js
+import { AxePuppeteer } from '@axe-core/puppeteer';
+import puppeteer from 'puppeteer';
+
+async function runA11yTests(urls) {
+ const browser = await puppeteer.launch({ headless: true });
+ const results = [];
+
+ for (const url of urls) {
+ const page = await browser.newPage();
+ await page.goto(`http://localhost:4321${url}`);
+
+ const axeResults = await new AxePuppeteer(page).analyze();
+
+ results.push({
+ url,
+ violations: axeResults.violations,
+ passes: axeResults.passes.length,
+ });
+ }
+
+ await browser.close();
+ return results;
+}
+```
+
+## GitHub Actions Workflow
+
+```yaml
+accessibility-tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - run: npm install
+ - run: npm run build
+ - run: npm run preview &
+ - run: sleep 5
+ - run: npm run axe:scan
+ - uses: actions/upload-artifact@v6
+ with:
+ name: accessibility-report
+ path: axe-results.json
+```
+
+## Key Insight
+
+Automated a11y testing isn't comprehensive—it catches about 30% of issues. But that 30% includes the most common violations: missing alt text, poor contrast, invalid ARIA. Catching these automatically frees up manual testing for complex interactions.
diff --git a/src/content/til/advanced-focus-management.md b/src/content/til/advanced-focus-management.md
new file mode 100644
index 00000000..bebf3e98
--- /dev/null
+++ b/src/content/til/advanced-focus-management.md
@@ -0,0 +1,139 @@
+---
+title: 'Advanced Focus Management for Accessibility'
+date: 2024-12-05
+tags: ['accessibility', 'javascript', 'focus', 'keyboard']
+description: 'Today I learned how to build a comprehensive focus management system with modal trapping, roving tabindex, and grid navigation patterns.'
+draft: false
+---
+
+# Advanced Focus Management for Accessibility
+
+Keyboard users rely on predictable focus behavior. I built a FocusManager class that handles complex patterns like modal focus trapping, roving tabindex, and grid navigation.
+
+## Focus Trapping for Modals
+
+Trap focus inside a modal, supporting nested modals:
+
+```javascript
+class FocusManager {
+ constructor() {
+ this.modalStack = [];
+ this.lastFocus = null;
+ }
+
+ trapFocus(container) {
+ this.lastFocus = document.activeElement;
+ this.modalStack.push(container);
+
+ const focusables = this.getFocusableElements(container);
+ if (focusables.length) focusables[0].focus();
+
+ container.addEventListener('keydown', this.handleTabKey);
+ }
+
+ releaseFocus() {
+ const container = this.modalStack.pop();
+ container?.removeEventListener('keydown', this.handleTabKey);
+ this.lastFocus?.focus();
+ }
+
+ handleTabKey = e => {
+ if (e.key !== 'Tab') return;
+
+ const container = this.modalStack.at(-1);
+ const focusables = this.getFocusableElements(container);
+ const first = focusables[0];
+ const last = focusables.at(-1);
+
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+}
+```
+
+## Roving Tabindex
+
+Move focus between siblings with arrow keys:
+
+```javascript
+initRovingTabindex(container) {
+ const items = container.querySelectorAll('[data-roving-item]');
+ items.forEach((item, i) => {
+ item.tabIndex = i === 0 ? 0 : -1;
+ });
+
+ container.addEventListener('keydown', (e) => {
+ if (!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
+
+ const items = [...container.querySelectorAll('[data-roving-item]')];
+ const current = items.findIndex(el => el === document.activeElement);
+ const next = e.key === 'ArrowRight'
+ ? (current + 1) % items.length
+ : (current - 1 + items.length) % items.length;
+
+ items[current].tabIndex = -1;
+ items[next].tabIndex = 0;
+ items[next].focus();
+ });
+}
+```
+
+## Grid Navigation
+
+Arrow keys navigate a 2D grid:
+
+```javascript
+initGridNavigation(container, columns) {
+ container.addEventListener('keydown', (e) => {
+ const cells = [...container.querySelectorAll('[role="gridcell"]')];
+ const current = cells.findIndex(c => c === document.activeElement);
+
+ let next;
+ switch (e.key) {
+ case 'ArrowRight': next = current + 1; break;
+ case 'ArrowLeft': next = current - 1; break;
+ case 'ArrowDown': next = current + columns; break;
+ case 'ArrowUp': next = current - columns; break;
+ default: return;
+ }
+
+ if (next >= 0 && next < cells.length) {
+ e.preventDefault();
+ cells[next].focus();
+ }
+ });
+}
+```
+
+## Auto-Focus for Dynamic Content
+
+Focus the first interactive element when new content loads:
+
+```html
+
+
+
+```
+
+```javascript
+const observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ mutation.addedNodes.forEach(node => {
+ if (node.hasAttribute?.('data-auto-focus')) {
+ const target = node.querySelector('[data-focus-target]');
+ target?.focus();
+ announce(node.dataset.announce);
+ }
+ });
+ });
+});
+```
+
+## Key Insight
+
+Focus management is about predictability. Users should know where focus goes when they open a modal, close a dialog, or navigate a grid. The patterns are consistent: trap focus in modals, restore focus on close, use roving tabindex for component navigation, and announce dynamic changes to screen readers.
diff --git a/src/content/til/astro-content-collections-zod.md b/src/content/til/astro-content-collections-zod.md
new file mode 100644
index 00000000..46be7095
--- /dev/null
+++ b/src/content/til/astro-content-collections-zod.md
@@ -0,0 +1,98 @@
+---
+title: 'Astro Content Collections with Zod Schemas'
+date: 2025-01-06
+tags: ['astro', 'typescript', 'zod', 'content']
+description: 'Today I learned how to build type-safe content collections in Astro using Zod schemas for validation and TypeScript inference.'
+draft: false
+---
+
+# Astro Content Collections with Zod Schemas
+
+Setting up content collections in Astro with Zod schemas gives you type safety and validation for your markdown content—catching errors at build time instead of runtime.
+
+## The Setup
+
+Content collections in Astro 5 use `defineCollection` with Zod schemas:
+
+```typescript
+// src/content/config.ts
+import { defineCollection, z } from 'astro:content';
+
+const tilCollection = defineCollection({
+ type: 'content',
+ schema: ({ image }) =>
+ z.object({
+ title: z.string(),
+ date: z.date(),
+ tags: z.array(z.string()),
+ description: z.string(),
+ draft: z.boolean().optional(),
+ image: image().optional(),
+ }),
+});
+
+export const collections = {
+ til: tilCollection,
+};
+```
+
+## Type Inference for Free
+
+The Zod schema automatically infers TypeScript types. In your components:
+
+```typescript
+import { getCollection } from 'astro:content';
+
+const entries = await getCollection('til');
+// entries is fully typed with title, date, tags, etc.
+
+entries.forEach(entry => {
+ console.log(entry.data.title); // TypeScript knows this is a string
+ console.log(entry.data.date); // TypeScript knows this is a Date
+});
+```
+
+## Image Optimization
+
+The `image()` helper from Astro enables automatic image optimization:
+
+```typescript
+schema: ({ image }) =>
+ z.object({
+ heroImage: image().optional(),
+ }),
+```
+
+Images referenced in frontmatter get processed by Astro's image optimization pipeline automatically.
+
+## Shared Schema Definitions
+
+For reusable metadata across collections, extract common patterns:
+
+```typescript
+const metadataDefinition = () =>
+ z
+ .object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ openGraph: z
+ .object({
+ url: z.string().optional(),
+ images: z.array(z.object({ url: z.string() })).optional(),
+ })
+ .optional(),
+ })
+ .optional();
+
+const postCollection = defineCollection({
+ schema: () =>
+ z.object({
+ title: z.string(),
+ metadata: metadataDefinition(),
+ }),
+});
+```
+
+## Key Insight
+
+Content collections transform your markdown files into a type-safe data layer. Build errors catch typos in frontmatter fields, missing required properties, and type mismatches. The DX improvement is significant—you get autocomplete and type checking everywhere you use content.
diff --git a/src/content/til/cloudinary-image-presets.md b/src/content/til/cloudinary-image-presets.md
new file mode 100644
index 00000000..7fe140ab
--- /dev/null
+++ b/src/content/til/cloudinary-image-presets.md
@@ -0,0 +1,82 @@
+---
+title: 'Cloudinary Image Optimization with Presets'
+date: 2025-01-02
+tags: ['cloudinary', 'images', 'performance', 'typescript']
+description: 'Today I learned how to build a type-safe Cloudinary utility layer with presets for consistent image optimization across a photography portfolio.'
+draft: false
+---
+
+# Cloudinary Image Optimization with Presets
+
+Managing a photography portfolio means dozens of images at different sizes. Cloudinary's transformation API handles optimization, but without structure it gets messy fast. The solution: a preset-based utility layer.
+
+## Preset Definition
+
+Define reusable transformation presets:
+
+```typescript
+// src/utils/cloudinary.ts
+type Preset = 'portfolio' | 'thumbnail' | 'hero' | 'portrait' | 'responsive';
+
+const presets: Record = {
+ portfolio: { width: 800, height: 600, crop: 'fill', quality: 'auto' },
+ thumbnail: { width: 400, height: 300, crop: 'fill', quality: 'auto' },
+ hero: { width: 1920, height: 1080, crop: 'fill', quality: 'auto' },
+ portrait: { width: 400, height: 400, crop: 'face', gravity: 'face' },
+ responsive: { width: 'auto', crop: 'scale', quality: 'auto', format: 'auto' },
+};
+```
+
+## URL Generation
+
+Transform presets into Cloudinary URLs:
+
+```typescript
+export function getCloudinaryImageUrl(publicId: string, options: { preset: Preset; aspectRatio?: string }): string {
+ const { preset, aspectRatio } = options;
+ const transforms = presets[preset];
+
+ const parts = [
+ `f_auto`,
+ `q_${transforms.quality || 'auto'}`,
+ transforms.width && `w_${transforms.width}`,
+ transforms.height && `h_${transforms.height}`,
+ transforms.crop && `c_${transforms.crop}`,
+ transforms.gravity && `g_${transforms.gravity}`,
+ aspectRatio && `ar_${aspectRatio}`,
+ ].filter(Boolean);
+
+ return `https://res.cloudinary.com/${cloudName}/image/upload/${parts.join(',')}/${publicId}`;
+}
+```
+
+## Responsive Image Sets
+
+Generate srcset for responsive images:
+
+```typescript
+export function getResponsiveImageUrls(publicId: string): ResponsiveUrls {
+ const widths = [320, 640, 768, 1024, 1280, 1536];
+
+ return {
+ srcSet: widths.map(w => `${getCloudinaryUrl(publicId, { width: w })} ${w}w`).join(', '),
+ sizes: '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw',
+ };
+}
+```
+
+## Environment Validation
+
+Fail fast when Cloudinary isn't configured:
+
+```typescript
+export function validateCloudinaryConfig(): void {
+ if (!import.meta.env.PUBLIC_CLOUDINARY_CLOUD_NAME) {
+ throw new Error('PUBLIC_CLOUDINARY_CLOUD_NAME is required');
+ }
+}
+```
+
+## Key Insight
+
+Cloudinary's automatic format (`f_auto`) and quality (`q_auto`) transformations reduce image sizes by 25-35% without visible quality loss. The preset pattern keeps transformations consistent and makes updates easy—change one preset, update every image using it.
diff --git a/src/content/til/content-readability-optimization.md b/src/content/til/content-readability-optimization.md
new file mode 100644
index 00000000..25bf1a0a
--- /dev/null
+++ b/src/content/til/content-readability-optimization.md
@@ -0,0 +1,148 @@
+---
+title: 'Content Readability Analysis and Optimization'
+date: 2024-11-15
+tags: ['content', 'seo', 'writing', 'accessibility']
+description: 'Today I learned how to analyze and improve content readability using Flesch-Kincaid scores, sentence length metrics, and automated content auditing.'
+draft: false
+---
+
+# Content Readability Analysis and Optimization
+
+Web content should be accessible to readers of all skill levels. I built a readability analyzer that measures key metrics and provides actionable recommendations.
+
+## Key Metrics
+
+Target these readability scores:
+
+| Metric | Target | Why |
+| ------------------- | ----------- | ------------------- |
+| Flesch Reading Ease | 60+ | 8th-9th grade level |
+| Avg Sentence Length | 15-20 words | Easier to scan |
+| Passive Voice | <20% | More direct |
+| Paragraph Length | 50-75 words | Digestible chunks |
+
+## Analysis Script
+
+Parse content and calculate metrics:
+
+```javascript
+// src/utils/readability-analyzer.ts
+export function analyzeReadability(text) {
+ const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
+ const words = text.split(/\s+/).filter(Boolean);
+ const syllables = words.reduce((sum, word) => sum + countSyllables(word), 0);
+
+ const avgSentenceLength = words.length / sentences.length;
+ const avgSyllablesPerWord = syllables / words.length;
+
+ // Flesch Reading Ease formula
+ const flesch = 206.835 - 1.015 * avgSentenceLength - 84.6 * avgSyllablesPerWord;
+
+ return {
+ flesch: Math.round(flesch),
+ avgSentenceLength: Math.round(avgSentenceLength * 10) / 10,
+ totalWords: words.length,
+ totalSentences: sentences.length,
+ grade: fleschToGrade(flesch),
+ };
+}
+```
+
+## Passive Voice Detection
+
+Find and flag passive constructions:
+
+```javascript
+const passivePatterns = [/\b(is|are|was|were|been|being)\s+\w+ed\b/gi, /\b(is|are|was|were|been|being)\s+\w+en\b/gi];
+
+export function findPassiveVoice(text) {
+ const matches = [];
+ passivePatterns.forEach(pattern => {
+ let match;
+ while ((match = pattern.exec(text)) !== null) {
+ matches.push({
+ text: match[0],
+ index: match.index,
+ suggestion: 'Consider rewriting in active voice',
+ });
+ }
+ });
+ return matches;
+}
+```
+
+## Long Sentence Detection
+
+Flag sentences that need splitting:
+
+```javascript
+export function findLongSentences(text, maxWords = 25) {
+ const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
+
+ return sentences
+ .map((sentence, index) => ({
+ sentence: sentence.trim(),
+ wordCount: sentence.split(/\s+/).length,
+ index,
+ }))
+ .filter(s => s.wordCount > maxWords);
+}
+```
+
+## Automated Content Audit
+
+Run analysis on all content pages:
+
+```javascript
+// scripts/content-analyzer.js
+import { glob } from 'glob';
+import { readFile } from 'fs/promises';
+import matter from 'gray-matter';
+
+async function auditContent() {
+ const files = await glob('src/content/**/*.md');
+ const results = [];
+
+ for (const file of files) {
+ const content = await readFile(file, 'utf-8');
+ const { content: body } = matter(content);
+ const analysis = analyzeReadability(body);
+
+ results.push({
+ file,
+ ...analysis,
+ passesTargets: analysis.flesch >= 60 && analysis.avgSentenceLength <= 20,
+ });
+ }
+
+ return results;
+}
+```
+
+## Word Substitutions
+
+Replace complex words with simpler alternatives:
+
+```javascript
+const simplifications = {
+ utilize: 'use',
+ facilitate: 'help',
+ demonstrate: 'show',
+ methodology: 'method',
+ implementation: 'setup',
+ comprehensive: 'complete',
+};
+
+export function suggestSimplifications(text) {
+ return Object.entries(simplifications)
+ .filter(([complex]) => text.toLowerCase().includes(complex))
+ .map(([complex, simple]) => ({
+ find: complex,
+ replace: simple,
+ }));
+}
+```
+
+## Key Insight
+
+Readability isn't dumbing down content—it's respecting your readers' time. Shorter sentences are easier to scan. Active voice is more direct. Simple words communicate faster. Measure readability metrics in CI to prevent regression.
diff --git a/src/content/til/convex-real-time-backend.md b/src/content/til/convex-real-time-backend.md
new file mode 100644
index 00000000..d0a6fbf6
--- /dev/null
+++ b/src/content/til/convex-real-time-backend.md
@@ -0,0 +1,112 @@
+---
+title: 'Convex: Real-Time Data Without WebSocket Complexity'
+date: 2025-06-16
+tags: ['convex', 'backend', 'real-time', 'typescript']
+description: 'Today I learned how Convex eliminates the complexity of building real-time applications with type-safe queries and automatic reactivity.'
+draft: false
+---
+
+# Convex: Real-Time Data Without WebSocket Complexity
+
+Setting up a real-time backend usually means wrestling with WebSockets, managing connections, and handling reconnection logic. Today I discovered that Convex makes this trivially simple.
+
+## The Traditional Approach
+
+Building real-time features typically requires:
+
+```typescript
+// Server: Set up WebSocket handling
+const wss = new WebSocketServer({ port: 8080 });
+wss.on('connection', ws => {
+ ws.on('message', handleMessage);
+ ws.on('close', handleDisconnect);
+ // Handle reconnection, heartbeats, etc.
+});
+
+// Client: Manage connection state
+const socket = new WebSocket('ws://localhost:8080');
+socket.onopen = () => {
+ /* reconnection logic */
+};
+socket.onclose = () => {
+ /* retry with backoff */
+};
+```
+
+## The Convex Way
+
+With Convex, real-time updates are automatic:
+
+```typescript
+// convex/users.ts - Define your query
+import { query } from './_generated/server';
+import { v } from 'convex/values';
+
+export const getProfile = query({
+ args: { userId: v.string() },
+ handler: async (ctx, { userId }) => {
+ return await ctx.db
+ .query('users')
+ .filter(q => q.eq(q.field('clerkId'), userId))
+ .first();
+ },
+});
+
+// React/Solid component - Use the query
+const profile = useQuery(api.users.getProfile, { userId });
+// Automatically updates when data changes!
+```
+
+## Type Safety End-to-End
+
+The best part is the type safety that flows from database to UI:
+
+```typescript
+// Schema definition
+const users = defineTable({
+ clerkId: v.string(),
+ email: v.string(),
+ birthDetails: v.optional(
+ v.object({
+ date: v.string(),
+ time: v.string(),
+ location: v.string(),
+ })
+ ),
+});
+
+// TypeScript knows the exact shape everywhere
+const user = useQuery(api.users.getProfile, { userId });
+user?.birthDetails?.date; // Fully typed!
+```
+
+## Mutations Are Just as Simple
+
+```typescript
+// convex/users.ts
+export const updatePreferences = mutation({
+ args: {
+ userId: v.string(),
+ preferences: v.object({
+ emailFrequency: v.array(v.string()),
+ timezone: v.string(),
+ }),
+ },
+ handler: async (ctx, { userId, preferences }) => {
+ const user = await ctx.db
+ .query('users')
+ .filter(q => q.eq(q.field('clerkId'), userId))
+ .first();
+
+ if (user) {
+ await ctx.db.patch(user._id, { preferences });
+ }
+ },
+});
+```
+
+## Key Insight
+
+Convex's type-safe queries eliminate an entire class of backend bugs. When your schema changes, TypeScript immediately tells you every place in your codebase that needs updating. No more runtime errors from mismatched field names or types.
+
+The mental model shift: think of your backend as a reactive database that your frontend subscribes to, not an API you poll.
diff --git a/src/content/til/notion-cms-astro-loader.md b/src/content/til/notion-cms-astro-loader.md
new file mode 100644
index 00000000..cda2e2d7
--- /dev/null
+++ b/src/content/til/notion-cms-astro-loader.md
@@ -0,0 +1,84 @@
+---
+title: 'Using Notion as a CMS with Astro'
+date: 2025-01-03
+tags: ['astro', 'notion', 'cms', 'headless']
+description: 'Today I learned how to use Notion as a headless CMS for Astro using a custom loader with live content sync.'
+draft: false
+---
+
+# Using Notion as a CMS with Astro
+
+Notion makes a surprisingly capable headless CMS when paired with Astro's content loaders. I built a custom loader that syncs Notion database content into Astro's content collection system.
+
+## The Loader Pattern
+
+Astro 5's experimental `loaders` feature lets you define custom content sources:
+
+```typescript
+// src/content/config.ts
+import { notionLoader } from '../vendor/notion-astro-loader/src';
+
+export const collections = {
+ resources: defineCollection({
+ loader: notionLoader({
+ auth: process.env.NOTION_TOKEN!,
+ database_id: process.env.NOTION_RR_RESOURCES_ID!,
+ imageSavePath: 'content/notion/images',
+ filter: {
+ property: 'Status',
+ status: { equals: 'Up-to-Date' },
+ },
+ }),
+ schema: () =>
+ z.object({
+ Name: z.string().optional(),
+ Category: z.array(z.string()).optional(),
+ Tags: z.array(z.string()).optional(),
+ 'AI summary': z.string().optional(),
+ }),
+ }),
+};
+```
+
+## Property Mapping
+
+Notion's property types need translation to Zod schemas:
+
+| Notion Type | Zod Schema |
+| ------------ | --------------------- |
+| Title | `z.string()` |
+| Select | `z.string()` |
+| Multi-select | `z.array(z.string())` |
+| Date | `z.date()` |
+| Checkbox | `z.boolean()` |
+| URL | `z.string().url()` |
+
+## Live Content Sync
+
+Enable experimental live content collections for dev server hot-reload:
+
+```typescript
+// astro.config.ts
+export default defineConfig({
+ experimental: {
+ liveContentCollections: true,
+ },
+});
+```
+
+Changes in Notion update in the browser within seconds during development.
+
+## Image Handling
+
+The loader downloads Notion images to a local directory, preventing broken image URLs when Notion's signed URLs expire:
+
+```typescript
+notionLoader({
+ imageSavePath: 'content/notion/images',
+ // Images get downloaded and served locally
+});
+```
+
+## Key Insight
+
+Notion works well for non-technical content editors. The structured database format maps cleanly to typed content collections. The main gotcha is Notion's rate limits—cache aggressively and use incremental sync for larger datasets.
diff --git a/src/content/til/opentelemetry-wide-events.md b/src/content/til/opentelemetry-wide-events.md
new file mode 100644
index 00000000..869adcbd
--- /dev/null
+++ b/src/content/til/opentelemetry-wide-events.md
@@ -0,0 +1,168 @@
+---
+title: 'OpenTelemetry: Structured Logging Beats console.log at Scale'
+date: 2026-01-03
+tags: ['opentelemetry', 'observability', 'logging', 'devops']
+description: 'Today I learned that wide events architecture captures full request context efficiently, making debugging production issues much faster.'
+draft: false
+---
+
+# OpenTelemetry: Structured Logging Beats console.log at Scale
+
+After drowning in unstructured logs, I discovered that OpenTelemetry's wide events pattern transforms debugging from archaeology into science.
+
+## The Problem with console.log
+
+```typescript
+// Scattered logs tell no story
+console.log('Processing request');
+console.log('User ID:', userId);
+console.log('Fetching data...');
+console.log('Data fetched:', data.length, 'items');
+console.log('Request complete');
+
+// In production logs, good luck correlating these!
+```
+
+## Wide Events Pattern
+
+One comprehensive event per request:
+
+```typescript
+// lib/telemetry/request-event.ts
+export class RequestEvent {
+ private data: Record = {};
+ private startTime: number;
+
+ constructor(requestId: string) {
+ this.startTime = performance.now();
+ this.set('requestId', requestId);
+ this.set('timestamp', new Date().toISOString());
+ }
+
+ set(key: string, value: unknown) {
+ this.data[key] = value;
+ return this;
+ }
+
+ setUser(user: { id: string; tier: string }) {
+ this.set('userId', user.id);
+ this.set('userTier', user.tier);
+ return this;
+ }
+
+ setError(error: Error) {
+ this.set('error', error.message);
+ this.set('errorStack', error.stack);
+ this.set('errorType', error.constructor.name);
+ return this;
+ }
+
+ send() {
+ this.set('durationMs', performance.now() - this.startTime);
+ telemetry.emit('request', this.data);
+ }
+}
+```
+
+## Usage in Request Handlers
+
+```typescript
+// server/api/dashboard.ts
+export async function getDashboard(req: Request) {
+ const event = new RequestEvent(req.id);
+
+ try {
+ const user = await getUser(req);
+ event.setUser(user);
+
+ const events = await fetchEvents(user.id);
+ event.set('eventsCount', events.length);
+
+ const lunar = await fetchLunarData(user.timezone);
+ event.set('lunarPhase', lunar.phase);
+
+ event.set('status', 'success');
+ return { events, lunar };
+ } catch (error) {
+ event.setError(error as Error);
+ event.set('status', 'error');
+ throw error;
+ } finally {
+ event.send();
+ }
+}
+```
+
+## tRPC Middleware Integration
+
+```typescript
+// server/trpc.ts
+const telemetryMiddleware = t.middleware(async ({ ctx, next, path }) => {
+ const event = new RequestEvent(ctx.requestId);
+ event.set('procedure', path);
+ event.set('type', 'trpc');
+
+ if (ctx.user) {
+ event.setUser(ctx.user);
+ }
+
+ try {
+ const result = await next();
+ event.set('status', 'success');
+ return result;
+ } catch (error) {
+ event.setError(error as Error);
+ event.set('status', 'error');
+ throw error;
+ } finally {
+ event.send();
+ }
+});
+```
+
+## Server-Side Proxy for Analytics
+
+```typescript
+// routes/api/telemetry.ts
+export async function POST(req: Request) {
+ const events = await req.json();
+
+ // Forward to Axiom/Datadog/etc
+ await fetch('https://api.axiom.co/v1/datasets/events/ingest', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${process.env.AXIOM_TOKEN}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(events),
+ });
+
+ return new Response('OK');
+}
+```
+
+## Querying Wide Events
+
+With wide events, queries become powerful:
+
+```sql
+-- Find slow requests for premium users
+SELECT * FROM events
+WHERE durationMs > 1000
+ AND userTier = 'premium'
+ AND timestamp > now() - interval '1 hour'
+
+-- Error rate by procedure
+SELECT
+ procedure,
+ count(*) FILTER (WHERE status = 'error') * 100.0 / count(*) as error_rate
+FROM events
+GROUP BY procedure
+ORDER BY error_rate DESC
+```
+
+## Key Insight
+
+Wide events capture full request context in a single log entry. Instead of hunting through scattered logs, you query a structured dataset. Every request becomes a self-contained debugging unit.
+
+The pattern: accumulate context throughout request processing, emit once at the end. This is dramatically more useful than breadcrumb-style logging.
diff --git a/src/content/til/react-email-templates.md b/src/content/til/react-email-templates.md
new file mode 100644
index 00000000..a8e7d912
--- /dev/null
+++ b/src/content/til/react-email-templates.md
@@ -0,0 +1,154 @@
+---
+title: 'React Email: Beautiful Emails with Component Architecture'
+date: 2025-08-27
+tags: ['email', 'react', 'templates', 'resend']
+description: 'Today I learned that email HTML is its own special hell, but React Email makes it manageable with familiar component patterns.'
+draft: false
+---
+
+# React Email: Beautiful Emails with Component Architecture
+
+Email HTML is notoriously difficult—tables for layout, inline styles, and inconsistent client support. React Email abstracts this nightmare into familiar components.
+
+## The Problem with Email HTML
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+## React Email Components
+
+```tsx
+import { Html, Head, Body, Container, Section, Text, Button, Img, Hr } from '@react-email/components';
+
+export function WeeklyDigestEmail({ userName, events, weekStart }: WeeklyDigestProps) {
+ return (
+
+
+
+
+
+
+
+ Hi {userName},
+ Here's your cosmic forecast for the week of {weekStart}.
+
+
+
+
+ {events.map(event => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+```
+
+## Reusable Components
+
+```tsx
+// components/EventCard.tsx
+function EventCard({ event }: { event: Event }) {
+ return (
+
+ {formatDate(event.date)}
+
+ {event.emoji} {event.title}
+
+ {event.description}
+
+ );
+}
+```
+
+## Tailwind Integration
+
+React Email supports Tailwind CSS:
+
+```tsx
+import { Tailwind } from '@react-email/components';
+
+export function Email() {
+ return (
+
+
+
+
+ Welcome!
+
+
+
+
+ );
+}
+```
+
+## Preview and Testing
+
+```bash
+# Run the preview server
+npx react-email dev
+
+# Or export to HTML
+npx react-email export
+```
+
+## Integration with Resend
+
+```typescript
+import { Resend } from 'resend';
+import { WeeklyDigestEmail } from './emails/weekly-digest';
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+
+await resend.emails.send({
+ from: 'Cosmic Updates ',
+ to: user.email,
+ subject: `Your Week Ahead: ${weekStart}`,
+ react: WeeklyDigestEmail({
+ userName: user.name,
+ events,
+ weekStart,
+ }),
+});
+```
+
+## Key Insight
+
+Email delivery needs tracking—you need to know if messages arrive. Set up webhooks to track delivery status:
+
+```typescript
+// Handle Resend webhooks
+export async function handleEmailWebhook(event: ResendEvent) {
+ switch (event.type) {
+ case 'email.delivered':
+ await markEmailDelivered(event.data.email_id);
+ break;
+ case 'email.bounced':
+ await handleBounce(event.data.email_id, event.data.reason);
+ break;
+ case 'email.complained':
+ await handleSpamComplaint(event.data.email_id);
+ break;
+ }
+}
+```
+
+React Email makes email development feel like regular React development. The component model means you can build a design system for emails just like you would for your web app.
diff --git a/src/content/til/solid-primitives-storage.md b/src/content/til/solid-primitives-storage.md
new file mode 100644
index 00000000..9f9a5b0a
--- /dev/null
+++ b/src/content/til/solid-primitives-storage.md
@@ -0,0 +1,152 @@
+---
+title: 'Solid Primitives: From Custom Utils to Battle-Tested Libraries'
+date: 2025-07-28
+tags: ['solidjs', 'storage', 'refactoring', 'libraries']
+description: "Today I learned that migrating to @solid-primitives/storage reduced my code by 75% while adding features I hadn't even thought of."
+draft: false
+---
+
+# Solid Primitives: From Custom Utils to Battle-Tested Libraries
+
+I spent days building custom localStorage utilities for my SolidJS app. Then I discovered @solid-primitives/storage and deleted 75% of my code.
+
+## My Custom Implementation
+
+```typescript
+// What I built (200+ lines)
+function createLocalStorage(key: string, defaultValue: T) {
+ const [value, setValue] = createSignal(defaultValue);
+
+ // Initial load
+ onMount(() => {
+ const stored = localStorage.getItem(key);
+ if (stored) {
+ try {
+ setValue(JSON.parse(stored));
+ } catch {
+ setValue(defaultValue);
+ }
+ }
+ });
+
+ // Persist on change
+ createEffect(() => {
+ localStorage.setItem(key, JSON.stringify(value()));
+ });
+
+ return [value, setValue] as const;
+}
+```
+
+Problems I hadn't solved:
+
+- SSR hydration mismatches
+- Cross-tab synchronization
+- Serialization of complex types
+- Migration between schema versions
+
+## The Solid Primitives Way
+
+```typescript
+import { makePersisted } from '@solid-primitives/storage';
+
+// 3 lines instead of 200
+const [preferences, setPreferences] = makePersisted(createSignal(defaultPreferences), {
+ name: 'user-preferences',
+});
+```
+
+## Features I Got for Free
+
+### SSR-Safe Hydration
+
+```typescript
+// Automatically handles SSR
+const [theme, setTheme] = makePersisted(createSignal<'light' | 'dark'>('light'), {
+ name: 'theme',
+ // No hydration mismatch!
+});
+```
+
+### Cross-Tab Synchronization
+
+```typescript
+const [settings, setSettings] = makePersisted(createSignal(defaultSettings), {
+ name: 'settings',
+ sync: true, // Changes sync across tabs!
+});
+```
+
+### Custom Serialization
+
+```typescript
+import { makePersisted } from '@solid-primitives/storage';
+
+const [birthDate, setBirthDate] = makePersisted(createSignal(null), {
+ name: 'birth-date',
+ serialize: date => date?.toISOString() ?? '',
+ deserialize: str => (str ? new Date(str) : null),
+});
+```
+
+### Storage Backends
+
+```typescript
+// SessionStorage
+const [session, setSession] = makePersisted(createSignal({}), {
+ name: 'session',
+ storage: sessionStorage,
+});
+
+// Custom async storage (IndexedDB, etc.)
+const [data, setData] = makePersisted(createSignal({}), {
+ name: 'large-data',
+ storage: indexedDBStorage,
+});
+```
+
+## Migration Pattern
+
+When your schema changes:
+
+```typescript
+const CURRENT_VERSION = 2;
+
+const [rawData, setRawData] = makePersisted(createSignal(null), { name: 'app-data' });
+
+// Derived signal with migration
+const data = createMemo(() => {
+ const raw = rawData();
+ if (!raw) return defaultData;
+
+ if (raw.version < CURRENT_VERSION) {
+ const migrated = migrateData(raw);
+ setRawData(migrated);
+ return migrated.data;
+ }
+
+ return raw.data;
+});
+```
+
+## Other Solid Primitives Worth Knowing
+
+```typescript
+// Debounced signals
+import { createDebounced } from '@solid-primitives/scheduled';
+const debouncedSearch = createDebounced(searchQuery, 300);
+
+// Media queries
+import { createMediaQuery } from '@solid-primitives/media';
+const isDesktop = createMediaQuery('(min-width: 1024px)');
+
+// Keyboard shortcuts
+import { createShortcut } from '@solid-primitives/keyboard';
+createShortcut(['Control', 'k'], () => openSearch());
+```
+
+## Key Insight
+
+Don't reinvent wheels. Well-maintained primitives libraries have solved edge cases you haven't encountered yet. The 75% code reduction wasn't the best part—it was eliminating bugs I didn't know I had.
+
+Rule of thumb: if you're building a "utility" that seems generally useful, search for an existing solution first. The SolidJS ecosystem has excellent primitives for most common needs.
diff --git a/src/content/til/stripe-webhook-processing.md b/src/content/til/stripe-webhook-processing.md
new file mode 100644
index 00000000..a85b1a13
--- /dev/null
+++ b/src/content/til/stripe-webhook-processing.md
@@ -0,0 +1,132 @@
+---
+title: 'Stripe Webhooks: Every Payment Event Tells a Story'
+date: 2025-11-08
+tags: ['stripe', 'payments', 'webhooks', 'backend']
+description: 'Today I learned that idempotent webhook processing prevents duplicate charges and that subscription status handling is far more complex than active/inactive.'
+draft: false
+---
+
+# Stripe Webhooks: Every Payment Event Tells a Story
+
+Implementing Stripe subscriptions taught me that payment integrations require meticulous error handling and that subscription status is definitely not binary.
+
+## The Webhook Event Flow
+
+Stripe sends a cascade of events during the subscription lifecycle:
+
+```typescript
+// Key events to handle
+const SUBSCRIPTION_EVENTS = [
+ 'customer.subscription.created',
+ 'customer.subscription.updated',
+ 'customer.subscription.deleted',
+ 'customer.subscription.paused',
+ 'customer.subscription.resumed',
+ 'invoice.payment_succeeded',
+ 'invoice.payment_failed',
+ 'checkout.session.completed',
+];
+```
+
+## Idempotent Processing Is Critical
+
+The same webhook might be sent multiple times. Handle it:
+
+```typescript
+// convex/stripe.ts
+export const handleWebhook = internalAction({
+ args: { event: v.any() },
+ handler: async (ctx, { event }) => {
+ // Check if we've already processed this event
+ const existing = await ctx.runQuery(internal.stripe.getProcessedEvent, {
+ eventId: event.id,
+ });
+
+ if (existing) {
+ console.log(`Event ${event.id} already processed, skipping`);
+ return;
+ }
+
+ // Process the event
+ await processEvent(ctx, event);
+
+ // Mark as processed
+ await ctx.runMutation(internal.stripe.markEventProcessed, {
+ eventId: event.id,
+ processedAt: Date.now(),
+ });
+ },
+});
+```
+
+## Subscription Status Mapping
+
+Stripe's subscription statuses need mapping to your app's logic:
+
+```typescript
+type StripeStatus =
+ | 'active'
+ | 'past_due'
+ | 'unpaid'
+ | 'canceled'
+ | 'incomplete'
+ | 'incomplete_expired'
+ | 'trialing'
+ | 'paused';
+
+function mapSubscriptionStatus(stripeStatus: StripeStatus): AppStatus {
+ switch (stripeStatus) {
+ case 'active':
+ case 'trialing':
+ return 'active';
+ case 'past_due':
+ return 'grace_period'; // Still give access, but warn user
+ case 'canceled':
+ case 'unpaid':
+ case 'incomplete_expired':
+ return 'inactive';
+ case 'incomplete':
+ case 'paused':
+ return 'pending';
+ }
+}
+```
+
+## Checkout Success Flow
+
+The moment after payment is crucial for user confidence:
+
+```typescript
+// src/routes/checkout/success.tsx
+export default function CheckoutSuccess() {
+ const [searchParams] = useSearchParams();
+ const sessionId = searchParams.get('session_id');
+
+ const syncStatus = createQuery(() => ({
+ queryKey: ['checkout', 'sync', sessionId],
+ queryFn: async () => {
+ // Poll until subscription is synced
+ const maxAttempts = 10;
+ for (let i = 0; i < maxAttempts; i++) {
+ const sub = await checkSubscriptionStatus(sessionId);
+ if (sub.status === 'active') return sub;
+ await delay(1000);
+ }
+ throw new Error('Subscription sync timeout');
+ },
+ retry: false,
+ }));
+
+ return (
+ }>
+
+
+ );
+}
+```
+
+## Key Insight
+
+Never trust client-side payment confirmation. Always verify via webhooks. The checkout session completing doesn't mean the payment succeeded—you need `invoice.payment_succeeded` for that.
+
+Also, handle `invoice.payment_failed` gracefully. Users with payment issues are still your customers—give them a path to fix it rather than immediately cutting access.
diff --git a/src/content/til/suspense-error-boundaries.md b/src/content/til/suspense-error-boundaries.md
new file mode 100644
index 00000000..36714579
--- /dev/null
+++ b/src/content/til/suspense-error-boundaries.md
@@ -0,0 +1,174 @@
+---
+title: 'Suspense Boundaries: Isolating Failures in React/Solid Apps'
+date: 2026-01-05
+tags: ['react', 'solidjs', 'error-handling', 'architecture']
+description: 'Today I learned that granular Suspense and Error Boundaries prevent cascading failures and dramatically improve perceived performance.'
+draft: false
+---
+
+# Suspense Boundaries: Isolating Failures in React/Solid Apps
+
+A single failed API call shouldn't crash your entire dashboard. Granular Suspense and Error Boundaries create resilient UIs that degrade gracefully.
+
+## The Problem: Cascading Failures
+
+```tsx
+// One failing query kills everything
+function Dashboard() {
+ const user = useQuery(userQuery); // ✓ Works
+ const events = useQuery(eventsQuery); // ✗ Server error
+ const lunar = useQuery(lunarQuery); // Never even tries
+
+ // User sees: blank screen or error page
+ return ...;
+}
+```
+
+## The Solution: Granular Boundaries
+
+```tsx
+function Dashboard() {
+ return (
+
+ }>
+ }>
+
+
+
+
+ }>
+ }>
+
+
+
+
+ }>
+ }>
+
+
+
+
+ );
+}
+```
+
+Now if events fail, users still see their profile and lunar data.
+
+## Error Boundary with Retry
+
+```tsx
+function SectionErrorBoundary({ children, fallback, sectionName }: Props) {
+ const [error, setError] = createSignal(null);
+
+ const retry = () => {
+ setError(null);
+ // Force re-render of children
+ };
+
+ return (
+ (
+ {
+ reset();
+ retry();
+ }}
+ />
+ )}
+ >
+ {children}
+
+ );
+}
+```
+
+## Skeleton Components for Perceived Performance
+
+```tsx
+// Match the exact layout of the real component
+function EventsSkeleton() {
+ return (
+
+
+
+
+ }>
+ {children}
+
+
+ );
+}
+```
+
+## Key Insight
+
+Think of Suspense boundaries as "loading zones" and Error boundaries as "blast shields." Place them strategically:
+
+1. Around independent data fetches
+2. Around third-party components
+3. Around user-generated content rendering
+
+The goal: a failure in one zone shouldn't affect others. Users should always see _something_—partial data is better than a blank screen.
diff --git a/src/content/til/tanstack-query-server-state.md b/src/content/til/tanstack-query-server-state.md
new file mode 100644
index 00000000..d843bb8f
--- /dev/null
+++ b/src/content/til/tanstack-query-server-state.md
@@ -0,0 +1,116 @@
+---
+title: 'TanStack Query: Server State Is Not Client State'
+date: 2025-08-02
+tags: ['tanstack-query', 'react', 'solidjs', 'caching', 'performance']
+description: 'Today I learned that TanStack Query handles caching, revalidation, and background updates better than any custom solution I could write.'
+draft: false
+---
+
+# TanStack Query: Server State Is Not Client State
+
+I spent weeks building custom data fetching utilities before discovering that TanStack Query handles every edge case I hadn't even considered. The mental model shift was profound.
+
+## The Old Way: Custom Fetching
+
+```typescript
+// My custom hook (simplified)
+function useData(url: string) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ fetch(url)
+ .then(res => res.json())
+ .then(setData)
+ .catch(setError)
+ .finally(() => setLoading(false));
+ }, [url]);
+
+ return { data, loading, error };
+}
+```
+
+This misses: caching, background refetching, stale-while-revalidate, deduplication, retry logic, prefetching, and more.
+
+## The TanStack Query Way
+
+```typescript
+import { createQuery } from '@tanstack/solid-query';
+
+const weekEvents = createQuery(() => ({
+ queryKey: ['events', 'week', weekStart.toString()],
+ queryFn: () => fetchWeekEvents(weekStart),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ placeholderData: previousData, // Show previous week while loading
+}));
+```
+
+## Centralized Query Keys
+
+The game-changer was centralizing query keys:
+
+```typescript
+// src/lib/query-keys.ts
+export const queryKeys = {
+ events: {
+ all: ['events'] as const,
+ week: (date: string) => ['events', 'week', date] as const,
+ month: (date: string) => ['events', 'month', date] as const,
+ },
+ user: {
+ profile: (id: string) => ['user', 'profile', id] as const,
+ preferences: (id: string) => ['user', 'preferences', id] as const,
+ },
+};
+
+// Usage
+const events = createQuery(() => ({
+ queryKey: queryKeys.events.week(weekStart),
+ queryFn: () => fetchWeekEvents(weekStart),
+}));
+
+// Invalidate all event queries
+queryClient.invalidateQueries({ queryKey: queryKeys.events.all });
+```
+
+## Prefetching Adjacent Data
+
+For a week-view calendar, prefetch next/previous weeks:
+
+```typescript
+// Prefetch adjacent weeks when current week loads
+createEffect(() => {
+ if (weekEvents.data) {
+ const nextWeek = addDays(weekStart, 7);
+ const prevWeek = addDays(weekStart, -7);
+
+ queryClient.prefetchQuery({
+ queryKey: queryKeys.events.week(nextWeek.toString()),
+ queryFn: () => fetchWeekEvents(nextWeek),
+ });
+
+ queryClient.prefetchQuery({
+ queryKey: queryKeys.events.week(prevWeek.toString()),
+ queryFn: () => fetchWeekEvents(prevWeek),
+ });
+ }
+});
+```
+
+## placeholderData Prevents Loading Flashes
+
+```typescript
+const weekEvents = createQuery(() => ({
+ queryKey: queryKeys.events.week(weekStart),
+ queryFn: () => fetchWeekEvents(weekStart),
+ placeholderData: previousData => previousData, // Smooth transitions!
+}));
+```
+
+## Key Insight
+
+Server state and client state are fundamentally different. Server state is owned elsewhere, can become stale, and needs synchronization strategies. Client state (like form inputs or UI toggles) is local and immediate.
+
+TanStack Query handles server state; use signals/useState for client state. Don't mix them.
diff --git a/src/content/til/temporal-api-date-handling.md b/src/content/til/temporal-api-date-handling.md
new file mode 100644
index 00000000..9f5f8ba2
--- /dev/null
+++ b/src/content/til/temporal-api-date-handling.md
@@ -0,0 +1,74 @@
+---
+title: 'JavaScript Dates Are Broken—Temporal API Fixes Them'
+date: 2025-12-04
+tags: ['javascript', 'temporal', 'dates', 'typescript']
+description: 'Today I learned that the Temporal API eliminates entire categories of date bugs by providing timezone-aware, immutable date handling in JavaScript.'
+draft: false
+---
+
+# JavaScript Dates Are Broken—Temporal API Fixes Them
+
+While building timezone-aware features for a planning dashboard, I discovered that JavaScript's native `Date` object is fundamentally broken for real-world applications. The Temporal API is the solution I didn't know I needed.
+
+## The Problem with Native Dates
+
+JavaScript's `Date` object has several critical flaws:
+
+```javascript
+// Mutation madness
+const date = new Date('2025-12-04');
+date.setDate(date.getDate() + 1); // Mutates the original!
+
+// Timezone confusion
+new Date('2025-12-04').toISOString(); // Assumes UTC
+new Date('2025-12-04T00:00:00').toISOString(); // Assumes local time
+
+// Month indexing starts at 0
+new Date(2025, 12, 4); // Actually January 2026!
+```
+
+## Enter the Temporal API
+
+The Temporal API provides immutable, timezone-aware date handling:
+
+```typescript
+import { Temporal } from '@js-temporal/polyfill';
+
+// Immutable operations
+const date = Temporal.PlainDate.from('2025-12-04');
+const tomorrow = date.add({ days: 1 }); // Returns NEW date
+
+// Explicit timezone handling
+const zonedDateTime = Temporal.ZonedDateTime.from({
+ timeZone: 'America/New_York',
+ year: 2025,
+ month: 12,
+ day: 4,
+ hour: 10,
+});
+
+// Easy conversions
+const utc = zonedDateTime.toInstant();
+const localDate = zonedDateTime.toPlainDate();
+```
+
+## Real-World Application
+
+In my dashboard, I needed to show "today's events" correctly for users in different timezones:
+
+```typescript
+// Get today in user's timezone
+const userTimezone = 'America/Los_Angeles';
+const now = Temporal.Now.zonedDateTimeISO(userTimezone);
+const today = now.toPlainDate();
+
+// Compare dates without timezone confusion
+const eventDate = Temporal.PlainDate.from(event.date);
+const isToday = Temporal.PlainDate.compare(today, eventDate) === 0;
+```
+
+## Key Insight
+
+The key insight was implementing a `MidnightInvalidationHook` that uses Temporal to detect when the date changes in the user's timezone, then invalidates date-dependent queries. This eliminated the "wrong day" bugs that plagued the app.
+
+Test your app in different timezones—bugs hide there. The Temporal API makes timezone-aware code readable and correct by default.
diff --git a/src/content/til/timezone-aware-cron-jobs.md b/src/content/til/timezone-aware-cron-jobs.md
new file mode 100644
index 00000000..a0e89a71
--- /dev/null
+++ b/src/content/til/timezone-aware-cron-jobs.md
@@ -0,0 +1,178 @@
+---
+title: 'Timezone-Aware Cron Jobs: Scheduling Across the Globe'
+date: 2025-08-18
+tags: ['cron', 'scheduling', 'timezone', 'backend']
+description: 'Today I learned that scheduled tasks need to respect user timezones religiously, and cohort-based processing makes global scheduling manageable.'
+draft: false
+---
+
+# Timezone-Aware Cron Jobs: Scheduling Across the Globe
+
+Sending "Good morning!" emails at 3 AM destroys user trust. Today I learned how to build a scheduling system that respects user timezones.
+
+## The Naive Approach (Don't Do This)
+
+```typescript
+// Runs at 8 AM UTC for everyone
+cron.schedule('0 8 * * *', async () => {
+ const users = await getAllUsers();
+ for (const user of users) {
+ await sendDailyEmail(user); // 3 AM in New York, 4 PM in Tokyo
+ }
+});
+```
+
+## Cohort-Based Processing
+
+Group users by timezone and process cohorts at their local times:
+
+```typescript
+// convex/crons.ts
+export const cronJobs = cronJobs({
+ // Run every hour, process users whose local time is now 8 AM
+ dailyEmailDispatch: {
+ schedule: '0 * * * *', // Every hour on the hour
+ handler: dailyEmailHandler,
+ },
+});
+
+// convex/emails.ts
+export const dailyEmailHandler = internalAction({
+ handler: async ctx => {
+ const now = new Date();
+ const currentHourUTC = now.getUTCHours();
+
+ // Find timezones where it's currently 8 AM
+ const targetTimezones = getTimezonesForLocalHour(8, currentHourUTC);
+
+ // Get users in those timezones
+ const users = await ctx.runQuery(internal.users.getUsersByTimezones, {
+ timezones: targetTimezones,
+ });
+
+ // Send emails
+ for (const user of users) {
+ await ctx.runAction(internal.emails.sendDailyEmail, { userId: user._id });
+ }
+ },
+});
+```
+
+## Mapping UTC Hours to Timezones
+
+```typescript
+function getTimezonesForLocalHour(targetLocalHour: number, currentUTCHour: number): string[] {
+ // Calculate what UTC offset would make it targetLocalHour right now
+ // targetLocalHour = currentUTCHour + offset
+ // offset = targetLocalHour - currentUTCHour
+ let targetOffset = targetLocalHour - currentUTCHour;
+
+ // Normalize to valid offset range (-12 to +14)
+ if (targetOffset < -12) targetOffset += 24;
+ if (targetOffset > 14) targetOffset -= 24;
+
+ // Map offsets to timezone identifiers
+ return TIMEZONE_OFFSET_MAP[targetOffset] || [];
+}
+
+// Comprehensive mapping
+const TIMEZONE_OFFSET_MAP: Record = {
+ [-5]: ['America/New_York', 'America/Toronto'],
+ [-8]: ['America/Los_Angeles', 'America/Vancouver'],
+ [0]: ['Europe/London', 'UTC'],
+ [1]: ['Europe/Paris', 'Europe/Berlin'],
+ [9]: ['Asia/Tokyo', 'Asia/Seoul'],
+ // ... etc
+};
+```
+
+## User Timezone Storage
+
+```typescript
+// Store user's timezone preference
+const userSchema = defineTable({
+ email: v.string(),
+ preferences: v.object({
+ timezone: v.string(), // e.g., 'America/New_York'
+ emailTime: v.number(), // Preferred hour (0-23)
+ emailFrequency: v.array(v.string()), // ['daily', 'weekly']
+ }),
+});
+```
+
+## Weekly/Monthly Scheduling
+
+```typescript
+export const weeklyEmailHandler = internalAction({
+ handler: async ctx => {
+ const now = new Date();
+ const dayOfWeek = now.getUTCDay();
+
+ // Only run on Sundays (or user's preferred day)
+ if (dayOfWeek !== 0) return;
+
+ const currentHourUTC = now.getUTCHours();
+ const targetTimezones = getTimezonesForLocalHour(9, currentHourUTC);
+
+ const users = await ctx.runQuery(internal.users.getUsersForWeekly, {
+ timezones: targetTimezones,
+ });
+
+ for (const user of users) {
+ await ctx.runAction(internal.emails.sendWeeklyDigest, {
+ userId: user._id,
+ });
+ }
+ },
+});
+```
+
+## Handling DST Transitions
+
+Daylight Saving Time causes timezone offsets to change:
+
+```typescript
+import { Temporal } from '@js-temporal/polyfill';
+
+function getTimezoneOffset(timezone: string, date: Date): number {
+ const instant = Temporal.Instant.from(date.toISOString());
+ const zonedDateTime = instant.toZonedDateTimeISO(timezone);
+
+ // Returns offset in minutes
+ return zonedDateTime.offsetNanoseconds / 1_000_000_000 / 60;
+}
+
+// Use Temporal for DST-aware calculations
+function getLocalHour(utcHour: number, timezone: string): number {
+ const now = Temporal.Now.instant();
+ const zoned = now.toZonedDateTimeISO(timezone);
+ return zoned.hour;
+}
+```
+
+## Notification Batching
+
+Prevent API rate limits with batching:
+
+```typescript
+async function processEmailCohort(users: User[]) {
+ const BATCH_SIZE = 50;
+ const BATCH_DELAY_MS = 1000;
+
+ for (let i = 0; i < users.length; i += BATCH_SIZE) {
+ const batch = users.slice(i, i + BATCH_SIZE);
+
+ await Promise.all(batch.map(user => sendEmail(user)));
+
+ if (i + BATCH_SIZE < users.length) {
+ await delay(BATCH_DELAY_MS);
+ }
+ }
+}
+```
+
+## Key Insight
+
+Timezone handling is not optional—it's a core feature. Users notice when emails arrive at wrong times, even if they can't articulate what's wrong. The cohort-based approach scales well: instead of individual timers per user, you process groups hourly.
+
+Store timezone as IANA identifiers (like 'America/New_York'), not offsets. Offsets change with DST; identifiers handle this automatically.
diff --git a/src/content/til/view-transitions-api.md b/src/content/til/view-transitions-api.md
new file mode 100644
index 00000000..659b355e
--- /dev/null
+++ b/src/content/til/view-transitions-api.md
@@ -0,0 +1,137 @@
+---
+title: 'View Transitions API: Smooth Navigation Feels Native'
+date: 2026-01-04
+tags: ['css', 'animation', 'ux', 'browser-apis']
+description: 'Today I learned that the View Transitions API eliminates the jarring page refresh feel and makes web apps feel as smooth as native apps.'
+draft: false
+---
+
+# View Transitions API: Smooth Navigation Feels Native
+
+After years of complex JavaScript animation libraries, the View Transitions API finally makes page transitions trivial. The result feels remarkably native.
+
+## The Problem
+
+Traditional SPAs feel janky because:
+
+- Content pops in abruptly
+- No visual continuity between pages
+- Layout shift during hydration
+
+## The Simple Solution
+
+```typescript
+// ViewTransitionLink.tsx
+export function ViewTransitionLink(props: { href: string; children: any }) {
+ const navigate = useNavigate();
+
+ const handleClick = (e: MouseEvent) => {
+ e.preventDefault();
+
+ if (!document.startViewTransition) {
+ // Fallback for unsupported browsers
+ navigate(props.href);
+ return;
+ }
+
+ document.startViewTransition(() => {
+ navigate(props.href);
+ });
+ };
+
+ return (
+
+ {props.children}
+
+ );
+}
+```
+
+## CSS Customization
+
+The magic is in the CSS:
+
+```css
+/* Default crossfade */
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation-duration: 0.3s;
+}
+
+/* Slide effect for specific elements */
+::view-transition-old(main-content) {
+ animation: slide-out 0.3s ease-out;
+}
+
+::view-transition-new(main-content) {
+ animation: slide-in 0.3s ease-out;
+}
+
+@keyframes slide-out {
+ to {
+ transform: translateX(-100%);
+ opacity: 0;
+ }
+}
+
+@keyframes slide-in {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+}
+```
+
+## Named Transitions for Shared Elements
+
+The real power: morphing elements between pages:
+
+```css
+/* Give elements a shared view-transition-name */
+.card-image {
+ view-transition-name: hero-image;
+}
+
+/* On the detail page */
+.detail-hero {
+ view-transition-name: hero-image;
+}
+```
+
+The browser automatically animates the element from its position on the list page to its position on the detail page. It's like magic.
+
+## Handling Loading States
+
+Combine with skeleton components for perceived performance:
+
+```typescript
+const PageWrapper = (props: { children: any }) => {
+ return (
+ }>
+
+ {props.children}
+
+
+ );
+};
+```
+
+## Browser Support
+
+```typescript
+// Feature detection
+const supportsViewTransitions = () => typeof document !== 'undefined' && 'startViewTransition' in document;
+
+// Progressive enhancement
+if (supportsViewTransitions()) {
+ document.startViewTransition(() => updateDOM());
+} else {
+ updateDOM();
+}
+```
+
+## Key Insight
+
+The View Transitions API works by taking screenshots of the old and new states, then animating between them. This means transitions work even with complex DOM changes—no need to carefully choreograph animations.
+
+The key insight: treat page transitions as first-class UX, not an afterthought. Users notice smooth navigation subconsciously; it makes the whole app feel more polished.
diff --git a/src/content/til/wcag-accessibility-audit.md b/src/content/til/wcag-accessibility-audit.md
new file mode 100644
index 00000000..968c12e1
--- /dev/null
+++ b/src/content/til/wcag-accessibility-audit.md
@@ -0,0 +1,150 @@
+---
+title: 'WCAG Accessibility: From 68% to 90% Compliance'
+date: 2025-06-22
+tags: ['accessibility', 'wcag', 'css', 'ux']
+description: 'Today I learned that accessibility compliance is achievable with systematic auditing and that most fixes are surprisingly simple.'
+draft: false
+---
+
+# WCAG Accessibility: From 68% to 90% Compliance
+
+Running a comprehensive WCAG 2.1 AA audit on my app was humbling. Starting at 68% compliance felt discouraging, but most fixes were straightforward.
+
+## The Audit Process
+
+I used a combination of tools:
+
+```bash
+# Automated scanning
+npx @axe-core/cli https://localhost:3000
+npx lighthouse https://localhost:3000 --only-categories=accessibility
+
+# Manual testing checklist
+# - Keyboard navigation
+# - Screen reader testing (VoiceOver/NVDA)
+# - Color contrast verification
+# - Focus indicators
+```
+
+## Most Common Issues
+
+### 1. Color Contrast (40% of issues)
+
+```css
+/* Before: Contrast ratio 3.2:1 (fails AA) */
+.muted-text {
+ color: #999999;
+}
+
+/* After: Contrast ratio 4.7:1 (passes AA) */
+.muted-text {
+ color: #757575;
+}
+```
+
+Use tools like WebAIM's contrast checker or the browser DevTools.
+
+### 2. Missing Focus Indicators
+
+```css
+/* Don't just remove outlines! */
+button:focus {
+ outline: none; /* Bad! */
+}
+
+/* Provide visible focus states */
+button:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 2px;
+}
+```
+
+### 3. Form Labels
+
+```html
+
+Email
+
+
+
+
+
+
+
+Email address
+
+```
+
+### 4. Skip Links
+
+```html
+
+ Skip to main content
+
+
+```
+
+### 5. ARIA Labels for Icon Buttons
+
+```html
+
+
+
+
+
+```
+
+## Testing with Real Users
+
+The most valuable insight came from testing with a screen reader:
+
+```typescript
+// What I thought was fine
+
Click me
+
+// What screen reader users experience:
+// Nothing. It's not focusable or announced.
+
+// The fix
+
+```
+
+## Semantic HTML Wins
+
+```html
+
+
+
Home
+
+
+
+
+```
+
+## Key Insight
+
+Accessibility isn't optional—it's how you serve all your users. The fixes that helped screen reader users also improved keyboard navigation, which benefits power users. Many accessibility improvements also boost SEO.
+
+Start with automated tools, but always test manually. Real screen reader testing revealed issues that no automated tool caught.