2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

修正 iPhone 手機版排版與導覽 (#63) 改善手機頁首導覽、首頁內容順序、新聞列表與文章頁換行,並新增手機版回歸測試。

SHA
3b0abbcc37ca94a867b7df08052c9acbc3348c06
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • static/app.js modified · +25 / −0
  • static/site.css modified · +103 / −0
  • templates/article.html modified · +5 / −5
  • templates/base.html modified · +22 / −18
  • templates/home.html modified · +6 / −6
  • tests/test_mobile_layout.py added · +45 / −0

Full 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()

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.