irongit

feat: docked AI terminal that answers questions about Lane

- Profile data moves to src/data/profile.ts and also builds /llms.txt
- Worker POST /__ai/chat streams DeepSeek answers grounded in /llms.txt,
  gate-required and rate limited per IP; the key stays a worker secret
- Terminal docks bottom right in a Claude CLI style: streamed answers,
  slash commands with a completion menu, suggestions, history, Esc to
  interrupt, conversation kept for the tab session
- Header drops /GCS and the Console button
- Skill column fills the gap beside the work cards
huncholanehuncholaneauthored
parent 594c755commit 0f9e32897ddfd080e99b8dc1639a6da554e4296cBrowse files

7 files changed, +1325 -514

+446-0src/data/profile.ts
@@ -0,0 +1,446 @@
1+// Everything the site says about Lane, in one place. The portfolio page
2+// renders it, and profileText() turns it into the plain-text profile served at
3+// /llms.txt, which the AI console (worker /__ai/chat) uses as its source of truth.
4+
5+export const email = 'lane@henslee.me';
6+export const linkedin = 'https://www.linkedin.com/in/lanehenslee';
7+export const github = 'https://github.com/huncholane';
8+
9+export const intro = [
10+ 'I build backend systems in Rust and Python and run the cloud and security infrastructure they depend on. I have shipped production software for over seven years, starting with flight-analysis and manufacturing tooling for unmanned aircraft at Arcturus UAV and AeroVironment.',
11+ 'Today I run Hygo, an independent software practice for logistics, aviation, and trades clients. I am open to developer roles on defense programs.',
12+];
13+
14+// Places on the locations globe. `short` labels the pin; `name` is used by the console.
15+// Records below reference these ids in `places`.
16+export const places = [
17+ { id: 'knoxville', name: 'Knoxville, TN', short: 'Knoxville', lat: 35.9606, lon: -83.9207 },
18+ { id: 'austin', name: 'Austin, TX', short: 'Austin', lat: 30.2672, lon: -97.7431 },
19+ { id: 'reno', name: 'Reno, NV', short: 'Reno', lat: 39.5296, lon: -119.8138 },
20+ { id: 'grassvalley', name: 'Grass Valley, CA', short: 'Grass Valley', lat: 39.2191, lon: -121.0611 },
21+ { id: 'petaluma', name: 'Petaluma, CA', short: 'Petaluma', lat: 38.2324, lon: -122.6367 },
22+ { id: 'dubai', name: 'Dubai, UAE', short: 'Dubai', lat: 25.1972, lon: 55.2744 },
23+] as const;
24+export type PlaceId = (typeof places)[number]['id'];
25+
26+// Companies and school. Each is a sticky banner over the work done there.
27+export type OrgId = 'hygo' | 'av' | 'arcturus' | 'utk';
28+export type Org = {
29+ short: string; // timeline label
30+ title: string;
31+ org: string;
32+ logo: string; // path under /public
33+ dates: string;
34+ places: PlaceId[];
35+ start: [number, number]; // [year, month]
36+ end?: [number, number]; // omitted means present
37+};
38+export const orgs: Record<OrgId, Org> = {
39+ hygo: {
40+ short: 'Hygo',
41+ title: 'Founder and software engineer (formerly Ghost Systems)',
42+ org: 'Hygo, LLC',
43+ logo: '/logos/hygo.svg',
44+ dates: 'Mar 2022 to present',
45+ places: ['reno', 'grassvalley', 'knoxville', 'austin'],
46+ start: [2022, 3],
47+ },
48+ av: {
49+ short: 'AeroVironment',
50+ title: 'Software developer (formerly Arcturus UAV)',
51+ org: 'AeroVironment, Inc.',
52+ logo: '/logos/aerovironment.png',
53+ dates: 'May 2021 to Dec 2021',
54+ places: ['petaluma'],
55+ start: [2021, 5],
56+ end: [2021, 12],
57+ },
58+ arcturus: {
59+ short: 'Arcturus UAV',
60+ title: 'Software engineer, intern',
61+ org: 'Arcturus UAV',
62+ logo: '/logos/arcturus.png',
63+ dates: 'Dec 2018 to May 2021',
64+ places: ['knoxville', 'petaluma'],
65+ start: [2018, 12],
66+ end: [2021, 5],
67+ },
68+ utk: {
69+ // Start is assumed (Aug 2016); the resume only lists the 2021 graduation.
70+ short: 'UT Knoxville',
71+ title: 'B.S. Computer Science',
72+ org: 'University of Tennessee, Knoxville',
73+ logo: '/logos/power-t.svg',
74+ dates: '2021',
75+ places: ['knoxville'],
76+ start: [2016, 8],
77+ end: [2021, 5],
78+ },
79+};
80+
81+// Eras, newest first. Each era is its own card, and its orgs stack in the
82+// card's sticky banner. Education has its own panel.
83+export const eras: { id: string; orgs: OrgId[] }[] = [
84+ { id: 'hygo', orgs: ['hygo'] },
85+ { id: 'av', orgs: ['av'] },
86+ { id: 'arcturus', orgs: ['arcturus'] },
87+];
88+
89+export type Work = {
90+ name: string;
91+ era: string; // which era banner it sits under
92+ href?: string;
93+ body: string[];
94+ stack?: string;
95+ // Before and after memory chart: [label, value text]; ratio is after / before.
96+ membar?: { caption: string; before: [string, string]; after: [string, string]; ratio: number };
97+ liveStats?: string; // slug for /__stats/<slug>: live traffic strip from Rybbit
98+ places: PlaceId[];
99+ start?: [number, number]; // [year, month]; dated work draws on the timeline
100+ end?: [number, number]; // omitted means present
101+};
102+
103+export const work: Work[] = [
104+ {
105+ name: 'Feather API',
106+ era: 'hygo',
107+ href: 'https://featherapi.com',
108+ body: [
109+ '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.',
110+ 'A Cloudflare Worker backend in TypeScript, backed by a D1 database, handles user authentication and payment processing, and Astro powers the marketing, payment, and sign-in pages.',
111+ ],
112+ membar: { caption: 'Memory in use', before: ['Postman', '1.5 GB'], after: ['Feather', '170 MB'], ratio: 170 / 1536 },
113+ stack: 'Rust, Slint, TypeScript, Astro, Cloudflare, D1',
114+ places: ['reno'],
115+ start: [2026, 8],
116+ },
117+ {
118+ name: 'Rybbit analytics fork',
119+ era: 'hygo',
120+ body: [
121+ 'Rewrote the TypeScript backend of my Rybbit analytics fork in Rust with Axum in one day. The Rust server also serves the built Next.js frontend, so two Docker containers became one.',
122+ 'Memory dropped from 1 GB to 50 MB, which lets my other projects share the same analytics server. This site’s analytics run on it.',
123+ ],
124+ membar: { caption: 'Memory in use', before: ['Before', '1 GB'], after: ['After', '50 MB'], ratio: 50 / 1024 },
125+ stack: 'Rust, Axum, Next.js, TypeScript, Docker, Self-hosted',
126+ places: ['reno'],
127+ start: [2026, 8],
128+ end: [2026, 8],
129+ },
130+ {
131+ name: 'Hygo CRM',
132+ era: 'hygo',
133+ href: 'https://hygo.ai',
134+ body: [
135+ 'A multi-tenant CRM with a Rust backend on PostgreSQL and a TypeScript frontend, with live updates over WebSockets. The backend runs in 20 to 50 MB of memory, and Cloudflare caching plus Astro islands keep it fast and indexable.',
136+ 'It started on Convex, with TypeScript functions handling database access and WebSockets. I rewrote the backend in Rust to cut costs and give users a better experience.',
137+ 'A custom MCP server lets Claude and other LLM clients build enriched lead lists and audit local search citations, pulling from the Google Places API and custom scrapers for business information in each state and city. Lists sourced through it convert at roughly 33%. I also shipped a client-facing iOS companion app.',
138+ 'Calls work in the app and in the browser through the Telnyx API. Cloudflare transcribes them and DeepSeek handles the text work, which keeps AI costs dramatically lower.',
139+ 'I host the backend myself on Linux with Docker: a pre-push git hook builds it locally and deploys it as a Docker image through GitHub Container Registry.',
140+ ],
141+ stack: 'Rust, Axum, TypeScript, PostgreSQL, WebSockets, Convex, OAuth, Linux, Docker, NGINX, Certbot, Self-hosted, CI/CD, Cloudflare, Astro, MCP, Flutter, API integrations',
142+ places: ['reno'],
143+ start: [2026, 6],
144+ },
145+ {
146+ name: 'Ruby’s Air',
147+ era: 'hygo',
148+ href: 'https://rubysair.com',
149+ body: [
150+ 'Rebuilt this Little Rock HVAC company’s website in TypeScript and Astro as a Cloudflare Worker, then overhauled its local search presence. Monthly revenue grew from $5K to $50K, and the playbook now runs for other trades clients.',
151+ ],
152+ stack: 'TypeScript, Astro, Cloudflare, SEO',
153+ places: ['reno'],
154+ start: [2026, 6],
155+ },
156+ {
157+ name: 'SpiritFacts',
158+ era: 'hygo',
159+ href: 'https://spiritfacts.com',
160+ body: [
161+ 'An open chemistry reference for every spirit, wine, and beer, covering congeners, histamines, sulfites, and sugar, with a 1 to 10 Hangover Index for each category and brand.',
162+ 'Written in Astro and TypeScript, with Convex as the database service and TypeScript functions that run next to the database server.',
163+ ],
164+ stack: 'Astro, TypeScript, Convex, Cloudflare, SEO',
165+ liveStats: 'spiritfacts',
166+ places: ['reno'],
167+ start: [2026, 5],
168+ },
169+ {
170+ name: 'Domhaul',
171+ era: 'hygo',
172+ href: 'https://www.domhaul.com',
173+ body: [
174+ 'A domain discovery platform built with Next.js and TypeScript, with brandability scoring, live registrar pricing, and Stripe checkout. Launched on Product Hunt and listed as an MCP connector in Anthropic’s Claude connector directory.',
175+ 'Its data lives in Cloudflare D1, accessed from TypeScript, and Better Auth handles sign-in. I also self-host its Stalwart mail server on Linux with Docker.',
176+ ],
177+ stack: 'Next.js, TypeScript, D1, SEO, MCP, Cloudflare, API integrations, Linux, Docker, NGINX, Certbot, Self-hosted',
178+ places: ['reno'],
179+ start: [2026, 3],
180+ },
181+ {
182+ name: 'Freeport Freight Systems',
183+ era: 'hygo',
184+ href: 'https://freeportfreight.com',
185+ body: [
186+ 'I own Fortinet perimeter security and Microsoft 365 identity for this multi-site FedEx Ground contractor: firewall policy, licensing, account lifecycle, and a zero-downtime DNS and email migration to Cloudflare.',
187+ 'I also rebuilt their website in Astro and TypeScript, bringing in 4x the application rate compared to LinkNow Media.',
188+ ],
189+ stack: 'TypeScript, Astro, FortiGate, Microsoft 365, Cloudflare DNS',
190+ places: ['reno'],
191+ start: [2025, 10],
192+ },
193+ {
194+ name: 'ForexNinja',
195+ era: 'hygo',
196+ body: [
197+ 'A Django authentication system for the ForexNinja trading bot. The bot is written in MQL and connects to the backend through C, and license purchases run through SendOwl, integrated with Django.',
198+ 'I also built the public site on WordPress, wrote its Discord bot integrations in Java, and delivered the contract ahead of schedule.',
199+ ],
200+ stack: 'Python, Django, MQL, C, Java, Docker, NGINX, Certbot, API integrations, WordPress',
201+ places: ['grassvalley'],
202+ start: [2023, 6],
203+ end: [2023, 7],
204+ },
205+ {
206+ name: 'FlyingBytes',
207+ era: 'hygo',
208+ body: [
209+ 'A Django and AWS pipeline for a FedEx contractor that parses sensitive employee PDFs through a Rust regex engine into RDS. It serves 10,000 requests a day at about 10 times the throughput of the process it replaced.',
210+ 'I later rewrote the platform in Rust and took over its production operations, holding incident response under 30 minutes. It runs on EC2, RDS, S3, and Docker. Deploys started on git-hook pipelines, which raised deployment frequency by 50%, and now build on AWS CodeBuild, which builds natively for its ARM servers and is faster.',
211+ '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.',
212+ 'Backend apps post their errors to Slack, and an ingestion endpoint forwards iOS app errors to Slack too.',
213+ ],
214+ stack: 'Rust, Python, Django, TypeScript, Astro, PostgreSQL, Redis, Docker, NGINX, Certbot, Self-hosted, Observability, CI/CD, API integrations, AWS',
215+ membar: { caption: 'Production memory (RSS)', before: ['Before', '3 GB'], after: ['After', '50 MB'], ratio: 50 / 3072 },
216+ places: ['reno', 'grassvalley', 'knoxville', 'austin'],
217+ start: [2022, 10],
218+ },
219+ {
220+ name: 'Ghost Systems',
221+ era: 'hygo',
222+ body: [
223+ 'A Python arbitrage bot for the Everdome token that traded the price gap between OKX and a decentralized exchange. KuCoin bridged USDT and BNB so the bot could rebalance when needed.',
224+ 'A Django admin panel on PostgreSQL ran the bot, and Discord notifications sent trade alerts. Ghost Systems was Hygo’s original name.',
225+ ],
226+ stack: 'Python, Django, PostgreSQL, Web3, Docker, NGINX, Certbot, API integrations',
227+ places: ['grassvalley'],
228+ start: [2022, 2],
229+ end: [2022, 11],
230+ },
231+ {
232+ name: 'HygoFit',
233+ era: 'hygo',
234+ href: 'https://hygofit.com',
235+ body: [
236+ 'An iOS training tracker built in Flutter that computes per-set volume, vertical load, and estimated one-rep max. Convex runs the backend, with Google and Apple sign-in over OAuth.',
237+ ],
238+ stack: 'Flutter, Convex, OAuth, iOS',
239+ places: ['reno'],
240+ start: [2026, 6],
241+ },
242+ {
243+ name: 'Alignerr (contract)',
244+ era: 'hygo',
245+ body: [
246+ 'Wrote and reviewed Python training data for frontier model alignment, holding a 70% acceptance rate against a review bar most contributors did not clear.',
247+ ],
248+ stack: 'Python',
249+ places: ['reno'],
250+ start: [2026, 2],
251+ end: [2026, 2],
252+ },
253+ {
254+ name: 'RFID production tracking',
255+ era: 'av',
256+ body: [
257+ 'A Python and Flask system that captures RFID scans into FileMaker to track parts and tooling across unmanned aircraft production.',
258+ 'It automated 2,000+ daily scans across 12 manufacturing sites, cutting manual data entry by 95% and improving inventory accuracy by 30%, and went live with under one hour of line downtime.',
259+ ],
260+ stack: 'Python, Flask, FileMaker, RFID',
261+ places: ['petaluma'],
262+ start: [2021, 5],
263+ end: [2021, 12],
264+ },
265+ {
266+ name: 'Flight-analysis tooling',
267+ era: 'arcturus',
268+ body: ['Python tooling for UAV flight test data that made failure analysis 12x more efficient.'],
269+ stack: 'Python',
270+ places: ['knoxville', 'petaluma'],
271+ start: [2018, 12],
272+ end: [2021, 5],
273+ },
274+ {
275+ name: 'Production scheduling',
276+ era: 'arcturus',
277+ body: ['Replaced a manual Excel production schedule with a Python scheduling tool, reducing scheduling time by over 90%.'],
278+ stack: 'Python, Excel',
279+ places: ['knoxville', 'petaluma'],
280+ start: [2018, 12],
281+ end: [2021, 5],
282+ },
283+];
284+
285+// Sort key for newest-first ordering; undated work sorts last.
286+export const startNum = (w: Work) => (w.start ? w.start[0] + (w.start[1] - 1) / 12 : -1);
287+
288+// Skills bubble map. `level` (1 to 10) sets bubble size; `find` lists extra
289+// terms that count as using the skill when matching the work below.
290+// Colors follow cockpit display conventions: amber, white, green, cyan, magenta.
291+export type Skill = { name: string; level: number; find?: string[] };
292+export type SkillGroup = { label: string; short: string; color: string; skills: Skill[] };
293+export const skillGroups: SkillGroup[] = [
294+ {
295+ label: 'Languages',
296+ short: 'Languages',
297+ color: '#ff8200',
298+ skills: [
299+ { name: 'Rust', level: 10 },
300+ { name: 'Python', level: 10 },
301+ { name: 'TypeScript', level: 8 },
302+ { name: 'SQL', level: 8, find: ['PostgreSQL', 'MySQL', 'SQLite', 'RDS'] },
303+ { name: 'JavaScript', level: 7 },
304+ { name: 'Bash', level: 6 },
305+ { name: 'Java', level: 5 },
306+ { name: 'C++', level: 4 },
307+ { name: 'C', level: 4 },
308+ ],
309+ },
310+ {
311+ label: 'Backend and systems',
312+ short: 'Backend',
313+ color: '#ecebe7',
314+ skills: [
315+ { name: 'Linux', level: 10 },
316+ { name: 'API integrations', level: 8, find: ['API', 'SendOwl', 'Stripe'] },
317+ { name: 'OAuth', level: 10 },
318+ { name: 'Django', level: 8 },
319+ { name: 'Axum', level: 10 },
320+ { name: 'Tokio', level: 10 },
321+ { name: 'REST', level: 10 },
322+ { name: 'WebSockets', level: 6 },
323+ { name: 'FastAPI', level: 6 },
324+ { name: 'NGINX', level: 5 },
325+ { name: 'GraphQL', level: 4 },
326+ ],
327+ },
328+ {
329+ label: 'Security and infrastructure',
330+ short: 'Infrastructure',
331+ color: '#3ddc84',
332+ skills: [
333+ { name: 'Cloudflare', level: 8 },
334+ { name: 'AWS', level: 7, find: ['EC2', 'RDS', 'S3'] },
335+ { name: 'Docker', level: 10 },
336+ { name: 'Self-hosted', level: 7, find: ['self-host'] },
337+ { name: 'Observability', level: 6, find: ['Grafana', 'Loki', 'Promtail'] },
338+ { name: 'CI/CD', level: 6, find: ['git-hook', 'git hook', 'CodeBuild'] },
339+ { name: 'DNS', level: 6 },
340+ { name: 'FortiGate', level: 6, find: ['Fortinet'] },
341+ { name: 'Microsoft 365', level: 5 },
342+ ],
343+ },
344+ {
345+ label: 'Web, mobile, and desktop',
346+ short: 'Web, mobile, desktop',
347+ color: '#f5d547',
348+ skills: [
349+ { name: 'Next.js', level: 7 },
350+ { name: 'Astro', level: 7 },
351+ { name: 'SEO', level: 7 },
352+ { name: 'Flutter', level: 6 },
353+ { name: 'Web3', level: 6 },
354+ { name: 'Slint', level: 8 },
355+ ],
356+ },
357+ {
358+ label: 'Data',
359+ short: 'Data',
360+ color: '#4cc9f0',
361+ skills: [
362+ { name: 'PostgreSQL', level: 8 },
363+ { name: 'Redis', level: 6 },
364+ { name: 'SQLite / Turso', level: 5, find: ['SQLite', 'Turso', 'D1'] },
365+ { name: 'MySQL', level: 5 },
366+ { name: 'RabbitMQ', level: 4 },
367+ { name: 'Web scraping', level: 7, find: ['scraper', 'scrapers', 'scraping'] },
368+ ],
369+ },
370+ {
371+ label: 'AI integration',
372+ short: 'AI integration',
373+ color: '#e879f9',
374+ skills: [
375+ { name: 'LLM workflows', level: 8, find: ['LLM', 'AI-assisted'] },
376+ { name: 'Claude', level: 7 },
377+ { name: 'MCP', level: 7 },
378+ { name: 'PyTorch', level: 6 },
379+ { name: 'Ollama', level: 6 },
380+ { name: 'DeepSeek', level: 5 },
381+ { name: 'Stable Diffusion', level: 5 },
382+ ],
383+ },
384+];
385+export const skillCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
386+
387+// ---------- Plain-text profile (/llms.txt and the AI console) ----------
388+const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
389+const ymText = ([y, m]: [number, number]) => `${MONTH_NAMES[m - 1]} ${y}`;
390+const spanText = (start?: [number, number], end?: [number, number]) => {
391+ if (!start) return 'dates not listed';
392+ if (end && start[0] === end[0] && start[1] === end[1]) return ymText(start);
393+ return `${ymText(start)} to ${end ? ymText(end) : 'present'}`;
394+};
395+const placeNames = (ids: readonly string[]) =>
396+ ids.map((id) => places.find((p) => p.id === id)?.name ?? id).join('; ') || 'not listed';
397+
398+export function profileText(): string {
399+ const lines: string[] = [];
400+ lines.push('# Lane Henslee');
401+ lines.push('');
402+ lines.push('Software engineer based in Reno, Nevada. U.S. citizen, eligible for a security clearance, open to developer roles on defense programs.');
403+ lines.push(`Email: ${email}`);
404+ lines.push(`LinkedIn: ${linkedin}`);
405+ lines.push(`GitHub: ${github}`);
406+ lines.push('Portfolio: https://henslee.me');
407+ lines.push('');
408+ lines.push('## Summary');
409+ intro.forEach((p) => lines.push(p));
410+ lines.push('');
411+ lines.push('## Companies and roles (newest first)');
412+ for (const era of eras) {
413+ for (const id of era.orgs) {
414+ const o = orgs[id];
415+ lines.push(`- ${o.org}: ${o.title}, ${spanText(o.start, o.end)}. Places: ${placeNames(o.places)}.`);
416+ }
417+ }
418+ lines.push('');
419+ lines.push('## Projects (newest first, grouped by company)');
420+ for (const era of eras) {
421+ const org = orgs[era.orgs[0]];
422+ const items = work.filter((w) => w.era === era.id).sort((a, b) => startNum(b) - startNum(a));
423+ for (const w of items) {
424+ lines.push('');
425+ lines.push(`### ${w.name} (${spanText(w.start, w.end)}, under ${org.org})`);
426+ if (w.href) lines.push(`Link: ${w.href}`);
427+ lines.push(`Places: ${placeNames(w.places)}`);
428+ w.body.forEach((p) => lines.push(p));
429+ if (w.membar) {
430+ lines.push(`${w.membar.caption}: ${w.membar.before[0]} ${w.membar.before[1]}, ${w.membar.after[0]} ${w.membar.after[1]}.`);
431+ }
432+ if (w.stack) lines.push(`Stack: ${w.stack}`);
433+ }
434+ }
435+ lines.push('');
436+ lines.push('## Skills (self-rated level out of 10)');
437+ for (const g of skillGroups) {
438+ lines.push(`- ${g.label}: ${g.skills.map((s) => `${s.name} ${s.level}`).join(', ')}`);
439+ }
440+ lines.push('');
441+ lines.push('## Education');
442+ lines.push(`- ${orgs.utk.title}, ${orgs.utk.org}, graduated ${orgs.utk.end ? orgs.utk.end[0] : orgs.utk.dates}.`);
443+ lines.push('');
444+ lines.push(`## Places\n${places.map((p) => p.name).join('; ')}`);
445+ return lines.join('\n') + '\n';
446+}
+431-105src/lib/gcs/console.ts
@@ -1,75 +1,189 @@
1-// Command console. Opens with "/" or the Console button. Commands navigate
2-// the page, open contact links, and drive the camera feed.
1+// Docked terminal, bottom right, styled after the Claude CLI. Plain text is a
2+// question about Lane: it streams from DeepSeek through the worker
3+// (/__ai/chat), which grounds the model in /llms.txt. Slash commands navigate
4+// the page, open contact links, and drive the camera feed. "/" or "`" opens
5+// it; Esc closes the slash menu, then interrupts an answer, then closes.
6+// The conversation survives reloads for the tab session (sessionStorage).
37 import { track } from '../track';
48
59 const EMAIL = 'lane@henslee.me';
610 const LINKEDIN = 'https://www.linkedin.com/in/lanehenslee';
711 const GITHUB = 'https://github.com/huncholane';
12+const STORE = 'henslee.term.v1';
13+const MAX_TURNS = 16;
14+const SPIN = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'];
15+const VERBS = ['Acquiring', 'Triangulating', 'Slewing', 'Tracking', 'Uplinking', 'Correlating', 'Plotting'];
816
9-type Out = (text: string, tone?: 'vol' | 'muted' | 'err') => void;
17+type Msg = { role: 'user' | 'assistant'; content: string };
18+type Tone = 'vol' | 'muted' | 'err';
19+type Command = { help?: string; usage?: string; run: (args: string[]) => void };
20+
21+// The model is told to write plain text without dashes; tidy anything that slips through.
22+const clean = (t: string) =>
23+ t
24+ .replace(/(\d)\s*[–—]\s*(\d)/g, '$1 to $2')
25+ .replace(/\s*—\s*/g, ', ')
26+ .replace(/–/g, '-')
27+ .replace(/\*\*|__|`/g, '')
28+ .replace(/^#{1,6}\s+/gm, '')
29+ .replace(/^(\s*)\*\s+/gm, '$1- ');
30+
31+const LINK_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"]|[\w.+-]+@[\w-]+(?:\.[\w-]+)+)/g;
32+
33+// Text with emails and URLs turned into links, built as DOM nodes (never HTML).
34+function linkify(el: HTMLElement, text: string) {
35+ const parts: Node[] = [];
36+ let last = 0;
37+ for (const m of text.matchAll(LINK_RE)) {
38+ if (m.index! > last) parts.push(document.createTextNode(text.slice(last, m.index)));
39+ const a = document.createElement('a');
40+ const isMail = !m[0].startsWith('http');
41+ a.href = isMail ? `mailto:${m[0]}` : m[0];
42+ a.textContent = m[0];
43+ a.className = 'term-link';
44+ if (!isMail) {
45+ a.target = '_blank';
46+ a.rel = 'noopener';
47+ }
48+ a.addEventListener('click', () => track('console_link', { kind: isMail ? 'email' : 'url' }));
49+ parts.push(a);
50+ last = m.index! + m[0].length;
51+ }
52+ if (last < text.length) parts.push(document.createTextNode(text.slice(last)));
53+ el.replaceChildren(...parts);
54+}
1055
1156 export function initConsole() {
12- const dialog = document.getElementById('console') as HTMLDialogElement | null;
13- if (!dialog) return;
14- const out = dialog.querySelector<HTMLElement>('[data-console-out]')!;
15- const form = dialog.querySelector<HTMLFormElement>('[data-console-form]')!;
16- const input = dialog.querySelector<HTMLInputElement>('#consoleInput')!;
57+ const panel = document.getElementById('term');
58+ if (!panel) return;
59+ const log = panel.querySelector<HTMLElement>('[data-term-log]')!;
60+ const welcome = panel.querySelector<HTMLElement>('[data-term-welcome]')!;
61+ const form = panel.querySelector<HTMLFormElement>('[data-term-form]')!;
62+ const input = panel.querySelector<HTMLInputElement>('#termInput')!;
63+ const menu = panel.querySelector<HTMLElement>('[data-term-menu]')!;
64+ const hints = panel.querySelector<HTMLElement>('[data-term-hints]')!;
65+ const launchers = Array.from(document.querySelectorAll<HTMLElement>('[data-term-open]'));
1766 const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
18- const history: string[] = [];
67+ const inputHistory: string[] = [];
1968 let hIndex = -1;
20- let greeted = false;
69+ let convo: Msg[] = [];
70+ let busy: AbortController | null = null;
71+ let matches: [string, Command][] = [];
72+ let menuIndex = 0;
73+
74+ // Follow new output only while the reader is at the bottom.
75+ const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 24;
76+ const stick = (was: boolean) => {
77+ if (was) log.scrollTop = log.scrollHeight;
78+ };
2179
22- const print: Out = (text, tone) => {
80+ const save = () => {
81+ try {
82+ sessionStorage.setItem(STORE, JSON.stringify(convo.slice(-MAX_TURNS)));
83+ } catch {
84+ /* storage blocked; the conversation just won't survive a reload */
85+ }
86+ };
87+
88+ const print = (text: string, tone?: Tone) => {
89+ const was = atBottom();
2390 const line = document.createElement('div');
24- line.textContent = text;
25- if (tone === 'vol') line.className = 'text-vol';
26- if (tone === 'muted') line.className = 'text-muted';
27- if (tone === 'err') line.className = 'text-[#ff6b5e]';
28- out.append(line);
29- out.scrollTop = out.scrollHeight;
91+ line.className = `term-out${tone ? ` is-${tone}` : ''}`;
92+ linkify(line, text);
93+ log.append(line);
94+ stick(was);
95+ return line;
3096 };
3197
32- const goTo = (id: string) => {
33- dialog.close();
34- document.getElementById(id)?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
98+ const printQ = (text: string) => {
99+ const line = document.createElement('div');
100+ line.className = 'term-q';
101+ const caret = document.createElement('span');
102+ caret.className = 'term-caret';
103+ caret.textContent = '>';
104+ const body = document.createElement('span');
105+ body.textContent = text;
106+ line.append(caret, body);
107+ log.append(line);
108+ log.scrollTop = log.scrollHeight;
35109 };
36110
37- const tracks = () =>
38- Array.from(document.querySelectorAll<HTMLElement>('[data-trk]')).map(
39- (r) => `${r.dataset.trk} ${r.dataset.trkName}`,
40- );
111+ const printA = (text = '') => {
112+ const line = document.createElement('div');
113+ line.className = 'term-a';
114+ const dot = document.createElement('span');
115+ dot.className = 'term-dot';
116+ dot.setAttribute('aria-hidden', 'true');
117+ const body = document.createElement('div');
118+ body.className = 'term-a-body';
119+ linkify(body, text);
120+ line.append(dot, body);
121+ log.append(line);
122+ return body;
123+ };
124+
125+ const spinner = () => {
126+ const line = document.createElement('div');
127+ line.className = 'term-spin';
128+ const glyph = document.createElement('span');
129+ glyph.className = 'term-spin-glyph';
130+ glyph.textContent = '✻';
131+ const verb = document.createElement('span');
132+ verb.textContent = `${VERBS[Math.floor(Math.random() * VERBS.length)]}…`;
133+ const meta = document.createElement('span');
134+ meta.className = 'text-muted';
135+ meta.textContent = '(esc to interrupt)';
136+ line.append(glyph, verb, meta);
137+ log.append(line);
138+ log.scrollTop = log.scrollHeight;
139+ const started = Date.now();
140+ let f = 0;
141+ const timer = setInterval(() => {
142+ if (!reduce) glyph.textContent = SPIN[(f = (f + 1) % SPIN.length)];
143+ const s = Math.floor((Date.now() - started) / 1000);
144+ if (s > 0) meta.textContent = `(${s}s, esc to interrupt)`;
145+ }, 120);
146+ return () => {
147+ clearInterval(timer);
148+ line.remove();
149+ };
150+ };
151+
152+ const isOpen = () => !panel.hidden;
153+ const narrow = () => window.matchMedia('(max-width: 767px)').matches;
41154
42- const commands: Record<string, { help?: string; run: (args: string[]) => void }> = {
155+ // Scroll to a section. Skills falls back to Work where the Skills panel is
156+ // replaced by the right-hand column. Small screens close the terminal so the
157+ // section is visible.
158+ const goTo = (id: string) => {
159+ let el = document.getElementById(id);
160+ if (el && getComputedStyle(el).display === 'none') el = document.getElementById('work');
161+ if (narrow()) close();
162+ el?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
163+ print(`Scrolled to ${id}.`, 'muted');
164+ };
165+
166+ const commands: Record<string, Command> = {
43167 help: {
44- help: 'list commands',
168+ help: 'list commands and shortcuts',
45169 run: () => {
46- for (const [name, c] of Object.entries(commands)) if (c.help) print(` ${name.padEnd(10)} ${c.help}`, 'muted');
170+ print('Type a question to ask about Lane, or a command:', 'muted');
171+ for (const [name, c] of Object.entries(commands)) {
172+ if (c.help) print(` /${(c.usage ?? name).padEnd(20)} ${c.help}`, 'muted');
173+ }
174+ print(' ↑ ↓ history tab completes esc interrupts or closes', 'muted');
47175 },
48176 },
49- whoami: {
50- help: 'who runs this station',
177+ clear: {
178+ help: 'start a new conversation',
51179 run: () => {
52- print('Lane Henslee. Software engineer in Reno, Nevada.');
53- print('Rust and Python backends, cloud and security infrastructure.');
54- print('U.S. citizen, clearance eligible, open to defense roles.');
180+ convo = [];
181+ save();
182+ log.replaceChildren(welcome);
55183 },
56184 },
57- tracks: { help: 'list selected work', run: () => tracks().forEach((t) => print(t)) },
58- places: {
59- help: 'places I have lived',
60- run: () => {
61- const data = JSON.parse(document.getElementById('places-data')?.textContent ?? '[]') as { id: string; name: string }[];
62- for (const p of data) {
63- const n = document.querySelectorAll(`[data-places~="${p.id}"]`).length;
64- print(`${p.name.padEnd(18)} ${n} ${n === 1 ? 'record' : 'records'}`);
65- }
66- },
67- },
68- work: { help: 'go to work and experience', run: () => goTo('work') },
69- skills: { help: 'go to skills', run: () => goTo('skills') },
70- contact: { help: 'go to contact', run: () => goTo('contact') },
71185 email: {
72- help: 'write me an email',
186+ help: 'write Lane an email',
73187 run: () => {
74188 print(`Opening mail to ${EMAIL}`, 'vol');
75189 track('contact_click', { channel: 'email', location: 'console' });
@@ -77,14 +191,17 @@ export function initConsole() {
77191 },
78192 },
79193 copy: {
80- help: 'copy my email address',
194+ help: "copy Lane's email address",
81195 run: () => {
82196 navigator.clipboard.writeText(EMAIL).then(
83197 () => {
84198 print(`Copied ${EMAIL}`, 'vol');
85199 track('email_copy', { result: 'ok', location: 'console' });
86200 },
87- () => print(`Clipboard blocked. Address: ${EMAIL}`, 'err'),
201+ (err) => {
202+ console.warn('[henslee] clipboard write failed', err);
203+ print(`Clipboard is blocked. The address is ${EMAIL}`, 'err');
204+ },
88205 );
89206 },
90207 },
@@ -102,16 +219,37 @@ export function initConsole() {
102219 window.open(GITHUB, '_blank', 'noopener');
103220 },
104221 },
222+ work: { help: 'go to work and experience', run: () => goTo('work') },
223+ education: { help: 'go to education', run: () => goTo('education') },
224+ skills: { help: 'go to skills', run: () => goTo('skills') },
225+ contact: { help: 'go to contact', run: () => goTo('contact') },
226+ tracks: {
227+ help: 'list every project',
228+ run: () => {
229+ for (const r of document.querySelectorAll<HTMLElement>('[data-trk]')) print(` ${r.dataset.trk} ${r.dataset.trkName}`);
230+ },
231+ },
232+ places: {
233+ help: 'places Lane has lived and worked',
234+ run: () => {
235+ const data = JSON.parse(document.getElementById('places-data')?.textContent ?? '[]') as { id: string; name: string }[];
236+ for (const p of data) {
237+ const n = document.querySelectorAll(`[data-places~="${p.id}"]`).length;
238+ print(` ${p.name.padEnd(18)} ${n} ${n === 1 ? 'record' : 'records'}`);
239+ }
240+ },
241+ },
105242 mode: {
106- help: 'camera mode: eo, wht, or iron',
243+ help: 'camera mode',
244+ usage: 'mode eo|wht|iron',
107245 run: ([m]) => {
108- if (!['eo', 'wht', 'iron'].includes(m ?? '')) return print('Usage: mode eo | wht | iron', 'err');
246+ if (!['eo', 'wht', 'iron'].includes(m ?? '')) return void print('Usage: /mode eo | wht | iron', 'err');
109247 document.dispatchEvent(new CustomEvent('gcs:mode', { detail: m }));
110248 print(`Sensor 1 set to ${m.toUpperCase()}`, 'vol');
111249 },
112250 },
113251 lock: {
114- help: 're-acquire the primary target',
252+ help: 're-acquire the camera target',
115253 run: () => {
116254 document.dispatchEvent(new CustomEvent('gcs:relock'));
117255 print('DEV 01 reacquired', 'vol');
@@ -123,88 +261,276 @@ export function initConsole() {
123261 const feed = document.querySelector<HTMLElement>('.feed');
124262 print(`LINK OK UTC ${new Date().toISOString().slice(11, 19)}Z`);
125263 print(`SENSOR 1 ${(feed?.dataset.mode ?? 'eo').toUpperCase()} ${document.querySelector('[data-tgt]')?.textContent ?? 'TRK 01'}`);
264+ print(`MODEL deepseek-chat ${convo.length} messages in context`);
126265 },
127266 },
128- clear: { help: 'clear the screen', run: () => (out.textContent = '') },
129- exit: { help: 'close the console', run: () => dialog.close() },
267+ exit: { help: 'close the terminal', run: () => close() },
130268 // Not listed in help.
131269 giga: { run: () => (window.location.href = '/giga') },
132270 };
133271
134- function open(source: string) {
135- if (dialog!.open) return;
136- dialog!.showModal();
137- if (!greeted) {
138- greeted = true;
139- print('GCS console ready. Type help for commands.', 'vol');
140- }
141- input.focus();
142- track('console_open', { source });
143- }
144-
145- form.addEventListener('submit', (e) => {
146- e.preventDefault();
147- const raw = input.value.trim();
148- input.value = '';
149- if (!raw) return;
150- history.unshift(raw);
151- hIndex = -1;
152- print(`lh@gcs:~$ ${raw}`, 'muted');
153- const [name, ...args] = raw.toLowerCase().split(/\s+/);
272+ function runCommand(raw: string) {
273+ const [name, ...args] = raw.slice(1).toLowerCase().split(/\s+/);
154274 const cmd = commands[name];
155275 track('console_command', { cmd: cmd ? name : 'unknown' });
156- if (!cmd) return print(`Unknown command: ${name}. Type help for the list.`, 'err');
276+ if (!cmd) return void print(`Unknown command /${name}. Type /help for the list.`, 'err');
157277 try {
158278 cmd.run(args);
159279 } catch (err) {
160280 console.error('[henslee] console command failed', name, err);
161- print(`${name} failed. Check the browser console for details.`, 'err');
281+ print(`/${name} failed. The browser console has details.`, 'err');
282+ }
283+ }
284+
285+ async function ask(question: string, source: string) {
286+ convo.push({ role: 'user', content: question });
287+ track('console_ask', { chars: question.length, turn: convo.length, source });
288+ const ctrl = new AbortController();
289+ busy = ctrl;
290+ const stopSpin = spinner();
291+ const started = performance.now();
292+ let text = '';
293+ let body: HTMLElement | null = null;
294+ try {
295+ const res = await fetch('/__ai/chat', {
296+ method: 'POST',
297+ headers: { 'content-type': 'application/json' },
298+ credentials: 'same-origin',
299+ body: JSON.stringify({ messages: convo.slice(-MAX_TURNS) }),
300+ signal: ctrl.signal,
301+ });
302+ if (!res.ok || !res.body) throw Object.assign(new Error(`ai ${res.status}`), { status: res.status });
303+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
304+ for (;;) {
305+ const { value, done } = await reader.read();
306+ if (done) break;
307+ if (!value) continue;
308+ const was = atBottom();
309+ text += value;
310+ if (!body) {
311+ stopSpin();
312+ body = printA();
313+ }
314+ linkify(body, clean(text).trimStart());
315+ stick(was);
316+ }
317+ if (!text.trim()) throw new Error('ai empty reply');
318+ convo.push({ role: 'assistant', content: clean(text).trim() });
319+ track('console_answer', { ok: true, ms: Math.round(performance.now() - started), chars: text.length });
320+ } catch (err) {
321+ stopSpin();
322+ const status = (err as { status?: number }).status;
323+ if (ctrl.signal.aborted) {
324+ if (text.trim()) convo.push({ role: 'assistant', content: `${clean(text).trim()} [interrupted]` });
325+ else convo.pop();
326+ print('Interrupted.', 'muted');
327+ track('console_answer', { ok: false, reason: 'interrupted' });
328+ } else {
329+ console.error('[henslee] terminal answer failed', status ?? '', err);
330+ if (text.trim()) convo.push({ role: 'assistant', content: clean(text).trim() });
331+ else convo.pop();
332+ print(
333+ status === 403
334+ ? 'Your visitor pass expired. Reload the page to pass the check again.'
335+ : status === 429
336+ ? `That is a lot of questions in a short time. Try again in a minute, or email ${EMAIL}.`
337+ : `The model is not answering right now. Try again, or email ${EMAIL}.`,
338+ 'err',
339+ );
340+ track('console_answer', { ok: false, reason: status ? String(status) : 'network' });
341+ }
342+ } finally {
343+ busy = null;
344+ save();
345+ }
346+ }
347+
348+ function submit(raw: string, source = 'typed') {
349+ const text = raw.trim();
350+ if (!text) return;
351+ if (inputHistory[0] !== text) inputHistory.unshift(text);
352+ hIndex = -1;
353+ if (text.startsWith('/')) {
354+ printQ(text);
355+ runCommand(text);
356+ return;
357+ }
358+ if (busy) return void print('Still answering. Press Esc to interrupt first.', 'muted');
359+ printQ(text);
360+ void ask(text, source);
361+ }
362+
363+ // Slash menu: lists matching commands under the input while typing "/name".
364+ function updateMenu() {
365+ const v = input.value;
366+ if (!v.startsWith('/') || /\s/.test(v)) return hideMenu();
367+ const q = v.slice(1).toLowerCase();
368+ matches = Object.entries(commands).filter(([n, c]) => c.help && n.startsWith(q));
369+ if (!matches.length) return hideMenu();
370+ menuIndex = Math.min(menuIndex, matches.length - 1);
371+ menu.replaceChildren(
372+ ...matches.map(([name, c], i) => {
373+ const opt = document.createElement('div');
374+ opt.className = `term-opt${i === menuIndex ? ' is-active' : ''}`;
375+ opt.setAttribute('role', 'option');
376+ opt.setAttribute('aria-selected', String(i === menuIndex));
377+ const n = document.createElement('span');
378+ n.textContent = `/${c.usage ?? name}`;
379+ const h = document.createElement('span');
380+ h.className = 'text-muted';
381+ h.textContent = c.help!;
382+ opt.append(n, h);
383+ opt.addEventListener('mousedown', (e) => {
384+ e.preventDefault(); // keep focus in the input
385+ pick(i, true);
386+ });
387+ return opt;
388+ }),
389+ );
390+ menu.hidden = false;
391+ hints.hidden = true;
392+ }
393+
394+ function hideMenu() {
395+ matches = [];
396+ menuIndex = 0;
397+ menu.hidden = true;
398+ hints.hidden = false;
399+ }
400+
401+ // Complete the highlighted command; run it unless it needs an argument.
402+ function pick(i: number, run: boolean) {
403+ const [name, c] = matches[i] ?? [];
404+ if (!name) return;
405+ if (c!.usage || !run) {
406+ input.value = `/${name}${c!.usage ? ' ' : ''}`;
407+ hideMenu();
408+ if (!c!.usage) updateMenu();
409+ return;
162410 }
411+ input.value = '';
412+ hideMenu();
413+ submit(`/${name}`, 'menu');
414+ }
415+
416+ function open(source: string) {
417+ if (isOpen()) return void input.focus();
418+ panel!.hidden = false;
419+ launchers.forEach((l) => {
420+ l.hidden = true;
421+ l.setAttribute('aria-expanded', 'true');
422+ });
423+ log.scrollTop = log.scrollHeight;
424+ input.focus();
425+ track('console_open', { source, restored: convo.length });
426+ }
427+
428+ function close() {
429+ busy?.abort();
430+ hideMenu();
431+ panel!.hidden = true;
432+ launchers.forEach((l) => {
433+ l.hidden = false;
434+ l.setAttribute('aria-expanded', 'false');
435+ });
436+ // Leave focus on the page rather than drawing a ring on the launcher.
437+ if (panel!.contains(document.activeElement)) (document.activeElement as HTMLElement).blur();
438+ track('console_close');
439+ }
440+
441+ form.addEventListener('submit', (e) => {
442+ e.preventDefault();
443+ if (!menu.hidden && matches.length && input.value.trim() !== `/${matches[menuIndex][0]}`) {
444+ return pick(menuIndex, true);
445+ }
446+ const raw = input.value;
447+ input.value = '';
448+ hideMenu();
449+ submit(raw);
450+ });
451+
452+ input.addEventListener('input', () => {
453+ menuIndex = 0;
454+ updateMenu();
163455 });
164456
165457 input.addEventListener('keydown', (e) => {
166- if (e.key === 'ArrowUp' && history.length) {
167- hIndex = Math.min(history.length - 1, hIndex + 1);
168- input.value = history[hIndex];
458+ const menuOpen = !menu.hidden && matches.length > 0;
459+ if (e.key === 'Tab' && menuOpen) {
460+ e.preventDefault();
461+ pick(menuIndex, false);
462+ } else if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && menuOpen) {
169463 e.preventDefault();
170- } else if (e.key === 'ArrowDown') {
171- hIndex = Math.max(-1, hIndex - 1);
172- input.value = hIndex === -1 ? '' : history[hIndex];
464+ menuIndex = (menuIndex + (e.key === 'ArrowUp' ? -1 : 1) + matches.length) % matches.length;
465+ updateMenu();
466+ } else if (e.key === 'ArrowUp' && inputHistory.length) {
173467 e.preventDefault();
468+ hIndex = Math.min(inputHistory.length - 1, hIndex + 1);
469+ input.value = inputHistory[hIndex];
470+ } else if (e.key === 'ArrowDown' && hIndex > -1) {
471+ e.preventDefault();
472+ hIndex -= 1;
473+ input.value = hIndex === -1 ? '' : inputHistory[hIndex];
174474 }
175475 });
176476
177- // Click on the backdrop closes it.
178- dialog.addEventListener('click', (e) => {
179- if (e.target === dialog) dialog.close();
477+ panel.addEventListener('keydown', (e) => {
478+ if (e.key !== 'Escape') return;
479+ e.preventDefault();
480+ if (!menu.hidden) hideMenu();
481+ else if (busy) busy.abort();
482+ else close();
180483 });
181484
182- // When the dialog closes the browser returns focus to the Console button,
183- // and because Esc or Enter closed it, that focus draws a keyboard ring.
184- // Drop it when the console was opened with the mouse; keep it for keyboard users.
185- let openedByPointer = false;
186- dialog.addEventListener('close', () => {
187- if (!openedByPointer) return;
188- openedByPointer = false;
189- requestAnimationFrame(() => {
190- const el = document.activeElement as HTMLElement | null;
191- if (el?.matches('[data-open-console]')) el.blur();
192- });
485+ // Ctrl+C interrupts like a terminal when nothing is selected.
486+ input.addEventListener('keydown', (e) => {
487+ if (e.key === 'c' && e.ctrlKey && busy && !window.getSelection()?.toString()) {
488+ e.preventDefault();
489+ busy.abort();
490+ }
193491 });
194492
195- document.querySelectorAll<HTMLElement>('[data-open-console]').forEach((b) => {
196- b.addEventListener('pointerdown', () => (openedByPointer = true));
197- b.addEventListener('click', (e) => {
198- // A keyboard "click" (Enter or Space) has detail 0.
199- if ((e as MouseEvent).detail === 0) openedByPointer = false;
200- open(openedByPointer ? 'button' : 'button_key');
201- });
493+ panel.querySelectorAll<HTMLElement>('[data-term-suggest]').forEach((b) =>
494+ b.addEventListener('click', () => {
495+ const q = b.textContent!.trim();
496+ if (busy) return void print('Still answering. Press Esc to interrupt first.', 'muted');
497+ track('console_suggest', { prompt: q });
498+ submit(q, 'suggestion');
499+ input.focus();
500+ }),
501+ );
502+ panel.querySelector('[data-term-close]')?.addEventListener('click', close);
503+ panel.querySelector('[data-term-clear]')?.addEventListener('click', () => {
504+ commands.clear.run([]);
505+ input.focus();
506+ track('console_command', { cmd: 'clear', via: 'button' });
507+ });
508+ // Clicking empty space in the log puts the cursor back in the input,
509+ // unless the reader is selecting text to copy.
510+ log.addEventListener('mouseup', () => {
511+ if (!window.getSelection()?.toString()) input.focus({ preventScroll: true });
202512 });
513+
514+ launchers.forEach((l) => l.addEventListener('click', () => open('button')));
203515 window.addEventListener('keydown', (e) => {
204- if (e.key !== '/' && e.key !== '`') return;
516+ if ((e.key !== '/' && e.key !== '`') || e.ctrlKey || e.metaKey || e.altKey) return;
205517 const t = e.target as HTMLElement;
206- if (t.closest('input, textarea, [contenteditable="true"]')) return;
518+ if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
207519 e.preventDefault();
208520 open('key');
209521 });
522+
523+ // Restore this tab's conversation.
524+ try {
525+ const saved = JSON.parse(sessionStorage.getItem(STORE) ?? '[]') as Msg[];
526+ if (Array.isArray(saved)) {
527+ convo = saved.filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string');
528+ for (const m of convo) {
529+ if (m.role === 'user') printQ(m.content);
530+ else printA(m.content);
531+ }
532+ }
533+ } catch {
534+ convo = [];
535+ }
210536 }
+85-408src/pages/index.astro
@@ -12,287 +12,27 @@ import SeoHead from '../components/SeoHead.astro';
1212 import SkillCluster from '../components/SkillCluster.astro';
1313 import SkillField from '../components/SkillField.astro';
1414 import photo from '../assets/lane-dubai.jpg';
15+import {
16+ email,
17+ linkedin,
18+ github,
19+ intro,
20+ places,
21+ orgs,
22+ eras,
23+ work,
24+ skillGroups,
25+ skillCount,
26+ startNum,
27+ type Work,
28+ type Skill,
29+} from '../data/profile';
1530
16-const email = 'lane@henslee.me';
17-const linkedin = 'https://www.linkedin.com/in/lanehenslee';
18-const github = 'https://github.com/huncholane';
1931
2032 const year = new Date().getFullYear();
2133
22-// Places on the locations globe. `short` labels the pin; `name` is used by the console.
23-// Records below reference these ids in `places`.
24-const places = [
25- { id: 'knoxville', name: 'Knoxville, TN', short: 'Knoxville', lat: 35.9606, lon: -83.9207 },
26- { id: 'austin', name: 'Austin, TX', short: 'Austin', lat: 30.2672, lon: -97.7431 },
27- { id: 'reno', name: 'Reno, NV', short: 'Reno', lat: 39.5296, lon: -119.8138 },
28- { id: 'grassvalley', name: 'Grass Valley, CA', short: 'Grass Valley', lat: 39.2191, lon: -121.0611 },
29- { id: 'petaluma', name: 'Petaluma, CA', short: 'Petaluma', lat: 38.2324, lon: -122.6367 },
30- { id: 'dubai', name: 'Dubai, UAE', short: 'Dubai', lat: 25.1972, lon: 55.2744 },
31-] as const;
32-type PlaceId = (typeof places)[number]['id'];
33-
34-// Companies and school. Each is a sticky banner over the work done there.
35-type OrgId = 'hygo' | 'av' | 'arcturus' | 'utk';
36-type Org = {
37- short: string; // timeline label
38- title: string;
39- org: string;
40- logo: string; // path under /public
41- dates: string;
42- places: PlaceId[];
43- start: [number, number]; // [year, month]
44- end?: [number, number]; // omitted means present
45-};
46-const orgs: Record<OrgId, Org> = {
47- hygo: {
48- short: 'Hygo',
49- title: 'Founder and software engineer (formerly Ghost Systems)',
50- org: 'Hygo, LLC',
51- logo: '/logos/hygo.svg',
52- dates: 'Mar 2022 to present',
53- places: ['reno', 'grassvalley', 'knoxville', 'austin'],
54- start: [2022, 3],
55- },
56- av: {
57- short: 'AeroVironment',
58- title: 'Software developer (formerly Arcturus UAV)',
59- org: 'AeroVironment, Inc.',
60- logo: '/logos/aerovironment.png',
61- dates: 'May 2021 to Dec 2021',
62- places: ['petaluma'],
63- start: [2021, 5],
64- end: [2021, 12],
65- },
66- arcturus: {
67- short: 'Arcturus UAV',
68- title: 'Software engineer, intern',
69- org: 'Arcturus UAV',
70- logo: '/logos/arcturus.png',
71- dates: 'Dec 2018 to May 2021',
72- places: ['knoxville', 'petaluma'],
73- start: [2018, 12],
74- end: [2021, 5],
75- },
76- utk: {
77- // Start is assumed (Aug 2016); the resume only lists the 2021 graduation.
78- short: 'UT Knoxville',
79- title: 'B.S. Computer Science',
80- org: 'University of Tennessee, Knoxville',
81- logo: '/logos/power-t.svg',
82- dates: '2021',
83- places: ['knoxville'],
84- start: [2016, 8],
85- end: [2021, 5],
86- },
87-};
88-
89-// Eras, newest first. Each era is its own card, and its orgs stack in the
90-// card's sticky banner. Education has its own panel.
91-const eras: { id: string; orgs: OrgId[] }[] = [
92- { id: 'hygo', orgs: ['hygo'] },
93- { id: 'av', orgs: ['av'] },
94- { id: 'arcturus', orgs: ['arcturus'] },
95-];
96-
97-type Work = {
98- name: string;
99- era: string; // which era banner it sits under
100- href?: string;
101- body: string[];
102- stack?: string;
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 };
105- liveStats?: string; // slug for /__stats/<slug>: live traffic strip from Rybbit
106- places: PlaceId[];
107- start?: [number, number]; // [year, month]; dated work draws on the timeline
108- end?: [number, number]; // omitted means present
109-};
110-
111-const work: Work[] = [
112- {
113- name: 'Feather API',
114- era: 'hygo',
115- href: 'https://featherapi.com',
116- body: [
117- '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.',
118- 'A Cloudflare Worker backend in TypeScript, backed by a D1 database, handles user authentication and payment processing, and Astro powers the marketing, payment, and sign-in pages.',
119- ],
120- membar: { caption: 'Memory in use', before: ['Postman', '1.5 GB'], after: ['Feather', '170 MB'], ratio: 170 / 1536 },
121- stack: 'Rust, Slint, TypeScript, Astro, Cloudflare, D1',
122- places: ['reno'],
123- start: [2026, 8],
124- },
125- {
126- name: 'Rybbit analytics fork',
127- era: 'hygo',
128- body: [
129- 'Rewrote the TypeScript backend of my Rybbit analytics fork in Rust with Axum in one day. The Rust server also serves the built Next.js frontend, so two Docker containers became one.',
130- 'Memory dropped from 1 GB to 50 MB, which lets my other projects share the same analytics server. This site’s analytics run on it.',
131- ],
132- membar: { caption: 'Memory in use', before: ['Before', '1 GB'], after: ['After', '50 MB'], ratio: 50 / 1024 },
133- stack: 'Rust, Axum, Next.js, TypeScript, Docker, Self-hosted',
134- places: ['reno'],
135- start: [2026, 8],
136- end: [2026, 8],
137- },
138- {
139- name: 'Hygo CRM',
140- era: 'hygo',
141- href: 'https://hygo.ai',
142- body: [
143- 'A multi-tenant CRM with a Rust backend on PostgreSQL and a TypeScript frontend, with live updates over WebSockets. The backend runs in 20 to 50 MB of memory, and Cloudflare caching plus Astro islands keep it fast and indexable.',
144- 'It started on Convex, with TypeScript functions handling database access and WebSockets. I rewrote the backend in Rust to cut costs and give users a better experience.',
145- 'A custom MCP server lets Claude and other LLM clients build enriched lead lists and audit local search citations, pulling from the Google Places API and custom scrapers for business information in each state and city. Lists sourced through it convert at roughly 33%. I also shipped a client-facing iOS companion app.',
146- 'Calls work in the app and in the browser through the Telnyx API. Cloudflare transcribes them and DeepSeek handles the text work, which keeps AI costs dramatically lower.',
147- 'I host the backend myself on Linux with Docker: a pre-push git hook builds it locally and deploys it as a Docker image through GitHub Container Registry.',
148- ],
149- stack: 'Rust, Axum, TypeScript, PostgreSQL, WebSockets, Convex, OAuth, Linux, Docker, NGINX, Certbot, Self-hosted, CI/CD, Cloudflare, Astro, MCP, Flutter, API integrations',
150- places: ['reno'],
151- start: [2026, 6],
152- },
153- {
154- name: 'Ruby’s Air',
155- era: 'hygo',
156- href: 'https://rubysair.com',
157- body: [
158- 'Rebuilt this Little Rock HVAC company’s website in TypeScript and Astro as a Cloudflare Worker, then overhauled its local search presence. Monthly revenue grew from $5K to $50K, and the playbook now runs for other trades clients.',
159- ],
160- stack: 'TypeScript, Astro, Cloudflare, SEO',
161- places: ['reno'],
162- start: [2026, 6],
163- },
164- {
165- name: 'SpiritFacts',
166- era: 'hygo',
167- href: 'https://spiritfacts.com',
168- body: [
169- 'An open chemistry reference for every spirit, wine, and beer, covering congeners, histamines, sulfites, and sugar, with a 1 to 10 Hangover Index for each category and brand.',
170- 'Written in Astro and TypeScript, with Convex as the database service and TypeScript functions that run next to the database server.',
171- ],
172- stack: 'Astro, TypeScript, Convex, Cloudflare, SEO',
173- liveStats: 'spiritfacts',
174- places: ['reno'],
175- start: [2026, 5],
176- },
177- {
178- name: 'Domhaul',
179- era: 'hygo',
180- href: 'https://www.domhaul.com',
181- body: [
182- 'A domain discovery platform built with Next.js and TypeScript, with brandability scoring, live registrar pricing, and Stripe checkout. Launched on Product Hunt and listed as an MCP connector in Anthropic’s Claude connector directory.',
183- 'Its data lives in Cloudflare D1, accessed from TypeScript, and Better Auth handles sign-in. I also self-host its Stalwart mail server on Linux with Docker.',
184- ],
185- stack: 'Next.js, TypeScript, D1, SEO, MCP, Cloudflare, API integrations, Linux, Docker, NGINX, Certbot, Self-hosted',
186- places: ['reno'],
187- start: [2026, 3],
188- },
189- {
190- name: 'Freeport Freight Systems',
191- era: 'hygo',
192- href: 'https://freeportfreight.com',
193- body: [
194- 'I own Fortinet perimeter security and Microsoft 365 identity for this multi-site FedEx Ground contractor: firewall policy, licensing, account lifecycle, and a zero-downtime DNS and email migration to Cloudflare.',
195- 'I also rebuilt their website in Astro and TypeScript, bringing in 4x the application rate compared to LinkNow Media.',
196- ],
197- stack: 'TypeScript, Astro, FortiGate, Microsoft 365, Cloudflare DNS',
198- places: ['reno'],
199- start: [2025, 10],
200- },
201- {
202- name: 'ForexNinja',
203- era: 'hygo',
204- body: [
205- 'A Django authentication system for the ForexNinja trading bot. The bot is written in MQL and connects to the backend through C, and license purchases run through SendOwl, integrated with Django.',
206- 'I also built the public site on WordPress, wrote its Discord bot integrations in Java, and delivered the contract ahead of schedule.',
207- ],
208- stack: 'Python, Django, MQL, C, Java, Docker, NGINX, Certbot, API integrations, WordPress',
209- places: ['grassvalley'],
210- start: [2023, 6],
211- end: [2023, 7],
212- },
213- {
214- name: 'FlyingBytes',
215- era: 'hygo',
216- body: [
217- 'A Django and AWS pipeline for a FedEx contractor that parses sensitive employee PDFs through a Rust regex engine into RDS. It serves 10,000 requests a day at about 10 times the throughput of the process it replaced.',
218- 'I later rewrote the platform in Rust and took over its production operations, holding incident response under 30 minutes. It runs on EC2, RDS, S3, and Docker. Deploys started on git-hook pipelines, which raised deployment frequency by 50%, and now build on AWS CodeBuild, which builds natively for its ARM servers and is faster.',
219- '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.',
220- 'Backend apps post their errors to Slack, and an ingestion endpoint forwards iOS app errors to Slack too.',
221- ],
222- stack: 'Rust, Python, Django, TypeScript, Astro, PostgreSQL, Redis, Docker, NGINX, Certbot, Self-hosted, Observability, CI/CD, API integrations, AWS',
223- membar: { caption: 'Production memory (RSS)', before: ['Before', '3 GB'], after: ['After', '50 MB'], ratio: 50 / 3072 },
224- places: ['reno', 'grassvalley', 'knoxville', 'austin'],
225- start: [2022, 10],
226- },
227- {
228- name: 'Ghost Systems',
229- era: 'hygo',
230- body: [
231- 'A Python arbitrage bot for the Everdome token that traded the price gap between OKX and a decentralized exchange. KuCoin bridged USDT and BNB so the bot could rebalance when needed.',
232- 'A Django admin panel on PostgreSQL ran the bot, and Discord notifications sent trade alerts. Ghost Systems was Hygo’s original name.',
233- ],
234- stack: 'Python, Django, PostgreSQL, Web3, Docker, NGINX, Certbot, API integrations',
235- places: ['grassvalley'],
236- start: [2022, 2],
237- end: [2022, 11],
238- },
239- {
240- name: 'HygoFit',
241- era: 'hygo',
242- href: 'https://hygofit.com',
243- body: [
244- 'An iOS training tracker built in Flutter that computes per-set volume, vertical load, and estimated one-rep max. Convex runs the backend, with Google and Apple sign-in over OAuth.',
245- ],
246- stack: 'Flutter, Convex, OAuth, iOS',
247- places: ['reno'],
248- start: [2026, 6],
249- },
250- {
251- name: 'Alignerr (contract)',
252- era: 'hygo',
253- body: [
254- 'Wrote and reviewed Python training data for frontier model alignment, holding a 70% acceptance rate against a review bar most contributors did not clear.',
255- ],
256- stack: 'Python',
257- places: ['reno'],
258- start: [2026, 2],
259- end: [2026, 2],
260- },
261- {
262- name: 'RFID production tracking',
263- era: 'av',
264- body: [
265- 'A Python and Flask system that captures RFID scans into FileMaker to track parts and tooling across unmanned aircraft production.',
266- 'It automated 2,000+ daily scans across 12 manufacturing sites, cutting manual data entry by 95% and improving inventory accuracy by 30%, and went live with under one hour of line downtime.',
267- ],
268- stack: 'Python, Flask, FileMaker, RFID',
269- places: ['petaluma'],
270- start: [2021, 5],
271- end: [2021, 12],
272- },
273- {
274- name: 'Flight-analysis tooling',
275- era: 'arcturus',
276- body: ['Python tooling for UAV flight test data that made failure analysis 12x more efficient.'],
277- stack: 'Python',
278- places: ['knoxville', 'petaluma'],
279- start: [2018, 12],
280- end: [2021, 5],
281- },
282- {
283- name: 'Production scheduling',
284- era: 'arcturus',
285- body: ['Replaced a manual Excel production schedule with a Python scheduling tool, reducing scheduling time by over 90%.'],
286- stack: 'Python, Excel',
287- places: ['knoxville', 'petaluma'],
288- start: [2018, 12],
289- end: [2021, 5],
290- },
291-];
292-
29334 // Newest first within each era; undated work goes last. TRK numbers run
29435 // across eras in display order.
295-const startNum = (w: Work) => (w.start ? w.start[0] + (w.start[1] - 1) / 12 : -1);
29636 let trkCount = 0;
29737 const eraTracks = eras.map((e) => ({
29838 ...e,
@@ -302,104 +42,6 @@ const eraTracks = eras.map((e) => ({
30242 .map((w) => ({ ...w, trk: String(++trkCount).padStart(2, '0') })),
30343 }));
30444
305-// Skills bubble map. `level` (1 to 10) sets bubble size; `find` lists extra
306-// terms that count as using the skill when matching the work below.
307-// Colors follow cockpit display conventions: amber, white, green, cyan, magenta.
308-type Skill = { name: string; level: number; find?: string[] };
309-type SkillGroup = { label: string; short: string; color: string; skills: Skill[] };
310-const skillGroups: SkillGroup[] = [
311- {
312- label: 'Languages',
313- short: 'Languages',
314- color: '#ff8200',
315- skills: [
316- { name: 'Rust', level: 10 },
317- { name: 'Python', level: 10 },
318- { name: 'TypeScript', level: 8 },
319- { name: 'SQL', level: 8, find: ['PostgreSQL', 'MySQL', 'SQLite', 'RDS'] },
320- { name: 'JavaScript', level: 7 },
321- { name: 'Bash', level: 6 },
322- { name: 'Java', level: 5 },
323- { name: 'C++', level: 4 },
324- { name: 'C', level: 4 },
325- ],
326- },
327- {
328- label: 'Backend and systems',
329- short: 'Backend',
330- color: '#ecebe7',
331- skills: [
332- { name: 'Linux', level: 10 },
333- { name: 'API integrations', level: 8, find: ['API', 'SendOwl', 'Stripe'] },
334- { name: 'OAuth', level: 10 },
335- { name: 'Django', level: 8 },
336- { name: 'Axum', level: 10 },
337- { name: 'Tokio', level: 10 },
338- { name: 'REST', level: 10 },
339- { name: 'WebSockets', level: 6 },
340- { name: 'FastAPI', level: 6 },
341- { name: 'NGINX', level: 5 },
342- { name: 'GraphQL', level: 4 },
343- ],
344- },
345- {
346- label: 'Security and infrastructure',
347- short: 'Infrastructure',
348- color: '#3ddc84',
349- skills: [
350- { name: 'Cloudflare', level: 8 },
351- { name: 'AWS', level: 7, find: ['EC2', 'RDS', 'S3'] },
352- { name: 'Docker', level: 10 },
353- { name: 'Self-hosted', level: 7, find: ['self-host'] },
354- { name: 'Observability', level: 6, find: ['Grafana', 'Loki', 'Promtail'] },
355- { name: 'CI/CD', level: 6, find: ['git-hook', 'git hook', 'CodeBuild'] },
356- { name: 'DNS', level: 6 },
357- { name: 'FortiGate', level: 6, find: ['Fortinet'] },
358- { name: 'Microsoft 365', level: 5 },
359- ],
360- },
361- {
362- label: 'Web, mobile, and desktop',
363- short: 'Web, mobile, desktop',
364- color: '#f5d547',
365- skills: [
366- { name: 'Next.js', level: 7 },
367- { name: 'Astro', level: 7 },
368- { name: 'SEO', level: 7 },
369- { name: 'Flutter', level: 6 },
370- { name: 'Web3', level: 6 },
371- { name: 'Slint', level: 8 },
372- ],
373- },
374- {
375- label: 'Data',
376- short: 'Data',
377- color: '#4cc9f0',
378- skills: [
379- { name: 'PostgreSQL', level: 8 },
380- { name: 'Redis', level: 6 },
381- { name: 'SQLite / Turso', level: 5, find: ['SQLite', 'Turso', 'D1'] },
382- { name: 'MySQL', level: 5 },
383- { name: 'RabbitMQ', level: 4 },
384- { name: 'Web scraping', level: 7, find: ['scraper', 'scrapers', 'scraping'] },
385- ],
386- },
387- {
388- label: 'AI integration',
389- short: 'AI integration',
390- color: '#e879f9',
391- skills: [
392- { name: 'LLM workflows', level: 8, find: ['LLM', 'AI-assisted'] },
393- { name: 'Claude', level: 7 },
394- { name: 'MCP', level: 7 },
395- { name: 'PyTorch', level: 6 },
396- { name: 'Ollama', level: 6 },
397- { name: 'DeepSeek', level: 5 },
398- { name: 'Stable Diffusion', level: 5 },
399- ],
400- },
401-];
402-const skillCount = skillGroups.reduce((n, g) => n + g.skills.length, 0);
40345
40446 // Where each skill was used: any work or role whose text mentions it.
40547 const mentions = (text: string, term: string) =>
@@ -473,7 +115,7 @@ const skillCols = [0, 1, 2]
473115 // Right-margin skill column (1690px+): each group is its own compact cluster
474116 // (force layout at build time, deterministic), stacked top to bottom with its
475117 // label underneath.
476-const FIELD_W = 196;
118+const FIELD_W = 240;
477119 const FIELD_K = 3.02; // px of radius per level
478120 const FIELD_GAP = 2.5;
479121 type FieldNode = ReturnType<typeof fitLabel> & {
@@ -597,7 +239,7 @@ const pad = (n: number) => String(n).padStart(2, '0');
597239 <!-- Status bar -->
598240 <header class="fixed inset-x-0 top-0 z-40 border-b border-rule bg-paper/90 backdrop-blur">
599241 <div class={`mx-auto flex h-10 max-w-[76rem] items-center gap-5 px-5 sm:px-10 xl:mr-auto xl:ml-[max(15.5rem,calc((100%_-_76rem)/2))] ${mono}`}>
600- <a href="#top" class="font-bold text-vol" data-track="nav_click" data-track-target="top">Henslee<span class="text-muted">/GCS</span></a>
242+ <a href="#top" class="font-bold text-vol" data-track="nav_click" data-track-target="top">Henslee</a>
601243 <span class="hidden items-center gap-1.5 text-muted md:inline-flex">
602244 <span class="size-1.5 bg-ok" aria-hidden="true"></span>Link ok
603245 </span>
@@ -610,9 +252,6 @@ const pad = (n: number) => String(n).padStart(2, '0');
610252 <a class={navLink} href="#contact" data-nav="contact" data-track="nav_click" data-track-target="contact">Contact</a>
611253 </nav>
612254 <span class="tabular-nums" aria-label="Current UTC time"><span class="text-muted">UTC </span><span data-clock>--:--:--</span>Z</span>
613- <button type="button" class="rounded-[2px] border border-rule px-2 py-1 transition-colors hover:border-vol hover:text-vol" data-open-console>
614- Console <kbd class="ml-1 hidden text-muted sm:inline">/</kbd>
615- </button>
616255 </div>
617256 </div>
618257 </header>
@@ -705,16 +344,7 @@ const pad = (n: number) => String(n).padStart(2, '0');
705344 Software engineer <span class="text-muted">/ Reno, Nevada</span>
706345 </p>
707346 <div class="mt-7 max-w-[38rem] space-y-4 text-[1.0625rem] leading-[1.65] text-ink/85">
708- <p>
709- I build backend systems in Rust and Python and run the cloud and security
710- infrastructure they depend on. I have shipped production software for over seven
711- years, starting with flight-analysis and manufacturing tooling for unmanned aircraft
712- at Arcturus UAV and AeroVironment.
713- </p>
714- <p>
715- Today I run Hygo, an independent software practice for logistics, aviation, and
716- trades clients. I am open to developer roles on defense programs.
717- </p>
347+ {intro.map((p) => <p>{p}</p>)}
718348 </div>
719349 <div class="mt-9 flex flex-wrap items-center gap-3">
720350 <a
@@ -922,10 +552,12 @@ const pad = (n: number) => String(n).padStart(2, '0');
922552 </footer>
923553 </div>
924554
925- <!-- Skill filter: fixed in the empty right margin, only where that margin exists (1690px+). -->
555+ <!-- Skill filter: fixed in the empty right margin, only where that margin exists (1690px+).
556+ Its width is set from JS to fill the gap between the work cards and the window edge;
557+ the bottom leaves room for the terminal launcher. -->
926558 <aside
927559 id="skill-filter"
928- class="fixed top-16 right-3 bottom-5 z-30 hidden w-[212px] overflow-y-auto px-1.5 min-[1690px]:block"
560+ class="fixed top-16 right-2 bottom-14 z-30 hidden w-[212px] overflow-y-auto px-1.5 min-[1690px]:block"
929561 aria-label="Filter by skill"
930562 >
931563 <SkillField groups={fieldGroups} width={FIELD_W} />
@@ -961,18 +593,52 @@ const pad = (n: number) => String(n).padStart(2, '0');
961593 ></svg>
962594 </aside>
963595
964- <!-- Command console: "/" or the Console button. -->
965- <dialog id="console" class="m-auto w-[min(44rem,calc(100vw-2rem))] border border-rule bg-paper p-0 text-ink backdrop:bg-black/70 backdrop:backdrop-blur-[2px]">
966- <div class={`flex items-center justify-between border-b border-rule px-4 py-2.5 ${mono}`}>
967- <span class="font-bold text-vol">Comms console</span>
968- <span class="text-muted">Esc to close</span>
596+ <!-- Terminal: docked bottom right, opens with "/" or the launcher. Questions go to DeepSeek through /__ai/chat. -->
597+ <button type="button" class="term-launch" data-term-open aria-controls="term" aria-expanded="false">
598+ <span class="text-vol" aria-hidden="true">&gt;</span>Ask about Lane<kbd class="term-kbd">/</kbd>
599+ </button>
600+ <section id="term" class="term" aria-label="Terminal: ask about Lane" hidden>
601+ <div class="term-bar">
602+ <span class="flex items-center gap-2"><span class="size-1.5 bg-vol" aria-hidden="true"></span>Terminal</span>
603+ <span class="flex items-center gap-3">
604+ <button type="button" class="term-btn" data-term-clear>Clear</button>
605+ <button type="button" class="term-btn" data-term-close aria-label="Close terminal">Esc</button>
606+ </span>
969607 </div>
970- <div class="h-[min(22rem,55vh)] overflow-y-auto p-4 font-mono text-[13px] leading-relaxed" data-console-out></div>
971- <form class="flex items-center gap-2 border-t border-rule px-4 py-3 font-mono text-[13px]" data-console-form>
972- <label for="consoleInput" class="shrink-0 text-vol">lh@gcs:~$</label>
973- <input id="consoleInput" class="min-w-0 flex-1 bg-transparent caret-vol outline-none" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send" />
608+ <div class="term-log" data-term-log role="log" aria-live="polite">
609+ <div class="term-welcome" data-term-welcome>
610+ <p class="term-hello"><span class="text-vol" aria-hidden="true">✻</span> Ask anything about Lane</p>
611+ <p class="text-muted">Work, skills, stack choices, availability. An AI model answers from Lane's profile on this page.</p>
612+ <p class="mt-2 text-muted">Try</p>
613+ <ul>
614+ <li><button type="button" class="term-suggest" data-term-suggest>What does Lane build in Rust?</button></li>
615+ <li><button type="button" class="term-suggest" data-term-suggest>What did Lane do at AeroVironment?</button></li>
616+ <li><button type="button" class="term-suggest" data-term-suggest>Is Lane open to defense roles?</button></li>
617+ <li><button type="button" class="term-suggest" data-term-suggest>What is Lane's strongest project?</button></li>
618+ </ul>
619+ </div>
620+ </div>
621+ <form class="term-form" data-term-form>
622+ <label class="term-input" for="termInput">
623+ <span class="text-vol" aria-hidden="true">&gt;</span>
624+ <input
625+ id="termInput"
626+ class="min-w-0 flex-1 bg-transparent caret-vol outline-none placeholder:text-muted/70"
627+ autocomplete="off"
628+ autocapitalize="off"
629+ spellcheck="false"
630+ enterkeyhint="send"
631+ maxlength="600"
632+ placeholder="Ask about Lane, or / for commands"
633+ aria-label="Ask about Lane, or type a slash command"
634+ />
635+ </label>
636+ <div class="term-menu" data-term-menu role="listbox" aria-label="Commands" hidden></div>
637+ <div class="term-hints" data-term-hints>
638+ <span>/ commands &nbsp; ↑ history &nbsp; esc close</span><span>deepseek-chat</span>
639+ </div>
974640 </form>
975- </dialog>
641+ </section>
976642
977643 <script>
978644 import { track, initSectionViews, initScrollDepth } from '../lib/track';
@@ -991,20 +657,31 @@ const pad = (n: number) => String(n).padStart(2, '0');
991657 initLiveStats();
992658 initTooltips();
993659
994- // Right-margin skill column: if it is taller than the window, narrow the
995- // bubble area (the SVGs scale with width) just enough to fit, so nothing
996- // scrolls. Taller windows get full-size bubbles.
660+ // Right-margin skill column: as wide as the gap between the work cards and
661+ // the window edge (8px from the cards), then, if it is taller than the
662+ // window, narrow the bubble area (the SVGs scale with width) just enough
663+ // to fit, so nothing scrolls.
997664 const skillCol = document.getElementById('skill-filter');
998665 const skillColMap = skillCol?.querySelector<HTMLElement>('[data-skill-map]');
666+ const workEl = document.getElementById('work');
999667 const fitSkillCol = () => {
1000668 if (!skillCol || !skillColMap || getComputedStyle(skillCol).display === 'none') return;
669+ const vw = document.documentElement.clientWidth;
670+ const gap = vw - (workEl?.getBoundingClientRect().right ?? vw - 220) - 16;
671+ skillCol.style.width = `${Math.round(Math.min(340, Math.max(200, gap)))}px`;
1001672 skillColMap.style.width = '';
673+ // Measure without a scrollbar; one that appears mid-fit narrows the
674+ // column, which shrinks the bubbles, and it never goes away again.
675+ skillCol.style.overflowY = 'hidden';
1002676 const over = skillCol.scrollHeight - skillCol.clientHeight;
1003- if (over <= 0) return;
1004- const svgH = [...skillColMap.querySelectorAll('svg')].reduce((h, svg) => h + svg.getBoundingClientRect().height, 0);
1005- const scale = Math.max(0.6, (svgH - over - 2) / svgH);
1006- skillColMap.style.width = `${(scale * 100).toFixed(2)}%`;
1007- skillColMap.style.marginInline = 'auto';
677+ if (over > 0) {
678+ const svgH = [...skillColMap.querySelectorAll('svg')].reduce((h, svg) => h + svg.getBoundingClientRect().height, 0);
679+ const scale = Math.max(0.6, (svgH - over - 2) / svgH);
680+ skillColMap.style.width = `${(scale * 100).toFixed(2)}%`;
681+ skillColMap.style.marginInline = 'auto';
682+ }
683+ // Scroll only when even the smallest bubbles do not fit.
684+ skillCol.style.overflowY = skillCol.scrollHeight > skillCol.clientHeight + 1 ? 'auto' : 'hidden';
1008685 };
1009686 fitSkillCol();
1010687 window.addEventListener('resize', fitSkillCol);
+7-0src/pages/llms.txt.ts
@@ -0,0 +1,7 @@
1+// Plain-text profile at /llms.txt: what the AI console answers from, and a
2+// readable summary for AI crawlers (the page itself sits behind Turnstile).
3+import type { APIRoute } from 'astro';
4+import { profileText } from '../data/profile';
5+
6+export const GET: APIRoute = () =>
7+ new Response(profileText(), { headers: { 'content-type': 'text/plain; charset=utf-8' } });
+236-0src/styles/site.css
@@ -140,3 +140,239 @@
140140 scrollbar-color: rgb(255 130 0 / 0.45) transparent;
141141 }
142142 }
143+
144+/* Docked terminal (src/lib/gcs/console.ts). Lines are created from JS, so
145+ these live in the global sheet. */
146+.term-launch {
147+ position: fixed;
148+ right: 0.75rem;
149+ bottom: 0.75rem;
150+ z-index: 45;
151+ display: flex;
152+ align-items: center;
153+ gap: 0.6rem;
154+ padding: 0.45rem 0.7rem;
155+ border: 1px solid var(--color-rule);
156+ border-radius: 2px;
157+ background: rgb(5 6 7 / 0.92);
158+ backdrop-filter: blur(6px);
159+ font-family: var(--font-mono);
160+ font-size: 12px;
161+ color: var(--color-ink);
162+ transition: border-color 0.15s, color 0.15s;
163+}
164+.term-launch:hover {
165+ border-color: var(--color-vol);
166+}
167+.term-kbd {
168+ margin-left: 0.25rem;
169+ padding: 0 0.35rem;
170+ border: 1px solid var(--color-rule);
171+ border-radius: 2px;
172+ color: var(--color-muted);
173+ font-size: 11px;
174+}
175+.term {
176+ position: fixed;
177+ right: 0.75rem;
178+ bottom: 0.75rem;
179+ z-index: 50;
180+ display: flex;
181+ flex-direction: column;
182+ width: min(36rem, calc(100vw - 1.5rem));
183+ height: min(32rem, calc(100dvh - 4.5rem));
184+ border: 1px solid rgb(255 130 0 / 0.45);
185+ border-radius: 4px;
186+ background: var(--color-paper);
187+ box-shadow: 0 18px 50px rgb(0 0 0 / 0.7);
188+ font-family: var(--font-mono);
189+ font-size: 12.5px;
190+ line-height: 1.55;
191+}
192+.term[hidden],
193+.term-launch[hidden] {
194+ display: none;
195+}
196+.term-bar {
197+ display: flex;
198+ align-items: center;
199+ justify-content: space-between;
200+ padding: 0.3rem 0.7rem;
201+ border-bottom: 1px solid var(--color-rule);
202+ font-size: 11px;
203+ letter-spacing: 0.1em;
204+ text-transform: uppercase;
205+ color: var(--color-muted);
206+}
207+.term-btn {
208+ color: var(--color-muted);
209+ text-transform: uppercase;
210+ letter-spacing: 0.1em;
211+ transition: color 0.15s;
212+}
213+.term-btn:hover {
214+ color: var(--color-vol);
215+}
216+.term-log {
217+ flex: 1;
218+ min-height: 0;
219+ overflow-y: auto;
220+ padding: 0.6rem 0.7rem 0.4rem;
221+ overscroll-behavior: contain;
222+}
223+.term-log > * + * {
224+ margin-top: 0.15rem;
225+}
226+.term-welcome {
227+ margin-bottom: 0.5rem;
228+ padding: 0.5rem 0.7rem;
229+ border: 1px solid rgb(255 130 0 / 0.45);
230+ border-radius: 4px;
231+}
232+.term-hello {
233+ color: var(--color-ink);
234+ font-weight: 700;
235+}
236+.term-suggest {
237+ display: flex;
238+ gap: 0.6rem;
239+ text-align: left;
240+ color: var(--color-muted);
241+ transition: color 0.1s;
242+}
243+.term-suggest::before {
244+ content: '>';
245+ color: var(--color-rock);
246+}
247+.term-suggest:hover,
248+.term-suggest:focus-visible {
249+ color: var(--color-ink);
250+}
251+.term-suggest:hover::before {
252+ color: var(--color-vol);
253+}
254+.term-q {
255+ display: flex;
256+ gap: 0.6rem;
257+ margin-top: 0.7rem !important;
258+ padding: 0.1rem 0.4rem;
259+ background: var(--color-panel);
260+ color: var(--color-muted);
261+ white-space: pre-wrap;
262+ overflow-wrap: anywhere;
263+}
264+.term-caret {
265+ color: var(--color-vol);
266+}
267+.term-a {
268+ display: flex;
269+ gap: 0.6rem;
270+ margin-top: 0.4rem !important;
271+ padding-left: 0.4rem;
272+}
273+.term-dot {
274+ flex: none;
275+ width: 6px;
276+ height: 6px;
277+ margin-top: 0.5em;
278+ border-radius: 50%;
279+ background: var(--color-vol);
280+}
281+.term-a-body {
282+ min-width: 0;
283+ white-space: pre-wrap;
284+ overflow-wrap: anywhere;
285+}
286+.term-out {
287+ padding-left: 0.4rem;
288+ white-space: pre-wrap;
289+ overflow-wrap: anywhere;
290+}
291+.term-out.is-vol {
292+ color: var(--color-vol);
293+}
294+.term-out.is-muted {
295+ color: var(--color-muted);
296+}
297+.term-out.is-err {
298+ color: #ff6b5e;
299+}
300+.term-link {
301+ color: var(--color-vol);
302+ text-decoration: underline;
303+ text-decoration-color: rgb(255 130 0 / 0.4);
304+ text-underline-offset: 2px;
305+}
306+.term-link:hover {
307+ color: var(--color-vol-ink);
308+}
309+.term-spin {
310+ display: flex;
311+ gap: 0.6rem;
312+ margin-top: 0.4rem !important;
313+ padding-left: 0.4rem;
314+ color: var(--color-vol);
315+}
316+.term-spin-glyph {
317+ display: inline-block;
318+ width: 1ch;
319+ text-align: center;
320+}
321+.term-form {
322+ padding: 0 0.7rem 0.45rem;
323+}
324+.term-input {
325+ display: flex;
326+ align-items: center;
327+ gap: 0.6rem;
328+ padding: 0.4rem 0.6rem;
329+ border: 1px solid var(--color-rock);
330+ border-radius: 4px;
331+ cursor: text;
332+}
333+.term-input:focus-within {
334+ border-color: rgb(255 130 0 / 0.6);
335+}
336+.term-hints {
337+ display: flex;
338+ justify-content: space-between;
339+ gap: 1rem;
340+ padding: 0.25rem 0.2rem 0;
341+ font-size: 10.5px;
342+ color: var(--color-muted);
343+}
344+.term-menu {
345+ padding-top: 0.25rem;
346+}
347+.term-opt {
348+ display: grid;
349+ grid-template-columns: 11.5rem 1fr;
350+ gap: 0.6rem;
351+ padding: 0 0.6rem;
352+ cursor: pointer;
353+ color: var(--color-ink);
354+}
355+.term-opt.is-active {
356+ color: var(--color-vol);
357+ background: rgb(255 130 0 / 0.08);
358+}
359+.term-opt.is-active .text-muted {
360+ color: var(--color-vol-ink);
361+}
362+@media (max-width: 639px) {
363+ .term-kbd {
364+ display: none;
365+ }
366+}
367+@media (max-width: 480px) {
368+ .term-opt {
369+ grid-template-columns: 1fr;
370+ gap: 0;
371+ }
372+ .term-opt .text-muted {
373+ display: none;
374+ }
375+ .term-hints > :first-child {
376+ display: none;
377+ }
378+}
+119-0worker/index.ts
@@ -16,6 +16,7 @@ interface Env {
1616 TURNSTILE_SITE_KEY: string;
1717 TURNSTILE_SECRET: string;
1818 RYBBIT_API_KEY: string;
19+ DEEPSEEK_API_KEY: string;
1920 }
2021
2122 const COOKIE = 'henslee_pass';
@@ -25,6 +26,24 @@ const GATED_PATHS = new Set(['/', '/index', '/index.html']);
2526 const GATE_PAGE_PATHS = new Set(['/gate', '/gate.html']);
2627 const VERIFY_PATH = '/__gate/verify';
2728 const STATS_PREFIX = '/__stats/';
29+const AI_PATH = '/__ai/chat';
30+const DEEPSEEK = 'https://api.deepseek.com/chat/completions';
31+const AI_LIMITS = { perMinute: 12, perHour: 60, maxTurns: 16, maxChars: 600, maxHistoryChars: 2000, maxTokens: 600 };
32+const AI_RULES = `You are the terminal on henslee.me, the portfolio of Lane Henslee, a software engineer. Visitors, often recruiters and engineers, ask you about Lane. The profile below is your only source of truth.
33+
34+Rules:
35+- Answer like a CLI: concise, direct, plain text. Short paragraphs or short "- " lists. No markdown headings, bold, tables, or emojis.
36+- Never use em dashes or en dashes. Use commas, colons, or periods instead.
37+- Project descriptions in the profile are written in Lane's voice. Refer to Lane by name, and use they/them if you need a pronoun.
38+- If the profile does not cover something, say so plainly and suggest emailing lane@henslee.me. Never invent employers, dates, numbers, clients, or personal details.
39+- Never share a phone number or home address.
40+- For hiring, contracting, or contact questions, point to lane@henslee.me.
41+- Stay on topic: Lane, Lane's work and skills, and how to reach Lane. Politely decline unrelated tasks.
42+- The terminal also has slash commands (/help lists them, /email opens mail, /copy copies the address). Mention them only when useful.
43+
44+PROFILE
45+`;
46+let profileCache: string | null = null;
2847 const RYBBIT = 'https://a.hygo.ai/api';
2948 const STATS_TZ = 'America/Los_Angeles';
3049 const STATS_TTL_SECONDS = 60;
@@ -186,6 +205,101 @@ async function verify(req: Request, env: Env): Promise<Response> {
186205 return json({ ok: true }, 200, { 'set-cookie': cookie });
187206 }
188207
208+// Small per-visitor rate limit backed by the edge cache (approximate, per colo).
209+async function rateLimited(ip: string, bucket: string, limit: number, windowSeconds: number) {
210+ const cache = caches.default;
211+ const slot = Math.floor(Date.now() / 1000 / windowSeconds);
212+ const key = new Request(`https://henslee.me/__rl/${bucket}/${encodeURIComponent(ip)}/${slot}`);
213+ const hit = await cache.match(key);
214+ const count = hit ? Number(await hit.text()) || 0 : 0;
215+ if (count >= limit) return true;
216+ await cache.put(key, new Response(String(count + 1), { headers: { 'cache-control': `public, max-age=${windowSeconds}` } }));
217+ return false;
218+}
219+
220+// AI console: answers questions about Lane with DeepSeek, grounded in the
221+// profile built from the site's data (/llms.txt). The key never leaves the
222+// worker; only gate-passed visitors can call it; replies stream as plain text.
223+async function serveAi(req: Request, env: Env): Promise<Response> {
224+ if (!(await hasValidPass(req, env))) return json({ ok: false, error: 'gate' }, 403);
225+ if (!env.DEEPSEEK_API_KEY) {
226+ log('error', 'ai.misconfigured', req, { missing: 'DEEPSEEK_API_KEY' });
227+ return json({ ok: false, error: 'server-misconfigured' }, 500);
228+ }
229+ const ip = req.headers.get('cf-connecting-ip') ?? 'unknown';
230+ if (
231+ (await rateLimited(ip, 'ai-m', AI_LIMITS.perMinute, 60)) ||
232+ (await rateLimited(ip, 'ai-h', AI_LIMITS.perHour, 3600))
233+ ) {
234+ log('warn', 'ai.rate_limited', req);
235+ return json({ ok: false, error: 'rate-limited' }, 429);
236+ }
237+
238+ type Msg = { role: 'user' | 'assistant'; content: string };
239+ const body = (await req.json().catch(() => null)) as { messages?: Msg[] } | null;
240+ const history = (body?.messages ?? [])
241+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
242+ .slice(-AI_LIMITS.maxTurns)
243+ .map((m) => ({ role: m.role, content: m.content.slice(0, AI_LIMITS.maxHistoryChars) }));
244+ const last = history[history.length - 1];
245+ if (!last || last.role !== 'user' || !last.content.trim() || last.content.length > AI_LIMITS.maxChars) {
246+ return json({ ok: false, error: 'bad-request' }, 400);
247+ }
248+
249+ if (!profileCache) {
250+ const res = await env.ASSETS.fetch(new Request(new URL('/llms.txt', req.url)));
251+ profileCache = res.ok ? await res.text() : '';
252+ }
253+
254+ const started = Date.now();
255+ const upstream = await fetch(DEEPSEEK, {
256+ method: 'POST',
257+ headers: { authorization: `Bearer ${env.DEEPSEEK_API_KEY}`, 'content-type': 'application/json' },
258+ body: JSON.stringify({
259+ model: 'deepseek-chat',
260+ stream: true,
261+ temperature: 0.4,
262+ max_tokens: AI_LIMITS.maxTokens,
263+ messages: [{ role: 'system', content: AI_RULES + profileCache }, ...history],
264+ }),
265+ });
266+ if (!upstream.ok || !upstream.body) {
267+ log('error', 'ai.upstream_failed', req, { status: upstream.status, ms: Date.now() - started });
268+ return json({ ok: false, error: 'upstream' }, 502);
269+ }
270+ log('info', 'ai.chat', req, { turns: history.length, chars: last.content.length });
271+
272+ // DeepSeek streams OpenAI-style SSE; pass along just the text deltas.
273+ const decoder = new TextDecoder();
274+ const encoder = new TextEncoder();
275+ let buffer = '';
276+ const stream = upstream.body.pipeThrough(
277+ new TransformStream<Uint8Array, Uint8Array>({
278+ transform(chunk, controller) {
279+ buffer += decoder.decode(chunk, { stream: true });
280+ const lines = buffer.split('\n');
281+ buffer = lines.pop() ?? '';
282+ for (const line of lines) {
283+ const data = line.startsWith('data:') ? line.slice(5).trim() : '';
284+ if (!data || data === '[DONE]') continue;
285+ try {
286+ const text = JSON.parse(data).choices?.[0]?.delta?.content;
287+ if (text) controller.enqueue(encoder.encode(text));
288+ } catch {
289+ /* partial or keep-alive line */
290+ }
291+ }
292+ },
293+ flush() {
294+ log('info', 'ai.done', req, { ms: Date.now() - started });
295+ },
296+ }),
297+ );
298+ return new Response(stream, {
299+ headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' },
300+ });
301+}
302+
189303 // Live traffic for a project card: daily visitors since launch, totals, and
190304 // visitors on the site right now. The Rybbit key stays here; the page only
191305 // gets aggregates. Only visitors who passed the gate can call it, and results
@@ -275,6 +389,11 @@ export default {
275389 async fetch(req, env, ctx): Promise<Response> {
276390 const url = new URL(req.url);
277391 try {
392+ if (url.pathname === AI_PATH) {
393+ if (req.method !== 'POST') return json({ ok: false, error: 'method-not-allowed' }, 405, { allow: 'POST' });
394+ return await serveAi(req, env);
395+ }
396+
278397 if (url.pathname.startsWith(STATS_PREFIX)) {
279398 if (req.method !== 'GET') return json({ ok: false, error: 'method-not-allowed' }, 405, { allow: 'GET' });
280399 return await serveStats(req, env, ctx);
+1-1wrangler.jsonc
@@ -9,7 +9,7 @@
99 "directory": "./dist",
1010 "binding": "ASSETS",
1111 // Only these paths hit the worker; all other requests are served directly.
12- "run_worker_first": ["/", "/index", "/index.html", "/gate", "/gate.html", "/__gate/*", "/__stats/*"]
12+ "run_worker_first": ["/", "/index", "/index.html", "/gate", "/gate.html", "/__gate/*", "/__stats/*", "/__ai/*"]
1313 },
1414 "vars": {
1515 // Public Turnstile site key (widget "henslee.me gate"). The matching