content: add Feather API (Hygo, Aug 2026 to present, Reno)
- Git-native HTTP client and Postman alternative in Rust with a Slint UI; Cloudflare Worker backend in TypeScript for auth and payments; Astro for marketing, payment, and sign-in pages - Memory charts are data-driven: Feather API shows Postman 1.5 GB vs 170 MB, FlyingBytes keeps 3 GB vs 50 MB - New Slint skill; skill group renamed "Web, mobile, and desktop"
1 file changed, +44 -31
+44-31src/pages/index.astro
| @@ -100,13 +100,27 @@ type Work = { | ||
| 100 | 100 | href?: string; |
| 101 | 101 | body: string[]; |
| 102 | 102 | stack?: string; |
| 103 | - membar?: boolean; | |
| 103 | + // Before and after memory chart: [label, value text]; ratio is after / before. | |
| 104 | + membar?: { caption: string; before: [string, string]; after: [string, string]; ratio: number }; | |
| 104 | 105 | places: PlaceId[]; |
| 105 | 106 | start?: [number, number]; // [year, month]; dated work draws on the timeline |
| 106 | 107 | end?: [number, number]; // omitted means present |
| 107 | 108 | }; |
| 108 | 109 | |
| 109 | 110 | const work: Work[] = [ |
| 111 | + { | |
| 112 | + name: 'Feather API', | |
| 113 | + era: 'hygo', | |
| 114 | + href: 'https://featherapi.com', | |
| 115 | + body: [ | |
| 116 | + 'A git-native HTTP client and Postman alternative, written in Rust with a Slint desktop UI. Where Postman runs 9 processes in 1.5 GB of RAM, Feather API runs in 170 MB.', | |
| 117 | + 'A Cloudflare Worker backend in TypeScript handles user authentication and payment processing, and Astro powers the marketing, payment, and sign-in pages.', | |
| 118 | + ], | |
| 119 | + membar: { caption: 'Memory in use', before: ['Postman', '1.5 GB'], after: ['Feather', '170 MB'], ratio: 170 / 1536 }, | |
| 120 | + stack: 'Rust, Slint, TypeScript, Astro, Cloudflare', | |
| 121 | + places: ['reno'], | |
| 122 | + start: [2026, 8], | |
| 123 | + }, | |
| 110 | 124 | { |
| 111 | 125 | name: 'Hygo CRM', |
| 112 | 126 | era: 'hygo', |
| @@ -187,7 +201,7 @@ const work: Work[] = [ | ||
| 187 | 201 | 'The website and admin panel now run on Astro and TypeScript. It also runs a self-hosted, custom Rust fork of Frankfurter for foreign exchange rates, plus self-hosted Grafana, Loki, and Promtail for logs and dashboards.', |
| 188 | 202 | ], |
| 189 | 203 | stack: 'Rust, Python, Django, TypeScript, Astro, PostgreSQL, Redis, Docker, NGINX, Certbot, Self-hosted, Observability, CI/CD, AWS', |
| 190 | - membar: true, | |
| 204 | + membar: { caption: 'Production memory (RSS)', before: ['Before', '3 GB'], after: ['After', '50 MB'], ratio: 50 / 3072 }, | |
| 191 | 205 | places: ['reno', 'grassvalley', 'knoxville', 'austin'], |
| 192 | 206 | start: [2022, 10], |
| 193 | 207 | }, |
| @@ -326,8 +340,8 @@ const skillGroups: SkillGroup[] = [ | ||
| 326 | 340 | ], |
| 327 | 341 | }, |
| 328 | 342 | { |
| 329 | - label: 'Web and mobile', | |
| 330 | - short: 'Web and mobile', | |
| 343 | + label: 'Web, mobile, and desktop', | |
| 344 | + short: 'Web, mobile, desktop', | |
| 331 | 345 | color: '#f5d547', |
| 332 | 346 | skills: [ |
| 333 | 347 | { name: 'Next.js', level: 7 }, |
| @@ -335,6 +349,7 @@ const skillGroups: SkillGroup[] = [ | ||
| 335 | 349 | { name: 'SEO', level: 7 }, |
| 336 | 350 | { name: 'Flutter', level: 6 }, |
| 337 | 351 | { name: 'Web3', level: 6 }, |
| 352 | + { name: 'Slint', level: 8 }, | |
| 338 | 353 | ], |
| 339 | 354 | }, |
| 340 | 355 | { |
| @@ -760,16 +775,16 @@ const pad = (n: number) => String(n).padStart(2, '0'); | ||
| 760 | 775 | </div> |
| 761 | 776 | {w.membar && ( |
| 762 | 777 | <figure class="mt-6 border border-rule bg-paper/60 p-4" data-membar> |
| 763 | - <figcaption class={`${mono} text-muted`}>Production memory (RSS)</figcaption> | |
| 764 | - <div class="mt-3 grid grid-cols-[3.75rem_minmax(0,1fr)_3.5rem] items-center gap-x-3 gap-y-2.5 font-mono text-xs tabular-nums"> | |
| 765 | - <span class="text-muted">BEFORE</span> | |
| 778 | + <figcaption class={`${mono} text-muted`}>{w.membar.caption}</figcaption> | |
| 779 | + <div class="mt-3 grid grid-cols-[4.5rem_minmax(0,1fr)_3.5rem] items-center gap-x-3 gap-y-2.5 font-mono text-xs tabular-nums"> | |
| 780 | + <span class="text-muted uppercase">{w.membar.before[0]}</span> | |
| 766 | 781 | <span class="block h-3 w-full bg-rock" /> |
| 767 | - <span class="text-right font-bold">3 GB</span> | |
| 768 | - <span class="text-muted">AFTER</span> | |
| 782 | + <span class="text-right font-bold">{w.membar.before[1]}</span> | |
| 783 | + <span class="text-muted uppercase">{w.membar.after[0]}</span> | |
| 769 | 784 | <span class="block h-3"> |
| 770 | - <span class="membar-after block h-full bg-vol" /> | |
| 785 | + <span class="membar-after block h-full bg-vol" style={`--to:${(w.membar.ratio * 100).toFixed(2)}%`} /> | |
| 771 | 786 | </span> |
| 772 | - <span class="text-right font-bold text-vol">50 MB</span> | |
| 787 | + <span class="text-right font-bold text-vol">{w.membar.after[1]}</span> | |
| 773 | 788 | </div> |
| 774 | 789 | </figure> |
| 775 | 790 | )} |
| @@ -1059,24 +1074,22 @@ const pad = (n: number) => String(n).padStart(2, '0'); | ||
| 1059 | 1074 | setTimeout(() => (copyBtn.textContent = 'Copy address'), 2000); |
| 1060 | 1075 | }); |
| 1061 | 1076 | |
| 1062 | - // Memory bar: the "after" bar starts at full width and shrinks to its | |
| 1063 | - // real size the first time it scrolls into view. | |
| 1064 | - const membar = document.querySelector<HTMLElement>('[data-membar]'); | |
| 1065 | - if (membar && !reduce && 'IntersectionObserver' in window) { | |
| 1066 | - const rect = membar.getBoundingClientRect(); | |
| 1067 | - if (rect.top > window.innerHeight) { | |
| 1068 | - membar.dataset.armed = ''; | |
| 1069 | - const io = new IntersectionObserver( | |
| 1070 | - ([entry]) => { | |
| 1071 | - if (!entry.isIntersecting) return; | |
| 1072 | - io.disconnect(); | |
| 1073 | - requestAnimationFrame(() => delete membar.dataset.armed); | |
| 1074 | - }, | |
| 1075 | - { threshold: 0.9 }, | |
| 1076 | - ); | |
| 1077 | - io.observe(membar); | |
| 1078 | - } | |
| 1079 | - } | |
| 1077 | + // Memory charts: the "after" bar starts at full width and shrinks to its | |
| 1078 | + // real size the first time each chart scrolls into view. | |
| 1079 | + document.querySelectorAll<HTMLElement>('[data-membar]').forEach((membar) => { | |
| 1080 | + if (reduce || !('IntersectionObserver' in window)) return; | |
| 1081 | + if (membar.getBoundingClientRect().top <= window.innerHeight) return; | |
| 1082 | + membar.dataset.armed = ''; | |
| 1083 | + const io = new IntersectionObserver( | |
| 1084 | + ([entry]) => { | |
| 1085 | + if (!entry.isIntersecting) return; | |
| 1086 | + io.disconnect(); | |
| 1087 | + requestAnimationFrame(() => delete membar.dataset.armed); | |
| 1088 | + }, | |
| 1089 | + { threshold: 0.9 }, | |
| 1090 | + ); | |
| 1091 | + io.observe(membar); | |
| 1092 | + }); | |
| 1080 | 1093 | </script> |
| 1081 | 1094 | |
| 1082 | 1095 | <style is:global> |
| @@ -1636,9 +1649,9 @@ const pad = (n: number) => String(n).padStart(2, '0'); | ||
| 1636 | 1649 | background: var(--color-vol); |
| 1637 | 1650 | } |
| 1638 | 1651 | |
| 1639 | - /* ---------- Memory bar: 50 MB of 3 GB (3072 MB) ---------- */ | |
| 1652 | + /* ---------- Memory charts: --to is after / before ---------- */ | |
| 1640 | 1653 | .membar-after { |
| 1641 | - width: 1.63%; | |
| 1654 | + width: var(--to); | |
| 1642 | 1655 | min-width: 3px; |
| 1643 | 1656 | transition: width 1.6s cubic-bezier(0.22, 0.8, 0.2, 1) 0.15s; |
| 1644 | 1657 | } |