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 [&.active]:text-foreground [&.active]:font-medium"> 142Articles 143 </a> <a href="/categories" class="no-underline text-sm text-muted-foreground hover:text-foreground [&.active]:text-foreground [&.active]:font-medium"> 144Categories 145 </a> <a href="/tags" class="no-underline text-sm text-muted-foreground hover:text-foreground [&.active]:text-foreground [&.active]:font-medium"> 146Tags 147 </a> <a href="/about" class="no-underline text-sm text-muted-foreground hover:text-foreground [&.active]:text-foreground [&.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('&', '&') 190 .replaceAll('<', '<') 191 .replaceAll('>', '>') 192 .replaceAll('"', '"') 193 .replaceAll("'", '''); 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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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 [&.active]:bg-muted/30 [&.active]:text-foreground [&.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('&', '&') 448 .replaceAll('<', '<') 449 .replaceAll('>', '>') 450 .replaceAll('"', '"') 451 .replaceAll("'", '''); 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('&', '&') 682 .replaceAll('<', '<') 683 .replaceAll('>', '>') 684 .replaceAll('"', '"') 685 .replaceAll("'", '''); 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>© 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.