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 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/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.