You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
motief/reports/overton_window/overton_window.qmd

906 lines
35 KiB

---
title: "Has the Overton Window Shifted?"
subtitle: "Acceptance Through Moderation in the Dutch Tweede Kamer (2016–2026)"
author: "Stemwijzer Analysis"
date: today
format: html
jupyter: python3
---
```{python}
#| label: setup
#| include: false
import duckdb
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from pathlib import Path
ROOT = Path(".").resolve().parents[1]
DB_PATH = str(ROOT / "data" / "motions.db")
con = duckdb.connect(DB_PATH, read_only=True)
BREAK_YEAR = 2024
PARTY_COLOURS = {
"VVD": "#1E73BE", "PVV": "#002366", "D66": "#00A36C",
"CDA": "#4CAF50", "CU": "#0288D1", "NSC": "#FF8F00",
"SGP": "#F4511E", "FVD": "#6A1B9A", "JA21": "#7B1FA2",
"BBB": "#8D6E63", "SP": "#E53935", "GroenLinks-PvdA": "#2E7D32",
"PvdD": "#43A047", "Volt": "#572AB7", "DENK": "#00897B",
}
```
> **Verdict:** The Overton window widened. More right-wing positions became
> politically acceptable. But the mechanism was right-wing moderation, not
> centrist conversion. The effect may be temporary.
## Introduction
Did the PVV's November 2023 election victory shift the Dutch Overton window?
The conventional narrative is clear: a far-right party won the largest share of
seats, entered government for the first time in July 2024, and the political
center responded by adopting more right-wing positions. Centrist parties,
according to this story, moved right to accommodate the new political reality.
The data tells a different story.
Using 29,591 Tweede Kamer motions with full MP-level vote records, Procrustes-aligned
SVD spatial analysis, and 2D extremity scoring (stijl-extremiteit vs materiële
impact), we find that **the Overton window widened**: centrist support for
right-wing motions surged from 25% to 51%, while centrist support for non-right-wing
motions rose modestly (58%→62%, +3.5 pp). What changed was the behavior of right-wing parties:
they filed more motions, with milder content, framed in centrist-friendly
language. Centrist voting support surged from 0.251 to 0.507 (Cohen's d = +0.65),
but centrists did not become more right-wing. They stayed ideologically left
while voting more permissively on proposals that had become less materially
consequential.
This article presents the evidence across three indicators: centrist voting
support, SVD spatial divergence, and 2D extremity decomposition, and examines
the mechanisms through which right-wing motions gained centrist support.
## About Stemwijzer
Stemwijzer is a data-driven political compass built from real parliamentary voting
records. It analyzes 29,591 motions from the Tweede Kamer (2016 to 2026), each with
per-MP vote records, to compute latent political dimensions using Singular Value
Decomposition (SVD). Users vote on real motions and find which MPs match their
positions, not based on party manifestos or campaign promises, but on how
representatives actually voted.
The platform tracks party positions across 11 annual windows using
Procrustes-aligned SVD, allowing year-over-year comparison of spatial drift.
Every motion has been scored on two independent dimensions of extremity:
**stijl-extremiteit** (stylistic rhetoric, 1 to 5) and **materiële impact**
(material policy consequence, 1 to 5), manually validated with 75% auditor agreement.
The Overton analysis presented here builds on this infrastructure. The same
SVD compass, extremity scores, and vote-level data that power the Stemwijzer
Explorer dashboard drive these findings.
## Methodology
**Right-wing motion classification.** We identify right-wing motions using a
hybrid keyword + voting-pattern classifier. A seed set of right-wing keywords
(vuurwerkverbod, stikstof, nareis, etc.) is expanded through an iterative
keyword-vote loop. Motions whose voting pattern correlates with right-wing
party support are flagged, their distinctive terms extracted, and the keyword
set refined. The final classifier identifies 3,030 motions as right-wing across
2016 to 2026, with full voting records for centrist support computation.
**2D extremity scoring.** Every motion in the database (29,591) has been scored
by an LLM on two dimensions: *stijl-extremiteit* (stylistic extremity:
inflammatory language, rhetorical framing) and *materiële impact* (material
impact: rights restriction, institutional change, resource reallocation), each
on a 1 to 5 scale. Manual audit of 117 stratified motions achieved 75% agreement.
The two dimensions are only moderately correlated (Pearson r = 0.43 for all
motions, r = 0.47 for right-wing), confirming they capture distinct
phenomena. Excluding ~6,000 placeholder motions scored (1,1) by default, r drops to 0.34. The dimensions are even more independent than the headline figure suggests.
**Strict centrist definition.** We define the centrist bloc narrowly as four
parties (D66, CDA, ChristenUnie, NSC), excluding VVD and BBB, which lean
center-right and would inflate centrist support mechanically. A strict
opposition-only filter further controls for coalition effects by excluding
motions whose lead submitter belongs to the governing coalition.
**SVD alignment.** Party positions are computed via SVD on annual voting
matrices and aligned using chained Procrustes orthogonal rotation followed by
global PCA, placing all annual party positions in a common 2D reference frame.
Centrist and right-wing centers of gravity are computed as the mean of
party-level axis scores within each bloc.
```{python}
#| label: chart-1-yearly-cs
#| fig-cap: "Centrist Support for Right-Wing Motions Over Time (2016–2026)"
#| column: page
yearly_agg = con.execute("""
SELECT
year,
AVG(centrist_support_strict) AS mean_cs,
STDDEV(centrist_support_strict) AS std_cs,
COUNT(*) AS n
FROM right_wing_motions
WHERE classified = TRUE
GROUP BY year ORDER BY year
""").fetchdf()
yearly_cat = con.execute("""
SELECT
year,
category,
AVG(centrist_support_strict) AS cs,
COUNT(*) AS n
FROM right_wing_motions
WHERE classified = TRUE AND year >= 2017 AND category IS NOT NULL
GROUP BY year, category
ORDER BY year, category
""").fetchdf()
categories_list = sorted(yearly_cat["category"].unique())
cat_colors = ["#66C2A5", "#FC8D62", "#8DA0CB", "#E78AC3", "#A6D854",
"#FFD92F", "#E5C494", "#B3B3B3", "#1E88E5", "#D81B60"]
cat_labels = {
"asiel/vreemdelingen": "Asiel & Vreemdelingen",
"landbouw/natuur": "Landbouw & Natuur",
"veiligheid/justitie": "Veiligheid & Justitie",
"zorg/gezondheid": "Zorg & Gezondheid",
"economie": "Economie",
"energie/klimaat": "Energie & Klimaat",
"buitenland/europa": "Buitenland & Europa",
"onderwijs/wetenschap": "Onderwijs & Wetenschap",
"verkeer/infrastructuur": "Verkeer & Infrastructuur",
"overig": "Overig",
}
fig1 = go.Figure()
fig1.add_trace(go.Scatter(
x=yearly_agg["year"], y=yearly_agg["mean_cs"],
mode="lines+markers", name="All right-wing",
line=dict(color="#002366", width=3),
marker=dict(size=8),
error_y=dict(
type="data",
array=1.96 * yearly_agg["std_cs"] / np.sqrt(yearly_agg["n"]),
visible=True, thickness=0.8, width=2
),
))
for i, cat in enumerate(categories_list):
cat_data = yearly_cat[yearly_cat["category"] == cat]
if cat_data.empty:
continue
fig1.add_trace(go.Scatter(
x=cat_data["year"], y=cat_data["cs"],
mode="lines+markers",
name=cat_labels.get(cat, cat),
line=dict(color=cat_colors[i % len(cat_colors)], width=2, dash="dash"),
marker=dict(size=6),
visible=False,
))
pre = yearly_agg[yearly_agg["year"] < BREAK_YEAR]
post = yearly_agg[yearly_agg["year"] >= BREAK_YEAR]
fig1.add_hline(
y=pre["mean_cs"].mean(),
line_dash="dot", line_color="#90CAF9",
annotation_text=f"Pre-2024 mean ({pre['mean_cs'].mean():.3f})",
)
fig1.add_hline(
y=post["mean_cs"].mean(),
line_dash="dot", line_color="#1E88E5",
annotation_text=f"Post-2024 mean ({post['mean_cs'].mean():.3f})",
)
fig1.add_vline(
x=BREAK_YEAR - 0.5, line_dash="dot", line_color="black", opacity=0.5,
)
buttons = [
dict(label="All right-wing", method="update",
args=[{"visible": [True] + [False] * len(categories_list)}])
]
for i, cat in enumerate(categories_list):
vis = [True] + [False] * len(categories_list)
vis[i + 1] = True
buttons.append(dict(
label=cat_labels.get(cat, cat),
method="update",
args=[{"visible": vis}],
))
fig1.update_layout(
title="Centrist Support (Strict) for Right-Wing Motions",
xaxis=dict(title="Year", dtick=1),
yaxis=dict(title="Centrist Support (fraction of parties)", range=[0, 1.1]),
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
template="plotly_white", height=450,
updatemenus=[dict(
type="dropdown", direction="down", showactive=True,
buttons=buttons, x=1.05, y=1.15,
)],
)
fig1.show()
```
## Indicator 1: Centrist Voting Support
The cleanest signal is in how centrist parties voted on right-wing motions.
Average support rose from 0.251 pre-2024 to 0.507 post-2024, a Cohen's d of
+0.65, a medium-to-large effect. The breakpoint is unmistakably 2024.
This is not a coalition artifact. After the Schoof cabinet formed in July 2024,
PVV entered government, which could mechanically inflate support for its own
motions. When we restrict analysis to opposition-only right-wing motions (lead
submitter outside the governing coalition), the effect is larger: d = +0.85,
with support jumping from 0.270 to 0.543. Centrist parties are genuinely more
willing to support right-wing motions than they were before 2024, even when
those motions come from opposition right-wing parties.
The gradient across extremity levels persists: centrists still differentiate by
how radical a motion is, but at a consistently higher baseline. High-extremity
motions gained proportionally more support than mild motions, consistent with
genuine tolerance expansion rather than compositional shift.
Pass rate is useless as an indicator. Dutch parliament passes 96%+ of motions
in both periods. With near-zero variance, pass rate cannot register a shift of
any magnitude. Centrist support among MPs is the meaningful behavioral measure.
```{python}
#| label: chart-2-gravity
#| fig-cap: "Gravity-Controlled Centrist Support by Material Impact Level, Pre vs Post 2024"
#| column: page
gravity = con.execute("""
SELECT
CASE WHEN r.year < 2024 THEN 'pre-2024' ELSE 'post-2024' END AS period,
e.materiele_impact AS m_level,
AVG(r.centrist_support_strict) AS cs,
COUNT(*) AS n
FROM right_wing_motions r
JOIN extremity_scores_all e ON r.motion_id = e.motion_id
WHERE r.classified = TRUE AND e.materiele_impact IS NOT NULL
GROUP BY period, m_level ORDER BY period, m_level
""").fetchdf()
levels = sorted(gravity["m_level"].unique())
pre_vals = gravity[gravity["period"] == "pre-2024"].set_index("m_level")
post_vals = gravity[gravity["period"] == "post-2024"].set_index("m_level")
fig2 = go.Figure()
fig2.add_trace(go.Bar(
name="Pre-2024",
x=[f"M={l}" for l in levels],
y=[pre_vals.loc[l, "cs"] if l in pre_vals.index else 0 for l in levels],
marker_color="#90CAF9",
text=[f"N={int(pre_vals.loc[l, 'n'])}" if l in pre_vals.index else "" for l in levels],
textposition="outside",
offset=0,
))
fig2.add_trace(go.Bar(
name="Post-2024",
x=[f"M={l}" for l in levels],
y=[post_vals.loc[l, "cs"] if l in post_vals.index else 0 for l in levels],
marker_color="#1E88E5",
text=[f"N={int(post_vals.loc[l, 'n'])}" if l in post_vals.index else "" for l in levels],
textposition="outside",
offset=0.3,
))
fig2.update_layout(
title="Gravity-Controlled Centrist Support by Material Impact",
xaxis=dict(title="Material Impact Level"),
yaxis=dict(title="Centrist Support", range=[0, 1.1]),
barmode="group",
template="plotly_white", height=450,
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
fig2.show()
```
The gravity-controlled chart reveals a critical pattern: the centrist support
shift is real at **every** material impact level. From M=1 (mild procedural
adjustments, +0.292) to M=5 (systemic overhaul, +0.122), centrist support rose
across the board. The largest absolute gains came from the middle range (M=2:
+0.205, M=3: +0.219, M=4: +0.267), where most right-wing motions cluster.
Comparing right-wing motions against all other motions confirms the shift is
specific: right-wing centrist support surged by +0.236, while non-right-wing
motions remained essentially flat (−0.006). This is a right-wing-specific
phenomenon, not a general parliamentary trend.
## Domain Decomposition
Right-wing motions span ten policy categories, and the 2024 centrist support shift
was not uniform across them. Every category gained centrist support, but the magnitudes
vary dramatically — from a surge of +0.39 for climate and energy to a modest +0.08
for education and science.
Leading the shift are **energie/klimaat** (+0.392), **buitenland/europa** (+0.364),
and **economie** (+0.342) — domains where right-wing parties adopted centrist-friendly
framing and right-wing governments in other European countries provided template
policies. At the other end, **onderwijs/wetenschap** (+0.081) and **veiligheid/justitie**
(+0.138) barely moved, consistent with their roles as high-consensus domains where
the window was already wide.
```{python}
#| label: chart-7-category-delta
#| fig-cap: "Pre/Post 2024 Centrist Support Delta by Policy Category"
#| column: page
delta = con.execute("""
SELECT
category,
AVG(CASE WHEN year < 2024 THEN centrist_support_strict END) as pre_cs,
AVG(CASE WHEN year >= 2024 THEN centrist_support_strict END) as post_cs,
COUNT(*) as n
FROM right_wing_motions
WHERE classified = TRUE AND year >= 2017 AND category IS NOT NULL
GROUP BY category
ORDER BY (AVG(CASE WHEN year >= 2024 THEN centrist_support_strict END)
- AVG(CASE WHEN year < 2024 THEN centrist_support_strict END)) DESC
""").fetchdf()
delta["d"] = delta["post_cs"] - delta["pre_cs"]
fig7 = go.Figure()
fig7.add_trace(go.Bar(
x=delta["d"],
y=delta["category"],
orientation="h",
marker_color=["#2ECC71" if d > 0 else "#E74C3C" for d in delta["d"]],
text=[f"{d:.3f}" for d in delta["d"]],
textposition="outside",
cliponaxis=False,
))
fig7.update_layout(
title="Pre/Post 2024 Centrist Support Delta by Category",
xaxis=dict(title="Delta (post-2024 − pre-2024)", range=[-0.05, 0.48]),
yaxis=dict(title="", autorange="reversed"),
template="plotly_white", height=450,
margin=dict(l=200),
)
fig7.show()
```
The pattern reveals a political gradient. Domains tied to European integration
(buitenland/europa) and climate action (energie/klimaat) — where center-right
governments abroad provided cover — saw the largest shifts. Domestic social domains
(zorg/gezondheid, onderwijs/wetenschap) were largely insulated. The migration domain
(asiel/vreemdelingen), central to the Overton narrative, ranked seventh with a
+0.210 delta — substantial but not exceptional. Its importance lies not in the
magnitude of the shift but in its durability: migration centrist support sustained
its gains through 2025 while non-migration domains reverted.
## Indicator 2: Spatial Divergence
If centrists are voting more with right-wing motions, one might expect
ideological convergence: centrist parties drifting rightward on the SVD
compass. Procrustes-aligned SVD analysis shows the opposite.
```{python}
#| label: chart-3-svd
#| fig-cap: "SVD Trajectories: Centrist vs Right-Wing Centers of Gravity (2016–2026)"
#| column: page
svd = con.execute("""
SELECT * FROM overton_svd_center ORDER BY window_id
""").fetchdf()
fig3 = go.Figure()
fig3.add_trace(go.Scatter(
x=svd["centrist_mean_axis1"], y=svd["centrist_mean_axis2"],
mode="lines+markers+text", name="Centrist center",
line=dict(color="#00A36C", width=2),
marker=dict(size=8, symbol="circle"),
text=svd["window_id"], textposition="top center",
))
fig3.add_trace(go.Scatter(
x=svd["right_mean_axis1"], y=svd["right_mean_axis2"],
mode="lines+markers+text", name="Right-wing center",
line=dict(color="#002366", width=2),
marker=dict(size=8, symbol="square"),
text=svd["window_id"], textposition="bottom center",
))
fig3.update_layout(
title="SVD Party Centers of Gravity Over Time",
xaxis=dict(title="Axis 1 (Economic)"),
yaxis=dict(title="Axis 2 (Cultural)"),
template="plotly_white", height=500,
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
hovermode="closest",
)
fig3.show()
```
Between the first and last annual windows:
- **Centrists moved left on both axes:** −0.223 on the economic axis (more
welfare-oriented) and +0.081 on the cultural axis (more kosmopolitisch).
- **Right-wing parties moved further right culturally:** −0.065 on the cultural
axis (more nationalist).
- **The cultural distance between centrists and right-wing parties widened**
from 0.282 to 0.428 (+0.146).
This is spatial divergence, not convergence. Centrist parties did not become
right-wing. They became marginally *more* left-wing in their overall voting
patterns. The centrist center of gravity moved toward welfare and cosmopolitanism,
while right-wing parties moved further into the nationalist corner.
Why this makes sense with the voting data: The SVD captures the *full*
voting landscape, including all motions, not just the ones centrists supported.
Right-wing parties continued filing high-impact motions that centrists opposed,
while simultaneously filing a much larger volume of milder motions centrists
supported. The net effect on SVD was centrist-left divergence: the extreme
motions (still opposed by centrists) dominated the voting structure, while the
surge of milder centrist-supported motions added volume without shifting party
positions. This is "acceptance without conversion." Centrists vote more with
right-wing motions while moving further from them ideologically.
## Indicator 3: Content Moderation
The original single-dimensional extremity score showed no increase post-2024
(d = −0.09, from 2.21 to 2.15). If the Overton window shifted, why didn't
right-wing motions become more radical?
The answer lies in what the single score measured. Two-dimensional rescoring
of all 29,591 motions reveals that stylistic extremity and material impact are
only moderately correlated (r = 0.43). When tracked separately over time, they
tell different stories.
```{python}
#| label: chart-4-2d-extremity
#| fig-cap: "2D Extremity Over Time: Stijl vs Materieel (Right-Wing Motions, 2019–2026)"
#| column: page
extremity_2d = con.execute("""
SELECT
r.year,
AVG(e.stijl_extremiteit) AS mean_stijl,
AVG(e.materiele_impact) AS mean_mat,
COUNT(*) AS n
FROM right_wing_motions r
JOIN extremity_scores_all e ON r.motion_id = e.motion_id
WHERE r.classified = TRUE AND r.year >= 2019
GROUP BY r.year ORDER BY r.year
""").fetchdf()
all_stijl, all_mat = con.execute("""
SELECT AVG(stijl_extremiteit), AVG(materiele_impact)
FROM extremity_scores_all
""").fetchone()
fig4 = make_subplots(
rows=1, cols=2,
subplot_titles=("Stylistic Extremity (Stijl)", "Material Impact (Materieel)"),
shared_yaxes=False,
)
fig4.add_trace(
go.Scatter(
x=extremity_2d["year"], y=extremity_2d["mean_stijl"],
mode="lines+markers", name="Right-wing",
line=dict(color="#6A1B9A", width=3),
marker=dict(size=8),
),
row=1, col=1,
)
fig4.add_hline(
y=all_stijl, line_dash="dot", line_color="#9E9E9E",
annotation_text=f"All motions ({all_stijl:.2f})",
row=1, col=1,
)
fig4.add_trace(
go.Scatter(
x=extremity_2d["year"], y=extremity_2d["mean_mat"],
mode="lines+markers", name="Right-wing",
line=dict(color="#E53935", width=3),
marker=dict(size=8),
),
row=1, col=2,
)
fig4.add_hline(
y=all_mat, line_dash="dot", line_color="#9E9E9E",
annotation_text=f"All motions ({all_mat:.2f})",
row=1, col=2,
)
fig4.update_layout(
title="2D Extremity Decomposition: Stijl vs Materieel",
template="plotly_white", height=400,
showlegend=False,
)
fig4.update_xaxes(title="Year", dtick=1)
fig4.update_yaxes(title="Score (1–5)", range=[0.5, 4])
fig4.show()
```
| Dimension | Pre-2024 Mean | Post-2024 Mean | Δ |
|-----------|--------------|---------------|-----|
| Stylistic extremity | 1.875 | 1.744 | −0.131 |
| Material impact | 2.786 | 2.450 | −0.336 |
| Gap (M−S) | 0.911 | 0.706 | −0.205 |
Both dimensions *decreased*: stylistic extremity (−0.131) and material impact
(−0.336). A Wilcoxon signed-rank test comparing yearly mean stylistic vs yearly
mean material scores confirms the dimensions systematically differ (W = 0.0,
n = 10 yearly pairs, p = 0.002). The gap between the two dimensions narrowed
from 0.911 to 0.706. Right-wing motions became both less rhetorically hostile
AND less substantively impactful.
Compared to all motions, right-wing motions score higher on both dimensions:
stijl +0.47, materieel +0.54. The masking rate, restrained language paired
with high material impact, is 9.7% (S≤2, M≥4) or 13.5% (S=1, M≥3) for right-wing motions
vs 24.0% for all motions. Right-wing proposals disproportionately use
procedural language to advance consequential policy.
## Mechanisms of Influence
If centrists didn't become right-wing, *how* did right-wing motions gain their
support? A systematic classification of 150 post-2024 motions (stratified by
centrist support level) identifies the dominant pathways.
```{python}
#| label: chart-5-mechanisms
#| fig-cap: "Mechanism Distribution: High-Support vs Low-Support Post-2024 Motions"
#| column: page
mechanisms = [
"Procedureel/technisch",
"Consensus framing",
"Gerichte restrictie",
"Institutioneel/rechtsstatelijk",
"Symbolisch/declaratoir",
"Welzijn/dienstverlening",
"Lokaal/regionaal",
"Coalitie-afstemming",
"Crisisrespons",
"Systeemontmanteling",
]
high_support = [24, 18, 13, 7, 4, 3, 3, 2, 1, 0]
low_support = [9, 6, 21, 19, 5, 1, 1, 0, 0, 13]
fig5 = go.Figure()
fig5.add_trace(go.Bar(
name="High-support (CS > 0.5)",
x=mechanisms, y=high_support,
marker_color="#1E88E5",
))
fig5.add_trace(go.Bar(
name="Low-support (CS ≤ 0.5)",
x=mechanisms, y=low_support,
marker_color="#90CAF9",
))
fig5.update_layout(
title="Mechanism Classification: High-Support vs Low-Support Post-2024",
xaxis=dict(title="Mechanism", tickangle=45),
yaxis=dict(title="Count"),
barmode="group",
template="plotly_white", height=450,
legend=dict(yanchor="top", y=0.99, xanchor="right", x=0.99),
)
fig5.show()
```
The contrast between high- and low-support post-2024 motions is sharp.
**High-support motions (CS > 0.5)** are dominated by procedural/technical
framing (32%), consensus framing appealing to shared values (24%), and targeted
restriction rather than blanket bans (17%). Institutional challenges and system
dismantling are notably absent.
**Low-support motions (CS ≤ 0.5)** are dominated by targeted restriction (28%),
institutional challenges (25%), and system dismantling (17%). Zero system
dismantling motions achieved high centrist support.
Consensus framing is significantly more common in high-support motions (24%)
than low-support (8%): χ²(1) = 6.00, p = 0.014. Exploratory evidence suggests
consensus framing drives centrist support. Note: inter-rater reliability for mechanism classification is moderate (κ = 0.41). These patterns are exploratory and require taxonomy refinement.
**Party-level analysis** reveals the shift is not uniform. JA21 is the primary
driver, with a +0.203 CS shift and the only volume + support gains combination.
PVV entered government and filed fewer, milder motions. FVD remains structurally
shunned. Its motions consistently fail to gain centrist support regardless of
content.
## Temporal Dynamics
Quarterly analysis across 33 quarters (2016-Q2 through 2026-Q1) replaces the
binary pre/post-2024 comparison with a continuous trajectory that reveals the
exact timing, shape, and sustainability of the shift.
```{python}
#| label: chart-6-quarterly
#| fig-cap: "Quarterly Centrist Support Trajectory (2016–2026)"
#| column: page
quarterly = con.execute("""
SELECT
EXTRACT(YEAR FROM m.date) AS y,
CEIL(EXTRACT(MONTH FROM m.date) / 3.0) AS q,
AVG(r.centrist_support_strict) AS cs,
COUNT(*) AS n,
STDDEV(r.centrist_support_strict) AS std_cs
FROM right_wing_motions r
JOIN motions m ON r.motion_id = m.id
WHERE r.classified = TRUE AND m.date IS NOT NULL
GROUP BY y, q ORDER BY y, q
""").fetchdf()
quarterly["label"] = quarterly["y"].astype(int).astype(str) + "-Q" + quarterly["q"].astype(int).astype(str)
inflection_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 1)].index
peak_idx = quarterly[(quarterly["y"].astype(int) == 2024) & (quarterly["q"].astype(int) == 4)].index
latest_idx = quarterly[(quarterly["y"].astype(int) == 2026) & (quarterly["q"].astype(int) == 1)].index
fig6 = go.Figure()
fig6.add_trace(go.Scatter(
x=quarterly["label"], y=quarterly["cs"],
mode="lines+markers",
line=dict(color="#002366", width=3),
marker=dict(size=6),
error_y=dict(
type="data",
array=1.96 * quarterly["std_cs"] / np.sqrt(quarterly["n"]),
visible=True, thickness=0.6, width=1.5,
),
name="Centrist Support",
))
for idx in [inflection_idx, peak_idx, latest_idx]:
if len(idx) > 0:
i = idx[0]
fig6.add_annotation(
x=quarterly.loc[i, "label"], y=quarterly.loc[i, "cs"],
text=f'{quarterly.loc[i, "cs"]:.3f}',
showarrow=True, arrowhead=1, ax=0, ay=-30,
)
fig6.add_shape(
type="line", x0="2024-Q1", x1="2024-Q1", y0=0, y1=1,
line=dict(dash="dot", color="red", width=1.5),
)
fig6.add_annotation(
x="2024-Q1", y=0.95,
text="PVV election (Nov 2023)",
showarrow=False, textangle=-90,
font=dict(color="red", size=10),
)
fig6.add_shape(
type="line", x0="2024-Q3", x1="2024-Q3", y0=0, y1=1,
line=dict(dash="dot", color="orange", width=1.5),
)
fig6.add_annotation(
x="2024-Q3", y=0.88,
text="Schoof cabinet (Jul 2024)",
showarrow=False, textangle=-90,
font=dict(color="orange", size=10),
)
fig6.update_layout(
title="Quarterly Centrist Support Trajectory",
xaxis=dict(
title="Quarter",
tickangle=45,
tickmode="array",
tickvals=quarterly["label"][::4],
),
yaxis=dict(title="Centrist Support", range=[0, 1.0]),
template="plotly_white", height=450,
)
fig6.show()
```
**Timing.** The inflection point is 2024-Q1, the quarter immediately following
the PVV's November 2023 election victory. Centrist support jumped from 0.321
(2023-Q4) to 0.501 (2024-Q1), a single-quarter increase of +0.180, roughly
twice the average quarterly change.
**Shape.** Centrist support rose sharply through 2024-Q4, reaching an all-time
peak of 0.648 in the first full quarter of the Schoof cabinet. From that peak,
it declined steadily: 0.598, 0.503, 0.437, 0.450, and 0.334 in 2026-Q1,
below the 0.4 inflection threshold and approaching pre-shift levels.
**Causal mechanism.** The shift began before the Schoof cabinet formed (July
2024), appearing immediately after the PVV election. This is less consistent with
coalition dynamics as the primary driver. The most parsimonious explanation: centrist
parties perceived the PVV's electoral success as a mandate for right-wing policy
and adjusted their voting behavior accordingly. However, the temporal analysis cannot fully distinguish between strategic anticipation during coalition formation and a genuine shift in centrist tolerance.
**Sustainability.** The 2026-Q1 reversion to 0.334 raises a critical question:
is the centrist support surge a temporary electoral-cycle effect rather than a
permanent Overton window shift? Material moderation persisted (materieel ~2.4)
through the decline, but stylistic extremity reverted from 1.70 to 2.02. CS was
already declining through 2025 (0.648→0.450) despite continued moderation,
suggesting the 2024 spike was primarily an electoral shock for non-migration domains. However, 2026-Q2 shows CS bouncing back to 0.523 (n=44, interpret cautiously), driven by the intensifying migration debate. Migration centrist support (0.395) now exceeds non-migration (0.368) for the first time. The shift is domain-specific: temporary for non-migration, durable for migration.
| Hypothesis | Evidence | Verdict |
|------------|----------|---------|
| Electoral shock | Jump immediately followed PVV victory (Nov 2023) | **Supported** |
| Coalition dynamics | Shift began 3 quarters before cabinet formed | **Less consistent with the data** |
| Gradual learning | Jump was 1.9× average quarterly — discrete, not incremental | **Less consistent with the data** |
| European contagion | No Dutch response during 2022–2023 European shift | **Less consistent with the data** |
```{python}
#| label: chart-8-domain-trajectories
#| fig-cap: "Quarterly Centrist Support by Domain (5 Key Categories)"
#| column: page
key_cats = ["asiel/vreemdelingen", "energie/klimaat", "buitenland/europa",
"landbouw/natuur", "economie"]
quarterly_cat = con.execute("""
SELECT
EXTRACT(YEAR FROM m.date) AS y,
CEIL(EXTRACT(MONTH FROM m.date) / 3.0) AS q,
r.category,
AVG(r.centrist_support_strict) AS cs,
COUNT(*) AS n
FROM right_wing_motions r
JOIN motions m ON r.motion_id = m.id
WHERE r.classified = TRUE AND m.date IS NOT NULL
AND r.category IN ({})
GROUP BY y, q, r.category
ORDER BY y, q
""".format(",".join(f"'{c}'" for c in key_cats))).fetchdf()
quarterly_cat["label"] = (
quarterly_cat["y"].astype(int).astype(str)
+ "-Q"
+ quarterly_cat["q"].astype(int).astype(str)
)
fig8 = go.Figure()
fig8.add_trace(go.Scatter(
x=quarterly["label"], y=quarterly["cs"],
mode="lines+markers", name="All right-wing",
line=dict(color="#002366", width=3),
marker=dict(size=6),
))
domain_colors = {
"asiel/vreemdelingen": "#D81B60",
"energie/klimaat": "#1E88E5",
"buitenland/europa": "#FF8F00",
"landbouw/natuur": "#2E7D32",
"economie": "#8E44AD",
}
for cat in key_cats:
cat_data = quarterly_cat[quarterly_cat["category"] == cat]
if cat_data.empty:
continue
fig8.add_trace(go.Scatter(
x=cat_data["label"], y=cat_data["cs"],
mode="lines+markers",
name=cat_labels.get(cat, cat),
line=dict(color=domain_colors.get(cat, "#666"), width=2, dash="dash"),
marker=dict(size=5),
))
fig8.add_shape(
type="line", x0="2024-Q1", x1="2024-Q1", y0=0, y1=1,
line=dict(dash="dot", color="red", width=1.5),
)
fig8.update_layout(
title="Quarterly Centrist Support by Domain",
xaxis=dict(
title="Quarter", tickangle=45,
tickmode="array",
tickvals=quarterly["label"][::4],
),
yaxis=dict(title="Centrist Support", range=[0, 1.0]),
template="plotly_white", height=500,
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
fig8.show()
```
## Verdict: The Window Widened Through Moderation
**The Overton window widened: more right-wing positions became politically
acceptable after 2024. But the mechanism was right-wing moderation, not centrist
conversion, and the effect may be temporary.**
Centrist support for right-wing motions surged from 25% to 51%, while centrist
support for non-right-wing motions rose modestly (58%→62%, +3.5 pp). The window of acceptable
debate expanded rightward.
1. **Volume surged, impact declined.** Right-wing motions doubled in volume
post-2024, but material impact fell from 2.79 to 2.45 (Cohen's d = −0.36).
The M ≥ 4 share dropped from 23.7% to 11.3% and continued falling to 2.7%
by 2026.
2. **Centrists did not become more tolerant.** The extremity-stratified
gradient persists. Centrists still differentiate between mild and extreme
motions. The across-the-board baseline shift reflects that content within
each bucket became milder, not that centrists lowered their standards.
3. **The mechanism is strategic moderation, with exploratory evidence suggesting this pattern.** Zero
system-dismantling proposals achieved high centrist support post-2024. The
dominant pathways, such as procedural/technical (32%), consensus framing (24%),
and targeted restriction (17%), suggest right-wing parties learned which
frames work, though mechanism classification has moderate reliability (κ = 0.41).
4. **SVD divergence confirms this.** Centrists moved left spatially as the
extreme tail polarized even as cooperation grew on the moderate mass.
5. **The shift is electorally driven and domain-specific.** Centrist support
surged immediately after the PVV election, peaked at 0.648 in 2024-Q4, and
declined through 2025 to 0.450 despite continued material moderation. Then
hit 0.334 in 2026-Q1. But 2026-Q2 bounced back to 0.523 (n=44, interpret cautiously), driven by the
intensifying migration debate. Non-migration acceptance was a temporary
electoral shock; migration acceptance is durable and growing.
The gateway domain: migration. Migration is where the Overton shift is most
genuine. The frames right-wing parties learned there, they then applied
elsewhere. Material impact barely declined (−0.13), yet centrist support more
than doubled (0.153 → 0.369). Centrists went from zero support for M = 5
migration motions to nearly 20%. The gradient between impact levels flattened.
Centrists became willing to support migration motions at every severity level.
This is measurable acceptance expansion,
driven primarily by CDA and ChristenUnie rather than D66. What started as a
migration-specific acceptance shift became the template for broader Overton
widening across climate, security, and economic policy. As of 2026, migration
centrist support (0.395) exceeds non-migration (0.368) for the first time,
confirming that migration acceptance is durable while non-migration acceptance
was the temporary component. Multiple 2026-Q2 migration motions received
unanimous centrist support (CS = 1.00), including high-impact items.
### Limitations
- **Small-N time series:** 8 pre-2024 annual windows and 3 post-2024
(2026 is partial). Effect sizes are descriptive Cohen's d, not inferred from
a time-series model.
- **Coalition coding:** 2024 is ambiguous (Rutte IV until July, Schoof
thereafter). Opposition-only analysis and temporal timing mitigate this.
- **Mechanism classification:** Based on 150 post-2024 motions, single-classifier
assignment. Inter-rater agreement is moderate (κ = 0.41).
- **Causal direction:** The timing strongly supports an electoral explanation,
but this remains correlational.
- **Success ceiling:** 96%+ pass rate makes pass rate an insensitive dependent
variable.
### Explore the Data
This article is one surface of a three-tier analysis:
1. **Narrative spine.** You're reading it. The story, with the evidence.
2. **Technical appendices.** Detailed markdown reports in `reports/overton_window/`
cover every methodological decision, robustness check, and sensitivity
analysis.
3. **Live exploration.** Explore the Stemwijzer Explorer:
- **Kompas tab:** party positions on the SVD axes
- **Trajectories tab:** how parties drifted over time
- **Overton tab:** centrist support trends and right-wing motion browser
**Visit the Explorer** at `localhost:8501` to interact with the compass, plot
your position, and verify these findings against the underlying vote data.
```{python}
#| label: close-connection
#| include: false
con.close()
```