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

首頁加入臺灣焦點頭條輪播 (#190) 優先顯示臺灣相關航空新聞,加入 8 秒自動輪播、左右切換、暫停及無障礙控制。

SHA
7f51371d84dfb0cd5e78a86f61a06902426a9c33
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • pipeline/build.py modified · +130 / −11
  • static/app.js modified · +102 / −6
  • static/site.css modified · +34 / −0
  • templates/home.html modified · +21 / −2
  • tests/test_headline_rotation.py modified · +46 / −2
  • tests/test_mobile_layout.py modified · +11 / −0

Full diff

@@ -101,6 +101,7 @@ def static_asset_version() -> str:\n         "homeLabel": "返回 SKYTICAL 首頁", "live": "LIVE 快訊", "updateBadge": "每小時自動更新",\n         "flash": "即時快訊", "sourceStatus": "來源狀態",\n         "latest": "最新新聞", "topStory": "頭條", "priorityLabel": "頭條",\n+        "focusLabel": "臺灣焦點",\n         "mainSource": "主要來源",\n         "briefLabel": "短訊", "roundupLabel": "事件彙整",\n         "summaryLabel": "重點摘要",\n@@ -230,6 +231,7 @@ def static_asset_version() -> str:\n         "homeLabel": "Back to the SKYTICAL home page", "live": "LIVE WIRE", "updateBadge": "Auto-updates hourly",\n         "flash": "Live flash", "sourceStatus": "Source status",\n         "latest": "Latest news", "topStory": "Top story", "priorityLabel": "TOP",\n+        "focusLabel": "Taiwan focus",\n         "mainSource": "Primary source",\n         "briefLabel": "Brief", "roundupLabel": "Event roundup",\n         "summaryLabel": "Quick Summary",\n@@ -1358,8 +1360,11 @@ def art_view(a, lang: str):\n \n \n \n-HERO_ROTATION_SECONDS = 5 * 60\n+HERO_ROTATION_SECONDS = 8\n _HERO_PIN_MAX_AGE = timedelta(hours=16)\n+_HERO_FOCUS_FRESH_AGE = timedelta(hours=24)\n+_HERO_FOCUS_FALLBACK_AGE = timedelta(days=7)\n+_HERO_FOCUS_LIMIT = 5\n _HERO_WEATHER_RE = re.compile(\n     r"(?:颱風|台風|熱帶(?:性)?(?:低氣壓|風暴)|豪大雨|豪雨|暴雨|大雨|"\n     r"強降雨|雷雨|強風|暴風|寒流|寒害|大雪|降雪|冰雹|濃霧|大霧|"\n@@ -1382,6 +1387,41 @@ def art_view(a, lang: str):\n     r"cancel|suspend|delay|divert|rerout|extra flight|relief flight)",\n     re.IGNORECASE,\n )\n+_HERO_TAIWAN_CARRIER_RE = re.compile(\n+    r"(?:國籍航空|臺灣航空|台灣航空|華航|中華航空|長榮航空|星宇航空|"\n+    r"台灣虎航|臺灣虎航|立榮航空|華信航空|China Airlines|EVA Air|"\n+    r"STARLUX|Tigerair Taiwan|UNI Air|Mandarin Airlines)",\n+    re.IGNORECASE,\n+)\n+_HERO_TAIWAN_PLACE_RE = re.compile(\n+    r"(?:臺灣|台灣|臺北|台北|桃園|松山|高雄|臺中|台中|花蓮|澎湖|金門|"\n+    r"馬祖|交通部民用航空局|國家運輸安全調查委員會|運安會|"\n+    r"Taiwan CAA|TTSB|Taiwan|Taipei|Taoyuan|Songshan|"\n+    # TSA is deliberately omitted: it commonly means the US Transportation\n+    # Security Administration, not Taipei Songshan Airport.\n+    r"Kaohsiung|Taichung|\b(?:TPE|KHH|RMQ)\b)",\n+    re.IGNORECASE,\n+)\n+_HERO_CATHAY_RE = re.compile(\n+    r"(?:國泰航空|香港快運|Cathay Pacific|HK Express)", re.IGNORECASE)\n+_HERO_MATERIAL_RE = re.compile(\n+    r"(?:航班|班機|航線|機場|空域|旅客|營運|機隊|飛安|事故|事件|"\n+    r"取消|停飛|延誤|轉降|新航點|訂單|交付|flight|route|airport|"\n+    r"airspace|passenger|operation|fleet|safety|incident|cancel|delay|"\n+    r"divert|destination|order|delivery)",\n+    re.IGNORECASE,\n+)\n+_HERO_LOW_VALUE_RE = re.compile(\n+    r"(?:餐點|菜單|貴賓室|彩繪|塗裝|代言|名人|公益|捐贈|里程活動|哩程活動|"\n+    r"meal|menu|lounge|livery|celebrity|charity|donat|mileage promotion)",\n+    re.IGNORECASE,\n+)\n+_HERO_TIME_SENSITIVE_RE = re.compile(\n+    r"(?:颱風|台風|豪雨|暴雨|強風|濃霧|地震|海嘯|航班異動|取消|停飛|"\n+    r"延誤|機場關閉|typhoon|storm|heavy rain|strong wind|fog|earthquake|"\n+    r"tsunami|cancel|suspend|delay|airport closure)",\n+    re.IGNORECASE,\n+)\n \n \n def is_weather_airline_flight_story(article, now=None) -> bool:\n@@ -1416,6 +1456,82 @@ def is_weather_airline_flight_story(article, now=None) -> bool:\n     )\n \n \n+def _hero_focus_text(article) -> str:\n+    values = [article.get("source"), article.get("primarySource")]\n+    for lang in ("zh", "en"):\n+        side = article.get(lang)\n+        if isinstance(side, dict):\n+            values.extend(side.get(key) for key in ("title", "summary"))\n+    return " ".join(str(value or "") for value in values)\n+\n+\n+def taiwan_focus_score(article, now=None) -> int:\n+    """Score a recent story for the Taiwan-reader homepage carousel.\n+\n+    Only headline, summary and source fields participate.  This keeps a\n+    passing body mention from promoting an otherwise unrelated story.\n+    """\n+    if not isinstance(article, dict) or article.get("published_dt") is None:\n+        return 0\n+    now = now or now_utc()\n+    try:\n+        age = now - article["published_dt"]\n+    except TypeError:\n+        return 0\n+    if age < timedelta(0) or age > _HERO_FOCUS_FALLBACK_AGE:\n+        return 0\n+\n+    text = _hero_focus_text(article)\n+    score = 0\n+    if _HERO_TAIWAN_CARRIER_RE.search(text):\n+        score = 100\n+    elif _HERO_TAIWAN_PLACE_RE.search(text):\n+        score = 95\n+    elif _HERO_CATHAY_RE.search(text) and _HERO_MATERIAL_RE.search(text):\n+        score = 70\n+    if not score:\n+        return 0\n+    if _HERO_MATERIAL_RE.search(text):\n+        score += 10\n+    if is_weather_airline_flight_story(article, now):\n+        score += 20\n+    if _HERO_LOW_VALUE_RE.search(text):\n+        score -= 50\n+    return score if score >= 60 else 0\n+\n+\n+def taiwan_focus_articles(articles, now=None, limit=_HERO_FOCUS_LIMIT):\n+    """Return fresh Taiwan-focus stories, or one safe recent fallback."""\n+    now = now or now_utc()\n+    scored = []\n+    for article in articles:\n+        score = taiwan_focus_score(article, now)\n+        if not score:\n+            continue\n+        age = now - article["published_dt"]\n+        scored.append((score, article["published_dt"], age, article))\n+    scored.sort(key=lambda row: (row[0], row[1]), reverse=True)\n+    fresh = [row[3] for row in scored if row[2] <= _HERO_FOCUS_FRESH_AGE]\n+    if fresh:\n+        selected = fresh[:limit]\n+        # With only one fresh match, add one still-current background story so\n+        # the carousel remains useful without filling it with stale alerts.\n+        if len(selected) == 1 and limit > 1:\n+            older = [\n+                row[3] for row in scored\n+                if row[2] > _HERO_FOCUS_FRESH_AGE\n+                and not _HERO_TIME_SENSITIVE_RE.search(\n+                    _hero_focus_text(row[3]))\n+            ]\n+            selected.extend(older[:1])\n+        return selected\n+    fallback = [\n+        row[3] for row in scored\n+        if not _HERO_TIME_SENSITIVE_RE.search(_hero_focus_text(row[3]))\n+    ]\n+    return fallback[:1]\n+\n+\n def _hero_image_caption(image, lang: str) -> str:\n     if not isinstance(image, dict):\n         return (\n@@ -1437,7 +1553,7 @@ def _hero_image_caption(image, lang: str) -> str:\n \n \n def hero_rotation_view(view, lang: str) -> dict:\n-    """Return the small JSON-safe payload used by the five-minute hero swap."""\n+    """Return the small JSON-safe payload used by the focus carousel."""\n     image = view.get("image") if isinstance(view.get("image"), dict) else None\n     labels = [str(view.get("cat_label") or "")]\n     if view.get("article_format") == "brief":\n@@ -1450,7 +1566,8 @@ def hero_rotation_view(view, lang: str) -> dict:\n         "external": bool(view.get("external")),\n         "title": view.get("title") or "",\n         "summary": view.get("display_summary") or "",\n-        "kicker": f"{' · '.join(label for label in labels if label)}"\n+        "kicker": f"{L[lang]['focusLabel']} · "\n+                  f"{' · '.join(label for label in labels if label)}"\n                   f" — {L[lang]['topStory']}",\n         "time": view.get("time") or "",\n         "source_meta": f"{L[lang]['mainSource']}: "\n@@ -3340,20 +3457,21 @@ def main() -> int:\n         lang_articles = [\n             a for a in articles if lang in a["available_languages"]]\n         views = [art_view(a, lang) for a in lang_articles]\n-        pinned_articles = [\n-            article for article in lang_articles\n-            if is_weather_airline_flight_story(article, now)\n-        ]\n-        pinned_views = [art_view(article, lang)\n-                        for article in pinned_articles]\n+        pinned_articles = taiwan_focus_articles(lang_articles, now)\n+        pinned_views = []\n+        for article in pinned_articles:\n+            view = art_view(article, lang)\n+            view["focus"] = True\n+            pinned_views.append(view)\n         priority_ids = {article["id"] for article in pinned_articles}\n         hero_candidates = [hero_rotation_view(view, lang)\n                            for view in pinned_views]\n         if views:\n             fl = flash_view(flashes, lang, priority_ids)\n             hero = pinned_views[0] if pinned_views else views[0]\n-            pinned_ids = ({view["id"] for view in pinned_views}\n-                          if pinned_views else {hero["id"]})\n+            # Keep the other focus candidates discoverable in Latest news;\n+            # only avoid repeating the initially visible hero immediately.\n+            pinned_ids = {hero["id"]}\n             feed = [view for view in views if view["id"] not in pinned_ids]\n             feed = feed[:HOME_FEED_LIMIT]\n         elif agg_items:\n@@ -3391,6 +3509,7 @@ def main() -> int:\n                        description=t["siteDesc"], ticker=ticker, build=build)\n         ctx["structured_data"] = website_structured_data(lang)\n         ctx.update(hero=hero, hero_candidates=hero_candidates,\n+                   hero_rotation_seconds=HERO_ROTATION_SECONDS,\n                    feed=feed, flashes=fl, agg=agg,\n                    source_status=source_status_view(sources, now),\n                    stats=sv["tiles"], otp=sv["otp"], fleet=sv["fleet"],\n@@ -179,7 +179,7 @@\n     }) || sevBtns.find(function (b) { return b.dataset.filterSev === "all"; });\n     applyIncidentFilter(initialButton.dataset.filterSev, initialButton, false);\n   }\n-  /* — Priority weather/airline hero: rotate every five minutes — */\n+  /* — Taiwan-focus hero carousel: timed, controllable and pauseable — */\n   var heroCandidatesNode = document.getElementById("hero-candidates");\n   var heroLink = document.getElementById("hero-story-link");\n   if (heroCandidatesNode && heroLink) {\n@@ -198,6 +198,20 @@\n     );\n     var heroTime = document.getElementById("hero-story-time");\n     var heroSource = document.getElementById("hero-story-source");\n+    var heroCarousel = document.getElementById("hero-carousel");\n+    var heroPrev = document.getElementById("hero-prev");\n+    var heroNext = document.getElementById("hero-next");\n+    var heroDots = Array.prototype.slice.call(\n+      document.querySelectorAll("[data-hero-index]")\n+    );\n+    var heroIndex = 0;\n+    var heroTimer = null;\n+    var heroPaused = false;\n+    var heroChanging = false;\n+    var reduceHeroMotion = window.matchMedia &&\n+      window.matchMedia("(prefers-reduced-motion: reduce)").matches;\n+    var heroTransitionMs = reduceHeroMotion ? 0 : 220;\n+    var heroRotationMs = Number(heroCarousel && heroCarousel.dataset.rotationMs) || 8000;\n \n     function renderPriorityHero(story) {\n       if (!story || !heroImage) return;\n@@ -230,14 +244,96 @@\n       if (heroSource) heroSource.textContent = story.source_meta || "";\n     }\n \n+    function reflectHeroDots() {\n+      heroDots.forEach(function (dot, index) {\n+        var active = index === heroIndex;\n+        dot.classList.toggle("active", active);\n+        if (active) dot.setAttribute("aria-current", "true");\n+        else dot.removeAttribute("aria-current");\n+      });\n+    }\n+\n+    function stopHeroTimer() {\n+      if (heroTimer !== null) window.clearTimeout(heroTimer);\n+      heroTimer = null;\n+    }\n+\n+    function scheduleHeroRotation() {\n+      stopHeroTimer();\n+      if (heroPaused || document.hidden || heroCandidates.length < 2) return;\n+      heroTimer = window.setTimeout(function () {\n+        showHero((heroIndex + 1) % heroCandidates.length, "next");\n+      }, heroRotationMs);\n+    }\n+\n+    function showHero(nextIndex, direction) {\n+      if (heroChanging || !heroCandidates.length) return;\n+      nextIndex = (nextIndex + heroCandidates.length) % heroCandidates.length;\n+      stopHeroTimer();\n+      if (nextIndex === heroIndex) {\n+        scheduleHeroRotation();\n+        return;\n+      }\n+      heroChanging = true;\n+      heroLink.classList.add(direction === "previous" ?\n+        "hero-exit-right" : "hero-exit-left");\n+      window.setTimeout(function () {\n+        heroIndex = nextIndex;\n+        renderPriorityHero(heroCandidates[heroIndex]);\n+        heroLink.classList.remove("hero-exit-left", "hero-exit-right");\n+        heroLink.classList.add(direction === "previous" ?\n+          "hero-enter-left" : "hero-enter-right");\n+        reflectHeroDots();\n+        window.requestAnimationFrame(function () {\n+          window.requestAnimationFrame(function () {\n+            heroLink.classList.remove("hero-enter-left", "hero-enter-right");\n+            heroChanging = false;\n+            scheduleHeroRotation();\n+          });\n+        });\n+      }, heroTransitionMs);\n+    }\n+\n     if (heroCandidates.length) {\n-      var heroIndex = 0;\n       renderPriorityHero(heroCandidates[heroIndex]);\n       if (heroCandidates.length > 1) {\n-        window.setInterval(function () {\n-          heroIndex = (heroIndex + 1) % heroCandidates.length;\n-          renderPriorityHero(heroCandidates[heroIndex]);\n-        }, 5 * 60 * 1000);\n+        if (heroPrev) heroPrev.addEventListener("click", function () {\n+          showHero(heroIndex - 1, "previous");\n+        });\n+        if (heroNext) heroNext.addEventListener("click", function () {\n+          showHero(heroIndex + 1, "next");\n+        });\n+        heroDots.forEach(function (dot) {\n+          dot.addEventListener("click", function () {\n+            var nextIndex = Number(dot.dataset.heroIndex);\n+            showHero(nextIndex, nextIndex < heroIndex ? "previous" : "next");\n+          });\n+        });\n+        if (heroCarousel) {\n+          heroCarousel.addEventListener("mouseenter", function () {\n+            heroPaused = true;\n+            stopHeroTimer();\n+          });\n+          heroCarousel.addEventListener("mouseleave", function () {\n+            heroPaused = false;\n+            scheduleHeroRotation();\n+          });\n+          heroCarousel.addEventListener("focusin", function () {\n+            heroPaused = true;\n+            stopHeroTimer();\n+          });\n+          heroCarousel.addEventListener("focusout", function () {\n+            window.setTimeout(function () {\n+              if (!heroCarousel.contains(document.activeElement)) {\n+                heroPaused = false;\n+                scheduleHeroRotation();\n+              }\n+            }, 0);\n+          });\n+        }\n+        document.addEventListener("visibilitychange", scheduleHeroRotation);\n+        reflectHeroDots();\n+        scheduleHeroRotation();\n       }\n     }\n   }\n@@ -242,11 +242,44 @@ a.flash-row:hover { color: inherit; }\n   display: flex; align-items: flex-end; padding: 10px;\n }\n /* real news photos: full frame, full color — never crop away the subject */\n+.hero-carousel { position: relative; overflow: hidden; }\n+.hero-story {\n+  display: block; color: inherit; text-decoration: none; cursor: pointer;\n+  transform: translateX(0); opacity: 1;\n+  transition: transform 220ms ease, opacity 220ms ease;\n+}\n+.hero-story:hover { color: inherit; }\n+.hero-story.hero-exit-left { transform: translateX(-6%); opacity: 0; }\n+.hero-story.hero-exit-right { transform: translateX(6%); opacity: 0; }\n+.hero-story.hero-enter-right {\n+  transform: translateX(6%); opacity: 0; transition: none;\n+}\n+.hero-story.hero-enter-left {\n+  transform: translateX(-6%); opacity: 0; transition: none;\n+}\n .hero-img {\n   width: 100%; height: auto; max-height: 420px; object-fit: contain;\n   background: color-mix(in srgb, var(--color-text) 6%, var(--color-surface));\n   border: 1px solid var(--color-divider);\n }\n+.hero-nav {\n+  position: absolute; z-index: 2; top: clamp(92px, 28%, 205px);\n+  width: 38px; height: 48px; border: 1px solid color-mix(in srgb, white 55%, transparent);\n+  background: color-mix(in srgb, black 58%, transparent); color: white;\n+  font: 500 32px/1 sans-serif; cursor: pointer;\n+  display: grid; place-items: center;\n+}\n+.hero-nav:hover, .hero-nav:focus-visible { background: var(--color-accent); }\n+.hero-nav--prev { left: 8px; }\n+.hero-nav--next { right: 8px; }\n+.hero-dots {\n+  display: flex; justify-content: center; gap: 8px; margin-top: 12px;\n+}\n+.hero-dot {\n+  width: 8px; height: 8px; padding: 0; border: 1px solid var(--color-text);\n+  border-radius: 50%; background: transparent; cursor: pointer;\n+}\n+.hero-dot.active { background: var(--color-accent); border-color: var(--color-accent); }\n \n .feed-row {\n   display: grid; grid-template-columns: 48px 84px minmax(0, 1fr) auto;\n@@ -335,6 +368,7 @@ a.flash-row:hover { color: inherit; }\n }\n @media (prefers-reduced-motion: reduce) {\n   .search-hit-pulse { animation: none; }\n+  .hero-story { transition: none; }\n }\n \n .is-hidden { display: none !important; }\n@@ -42,7 +42,11 @@ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n     </div>\n     <div class="center-col">\n       {% if hero %}\n-      <a id="hero-story-link" href="{{ hero.url }}"{% if hero.external %} target="_blank" rel="noopener"{% endif %} style="display:block;color:inherit;text-decoration:none;cursor:pointer;">\n+      <div id="hero-carousel" class="hero-carousel" role="region"\n+           data-rotation-ms="{{ hero_rotation_seconds * 1000 }}"\n+           aria-roledescription="carousel"\n+           aria-label="{% if html_lang.startswith('zh') %}臺灣焦點新聞{% else %}Taiwan focus stories{% endif %}">\n+      <a id="hero-story-link" class="hero-story" href="{{ hero.url }}"{% if hero.external %} target="_blank" rel="noopener"{% endif %}>\n         {% if hero.image %}\n         <img id="hero-story-image" class="hero-img"\n              data-fallback-src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}"\n@@ -55,7 +59,7 @@ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n         <div id="hero-image-caption" class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if html_lang.startswith('zh') %}暫無可驗證的事件或資料照片,先顯示 SKYTICAL 預設圖片。{% else %}No verified event or file photo is available yet; showing the SKYTICAL default image.{% endif %}</div>\n         {% endif %}\n         <div style="margin-top:16px;">\n-          <span id="hero-story-kicker" class="card-kicker" style="font-size:11px;">{{ hero.cat_label }}{% if hero.article_format == "brief" %} · {{ t.briefLabel }}{% elif hero.article_format == "roundup" %} · {{ t.roundupLabel }}{% endif %} — {{ t.topStory }}</span>\n+          <span id="hero-story-kicker" class="card-kicker" style="font-size:11px;">{% if hero.focus|default(false) %}{{ t.focusLabel }} · {% endif %}{{ hero.cat_label }}{% if hero.article_format == "brief" %} · {{ t.briefLabel }}{% elif hero.article_format == "roundup" %} · {{ t.roundupLabel }}{% endif %} — {{ t.topStory }}</span>\n           <h2 id="hero-story-title" class="hero-title" aria-live="polite">{{ hero.title }}</h2>\n           {{ notification_summary(hero.display_summary, t.summaryLabel, 'hero') }}\n           <div class="hero-meta">\n@@ -65,6 +69,21 @@ <h2 id="hero-story-title" class="hero-title" aria-live="polite">{{ hero.title }}\n           </div>\n         </div>\n       </a>\n+      {% if hero_candidates|length > 1 %}\n+      <button id="hero-prev" class="hero-nav hero-nav--prev" type="button"\n+              aria-label="{% if html_lang.startswith('zh') %}上一篇焦點新聞{% else %}Previous focus story{% endif %}">‹</button>\n+      <button id="hero-next" class="hero-nav hero-nav--next" type="button"\n+              aria-label="{% if html_lang.startswith('zh') %}下一篇焦點新聞{% else %}Next focus story{% endif %}">›</button>\n+      <div id="hero-dots" class="hero-dots" aria-label="{% if html_lang.startswith('zh') %}選擇焦點新聞{% else %}Choose a focus story{% endif %}">\n+        {% for candidate in hero_candidates %}\n+        <button type="button" class="hero-dot{% if loop.first %} active{% endif %}"\n+                data-hero-index="{{ loop.index0 }}"\n+                aria-label="{% if html_lang.startswith('zh') %}第 {{ loop.index }} 篇焦點新聞{% else %}Focus story {{ loop.index }}{% endif %}"\n+                {% if loop.first %}aria-current="true"{% endif %}></button>\n+        {% endfor %}\n+      </div>\n+      {% endif %}\n+      </div>\n       {% else %}\n       <div class="grayscale hero-ph">\n         <span class="text-muted" style="font-family:monospace;font-size:11px;">{{ t.awaiting }}</span>\n@@ -95,10 +95,52 @@ def test_rotation_payload_contains_localized_hero_fields():\n         },\n     }\n     payload = build.hero_rotation_view(view, "zh")\n-    assert payload["kicker"] == "營運 · 短訊 — 頭條"\n+    assert payload["kicker"] == "臺灣焦點 · 營運 · 短訊 — 頭條"\n     assert payload["summary"] == "國籍航空調整航班。"\n     assert "China Airlines Boeing 777-300ER" in payload["image_caption"]\n-    assert build.HERO_ROTATION_SECONDS == 300\n+    assert build.HERO_ROTATION_SECONDS == 8\n+\n+\n+def test_taiwan_focus_scores_direct_and_material_cathay_stories():\n+    now = build.now_utc()\n+    taiwan = _article(\n+        now, "星宇航空宣布臺北新航線", "新航線將由桃園機場出發。")\n+    cathay = _article(\n+        now, "國泰航空調整台北香港航班", "航班時刻將於冬季更新。",\n+        source="Cathay Pacific")\n+    promotion = _article(\n+        now, "華航公益捐贈活動", "中華航空捐贈公益團體。")\n+    unrelated = _article(\n+        now, "歐洲地方機場翻修停機坪", "工程預計年底完成。",\n+        source="Regional Airport News")\n+    tsa = _article(\n+        now, "911事件25週年:航空安檢演變歷程回顧",\n+        "美國運輸安全管理局TSA成立並導入全身掃描儀。",\n+        source="Aerospace Global News")\n+    foreign_caa = _article(\n+        now, "英國民航局警告雷射攻擊直升機",\n+        "英國民航局要求機場通報相關事件。",\n+        source="UK Civil Aviation Authority")\n+    assert build.taiwan_focus_score(taiwan, now) > \\n+        build.taiwan_focus_score(cathay, now) >= 60\n+    assert build.taiwan_focus_score(promotion, now) == 0\n+    assert build.taiwan_focus_score(unrelated, now) == 0\n+    assert build.taiwan_focus_score(tsa, now) == 0\n+    assert build.taiwan_focus_score(foreign_caa, now) == 0\n+\n+\n+def test_focus_selection_prefers_fresh_and_uses_safe_recent_fallback():\n+    now = build.now_utc()\n+    fresh = _article(\n+        now, "長榮航空新增桃園航線", "新航線預計下月開航。", age_hours=23)\n+    old_route = _article(\n+        now, "華航公布臺北巴黎新航線", "中華航空公布新航線規畫。",\n+        age_hours=30)\n+    stale_alert = _article(\n+        now, "颱風造成國籍航空航班取消", "航空公司宣布停飛。",\n+        age_hours=30)\n+    assert build.taiwan_focus_articles([old_route, fresh], now) == [fresh, old_route]\n+    assert build.taiwan_focus_articles([stale_alert, old_route], now) == [old_route]\n \n \n def test_flash_priority_is_distinct_from_legacy_pinned_flag():\n@@ -122,6 +164,8 @@ def main() -> int:\n         test_priority_story_at_sixteen_hours_is_still_pinned,\n         test_stale_priority_story_is_not_pinned,\n         test_rotation_payload_contains_localized_hero_fields,\n+        test_taiwan_focus_scores_direct_and_material_cathay_stories,\n+        test_focus_selection_prefers_fresh_and_uses_safe_recent_fallback,\n         test_flash_priority_is_distinct_from_legacy_pinned_flag,\n     )\n     for test in tests:\n@@ -15,6 +15,8 @@ def main() -> None:\n     assert build.main() == 0\n \n     home = (ROOT / "site" / "index.html").read_text(encoding="utf-8")\n+    home_template = (ROOT / "templates" / "home.html").read_text(\n+        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@@ -115,6 +117,15 @@ def main() -> None:\n     assert 'font-family: ui-monospace, "SFMono-Regular", Menlo' in css\n     assert "font-size: 12px; font-weight: 700" in css\n     assert "@media (prefers-reduced-motion: reduce)" in css\n+    assert 'id="hero-prev"' in home_template\n+    assert 'id="hero-next"' in home_template\n+    assert 'id="hero-dots"' in home_template\n+    assert 'aria-roledescription="carousel"' in home_template\n+    assert "hero-enter-right" in css\n+    assert 'data-rotation-ms="8000"' in home\n+    assert "dataset.rotationMs" in script\n+    assert 'addEventListener("mouseenter"' in script\n+    assert 'addEventListener("focusin"' in script\n     assert ".marquee-track .ticker-pass:nth-child(2) { display: none; }" in css\n     assert 'setAttribute("aria-expanded"' in script\n     assert 'event.key === "Escape"' in script

You found a secret theme!

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