diff --git a/.github/workflows/quarto-pages.yml b/.github/workflows/quarto-pages.yml
new file mode 100644
index 0000000..5989640
--- /dev/null
+++ b/.github/workflows/quarto-pages.yml
@@ -0,0 +1,40 @@
+name: Quarto Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v5
+ - uses: quarto-dev/quarto-actions/setup@v2
+ - uses: actions/configure-pages@v5
+ - run: uv sync
+ - run: uv run python scripts/summarize_model_matrix.py
+ - run: |
+ QUARTO_PYTHON="$(uv run python -c 'import sys; print(sys.executable)')" \
+ quarto render docs/index.qmd --to html --output-dir _site
+ - run: |
+ mkdir -p docs/_site/out/model_matrix
+ cp out/model_matrix/refusal_probe_seed24_n1_model_matrix.svg docs/_site/out/model_matrix/
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: docs/_site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index ba44966..9d4359a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,8 @@ data/*seed*.csv
data/template_catalog.jsonl
data/template_sources.jsonl
data/templates_v2_candidates*.txt
+_site/
+docs/_site/
+**/.quarto/
+**/*.quarto_ipynb
+docs/.gitignore
diff --git a/README.md b/README.md
index f88e8a3..ece6a70 100644
--- a/README.md
+++ b/README.md
@@ -355,10 +355,27 @@ Controls:
## Appendix: Refusal-Pole Probe
-This is a separate two-axis refusal/harm probe across four clean
-generator artifacts. It is not the main template result, because it does
-not cover all persona pairs. Treat it as a filter for templates worth
-retesting on refusal-ish negative poles in the main evaluation frame.
+This is a rejected-pole slice: it keeps the template and suffix sweep
+unfiltered, then evaluates persona pairs whose negative/rejected pole is
+refusal-prone or harm-adjacent. It is not the main template result,
+because it does not cover all persona pairs.
+
+Why include it? These negative poles can collapse into generic safety
+refusal, AI-role breaks, or persona echo instead of the intended
+behavioral contrast. This plot is a quick check for templates that move
+those hard axes without simply making the model refuse.
+
+
+
+Caption: each dot is one template, averaged over the two refusal-probe
+axes and four clean models. Right is more on-axis movement; lower is
+less off-axis confounding. Numbered dots are the first rows of the
+appendix table.
+
+`refusal_or_ai_break_rate` is only an output audit column: it marks
+completions that refused or broke AI role, and is not used to select
+this data slice.
Interactive hover plot: [GitHub
Pages](https://wassname.github.io/persona-steering-template-library/).
diff --git a/docs/.nojekyll b/docs/.nojekyll
deleted file mode 100644
index 8b13789..0000000
--- a/docs/.nojekyll
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/docs/index.html b/docs/index.html
deleted file mode 100644
index 90b6e12..0000000
--- a/docs/index.html
+++ /dev/null
@@ -1,527 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-This page is the interactive companion to the README. Use hover labels to inspect the refusal-pole probe without forcing the README plot to carry every label.
-
-Refusal-Pole Probe
-
-Each point is one template, averaged over two refusal-probe axes and four clean model artifacts. Lower-right is better: more intended-axis movement with less off-axis confounding.
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/docs/index.qmd b/docs/index.qmd
index 6eb46f2..3e8b331 100644
--- a/docs/index.qmd
+++ b/docs/index.qmd
@@ -13,8 +13,10 @@ execute:
```{python}
from pathlib import Path
+import html
import json
import sys
+import textwrap
import plotly.graph_objects as go
@@ -31,6 +33,13 @@ the refusal-pole probe without forcing the README plot to carry every label.
summary_path = ROOT / "out/model_matrix/refusal_probe_seed24_n1_template_model_summary.jsonl"
rows = [json.loads(line) for line in summary_path.read_text().splitlines() if line.strip()]
+
+def wrap_tooltip_text(text: str, width: int = 56) -> str:
+ escaped = html.escape(" ".join(text.split()))
+ return "