{
  "sha": "3b0abbcc37ca94a867b7df08052c9acbc3348c06",
  "message": "修正 iPhone 手機版排版與導覽 (#63)\n\n改善手機頁首導覽、首頁內容順序、新聞列表與文章頁換行，並新增手機版回歸測試。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T21:55:26Z",
  "files": [
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 25,
      "deletions": 0,
      "changes": 25
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 103,
      "deletions": 0,
      "changes": 103
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 5,
      "changes": 10
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 22,
      "deletions": 18,
      "changes": 40
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 6,
      "changes": 12
    },
    {
      "filename": "tests/test_mobile_layout.py",
      "previous_filename": "",
      "status": "added",
      "additions": 45,
      "deletions": 0,
      "changes": 45
    }
  ],
  "diff": "@@ -52,6 +52,31 @@\\n     });\\n   }\\n \\n+  /* — mobile navigation: compact by default, keyboard and screen-reader safe — */\\n+  var navToggle = document.querySelector(\"[data-nav-toggle]\");\\n+  var siteNav = document.getElementById(\"site-nav\");\\n+  function setNavOpen(open) {\\n+    if (!navToggle || !siteNav) return;\\n+    siteNav.classList.toggle(\"is-open\", open);\\n+    navToggle.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\\n+    navToggle.querySelector(\".nav-toggle-icon\").textContent = open ? \"×\" : \"☰\";\\n+  }\\n+  if (navToggle && siteNav) {\\n+    navToggle.addEventListener(\"click\", function () {\\n+      setNavOpen(navToggle.getAttribute(\"aria-expanded\") !== \"true\");\\n+    });\\n+    siteNav.addEventListener(\"click\", function (event) {\\n+      if (event.target.closest(\"a\")) setNavOpen(false);\\n+    });\\n+    document.addEventListener(\"keydown\", function (event) {\\n+      if (event.key === \"Escape\") setNavOpen(false);\\n+    });\\n+    var desktopNav = window.matchMedia(\"(min-width: 721px)\");\\n+    desktopNav.addEventListener(\"change\", function (event) {\\n+      if (event.matches) setNavOpen(false);\\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@@ -106,6 +106,45 @@ a.ticker-item:hover .ticker-time { color: var(--color-accent); }\\n   outline: 2px solid var(--color-accent); outline-offset: 2px;\\n }\\n \\n+.utility-bar { border-bottom: 1px solid var(--color-divider); }\\n+.utility-row {\\n+  padding: 6px 20px; display: flex; flex-wrap: wrap; align-items: center;\\n+  gap: 16px; font-size: 12px;\\n+}\\n+.utility-date, .utility-clock { letter-spacing: .06em; }\\n+.utility-clock { font-weight: 600; }\\n+.utility-update { margin-left: auto; display: flex; align-items: center; gap: 7px; }\\n+.brand-shell { border-bottom: 2px solid var(--color-divider); }\\n+.brand-row { padding: 14px 20px; display: flex; align-items: center; gap: 28px; }\\n+.brand-link {\\n+  display: flex; align-items: center; gap: 12px; min-width: 0;\\n+  color: inherit; text-decoration: none;\\n+}\\n+.brand-link:hover { color: inherit; }\\n+.brand-mark {\\n+  width: 34px; height: 34px; flex: 0 0 34px; display: flex;\\n+  align-items: center; justify-content: center; background: var(--color-accent);\\n+  color: var(--color-bg); font-size: 15px; font-weight: 800;\\n+}\\n+.brand-copy { min-width: 0; }\\n+.brand-name { font-size: 20px; font-weight: 800; line-height: 1.05; letter-spacing: -.015em; }\\n+.brand-name span { color: var(--color-accent); }\\n+.brand-tagline { margin-top: 2px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; }\\n+.nav-toggle {\\n+  display: none; min-height: 44px; padding: 8px 11px; align-items: center; gap: 7px;\\n+  border: 1px solid var(--color-divider); background: var(--color-bg); color: var(--color-text);\\n+  font: inherit; font-size: 12px; font-weight: 800; cursor: pointer;\\n+}\\n+.nav-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }\\n+.nav-toggle-icon { font-size: 18px; line-height: 1; }\\n+.ticker-shell { border-bottom: 2px solid var(--color-divider); display: flex; align-items: stretch; min-width: 0; }\\n+.ticker-label {\\n+  padding: 7px 16px; display: flex; align-items: center; flex-shrink: 0;\\n+  background: var(--color-accent); color: var(--color-bg); font-size: 12px;\\n+  font-weight: 800; letter-spacing: .14em; text-transform: uppercase;\\n+}\\n+.ticker-window { min-width: 0; overflow: hidden; flex: 1; display: flex; align-items: center; }\\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@@ -178,6 +217,27 @@ a.flash-row:hover { color: inherit; }\\n   color: inherit; text-decoration: none; cursor: pointer;\\n }\\n .feed-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); color: inherit; }\\n+.feed-time { font-size: 13px; font-weight: 800; }\\n+.feed-category { justify-content: flex-start; }\\n+.feed-copy { min-width: 0; }\\n+.feed-source {\\n+  padding: 3px 9px; border: 1px solid var(--color-divider);\\n+  font-size: 11px; white-space: nowrap;\\n+}\\n+.hero-title { margin: 8px 0 10px; font-size: 34px; line-height: 1.15; }\\n+.hero-meta { display: flex; gap: 16px; align-items: baseline; margin-top: 10px; font-size: 12px; }\\n+\\n+/* — article — */\\n+.article-page { max-width: 860px; margin: 0 auto; padding: 28px 20px 64px; }\\n+.article-title { margin: 14px 0 12px; font-size: 38px; line-height: 1.18; }\\n+.article-meta {\\n+  display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline;\\n+  padding-bottom: 16px; margin-bottom: 24px; border-bottom: 2px solid var(--color-divider);\\n+  font-size: 12.5px;\\n+}\\n+.article-sources { margin-top: 28px; padding: 16px 20px 12px; border: 2px solid var(--color-divider); }\\n+.article-source-row { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; font-size: 13.5px; }\\n+.article-source-row a { min-width: 0; overflow-wrap: anywhere; }\\n \\n .is-hidden { display: none !important; }\\n .sr-only {\\n@@ -391,6 +451,30 @@ a.flash-row:hover { color: inherit; }\\n   .changelog-group { grid-template-columns: 96px minmax(0, 1fr); }\\n }\\n \\n+@media (max-width: 720px) {\\n+  .utility-row { padding: 8px 12px; gap: 8px 12px; }\\n+  .utility-date { flex-basis: 100%; }\\n+  .utility-update { margin-left: 0; }\\n+  .brand-row { padding: 11px 12px; gap: 10px; }\\n+  .brand-link { flex: 1; }\\n+  .brand-name { font-size: 18px; }\\n+  .brand-tagline { font-size: 9px; letter-spacing: .11em; white-space: nowrap; }\\n+  .nav-toggle { display: inline-flex; flex-shrink: 0; }\\n+  .site-nav {\\n+    order: 3; width: 100%; margin: 0; padding: 4px 0 0; overflow: visible;\\n+    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px;\\n+    white-space: normal;\\n+  }\\n+  .js .site-nav { display: none; }\\n+  .js .site-nav.is-open { display: grid; }\\n+  .nav-link {\\n+    min-height: 44px; padding: 10px 12px; display: flex; align-items: center;\\n+    border: 1px solid var(--color-divider); border-bottom-width: 1px;\\n+  }\\n+  .nav-link.active { border-color: var(--color-accent); }\\n+  .ticker-label { padding: 7px 10px; font-size: 11px; letter-spacing: .08em; }\\n+}\\n+\\n @media (max-width: 640px) {\\n   .header-search-row { padding: 9px 12px; }\\n   .header-search-form {\\n@@ -399,6 +483,25 @@ a.flash-row:hover { color: inherit; }\\n   }\\n   .header-search-input { font-size: 14px; padding: 7px 0; }\\n   .header-search-submit { width: 36px; height: 36px; }\\n+  .page { width: 100%; overflow-x: clip; }\\n+  .center-col { order: 1; padding: 16px 12px 20px; }\\n+  .rail-left { order: 2; padding: 18px 12px; border-top: 2px solid var(--color-divider); border-bottom: 0; }\\n+  .rail-right { order: 3; padding: 18px 12px; }\\n+  .hero-title { font-size: 27px; line-height: 1.2; }\\n+  .hero-meta { flex-wrap: wrap; gap: 7px 12px; }\\n+  .hero-meta > :last-child { margin-left: 0 !important; }\\n+  .feed-row { grid-template-columns: auto minmax(0, 1fr); gap: 7px 10px; padding: 15px 0; }\\n+  .feed-time { grid-column: 1; }\\n+  .feed-category { grid-column: 2; justify-self: start; }\\n+  .feed-copy { grid-column: 1 / -1; }\\n+  .feed-source { grid-column: 1 / -1; justify-self: start; white-space: normal; }\\n+  .article-page { padding: 20px 12px 44px; }\\n+  .article-title { font-size: 29px; line-height: 1.22; }\\n+  .article-meta { gap: 7px 12px; }\\n+  .article-meta > :last-child { margin-left: 0 !important; flex-basis: 100%; }\\n+  .article-sources { padding: 14px 12px 10px; }\\n+  .article-source-row { flex-wrap: wrap; gap: 4px 8px; }\\n+  .footer-grid { padding: 20px 12px 28px; gap: 24px; }\\n   .search-page { padding: 24px 14px 40px; }\\n   .search-result h2 { font-size: 18px; }\\n   .search-result-footer { align-items: flex-start; flex-direction: column; gap: 6px; }\\n@@ -1,14 +1,14 @@\\n {% extends \"base.html\" %}\\n {% block content %}\\n-  <div style=\"max-width:860px;margin:0 auto;padding:28px 20px 64px;\">\\n+  <div class=\"article-page\">\\n     <a href=\"{{ home_url }}\" class=\"btn btn-ghost\" style=\"margin-bottom:18px;\">{{ t.back }}</a>\\n     <div>\\n       <span class=\"tag {{ a.tag_class }}\">{{ a.cat_label }}</span>\\n       {% if a.article_format == \"brief\" %}\\n       <span class=\"tag tag-neutral\">{{ t.briefLabel }}</span>\\n       {% endif %}\\n-      <h1 style=\"font-size:38px;line-height:1.18;margin:14px 0 12px;\">{{ a.title }}</h1>\\n-      <div class=\"text-muted\" style=\"display:flex;gap:16px;align-items:baseline;font-size:12.5px;padding-bottom:16px;border-bottom:2px solid var(--color-divider);margin-bottom:24px;flex-wrap:wrap;\">\\n+      <h1 class=\"article-title\">{{ a.title }}</h1>\\n+      <div class=\"article-meta text-muted\">\\n         <span style=\"font-weight:800;color:var(--color-accent);\">{{ a.meta_ts }}</span>\\n         <span>{{ t.autoCompiled }} · {{ t.mainSource }}: {{ a.source }}</span>\\n         <span style=\"margin-left:auto;\">{{ t.refreshNote }}</span>\\n@@ -31,10 +31,10 @@ <h1 style=\"font-size:38px;line-height:1.18;margin:14px 0 12px;\">{{ a.title }}</h\\n     {% for p in a.body %}\\n     <p style=\"font-size:16px;line-height:1.95;margin:0 0 18px;\">{{ p }}</p>\\n     {% endfor %}\\n-    <div style=\"border:2px solid var(--color-divider);padding:16px 20px 12px;margin-top:28px;\">\\n+    <div class=\"article-sources\">\\n       <h6 style=\"margin:0 0 10px;\">{{ t.sourcesBox }}</h6>\\n       {% for s in a.sources %}\\n-      <div style=\"display:flex;gap:10px;align-items:baseline;font-size:13.5px;padding:4px 0;\">\\n+      <div class=\"article-source-row\">\\n         <span class=\"text-muted\" style=\"font-weight:800;font-size:12px;\">{{ s.n }}</span>\\n         <a href=\"{{ s.url }}\" target=\"_blank\" rel=\"noopener\">{{ s.name }}</a>\\n         <span class=\"text-muted\" style=\"font-size:11.5px;\">{{ s.host }}</span>\\n@@ -12,7 +12,7 @@\\n <link rel=\"alternate\" hreflang=\"x-default\" href=\"{{ zh_abs_url }}\">\\n {% endif %}\\n <link rel=\"icon\" type=\"image/svg+xml\" href=\"{{ favicon }}\">\\n-<script>try{var t=localStorage.getItem(\"avwire-theme\");if(t){document.documentElement.dataset.theme=t;}}catch(e){}</script>\\n+<script>try{var t=localStorage.getItem(\"avwire-theme\");if(t){document.documentElement.dataset.theme=t;}}catch(e){}document.documentElement.classList.add(\"js\");</script>\\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\\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@@ -43,12 +43,12 @@\\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-      <span id=\"clock-utc\" style=\"font-weight:600;letter-spacing:.06em;\">UTC {{ build.utc_hm }}</span>\\n-      <span id=\"clock-tpe\" class=\"text-muted\" style=\"letter-spacing:.06em;\">TPE {{ build.tpe_hm }}</span>\\n-      <span style=\"margin-left:auto;display:flex;align-items:center;gap:7px;\">\\n+  <div class=\"utility-bar\">\\n+    <div class=\"utility-row\">\\n+      <span class=\"utility-date text-muted\">{{ build.date }}</span>\\n+      <span id=\"clock-utc\" class=\"utility-clock\">UTC {{ build.utc_hm }}</span>\\n+      <span id=\"clock-tpe\" class=\"utility-clock text-muted\">TPE {{ build.tpe_hm }}</span>\\n+      <span class=\"utility-update\">\\n         <span class=\"pulse-dot\"></span>\\n         <span style=\"letter-spacing:.04em;\">{{ t.updateBadge }}</span>\\n       </span>\\n@@ -62,26 +62,30 @@\\n       </span>\\n     </div>\\n   </div>\\n-  <div style=\"border-bottom:2px solid var(--color-divider);\">\\n-    <div class=\"brand-row\" style=\"padding:14px 20px;display:flex;align-items:center;gap:28px;\">\\n-      <a href=\"{{ home_url }}\" style=\"display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;\">\\n-        <div style=\"width:34px;height:34px;background:var(--color-accent);color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;\">AW</div>\\n-        <div>\\n-          <div style=\"font-weight:800;font-size:20px;line-height:1.05;letter-spacing:-.015em;\">AVWIRE <span style=\"color:var(--color-accent);\">{{ t.brandZh }}</span></div>\\n-          <div class=\"text-muted\" style=\"font-size:10px;letter-spacing:.18em;text-transform:uppercase;\">Aviation News · Automated Hourly</div>\\n+  <div class=\"brand-shell\">\\n+    <div class=\"brand-row\">\\n+      <a class=\"brand-link\" href=\"{{ home_url }}\">\\n+        <div class=\"brand-mark\">AW</div>\\n+        <div class=\"brand-copy\">\\n+          <div class=\"brand-name\">AVWIRE <span>{{ t.brandZh }}</span></div>\\n+          <div class=\"brand-tagline text-muted\">Aviation News · Automated Hourly</div>\\n         </div>\\n       </a>\\n-      <nav class=\"site-nav\">\\n+      <button class=\"nav-toggle\" type=\"button\" data-nav-toggle aria-expanded=\"false\" aria-controls=\"site-nav\">\\n+        <span class=\"nav-toggle-icon\" aria-hidden=\"true\">☰</span>\\n+        <span>{% if lang == 'zh' %}選單{% else %}Menu{% endif %}</span>\\n+      </button>\\n+      <nav class=\"site-nav\" id=\"site-nav\">\\n         {% for n in nav %}\\n         <a class=\"nav-link{% if n.active %} active{% endif %}\" href=\"{{ n.url }}\">{{ n.label }}</a>\\n         {% endfor %}\\n       </nav>\\n     </div>\\n   </div>\\n   {% if ticker_items %}\\n-  <div style=\"border-bottom:2px solid var(--color-divider);display:flex;align-items:stretch;\">\\n-    <div style=\"background:var(--color-accent);color:var(--color-bg);font-weight:800;font-size:12px;letter-spacing:.14em;display:flex;align-items:center;padding:7px 16px;flex-shrink:0;text-transform:uppercase;\">{{ t.live }}</div>\\n-    <div style=\"overflow:hidden;flex:1;display:flex;align-items:center;\">\\n+  <div class=\"ticker-shell\">\\n+    <div class=\"ticker-label\">{{ t.live }}</div>\\n+    <div class=\"ticker-window\">\\n       <div class=\"marquee-track\">\\n         {% for pass in range(2) %}\\n         <span style=\"padding-right:70px;font-size:13px;white-space:nowrap;\">\\n@@ -47,9 +47,9 @@ <h6 style=\"margin:28px 0 12px;\">{{ t.sourceStatus }}</h6>\\n         {% endif %}\\n         <div style=\"margin-top:16px;\">\\n           <span class=\"card-kicker\" style=\"font-size:11px;\">{{ hero.cat_label }}{% if hero.article_format == \"brief\" %} · {{ t.briefLabel }}{% endif %} — {{ t.topStory }}</span>\\n-          <h2 style=\"font-size:34px;line-height:1.15;margin:8px 0 10px;\">{{ hero.title }}</h2>\\n+          <h2 class=\"hero-title\">{{ hero.title }}</h2>\\n           <p class=\"text-muted\" style=\"font-size:14.5px;line-height:1.75;margin:0;\">{{ hero.summary }}</p>\\n-          <div style=\"display:flex;gap:16px;align-items:baseline;margin-top:10px;font-size:12px;\">\\n+          <div class=\"hero-meta\">\\n             <span style=\"font-weight:800;color:var(--color-accent);\">{{ hero.time }}</span>\\n             <span class=\"text-muted\">{{ t.mainSource }}: {{ hero.source }}</span>\\n             <span style=\"margin-left:auto;font-weight:600;\">{{ t.readMore }}</span>\\n@@ -76,13 +76,13 @@ <h6 style=\"margin:0;\">{{ t.latest }}</h6>\\n       </div>\\n       {% for item in feed %}\\n       <a class=\"feed-row\" data-cat=\"{{ item.cat }}\" href=\"{{ item.url }}\"{% if item.external %} target=\"_blank\" rel=\"noopener\"{% endif %}>\\n-        <span style=\"font-size:13px;font-weight:800;\">{{ item.time }}</span>\\n-        <span class=\"tag {{ item.tag_class }}\" style=\"justify-content:flex-start;\">{{ item.cat_label }}{% if item.article_format == \"brief\" %} · {{ t.briefLabel }}{% endif %}</span>\\n-        <span style=\"min-width:0;\">\\n+        <span class=\"feed-time\">{{ item.time }}</span>\\n+        <span class=\"feed-category tag {{ item.tag_class }}\">{{ item.cat_label }}{% if item.article_format == \"brief\" %} · {{ t.briefLabel }}{% endif %}</span>\\n+        <span class=\"feed-copy\">\\n           <span style=\"display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;\">{{ item.title }}</span>\\n           <span class=\"text-muted\" style=\"display:block;font-size:12.5px;line-height:1.65;margin-top:3px;\">{{ item.summary }}</span>\\n         </span>\\n-        <span class=\"text-muted\" style=\"font-size:11px;border:1px solid var(--color-divider);padding:3px 9px;white-space:nowrap;\">{{ item.source }}</span>\\n+        <span class=\"feed-source text-muted\">{{ item.source }}</span>\\n       </a>\\n       {% else %}\\n       <div class=\"text-muted\" style=\"padding:13px 0;border-top:1px solid var(--color-divider);font-size:12.5px;\">{{ t.awaiting }}</div>\\n@@ -0,0 +1,45 @@\\n+\"\"\"Offline regression checks for AVWIRE's shared mobile layout.\"\"\"\\n+from __future__ import annotations\\n+\\n+import sys\\n+from pathlib import Path\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import build  # noqa: E402\\n+\\n+\\n+def main() -> None:\\n+    assert build.main() == 0\\n+\\n+    home = (ROOT / \"site\" / \"index.html\").read_text(encoding=\"utf-8\")\\n+    css = (ROOT / \"static\" / \"site.css\").read_text(encoding=\"utf-8\")\\n+    script = (ROOT / \"static\" / \"app.js\").read_text(encoding=\"utf-8\")\\n+    articles = list((ROOT / \"site\" / \"news\").glob(\"*/index.html\"))\\n+    assert articles\\n+    article = articles[0].read_text(encoding=\"utf-8\")\\n+\\n+    assert 'class=\"nav-toggle\"' in home\\n+    assert 'aria-controls=\"site-nav\"' in home\\n+    assert 'class=\"site-nav\" id=\"site-nav\"' in home\\n+    assert 'class=\"utility-row\"' in home\\n+    assert 'class=\"ticker-window\"' in home\\n+    assert 'class=\"feed-copy\"' in home\\n+    assert 'class=\"article-page\"' in article\\n+    assert 'class=\"article-source-row\"' in article\\n+\\n+    assert \"@media (max-width: 720px)\" in css\\n+    assert \".js .site-nav.is-open { display: grid; }\" in css\\n+    assert \"grid-template-columns: repeat(2, minmax(0, 1fr))\" in css\\n+    assert \".center-col { order: 1;\" in css\\n+    assert \".feed-copy { grid-column: 1 / -1; }\" in css\\n+    assert 'setAttribute(\"aria-expanded\"' in script\\n+    assert 'event.key === \"Escape\"' in script\\n+    assert 'matchMedia(\"(min-width: 721px)\")' in script\\n+\\n+    print(\"test_mobile_layout: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()"
}
