{
  "sha": "7f51371d84dfb0cd5e78a86f61a06902426a9c33",
  "message": "首頁加入臺灣焦點頭條輪播 (#190)\n\n優先顯示臺灣相關航空新聞，加入 8 秒自動輪播、左右切換、暫停及無障礙控制。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-13T10:45:25Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 130,
      "deletions": 11,
      "changes": 141
    },
    {
      "filename": "static/app.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 102,
      "deletions": 6,
      "changes": 108
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 34,
      "deletions": 0,
      "changes": 34
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 21,
      "deletions": 2,
      "changes": 23
    },
    {
      "filename": "tests/test_headline_rotation.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 46,
      "deletions": 2,
      "changes": 48
    },
    {
      "filename": "tests/test_mobile_layout.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 0,
      "changes": 11
    }
  ],
  "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"
}
