PageSourceSearch

https://fieldjournal.ai/search/

html fieldjournal.ai collected 2026-10-05 11:58:37 UTC 44,791 bytes, 904 lines download raw bytes

1<!DOCTYPE html><html lang="en"> <head>
1<script>(function(w,i,g){w[g]=w[g]||[];if(typeof w[g].push=='function')w[g].push(i)})
2(window,'G-B9GJFYHD59','google_tags_first_party');</script>
2<script async src="/j32j/"></script>
2
3			
3<script>
4				window.dataLayer = window.dataLayer || [];
5				function gtag(){dataLayer.push(arguments);}
6				gtag('js', new Date());
7				gtag('set', 'developer_id.dYzg1YT', true);
8				gtag('config', 'G-B9GJFYHD59');
9			</script>
9
10			<!-- Global Metadata --><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark">
10<script>
11	(() => {
12		try {
13			const params = new URLSearchParams(window.location.search);
14			if (params.get('debug') !== 'overflow') return;
15		} catch {
16			return;
17		}
18
19		const vw = document.documentElement.clientWidth || window.innerWidth;
20		const vh = document.documentElement.clientHeight || window.innerHeight;
21		/** @type {Array<{ el: HTMLElement; over: number }>} */
22		const offenders = [];
23
24		const all = Array.from(document.querySelectorAll('body *'));
25		for (const el of all) {
26			if (!(el instanceof HTMLElement)) continue;
27			const rect = el.getBoundingClientRect();
28			if (rect.width <= 0 || rect.height <= 0) continue;
29			if (rect.bottom < 0 || rect.top > vh) continue;
30			const overRight = rect.right - vw;
31			const overLeft = 0 - rect.left;
32			const over = Math.max(overRight, overLeft);
33			if (over <= 1) continue;
34			offenders.push({ el, over });
35		}
36
37		offenders.sort((a, b) => b.over - a.over);
38		const top = offenders.slice(0, 12);
39		for (const [i, o] of top.entries()) {
40			o.el.style.outline = i === 0 ? '3px solid #ef4444' : '2px solid #f59e0b';
41			o.el.style.outlineOffset = '1px';
42		}
43
44		const panel = document.createElement('div');
45		panel.style.position = 'fixed';
46		panel.style.left = '0';
47		panel.style.right = '0';
48		panel.style.bottom = '0';
49		panel.style.zIndex = '999999';
50		panel.style.background = 'rgba(0,0,0,0.86)';
51		panel.style.color = '#fff';
52		panel.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
53		panel.style.fontSize = '12px';
54		panel.style.lineHeight = '1.4';
55		panel.style.maxHeight = '40vh';
56		panel.style.overflow = 'auto';
57		panel.style.padding = '10px 12px';
58		panel.innerHTML = '<div style="font-weight:700;margin-bottom:6px">Overflow debug (top offenders)</div>';
59
60		const list = document.createElement('ol');
61		list.style.margin = '0';
62		list.style.paddingLeft = '18px';
63		for (const o of top) {
64			const cls = (o.el.className || '').toString().trim().slice(0, 140);
65			const line = document.createElement('li');
66			line.textContent = `${Math.round(o.over)}px: <${o.el.tagName.toLowerCase()}> ${cls}`;
67			list.appendChild(line);
68		}
69		panel.appendChild(list);
70
71		document.addEventListener('keydown', (e) => {
72			if (e.key === 'Escape') panel.remove();
73		});
74
75		// If body is not ready yet, wait for it.
76		const attach = () => {
77			const body = document.body;
78			if (!body) return;
79			body.appendChild(panel);
80		};
81		if (document.readyState === 'loading') {
82			document.addEventListener('DOMContentLoaded', attach, { once: true });
83		} else {
84			attach();
85		}
86	})();
87</script>
87<script>
88	// Theme init (shadcn-style): set a `dark` class on <html> before paint to avoid flashes.
89	// Supports explicit "light"/"dark" as well as "system" (default).
90	(() => {
91		const storageKey = 'theme'; // 'light' | 'dark' | 'system'
92		const darkClass = 'dark';
93		const root = document.documentElement;
94
95		const getStored = () => {
96			try {
97				return localStorage.getItem(storageKey);
98			} catch {
99				return null;
100			}
101		};
102
103		const getPref = () => {
104			const stored = getStored();
105			return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
106		};
107
108		const systemTheme = () => {
109			return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
110		};
111
112		const apply = (pref) => {
113			const theme = pref === 'system' ? systemTheme() : pref;
114			root.classList.toggle(darkClass, theme === 'dark');
115		};
116
117		// Expose a tiny API for the toggle component.
118		window.__setThemePreference = (pref) => {
119			try {
120				localStorage.setItem(storageKey, pref);
121			} catch {
122				// ignore
123			}
124			apply(pref);
125		};
126		window.__getThemePreference = getPref;
127
128		apply(getPref());
129
130		// Keep "system" in sync with OS changes.
131		if (window.matchMedia) {
132			const mql = window.matchMedia('(prefers-color-scheme: dark)');
133			const onChange = () => {
134				if (getPref() === 'system') apply('system');
135			};
136			mql.addEventListener?.('change', onChange);
137			// Safari fallback
138			mql.addListener?.(onChange);
139		}
140	})();
141</script>
141<!-- Preconnect for LCP images / R2 assets --><link rel="dns-prefetch" href="https://assets.fieldjournal.ai"><link rel="preconnect" href="https://assets.fieldjournal.ai"><!-- LCP image preload --><link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2"><link rel="icon" href="/favicon.ico?v=2"><link rel="sitemap" href="/sitemap.xml"><link rel="alternate" type="application/rss+xml" title="Field Journal.ai" href="https://fieldjournal.ai/rss.xml"><meta name="generator" content="Astro v5.16.15"><meta name="robots" content="noindex, nofollow"><!-- Font preloads --><link rel="preload" href="/fonts/source-sans-3.var.woff2" as="font" type="font/woff2" crossorigin><link rel="preload" href="/fonts/source-serif-4.var.woff2" as="font" type="font/woff2" crossorigin><!-- Logo font (Google Fonts) --><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><!-- Avoid render-blocking the page on a third-party stylesheet; keep noscript fallback. --><link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,[email protected],400..700&display=swap" crossorigin="anonymous"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,[email protected],400..700&display=swap" media="print" onload="this.media='all'" crossorigin="anonymous"><noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,[email protected],400..700&display=swap" crossorigin="anonymous"></noscript><!-- Canonical URL --><link rel="canonical" href="https://fieldjournal.ai/search/"><!-- Primary Meta Tags --><title>Search | Field Journal</title><meta name="title" content="Search | Field Journal"><meta name="description" content="Search Field Journal."><!-- Open Graph / Facebook --><meta property="og:type" content="website"><meta property="og:url" content="https://fieldjournal.ai/search/"><meta property="og:site_name" content="Field Journal.ai"><meta property="og:locale" content="en_US"><meta property="og:title" content="Search | Field Journal"><meta property="og:description" content="Search Field Journal."><meta property="og:image" content="https://fieldjournal.ai/cdn-cgi/image/onerror=redirect,width=1200,height=630,format=jpg/_astro/blog-placeholder-1.Bx0Zcyzv.jpg"><meta property="og:image:alt" content="Search | Field Journal"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><!-- Twitter --><meta property="twitter:card" content="summary_large_image"><meta property="twitter:url" content="https://fieldjournal.ai/search/"><meta property="twitter:title" content="Search | Field Journal"><meta property="twitter:description" content="Search Field Journal."><meta property="twitter:image" content="https://fieldjournal.ai/cdn-cgi/image/onerror=redirect,width=1200,height=630,format=jpg/_astro/blog-placeholder-1.Bx0Zcyzv.jpg"><meta property="twitter:image:alt" content="Search | Field Journal">
141<script type="application/ld+json">[{"@context":"https://schema.org","@type":"Organization","name":"Field Journal.ai","url":"https://fieldjournal.ai/","logo":{"@type":"ImageObject","url":"https://fieldjournal.ai/favicon.svg"}},{"@context":"https://schema.org","@type":"WebSite","name":"Field Journal.ai","url":"https://fieldjournal.ai/","potentialAction":{"@type":"SearchAction","target":"https://fieldjournal.ai/search/?q={search_term_string}","query-input":"required name=search_term_string"}}]</script>
141<link rel="stylesheet" href="/_astro/_slug_.e9f4Ok3l.css"></head> <body> <header class="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div class="container flex h-16 items-center gap-3 md:gap-4 md:grid md:grid-cols-[1fr_auto_1fr]"> <a href="/" class="no-underline inline-flex min-w-0 items-center gap-2 font-logo font-semibold tracking-tight leading-none text-2xl md:text-3xl text-primary hover:text-primary dark:text-white dark:hover:text-white"> <svg viewBox="0 0 24 24" class="block shrink-0 h-6 w-6 md:h-7 md:w-7 relative -top-px" fill="none" aria-hidden="true"> <path d="M7 4.5h10a2 2 0 0 1 2 2v13a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v-13a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path> <path d="M9 6.2v10.8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> <path d="M15.5 4.5v7l2-1.2 2 1.2v-7" class="fill-[hsl(var(--ring))] stroke-[hsl(var(--ring))] dark:fill-current dark:stroke-current" stroke-width="0.75" stroke-linejoin="round"></path> </svg> <span class="leading-none truncate">Field Journal.ai</span> </a> <nav class="hidden md:flex items-center gap-6" aria-label="Primary"> <a href="/blog" class="no-underline text-sm text-muted-foreground hover:text-foreground [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
142Articles
143 </a> <a href="/categories" class="no-underline text-sm text-muted-foreground hover:text-foreground [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
144Categories
145 </a> <a href="/tags" class="no-underline text-sm text-muted-foreground hover:text-foreground [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
146Tags
147 </a> <a href="/about" class="no-underline text-sm text-muted-foreground hover:text-foreground [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
148About
149 </a> </nav> <div class="ml-auto flex shrink-0 items-center gap-3 md:gap-4 md:ml-0 md:justify-self-end"> <div class="relative hidden md:block md:h-10 md:w-10" data-search-root> <div class="group absolute right-0 top-0 w-10 transition-[width] duration-200 ease-out focus-within:w-72" data-search-shell> <form action="/search" method="get" aria-label="Search posts" data-search-form> <div class="relative flex items-center"> <span class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground transition-[left,transform] duration-200 ease-out left-1/2 -translate-x-1/2 group-focus-within:left-3 group-focus-within:translate-x-0" aria-hidden="true"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none"> <path d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z" stroke="currentColor" stroke-width="1.8"></path> <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> </svg> </span> <input type="search" name="q" autocomplete="off" placeholder="Search…" class="h-10 w-full rounded-md border bg-background py-0 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer text-transparent placeholder:text-transparent caret-transparent px-0 text-center group-focus-within:cursor-text group-focus-within:text-foreground group-focus-within:placeholder:text-muted-foreground group-focus-within:caret-foreground group-focus-within:pl-10 group-focus-within:pr-3 group-focus-within:text-left" data-search-input> </div> </form> <div class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg opacity-0 scale-[0.98] translate-y-[-4px] pointer-events-none transition-[opacity,transform] duration-150 ease-out data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=open]:translate-y-0 data-[state=open]:pointer-events-auto" data-search-panel data-state="closed" inert aria-hidden="true"> <div class="flex items-center justify-between gap-4 border-b px-4 py-2"> <p class="text-xs text-muted-foreground" data-search-status></p> <a class="text-xs text-muted-foreground hover:text-foreground" href="/search">Open search</a> </div> <ul class="max-h-80 overflow-auto p-2" data-search-results></ul> </div> </div> </div> 
149<script>(function(){const baseUrl = "/";
150const formAction = "/search";
151
152	(() => {
153		const root = document.currentScript?.previousElementSibling;
154		if (!(root instanceof HTMLElement)) return;
155
156		const form = root.querySelector('[data-search-form]');
157		const input = root.querySelector('[data-search-input]');
158		const panel = root.querySelector('[data-search-panel]');
159		const status = root.querySelector('[data-search-status]');
160		const results = root.querySelector('[data-search-results]');
161
162		if (!(form instanceof HTMLFormElement)) return;
163		if (!(input instanceof HTMLInputElement)) return;
164		if (!(panel instanceof HTMLElement)) return;
165		if (!(status instanceof HTMLElement)) return;
166		if (!(results instanceof HTMLElement)) return;
167
168		const base = new URL(baseUrl || '/', window.location.origin);
169		const searchIndexUrl = new URL('search-index.json', base);
170
171		/** @type {{ items: Array<{ title: string; description?: string; url: string; category?: string; tags?: string[] }> } | null} */
172		let index = null;
173		let loading = false;
174
175		function openPanel() {
176			panel.dataset.state = 'open';
177			panel.removeAttribute('inert');
178			panel.setAttribute('aria-hidden', 'false');
179		}
180
181		function closePanel() {
182			panel.dataset.state = 'closed';
183			panel.setAttribute('inert', '');
184			panel.setAttribute('aria-hidden', 'true');
185		}
186
187		function escapeHtml(str) {
188			return String(str)
189				.replaceAll('&', '&amp;')
190				.replaceAll('<', '&lt;')
191				.replaceAll('>', '&gt;')
192				.replaceAll('"', '&quot;')
193				.replaceAll("'", '&#39;');
194		}
195
196		async function ensureIndex() {
197			if (index || loading) return;
198			loading = true;
199			status.textContent = 'Loading…';
200			try {
201				const res = await fetch(searchIndexUrl);
202				if (!res.ok) throw new Error(`Failed to load search index (${res.status})`);
203				index = await res.json();
204			} catch {
205				status.textContent = 'Search unavailable.';
206				index = { items: [] };
207			} finally {
208				loading = false;
209			}
210		}
211
212		function searchItems(q) {
213			const query = q.trim().toLowerCase();
214			if (!query || !index) return [];
215			return index.items
216				.filter((item) => {
217					const haystack = [
218						item.title,
219						item.description || '',
220						item.category || '',
221						...((item.tags || []) ?? []),
222					]
223						.join(' ')
224						.toLowerCase();
225					return haystack.includes(query);
226				})
227				.slice(0, 8);
228		}
229
230		function render(items, q) {
231			const query = q.trim();
232			status.textContent =
233				query.length === 0 ? '' : items.length === 0 ? 'No matches.' : `${items.length} result${items.length === 1 ? '' : 's'}`;
234
235			results.innerHTML = items
236				.map((item) => {
237					const meta = [item.category, ...((item.tags || []) ?? [])].filter(Boolean).join(' · ');
238					return `
239						<li>
240							<a class="no-underline block rounded-lg px-3 py-2 hover:bg-accent/40 hover:text-accent-foreground" href="${item.url}">
241								<div class="text-sm font-medium leading-snug">${escapeHtml(item.title)}</div>
242								${meta ? `<div class="mt-1 text-xs text-muted-foreground">${escapeHtml(meta)}</div>` : ''}
243							</a>
244						</li>
245					`;
246				})
247				.join('');
248		}
249
250		function goToSearch(q) {
251			const u = new URL(formAction, window.location.origin);
252			const query = q.trim();
253			if (query) u.searchParams.set('q', query);
254			window.location.assign(u.toString());
255		}
256
257		// Init from URL when on /search
258		const params = new URLSearchParams(window.location.search);
259		if (params.get('q')) input.value = params.get('q') || '';
260
261		input.addEventListener('focus', async () => {
262			await ensureIndex();
263			const items = searchItems(input.value);
264			render(items, input.value);
265			openPanel();
266		});
267
268		input.addEventListener('input', async () => {
269			await ensureIndex();
270			const items = searchItems(input.value);
271			render(items, input.value);
272			if (input.value.trim().length > 0) openPanel();
273		});
274
275		form.addEventListener('submit', (e) => {
276			e.preventDefault();
277			goToSearch(input.value);
278		});
279
280		document.addEventListener('keydown', (e) => {
281			if (e.key === 'Escape') closePanel();
282		});
283
284		// Close early on outside pointerdown to avoid width-collapsing artifacts.
285		document.addEventListener(
286			'pointerdown',
287			(e) => {
288				const t = e.target;
289				if (!(t instanceof Node)) return;
290				if (root.contains(t)) return;
291				closePanel();
292			},
293			{ capture: true },
294		);
295
296		// Close on outside click (fallback).
297		document.addEventListener('click', (e) => {
298			const t = e.target;
299			if (!(t instanceof Node)) return;
300			if (root.contains(t)) return;
301			closePanel();
302		});
303
304		// Close when focus fully leaves the component (keyboard / tab navigation).
305		root.addEventListener('focusout', () => {
306			window.setTimeout(() => {
307				const active = document.activeElement;
308				if (!(active instanceof Node)) return;
309				if (root.contains(active)) return;
310				closePanel();
311			}, 0);
312		});
313
314		// Keep the query param in sync while on /search, without full reload.
315		if (window.location.pathname.endsWith('/search') || window.location.pathname.endsWith('/search/')) {
316			input.addEventListener('input', () => {
317				const nextUrl = new URL(window.location.href);
318				const q = input.value.trim();
319				if (q) nextUrl.searchParams.set('q', q);
320				else nextUrl.searchParams.delete('q');
321				history.replaceState(null, '', nextUrl);
322			});
323		}
324	})();
325})();</script>
325 <details class="relative" data-mode> <summary class="list-none [&::-webkit-details-marker]:hidden inline-flex h-10 w-10 items-center justify-center rounded-md border bg-background p-2 text-foreground hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Toggle theme menu"> <span class="relative h-5 w-5" aria-hidden="true"> <!-- Sun (light) --> <svg class="absolute inset-0 h-5 w-5 rotate-0 scale-100 opacity-100 transition-all duration-200 dark:-rotate-90 dark:scale-0 dark:opacity-0" viewBox="0 0 24 24" fill="none"> <path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Z" stroke="currentColor" stroke-width="1.6"></path> <path d="M12 2v2m0 16v2M4 12H2m20 0h-2M5.1 5.1 6.5 6.5m11 11 1.4 1.4M18.9 5.1 17.5 6.5m-11 11L5.1 18.9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"></path> </svg> <!-- Moon (dark) --> <svg class="absolute inset-0 h-5 w-5 rotate-90 scale-0 opacity-0 transition-all duration-200 dark:rotate-0 dark:scale-100 dark:opacity-100" viewBox="0 0 24 24" fill="none"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path> </svg> </span> <span class="sr-only">Theme</span> </summary> <div class="absolute right-0 top-full z-50 mt-2 w-40 rounded-md border bg-popover p-1 text-popover-foreground shadow-lg" aria-label="Theme options"> <button type="button" data-set-theme="light" class="w-full rounded-sm px-2 py-2 text-left text-sm hover:bg-accent/40 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active]:bg-accent/50 data-[active]:text-accent-foreground data-[active]:font-medium">
326Light
327</button> <button type="button" data-set-theme="dark" class="w-full rounded-sm px-2 py-2 text-left text-sm hover:bg-accent/40 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active]:bg-accent/50 data-[active]:text-accent-foreground data-[active]:font-medium">
328Dark
329</button> <button type="button" data-set-theme="system" class="w-full rounded-sm px-2 py-2 text-left text-sm hover:bg-accent/40 hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active]:bg-accent/50 data-[active]:text-accent-foreground data-[active]:font-medium">
330System
331</button> </div> </details> 
331<script>
332	(() => {
333		const root = document.documentElement;
334		const host = document.currentScript?.previousElementSibling;
335		if (!(host instanceof HTMLElement)) return;
336		if (!(host instanceof HTMLDetailsElement)) return;
337
338		/** @type {(pref: 'light'|'dark'|'system') => void} */
339		const setPref = window.__setThemePreference || (() => {});
340		/** @type {() => ('light'|'dark'|'system')} */
341		const getPref = window.__getThemePreference || (() => 'system');
342
343		const buttons = Array.from(host.querySelectorAll('[data-set-theme]'));
344		function syncMenu() {
345			const pref = getPref();
346			for (const btn of buttons) {
347				if (!(btn instanceof HTMLButtonElement)) continue;
348				const val = btn.getAttribute('data-set-theme');
349				const active = val === pref;
350				btn.setAttribute('aria-pressed', active ? 'true' : 'false');
351				btn.toggleAttribute('data-active', active);
352			}
353			// Helpful hook for CSS if needed.
354			host.setAttribute('data-pref', pref);
355		}
356
357		host.addEventListener('toggle', syncMenu);
358		syncMenu();
359
360		host.addEventListener('click', (e) => {
361			const target = e.target;
362			if (!(target instanceof HTMLElement)) return;
363			const btn = target.closest('[data-set-theme]');
364			if (!(btn instanceof HTMLButtonElement)) return;
365			const pref = btn.getAttribute('data-set-theme');
366			if (pref !== 'light' && pref !== 'dark' && pref !== 'system') return;
367			setPref(pref);
368			syncMenu();
369			host.open = false;
370		});
371
372		document.addEventListener('keydown', (e) => {
373			if (!host.open) return;
374			if (e.key === 'Escape') host.open = false;
375		});
376
377		// Close on outside click.
378		document.addEventListener('click', (e) => {
379			if (!host.open) return;
380			const t = e.target;
381			if (!(t instanceof Node)) return;
382			if (host.contains(t)) return;
383			host.open = false;
384		});
385
386		// Keep tabs in sync.
387		window.addEventListener('storage', (e) => {
388			if (e.key === 'theme') syncMenu();
389		});
390
391		// Keep icon state updated if something else toggles the root class.
392		const obs = new MutationObserver(syncMenu);
393		obs.observe(root, { attributes: true, attributeFilter: ['class'] });
394	})();
395</script>
395 <button type="button" class="inline-flex md:hidden h-10 w-10 items-center justify-center rounded-md border bg-background p-2 text-foreground hover:bg-muted/30" aria-expanded="false" aria-controls="mobile-nav" aria-label="Toggle menu" data-menu-button> <span class="sr-only">Toggle menu</span> <svg class="menu-open" viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true"> <path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> </svg> <svg class="menu-close hidden" viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true"> <path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> </svg> </button> </div> </div> <div id="mobile-nav" class="md:hidden border-t overflow-hidden transition-[max-height,opacity,transform] duration-200 ease-out max-h-0 opacity-0 -translate-y-1 pointer-events-none data-[state=open]:max-h-[70vh] data-[state=open]:opacity-100 data-[state=open]:translate-y-0 data-[state=open]:pointer-events-auto" data-mobile-nav data-state="closed" inert aria-hidden="true"> <div class="container py-4"> <nav class="flex flex-col gap-1" aria-label="Mobile"> <a href="/" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
396Home
397 </a> <a href="/blog" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
398Articles
399 </a> <a href="/categories" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
400Categories
401 </a> <a href="/tags" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
402Tags
403 </a> <a href="/search" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium active"> 
404Search
405 </a> <a href="/about" class="no-underline rounded-md px-2 py-2 text-sm text-muted-foreground hover:bg-muted/30 hover:text-foreground [&#38;.active]:bg-muted/30 [&#38;.active]:text-foreground [&#38;.active]:font-medium"> 
406About
407 </a> <div class="mt-3"> <div class="relative" data-search-root> <div class="relative" data-search-shell> <form action="/search" method="get" aria-label="Search posts" data-search-form> <div class="relative flex items-center"> <span class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground transition-[left,transform] duration-200 ease-out left-3" aria-hidden="true"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none"> <path d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z" stroke="currentColor" stroke-width="1.8"></path> <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> </svg> </span> <input type="search" name="q" autocomplete="off" placeholder="Search…" class="h-10 w-full rounded-md border bg-background py-0 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pl-10 pr-3" data-search-input> </div> </form> <div class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg opacity-0 scale-[0.98] translate-y-[-4px] pointer-events-none transition-[opacity,transform] duration-150 ease-out data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=open]:translate-y-0 data-[state=open]:pointer-events-auto" data-search-panel data-state="closed" inert aria-hidden="true"> <div class="flex items-center justify-between gap-4 border-b px-4 py-2"> <p class="text-xs text-muted-foreground" data-search-status></p> <a class="text-xs text-muted-foreground hover:text-foreground" href="/search">Open search</a> </div> <ul class="max-h-80 overflow-auto p-2" data-search-results></ul> </div> </div> </div> 
407<script>(function(){const baseUrl = "/";
408const formAction = "/search";
409
410	(() => {
411		const root = document.currentScript?.previousElementSibling;
412		if (!(root instanceof HTMLElement)) return;
413
414		const form = root.querySelector('[data-search-form]');
415		const input = root.querySelector('[data-search-input]');
416		const panel = root.querySelector('[data-search-panel]');
417		const status = root.querySelector('[data-search-status]');
418		const results = root.querySelector('[data-search-results]');
419
420		if (!(form instanceof HTMLFormElement)) return;
421		if (!(input instanceof HTMLInputElement)) return;
422		if (!(panel instanceof HTMLElement)) return;
423		if (!(status instanceof HTMLElement)) return;
424		if (!(results instanceof HTMLElement)) return;
425
426		const base = new URL(baseUrl || '/', window.location.origin);
427		const searchIndexUrl = new URL('search-index.json', base);
428
429		/** @type {{ items: Array<{ title: string; description?: string; url: string; category?: string; tags?: string[] }> } | null} */
430		let index = null;
431		let loading = false;
432
433		function openPanel() {
434			panel.dataset.state = 'open';
435			panel.removeAttribute('inert');
436			panel.setAttribute('aria-hidden', 'false');
437		}
438
439		function closePanel() {
440			panel.dataset.state = 'closed';
441			panel.setAttribute('inert', '');
442			panel.setAttribute('aria-hidden', 'true');
443		}
444
445		function escapeHtml(str) {
446			return String(str)
447				.replaceAll('&', '&amp;')
448				.replaceAll('<', '&lt;')
449				.replaceAll('>', '&gt;')
450				.replaceAll('"', '&quot;')
451				.replaceAll("'", '&#39;');
452		}
453
454		async function ensureIndex() {
455			if (index || loading) return;
456			loading = true;
457			status.textContent = 'Loading…';
458			try {
459				const res = await fetch(searchIndexUrl);
460				if (!res.ok) throw new Error(`Failed to load search index (${res.status})`);
461				index = await res.json();
462			} catch {
463				status.textContent = 'Search unavailable.';
464				index = { items: [] };
465			} finally {
466				loading = false;
467			}
468		}
469
470		function searchItems(q) {
471			const query = q.trim().toLowerCase();
472			if (!query || !index) return [];
473			return index.items
474				.filter((item) => {
475					const haystack = [
476						item.title,
477						item.description || '',
478						item.category || '',
479						...((item.tags || []) ?? []),
480					]
481						.join(' ')
482						.toLowerCase();
483					return haystack.includes(query);
484				})
485				.slice(0, 8);
486		}
487
488		function render(items, q) {
489			const query = q.trim();
490			status.textContent =
491				query.length === 0 ? '' : items.length === 0 ? 'No matches.' : `${items.length} result${items.length === 1 ? '' : 's'}`;
492
493			results.innerHTML = items
494				.map((item) => {
495					const meta = [item.category, ...((item.tags || []) ?? [])].filter(Boolean).join(' · ');
496					return `
497						<li>
498							<a class="no-underline block rounded-lg px-3 py-2 hover:bg-accent/40 hover:text-accent-foreground" href="${item.url}">
499								<div class="text-sm font-medium leading-snug">${escapeHtml(item.title)}</div>
500								${meta ? `<div class="mt-1 text-xs text-muted-foreground">${escapeHtml(meta)}</div>` : ''}
501							</a>
502						</li>
503					`;
504				})
505				.join('');
506		}
507
508		function goToSearch(q) {
509			const u = new URL(formAction, window.location.origin);
510			const query = q.trim();
511			if (query) u.searchParams.set('q', query);
512			window.location.assign(u.toString());
513		}
514
515		// Init from URL when on /search
516		const params = new URLSearchParams(window.location.search);
517		if (params.get('q')) input.value = params.get('q') || '';
518
519		input.addEventListener('focus', async () => {
520			await ensureIndex();
521			const items = searchItems(input.value);
522			render(items, input.value);
523			openPanel();
524		});
525
526		input.addEventListener('input', async () => {
527			await ensureIndex();
528			const items = searchItems(input.value);
529			render(items, input.value);
530			if (input.value.trim().length > 0) openPanel();
531		});
532
533		form.addEventListener('submit', (e) => {
534			e.preventDefault();
535			goToSearch(input.value);
536		});
537
538		document.addEventListener('keydown', (e) => {
539			if (e.key === 'Escape') closePanel();
540		});
541
542		// Close early on outside pointerdown to avoid width-collapsing artifacts.
543		document.addEventListener(
544			'pointerdown',
545			(e) => {
546				const t = e.target;
547				if (!(t instanceof Node)) return;
548				if (root.contains(t)) return;
549				closePanel();
550			},
551			{ capture: true },
552		);
553
554		// Close on outside click (fallback).
555		document.addEventListener('click', (e) => {
556			const t = e.target;
557			if (!(t instanceof Node)) return;
558			if (root.contains(t)) return;
559			closePanel();
560		});
561
562		// Close when focus fully leaves the component (keyboard / tab navigation).
563		root.addEventListener('focusout', () => {
564			window.setTimeout(() => {
565				const active = document.activeElement;
566				if (!(active instanceof Node)) return;
567				if (root.contains(active)) return;
568				closePanel();
569			}, 0);
570		});
571
572		// Keep the query param in sync while on /search, without full reload.
573		if (window.location.pathname.endsWith('/search') || window.location.pathname.endsWith('/search/')) {
574			input.addEventListener('input', () => {
575				const nextUrl = new URL(window.location.href);
576				const q = input.value.trim();
577				if (q) nextUrl.searchParams.set('q', q);
578				else nextUrl.searchParams.delete('q');
579				history.replaceState(null, '', nextUrl);
580			});
581		}
582	})();
583})();</script>
583 </div> </nav> </div> </div> </header> 
583<script>
584	(() => {
585		const header = document.currentScript?.previousElementSibling;
586		if (!(header instanceof HTMLElement)) return;
587
588		const btn = header.querySelector('[data-menu-button]');
589		const panel = header.querySelector('[data-mobile-nav]');
590		const iconOpen = header.querySelector('.menu-open');
591		const iconClose = header.querySelector('.menu-close');
592
593		if (!(btn instanceof HTMLButtonElement)) return;
594		if (!(panel instanceof HTMLElement)) return;
595
596		const close = () => {
597			btn.setAttribute('aria-expanded', 'false');
598			panel.dataset.state = 'closed';
599			panel.setAttribute('inert', '');
600			panel.setAttribute('aria-hidden', 'true');
601			iconOpen?.classList.remove('hidden');
602			iconClose?.classList.add('hidden');
603		};
604
605		const open = () => {
606			btn.setAttribute('aria-expanded', 'true');
607			panel.dataset.state = 'open';
608			panel.removeAttribute('inert');
609			panel.setAttribute('aria-hidden', 'false');
610			iconOpen?.classList.add('hidden');
611			iconClose?.classList.remove('hidden');
612		};
613
614		btn.addEventListener('click', () => {
615			const isOpen = btn.getAttribute('aria-expanded') === 'true';
616			if (isOpen) close();
617			else open();
618		});
619
620		document.addEventListener('keydown', (e) => {
621			if (e.key !== 'Escape') return;
622			if (btn.getAttribute('aria-expanded') !== 'true') return;
623			close();
624		});
625
626		document.addEventListener('click', (e) => {
627			if (btn.getAttribute('aria-expanded') !== 'true') return;
628			const target = e.target;
629			if (!(target instanceof Node)) return;
630			if (header.contains(target)) return;
631			close();
632		});
633
634		// Close after a navigation click.
635		panel.addEventListener('click', (e) => {
636			const t = e.target;
637			if (!(t instanceof HTMLElement)) return;
638			if (t.closest('a')) close();
639		});
640	})();
641</script>
641 <main class="container py-10 md:py-14"> <section class="mx-auto max-w-3xl"> <h1 class="sr-only">Search</h1> <div class="relative" data-search-root> <div class="relative" data-search-shell> <form action="/search" method="get" aria-label="Search posts" data-search-form> <div class="relative flex items-center"> <span class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-muted-foreground transition-[left,transform] duration-200 ease-out left-3" aria-hidden="true"> <svg viewBox="0 0 24 24" width="18" height="18" fill="none"> <path d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z" stroke="currentColor" stroke-width="1.8"></path> <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path> </svg> </span> <input type="search" name="q" autocomplete="off" placeholder="Search…" class="h-10 w-full rounded-md border bg-background py-0 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring pl-10 pr-3" data-search-input> </div> </form> <div class="absolute left-0 right-0 z-50 mt-2 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg opacity-0 scale-[0.98] translate-y-[-4px] pointer-events-none transition-[opacity,transform] duration-150 ease-out data-[state=open]:opacity-100 data-[state=open]:scale-100 data-[state=open]:translate-y-0 data-[state=open]:pointer-events-auto" data-search-panel data-state="closed" inert aria-hidden="true"> <div class="flex items-center justify-between gap-4 border-b px-4 py-2"> <p class="text-xs text-muted-foreground" data-search-status></p> <a class="text-xs text-muted-foreground hover:text-foreground" href="/search">Open search</a> </div> <ul class="max-h-80 overflow-auto p-2" data-search-results></ul> </div> </div> </div> 
641<script>(function(){const baseUrl = "/";
642const formAction = "/search";
643
644	(() => {
645		const root = document.currentScript?.previousElementSibling;
646		if (!(root instanceof HTMLElement)) return;
647
648		const form = root.querySelector('[data-search-form]');
649		const input = root.querySelector('[data-search-input]');
650		const panel = root.querySelector('[data-search-panel]');
651		const status = root.querySelector('[data-search-status]');
652		const results = root.querySelector('[data-search-results]');
653
654		if (!(form instanceof HTMLFormElement)) return;
655		if (!(input instanceof HTMLInputElement)) return;
656		if (!(panel instanceof HTMLElement)) return;
657		if (!(status instanceof HTMLElement)) return;
658		if (!(results instanceof HTMLElement)) return;
659
660		const base = new URL(baseUrl || '/', window.location.origin);
661		const searchIndexUrl = new URL('search-index.json', base);
662
663		/** @type {{ items: Array<{ title: string; description?: string; url: string; category?: string; tags?: string[] }> } | null} */
664		let index = null;
665		let loading = false;
666
667		function openPanel() {
668			panel.dataset.state = 'open';
669			panel.removeAttribute('inert');
670			panel.setAttribute('aria-hidden', 'false');
671		}
672
673		function closePanel() {
674			panel.dataset.state = 'closed';
675			panel.setAttribute('inert', '');
676			panel.setAttribute('aria-hidden', 'true');
677		}
678
679		function escapeHtml(str) {
680			return String(str)
681				.replaceAll('&', '&amp;')
682				.replaceAll('<', '&lt;')
683				.replaceAll('>', '&gt;')
684				.replaceAll('"', '&quot;')
685				.replaceAll("'", '&#39;');
686		}
687
688		async function ensureIndex() {
689			if (index || loading) return;
690			loading = true;
691			status.textContent = 'Loading…';
692			try {
693				const res = await fetch(searchIndexUrl);
694				if (!res.ok) throw new Error(`Failed to load search index (${res.status})`);
695				index = await res.json();
696			} catch {
697				status.textContent = 'Search unavailable.';
698				index = { items: [] };
699			} finally {
700				loading = false;
701			}
702		}
703
704		function searchItems(q) {
705			const query = q.trim().toLowerCase();
706			if (!query || !index) return [];
707			return index.items
708				.filter((item) => {
709					const haystack = [
710						item.title,
711						item.description || '',
712						item.category || '',
713						...((item.tags || []) ?? []),
714					]
715						.join(' ')
716						.toLowerCase();
717					return haystack.includes(query);
718				})
719				.slice(0, 8);
720		}
721
722		function render(items, q) {
723			const query = q.trim();
724			status.textContent =
725				query.length === 0 ? '' : items.length === 0 ? 'No matches.' : `${items.length} result${items.length === 1 ? '' : 's'}`;
726
727			results.innerHTML = items
728				.map((item) => {
729					const meta = [item.category, ...((item.tags || []) ?? [])].filter(Boolean).join(' · ');
730					return `
731						<li>
732							<a class="no-underline block rounded-lg px-3 py-2 hover:bg-accent/40 hover:text-accent-foreground" href="${item.url}">
733								<div class="text-sm font-medium leading-snug">${escapeHtml(item.title)}</div>
734								${meta ? `<div class="mt-1 text-xs text-muted-foreground">${escapeHtml(meta)}</div>` : ''}
735							</a>
736						</li>
737					`;
738				})
739				.join('');
740		}
741
742		function goToSearch(q) {
743			const u = new URL(formAction, window.location.origin);
744			const query = q.trim();
745			if (query) u.searchParams.set('q', query);
746			window.location.assign(u.toString());
747		}
748
749		// Init from URL when on /search
750		const params = new URLSearchParams(window.location.search);
751		if (params.get('q')) input.value = params.get('q') || '';
752
753		input.addEventListener('focus', async () => {
754			await ensureIndex();
755			const items = searchItems(input.value);
756			render(items, input.value);
757			openPanel();
758		});
759
760		input.addEventListener('input', async () => {
761			await ensureIndex();
762			const items = searchItems(input.value);
763			render(items, input.value);
764			if (input.value.trim().length > 0) openPanel();
765		});
766
767		form.addEventListener('submit', (e) => {
768			e.preventDefault();
769			goToSearch(input.value);
770		});
771
772		document.addEventListener('keydown', (e) => {
773			if (e.key === 'Escape') closePanel();
774		});
775
776		// Close early on outside pointerdown to avoid width-collapsing artifacts.
777		document.addEventListener(
778			'pointerdown',
779			(e) => {
780				const t = e.target;
781				if (!(t instanceof Node)) return;
782				if (root.contains(t)) return;
783				closePanel();
784			},
785			{ capture: true },
786		);
787
788		// Close on outside click (fallback).
789		document.addEventListener('click', (e) => {
790			const t = e.target;
791			if (!(t instanceof Node)) return;
792			if (root.contains(t)) return;
793			closePanel();
794		});
795
796		// Close when focus fully leaves the component (keyboard / tab navigation).
797		root.addEventListener('focusout', () => {
798			window.setTimeout(() => {
799				const active = document.activeElement;
800				if (!(active instanceof Node)) return;
801				if (root.contains(active)) return;
802				closePanel();
803			}, 0);
804		});
805
806		// Keep the query param in sync while on /search, without full reload.
807		if (window.location.pathname.endsWith('/search') || window.location.pathname.endsWith('/search/')) {
808			input.addEventListener('input', () => {
809				const nextUrl = new URL(window.location.href);
810				const q = input.value.trim();
811				if (q) nextUrl.searchParams.set('q', q);
812				else nextUrl.searchParams.delete('q');
813				history.replaceState(null, '', nextUrl);
814			});
815		}
816	})();
817})();</script>
817 </section> </main> <footer class="border-t py-10"> <div class="container flex flex-col items-center justify-between gap-2 text-sm text-muted-foreground md:flex-row"> <div>&copy; 2026 Field Journal.ai.</div> <div class="flex items-center gap-4"> <a class="no-underline hover:underline hover:text-foreground" href="/privacy/">Privacy</a> <a class="no-underline hover:underline hover:text-foreground" href="/terms/">Terms</a> <a class="no-underline hover:underline hover:text-foreground" href="/disclaimer/">Disclaimer</a> <a class="no-underline hover:underline hover:text-foreground" href="/cookies/">Cookies</a> <a class="no-underline hover:underline hover:text-foreground" href="/rss.xml">RSS</a> </div> </div> </footer> <div id="fj-cookie-banner" class="fixed inset-x-0 bottom-0 z-[60] hidden border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur supports-[backdrop-filter]:bg-background/80" role="dialog" aria-modal="true" aria-label="Cookie consent"> <div class="container py-4"> <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div class="text-sm text-muted-foreground">
818We use analytics cookies for website management purposes (understanding aggregate usage and improving the site).
819<a class="no-underline underline-offset-4 hover:underline hover:text-foreground" href="/cookies/">Learn more</a>.
820</div> <div class="flex items-center gap-2"> <button id="fj-cookie-decline" type="button" class="inline-flex h-10 items-center justify-center rounded-md border bg-background px-4 text-sm font-medium hover:bg-muted/30">
821Decline
822</button> <button id="fj-cookie-accept" type="button" class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90">
823Accept
824</button> </div> </div> </div> </div> 
824<script>
825	(() => {
826		const CONSENT_KEY = 'fj_cookie_consent'; // 'accepted' | 'rejected'
827		const banner = document.getElementById('fj-cookie-banner');
828		const acceptBtn = document.getElementById('fj-cookie-accept');
829		const declineBtn = document.getElementById('fj-cookie-decline');
830		if (!banner || !acceptBtn || !declineBtn) return;
831
832		const getCookie = (name) => {
833			try {
834				const cookie = document.cookie || '';
835				if (!cookie) return null;
836				const parts = cookie.split('; ');
837				for (const part of parts) {
838					const idx = part.indexOf('=');
839					const key = idx === -1 ? part : part.slice(0, idx);
840					if (key !== name) continue;
841					const raw = idx === -1 ? '' : part.slice(idx + 1);
842					return decodeURIComponent(raw);
843				}
844			} catch {
845				// ignore
846			}
847			return null;
848		};
849
850		const getStored = () => {
851			// Prefer localStorage, but fall back to the cookie. Some browsers/contexts block localStorage.
852			try {
853				const value = localStorage.getItem(CONSENT_KEY);
854				if (value === 'accepted' || value === 'rejected') return value;
855			} catch {
856				// ignore
857			}
858			const value = getCookie(CONSENT_KEY);
859			if (value === 'accepted' || value === 'rejected') return value;
860			return null;
861		};
862
863		const setStored = (value) => {
864			try {
865				localStorage.setItem(CONSENT_KEY, value);
866			} catch {
867				// ignore
868			}
869			try {
870				const maxAge = 60 * 60 * 24 * 365; // 1y
871				document.cookie = `${CONSENT_KEY}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; samesite=lax`;
872			} catch {
873				// ignore
874			}
875		};
876
877		const notify = () => {
878			try {
879				window.dispatchEvent(new Event('fj-cookie-consent'));
880			} catch {
881				// ignore
882			}
883		};
884
885		const hide = () => banner.classList.add('hidden');
886		const show = () => banner.classList.remove('hidden');
887
888		const stored = getStored();
889		if (stored !== 'accepted' && stored !== 'rejected') show();
890
891		acceptBtn.addEventListener('click', () => {
892			setStored('accepted');
893			hide();
894			notify();
895		});
896
897		declineBtn.addEventListener('click', () => {
898			setStored('rejected');
899			hide();
900			notify();
901		});
902	})();
903</script>
903 
903<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"2410a260a9e54af086a2d8e06e574e15","r":1,"spa":2}' crossorigin="anonymous"></script>
903
904</body></html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.