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_html_routes.py b/docs/app/scripts/check_html_routes.py
index a76ab38e..95e1e002 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 supported prerendered HTML paths for a route.
@@ -139,6 +187,11 @@ def main() -> None:
if LLMS_DIRECTIVE not in html_path.read_text(encoding="utf-8"):
msg = f"Prerendered route omits the llms.txt directive: {html_path}"
raise RuntimeError(msg)
+ validate_unique_html_ids(
+ page.route,
+ html_path,
+ html_path.read_text(encoding="utf-8"),
+ )
for route in DOCS_REDIRECTS:
module_path = route_module_path(route)
@@ -149,6 +202,11 @@ def main() -> None:
if not html_path.is_file():
msg = f"Missing prerendered redirect route: {html_path}"
raise RuntimeError(msg)
+ validate_unique_html_ids(
+ route,
+ html_path,
+ html_path.read_text(encoding="utf-8"),
+ )
if __name__ == "__main__":
diff --git a/docs/app/tests/test_check_html_routes.py b/docs/app/tests/test_check_html_routes.py
new file mode 100644
index 00000000..20db330b
--- /dev/null
+++ b/docs/app/tests/test_check_html_routes.py
@@ -0,0 +1,49 @@
+"""Focused tests for the post-build HTML route validator."""
+
+import importlib.util
+import sys
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+
+def test_duplicate_id_validator_covers_html_svg_and_diagnostics(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ """Accept unique IDs but reject an HTML/SVG collision with route context."""
+ reflex_site_shared = ModuleType("reflex_site_shared")
+ reflex_docs = ModuleType("reflex_site_shared.docs")
+ reflex_docs.discover_docs = lambda _config: []
+ xy_docs = ModuleType("xy_docs")
+ xy_config = ModuleType("xy_docs.config")
+ xy_config.DOCS_CONFIG = None
+ xy_config.DOCS_REDIRECTS = {}
+ monkeypatch.setitem(sys.modules, "reflex_site_shared", reflex_site_shared)
+ monkeypatch.setitem(sys.modules, "reflex_site_shared.docs", reflex_docs)
+ monkeypatch.setitem(sys.modules, "xy_docs", xy_docs)
+ monkeypatch.setitem(sys.modules, "xy_docs.config", xy_config)
+
+ validator_path = Path(__file__).resolve().parents[1] / "scripts" / "check_html_routes.py"
+ spec = importlib.util.spec_from_file_location("focused_check_html_routes", validator_path)
+ assert spec is not None and spec.loader is not None
+ validator = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(validator)
+
+ unique_source = ''
+ assert validator.duplicate_html_ids(unique_source) == ()
+
+ duplicate_source = ''
+ html_path = tmp_path / "charts" / "scatter.html"
+ assert validator.duplicate_html_ids(duplicate_source) == ("shared",)
+
+ with pytest.raises(
+ RuntimeError,
+ match=r"duplicate element IDs \('shared',\).*?/charts/scatter/.*scatter\.html",
+ ):
+ validator.validate_unique_html_ids(
+ "/charts/scatter/",
+ html_path,
+ duplicate_source,
+ )
diff --git a/docs/app/xy_docs/api_reference.py b/docs/app/xy_docs/api_reference.py
index 92d3dd08..9e8523b8 100644
--- a/docs/app/xy_docs/api_reference.py
+++ b/docs/app/xy_docs/api_reference.py
@@ -74,8 +74,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 88ad0b0d..2c66d053 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.
@@ -105,7 +105,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 069fc218..15bbd430 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.
@@ -113,7 +113,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.
@@ -136,7 +136,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.