完整差異
@@ -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