{
  "sha": "b739f1f6f4d14786fc26ff5e6d43cc3fc32eae3b",
  "message": "將 Google 風格搜尋欄移至全站頁首 (#62)\n\n將全站新聞搜尋欄移至頁首最上方，加入響應式 Google 風格介面、清除控制與靜態資產版本碼。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T21:49:06Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 19,
      "deletions": 7,
      "changes": 26
    },
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 0,
      "changes": 11
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 0,
      "deletions": 10,
      "changes": 10
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 55,
      "deletions": 16,
      "changes": 71
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 22,
      "deletions": 2,
      "changes": 24
    },
    {
      "filename": "templates/search.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 10,
      "changes": 11
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 25,
      "deletions": 1,
      "changes": 26
    }
  ],
  "diff": "@@ -10,13 +10,14 @@\\n \"\"\"\\n from __future__ import annotations\\n \\n+import hashlib\\n import json\\n+import math\\n import os\\n import re\\n import shutil\\n import sys\\n import time\\n-import math\\n import unicodedata\\n from datetime import timedelta, timezone\\n from pathlib import Path\\n@@ -49,6 +50,16 @@\\n     MODEL_ORDER,\\n )\\n \\n+\\n+def static_asset_version() -> str:\\n+    digest = hashlib.sha256()\\n+    for name in (\"site.css\", \"app.js\", \"search.js\"):\\n+        digest.update((STATIC_DIR / name).read_bytes())\\n+    return digest.hexdigest()[:10]\\n+\\n+\\n+ASSET_VERSION = static_asset_version()\\n+\\n # Asia/Taipei via zoneinfo where tz data exists (Linux CI); the zone has had a\\n # fixed +08:00 offset with no DST since 1980, so a static fallback is exact.\\n try:\\n@@ -106,11 +117,11 @@\\n         \"footerAbout\": \"全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源，自動撰寫並發布，每篇文末標註原始出處。本頁為設計原型，內容為示意樣本。\",\\n         \"footerSources\": \"資料來源 Data Sources\",\\n         \"footerChangelog\": \"網站更新紀錄\",\\n-        \"nav\": [\"最新\", \"搜尋\", \"快報\", \"航班雷達\", \"事故資料庫\", \"來源\", \"方法論\"],\\n+        \"nav\": [\"最新\", \"快報\", \"航班雷達\", \"事故資料庫\", \"來源\", \"方法論\"],\\n         \"cats\": [\"全部\", \"事故\", \"法規\", \"商業\", \"營運\", \"軍事\"],\\n         \"searchKicker\": \"News Search\", \"searchTitle\": \"搜尋新聞\",\\n+        \"headerSearchPlaceholder\": \"搜尋 AVWIRE 新聞\",\\n         \"searchSub\": \"搜尋標題、摘要、全文、來源、日期、航班、機型與機場代碼；航空公司正式名稱與常用簡稱可互相查找。\",\\n-        \"searchPlaceholder\": \"輸入關鍵字，例如：華航、A321neo、桃園\",\\n         \"searchButton\": \"搜尋\", \"searchClear\": \"清除\",\\n         \"searchLoading\": \"正在載入新聞索引…\",\\n         \"searchPrompt\": \"輸入關鍵字開始搜尋全部新聞。\",\\n@@ -202,13 +213,13 @@\\n         \"footerAbout\": \"A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.\",\\n         \"footerSources\": \"Data Sources\",\\n         \"footerChangelog\": \"Site changelog\",\\n-        \"nav\": [\"Latest\", \"Search\", \"Briefings\", \"Flight Radar\", \"Incident DB\", \"Sources\",\\n+        \"nav\": [\"Latest\", \"Briefings\", \"Flight Radar\", \"Incident DB\", \"Sources\",\\n                  \"Methodology\"],\\n         \"cats\": [\"All\", \"Safety\", \"Regulation\", \"Business\", \"Operations\",\\n                  \"Military\"],\\n         \"searchKicker\": \"News Search\", \"searchTitle\": \"Search news\",\\n+        \"headerSearchPlaceholder\": \"Search AVWIRE news\",\\n         \"searchSub\": \"Search titles, summaries, full text, sources, dates, flights, aircraft types and airport codes. Official airline names and common short names are interchangeable.\",\\n-        \"searchPlaceholder\": \"Enter keywords, e.g. China Airlines, A321neo, TPE\",\\n         \"searchButton\": \"Search\", \"searchClear\": \"Clear\",\\n         \"searchLoading\": \"Loading the news index…\",\\n         \"searchPrompt\": \"Enter keywords to search every published story.\",\\n@@ -2262,13 +2273,13 @@ def render_manual_workbench(env, build) -> int:\\n \\n def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\\n     t = L[lang]\\n-    nav_defs = [(\"home\", \"\"), (\"search\", \"search/\"),\\n-                (\"briefings\", \"briefings/\"),\\n+    nav_defs = [(\"home\", \"\"), (\"briefings\", \"briefings/\"),\\n                 (\"radar\", \"radar/\"),\\n                 (\"incidents\", \"incidents/\"),\\n                 (\"sources\", \"sources/\"), (\"about\", \"about/\")]\\n     return {\\n         \"lang\": lang, \"t\": t, \"base\": BASE_PATH, \"favicon\": FAVICON,\\n+        \"asset_version\": ASSET_VERSION,\\n         \"html_lang\": \"zh-Hant\" if lang == \"zh\" else \"en\",\\n         \"title\": title, \"description\": description,\\n         \"zh_url\": page_url(\"zh\", sub), \"en_url\": page_url(\"en\", sub),\\n@@ -2277,6 +2288,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\\n         \"self_abs_url\": SITE_ORIGIN + page_url(lang, sub),\\n         \"hreflang\": hreflang,\\n         \"home_url\": page_url(lang, \"\"),\\n+        \"search_url\": page_url(lang, \"search/\"),\\n         \"radar_url\": page_url(lang, \"radar/\"),\\n         \"changelog_url\": page_url(lang, \"changelog/\"),\\n         \"nav\": [{\"label\": t[\"nav\"][i], \"url\": page_url(lang, s), \"active\": page == p}\\n@@ -41,6 +41,17 @@\\n   });\\n   reflectTheme();\\n \\n+  /* — global header search: operate the Google-style clear key — */\\n+  var headerSearchInput = document.getElementById(\"news-search-input\");\\n+  var headerSearchClear = document.getElementById(\"news-search-clear\");\\n+  if (headerSearchInput && headerSearchClear) {\\n+    headerSearchClear.addEventListener(\"click\", function () {\\n+      headerSearchInput.value = \"\";\\n+      headerSearchInput.dispatchEvent(new Event(\"input\", { bubbles: true }));\\n+      headerSearchInput.focus();\\n+    });\\n+  }\\n+\\n   /* — home: category filter over feed rows ([data-cat]) — */\\n   var catBtns = Array.prototype.slice.call(document.querySelectorAll(\"[data-filter-cat]\"));\\n   catBtns.forEach(function (b) {\\n@@ -8,7 +8,6 @@\\n \\n   var form = document.getElementById(\"news-search-form\");\\n   var input = document.getElementById(\"news-search-input\");\\n-  var clear = document.getElementById(\"news-search-clear\");\\n   var status = document.getElementById(\"news-search-status\");\\n   var results = document.getElementById(\"news-search-results\");\\n   var lang = root.dataset.lang === \"en\" ? \"en\" : \"zh\";\\n@@ -102,7 +101,6 @@\\n \\n   function runSearch() {\\n     var query = input.value.trim();\\n-    clear.hidden = !query;\\n     updateQueryUrl(query);\\n     results.replaceChildren();\\n \\n@@ -145,19 +143,11 @@\\n     window.clearTimeout(debounceTimer);\\n     debounceTimer = window.setTimeout(runSearch, 120);\\n   });\\n-  clear.addEventListener(\"click\", function () {\\n-    input.value = \"\";\\n-    runSearch();\\n-    input.focus();\\n-  });\\n-\\n   try {\\n     input.value = new URL(window.location.href).searchParams.get(\"q\") || \"\";\\n   } catch (error) {\\n     input.value = \"\";\\n   }\\n-  clear.hidden = !input.value;\\n-\\n   fetch(root.dataset.indexUrl, { credentials: \"same-origin\" })\\n     .then(function (response) {\\n       if (!response.ok) throw new Error(\"HTTP \" + response.status);\\n@@ -58,6 +58,54 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\\n .pulse-dot { width: 8px; height: 8px; background: var(--color-accent); animation: avwire-pulse 2s infinite; }\\n .marquee-track { display: flex; width: max-content; animation: avwire-marquee 80s linear infinite; white-space: nowrap; }\\n \\n+/* — Google-style search, deliberately the first row of every page — */\\n+.header-search-row {\\n+  padding: 11px 20px; border-bottom: 1px solid var(--color-divider);\\n+  background: var(--color-bg);\\n+}\\n+.header-search-form {\\n+  width: min(720px, 100%); min-height: 48px; margin: 0 auto;\\n+  display: grid; grid-template-columns: 22px minmax(0, 1fr) auto 40px;\\n+  align-items: center; gap: 9px; padding: 3px 5px 3px 16px;\\n+  border: 1px solid color-mix(in srgb, var(--color-text) 24%, transparent);\\n+  border-radius: 999px; background: var(--color-surface);\\n+  box-shadow: 0 2px 8px color-mix(in srgb, var(--color-text) 12%, transparent);\\n+}\\n+.header-search-form:focus-within {\\n+  border-color: color-mix(in srgb, var(--color-accent) 70%, var(--color-divider));\\n+  box-shadow: 0 3px 12px color-mix(in srgb, var(--color-text) 18%, transparent);\\n+}\\n+.header-search-icon, .header-search-submit svg {\\n+  width: 20px; height: 20px; fill: none; stroke: currentColor;\\n+  stroke-width: 2; stroke-linecap: round;\\n+}\\n+.header-search-icon { color: color-mix(in srgb, var(--color-text) 55%, transparent); }\\n+.header-search-input {\\n+  min-width: 0; width: 100%; border: 0; outline: 0; padding: 9px 0;\\n+  background: transparent; color: var(--color-text); font: inherit;\\n+  font-size: 15px;\\n+}\\n+.header-search-input::placeholder {\\n+  color: color-mix(in srgb, var(--color-text) 52%, transparent);\\n+}\\n+.header-search-clear, .header-search-submit {\\n+  appearance: none; display: inline-flex; align-items: center; justify-content: center;\\n+  border: 0; border-radius: 50%; cursor: pointer; font-family: inherit;\\n+}\\n+.header-search-clear {\\n+  width: 32px; height: 32px; background: transparent; color: var(--color-text);\\n+  font-size: 24px; font-weight: 400; line-height: 1; opacity: 0; pointer-events: none;\\n+}\\n+.header-search-input:not(:placeholder-shown) + .header-search-clear { opacity: 1; pointer-events: auto; }\\n+.header-search-clear:hover { background: color-mix(in srgb, var(--color-text) 8%, transparent); }\\n+.header-search-submit {\\n+  width: 40px; height: 40px; background: var(--color-accent); color: var(--color-bg);\\n+}\\n+.header-search-submit:hover { background: var(--color-accent-600); }\\n+.header-search-clear:focus-visible, .header-search-submit:focus-visible {\\n+  outline: 2px solid var(--color-accent); outline-offset: 2px;\\n+}\\n+\\n .nav-link {\\n   cursor: pointer; font-size: 14px; font-weight: 600; padding: 8px 14px;\\n   border-bottom: 2px solid transparent; color: var(--color-text); text-decoration: none;\\n@@ -308,20 +356,6 @@ a.flash-row:hover { color: inherit; }\\n }\\n .search-header p { margin: 0; font-size: 14px; line-height: 1.8; }\\n .news-search { max-width: 960px; }\\n-.news-search-form {\\n-  display: grid; grid-template-columns: minmax(0, 1fr) auto auto;\\n-  gap: 8px; padding: 12px; border: 2px solid var(--color-divider);\\n-  background: var(--color-surface);\\n-}\\n-.news-search-form input {\\n-  min-width: 0; min-height: 44px; padding: 9px 12px;\\n-  border: 1px solid var(--color-divider); background: var(--color-bg);\\n-  color: var(--color-text); font: inherit;\\n-}\\n-.news-search-form input:focus {\\n-  outline: 2px solid var(--color-accent); outline-offset: -2px;\\n-}\\n-.news-search-form .btn { min-height: 44px; white-space: nowrap; }\\n .search-status { margin: 14px 0 0; min-height: 24px; font-size: 13px; }\\n .search-results { margin-top: 8px; border-top: 2px solid var(--color-divider); }\\n .search-result { padding: 18px 0; border-bottom: 1px solid var(--color-divider); }\\n@@ -358,9 +392,14 @@ a.flash-row:hover { color: inherit; }\\n }\\n \\n @media (max-width: 640px) {\\n+  .header-search-row { padding: 9px 12px; }\\n+  .header-search-form {\\n+    min-height: 44px; grid-template-columns: 20px minmax(0, 1fr) auto 36px;\\n+    gap: 7px; padding-left: 13px;\\n+  }\\n+  .header-search-input { font-size: 14px; padding: 7px 0; }\\n+  .header-search-submit { width: 36px; height: 36px; }\\n   .search-page { padding: 24px 14px 40px; }\\n-  .news-search-form { grid-template-columns: minmax(0, 1fr) auto; }\\n-  .news-search-form #news-search-clear { grid-column: 1 / -1; }\\n   .search-result h2 { font-size: 18px; }\\n   .search-result-footer { align-items: flex-start; flex-direction: column; gap: 6px; }\\n   .radar-page { padding: 18px 12px 24px; }\\n@@ -17,12 +17,32 @@\\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\\n <link href=\"https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Noto+Sans+TC:wght@400;500;700;900&display=swap\" rel=\"stylesheet\">\\n <link rel=\"stylesheet\" href=\"{{ base }}/assets/modernist.css\">\\n-<link rel=\"stylesheet\" href=\"{{ base }}/assets/site.css\">\\n+<link rel=\"stylesheet\" href=\"{{ base }}/assets/site.css?v={{ asset_version }}\">\\n {% block head_extra %}{% endblock %}\\n-<script src=\"{{ base }}/assets/app.js\" defer></script>\\n+<script src=\"{{ base }}/assets/app.js?v={{ asset_version }}\" defer></script>\\n </head>\\n <body>\\n <div class=\"page\">\\n+  <div class=\"header-search-row\">\\n+    <form id=\"news-search-form\" class=\"header-search-form\"\\n+          action=\"{{ search_url }}\" method=\"get\" role=\"search\">\\n+      <svg class=\"header-search-icon\" aria-hidden=\"true\" viewBox=\"0 0 24 24\">\\n+        <path d=\"m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z\"></path>\\n+      </svg>\\n+      <label class=\"sr-only\" for=\"news-search-input\">{{ t.searchTitle }}</label>\\n+      <input id=\"news-search-input\" class=\"header-search-input\" name=\"q\"\\n+             type=\"search\" autocomplete=\"off\" enterkeyhint=\"search\"\\n+             placeholder=\"{{ t.headerSearchPlaceholder }}\">\\n+      <button id=\"news-search-clear\" class=\"header-search-clear\" type=\"button\"\\n+              aria-label=\"{{ t.searchClear }}\" title=\"{{ t.searchClear }}\">×</button>\\n+      <button class=\"header-search-submit\" type=\"submit\"\\n+              aria-label=\"{{ t.searchButton }}\" title=\"{{ t.searchButton }}\">\\n+        <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\">\\n+          <path d=\"m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z\"></path>\\n+        </svg>\\n+      </button>\\n+    </form>\\n+  </div>\\n   <div style=\"border-bottom:1px solid var(--color-divider);\">\\n     <div style=\"padding:6px 20px;display:flex;flex-wrap:wrap;align-items:center;gap:16px;font-size:12px;\">\\n       <span class=\"text-muted\" style=\"letter-spacing:.06em;\">{{ build.date }}</span>\\n@@ -17,15 +17,6 @@ <h1>{{ t.searchTitle }}</h1>\\n              data-error=\"{{ t.searchError }}\"\\n              data-brief-label=\"{{ t.briefLabel }}\"\\n              data-read-more=\"{{ t.readMore }}\">\\n-      <form id=\"news-search-form\" class=\"news-search-form\" role=\"search\">\\n-        <label class=\"sr-only\" for=\"news-search-input\">{{ t.searchTitle }}</label>\\n-        <input id=\"news-search-input\" name=\"q\" type=\"search\"\\n-               autocomplete=\"off\" enterkeyhint=\"search\"\\n-               placeholder=\"{{ t.searchPlaceholder }}\"\\n-               aria-controls=\"news-search-results\">\\n-        <button class=\"btn btn-primary\" type=\"submit\">{{ t.searchButton }}</button>\\n-        <button id=\"news-search-clear\" class=\"btn btn-secondary\" type=\"button\" hidden>{{ t.searchClear }}</button>\\n-      </form>\\n       <p id=\"news-search-status\" class=\"search-status text-muted\"\\n          role=\"status\" aria-live=\"polite\">{{ t.searchLoading }}</p>\\n       <div id=\"news-search-results\" class=\"search-results\"></div>\\n@@ -34,5 +25,5 @@ <h1>{{ t.searchTitle }}</h1>\\n   </main>\\n {% endblock %}\\n {% block body_scripts %}\\n-<script src=\"{{ base }}/assets/search.js\" defer></script>\\n+<script src=\"{{ base }}/assets/search.js?v={{ asset_version }}\" defer></script>\\n {% endblock %}\\n@@ -2,6 +2,7 @@\\n from __future__ import annotations\\n \\n import json\\n+import re\\n import sys\\n from pathlib import Path\\n \\n@@ -70,20 +71,43 @@ def main() -> None:\\n     en = (ROOT / \"site\" / \"en\" / \"search\" / \"index.html\").read_text(\\n         encoding=\"utf-8\")\\n     home = (ROOT / \"site\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    article = next((ROOT / \"site\" / \"news\").glob(\"*/index.html\")).read_text(\\n+        encoding=\"utf-8\")\\n     script = (ROOT / \"static\" / \"search.js\").read_text(encoding=\"utf-8\")\\n+    app_script = (ROOT / \"static\" / \"app.js\").read_text(encoding=\"utf-8\")\\n+    css = (ROOT / \"static\" / \"site.css\").read_text(encoding=\"utf-8\")\\n \\n     assert 'id=\"news-search-input\"' in zh\\n+    assert zh.count('id=\"news-search-form\"') == 1\\n+    assert 'class=\"header-search-form\"' in home\\n+    assert 'class=\"header-search-form\"' in article\\n+    assert 'action=\"/avwire/search/\"' in home\\n+    assert 'action=\"/avwire/en/search/\"' in en\\n+    assert re.search(r'/avwire/assets/site\\.css\\?v=[0-9a-f]{10}', zh)\\n+    assert re.search(r'/avwire/assets/app\\.js\\?v=[0-9a-f]{10}', zh)\\n+    assert re.search(r'/avwire/assets/search\\.js\\?v=[0-9a-f]{10}', zh)\\n+    assert home.index('class=\"header-search-row\"') < home.index('id=\"clock-utc\"')\\n+    assert home.index('class=\"header-search-row\"') < home.index('class=\"brand-row\"')\\n+    main_markup = re.search(r'<main class=\"search-page\">(.*?)</main>', zh,\\n+                            re.DOTALL).group(1)\\n+    assert \"<form\" not in main_markup\\n     assert 'data-index-url=\"/avwire/search-index.json\"' in zh\\n     assert 'data-index-url=\"/avwire/search-index.json\"' in en\\n-    assert 'href=\"/avwire/search/\"' in home\\n     assert 'href=\"/avwire/en/search/\"' in en\\n+    assert \"border-radius: 999px\" in css\\n+    assert \".header-search-form:focus-within\" in css\\n+    assert \".header-search-input:not(:placeholder-shown) + .header-search-clear\" in css\\n+    assert \".header-search-submit\" in css\\n     assert \"航空公司正式名稱與常用簡稱可互相查找\" in zh\\n     assert \"Official airline names and common short names are interchangeable\" in en\\n     assert \"terms.every\" in script\\n     assert \"textContent\" in script\\n     assert \"replaceChildren\" in script\\n     assert \"innerHTML\" not in script\\n     assert \"URLSearchParams\" not in script  # URL.searchParams is used directly\\n+    assert 'headerSearchClear.addEventListener(\"click\"' in app_script\\n+    assert 'headerSearchInput.value = \"\"' in app_script\\n+    assert 'new Event(\"input\", { bubbles: true })' in app_script\\n \\n     print(\"test_search: OK\")\\n "
}
