irongit

feat: ground control station redesign with interactive camera, PFD, radar, console

- Camera feed card: pointer slews the gimbal, click locks targets (DEV 01 on the face),
  ctrl+scroll or +/- zooms, EO / white-hot / ironbow thermal modes, HUD text inside brackets
- Primary flight display: mouse flies attitude and heading, scroll speed is airspeed,
  scroll position is altitude, autopilot levels off when idle; hideable
- Radar scope of selected work: blips jump to tracks and follow scroll
- Command console on "/" with help, whoami, tracks, email, copy, mode, lock, status
- Status bar with live UTC clock and scroll-spy nav, instrument-panel sections
- Dark display with Tennessee Orange as HUD amber, B612 Mono cockpit font
- Gate restyled as an IFF check; new share image
huncholanehuncholaneauthored
parent adb634ccommit 6fe40150a2de9a9672d54a113b3558c1c8188051Browse files

11 files changed, +1330 -149

+12-2package-lock.json
@@ -1,14 +1,15 @@
11 {
2- "name": "gigachad-gallery",
2+ "name": "henslee",
33 "version": "1.0.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
8- "name": "gigachad-gallery",
8+ "name": "henslee",
99 "version": "1.0.0",
1010 "dependencies": {
1111 "@fontsource-variable/archivo": "^5.3.0",
12+ "@fontsource/b612-mono": "^5.3.0",
1213 "astro": "^5.0.0"
1314 },
1415 "devDependencies": {
@@ -713,6 +714,15 @@
713714 "url": "https://github.com/sponsors/ayuhito"
714715 }
715716 },
717+ "node_modules/@fontsource/b612-mono": {
718+ "version": "5.3.0",
719+ "resolved": "https://registry.npmjs.org/@fontsource/b612-mono/-/b612-mono-5.3.0.tgz",
720+ "integrity": "sha512-G0Ihm+ekqqP5PZJ1zrx6GbeS2q4GDmYnM01zrNrPARRg64GoG0D2pTf+FsbDWvbg/9tVWFRmFIz8xYLjgFrUpg==",
721+ "license": "OFL-1.1",
722+ "funding": {
723+ "url": "https://github.com/sponsors/ayuhito"
724+ }
725+ },
716726 "node_modules/@img/colour": {
717727 "version": "1.1.0",
718728 "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+1-0package.json
@@ -11,6 +11,7 @@
1111 },
1212 "dependencies": {
1313 "@fontsource-variable/archivo": "^5.3.0",
14+ "@fontsource/b612-mono": "^5.3.0",
1415 "astro": "^5.0.0"
1516 },
1617 "devDependencies": {
+0-0public/og.jpg

Binary file changed.

+23-0src/components/Panel.astro
@@ -0,0 +1,23 @@
1+---
2+// An instrument panel: bordered box with orange corner brackets and a
3+// monospace title bar. `meta` shows on the right of the title bar.
4+interface Props {
5+ id: string;
6+ title: string;
7+ meta?: string;
8+}
9+const { id, title, meta } = Astro.props;
10+---
11+
12+<section id={id} data-section={id} class="relative mt-8 scroll-mt-16 border border-rule bg-panel/80 lg:mt-10">
13+ <div class="brackets absolute -inset-px" aria-hidden="true"></div>
14+ <div class="flex items-center justify-between gap-4 border-b border-rule px-5 py-2.5 font-mono text-[11px] tracking-[0.12em] uppercase md:px-8">
15+ <h2 class="flex items-center gap-2.5 font-bold text-vol">
16+ <span class="size-2 bg-vol" aria-hidden="true"></span>{title}
17+ </h2>
18+ {meta && <span class="text-right text-muted">{meta}</span>}
19+ </div>
20+ <div class="px-5 py-6 md:px-8 md:py-8">
21+ <slot />
22+ </div>
23+</section>
+180-0src/lib/gcs/console.ts
@@ -0,0 +1,180 @@
1+// Command console. Opens with "/" or the Console button. Commands navigate
2+// the page, open contact links, and drive the camera feed.
3+import { track } from '../track';
4+
5+const EMAIL = 'lane@henslee.me';
6+const LINKEDIN = 'https://www.linkedin.com/in/lanehenslee';
7+const GITHUB = 'https://github.com/huncholane';
8+
9+type Out = (text: string, tone?: 'vol' | 'muted' | 'err') => void;
10+
11+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')!;
17+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
18+ const history: string[] = [];
19+ let hIndex = -1;
20+ let greeted = false;
21+
22+ const print: Out = (text, tone) => {
23+ 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;
30+ };
31+
32+ const goTo = (id: string) => {
33+ dialog.close();
34+ document.getElementById(id)?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
35+ };
36+
37+ const tracks = () =>
38+ Array.from(document.querySelectorAll<HTMLElement>('[data-trk]')).map(
39+ (r) => `${r.dataset.trk} ${r.dataset.trkName}`,
40+ );
41+
42+ const commands: Record<string, { help?: string; run: (args: string[]) => void }> = {
43+ help: {
44+ help: 'list commands',
45+ run: () => {
46+ for (const [name, c] of Object.entries(commands)) if (c.help) print(` ${name.padEnd(10)} ${c.help}`, 'muted');
47+ },
48+ },
49+ whoami: {
50+ help: 'who runs this station',
51+ 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 in the DC area.');
55+ },
56+ },
57+ tracks: { help: 'list selected work', run: () => tracks().forEach((t) => print(t)) },
58+ work: { help: 'go to selected work', run: () => goTo('work') },
59+ exp: { help: 'go to experience', run: () => goTo('experience') },
60+ skills: { help: 'go to skills', run: () => goTo('skills') },
61+ contact: { help: 'go to contact', run: () => goTo('contact') },
62+ email: {
63+ help: 'write me an email',
64+ run: () => {
65+ print(`Opening mail to ${EMAIL}`, 'vol');
66+ track('contact_click', { channel: 'email', location: 'console' });
67+ window.location.href = `mailto:${EMAIL}`;
68+ },
69+ },
70+ copy: {
71+ help: 'copy my email address',
72+ run: () => {
73+ navigator.clipboard.writeText(EMAIL).then(
74+ () => {
75+ print(`Copied ${EMAIL}`, 'vol');
76+ track('email_copy', { result: 'ok', location: 'console' });
77+ },
78+ () => print(`Clipboard blocked. Address: ${EMAIL}`, 'err'),
79+ );
80+ },
81+ },
82+ linkedin: {
83+ help: 'open LinkedIn',
84+ run: () => {
85+ track('contact_click', { channel: 'linkedin', location: 'console' });
86+ window.open(LINKEDIN, '_blank', 'noopener');
87+ },
88+ },
89+ github: {
90+ help: 'open GitHub',
91+ run: () => {
92+ track('contact_click', { channel: 'github', location: 'console' });
93+ window.open(GITHUB, '_blank', 'noopener');
94+ },
95+ },
96+ mode: {
97+ help: 'camera mode: eo, wht, or iron',
98+ run: ([m]) => {
99+ if (!['eo', 'wht', 'iron'].includes(m ?? '')) return print('Usage: mode eo | wht | iron', 'err');
100+ document.dispatchEvent(new CustomEvent('gcs:mode', { detail: m }));
101+ print(`Sensor 1 set to ${m.toUpperCase()}`, 'vol');
102+ },
103+ },
104+ lock: {
105+ help: 're-acquire the primary target',
106+ run: () => {
107+ document.dispatchEvent(new CustomEvent('gcs:relock'));
108+ print('DEV 01 reacquired', 'vol');
109+ },
110+ },
111+ status: {
112+ help: 'system status',
113+ run: () => {
114+ print(`LINK OK UTC ${new Date().toISOString().slice(11, 19)}Z`);
115+ print(`ALT ${document.querySelector('[data-alt]')?.textContent ?? '-----'} FT HDG ${document.querySelector('[data-hdg]')?.textContent ?? '---'}`);
116+ },
117+ },
118+ clear: { help: 'clear the screen', run: () => (out.textContent = '') },
119+ exit: { help: 'close the console', run: () => dialog.close() },
120+ // Not listed in help.
121+ giga: { run: () => (window.location.href = '/giga') },
122+ };
123+
124+ function open(source: string) {
125+ if (dialog!.open) return;
126+ dialog!.showModal();
127+ if (!greeted) {
128+ greeted = true;
129+ print('GCS console ready. Type help for commands.', 'vol');
130+ }
131+ input.focus();
132+ track('console_open', { source });
133+ }
134+
135+ form.addEventListener('submit', (e) => {
136+ e.preventDefault();
137+ const raw = input.value.trim();
138+ input.value = '';
139+ if (!raw) return;
140+ history.unshift(raw);
141+ hIndex = -1;
142+ print(`lh@gcs:~$ ${raw}`, 'muted');
143+ const [name, ...args] = raw.toLowerCase().split(/\s+/);
144+ const cmd = commands[name];
145+ track('console_command', { cmd: cmd ? name : 'unknown' });
146+ if (!cmd) return print(`Unknown command: ${name}. Type help for the list.`, 'err');
147+ try {
148+ cmd.run(args);
149+ } catch (err) {
150+ console.error('[henslee] console command failed', name, err);
151+ print(`${name} failed. Check the browser console for details.`, 'err');
152+ }
153+ });
154+
155+ input.addEventListener('keydown', (e) => {
156+ if (e.key === 'ArrowUp' && history.length) {
157+ hIndex = Math.min(history.length - 1, hIndex + 1);
158+ input.value = history[hIndex];
159+ e.preventDefault();
160+ } else if (e.key === 'ArrowDown') {
161+ hIndex = Math.max(-1, hIndex - 1);
162+ input.value = hIndex === -1 ? '' : history[hIndex];
163+ e.preventDefault();
164+ }
165+ });
166+
167+ // Click on the backdrop closes it.
168+ dialog.addEventListener('click', (e) => {
169+ if (e.target === dialog) dialog.close();
170+ });
171+
172+ document.querySelectorAll('[data-open-console]').forEach((b) => b.addEventListener('click', () => open('button')));
173+ window.addEventListener('keydown', (e) => {
174+ if (e.key !== '/' && e.key !== '`') return;
175+ const t = e.target as HTMLElement;
176+ if (t.closest('input, textarea, [contenteditable="true"]')) return;
177+ e.preventDefault();
178+ open('key');
179+ });
180+}
+174-0src/lib/gcs/feed.ts
@@ -0,0 +1,174 @@
1+// Camera feed: pointer slews the gimbal, click designates a target, ctrl or
2+// cmd + wheel (or the +/- buttons and keys) zooms, and three sensor modes.
3+// Other modules drive it through `gcs:mode` and `gcs:relock` events.
4+import { track } from '../track';
5+
6+const BASE = { lat: 25.1972, lon: 55.2744 }; // Burj Khalifa
7+const FACE = { x: 77, y: 35, w: 26, h: 21 }; // face position in the 4:5 crop, in %
8+const MIN_ZOOM = 1.12; // a little headroom so slewing has somewhere to go
9+const MAX_ZOOM = 4;
10+const MODE_NAMES: Record<string, string> = { eo: 'EO', wht: 'IR WHT', iron: 'IR IRON' };
11+
12+const clamp = (v: number, lo = -1, hi = 1) => Math.min(hi, Math.max(lo, v));
13+const pad = (n: number, w = 2) => String(n).padStart(w, '0');
14+const fmtLat = (v: number) => `${v.toFixed(4)}${v >= 0 ? 'N' : 'S'}`;
15+const fmtLon = (v: number) => `${pad(Math.floor(Math.abs(v)), 3)}${(Math.abs(v) % 1).toFixed(4).slice(1)}${v >= 0 ? 'E' : 'W'}`;
16+
17+export function initFeed() {
18+ const feed = document.querySelector<HTMLElement>('.feed');
19+ if (!feed) return;
20+ const view = feed.querySelector<HTMLElement>('.feed-view')!;
21+ const layer = feed.querySelector<HTMLElement>('.feed-layer')!;
22+ const box = feed.querySelector<HTMLElement>('.lockbox')!;
23+ const boxLabel = box.querySelector<HTMLElement>('.lock-label')!;
24+ const azel = feed.querySelector<HTMLElement>('[data-azel]');
25+ const zoomOut = feed.querySelector<HTMLElement>('[data-zoom]');
26+ const coords = feed.querySelector<HTMLElement>('[data-coords]');
27+ const fovs = feed.querySelectorAll<HTMLElement>('[data-fov]');
28+ const tgtLabels = feed.querySelectorAll<HTMLElement>('[data-tgt]');
29+ const modeButtons = feed.querySelectorAll<HTMLButtonElement>('[data-set-mode]');
30+
31+ let zoom = MIN_ZOOM;
32+ let dx = 0;
33+ let dy = 0;
34+ let tgt = 1;
35+ let zoomTrackTimer: ReturnType<typeof setTimeout> | undefined;
36+
37+ function render() {
38+ // Max pan (in the layer's own %) that still keeps the image covering the view.
39+ const k = ((zoom - 1) / (2 * zoom)) * 100;
40+ layer.style.transform = `scale(${zoom.toFixed(3)}) translate(${(-dx * k).toFixed(2)}%, ${(-dy * k).toFixed(2)}%)`;
41+ layer.style.setProperty('--z', zoom.toFixed(3));
42+ if (azel) azel.textContent = `AZ ${(112.4 + dx * 14).toFixed(1).padStart(5, '0')} EL ${(dy * -9 - 4.2).toFixed(1)}`;
43+ if (zoomOut) zoomOut.textContent = `${zoom.toFixed(1)}X`;
44+ const fov = zoom < 1.6 ? 'WFOV' : zoom < 2.8 ? 'MFOV' : 'NFOV';
45+ fovs.forEach((f) => (f.textContent = fov));
46+ }
47+
48+ function setZoom(next: number, source: string) {
49+ const z = clamp(next, MIN_ZOOM, MAX_ZOOM);
50+ if (Math.abs(z - zoom) < 0.001) return;
51+ zoom = z;
52+ render();
53+ clearTimeout(zoomTrackTimer);
54+ zoomTrackTimer = setTimeout(() => track('feed_zoom', { zoom: Number(zoom.toFixed(1)), source }), 600);
55+ }
56+
57+ function lockAt(x: number, y: number, w: number, h: number, label: string) {
58+ box.style.left = `${x}%`;
59+ box.style.top = `${y}%`;
60+ box.style.width = `${w}%`;
61+ box.style.height = `${h}%`;
62+ boxLabel.textContent = label;
63+ box.classList.remove('acquiring');
64+ void box.offsetWidth; // restart the acquire animation
65+ box.classList.add('acquiring');
66+ const lat = BASE.lat + (FACE.y - y) * 0.00004;
67+ const lon = BASE.lon + (x - FACE.x) * 0.00004;
68+ if (coords) coords.textContent = `${fmtLat(lat)} ${fmtLon(lon)}`;
69+ }
70+
71+ function designate(clientX: number, clientY: number, source: string) {
72+ const r = layer.getBoundingClientRect();
73+ const x = clamp(((clientX - r.left) / r.width) * 100, 4, 96);
74+ const y = clamp(((clientY - r.top) / r.height) * 100, 4, 96);
75+ tgt += 1;
76+ tgtLabels.forEach((t) => (t.textContent = `TRK ${pad(tgt)}`));
77+ lockAt(x, y, 16, 13, `TGT ${pad(tgt)} LOCK`);
78+ track('feed_designate', { target: tgt, source });
79+ }
80+
81+ function relock() {
82+ tgt = 1;
83+ tgtLabels.forEach((t) => (t.textContent = 'TRK 01'));
84+ lockAt(FACE.x, FACE.y, FACE.w, FACE.h, 'DEV 01 LOCK');
85+ }
86+
87+ function setMode(mode: string, source: string) {
88+ if (!MODE_NAMES[mode] || feed!.dataset.mode === mode) return;
89+ feed!.dataset.mode = mode;
90+ modeButtons.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.setMode === mode)));
91+ feed!.querySelectorAll('[data-mode-label]').forEach((el) => (el.textContent = MODE_NAMES[mode]));
92+ track('sensor_mode', { mode, source });
93+ }
94+
95+ // Pointer slews the gimbal (mouse only; touch just taps to designate).
96+ view.addEventListener('pointermove', (e) => {
97+ if (e.pointerType !== 'mouse') return;
98+ const r = view.getBoundingClientRect();
99+ dx = clamp(((e.clientX - r.left) / r.width - 0.5) * 2);
100+ dy = clamp(((e.clientY - r.top) / r.height - 0.5) * 2);
101+ render();
102+ });
103+ view.addEventListener('pointerleave', () => {
104+ dx = 0;
105+ dy = 0;
106+ render();
107+ });
108+ view.addEventListener('click', (e) => {
109+ if ((e.target as Element).closest('button')) return;
110+ designate(e.clientX, e.clientY, 'click');
111+ });
112+
113+ // Like a map: plain wheel scrolls the page, ctrl or cmd + wheel zooms.
114+ view.addEventListener(
115+ 'wheel',
116+ (e) => {
117+ if (!e.ctrlKey && !e.metaKey) return;
118+ e.preventDefault();
119+ setZoom(zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12), 'wheel');
120+ },
121+ { passive: false },
122+ );
123+
124+ view.addEventListener('keydown', (e) => {
125+ const step = 0.2;
126+ switch (e.key) {
127+ case 'ArrowLeft':
128+ dx = clamp(dx - step);
129+ break;
130+ case 'ArrowRight':
131+ dx = clamp(dx + step);
132+ break;
133+ case 'ArrowUp':
134+ dy = clamp(dy - step);
135+ break;
136+ case 'ArrowDown':
137+ dy = clamp(dy + step);
138+ break;
139+ case '+':
140+ case '=':
141+ setZoom(zoom * 1.25, 'key');
142+ break;
143+ case '-':
144+ case '_':
145+ setZoom(zoom / 1.25, 'key');
146+ break;
147+ case 'Enter':
148+ case ' ': {
149+ const r = view.getBoundingClientRect();
150+ designate(r.left + r.width / 2, r.top + r.height / 2, 'key');
151+ break;
152+ }
153+ default:
154+ return;
155+ }
156+ e.preventDefault();
157+ render();
158+ });
159+
160+ feed.querySelector('[data-zoom-in]')?.addEventListener('click', () => setZoom(zoom * 1.25, 'button'));
161+ feed.querySelector('[data-zoom-out]')?.addEventListener('click', () => setZoom(zoom / 1.25, 'button'));
162+ modeButtons.forEach((b) => b.addEventListener('click', () => setMode(b.dataset.setMode ?? 'eo', 'button')));
163+
164+ document.addEventListener('gcs:mode', (e) => setMode(String((e as CustomEvent).detail), 'console'));
165+ document.addEventListener('gcs:relock', () => {
166+ zoom = MIN_ZOOM;
167+ dx = 0;
168+ dy = 0;
169+ render();
170+ relock();
171+ });
172+
173+ render();
174+}
+134-0src/lib/gcs/pfd.ts
@@ -0,0 +1,134 @@
1+// Primary flight display: the mouse flies the attitude and heading, scroll
2+// speed is airspeed, scroll position is altitude. After a few idle seconds
3+// the "autopilot" levels the wings. Runs a rAF loop only while needles move.
4+import { track } from '../track';
5+
6+const IDLE_MS = 5000;
7+const PX_PER_DEG_PITCH = 3;
8+const PX_PER_DEG_HDG = 4;
9+
10+export function initPfd() {
11+ const root = document.querySelector<HTMLElement>('[data-pfd]');
12+ if (!root) return;
13+ const horizon = root.querySelector<HTMLElement>('[data-horizon]')!;
14+ const spdOut = root.querySelector<HTMLElement>('[data-spd]')!;
15+ const altOut = root.querySelector<HTMLElement>('[data-alt]')!;
16+ const hdgOut = root.querySelector<HTMLElement>('[data-hdg]')!;
17+ const spdTape = root.querySelector<HTMLElement>('[data-spd-tape]')!;
18+ const altTape = root.querySelector<HTMLElement>('[data-alt-tape]')!;
19+ const hdgTape = root.querySelector<HTMLElement>('[data-hdg-tape]')!;
20+ const ap = root.querySelector<HTMLElement>('[data-ap]')!;
21+ const toggle = root.querySelector<HTMLButtonElement>('[data-pfd-toggle]')!;
22+
23+ // Current and target values.
24+ const cur = { roll: 0, pitch: 0, hdg: 270, spd: 0, alt: 0 };
25+ const tgt = { roll: 0, pitch: 0, hdg: 270, spd: 0, alt: 0 };
26+ let lastY = window.scrollY;
27+ let lastT = performance.now();
28+ let lastMove = 0;
29+ let running = false;
30+ let autopilot = true;
31+
32+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
33+
34+ function setAutopilot(on: boolean) {
35+ if (on === autopilot) return;
36+ autopilot = on;
37+ ap.textContent = on ? 'AP ON' : 'AP OFF';
38+ ap.classList.toggle('text-ok', on);
39+ }
40+
41+ function frame(now: number) {
42+ const dt = Math.max(1, now - lastT);
43+ lastT = now;
44+
45+ // Airspeed from scroll velocity (px/s), decays back to zero.
46+ const y = window.scrollY;
47+ const v = (Math.abs(y - lastY) / dt) * 1000;
48+ lastY = y;
49+ tgt.spd = Math.min(999, v * 0.3);
50+ const max = document.documentElement.scrollHeight - window.innerHeight;
51+ tgt.alt = max > 0 ? Math.min(1, Math.max(0, y / max)) * 41000 : 0;
52+
53+ if (now - lastMove > IDLE_MS) {
54+ setAutopilot(true);
55+ tgt.roll = 0;
56+ tgt.pitch = 0;
57+ }
58+
59+ cur.roll = lerp(cur.roll, tgt.roll, 0.12);
60+ cur.pitch = lerp(cur.pitch, tgt.pitch, 0.12);
61+ // Shortest way around the compass.
62+ const dh = ((tgt.hdg - cur.hdg + 540) % 360) - 180;
63+ cur.hdg = (cur.hdg + dh * 0.08 + 360) % 360;
64+ cur.spd = lerp(cur.spd, tgt.spd, 0.18);
65+ cur.alt = lerp(cur.alt, tgt.alt, 0.2);
66+
67+ horizon.style.transform = `rotate(${(-cur.roll).toFixed(2)}deg) translateY(${(cur.pitch * PX_PER_DEG_PITCH).toFixed(1)}px)`;
68+ spdOut.textContent = String(Math.round(cur.spd)).padStart(3, '0');
69+ altOut.textContent = String(Math.round(cur.alt / 10) * 10).padStart(5, '0');
70+ hdgOut.textContent = String(Math.round(cur.hdg) % 360).padStart(3, '0');
71+ spdTape.style.setProperty('--shift', `${(cur.spd * 0.6).toFixed(1)}px`);
72+ altTape.style.setProperty('--shift', `${(cur.alt * 0.02).toFixed(1)}px`);
73+ hdgTape.style.setProperty('--shift', `${(-cur.hdg * PX_PER_DEG_HDG).toFixed(1)}px`);
74+
75+ const settled =
76+ Math.abs(cur.roll - tgt.roll) < 0.05 &&
77+ Math.abs(cur.pitch - tgt.pitch) < 0.05 &&
78+ Math.abs(dh) < 0.05 &&
79+ cur.spd < 0.5 &&
80+ tgt.spd < 0.5 &&
81+ Math.abs(cur.alt - tgt.alt) < 1 &&
82+ (autopilot || now - lastMove > IDLE_MS + 1500);
83+ if (settled && autopilot) {
84+ running = false;
85+ return;
86+ }
87+ requestAnimationFrame(frame);
88+ }
89+
90+ function wake() {
91+ if (running || root!.dataset.min === 'true') return;
92+ running = true;
93+ lastT = performance.now();
94+ requestAnimationFrame(frame);
95+ }
96+
97+ window.addEventListener(
98+ 'mousemove',
99+ (e) => {
100+ lastMove = performance.now();
101+ setAutopilot(false);
102+ const nx = e.clientX / window.innerWidth - 0.5;
103+ const ny = e.clientY / window.innerHeight - 0.5;
104+ tgt.roll = nx * 50;
105+ tgt.pitch = -ny * 24;
106+ tgt.hdg = (270 + nx * 90 + 360) % 360;
107+ wake();
108+ },
109+ { passive: true },
110+ );
111+ window.addEventListener('scroll', wake, { passive: true });
112+
113+ function setMin(min: boolean, user: boolean) {
114+ root!.dataset.min = String(min);
115+ toggle.textContent = min ? 'Show' : 'Hide';
116+ toggle.setAttribute('aria-expanded', String(!min));
117+ try {
118+ localStorage.setItem('pfd-min', String(min));
119+ } catch {
120+ /* storage blocked: the setting just won't persist */
121+ }
122+ if (user) track('pfd_toggle', { state: min ? 'hidden' : 'shown' });
123+ if (!min) wake();
124+ }
125+ toggle.addEventListener('click', () => setMin(root.dataset.min !== 'true', true));
126+ let saved = false;
127+ try {
128+ saved = localStorage.getItem('pfd-min') === 'true';
129+ } catch {
130+ /* ignore */
131+ }
132+ setMin(saved, false);
133+ wake();
134+}
+51-0src/lib/gcs/radar.ts
@@ -0,0 +1,51 @@
1+// Radar scope for the Selected work panel. Each track is a blip; selecting a
2+// blip scrolls to that track. As tracks scroll through view, their blip lights.
3+import { track } from '../track';
4+
5+export function initRadar() {
6+ const radar = document.querySelector<HTMLElement>('[data-radar]');
7+ if (!radar) return;
8+ const readout = document.querySelector<HTMLElement>('[data-radar-readout]');
9+ const blips = Array.from(radar.querySelectorAll<HTMLButtonElement>('[data-blip]'));
10+ const rows = Array.from(document.querySelectorAll<HTMLElement>('[data-trk]'));
11+ const idle = readout?.textContent ?? '';
12+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
13+
14+ const describe = (b: HTMLButtonElement) => `${b.dataset.id} ${b.dataset.name} BRG ${b.dataset.brg} RNG ${b.dataset.rng}`;
15+
16+ function setActive(id: string | null) {
17+ blips.forEach((b) => b.classList.toggle('is-active', b.dataset.id === id));
18+ }
19+
20+ blips.forEach((b) => {
21+ const show = () => readout && (readout.textContent = describe(b));
22+ const hide = () => readout && (readout.textContent = idle);
23+ b.addEventListener('mouseenter', show);
24+ b.addEventListener('focus', show);
25+ b.addEventListener('mouseleave', hide);
26+ b.addEventListener('blur', hide);
27+ b.addEventListener('click', () => {
28+ const row = rows.find((r) => r.dataset.trk === b.dataset.id);
29+ if (!row) return;
30+ row.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' });
31+ row.classList.remove('is-selected');
32+ void row.offsetWidth;
33+ row.classList.add('is-selected');
34+ setTimeout(() => row.classList.remove('is-selected'), 1800);
35+ track('radar_select', { track: b.dataset.id ?? '', name: b.dataset.name ?? '' });
36+ });
37+ });
38+
39+ // Light the blip for whichever track crosses the middle of the viewport.
40+ if ('IntersectionObserver' in window) {
41+ const io = new IntersectionObserver(
42+ (entries) => {
43+ for (const e of entries) {
44+ if (e.isIntersecting) setActive((e.target as HTMLElement).dataset.trk ?? null);
45+ }
46+ },
47+ { rootMargin: '-45% 0px -45% 0px' },
48+ );
49+ rows.forEach((r) => io.observe(r));
50+ }
51+}
+40-16src/pages/gate.astro
@@ -3,6 +3,8 @@
33 // The worker rewrites data-sitekey with the real key; the default here is
44 // Cloudflare's always-pass test key so `astro dev` renders a working widget.
55 import '@fontsource-variable/archivo/wdth.css';
6+import '@fontsource/b612-mono/400.css';
7+import '@fontsource/b612-mono/700.css';
68 import '../styles/site.css';
79 import Analytics from '../components/Analytics.astro';
810 ---
@@ -14,7 +16,7 @@ import Analytics from '../components/Analytics.astro';
1416 <meta name="viewport" content="width=device-width, initial-scale=1" />
1517 <title>Lane Henslee</title>
1618 <meta name="description" content="Lane Henslee, software engineer." />
17- <meta name="theme-color" content="#ff8200" />
19+ <meta name="theme-color" content="#050607" />
1820 <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
1921 <link rel="preconnect" href="https://challenges.cloudflare.com" />
2022 <script is:inline>
@@ -23,21 +25,42 @@ import Analytics from '../components/Analytics.astro';
2325 </script>
2426 <Analytics />
2527 </head>
26- <body class="grid min-h-svh place-items-center bg-paper px-5 text-ink">
27- <div class="checker fixed inset-x-0 top-0 h-5" aria-hidden="true"></div>
28- <main class="w-full max-w-[22rem] py-16">
29- <h1 class="text-[2.6rem] leading-[0.9] font-extrabold tracking-[-0.03em] font-stretch-[125%]">
30- Lane<br />Henslee
31- </h1>
32- <p class="mt-5 leading-[1.6] text-ink/80">
33- Running a quick check to confirm you are a person. The page opens as soon as it passes.
34- </p>
35- <div id="widget" class="mt-7 min-h-[65px]" data-sitekey="1x00000000000000000000AA"></div>
36- <p id="status" class="mt-3 min-h-[1.5em] text-sm text-muted" role="status" aria-live="polite"></p>
28+ <body class="grid-bg grid min-h-svh place-items-center bg-paper px-5 text-ink">
29+ <main class="relative w-full max-w-[24rem] border border-rule bg-panel/80">
30+ <div class="brackets absolute -inset-px" aria-hidden="true"></div>
31+ <div class="flex items-center justify-between border-b border-rule px-5 py-2.5 font-mono text-[11px] tracking-[0.12em] uppercase">
32+ <span class="flex items-center gap-2.5 font-bold text-vol"><span class="size-2 bg-vol" aria-hidden="true"></span>IFF check</span>
33+ <span class="iff-blink text-muted">Interrogating</span>
34+ </div>
35+ <div class="px-5 py-7">
36+ <h1 class="text-[2.6rem] leading-[0.9] font-extrabold tracking-[-0.03em] font-stretch-[125%]">
37+ Lane<br />Henslee
38+ </h1>
39+ <p class="mt-5 leading-[1.6] text-ink/80">
40+ Confirming you are a person before opening the station. It usually passes on its own.
41+ </p>
42+ <div id="widget" class="mt-7 min-h-[65px]" data-sitekey="1x00000000000000000000AA"></div>
43+ <p id="status" class="mt-3 min-h-[1.5em] font-mono text-xs tracking-[0.06em] text-muted uppercase" role="status" aria-live="polite"></p>
44+ </div>
3745 <noscript>
38- <p class="mt-3 text-sm">This check needs JavaScript. Turn it on and reload the page.</p>
46+ <p class="px-5 pb-6 text-sm">This check needs JavaScript. Turn it on and reload the page.</p>
3947 </noscript>
4048 </main>
49+ <style>
50+ .iff-blink {
51+ animation: iff 1.2s steps(1) infinite;
52+ }
53+ @keyframes iff {
54+ 50% {
55+ opacity: 0.3;
56+ }
57+ }
58+ @media (prefers-reduced-motion: reduce) {
59+ .iff-blink {
60+ animation: none;
61+ }
62+ }
63+ </style>
4164
4265 <script>
4366 import { track } from '../lib/track';
@@ -59,12 +82,13 @@ import Analytics from '../components/Analytics.astro';
5982
6083 function say(msg: string, isError = false) {
6184 status.textContent = msg;
62- status.classList.toggle('text-red-700', isError);
85+ status.classList.toggle('text-[#ff6b5e]', isError);
6386 status.classList.toggle('text-muted', !isError);
6487 }
6588
6689 async function onToken(token: string) {
67- say('Verified. Opening the page.');
90+ say('Squawk verified. Opening the station.');
91+ document.querySelector('.iff-blink')?.classList.remove('iff-blink');
6892 try {
6993 const res = await fetch('/__gate/verify', {
7094 method: 'POST',
@@ -95,7 +119,7 @@ import Analytics from '../components/Analytics.astro';
95119 widgetId = w.turnstile!.render(widget, {
96120 sitekey,
97121 action: 'gate',
98- theme: 'light',
122+ theme: 'dark',
99123 callback: onToken,
100124 'error-callback': (code: string) => {
101125 console.warn('[henslee] turnstile error', code);
+664-117src/pages/index.astro
@@ -1,8 +1,11 @@
11 ---
22 import '@fontsource-variable/archivo/wdth.css';
3+import '@fontsource/b612-mono/400.css';
4+import '@fontsource/b612-mono/700.css';
35 import '../styles/site.css';
46 import { Picture } from 'astro:assets';
57 import Analytics from '../components/Analytics.astro';
8+import Panel from '../components/Panel.astro';
69 import photo from '../assets/lane-dubai.jpg';
710
811 const email = 'lane@henslee.me';
@@ -139,12 +142,34 @@ const jsonLd = {
139142 sameAs: [linkedin, github],
140143 };
141144
142-const h2 = 'text-2xl font-bold font-stretch-[112%] tracking-[-0.01em]';
145+
143146 const h3 = 'text-lg font-semibold font-stretch-[106%] leading-snug';
144-const meta = 'text-sm text-muted font-stretch-[87.5%] tabular-nums';
147+const mono = 'font-mono text-[11px] tracking-[0.1em] uppercase';
148+const meta = 'font-mono text-xs text-muted tabular-nums';
145149 const row = 'grid gap-2 md:grid-cols-[12rem_minmax(0,1fr)] md:gap-10';
146150 const textLink =
147151 'underline decoration-rule decoration-2 underline-offset-[5px] transition-colors hover:text-vol-ink hover:decoration-vol';
152+const navLink = 'transition-colors hover:text-vol aria-[current=true]:text-vol';
153+const ghostBtn = `inline-flex items-center rounded-[2px] border border-rule px-4 py-3 ${mono} text-ink transition-colors hover:border-vol hover:text-vol`;
154+const hud = 'hud-text absolute font-mono text-[10px] leading-relaxed tracking-[0.1em] text-white/85';
155+const pad = (n: number) => String(n).padStart(2, '0');
156+
157+// Radar blips: spread the tracks around the scope at fixed bearings and ranges.
158+const blips = work.map((w, i) => {
159+ const brg = (i * 67 + 35) % 360;
160+ const rng = [0.38, 0.64, 0.86][i % 3];
161+ const rad = (brg * Math.PI) / 180;
162+ return {
163+ id: `TRK ${pad(i + 1)}`,
164+ num: pad(i + 1),
165+ name: w.name,
166+ brg: String(brg).padStart(3, '0'),
167+ rng: (rng * 40).toFixed(0),
168+ x: 50 + Math.sin(rad) * rng * 50,
169+ y: 50 - Math.cos(rad) * rng * 50,
170+ };
171+});
172+const ladder = [-20, -10, 10, 20];
148173 ---
149174
150175 <!doctype html>
@@ -156,7 +181,7 @@ const textLink =
156181 <title>{title}</title>
157182 <meta name="description" content={description} />
158183 <link rel="canonical" href={canonical} />
159- <meta name="theme-color" content="#ff8200" />
184+ <meta name="theme-color" content="#050607" />
160185 <meta property="og:type" content="profile" />
161186 <meta property="og:title" content={title} />
162187 <meta property="og:description" content={description} />
@@ -173,44 +198,130 @@ const textLink =
173198 </script>
174199 <Analytics />
175200 </head>
176- <body id="top" class="bg-paper text-ink">
177- <div class="checker h-5" aria-hidden="true"></div>
178- <div class="mx-auto max-w-[76rem] px-5 sm:px-10">
179- <header class="hidden items-center justify-end gap-7 pt-8 text-sm font-medium sm:flex">
180- <a class="hover:text-vol-ink" href="#work" data-track="nav_click" data-track-target="work">Work</a>
181- <a class="hover:text-vol-ink" href="#experience" data-track="nav_click" data-track-target="experience">Experience</a>
182- <a class="hover:text-vol-ink" href="#skills" data-track="nav_click" data-track-target="skills">Skills</a>
183- <a class="hover:text-vol-ink" href="#contact" data-track="nav_click" data-track-target="contact">Contact</a>
184- </header>
201+ <body id="top" class="grid-bg bg-paper text-ink">
202+ <!-- Thermal false-color palette for the camera feed's IRON mode. -->
203+ <svg class="absolute size-0" aria-hidden="true" focusable="false">
204+ <filter id="ironbow" color-interpolation-filters="sRGB">
205+ <feColorMatrix type="matrix" values="0.3 0.59 0.11 0 0 0.3 0.59 0.11 0 0 0.3 0.59 0.11 0 0 0 0 0 1 0" />
206+ <feComponentTransfer>
207+ <feFuncR type="table" tableValues="0.02 0.25 0.55 0.85 1 1 1" />
208+ <feFuncG type="table" tableValues="0 0 0.05 0.25 0.55 0.85 1" />
209+ <feFuncB type="table" tableValues="0.08 0.4 0.55 0.3 0.05 0.25 1" />
210+ </feComponentTransfer>
211+ </filter>
212+ </svg>
185213
214+ <!-- Status bar -->
215+ <header class="fixed inset-x-0 top-0 z-40 border-b border-rule bg-paper/90 backdrop-blur">
216+ <div class={`mx-auto flex h-10 max-w-[76rem] items-center gap-5 px-5 sm:px-10 ${mono}`}>
217+ <a href="#top" class="font-bold text-vol" data-track="nav_click" data-track-target="top">Henslee<span class="text-muted">/GCS</span></a>
218+ <span class="hidden items-center gap-1.5 text-muted md:inline-flex">
219+ <span class="size-1.5 bg-ok" aria-hidden="true"></span>Link ok
220+ </span>
221+ <span class="hidden text-muted lg:inline">39.5296N 119.8138W</span>
222+ <div class="ml-auto flex items-center gap-5">
223+ <nav class="hidden gap-5 sm:flex" aria-label="Sections">
224+ <a class={navLink} href="#work" data-nav="work" data-track="nav_click" data-track-target="work">Work</a>
225+ <a class={navLink} href="#experience" data-nav="experience" data-track="nav_click" data-track-target="experience">Experience</a>
226+ <a class={navLink} href="#skills" data-nav="skills" data-track="nav_click" data-track-target="skills">Skills</a>
227+ <a class={navLink} href="#contact" data-nav="contact" data-track="nav_click" data-track-target="contact">Contact</a>
228+ </nav>
229+ <span class="tabular-nums" aria-label="Current UTC time"><span class="text-muted">UTC </span><span data-clock>--:--:--</span>Z</span>
230+ <button type="button" class="rounded-[2px] border border-rule px-2 py-1 transition-colors hover:border-vol hover:text-vol" data-open-console>
231+ Console <kbd class="ml-1 hidden text-muted sm:inline">/</kbd>
232+ </button>
233+ </div>
234+ </div>
235+ </header>
236+
237+ <div class="mx-auto max-w-[76rem] px-5 pb-10 sm:px-10">
186238 <section
187239 data-section="intro"
188- class="grid gap-10 pt-8 pb-16 sm:pt-12 lg:grid-cols-[minmax(0,1fr)_22rem] lg:items-start lg:gap-16 lg:pt-20 lg:pb-24 xl:grid-cols-[minmax(0,1fr)_24rem]"
240+ class="grid gap-10 pt-20 pb-6 sm:pt-24 lg:grid-cols-[minmax(0,1fr)_24rem] lg:items-start lg:gap-14 lg:pt-28 lg:pb-10"
189241 >
190- <figure class="m-0 w-full max-w-[22rem] rounded-[4px] border border-rule bg-white p-2 shadow-[0_18px_40px_-24px_rgba(42,43,45,0.4)] lg:order-last lg:max-w-none">
191- <Picture
192- src={photo}
193- formats={['avif', 'webp']}
194- widths={[420, 698]}
195- sizes="(min-width: 1024px) 24rem, 22rem"
196- alt="Lane Henslee at night in downtown Dubai, with the Burj Khalifa lit blue behind him"
197- loading="eager"
198- fetchpriority="high"
199- pictureAttributes={{ class: 'block aspect-[4/5] overflow-hidden rounded-[2px] bg-ink' }}
200- class="h-full w-full object-cover object-[70%_22%]"
201- />
202- <figcaption class="px-1 pt-2 pb-0.5 text-xs text-muted font-stretch-[87.5%]">Downtown Dubai</figcaption>
242+ <!-- Camera feed -->
243+ <figure class="feed m-0 w-full max-w-[24rem] border border-rule bg-panel p-2 lg:order-last lg:max-w-none" data-mode="eo">
244+ <div class={`flex justify-between px-1 pb-2 ${mono} text-muted`}>
245+ <span>Sensor 1 <span class="text-ink" data-mode-label>EO</span></span>
246+ <span data-tgt>TRK 01</span>
247+ </div>
248+ <div
249+ class="feed-view relative aspect-[4/5] cursor-crosshair overflow-hidden bg-black select-none"
250+ tabindex="0"
251+ aria-label="Camera feed. Move the pointer to slew, click to lock a target, control or command plus scroll to zoom. With the keyboard: arrows slew, plus and minus zoom, Enter locks."
252+ >
253+ <div class="feed-layer absolute inset-0">
254+ <Picture
255+ src={photo}
256+ formats={['avif', 'webp']}
257+ widths={[420, 698]}
258+ sizes="(min-width: 1024px) 24rem, 22rem"
259+ alt="Lane Henslee at night in downtown Dubai, with the Burj Khalifa lit blue behind him"
260+ loading="eager"
261+ fetchpriority="high"
262+ draggable="false"
263+ pictureAttributes={{ class: 'block h-full w-full' }}
264+ class="feed-img h-full w-full object-cover object-[70%_22%]"
265+ />
266+ <!-- Face sits at about 77% x, 35% y of this crop. -->
267+ <div class="lockbox acquiring absolute top-[35%] left-[77%] h-[21%] w-[26%]" aria-hidden="true">
268+ <span class="lock-label font-mono tracking-[0.1em] text-vol">DEV 01 LOCK</span>
269+ </div>
270+ </div>
271+ <div class="scanlines absolute inset-0" aria-hidden="true"></div>
272+ <div class="feed-vignette absolute inset-0" aria-hidden="true"></div>
273+ <div class="brackets absolute inset-3 [--bl:22px]" aria-hidden="true"></div>
274+ <div class="crosshair absolute top-1/2 left-1/2" aria-hidden="true"></div>
275+ <!-- Overlay text sits inside the corner brackets (brackets at 12px, text at 20px). -->
276+ <div class={`${hud} top-5 left-5`} aria-hidden="true">
277+ CAM 1<br /><span data-mode-label>EO</span> <span data-fov>WFOV</span>
278+ </div>
279+ <div class={`${hud} top-5 right-5 text-right`} aria-hidden="true">
280+ <span class="inline-flex items-center gap-1.5"><span class="rec-dot size-2 bg-vol"></span>REC</span><br /><span data-zoom>1.1X</span>
281+ </div>
282+ <div class={`${hud} bottom-5 left-5`} aria-hidden="true">
283+ <span data-azel>AZ 112.4 EL -4.2</span><br /><span data-coords>25.1972N 055.2744E</span>
284+ </div>
285+ <div class={`${hud} right-5 bottom-5 text-right tabular-nums`} aria-hidden="true">
286+ <span data-clock>--:--:--</span>Z
287+ </div>
288+ <div class="absolute top-1/2 left-5 flex -translate-y-1/2 flex-col gap-1">
289+ <button type="button" class="zoom-btn" data-zoom-in aria-label="Zoom in">+</button>
290+ <button type="button" class="zoom-btn" data-zoom-out aria-label="Zoom out">&minus;</button>
291+ </div>
292+ </div>
293+ <figcaption class="px-1 pt-2">
294+ <div class="flex items-center justify-between gap-3">
295+ <span class={`${mono} text-muted`}>Downtown Dubai</span>
296+ <span class="flex" role="group" aria-label="Camera mode">
297+ <button type="button" class="mode-btn" data-set-mode="eo" aria-pressed="true" title="Daylight camera">EO</button>
298+ <button type="button" class="mode-btn" data-set-mode="wht" aria-pressed="false" title="Thermal, white hot">WHT</button>
299+ <button type="button" class="mode-btn" data-set-mode="iron" aria-pressed="false" title="Thermal, ironbow palette">IRON</button>
300+ </span>
301+ </div>
302+ <p class="mt-2 font-mono text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">
303+ <span class="hidden sm:inline">Move to slew. Click to lock. Ctrl + scroll to zoom.</span>
304+ <span class="sm:hidden">Tap to lock. Use + and &minus; to zoom.</span>
305+ </p>
306+ </figcaption>
203307 </figure>
204308
205309 <div class="@container min-w-0">
310+ <ul class={`flex flex-wrap gap-2 ${mono}`}>
311+ <li class="flex items-center gap-2 border border-rule px-2.5 py-1.5 text-ink">
312+ <span class="size-1.5 bg-ok" aria-hidden="true"></span>Open to defense roles
313+ </li>
314+ <li class="border border-rule px-2.5 py-1.5 text-muted">U.S. citizen</li>
315+ <li class="border border-rule px-2.5 py-1.5 text-muted">Clearance eligible</li>
316+ </ul>
206317 <!-- "Henslee" is about 4.9em wide at this setting; 19.5cqi keeps it inside the column. -->
207- <h1 class="text-[min(8.5rem,19.5cqi)] leading-[0.86] font-extrabold tracking-[-0.03em] font-stretch-[125%]">
318+ <h1 class="mt-8 text-[min(8.5rem,19.5cqi)] leading-[0.86] font-extrabold tracking-[-0.03em] font-stretch-[125%] lg:mt-10">
208319 Lane<br />Henslee
209320 </h1>
210- <p class="mt-6 text-xl font-medium font-stretch-[106%] lg:mt-8 lg:text-2xl">
211- Software engineer in Reno, Nevada
321+ <p class="mt-6 font-mono text-sm tracking-[0.12em] text-vol uppercase lg:mt-8 lg:text-base">
322+ Software engineer <span class="text-muted">/ Reno, Nevada</span>
212323 </p>
213- <div class="mt-8 max-w-[38rem] space-y-4 text-[1.0625rem] leading-[1.65] text-ink/85">
324+ <div class="mt-7 max-w-[38rem] space-y-4 text-[1.0625rem] leading-[1.65] text-ink/85">
214325 <p>
215326 I build backend systems in Rust and Python and run the cloud and security
216327 infrastructure they depend on. I have shipped production software for over seven
@@ -219,92 +330,121 @@ const textLink =
219330 </p>
220331 <p>
221332 Today I run Hygo, an independent software practice for logistics, aviation, and
222- trades clients. I am a U.S. citizen, eligible for a security clearance, and open to
223- developer roles on defense programs in the Washington, DC area.
333+ trades clients. I am open to developer roles on defense programs in the Washington,
334+ DC area.
224335 </p>
225336 </div>
226- <div class="mt-10 flex flex-wrap items-center gap-x-7 gap-y-4">
337+ <div class="mt-9 flex flex-wrap items-center gap-3">
227338 <a
228339 href={`mailto:${email}`}
229- class="inline-flex items-center rounded-[3px] bg-vol px-5 py-3 font-semibold text-ink transition-colors hover:bg-vol-deep"
340+ class={`inline-flex items-center rounded-[2px] bg-vol px-5 py-3 ${mono} font-bold text-black transition-colors hover:bg-vol-deep`}
230341 data-track="contact_click"
231342 data-track-channel="email"
232343 data-track-location="intro"
233344 >
234345 Email me
235346 </a>
236- <a class={`font-medium ${textLink}`} href={linkedin} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="linkedin" data-track-location="intro">LinkedIn</a>
237- <a class={`font-medium ${textLink}`} href={github} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="github" data-track-location="intro">GitHub</a>
347+ <a class={ghostBtn} href={linkedin} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="linkedin" data-track-location="intro">LinkedIn</a>
348+ <a class={ghostBtn} href={github} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="github" data-track-location="intro">GitHub</a>
238349 </div>
239350 </div>
240351 </section>
241352
242- <section id="work" data-section="work" class="scroll-mt-6 border-t border-rule py-16 lg:py-24">
243- <h2 class={h2}>Selected work</h2>
244- <p class="mt-2 max-w-[38rem] text-muted">Recent systems I designed, built, and run.</p>
245- <ul class="mt-10 divide-y divide-rule border-t border-rule">
246- {
247- work.map((w) => (
248- <li class={`${row} py-8`}>
249- <div>
250- <h3 class={h3}>{w.name}</h3>
251- {w.href && (
252- <a
253- class={`mt-1 inline-block text-sm text-muted ${textLink}`}
254- href={w.href}
255- target="_blank"
256- rel="noopener"
257- data-track="project_link_click"
258- data-track-project={w.name}
259- >
260- {new URL(w.href).hostname.replace(/^www\./, '')}
261- </a>
262- )}
263- </div>
264- <div class="max-w-[40rem]">
265- <div class="space-y-3 leading-[1.65]">
266- {w.body.map((p) => (
267- <p>{p}</p>
268- ))}
353+ <Panel id="work" title="Selected work" meta={`${work.length} tracks`}>
354+ <div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_13.5rem] lg:gap-10">
355+ <!-- Radar: each blip is a track below. -->
356+ <div class="self-start lg:sticky lg:top-16 lg:order-last">
357+ <div class="radar relative mx-auto size-[200px] border border-rule bg-paper" data-radar>
358+ <div class="radar-sweep absolute inset-0" aria-hidden="true"></div>
359+ {
360+ blips.map((b) => (
361+ <button
362+ type="button"
363+ class="blip absolute"
364+ style={`left:${b.x.toFixed(1)}%;top:${b.y.toFixed(1)}%`}
365+ data-blip
366+ data-id={b.id}
367+ data-name={b.name}
368+ data-brg={b.brg}
369+ data-rng={b.rng}
370+ aria-label={`${b.id}, ${b.name}. Jump to it.`}
371+ >
372+ <span class="blip-num font-mono text-[9px] text-vol">{b.num}</span>
373+ </button>
374+ ))
375+ }
376+ <span class="absolute top-1.5 left-2 font-mono text-[9px] tracking-[0.1em] text-muted" aria-hidden="true">RNG 40</span>
377+ <span class="absolute top-1.5 right-2 font-mono text-[9px] tracking-[0.1em] text-muted" aria-hidden="true">N</span>
378+ </div>
379+ <p class="mx-auto mt-3 min-h-[3em] max-w-[200px] font-mono text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase" data-radar-readout aria-live="polite">
380+ Select a contact to jump to it.
381+ </p>
382+ </div>
383+
384+ <ul class="divide-y divide-rule">
385+ {
386+ work.map((w, i) => (
387+ <li class={`trk ${row} -mx-3 px-3 py-7 first:pt-3 last:pb-3`} data-trk={`TRK ${pad(i + 1)}`} data-trk-name={w.name}>
388+ <div>
389+ <p class={`${mono} text-vol`}>Trk {pad(i + 1)}</p>
390+ <h3 class={`mt-1.5 ${h3}`}>{w.name}</h3>
391+ {w.href && (
392+ <a
393+ class={`mt-1 inline-block font-mono text-xs text-muted ${textLink}`}
394+ href={w.href}
395+ target="_blank"
396+ rel="noopener"
397+ data-track="project_link_click"
398+ data-track-project={w.name}
399+ >
400+ {new URL(w.href).hostname.replace(/^www\./, '')}
401+ </a>
402+ )}
269403 </div>
270- {w.membar && (
271- <figure class="mt-6" data-membar>
272- <figcaption class={meta}>Production memory, before and after the rewrite</figcaption>
273- <div class="mt-3 grid grid-cols-[3.75rem_minmax(0,1fr)_3.5rem] items-center gap-x-3 gap-y-2.5 text-sm tabular-nums">
274- <span class="text-muted">Before</span>
275- <span class="block h-3 w-full bg-rock" />
276- <span class="text-right font-semibold">3 GB</span>
277- <span class="text-muted">After</span>
278- <span class="block h-3">
279- <span class="membar-after block h-full bg-vol" />
280- </span>
281- <span class="text-right font-semibold">50 MB</span>
282- </div>
283- </figure>
284- )}
285- {w.stack && <p class={`mt-4 ${meta}`}>{w.stack}</p>}
286- </div>
287- </li>
288- ))
289- }
290- </ul>
291- </section>
404+ <div class="max-w-[42rem]">
405+ <div class="space-y-3 leading-[1.65] text-ink/90">
406+ {w.body.map((p) => (
407+ <p>{p}</p>
408+ ))}
409+ </div>
410+ {w.membar && (
411+ <figure class="mt-6 border border-rule bg-paper/60 p-4" data-membar>
412+ <figcaption class={`${mono} text-muted`}>Production memory (RSS)</figcaption>
413+ <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">
414+ <span class="text-muted">BEFORE</span>
415+ <span class="block h-3 w-full bg-rock" />
416+ <span class="text-right font-bold">3 GB</span>
417+ <span class="text-muted">AFTER</span>
418+ <span class="block h-3">
419+ <span class="membar-after block h-full bg-vol" />
420+ </span>
421+ <span class="text-right font-bold text-vol">50 MB</span>
422+ </div>
423+ </figure>
424+ )}
425+ {w.stack && <p class={`mt-4 ${meta}`}>{w.stack}</p>}
426+ </div>
427+ </li>
428+ ))
429+ }
430+ </ul>
431+ </div>
432+ </Panel>
292433
293- <section id="experience" data-section="experience" class="scroll-mt-6 border-t border-rule py-16 lg:py-24">
294- <h2 class={h2}>Experience</h2>
295- <ol class="mt-10 space-y-12">
434+ <Panel id="experience" title="Experience" meta="2018 to now">
435+ <ol class="space-y-10">
296436 {
297437 roles.map((r) => (
298438 <li class={row}>
299- <p class={`${meta} md:pt-1`}>
439+ <p class={`${meta} leading-relaxed md:pt-1`}>
300440 {r.dates}
301441 <br />
302442 {r.place}
303443 </p>
304- <div class="max-w-[40rem]">
444+ <div class="max-w-[42rem]">
305445 <h3 class={h3}>{r.title}</h3>
306446 <p class="text-muted">{r.org}</p>
307- <ul class="mt-4 list-[square] space-y-2 pl-5 leading-[1.6] marker:text-vol">
447+ <ul class="mt-4 list-[square] space-y-2 pl-5 leading-[1.6] text-ink/90 marker:text-vol">
308448 {r.points.map((p) => (
309449 <li>{p}</li>
310450 ))}
@@ -314,37 +454,36 @@ const textLink =
314454 ))
315455 }
316456 </ol>
457+ </Panel>
317458
318- <h2 class={`${h2} mt-20`}>Education</h2>
319- <div class={`${row} mt-8`}>
459+ <Panel id="education" title="Education">
460+ <div class={row}>
320461 <p class={`${meta} md:pt-1`}>2021</p>
321462 <div>
322463 <h3 class={h3}>B.S. Computer Science</h3>
323464 <p class="text-muted">University of Tennessee, Knoxville</p>
324465 </div>
325466 </div>
326- </section>
467+ </Panel>
327468
328- <section id="skills" data-section="skills" class="scroll-mt-6 border-t border-rule py-16 lg:py-24">
329- <h2 class={h2}>Skills</h2>
330- <dl class="mt-10 divide-y divide-rule border-t border-rule">
469+ <Panel id="skills" title="Skills" meta={`${skills.length} systems`}>
470+ <dl class="divide-y divide-rule">
331471 {
332472 skills.map(([group, list]) => (
333- <div class={`${row} py-4`}>
334- <dt class="font-semibold">{group}</dt>
335- <dd class="max-w-[40rem] leading-[1.6]">{list}</dd>
473+ <div class={`${row} py-4 first:pt-0 last:pb-0`}>
474+ <dt class={`${mono} pt-0.5 text-muted`}>{group}</dt>
475+ <dd class="max-w-[42rem] leading-[1.6]">{list}</dd>
336476 </div>
337477 ))
338478 }
339479 </dl>
340- </section>
480+ </Panel>
341481
342- <section id="contact" data-section="contact" class="scroll-mt-6 border-t border-rule py-16 lg:py-24">
343- <h2 class={h2}>Contact</h2>
344- <p class="mt-2 text-muted">Email is the fastest way to reach me.</p>
345- <div class="mt-6 flex flex-wrap items-center gap-x-4 gap-y-3">
482+ <Panel id="contact" title="Contact" meta="Channel open">
483+ <p class="text-muted">Email is the fastest way to reach me.</p>
484+ <div class="mt-5 flex flex-wrap items-center gap-x-4 gap-y-3">
346485 <a
347- class={`text-[clamp(1.5rem,6vw,2.25rem)] font-semibold font-stretch-[112%] ${textLink}`}
486+ class={`text-[clamp(1.4rem,6vw,2.25rem)] font-semibold font-stretch-[112%] text-vol ${textLink}`}
348487 href={`mailto:${email}`}
349488 data-track="contact_click"
350489 data-track-channel="email"
@@ -356,29 +495,125 @@ const textLink =
356495 id="copyEmail"
357496 type="button"
358497 data-email={email}
359- class="rounded-[3px] border border-ink/25 px-3 py-1.5 text-sm font-medium transition-colors hover:border-vol hover:text-vol-ink"
498+ class={`rounded-[2px] border border-rule px-3 py-2 ${mono} transition-colors hover:border-vol hover:text-vol`}
360499 >
361500 Copy address
362501 </button>
363502 </div>
364- <ul class="mt-6 flex gap-7 font-medium">
365- <li><a class={textLink} href={linkedin} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="linkedin" data-track-location="contact">LinkedIn</a></li>
366- <li><a class={textLink} href={github} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="github" data-track-location="contact">GitHub</a></li>
367- </ul>
368- </section>
503+ <div class="mt-6 flex flex-wrap gap-3">
504+ <a class={ghostBtn} href={linkedin} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="linkedin" data-track-location="contact">LinkedIn</a>
505+ <a class={ghostBtn} href={github} target="_blank" rel="noopener" data-track="contact_click" data-track-channel="github" data-track-location="contact">GitHub</a>
506+ </div>
507+ </Panel>
369508
370- <footer class="flex items-center justify-between border-t border-rule py-8 text-sm text-muted">
509+ <footer class={`mt-10 flex items-center justify-between gap-4 ${mono} text-muted`}>
371510 <span>&copy; {year} Lane Henslee</span>
372- <a class="hover:text-vol-ink" href="#top" data-track="nav_click" data-track-target="top">Back to top</a>
511+ <span class="checker hidden h-4 w-32 sm:block" aria-hidden="true"></span>
512+ <a class={navLink} href="#top" data-track="nav_click" data-track-target="top">Back to top</a>
373513 </footer>
374514 </div>
375- <div class="checker h-5" aria-hidden="true"></div>
515+
516+ <!-- Primary flight display: mouse flies it, scrolling climbs. Wide screens only. -->
517+ <aside data-pfd class="fixed right-5 bottom-5 z-30 hidden w-[236px] xl:block" aria-label="Flight display">
518+ <div class="relative border border-rule bg-paper/90 backdrop-blur">
519+ <div class="brackets absolute -inset-px [--bl:10px]" aria-hidden="true"></div>
520+ <div class="flex items-center justify-between border-b border-rule px-2.5 py-1.5 font-mono text-[10px] tracking-[0.12em] uppercase">
521+ <span class="font-bold text-vol">PFD</span>
522+ <span class="text-ok" data-ap>AP ON</span>
523+ <button type="button" class="text-muted uppercase transition-colors hover:text-vol" data-pfd-toggle aria-expanded="true">Hide</button>
524+ </div>
525+ <div class="pfd-body" aria-hidden="true">
526+ <div class="flex items-stretch gap-2 p-2">
527+ <div class="flex w-[38px] flex-col">
528+ <span class="text-center font-mono text-[9px] tracking-[0.1em] text-muted">SPD</span>
529+ <div class="relative mt-1 flex-1">
530+ <div class="vtape vtape-left absolute inset-0" data-spd-tape></div>
531+ <span class="pfd-readout left-0" data-spd>000</span>
532+ </div>
533+ </div>
534+ <div class="adi relative size-[132px] shrink-0 overflow-hidden border border-rule">
535+ <div class="horizon absolute" data-horizon>
536+ {
537+ ladder.map((p) => (
538+ <span class="ladder" style={`top:calc(50% - ${p * 3}px)`}>
539+ <span>{Math.abs(p)}</span>
540+ </span>
541+ ))
542+ }
543+ </div>
544+ <span class="roll-mark absolute top-1 left-1/2 -translate-x-1/2"></span>
545+ <span class="wing absolute top-1/2 left-[18px]"></span>
546+ <span class="wing absolute top-1/2 right-[18px]"></span>
547+ <span class="nose absolute top-1/2 left-1/2"></span>
548+ </div>
549+ <div class="flex w-[44px] flex-col">
550+ <span class="text-center font-mono text-[9px] tracking-[0.1em] text-muted">ALT</span>
551+ <div class="relative mt-1 flex-1">
552+ <div class="vtape absolute inset-0" data-alt-tape></div>
553+ <span class="pfd-readout right-0" data-alt>00000</span>
554+ </div>
555+ </div>
556+ </div>
557+ <div class="relative mx-2 mb-2 h-7 overflow-hidden border border-rule">
558+ <div class="htape absolute inset-0" data-hdg-tape></div>
559+ <span class="pfd-readout left-1/2 -translate-x-1/2" data-hdg>270</span>
560+ </div>
561+ </div>
562+ </div>
563+ </aside>
564+
565+ <!-- Command console: "/" or the Console button. -->
566+ <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]">
567+ <div class={`flex items-center justify-between border-b border-rule px-4 py-2.5 ${mono}`}>
568+ <span class="font-bold text-vol">Comms console</span>
569+ <span class="text-muted">Esc to close</span>
570+ </div>
571+ <div class="h-[min(22rem,55vh)] overflow-y-auto p-4 font-mono text-[13px] leading-relaxed" data-console-out></div>
572+ <form class="flex items-center gap-2 border-t border-rule px-4 py-3 font-mono text-[13px]" data-console-form>
573+ <label for="consoleInput" class="shrink-0 text-vol">lh@gcs:~$</label>
574+ <input id="consoleInput" class="min-w-0 flex-1 bg-transparent caret-vol outline-none" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send" />
575+ </form>
576+ </dialog>
376577
377578 <script>
378579 import { track, initSectionViews, initScrollDepth } from '../lib/track';
580+ import { initFeed } from '../lib/gcs/feed';
581+ import { initPfd } from '../lib/gcs/pfd';
582+ import { initRadar } from '../lib/gcs/radar';
583+ import { initConsole } from '../lib/gcs/console';
379584
380585 initSectionViews();
381586 initScrollDepth();
587+ initFeed();
588+ initPfd();
589+ initRadar();
590+ initConsole();
591+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
592+
593+ // UTC clocks in the status bar and on the camera feed.
594+ const clocks = document.querySelectorAll<HTMLElement>('[data-clock]');
595+ const tick = () => {
596+ const t = new Date().toISOString().slice(11, 19);
597+ clocks.forEach((c) => (c.textContent = t));
598+ };
599+ tick();
600+ setInterval(tick, 1000);
601+
602+ // Status bar highlights the section you are reading.
603+ const navLinks = document.querySelectorAll<HTMLElement>('[data-nav]');
604+ if ('IntersectionObserver' in window) {
605+ const spy = new IntersectionObserver(
606+ (entries) => {
607+ for (const e of entries) {
608+ if (!e.isIntersecting) continue;
609+ const id = e.target.id === 'education' ? 'experience' : e.target.id;
610+ navLinks.forEach((l) => l.setAttribute('aria-current', String(l.dataset.nav === id)));
611+ }
612+ },
613+ { rootMargin: '-40% 0px -55% 0px' },
614+ );
615+ document.querySelectorAll('section[id]').forEach((s) => spy.observe(s));
616+ }
382617
383618 // Copy email: swap the label briefly so the click has visible feedback.
384619 const copyBtn = document.getElementById('copyEmail') as HTMLButtonElement | null;
@@ -399,7 +634,6 @@ const textLink =
399634 // Memory bar: the "after" bar starts at full width and shrinks to its
400635 // real size the first time it scrolls into view.
401636 const membar = document.querySelector<HTMLElement>('[data-membar]');
402- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
403637 if (membar && !reduce && 'IntersectionObserver' in window) {
404638 const rect = membar.getBoundingClientRect();
405639 if (rect.top > window.innerHeight) {
@@ -418,7 +652,308 @@ const textLink =
418652 </script>
419653
420654 <style>
421- /* 50 MB of 3 GB (3072 MB) */
655+ /* ---------- Camera feed ---------- */
656+ .feed-layer {
657+ transform-origin: center;
658+ transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
659+ will-change: transform;
660+ }
661+ .feed-view:focus-visible {
662+ outline-offset: 2px;
663+ }
664+ .feed[data-mode='wht'] .feed-img {
665+ filter: grayscale(1) contrast(1.35) brightness(1.08);
666+ }
667+ .feed[data-mode='iron'] .feed-img {
668+ filter: url(#ironbow) contrast(1.1);
669+ }
670+ .feed-vignette {
671+ background: radial-gradient(ellipse at center, transparent 55%, rgb(0 0 0 / 0.55) 100%);
672+ pointer-events: none;
673+ }
674+ .hud-text {
675+ text-shadow: 0 0 4px #000, 0 0 2px #000;
676+ pointer-events: none;
677+ }
678+
679+ /* Boresight crosshair: four short ticks around a gap. */
680+ .crosshair {
681+ width: 44px;
682+ height: 44px;
683+ transform: translate(-50%, -50%);
684+ pointer-events: none;
685+ background:
686+ linear-gradient(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.7)) center top / 1px 14px no-repeat,
687+ linear-gradient(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.7)) center bottom / 1px 14px no-repeat,
688+ linear-gradient(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.7)) left center / 14px 1px no-repeat,
689+ linear-gradient(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.7)) right center / 14px 1px no-repeat;
690+ }
691+
692+ /* Track box. Border and label are divided by the zoom so they stay crisp. */
693+ .lockbox {
694+ transform: translate(-50%, -50%);
695+ border: calc(1.5px / var(--z, 1)) solid var(--color-vol);
696+ pointer-events: none;
697+ }
698+ .lockbox.acquiring {
699+ animation: acquire 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.2s both;
700+ }
701+ .lock-label {
702+ position: absolute;
703+ bottom: 100%;
704+ left: calc(-1.5px / var(--z, 1));
705+ margin-bottom: calc(3px / var(--z, 1));
706+ font-size: calc(10px / var(--z, 1));
707+ white-space: nowrap;
708+ text-shadow: 0 0 4px #000;
709+ }
710+ .lockbox.acquiring .lock-label {
711+ animation: lock-label 0.3s steps(2) 1.3s both;
712+ }
713+ @keyframes acquire {
714+ 0% {
715+ width: 92%;
716+ height: 80%;
717+ left: 50%;
718+ top: 50%;
719+ opacity: 0;
720+ border-style: dashed;
721+ }
722+ 25% {
723+ opacity: 1;
724+ }
725+ 70% {
726+ border-style: dashed;
727+ }
728+ 100% {
729+ border-style: solid;
730+ }
731+ }
732+ @keyframes lock-label {
733+ from {
734+ opacity: 0;
735+ }
736+ }
737+ .rec-dot {
738+ animation: rec 1.2s steps(1) infinite;
739+ }
740+ @keyframes rec {
741+ 50% {
742+ opacity: 0.15;
743+ }
744+ }
745+
746+ .zoom-btn {
747+ width: 24px;
748+ height: 24px;
749+ border: 1px solid rgb(255 255 255 / 0.35);
750+ border-radius: 2px;
751+ background: rgb(0 0 0 / 0.45);
752+ color: #fff;
753+ font-family: var(--font-mono);
754+ font-size: 14px;
755+ line-height: 1;
756+ cursor: pointer;
757+ transition: border-color 0.15s, color 0.15s;
758+ }
759+ .zoom-btn:hover {
760+ border-color: var(--color-vol);
761+ color: var(--color-vol);
762+ }
763+
764+ .mode-btn {
765+ margin-left: -1px;
766+ border: 1px solid var(--color-rule);
767+ padding: 0.3rem 0.55rem;
768+ font-family: var(--font-mono);
769+ font-size: 10px;
770+ letter-spacing: 0.1em;
771+ color: var(--color-muted);
772+ cursor: pointer;
773+ transition: color 0.15s, border-color 0.15s;
774+ }
775+ .mode-btn:first-child {
776+ border-radius: 2px 0 0 2px;
777+ }
778+ .mode-btn:last-child {
779+ border-radius: 0 2px 2px 0;
780+ }
781+ .mode-btn:hover {
782+ color: var(--color-ink);
783+ }
784+ .mode-btn[aria-pressed='true'] {
785+ position: relative;
786+ border-color: var(--color-vol);
787+ color: #000;
788+ background: var(--color-vol);
789+ }
790+
791+ /* ---------- Radar ---------- */
792+ .radar {
793+ background:
794+ linear-gradient(rgb(255 130 0 / 0.18), rgb(255 130 0 / 0.18)) center / 1px 100% no-repeat,
795+ linear-gradient(rgb(255 130 0 / 0.18), rgb(255 130 0 / 0.18)) center / 100% 1px no-repeat,
796+ radial-gradient(
797+ circle closest-side,
798+ transparent 0 calc(25% - 1px),
799+ rgb(255 130 0 / 0.22) calc(25% - 1px) 25%,
800+ transparent 25% calc(50% - 1px),
801+ rgb(255 130 0 / 0.22) calc(50% - 1px) 50%,
802+ transparent 50% calc(75% - 1px),
803+ rgb(255 130 0 / 0.22) calc(75% - 1px) 75%,
804+ transparent 75% calc(100% - 1px),
805+ rgb(255 130 0 / 0.4) calc(100% - 1px) 100%,
806+ transparent 100%
807+ ),
808+ var(--color-paper);
809+ }
810+ .radar-sweep {
811+ background: conic-gradient(from 0deg, transparent 0 280deg, rgb(255 130 0 / 0.32) 358deg, rgb(255 160 64 / 0.7) 360deg);
812+ mask-image: radial-gradient(circle closest-side, #000 99%, transparent 100%);
813+ animation: sweep 4s linear infinite;
814+ pointer-events: none;
815+ }
816+ @keyframes sweep {
817+ to {
818+ transform: rotate(360deg);
819+ }
820+ }
821+ .blip {
822+ width: 22px;
823+ height: 22px;
824+ transform: translate(-50%, -50%);
825+ cursor: pointer;
826+ }
827+ .blip::before {
828+ content: '';
829+ position: absolute;
830+ left: 50%;
831+ top: 50%;
832+ width: 7px;
833+ height: 7px;
834+ transform: translate(-50%, -50%);
835+ background: rgb(255 130 0 / 0.55);
836+ transition: background 0.2s, box-shadow 0.2s;
837+ }
838+ .blip-num {
839+ position: absolute;
840+ left: 16px;
841+ top: -3px;
842+ opacity: 0.6;
843+ }
844+ .blip:hover::before,
845+ .blip:focus-visible::before,
846+ .blip.is-active::before {
847+ background: var(--color-vol);
848+ box-shadow: 0 0 10px 2px rgb(255 130 0 / 0.6);
849+ }
850+ .blip:hover .blip-num,
851+ .blip:focus-visible .blip-num,
852+ .blip.is-active .blip-num {
853+ opacity: 1;
854+ }
855+ .trk {
856+ transition: background-color 0.4s, box-shadow 0.4s;
857+ }
858+ .trk.is-selected {
859+ background: rgb(255 130 0 / 0.07);
860+ box-shadow: inset 2px 0 0 var(--color-vol);
861+ }
862+
863+ /* ---------- Flight display ---------- */
864+ [data-pfd][data-min='true'] .pfd-body {
865+ display: none;
866+ }
867+ .horizon {
868+ left: -150%;
869+ top: -150%;
870+ width: 400%;
871+ height: 400%;
872+ background: linear-gradient(to bottom, #1c3a56 calc(50% - 1px), #f2f2f2 calc(50% - 1px) calc(50% + 1px), #4a2f17 calc(50% + 1px));
873+ will-change: transform;
874+ }
875+ .ladder {
876+ position: absolute;
877+ left: 50%;
878+ width: 40px;
879+ height: 1px;
880+ transform: translateX(-50%);
881+ background: rgb(255 255 255 / 0.75);
882+ }
883+ .ladder span {
884+ position: absolute;
885+ left: 44px;
886+ top: -6px;
887+ font-family: var(--font-mono);
888+ font-size: 8px;
889+ color: rgb(255 255 255 / 0.8);
890+ }
891+ .wing {
892+ width: 30px;
893+ height: 3px;
894+ margin-top: -1.5px;
895+ background: var(--color-vol);
896+ box-shadow: 0 0 0 1px #000;
897+ }
898+ .nose {
899+ width: 6px;
900+ height: 6px;
901+ transform: translate(-50%, -50%);
902+ background: var(--color-vol);
903+ box-shadow: 0 0 0 1px #000;
904+ }
905+ .roll-mark {
906+ width: 0;
907+ height: 0;
908+ border-left: 5px solid transparent;
909+ border-right: 5px solid transparent;
910+ border-top: 7px solid var(--color-vol);
911+ }
912+ .vtape {
913+ background-image:
914+ linear-gradient(to bottom, var(--color-vol) 0 1px, transparent 1px),
915+ linear-gradient(to bottom, rgb(255 130 0 / 0.45) 0 1px, transparent 1px);
916+ background-size:
917+ 14px 40px,
918+ 7px 8px;
919+ background-repeat: repeat-y;
920+ background-position:
921+ right 0 top var(--shift, 0px),
922+ right 0 top var(--shift, 0px);
923+ mask-image: linear-gradient(transparent, #000 20%, #000 80%, transparent);
924+ }
925+ .vtape-left {
926+ background-position:
927+ left 0 top var(--shift, 0px),
928+ left 0 top var(--shift, 0px);
929+ }
930+ .htape {
931+ background-image:
932+ linear-gradient(to right, var(--color-vol) 0 1px, transparent 1px),
933+ linear-gradient(to right, rgb(255 130 0 / 0.45) 0 1px, transparent 1px);
934+ background-size:
935+ 40px 10px,
936+ 8px 5px;
937+ background-repeat: repeat-x;
938+ background-position:
939+ left var(--shift, 0px) bottom 0,
940+ left var(--shift, 0px) bottom 0;
941+ mask-image: linear-gradient(to right, transparent, #000 20%, #000 80%, transparent);
942+ }
943+ .pfd-readout {
944+ position: absolute;
945+ top: 50%;
946+ translate: 0 -50%;
947+ border: 1px solid var(--color-vol);
948+ background: var(--color-paper);
949+ padding: 1px 3px;
950+ font-family: var(--font-mono);
951+ font-size: 11px;
952+ color: var(--color-vol);
953+ font-variant-numeric: tabular-nums;
954+ }
955+
956+ /* ---------- Memory bar: 50 MB of 3 GB (3072 MB) ---------- */
422957 .membar-after {
423958 width: 1.63%;
424959 min-width: 3px;
@@ -428,6 +963,18 @@ const textLink =
428963 width: 100%;
429964 transition: none;
430965 }
966+
967+ @media (prefers-reduced-motion: reduce) {
968+ .lockbox.acquiring,
969+ .lockbox.acquiring .lock-label,
970+ .rec-dot,
971+ .radar-sweep {
972+ animation: none;
973+ }
974+ .feed-layer {
975+ transition: none;
976+ }
977+ }
431978 </style>
432979 </body>
433980 </html>
+51-14src/styles/site.css
@@ -1,21 +1,25 @@
11 @import 'tailwindcss';
22
33 /*
4- * Tennessee Vols palette (University of Tennessee brand colors).
5- * Tennessee Orange is too light for text on white (about 2.4:1), so it is
6- * used for fills, bars, and markers; text links use vol-ink instead.
4+ * Ground control station look: a black display with Tennessee Orange as the
5+ * HUD amber (UT's "Smokey" alternates are dark gray and orange). Orange on
6+ * this black is about 7.9:1, so it is safe for text as well as symbology.
7+ * B612 Mono is the typeface Airbus designed for cockpit displays.
78 */
89 @theme {
910 --font-sans: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
11+ --font-mono: 'B612 Mono', ui-monospace, 'SFMono-Regular', monospace;
1012
11- --color-paper: #ffffff;
12- --color-ink: #2a2b2d; /* deep Smokey, for headings and body */
13- --color-muted: #58595b; /* Smokey, PMS 7540 */
14- --color-rule: #e2e2e0;
13+ --color-paper: #050607; /* display black */
14+ --color-panel: #0b0d0f;
15+ --color-ink: #ecebe7;
16+ --color-muted: #8f9194; /* Smokey, lifted for a dark background */
17+ --color-rule: #23272b;
1518 --color-vol: #ff8200; /* Tennessee Orange, PMS 151 */
16- --color-vol-deep: #e57400; /* pressed and hover state for orange fills */
17- --color-vol-ink: #b35700; /* orange that passes AA as text on white */
18- --color-rock: #a7a9ac; /* UT Rock gray */
19+ --color-vol-deep: #e57400;
20+ --color-vol-ink: #ffa040; /* hover state for orange text */
21+ --color-rock: #3a3e43;
22+ --color-ok: #3ddc84; /* status green: link nominal */
1923 }
2024
2125 @layer base {
@@ -26,18 +30,51 @@
2630 font-feature-settings: 'kern', 'liga';
2731 -webkit-font-smoothing: antialiased;
2832 text-rendering: optimizeLegibility;
33+ color-scheme: dark;
2934 }
3035 ::selection {
3136 background: var(--color-vol);
32- color: var(--color-ink);
37+ color: #000;
3338 }
3439 :focus-visible {
35- outline: 2px solid var(--color-ink);
40+ outline: 2px solid var(--color-vol);
3641 outline-offset: 3px;
3742 }
3843 }
3944
40-/* Neyland Stadium end zone checkerboard: orange and white, 10px squares. */
45+/* Faint map grid behind everything. */
46+@utility grid-bg {
47+ background-image:
48+ linear-gradient(rgb(255 130 0 / 0.035) 1px, transparent 1px),
49+ linear-gradient(90deg, rgb(255 130 0 / 0.035) 1px, transparent 1px);
50+ background-size: 64px 64px;
51+}
52+
53+/* Orange corner brackets, drawn on an absolutely positioned overlay. */
54+@utility brackets {
55+ --bc: var(--color-vol);
56+ --bl: 14px;
57+ --bw: 2px;
58+ background:
59+ linear-gradient(var(--bc), var(--bc)) top left / var(--bl) var(--bw),
60+ linear-gradient(var(--bc), var(--bc)) top left / var(--bw) var(--bl),
61+ linear-gradient(var(--bc), var(--bc)) top right / var(--bl) var(--bw),
62+ linear-gradient(var(--bc), var(--bc)) top right / var(--bw) var(--bl),
63+ linear-gradient(var(--bc), var(--bc)) bottom left / var(--bl) var(--bw),
64+ linear-gradient(var(--bc), var(--bc)) bottom left / var(--bw) var(--bl),
65+ linear-gradient(var(--bc), var(--bc)) bottom right / var(--bl) var(--bw),
66+ linear-gradient(var(--bc), var(--bc)) bottom right / var(--bw) var(--bl);
67+ background-repeat: no-repeat;
68+ pointer-events: none;
69+}
70+
71+/* CRT-style scanlines for the camera feed. */
72+@utility scanlines {
73+ background: repeating-linear-gradient(to bottom, transparent 0 2px, rgb(0 0 0 / 0.22) 2px 3px);
74+ pointer-events: none;
75+}
76+
77+/* Neyland Stadium end zone checkerboard: orange and black here, 8px squares. */
4178 @utility checker {
42- background: repeating-conic-gradient(var(--color-vol) 0 25%, #fff 0 50%) 0 0 / 20px 20px;
79+ background: repeating-conic-gradient(var(--color-vol) 0 25%, transparent 0 50%) 0 0 / 16px 16px;
4380 }