diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index b5bd213d8b..a8d5674f40 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -2253,7 +2253,7 @@
"thousandsOfOpenQuestions": "20 000+ otevřených otázek",
"midtermsHubMetaTitle": "Mezičasové volby USA 2026 | Metaculus",
"midtermsHubMetaDescription": "Předpovědi komunity Metaculus v reálném čase k mezičasovým volbám USA v roce 2026.",
- "midtermsHubChamberCurrent": "Aktuálně:",
+ "midtermsHubChamberToday": "Dnes:",
"midtermsHubChamberForecast": "Předpověď:",
"midtermsHubChamberTooltipBody": "Prognostici dávají {party} {pct}% šanci na získání nejvíce křesel v {chamber}.",
"midtermsHubChamberTooltipDisclaimer": "Klikněte pro zobrazení předpovědní otázky",
@@ -2296,6 +2296,8 @@
"midtermsHubScrollRight": "Posunout poznatky doprava",
"midtermsHubDemocrat": "Demokraté",
"midtermsHubRepublican": "Republikáni",
+ "midtermsHubRaceLeanSummary": "V {count} z {total} soubojů vedou {party}",
+ "midtermsHubCloseRacesSummary": "{count, plural, one {# vyrovnaný souboj} few {# vyrovnané souboje} other {# vyrovnaných soubojů}}",
"midtermsHubNotContested": "Bez voleb",
"midtermsHubDemPct": "{pct}% demokraté",
"midtermsHubRepPct": "{pct}% republikáni",
@@ -2317,11 +2319,32 @@
"midtermsHubDemSeatAdvantage": "Demokratická převaha mandátů",
"midtermsHubRepSeatAdvantage": "Republikánská převaha mandátů",
"midtermsHubEven": "REMÍZA",
+ "midtermsHubMedianLabel": "Medián:",
+ "midtermsHubMedianDem": "D +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}",
+ "midtermsHubMedianRep": "R +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}",
+ "midtermsHubMedianEven": "Vyrovnané rozdělení",
"midtermsHubForecastUnavailable": "Předpověď nedostupná",
"midtermsHubSeatAdvantageTooltip": "Náskok {count} mandátů",
"midtermsHubSeatAdvantageOverTooltip": "Náskok více než {count} mandátů",
"midtermsHubProbabilityTooltip": "{value}% pravděpodobnost",
"midtermsHubEvenTooltip": "Remíza",
+ "midtermsHubTimelineUnavailable": "Historie předpovědí není dostupná",
+ "midtermsHubEngagementReachTitle": "Kontaktujte Metaculus",
+ "midtermsHubEngagementReachBody": "Máte postřehy, dotazy nebo jste našli něco nejasného? Vaše zpětná vazba nám pomáhá tento přehled zlepšovat.",
+ "midtermsHubEngagementViewServices": "Zobrazit služby",
+ "midtermsHubEngagementShareTitle": "Sdílejte přehled",
+ "midtermsHubEngagementShareBody": "Myslíte, že to pomůže i ostatním sledovat americké volby v polovině mandátu 2026? Sdílejte to se svou sítí.",
+ "midtermsHubEngagementShareOnX": "Sdílet na X",
+ "midtermsHubEngagementCopySuccess": "Odkaz byl zkopírován do schránky",
+ "midtermsHubEngagementCopyError": "Odkaz se nepodařilo zkopírovat. Zkuste to prosím znovu.",
+ "midtermsHubEngagementTweetText": "Podívejte se na přehled predikcí amerických voleb v polovině mandátu 2026 na Metaculu",
+ "midtermsHubEngagementNewsletterTitle": "Přihlaste se k odběru novinek",
+ "midtermsHubEngagementNewsletterBody": "Zaregistrujte se a dostanete upozornění, když se předpovědi změní a zveřejníme nové volební analýzy.",
+ "midtermsHubEngagementNewsletterSubmit": "Uložit",
+ "midtermsHubEngagementNewsletterSuccess": "Odběr je aktivní!",
+ "midtermsHubEngagementEmailPlaceholder": "E-mailová adresa",
+ "midtermsHubEngagementToastSuccess": "Odběr byl úspěšně zahájen!",
+ "midtermsHubEngagementToastError": "Odběr se nepodařilo nastavit. Zkuste to prosím znovu.",
"nMore": "{count} více",
"noHistogramData": "Žádná data histogramu nejsou k dispozici",
"laborHubJobsBreadcrumb": "Všechna povolání",
diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index 3b4b48b1fd..a742924847 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -2332,7 +2332,7 @@
"midtermsHubChamberGovernor": "Governor",
"midtermsHubChamberControl": "Chamber control",
"midtermsHubCongressForecast": "Congressional Control",
- "midtermsHubChamberCurrent": "Current:",
+ "midtermsHubChamberToday": "Today:",
"midtermsHubChamberForecast": "Forecast:",
"midtermsHubChamberTooltipBody": "Forecasters give {party} a {pct}% chance of holding the most {chamber} seats.",
"midtermsHubChamberTooltipDisclaimer": "Click to view forecasting question",
@@ -2370,6 +2370,8 @@
"midtermsHubScrollRight": "Scroll insights right",
"midtermsHubDemocrat": "Democrat",
"midtermsHubRepublican": "Republican",
+ "midtermsHubRaceLeanSummary": "{count} of {total} races lean {party}",
+ "midtermsHubCloseRacesSummary": "{count} too close to call",
"midtermsHubNotContested": "Not contested",
"midtermsHubDemPct": "{pct}% Democrat",
"midtermsHubRepPct": "{pct}% Republican",
@@ -2391,9 +2393,30 @@
"midtermsHubDemSeatAdvantage": "Democratic Seat Advantage",
"midtermsHubRepSeatAdvantage": "Republican Seat Advantage",
"midtermsHubEven": "EVEN",
+ "midtermsHubMedianLabel": "Median:",
+ "midtermsHubMedianDem": "D +{count, plural, one {# seat} other {# seats}}",
+ "midtermsHubMedianRep": "R +{count, plural, one {# seat} other {# seats}}",
+ "midtermsHubMedianEven": "Even split",
"midtermsHubForecastUnavailable": "Forecast unavailable",
"midtermsHubSeatAdvantageTooltip": "{count} seat advantage",
"midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage",
"midtermsHubProbabilityTooltip": "{value}% probability",
- "midtermsHubEvenTooltip": "Even"
+ "midtermsHubEvenTooltip": "Even",
+ "midtermsHubTimelineUnavailable": "Forecast history unavailable",
+ "midtermsHubEngagementReachTitle": "Reach out to Metaculus",
+ "midtermsHubEngagementReachBody": "Have thoughts, questions, or spotted something unclear? Your input helps us improve this dashboard.",
+ "midtermsHubEngagementViewServices": "View Services",
+ "midtermsHubEngagementShareTitle": "Share the dashboard",
+ "midtermsHubEngagementShareBody": "Think this could help others follow the 2026 midterms? Share it with your network.",
+ "midtermsHubEngagementShareOnX": "Share on X",
+ "midtermsHubEngagementCopySuccess": "Link copied to your clipboard",
+ "midtermsHubEngagementCopyError": "Couldn't copy the link. Please try again.",
+ "midtermsHubEngagementTweetText": "Check out the 2026 US Midterm Elections forecasting dashboard on Metaculus",
+ "midtermsHubEngagementNewsletterTitle": "Subscribe for updates",
+ "midtermsHubEngagementNewsletterBody": "Sign up to get notified as forecasts shift and we publish new midterms insights.",
+ "midtermsHubEngagementNewsletterSubmit": "Save",
+ "midtermsHubEngagementNewsletterSuccess": "You're subscribed!",
+ "midtermsHubEngagementEmailPlaceholder": "Email address",
+ "midtermsHubEngagementToastSuccess": "Subscribed successfully!",
+ "midtermsHubEngagementToastError": "Failed to subscribe. Please try again."
}
diff --git a/front_end/messages/es.json b/front_end/messages/es.json
index a3d1f7276f..9c67698706 100644
--- a/front_end/messages/es.json
+++ b/front_end/messages/es.json
@@ -2253,7 +2253,7 @@
"thousandsOfOpenQuestions": "20,000+ preguntas abiertas",
"midtermsHubMetaTitle": "Elecciones intermedias de EE. UU. de 2026 | Metaculus",
"midtermsHubMetaDescription": "Pronósticos en tiempo real de la comunidad de Metaculus sobre las elecciones intermedias de EE. UU. de 2026.",
- "midtermsHubChamberCurrent": "Actual:",
+ "midtermsHubChamberToday": "Hoy:",
"midtermsHubChamberForecast": "Pronóstico:",
"midtermsHubChamberTooltipBody": "Los pronosticadores dan a {party} un {pct}% de probabilidad de obtener la mayor cantidad de escaños en {chamber}.",
"midtermsHubChamberTooltipDisclaimer": "Haz clic para ver la pregunta de pronóstico",
@@ -2296,6 +2296,8 @@
"midtermsHubScrollRight": "Desplazar perspectivas a la derecha",
"midtermsHubDemocrat": "Demócrata",
"midtermsHubRepublican": "Republicano",
+ "midtermsHubRaceLeanSummary": "{count} de {total} contiendas favorecen al partido {party}",
+ "midtermsHubCloseRacesSummary": "{count, plural, one {# contienda muy reñida} other {# contiendas muy reñidas}}",
"midtermsHubNotContested": "No disputado",
"midtermsHubDemPct": "{pct}% Demócrata",
"midtermsHubRepPct": "{pct}% Republicano",
@@ -2317,11 +2319,32 @@
"midtermsHubDemSeatAdvantage": "Ventaja de escaños demócratas",
"midtermsHubRepSeatAdvantage": "Ventaja de escaños republicanos",
"midtermsHubEven": "EMPATE",
+ "midtermsHubMedianLabel": "Mediana:",
+ "midtermsHubMedianDem": "D +{count, plural, one {# escaño} other {# escaños}}",
+ "midtermsHubMedianRep": "R +{count, plural, one {# escaño} other {# escaños}}",
+ "midtermsHubMedianEven": "Reparto igualado",
"midtermsHubForecastUnavailable": "Pronóstico no disponible",
"midtermsHubSeatAdvantageTooltip": "{count} escaños de ventaja",
"midtermsHubSeatAdvantageOverTooltip": ">{count} escaños de ventaja",
"midtermsHubProbabilityTooltip": "{value}% de probabilidad",
"midtermsHubEvenTooltip": "Empate",
+ "midtermsHubTimelineUnavailable": "Historial de previsiones no disponible",
+ "midtermsHubEngagementReachTitle": "Ponte en contacto con Metaculus",
+ "midtermsHubEngagementReachBody": "¿Tienes ideas, preguntas o has detectado algo confuso? Tus comentarios nos ayudan a mejorar este panel.",
+ "midtermsHubEngagementViewServices": "Ver servicios",
+ "midtermsHubEngagementShareTitle": "Comparte el panel",
+ "midtermsHubEngagementShareBody": "¿Crees que esto puede ayudar a otras personas a seguir las elecciones de medio mandato de 2026? Compártelo con tu red.",
+ "midtermsHubEngagementShareOnX": "Compartir en X",
+ "midtermsHubEngagementCopySuccess": "Enlace copiado al portapapeles",
+ "midtermsHubEngagementCopyError": "No se pudo copiar el enlace. Inténtalo de nuevo.",
+ "midtermsHubEngagementTweetText": "Echa un vistazo al panel de previsiones de las elecciones de medio mandato de EE. UU. de 2026 en Metaculus",
+ "midtermsHubEngagementNewsletterTitle": "Suscríbete para recibir novedades",
+ "midtermsHubEngagementNewsletterBody": "Regístrate para recibir avisos cuando cambien las previsiones y publiquemos nuevos análisis electorales.",
+ "midtermsHubEngagementNewsletterSubmit": "Guardar",
+ "midtermsHubEngagementNewsletterSuccess": "¡Ya estás suscrito!",
+ "midtermsHubEngagementEmailPlaceholder": "Correo electrónico",
+ "midtermsHubEngagementToastSuccess": "¡Suscripción realizada!",
+ "midtermsHubEngagementToastError": "No se pudo completar la suscripción. Inténtalo de nuevo.",
"nMore": "{count} más",
"noHistogramData": "No hay datos de histograma disponibles",
"laborHubJobsBreadcrumb": "Todos los empleos",
diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json
index da0b452e04..2a801a8848 100644
--- a/front_end/messages/pt.json
+++ b/front_end/messages/pt.json
@@ -2251,7 +2251,7 @@
"thousandsOfOpenQuestions": "20.000+ perguntas abertas",
"midtermsHubMetaTitle": "Eleições de meio de mandato dos EUA 2026 | Metaculus",
"midtermsHubMetaDescription": "Previsões em tempo real da comunidade Metaculus sobre as eleições de meio de mandato dos EUA de 2026.",
- "midtermsHubChamberCurrent": "Atual:",
+ "midtermsHubChamberToday": "Hoje:",
"midtermsHubChamberForecast": "Previsão:",
"midtermsHubChamberTooltipBody": "Os analistas dão a {party} uma probabilidade de {pct}% de obter o maior número de cadeiras na {chamber}.",
"midtermsHubChamberTooltipDisclaimer": "Clique para ver a pergunta de previsão",
@@ -2294,6 +2294,8 @@
"midtermsHubScrollRight": "Rolar insights para a direita",
"midtermsHubDemocrat": "Democrata",
"midtermsHubRepublican": "Republicano",
+ "midtermsHubRaceLeanSummary": "{count} de {total} disputas favorecem o partido {party}",
+ "midtermsHubCloseRacesSummary": "{count, plural, one {# disputa muito acirrada} other {# disputas muito acirradas}}",
"midtermsHubNotContested": "Sem disputa",
"midtermsHubDemPct": "{pct}% Democrata",
"midtermsHubRepPct": "{pct}% Republicano",
@@ -2315,11 +2317,32 @@
"midtermsHubDemSeatAdvantage": "Vantagem de cadeiras democratas",
"midtermsHubRepSeatAdvantage": "Vantagem de cadeiras republicanas",
"midtermsHubEven": "EMPATE",
+ "midtermsHubMedianLabel": "Mediana:",
+ "midtermsHubMedianDem": "D +{count, plural, one {# cadeira} other {# cadeiras}}",
+ "midtermsHubMedianRep": "R +{count, plural, one {# cadeira} other {# cadeiras}}",
+ "midtermsHubMedianEven": "Divisão igualada",
"midtermsHubForecastUnavailable": "Previsão indisponível",
"midtermsHubSeatAdvantageTooltip": "{count} cadeiras de vantagem",
"midtermsHubSeatAdvantageOverTooltip": ">{count} cadeiras de vantagem",
"midtermsHubProbabilityTooltip": "{value}% de probabilidade",
"midtermsHubEvenTooltip": "Empate",
+ "midtermsHubTimelineUnavailable": "Histórico de previsões indisponível",
+ "midtermsHubEngagementReachTitle": "Fale com a Metaculus",
+ "midtermsHubEngagementReachBody": "Tem ideias, dúvidas ou encontrou algo pouco claro? Sua opinião nos ajuda a melhorar este painel.",
+ "midtermsHubEngagementViewServices": "Ver serviços",
+ "midtermsHubEngagementShareTitle": "Compartilhe o painel",
+ "midtermsHubEngagementShareBody": "Acha que isso pode ajudar outras pessoas a acompanhar as eleições de meio de mandato de 2026? Compartilhe com sua rede.",
+ "midtermsHubEngagementShareOnX": "Compartilhar no X",
+ "midtermsHubEngagementCopySuccess": "Link copiado para a área de transferência",
+ "midtermsHubEngagementCopyError": "Não foi possível copiar o link. Tente novamente.",
+ "midtermsHubEngagementTweetText": "Confira o painel de previsões das eleições de meio de mandato dos EUA de 2026 na Metaculus",
+ "midtermsHubEngagementNewsletterTitle": "Assine para receber novidades",
+ "midtermsHubEngagementNewsletterBody": "Cadastre-se para ser avisado quando as previsões mudarem e publicarmos novas análises eleitorais.",
+ "midtermsHubEngagementNewsletterSubmit": "Salvar",
+ "midtermsHubEngagementNewsletterSuccess": "Você está inscrito!",
+ "midtermsHubEngagementEmailPlaceholder": "Endereço de e-mail",
+ "midtermsHubEngagementToastSuccess": "Inscrição realizada com sucesso!",
+ "midtermsHubEngagementToastError": "Não foi possível concluir a inscrição. Tente novamente.",
"nMore": "{count} a mais",
"noHistogramData": "Não há dados de histograma disponíveis",
"laborHubJobsBreadcrumb": "Todas as profissões",
diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json
index 11f18ee7bd..bb75317e5b 100644
--- a/front_end/messages/zh-TW.json
+++ b/front_end/messages/zh-TW.json
@@ -2250,7 +2250,7 @@
"thousandsOfOpenQuestions": "20,000+ 開放問題",
"midtermsHubMetaTitle": "2026 年美國期中選舉 | Metaculus",
"midtermsHubMetaDescription": "Metaculus 社群對 2026 年美國期中選舉的即時預測。",
- "midtermsHubChamberCurrent": "目前:",
+ "midtermsHubChamberToday": "今日:",
"midtermsHubChamberForecast": "預測:",
"midtermsHubChamberTooltipBody": "預測者認為{party}有 {pct}% 的機率在{chamber}中贏得最多席位。",
"midtermsHubChamberTooltipDisclaimer": "點擊查看預測問題",
@@ -2293,6 +2293,8 @@
"midtermsHubScrollRight": "向右捲動觀點",
"midtermsHubDemocrat": "民主黨",
"midtermsHubRepublican": "共和黨",
+ "midtermsHubRaceLeanSummary": "在 {total} 場競選中,{count} 場傾向{party}",
+ "midtermsHubCloseRacesSummary": "{count} 場勢均力敵",
"midtermsHubNotContested": "無競爭",
"midtermsHubDemPct": "{pct}% 民主黨",
"midtermsHubRepPct": "{pct}% 共和黨",
@@ -2314,11 +2316,32 @@
"midtermsHubDemSeatAdvantage": "民主黨席位優勢",
"midtermsHubRepSeatAdvantage": "共和黨席位優勢",
"midtermsHubEven": "持平",
+ "midtermsHubMedianLabel": "中位數:",
+ "midtermsHubMedianDem": "民主黨 +{count} 席",
+ "midtermsHubMedianRep": "共和黨 +{count} 席",
+ "midtermsHubMedianEven": "席次均分",
"midtermsHubForecastUnavailable": "暫無預測",
"midtermsHubSeatAdvantageTooltip": "領先 {count} 席",
"midtermsHubSeatAdvantageOverTooltip": "領先超過 {count} 席",
"midtermsHubProbabilityTooltip": "{value}% 機率",
"midtermsHubEvenTooltip": "持平",
+ "midtermsHubTimelineUnavailable": "暫無預測歷史",
+ "midtermsHubEngagementReachTitle": "聯絡 Metaculus",
+ "midtermsHubEngagementReachBody": "有想法、疑問,或發現了不清楚的地方?您的回饋能幫助我們改進這個面板。",
+ "midtermsHubEngagementViewServices": "查看服務",
+ "midtermsHubEngagementShareTitle": "分享這個面板",
+ "midtermsHubEngagementShareBody": "覺得這能幫助其他人了解 2026 年美國期中選舉嗎?分享給你的朋友。",
+ "midtermsHubEngagementShareOnX": "在 X 上分享",
+ "midtermsHubEngagementCopySuccess": "連結已複製到剪貼簿",
+ "midtermsHubEngagementCopyError": "複製連結失敗,請重試。",
+ "midtermsHubEngagementTweetText": "來看看 Metaculus 上的 2026 年美國期中選舉預測面板",
+ "midtermsHubEngagementNewsletterTitle": "訂閱更新",
+ "midtermsHubEngagementNewsletterBody": "註冊後,我們會在預測發生變化或發布新的期中選舉分析時通知您。",
+ "midtermsHubEngagementNewsletterSubmit": "儲存",
+ "midtermsHubEngagementNewsletterSuccess": "您已訂閱!",
+ "midtermsHubEngagementEmailPlaceholder": "電子郵件地址",
+ "midtermsHubEngagementToastSuccess": "訂閱成功!",
+ "midtermsHubEngagementToastError": "訂閱失敗,請重試。",
"nMore": "還有 {count} 個",
"noHistogramData": "沒有可用的直方圖資料",
"laborHubJobsBreadcrumb": "所有職業",
diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json
index d02e635d54..896c23bf5e 100644
--- a/front_end/messages/zh.json
+++ b/front_end/messages/zh.json
@@ -2254,7 +2254,7 @@
"thousandsOfOpenQuestions": "20,000+ 开放问题",
"midtermsHubMetaTitle": "2026 年美国中期选举 | Metaculus",
"midtermsHubMetaDescription": "Metaculus 社区对 2026 年美国中期选举的实时预测。",
- "midtermsHubChamberCurrent": "当前:",
+ "midtermsHubChamberToday": "今天:",
"midtermsHubChamberForecast": "预测:",
"midtermsHubChamberTooltipBody": "预测者认为{party}有 {pct}% 的概率在{chamber}中赢得最多席位。",
"midtermsHubChamberTooltipDisclaimer": "点击查看预测问题",
@@ -2297,6 +2297,8 @@
"midtermsHubScrollRight": "向右滚动洞察",
"midtermsHubDemocrat": "民主党",
"midtermsHubRepublican": "共和党",
+ "midtermsHubRaceLeanSummary": "在 {total} 场竞选中,{count} 场倾向{party}",
+ "midtermsHubCloseRacesSummary": "{count} 场势均力敌",
"midtermsHubNotContested": "无竞争",
"midtermsHubDemPct": "{pct}% 民主党",
"midtermsHubRepPct": "{pct}% 共和党",
@@ -2318,11 +2320,32 @@
"midtermsHubDemSeatAdvantage": "民主党席位优势",
"midtermsHubRepSeatAdvantage": "共和党席位优势",
"midtermsHubEven": "持平",
+ "midtermsHubMedianLabel": "中位数:",
+ "midtermsHubMedianDem": "民主党 +{count} 席",
+ "midtermsHubMedianRep": "共和党 +{count} 席",
+ "midtermsHubMedianEven": "席位均分",
"midtermsHubForecastUnavailable": "暂无预测",
"midtermsHubSeatAdvantageTooltip": "领先 {count} 席",
"midtermsHubSeatAdvantageOverTooltip": "领先超过 {count} 席",
"midtermsHubProbabilityTooltip": "{value}% 概率",
"midtermsHubEvenTooltip": "持平",
+ "midtermsHubTimelineUnavailable": "暂无预测历史",
+ "midtermsHubEngagementReachTitle": "联系 Metaculus",
+ "midtermsHubEngagementReachBody": "有想法、疑问,或发现了不清楚的地方?您的反馈能帮助我们改进这个面板。",
+ "midtermsHubEngagementViewServices": "查看服务",
+ "midtermsHubEngagementShareTitle": "分享这个面板",
+ "midtermsHubEngagementShareBody": "觉得这能帮助其他人了解 2026 年美国中期选举吗?分享给你的朋友。",
+ "midtermsHubEngagementShareOnX": "在 X 上分享",
+ "midtermsHubEngagementCopySuccess": "链接已复制到剪贴板",
+ "midtermsHubEngagementCopyError": "复制链接失败,请重试。",
+ "midtermsHubEngagementTweetText": "来看看 Metaculus 上的 2026 年美国中期选举预测面板",
+ "midtermsHubEngagementNewsletterTitle": "订阅更新",
+ "midtermsHubEngagementNewsletterBody": "注册后,我们会在预测发生变化或发布新的中期选举分析时通知您。",
+ "midtermsHubEngagementNewsletterSubmit": "保存",
+ "midtermsHubEngagementNewsletterSuccess": "您已订阅!",
+ "midtermsHubEngagementEmailPlaceholder": "电子邮件地址",
+ "midtermsHubEngagementToastSuccess": "订阅成功!",
+ "midtermsHubEngagementToastError": "订阅失败,请重试。",
"nMore": "{count} 更多",
"noHistogramData": "没有可用的直方图数据",
"laborHubJobsBreadcrumb": "所有职业",
diff --git a/front_end/src/app/(main)/actions.ts b/front_end/src/app/(main)/actions.ts
index 4fdd40edf4..9bcc729f98 100644
--- a/front_end/src/app/(main)/actions.ts
+++ b/front_end/src/app/(main)/actions.ts
@@ -18,8 +18,14 @@ export async function dismissFeedTile(id: string) {
return await serverMiscApi.dismissFeedTile(id);
}
+// Mailjet contact list per hub. A listKey that isn't in here resolves to
+// undefined and then falls back to MAILJET_NEWSLETTER_ID downstream (see
+// misc.server.ts), so a missing or misspelled entry silently subscribes people to
+// the general newsletter instead of erroring — always add the key here alongside
+// the hub that passes it.
const NEWSLETTER_LIST_IDS: Record = {
labor: "10617959",
+ midterms: "10628608",
};
export async function subscribeToNewsletter(email: string, listKey?: string) {
diff --git a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx
index 6d188ba9ce..2b2fa02913 100644
--- a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx
+++ b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx
@@ -10,21 +10,48 @@ import cn from "@/utils/core/cn";
import { subscribeToNewsletter } from "../../actions";
+export type NewsletterCopy = {
+ title: string;
+ description: string;
+ placeholder: string;
+ submit: string;
+ success: string;
+ toastSuccess: string;
+ toastError: string;
+};
+
+// The Labor Hub's original hardcoded English. Callers that are translated (e.g.
+// the Midterms hub) pass localized strings via `copy`; omitting it keeps this
+// card rendering exactly as it always has.
+const DEFAULT_COPY: NewsletterCopy = {
+ title: "Subscribe for updates",
+ description:
+ "Sign up to get notified when we publish new forecasts, insights, and improvements.",
+ placeholder: "Email address",
+ submit: "Save",
+ success: "You're subscribed!",
+ toastSuccess: "Subscribed successfully!",
+ toastError: "Failed to subscribe. Please try again.",
+};
+
type Props = {
listKey?: string;
className?: string;
formClassName?: string;
+ copy?: Partial;
};
export function NewsletterSubscribeCard({
listKey,
className,
formClassName,
+ copy,
}: Props) {
const { user } = useAuth();
const [email, setEmail] = useState(user?.email ?? "");
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
+ const text = { ...DEFAULT_COPY, ...copy };
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -35,11 +62,11 @@ export function NewsletterSubscribeCard({
await subscribeToNewsletter(email, listKey);
setIsSuccess(true);
setEmail("");
- toast("Subscribed successfully!", {
+ toast(text.toastSuccess, {
className: "dark:bg-blue-700-dark dark:text-gray-0-dark",
});
} catch {
- toast.error("Failed to subscribe. Please try again.");
+ toast.error(text.toastError);
} finally {
setIsLoading(false);
}
@@ -59,17 +86,16 @@ export function NewsletterSubscribeCard({
className="text-xl text-purple-800 dark:text-purple-800-dark lg:text-[22px]"
/>
- Subscribe for updates
+ {text.title}
- Sign up to get notified when we publish new forecasts, insights, and
- improvements.
+ {text.description}
{isSuccess ? (
- You're subscribed!
+ {text.success}
) : (
)}
diff --git a/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx
new file mode 100644
index 0000000000..de056b95a9
--- /dev/null
+++ b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx
@@ -0,0 +1,278 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { FC, ReactNode, useMemo, useState } from "react";
+
+import GroupChart from "@/components/charts/group_chart";
+import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors";
+import { TimelineChartZoomOption } from "@/types/charts";
+import { PostWithForecasts } from "@/types/post";
+import {
+ QuestionType,
+ QuestionWithMultipleChoiceForecasts,
+} from "@/types/question";
+import { ThemeColor } from "@/types/theme";
+
+import { MIDTERMS_COLORS } from "../constants";
+import { CONGRESS_OUTCOME_LABELS } from "../data";
+import TimelineRangeToggle from "./timeline_range_toggle";
+import {
+ buildControlTimeline,
+ ControlTimeline,
+} from "../helpers/build_control_timelines";
+import { useIsDark } from "../helpers/use_is_dark";
+
+// Deliberately compact for the ~320px-wide sidebar column. Of this, PLOT_TOP
+// (10px) and the axis band (20px) are chrome, so the trend itself gets ~72px —
+// enough to read direction and crossings, which is all these are for. The
+// precise numbers live in the legend, not the axes.
+const CHART_HEIGHT = 102;
+
+const DEM: ThemeColor = {
+ DEFAULT: MIDTERMS_COLORS.demPrimary,
+ dark: MIDTERMS_COLORS.demPrimaryDark,
+};
+const REP: ThemeColor = {
+ DEFAULT: MIDTERMS_COLORS.repPrimary,
+ dark: MIDTERMS_COLORS.repPrimaryDark,
+};
+
+// The four congress outcomes use the shared multiple-choice palette rather than
+// party tints: two blues and two reds sit too close together to tell apart as
+// four separate lines.
+const NEUTRAL_FALLBACK: ThemeColor = {
+ DEFAULT: MIDTERMS_COLORS.spectrumNeutral,
+ dark: MIDTERMS_COLORS.spectrumNeutral,
+};
+const MC = (i: number): ThemeColor =>
+ MULTIPLE_CHOICE_COLOR_SCALE[i] ?? NEUTRAL_FALLBACK;
+
+type Props = {
+ /** The congress-control post (#34484) — the source for all three timelines. */
+ post: PostWithForecasts | null;
+};
+
+const BalanceOfPowerTimelines: FC = ({ post }) => {
+ const t = useTranslations();
+ const question = post?.question as
+ | QuestionWithMultipleChoiceForecasts
+ | undefined;
+ // One timeframe for all three charts — they answer the same question about the
+ // same window, so letting them drift apart would invite false comparisons.
+ // Opens on two months: the full history reaches back to Jan 2025 and its early
+ // flat stretch squashes the recent movement. ALL is a click away.
+ const [zoom, setZoom] = useState(
+ TimelineChartZoomOption.TwoMonths
+ );
+
+ const demLabel = t("midtermsHubPartyDemocrats");
+ const repLabel = t("midtermsHubPartyRepublicans");
+
+ const timelines = useMemo(() => {
+ // Same option sums as chamber_control_card.tsx, so the last point of each
+ // series lands on the percentage the snapshot view shows.
+ const house = buildControlTimeline(question, [
+ {
+ label: demLabel,
+ color: DEM,
+ optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.RD],
+ },
+ {
+ label: repLabel,
+ color: REP,
+ optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.DR],
+ },
+ ]);
+ const senate = buildControlTimeline(question, [
+ {
+ label: demLabel,
+ color: DEM,
+ optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.DR],
+ },
+ {
+ label: repLabel,
+ color: REP,
+ optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.RD],
+ },
+ ]);
+ // The four outcomes read directly — no summing.
+ const congress = buildControlTimeline(question, [
+ {
+ label: t("midtermsHubOutcomeRepRep"),
+ color: MC(0),
+ optionLabels: [CONGRESS_OUTCOME_LABELS.RR],
+ },
+ {
+ label: t("midtermsHubOutcomeRepDem"),
+ color: MC(1),
+ optionLabels: [CONGRESS_OUTCOME_LABELS.RD],
+ },
+ {
+ label: t("midtermsHubOutcomeDemRep"),
+ color: MC(2),
+ optionLabels: [CONGRESS_OUTCOME_LABELS.DR],
+ },
+ {
+ label: t("midtermsHubOutcomeDemDem"),
+ color: MC(3),
+ optionLabels: [CONGRESS_OUTCOME_LABELS.DD],
+ },
+ ]);
+ return { house, senate, congress };
+ }, [question, demLabel, repLabel, t]);
+
+ const unavailableLabel = t("midtermsHubTimelineUnavailable");
+ const rangeToggle = ;
+
+ return (
+
+ {/* md+: straddle the card's top edge at the panel's right. The negative
+ offset cancels the column's own padding so the pill's centre lands on
+ the border, and it tracks the padding step at lg. */}
+ {/* flex, not block: a block wrapper around the inline-flex pill picks up
+ ~8px of baseline descender space, which would offset the centring. */}
+
+ {rangeToggle}
+
+ {/* Spacing lives on its own wrapper: as a sibling of the absolute toggle,
+ the child selectors below can't catch it — previously it picked up py-2
+ and stole :first-child from the House block. */}
+
+ {rangeToggle}
}
+ />
+
+
+
+
+ );
+};
+
+const TimelineBlock: FC<{
+ title: string;
+ timeline: ControlTimeline | null;
+ unavailableLabel: string;
+ zoom: TimelineChartZoomOption;
+ /** Optional control rendered on the title's row, right-aligned. */
+ titleAccessory?: ReactNode;
+}> = ({ title, timeline, unavailableLabel, zoom, titleAccessory }) => {
+ // Null = not hovering, so the legend shows the latest value. GroupChart reports
+ // the hovered timestamp here and we look each series up at that point.
+ const [cursorTimestamp, setCursorTimestamp] = useState(null);
+
+ return (
+
+ );
+};
+
+/** Index of the last point at or before the cursor; the latest point when idle. */
+function resolveIndex(
+ timestamps: number[],
+ cursorTimestamp: number | null
+): number {
+ const lastIndex = timestamps.length - 1;
+ if (cursorTimestamp == null) return lastIndex;
+ for (let i = lastIndex; i >= 0; i--) {
+ if ((timestamps[i] ?? 0) <= cursorTimestamp) return i;
+ }
+ return 0;
+}
+
+export default BalanceOfPowerTimelines;
diff --git a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx
index 097da0b2fd..513bcb0fc5 100644
--- a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx
+++ b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx
@@ -55,7 +55,7 @@ export default async function ChamberControlCard({ data }: Props) {
const labels = {
forecast: t("midtermsHubChamberForecast"),
- current: t("midtermsHubChamberCurrent"),
+ today: t("midtermsHubChamberToday"),
democrats: t("midtermsHubPartyDemocrats"),
republicans: t("midtermsHubPartyRepublicans"),
};
@@ -126,7 +126,7 @@ export default async function ChamberControlCard({ data }: Props) {
type Labels = {
forecast: string;
- current: string;
+ today: string;
democrats: string;
republicans: string;
};
@@ -222,15 +222,14 @@ function ChamberRow({
)}
-
- {labels.current}
-
- D {currentDem}
-
+ {/* Pre-midterm baseline. Deliberately gray rather than party-colored (or
+ even the card's blue) so it reads as context and doesn't compete with
+ the live forecast above it. */}
+
+ {labels.today}
+ D {currentDem}
{slash}
-
- R {currentRep}
-
+ R {currentRep}
);
diff --git a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx
index b7cde07bf8..6510b77303 100644
--- a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx
+++ b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx
@@ -23,7 +23,6 @@ import type {
} from "topojson-specification";
import { MIDTERMS_COLORS, STATE_NAMES } from "../constants";
-import MapLegend from "./map_legend";
import MapTooltipPortal from "./map_tooltip_portal";
import StateTooltipContent from "./state_tooltip";
import { SenateRaceWithQuestion } from "../helpers/post_utils";
@@ -88,8 +87,10 @@ const FIPS_TO_ABBR: Record = {
type Props = {
races: SenateRaceWithQuestion[];
- /** Tabs slot (rendered absolute top-left). */
+ /** Tabs slot (rendered top-left of the header overlay). */
tabsSlot?: ReactNode;
+ /** Optional summary line, centered between the tabs and the legend. */
+ summarySlot?: ReactNode;
};
type HoverState = {
@@ -111,7 +112,7 @@ const MAP_TRANSLATE: [number, number] = [400, 270];
const UNCONTESTED_OPACITY_DEFAULT = 0.75;
const UNCONTESTED_OPACITY_HOVER = 1;
-const GeographicMap: FC = ({ races, tabsSlot }) => {
+const GeographicMap: FC = ({ races, tabsSlot, summarySlot }) => {
const isDark = useIsDark();
const strokeColor = isDark
@@ -232,13 +233,20 @@ const GeographicMap: FC = ({ races, tabsSlot }) => {
ref={containerRef}
className="geo-map-container relative h-full w-full"
>
- {tabsSlot && (
-
- {tabsSlot}
+ {/* Header overlay: tabs on the left, summary on the right. Absolutely
+ positioned as one row so a wrapping summary grows over the map instead
+ of pushing it down, while the gap keeps it clear of the tabs.
+ The summary occupies the corner the party legend used to; its own
+ alignment comes from the className the caller passes, since this cell
+ can't override a class the summary sets on itself. */}
+ {/* Flush to the container: the map column's own padding provides the inset,
+ so the summary clears the dividing rule by the same 40px as everything
+ else. */}
+
+ {tabsSlot &&
{tabsSlot}
}
+
+ {summarySlot}
- )}
-
-
-
+
+ {/* empty:hidden so the gap collapses when the summary renders nothing
+ — it bails to null before any race is forecast. */}
+
+
+
diff --git a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx
index d893799970..ab16c8028b 100644
--- a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx
+++ b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
-import { FC, useId, useMemo } from "react";
+import { FC, useCallback, useId, useMemo, useRef, useState } from "react";
import {
VictoryArea,
VictoryAxis,
@@ -9,6 +9,7 @@ import {
VictoryChart,
VictoryLabel,
VictoryLine,
+ VictoryScatter,
VictoryTooltip,
VictoryVoronoiContainer,
} from "victory";
@@ -81,8 +82,65 @@ const HOVER_BAR_COLOR_DARK = "#E2E8F0";
const HOVER_BAR_WIDTH = 1;
const HOVER_BAR_ACTIVE_OPACITY = 0.75;
+// Shared flyout geometry for both the hover tooltip and the median callout.
+const TOOLTIP_FLYOUT_PADDING = { top: 11, bottom: 10, left: 12, right: 12 };
+const TOOLTIP_CORNER_RADIUS = 6;
+const TOOLTIP_POINTER_LENGTH = 10;
+const HOVER_FLYOUT_OPACITY = 0.9;
+// Per-line line-heights for the hover tooltip's two lines. The second value is
+// bumped over the first to open ~2px more gap between them (line 2 is 14px, so
+// 1.3 -> 1.44 moves its baseline 18.2px -> 20.2px).
+const HOVER_TOOLTIP_LINE_HEIGHTS: [number, number] = [1.3, 1.44];
+
+// Median callout. The flyout inverts against the page — black on light, white on
+// dark — so the party inks have to invert too: the light page needs the *light*
+// party swatch to read on its black flyout, and the dark page needs the darker
+// one on white. That's the opposite of every other party color in this file,
+// which is why these don't reuse demFill / demStroke.
+const MEDIAN_INK_LIGHT = "#000000";
+const MEDIAN_INK_DARK = "#ffffff";
+const MEDIAN_FLYOUT_OPACITY = 0.8;
+const MEDIAN_FONT_SIZE = 14;
+const MEDIAN_MARKER_SIZE = 4;
+const MEDIAN_MARKER_STROKE_WIDTH = 1.5;
+
type Point = { x: number; y: number };
+/**
+ * One-line, two-ink tooltip label: "Median:" in the flyout's foreground color
+ * followed by the seat value in its party color. VictoryLabel's array API only
+ * stacks lines vertically, so the two runs need to be sibling tspans here.
+ * VictoryTooltip still sizes its flyout from the concatenated `text` prop it was
+ * given, so auto-sizing keeps working.
+ */
+const MedianTooltipLabel: FC<{
+ x?: number;
+ y?: number;
+ labelText: string;
+ valueText: string;
+ labelFill: string;
+ valueFill: string;
+}> = ({ x, y, labelText, valueText, labelFill, valueFill }) => (
+
+ {/* Non-breaking space so the SVG renderer can't collapse the gap between
+ the two runs. */}
+ {`${labelText} `}
+
+ {valueText}
+
+
+);
+
const SeatDistributionChart: FC = ({
post,
cdfOverride,
@@ -101,6 +159,32 @@ const SeatDistributionChart: FC = ({
const reactId = useId().replace(/:/g, "");
const fillGradientId = `seat-fill-${reactId}`;
const strokeGradientId = `seat-stroke-${reactId}`;
+ // The median callout is a default-state affordance: it yields the moment a bin
+ // is hovered so it never stacks with the hover tooltip.
+ const [isBinHovered, setIsBinHovered] = useState(false);
+ // Victory's voronoi handleMouseMove calls onActivated(newPoints) and then
+ // onDeactivated(previousPoints) back-to-back in the same tick whenever the
+ // active bin changes. Without this flag the paired onDeactivated would
+ // immediately undo every activation and the callout would never hide. Only the
+ // genuine clears — pointer out of bounds, or off the container — arrive
+ // without a preceding activation.
+ const justActivatedRef = useRef(false);
+ const handleBinActivated = useCallback((points: unknown[]) => {
+ const active = points.length > 0;
+ justActivatedRef.current = active;
+ setIsBinHovered(active);
+ }, []);
+ const handleBinDeactivated = useCallback(() => {
+ if (justActivatedRef.current) {
+ justActivatedRef.current = false;
+ return;
+ }
+ setIsBinHovered(false);
+ }, []);
+ const handlePointerLeave = useCallback(() => {
+ justActivatedRef.current = false;
+ setIsBinHovered(false);
+ }, []);
const question = post.question as QuestionWithNumericForecasts | undefined;
@@ -274,6 +358,43 @@ const SeatDistributionChart: FC = ({
const neutralFill = isDark ? EVEN_BAR_FILL_DARK : EVEN_BAR_FILL_LIGHT;
const hoverBarColor = isDark ? HOVER_BAR_COLOR_DARK : HOVER_BAR_COLOR_LIGHT;
const evenTextColor = isDark ? EVEN_TEXT_COLOR_DARK : EVEN_TEXT_COLOR_LIGHT;
+ const chartBg = isDark
+ ? MIDTERMS_COLORS.cardBgDark
+ : MIDTERMS_COLORS.cardBgLight;
+
+ // Median callout. Both questions are Discrete, so the median lands on an
+ // integer bin center and the marker snaps to a bar rather than floating
+ // between two. Negative = Dem advantage, positive = Rep (see data.ts).
+ const medianInk = isDark ? MEDIAN_INK_DARK : MEDIAN_INK_LIGHT;
+ const medianLabelFill = isDark ? "#262f38" : "#ffffff";
+ const medianSeats = Math.round(quartileXs.median);
+ // Zero needs its own wording: it belongs to neither party, and without a branch
+ // it falls through to the Republican string as "R +0 seats" in red. It lands on
+ // the EVEN bin, which does state a different thing (that bar is
+ // P(advantage = 0); the median is where the CDF crosses 50%) — the callout is
+ // shown at every median regardless.
+ const medianLabelText = t("midtermsHubMedianLabel");
+ const medianValueText =
+ medianSeats === 0
+ ? t("midtermsHubMedianEven")
+ : medianSeats < 0
+ ? t("midtermsHubMedianDem", { count: Math.abs(medianSeats) })
+ : t("midtermsHubMedianRep", { count: medianSeats });
+ // Keyed off the flyout's own background, not the page: the light page's black
+ // flyout needs the *Dark* swatches (the pair designed to read on dark
+ // surfaces), and the dark page's white flyout needs the darker Border pair.
+ // Zero takes the label's own ink — party colors would assert a lean that isn't
+ // there.
+ const medianValueFill =
+ medianSeats === 0
+ ? medianLabelFill
+ : medianSeats < 0
+ ? isDark
+ ? MIDTERMS_COLORS.demBorder
+ : MIDTERMS_COLORS.demPrimaryDark
+ : isDark
+ ? MIDTERMS_COLORS.repBorder
+ : MIDTERMS_COLORS.repPrimaryDark;
const BAR_FILL_OPACITY = 0.7;
const BAR_FILL_OPACITY_HOVER = 1;
@@ -385,10 +506,14 @@ const SeatDistributionChart: FC = ({
const tooltipComponent = (
}
+ cornerRadius={TOOLTIP_CORNER_RADIUS}
+ flyoutPadding={TOOLTIP_FLYOUT_PADDING}
+ flyoutStyle={{
+ fill: tooltipBgFill,
+ fillOpacity: HOVER_FLYOUT_OPACITY,
+ stroke: "transparent",
+ }}
+ labelComponent={}
style={[
{
fill: tooltipTextFill,
@@ -405,8 +530,40 @@ const SeatDistributionChart: FC = ({
fontVariantNumeric: "tabular-nums",
},
]}
- pointerLength={10}
+ pointerLength={TOOLTIP_POINTER_LENGTH}
+ constrainToVisibleArea
+ />
+ );
+
+ const medianTooltipComponent = (
+
+ }
/>
);
@@ -444,6 +601,9 @@ const SeatDistributionChart: FC = ({
className="relative w-full"
ref={containerRef}
aria-label={ariaTitle}
+ // Safety net: Victory's onDeactivated doesn't reliably fire when the
+ // pointer leaves the plot quickly, which would strand the median hidden.
+ onMouseLeave={handlePointerLeave}
role="img"
>
{/* Hard-edge linear gradient that snaps from dem fill to rep fill at
@@ -491,10 +651,17 @@ const SeatDistributionChart: FC = ({
labels={formatTooltipLabel}
labelComponent={tooltipComponent}
mouseFollowTooltips={false}
+ onActivated={handleBinActivated}
+ onDeactivated={handleBinDeactivated}
voronoiBlacklist={
isDiscrete
- ? ["even-connector", "oob-divider-l", "oob-divider-r"]
- : ["area", "q-l", "q-m", "q-u"]
+ ? [
+ "median-marker",
+ "even-connector",
+ "oob-divider-l",
+ "oob-divider-r",
+ ]
+ : ["median-marker", "area", "q-l", "q-m", "q-u"]
}
// Let the page scroll vertically through the chart on touch; the
// chart only consumes horizontal moves (for the tooltip). Mirrors
@@ -707,6 +874,29 @@ const SeatDistributionChart: FC = ({
/>
)}
+ {/* Median marker — a dot on the x-axis at the median bin carrying an
+ always-on flyout. Rendered last so it paints above the bars, and
+ unmounted entirely while a bin is hovered so it never stacks with
+ the hover tooltip. */}
+ {!isBinHovered && (
+ `${medianLabelText} ${medianValueText}`}
+ labelComponent={medianTooltipComponent}
+ />
+ )}
+
{/* Chart title (SENATE / HOUSE) — rendered inside the SVG (not as an
HTML overlay) so the voronoi tooltip paints above it. */}
= ({ races }) => {
/>
)}
-
-
);
};
diff --git a/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx
new file mode 100644
index 0000000000..e522e57f7a
--- /dev/null
+++ b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { FC } from "react";
+
+import { TimelineChartZoomOption } from "@/types/charts";
+import { getChartZoomOptions } from "@/utils/charts/helpers";
+import cn from "@/utils/core/cn";
+
+type Props = {
+ value: TimelineChartZoomOption;
+ onChange: (value: TimelineChartZoomOption) => void;
+ className?: string;
+};
+
+/**
+ * Timeframe picker for the balance-of-power timelines. Same options and values as
+ * the zoom picker built into ChartContainer — sourced from the same
+ * `getChartZoomOptions` so the two can't drift — but fully rounded and hoisted
+ * out of the chart so a single control drives all three at once.
+ */
+const TimelineRangeToggle: FC = ({ value, onChange, className }) => {
+ const options = getChartZoomOptions();
+
+ return (
+
+ );
+};
+
+export default TimelineRangeToggle;
diff --git a/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts
new file mode 100644
index 0000000000..9b5977b36f
--- /dev/null
+++ b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts
@@ -0,0 +1,167 @@
+import { ChoiceItem } from "@/types/choices";
+import {
+ MultipleChoiceAggregateForecast,
+ QuestionWithMultipleChoiceForecasts,
+} from "@/types/question";
+import { ThemeColor } from "@/types/theme";
+
+export type ControlSeriesConfig = {
+ /** Option labels whose probabilities are summed into this one series. */
+ optionLabels: string[];
+ /** Legend / tooltip label. */
+ label: string;
+ color: ThemeColor;
+};
+
+export type ControlTimeline = {
+ timestamps: number[];
+ choiceItems: ChoiceItem[];
+};
+
+/**
+ * Builds a community-prediction timeline from a multiple-choice question,
+ * summing one or more options into each series.
+ *
+ * Deliberately not built on `generateChoiceItemsFromMultipleChoiceForecast`:
+ * that routes through `getAllOptionsHistory`, which returns only the *last*
+ * entry of `question.options` when `options_history` is absent — so it can
+ * silently collapse a four-option question to a single series. Summing has no
+ * equivalent there either; the closest thing, `buildChoicesWithOthers`, only
+ * folds *inactive* options into a gray bucket.
+ *
+ * Option indices are matched by label, case-insensitively — the same convention
+ * `getMultipleChoiceOptionProbability` uses — so these are the same sums the
+ * snapshot cards display and the two views cannot drift apart.
+ *
+ * Client-only: the timestamp grid compares `end_time` against the current
+ * clock, so building this during SSR and again on the client would risk a
+ * hydration mismatch.
+ */
+export function buildControlTimeline(
+ question: QuestionWithMultipleChoiceForecasts | null | undefined,
+ series: ControlSeriesConfig[]
+): ControlTimeline | null {
+ if (!question || !series.length) return null;
+
+ const history =
+ question.aggregations?.[question.default_aggregation_method]?.history;
+ if (!history?.length) return null;
+
+ const options = question.options ?? [];
+ const seriesIndices = series.map((s) =>
+ s.optionLabels.map((label) =>
+ options.findIndex((o) => o.toLowerCase() === label.toLowerCase())
+ )
+ );
+ // Fail closed, like the snapshot cards do: if an option label no longer
+ // matches, we can't reproduce its number, and a silently understated line is
+ // worse than no chart.
+ if (seriesIndices.some((group) => group.some((i) => i < 0))) return null;
+
+ const timestamps = collectTimestamps(history);
+ // A single point can't draw a trend.
+ if (timestamps.length < 2) return null;
+
+ // Resolved once and shared by every series, rather than rescanning `history`
+ // for each timestamp of each series.
+ const coverage = mapTimestampsToForecasts(timestamps, history);
+
+ const choiceItems: ChoiceItem[] = series.map((config, i) => {
+ const optionIndices = seriesIndices[i] ?? [];
+ const aggregationValues = coverage.map((entry) =>
+ sumCenters(entry, optionIndices)
+ );
+ const aggregationForecasterCounts = coverage.map(
+ (entry) => entry?.forecaster_count ?? 0
+ );
+
+ return {
+ choice: config.label,
+ label: config.label,
+ color: config.color,
+ highlighted: false,
+ active: true,
+ resolution: null,
+ aggregationTimestamps: timestamps,
+ aggregationValues,
+ // No interval band: the CI around a sum of options is not the sum of
+ // their CIs, so synthesizing one would be a fabrication. MultipleChoiceChart
+ // never reads these anyway — its y1/y2 come from its own stacking pass.
+ aggregationMinValues: timestamps.map(() => null),
+ aggregationMaxValues: timestamps.map(() => null),
+ aggregationForecasterCounts,
+ userTimestamps: [],
+ userValues: [],
+ };
+ });
+
+ return { timestamps, choiceItems };
+}
+
+/**
+ * Sorted, de-duplicated grid of every point where the aggregation changed.
+ * `end_time` only counts once it's in the past, matching how the shared
+ * `collectSortedTimestamps` treats still-open windows.
+ */
+function collectTimestamps(
+ history: MultipleChoiceAggregateForecast[]
+): number[] {
+ const nowSeconds = Date.now() / 1000;
+ const unique = new Set();
+ for (const forecast of history) {
+ unique.add(forecast.start_time);
+ if (forecast.end_time != null && forecast.end_time <= nowSeconds) {
+ unique.add(forecast.end_time);
+ }
+ }
+ return [...unique].sort((a, b) => a - b);
+}
+
+/**
+ * The forecast window covering each timestamp, resolved in a single pass.
+ *
+ * Both sequences ascend — `timestamps` by construction, `history` sorted here —
+ * so one monotonic cursor replaces a scan per timestamp per series. Assumes
+ * non-overlapping aggregation windows, which is how CP history is built; were
+ * they to overlap, this picks the latest-starting match rather than the first.
+ * A timestamp no window covers maps to `undefined`, which `sumCenters` turns
+ * into a null and the chart renders as a gap.
+ */
+function mapTimestampsToForecasts(
+ timestamps: number[],
+ history: MultipleChoiceAggregateForecast[]
+): Array {
+ const sorted = [...history].sort((a, b) => a.start_time - b.start_time);
+ let cursor = 0;
+
+ return timestamps.map((timestamp) => {
+ while (
+ cursor + 1 < sorted.length &&
+ (sorted[cursor + 1]?.start_time ?? Infinity) <= timestamp
+ ) {
+ cursor++;
+ }
+ const candidate = sorted[cursor];
+ const covers =
+ !!candidate &&
+ candidate.start_time <= timestamp &&
+ (candidate.end_time === null || candidate.end_time > timestamp);
+ return covers ? candidate : undefined;
+ });
+}
+
+/** Null if any contributing option is missing, so a gap stays a gap. */
+function sumCenters(
+ entry: MultipleChoiceAggregateForecast | undefined,
+ optionIndices: number[]
+): number | null {
+ const centers = entry?.centers;
+ if (!centers) return null;
+ let total = 0;
+ for (const index of optionIndices) {
+ const value = centers[index];
+ if (value == null) return null;
+ total += value;
+ }
+ return Number(total.toFixed(6));
+}
diff --git a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts
index 88c83a9ec3..cc3201849a 100644
--- a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts
+++ b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts
@@ -94,3 +94,33 @@ export function getDemWinPct(
if (prob == null) return null;
return Math.round(prob * 100);
}
+
+/** Half-width of the toss-up band, in points either side of 50. */
+const CLOSE_RACE_MARGIN = 10;
+
+/**
+ * Counts races by favored side, plus how many are close enough to be in play.
+ * The `>= 50` split matches the one `state_tooltip.tsx` uses, so a summary can
+ * never disagree with a tooltip. Races with no forecast are excluded from every
+ * count, and `total` is the sum of the two sides rather than `races.length` so
+ * the numbers always reconcile. Null when nothing is forecast yet.
+ *
+ * `close` deliberately overlaps `dem` and `rep` rather than partitioning them: a
+ * toss-up still leans one way, and pulling it out of its side's count would make
+ * the two figures contradict each other when read in one sentence.
+ */
+export function summarizeRaceLeans(
+ races: SenateRaceWithQuestion[]
+): { dem: number; rep: number; close: number; total: number } | null {
+ let dem = 0;
+ let rep = 0;
+ let close = 0;
+ for (const race of races) {
+ if (race.demWinPct == null) continue;
+ if (race.demWinPct >= 50) dem += 1;
+ else rep += 1;
+ if (Math.abs(race.demWinPct - 50) <= CLOSE_RACE_MARGIN) close += 1;
+ }
+ const total = dem + rep;
+ return total > 0 ? { dem, rep, close, total } : null;
+}
diff --git a/front_end/src/app/(main)/midterms-2026/page.tsx b/front_end/src/app/(main)/midterms-2026/page.tsx
index 4259e25aaf..5e5f362ed5 100644
--- a/front_end/src/app/(main)/midterms-2026/page.tsx
+++ b/front_end/src/app/(main)/midterms-2026/page.tsx
@@ -6,6 +6,7 @@ import { getPublicSettings } from "@/utils/public_settings.server";
import CommunityInsightsSection from "./sections/community_insights";
import ElectionsMapSection from "./sections/elections_map_section";
import ElectoralConsequencesSection from "./sections/electoral_consequences";
+import EngagementSection from "./sections/engagement_section";
import FooterSection from "./sections/footer";
import HeroSection from "./sections/hero";
import SeatDistributionsSection from "./sections/seat_distributions";
@@ -47,6 +48,7 @@ export default function MidtermsHubPage() {
+
diff --git a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx
index 9776005efd..09ebcfab15 100644
--- a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx
+++ b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx
@@ -1,7 +1,6 @@
import { SectionCard } from "@/app/(main)/labor-hub/components/section";
-import ChamberControlCard from "../components/chamber_control_card";
-import CongressOutcomeCard from "../components/congress_outcome_card";
+import BalanceOfPowerTimelines from "../components/balance_of_power_timelines";
import ResponsiveMap from "../components/responsive_map";
import {
fetchChamberData,
@@ -17,22 +16,28 @@ export default async function ElectionsMapSection() {
fetchChamberData(),
]);
+ // The card carries no overflow clip: that only ever existed to contain the
+ // geographic map's paths, and on the card it also cut off the balance-of-power
+ // panel's range toggle, which is meant to straddle the top edge. The clip now
+ // sits on the map column alone.
return (
-
-
- {/* Map column: edge-to-edge of the SectionCard on md+. */}
-
+
+
+ {/* Map column. Padding matches the panel's, so the map is inset the same
+ 40px from the dividing rule as the panel's content is — and the tabs,
+ summary and legend overlay inherit it instead of setting their own. */}
+
- {/* Sidebar column: capped at 30% of the container with its own
- internal padding so the cards stay inset from the white card
- edges. */}
-
-
-
+ {/* Balance-of-power column. One rule divides it from the map and runs the
+ full height — grid items stretch, so it meets both card edges. Below
+ md the layout is a single column, so the same rule becomes the
+ horizontal separator above the panel instead. */}
+
+
diff --git a/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx
new file mode 100644
index 0000000000..a3bee3ec16
--- /dev/null
+++ b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { faXTwitter } from "@fortawesome/free-brands-svg-icons";
+import { faCommentDots, faCopy } from "@fortawesome/free-regular-svg-icons";
+import { faChartBar } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import { NewsletterSubscribeCard } from "@/app/(main)/labor-hub/components/newsletter_subscribe_card";
+import { useModal } from "@/contexts/modal_context";
+import { useCopyUrl, useShareOnTwitterLink } from "@/hooks/share";
+
+// Mailjet contact list key for this hub — resolved in app/(main)/actions.ts.
+const NEWSLETTER_LIST_KEY = "midterms";
+
+function PartnerCard() {
+ const t = useTranslations();
+ const { setCurrentModal } = useModal();
+
+ return (
+