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