diff --git a/docs/api-reference/chart-factories.md b/docs/api-reference/chart-factories.md
index 7682b63f..c0c4f5bd 100644
--- a/docs/api-reference/chart-factories.md
+++ b/docs/api-reference/chart-factories.md
@@ -24,11 +24,11 @@ from xy_docs.api_reference import chart_containers_api, chart_factories_api
chart_factories_api()
~~~
-Annotation-only compositions use the neutral `chart()` container, so that
-factory appears under Annotations. It also appears beside `facet_chart()` in
-Facets and Layers because layered marks use the same neutral container.
+Annotation-only and layered compositions use the neutral `chart()` container,
+so that factory appears once under Annotations and Layers. `facet_chart()` has
+its own Facets group.
-## Shared Chart Props
+## Chart Container Props
~~~python eval
chart_containers_api()
diff --git a/docs/app/scripts/check_duplicate_ids.py b/docs/app/scripts/check_duplicate_ids.py
new file mode 100644
index 00000000..d3afb304
--- /dev/null
+++ b/docs/app/scripts/check_duplicate_ids.py
@@ -0,0 +1,59 @@
+"""Fast source-level validation for Markdown-rendered documentation IDs.
+
+The post-build route validator checks complete HTML, including shared layout,
+raw HTML/SVG, and redirects.
+"""
+
+from collections import Counter
+from collections.abc import Iterator, Sequence
+from typing import Any
+
+from reflex_site_shared.docs.content import discover_docs
+from xy_docs.config import DOCS_CONFIG
+from xy_docs.markdown import render_xy_markdown_page
+
+
+def _static_string(value: Any) -> str | None:
+ """Return a Python string from a literal Reflex value."""
+ if isinstance(value, str):
+ return value
+ literal = getattr(value, "_var_value", None)
+ return literal if isinstance(literal, str) else None
+
+
+def _walk_component_tree(component: object) -> Iterator[object]:
+ """Yield a rendered Reflex component tree in document order."""
+ yield component
+ for child in getattr(component, "children", ()):
+ yield from _walk_component_tree(child)
+
+
+def duplicate_ids(components: Sequence[object]) -> tuple[str, ...]:
+ """Return duplicate static IDs from a rendered component sequence."""
+ static_ids = tuple(
+ component_id
+ for component in components
+ if (component_id := _static_string(getattr(component, "id", None)))
+ )
+ return tuple(component_id for component_id, count in Counter(static_ids).items() if count > 1)
+
+
+def validate_public_page_ids() -> None:
+ """Raise when Markdown-rendered page content has duplicate static IDs."""
+ failures: dict[str, tuple[str, ...]] = {}
+ for page in discover_docs(DOCS_CONFIG):
+ components = tuple(_walk_component_tree(render_xy_markdown_page(page)))
+ duplicates = duplicate_ids(components)
+ if duplicates:
+ failures[page.route] = duplicates
+ if failures:
+ raise RuntimeError(f"duplicate static IDs: {failures}")
+
+
+def main() -> None:
+ """Validate literal IDs produced from all Markdown-backed pages."""
+ validate_public_page_ids()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/app/scripts/check_html_routes.py b/docs/app/scripts/check_html_routes.py
index 720097d2..2de95c33 100644
--- a/docs/app/scripts/check_html_routes.py
+++ b/docs/app/scripts/check_html_routes.py
@@ -1,6 +1,8 @@
"""Validate human-facing documentation routes."""
import re
+from collections import Counter
+from html.parser import HTMLParser
from pathlib import Path
from reflex_site_shared.docs import discover_docs
@@ -18,6 +20,52 @@
LLMS_DIRECTIVE = "For AI agents: the complete XY documentation index is at"
+class _ElementIdParser(HTMLParser):
+ """Collect element IDs from prerendered HTML, including inline SVG."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.ids: list[str] = []
+
+ def _collect_ids(self, attrs: list[tuple[str, str | None]]) -> None:
+ """Record non-empty IDs from one start tag."""
+ self.ids.extend(value for name, value in attrs if name == "id" and value)
+
+ def handle_starttag(
+ self,
+ _tag: str,
+ attrs: list[tuple[str, str | None]],
+ ) -> None:
+ """Collect IDs from a normal start tag."""
+ self._collect_ids(attrs)
+
+ def handle_startendtag(
+ self,
+ _tag: str,
+ attrs: list[tuple[str, str | None]],
+ ) -> None:
+ """Collect IDs from a self-closing start tag."""
+ self._collect_ids(attrs)
+
+
+def duplicate_html_ids(source: str) -> tuple[str, ...]:
+ """Return duplicate element IDs from a complete prerendered document."""
+ parser = _ElementIdParser()
+ parser.feed(source)
+ parser.close()
+ return tuple(element_id for element_id, count in Counter(parser.ids).items() if count > 1)
+
+
+def validate_unique_html_ids(page_route: str, html_path: Path, source: str) -> None:
+ """Reject repeated IDs anywhere in a complete prerendered route."""
+ duplicates = duplicate_html_ids(source)
+ if duplicates:
+ msg = (
+ f"Prerendered route has duplicate element IDs {duplicates}: {page_route} ({html_path})"
+ )
+ raise RuntimeError(msg)
+
+
def route_html_paths(route: str) -> tuple[Path, ...]:
"""Return the canonical trailing-slash HTML path for a route.
@@ -115,7 +163,11 @@ def main() -> None:
msg = f"Missing prerendered documentation route: {html_paths!r}"
raise RuntimeError(msg)
for html_path in html_paths:
- if html_path.is_file() and LLMS_DIRECTIVE not in html_path.read_text(encoding="utf-8"):
+ if not html_path.is_file():
+ continue
+ source = html_path.read_text(encoding="utf-8")
+ validate_unique_html_ids(page.route, html_path, source)
+ if LLMS_DIRECTIVE not in source:
msg = f"Prerendered route omits the llms.txt directive: {html_path}"
raise RuntimeError(msg)
@@ -128,6 +180,13 @@ def main() -> None:
if not any(path.is_file() for path in html_paths):
msg = f"Missing prerendered redirect route: {html_paths!r}"
raise RuntimeError(msg)
+ for html_path in html_paths:
+ if html_path.is_file():
+ validate_unique_html_ids(
+ route,
+ html_path,
+ html_path.read_text(encoding="utf-8"),
+ )
if __name__ == "__main__":
diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py
index d8b1c25a..d826a938 100644
--- a/docs/app/tests/test_docs_site.py
+++ b/docs/app/tests/test_docs_site.py
@@ -10,6 +10,7 @@
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from pathlib import Path
+from types import SimpleNamespace
from urllib.parse import urlparse
import pytest
@@ -1795,6 +1796,75 @@ def test_inline_svg_gallery_validator_requires_every_styled_preview(tmp_path: Pa
check_html_routes.validate_inline_svg_gallery("/overview/gallery/", module_path)
+def test_prerendered_route_validator_rejects_ids_from_layout_and_raw_svg(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ """Inspect layout and raw SVG IDs through the final content-route check."""
+ build_root = tmp_path / "build"
+ routes_root = tmp_path / "routes"
+ route = "/guide/"
+ html_path = build_root / "guide" / "index.html"
+ module_path = routes_root / "[guide]._index.jsx"
+ html_path.parent.mkdir(parents=True)
+ module_path.parent.mkdir(parents=True)
+ (build_root / "__spa-fallback.html").write_text("", encoding="utf-8")
+ module_path.write_text("export default function Guide() {}", encoding="utf-8")
+ source = f"""\
+
+
+
+
+
+{check_html_routes.LLMS_DIRECTIVE}
+"""
+ html_path.write_text(source, encoding="utf-8")
+ monkeypatch.setattr(check_html_routes, "BUILD_ROOT", build_root)
+ monkeypatch.setattr(check_html_routes, "ROUTES_ROOT", routes_root)
+ monkeypatch.setattr(check_html_routes, "DOCS_REDIRECTS", {})
+ monkeypatch.setattr(
+ check_html_routes,
+ "discover_docs",
+ lambda _config: (SimpleNamespace(route=route, content=""),),
+ )
+ assert check_html_routes.duplicate_html_ids(source) == ("shared-id", "raw-svg-id")
+ with pytest.raises(
+ RuntimeError,
+ match=r"duplicate element IDs .*shared-id.*raw-svg-id",
+ ):
+ check_html_routes.main()
+
+
+def test_redirect_route_validator_rejects_duplicate_ids(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ """Run the final-ID check for redirects as well as content pages."""
+ build_root = tmp_path / "build"
+ routes_root = tmp_path / "routes"
+ redirect = "/legacy/"
+ redirect_html = build_root / "legacy" / "index.html"
+ redirect_module = routes_root / "[legacy]._index.jsx"
+ redirect_html.parent.mkdir(parents=True)
+ redirect_module.parent.mkdir(parents=True)
+ (build_root / "__spa-fallback.html").write_text("", encoding="utf-8")
+ redirect_html.write_text(
+ '',
+ encoding="utf-8",
+ )
+ redirect_module.write_text("export default function Redirect() {}", encoding="utf-8")
+ monkeypatch.setattr(check_html_routes, "BUILD_ROOT", build_root)
+ monkeypatch.setattr(check_html_routes, "ROUTES_ROOT", routes_root)
+ monkeypatch.setattr(check_html_routes, "DOCS_REDIRECTS", {redirect: "/"})
+ monkeypatch.setattr(check_html_routes, "discover_docs", lambda _config: ())
+
+ with pytest.raises(RuntimeError, match=r"duplicate element IDs .*redirect-id"):
+ check_html_routes.main()
+
+
@pytest.mark.xfail(
not EXPORTED_SITEMAP.is_file(),
reason="Build the XY docs frontend before validating Markdown links.",
diff --git a/docs/app/tests/test_unique_ids.py b/docs/app/tests/test_unique_ids.py
new file mode 100644
index 00000000..bbb2ad55
--- /dev/null
+++ b/docs/app/tests/test_unique_ids.py
@@ -0,0 +1,42 @@
+"""Regression coverage for public documentation element IDs."""
+
+import importlib.util
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+DOCS_APP_ROOT = Path(__file__).resolve().parent.parent
+CHECK_DUPLICATE_IDS_PATH = DOCS_APP_ROOT / "scripts" / "check_duplicate_ids.py"
+SPEC = importlib.util.spec_from_file_location(
+ "xy_docs_check_duplicate_ids",
+ CHECK_DUPLICATE_IDS_PATH,
+)
+if SPEC is None or SPEC.loader is None:
+ msg = f"Unable to load duplicate-ID validator: {CHECK_DUPLICATE_IDS_PATH}"
+ raise RuntimeError(msg)
+check_duplicate_ids = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(check_duplicate_ids)
+
+
+def test_markdown_page_bodies_have_unique_literal_ids() -> None:
+ """Keep Markdown-rendered page content free of duplicate literal IDs."""
+ result = subprocess.run(
+ [sys.executable, str(CHECK_DUPLICATE_IDS_PATH)],
+ cwd=DOCS_APP_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ check=False,
+ )
+
+ assert result.returncode == 0, f"{result.stdout}\n{result.stderr}"
+
+
+def test_duplicate_id_validator_rejects_repeated_literal_ids() -> None:
+ """Reject two rendered components with the same literal ID."""
+ duplicate = SimpleNamespace(_var_value="duplicate")
+ first = SimpleNamespace(id=duplicate)
+ second = SimpleNamespace(id=duplicate)
+
+ assert check_duplicate_ids.duplicate_ids((first, second)) == ("duplicate",)
diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py
index 74f35665..ed2046fb 100644
--- a/docs/app/xy_docs/api_reference.py
+++ b/docs/app/xy_docs/api_reference.py
@@ -73,8 +73,8 @@
xy.triangle_mesh_chart,
),
),
- ("Annotations", (xy.chart,)),
- ("Facets and Layers", (xy.chart, xy.facet_chart)),
+ ("Annotations and Layers", (xy.chart,)),
+ ("Facets", (xy.facet_chart,)),
)
_CHART_FACTORY_COMPONENTS = frozenset(
factory for _group_name, factories in CHART_FACTORY_GROUPS for factory in factories
diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md
index 4aef9f3b..4f604eaa 100644
--- a/docs/styling/capabilities.md
+++ b/docs/styling/capabilities.md
@@ -34,7 +34,7 @@ built — one renderer never silently ignores what another draws.
| `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped |
| `marker-shape` | xy | `scatter` | full | full | full | shipped |
-### Notes
+### Mark style notes
- **`opacity`** — Multiplies the mark's own alpha in every renderer.
- **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program.
@@ -86,7 +86,7 @@ token bag or in mark and axis `style=`, which every renderer reads.
| `axis_title` | full | partial | partial |
| `annotation_label` | full | none | none |
-### Notes
+### Chrome slot notes
- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export.
- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py
index f7ce5f12..b135f096 100644
--- a/scripts/gen_capability_matrix.py
+++ b/scripts/gen_capability_matrix.py
@@ -79,7 +79,7 @@ def render() -> str:
"",
]
lines += caps.markdown_mark_property_table()
- lines += ["", "### Notes", ""]
+ lines += ["", "### Mark style notes", ""]
for prop in caps.MARK_STYLE_PROPERTIES:
if prop.notes:
lines.append(f"- **`{prop.id}`** — {prop.notes}")
@@ -98,7 +98,7 @@ def render() -> str:
"",
]
lines += caps.markdown_slot_table()
- lines += ["", "### Notes", ""]
+ lines += ["", "### Chrome slot notes", ""]
for slot in caps.CHART_SLOTS:
if slot.notes:
lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}")
@@ -110,7 +110,7 @@ def render() -> str:
"",
]
lines += caps.markdown_extension_table()
- lines += ["", "### Notes", ""]
+ lines += ["", "### Extension point notes", ""]
for point in caps.EXTENSION_POINTS:
lines.append(f"- **{point.id}** — {point.notes}")
lines += [
@@ -179,7 +179,7 @@ def render_public() -> str:
"",
]
lines += caps.markdown_mark_property_table()
- lines += ["", "### Notes", ""]
+ lines += ["", "### Mark style notes", ""]
for prop in caps.MARK_STYLE_PROPERTIES:
if prop.notes:
lines.append(f"- **`{prop.id}`** — {prop.notes}")
@@ -194,7 +194,7 @@ def render_public() -> str:
"",
]
lines += caps.markdown_slot_table()
- lines += ["", "### Notes", ""]
+ lines += ["", "### Chrome slot notes", ""]
for slot in caps.CHART_SLOTS:
if slot.notes:
lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}")
diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md
index a77247cf..a9088853 100644
--- a/spec/api/capability-matrix.md
+++ b/spec/api/capability-matrix.md
@@ -38,7 +38,7 @@ one honors.
| `wedge-gap` | xy | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped |
| `marker-shape` | xy | `scatter` | full | full | full | shipped |
-### Notes
+### Mark style notes
- **`opacity`** — Multiplies the mark's own alpha in every renderer.
- **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program.
@@ -94,7 +94,7 @@ contracted in [export.md](export.md) §9 and pinned by
| `axis_title` | full | partial | partial |
| `annotation_label` | full | none | none |
-### Notes
+### Chrome slot notes
- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export.
- **`title`** (via `styles={'title': ...}`) — Vector (SVG, PDF) honors font-size, font-weight, font-style, font-family, letter-spacing, opacity and the text paint (`fill`, or `color`). The raster writer's glyph primitive takes a size and one RGBA paint and nothing else, so it honors font-size and the paint only — font-weight, font-style, font-family, letter-spacing and opacity are vector-only rather than silently approximated. Properties outside the subset stay browser-only.
@@ -117,7 +117,7 @@ Ways to add behavior the core does not ship, without forking it.
| mark_plugin_shader | planned | `—` | — |
| custom_renderer | planned | `—` | — |
-### Notes
+### Extension point notes
- **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its output is ordinary traces, so it reuses the built-in rendering, picking, and export paths rather than reimplementing them.
- **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred: a plugin with its own shader reuses none of the built-in rendering, picking, or export paths and would have to reimplement them.