diff --git a/.gitignore b/.gitignore
index a168fb481..a3491066b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ __pycache__/
# Test results (nunit/junit) and coverage
/test-data/
/*coverage*
+tests/repr_html_visual_test.html
# jupyter
.ipynb_checkpoints
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c92d8b3ed..a60c24e2b 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,5 @@
{
- "[python][toml][json][jsonc]": {
+ "[python][javascript][toml][json][jsonc]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
@@ -12,7 +12,7 @@
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml",
},
- "[json][jsonc]": {
+ "[javascript][json][jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
},
"python.analysis.typeCheckingMode": "basic",
diff --git a/biome.jsonc b/biome.jsonc
index 3c34a2071..3f3c15941 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -1,6 +1,18 @@
{
- "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
+ "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"formatter": { "useEditorconfig": true },
+ "linter": {
+ "rules": {
+ "complexity": {
+ "noForEach": "on",
+ },
+ },
+ },
+ "javascript": {
+ "formatter": {
+ "semicolons": "asNeeded",
+ },
+ },
"overrides": [
{
"includes": ["./.vscode/*.json", "**/*.jsonc", "**/asv.conf.json"],
diff --git a/docs/release-notes/2236.feat.md b/docs/release-notes/2236.feat.md
new file mode 100644
index 000000000..38093264d
--- /dev/null
+++ b/docs/release-notes/2236.feat.md
@@ -0,0 +1 @@
+Add rich HTML representation for {class}`~anndata.AnnData` objects in Jupyter notebooks with foldable sections, search/filter, category color visualization, dark mode support, and configurable settings via {attr}`anndata.settings`
diff --git a/pyproject.toml b/pyproject.toml
index 200f926e8..fd4b98f97 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -266,7 +266,7 @@ max-positional-args = 5
[tool.codespell]
skip = ".git,*.pdf,*.svg"
-ignore-words-list = "theis,coo,homogenous,GroupT"
+ignore-words-list = "theis,coo,homogenous,vart,GroupT"
[tool.towncrier]
package = "anndata"
diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py
index a2d0ade75..e476c2729 100644
--- a/src/anndata/_core/anndata.py
+++ b/src/anndata/_core/anndata.py
@@ -556,6 +556,64 @@ def __repr__(self) -> str:
else:
return self._gen_repr(self.n_obs, self.n_vars)
+ def _repr_html_(self) -> str | None:
+ """Rich HTML representation for Jupyter notebooks.
+
+ Returns an interactive HTML representation with:
+
+ - Foldable sections for each attribute (auto-collapse for large sections)
+ - Search/filter functionality across all fields
+ - Copy-to-clipboard buttons for field names
+ - Color visualization for categorical data with color palettes
+ - Serialization warnings for non-serializable types
+ - Memory usage and version information
+ - Dark mode support (auto-detects Jupyter/VS Code themes)
+ - Graceful degradation when JavaScript is disabled
+
+ The representation can be configured via :attr:`anndata.settings`:
+
+ - ``repr_html_enabled``: Enable/disable HTML repr (default: True)
+ - ``repr_html_fold_threshold``: Auto-fold sections with more entries (default: 5)
+ - ``repr_html_max_depth``: Max recursion depth for nested AnnData (default: 3)
+ - ``repr_html_max_items``: Max items to show per section (default: 200)
+ - ``repr_html_max_categories``: Max category values to display inline (default: 100)
+ - ``repr_html_unique_limit``: Max rows for unique count computation (default: 1M)
+ - ``repr_html_max_field_width``: Max width in pixels for field name column (default: 400)
+ - ``repr_html_type_width``: Width in pixels for type column (default: 220)
+
+ Examples
+ --------
+ Disable HTML representation globally:
+
+ >>> import anndata
+ >>> anndata.settings.repr_html_enabled = False
+
+ Temporarily change settings using context manager::
+
+ with anndata.settings.override(repr_html_fold_threshold=10):
+ display(adata) # Sections fold only when >10 items
+
+ Returns
+ -------
+ str | None
+ HTML string if enabled, None otherwise (falls back to text repr).
+ """
+ if not settings.repr_html_enabled:
+ return None
+
+ try:
+ from anndata._repr import generate_repr_html
+
+ return generate_repr_html(self)
+ except Exception as e: # noqa: BLE001
+ # Intentional broad catch: HTML repr should never crash the notebook
+ # Fall back to text repr if HTML generation fails, but log the error
+ warn(
+ f"HTML repr failed, falling back to text repr: {e}",
+ UserWarning,
+ )
+ return None
+
def __eq__(self, other):
"""Equality testing"""
msg = (
diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py
new file mode 100644
index 000000000..6c39c6394
--- /dev/null
+++ b/src/anndata/_repr/__init__.py
@@ -0,0 +1,443 @@
+"""
+Rich HTML representation for AnnData objects in Jupyter notebooks.
+
+Module Architecture
+-------------------
+This package uses a layered import hierarchy to avoid circular imports.
+When modifying imports, maintain this order:
+
+.. code-block:: text
+
+ _repr_constants.py (outside _repr/, no internal imports)
+ │ Constants only. Imported by _settings.py at anndata import time.
+ │ Must not import anything from anndata.
+ │
+ └─► utils.py (depends only on external: numpy, pandas)
+ │ HTML escaping, formatting, serialization checks.
+ │
+ └─► components.py (depends on: utils)
+ │ UI building blocks: badges, buttons, icons.
+ │
+ └─► registry.py (depends on: _repr_constants)
+ │ Formatter registry, TypeFormatter, SectionFormatter.
+ │ NOTE: formatters.py imports registry for registration.
+ │
+ └─► core.py (depends on: components, registry, utils)
+ │ Shared rendering primitives: render_section().
+ │
+ ├─► sections.py (depends on: core, components, registry, utils)
+ │ │ Section-specific renderers (obs, var, uns, etc.).
+ │ │ Uses late import of html.render_formatted_entry.
+ │ │
+ │ └─► html.py (depends on: core, sections, components, registry, utils)
+ │ Main orchestrator: generate_repr_html().
+ │ Side-effect import of formatters.py for registration.
+ │
+ └─► formatters.py (depends on: registry, components, utils)
+ Built-in type formatters. Auto-registers on import.
+ Late import of html.generate_repr_html for AnnDataFormatter.
+
+ __init__.py (imports from all modules for public API)
+
+Key patterns for avoiding circular imports:
+1. ``_repr_constants.py`` is outside ``_repr/`` - safe to import anywhere
+2. Side-effect imports use ``from . import formatters as _formatters # noqa: F401``
+3. Late imports inside functions for cross-module dependencies
+4. Type hints use ``if TYPE_CHECKING:`` blocks
+
+This module provides an extensible HTML representation system with:
+- Foldable sections with auto-collapse
+- Search/filter functionality
+- Color visualization for categorical data
+- Value previews for simple types in uns (strings, numbers, dicts, lists)
+- Serialization warnings
+- Support for nested AnnData objects
+- Graceful handling of unknown types
+
+Extensibility
+-------------
+The system is designed to be extensible via two registry patterns:
+
+**TypeFormatter** (for custom visualization of values):
+ Register a formatter to customize how specific types are displayed.
+ Can match by Python type OR by embedded type hints in data.
+
+ Attributes:
+ - ``priority``: Higher priority formatters are checked first (default: 0)
+ - ``sections``: Tuple of section names to restrict formatter to (default: None = all)
+
+ Example - format by Python type::
+
+ from anndata._repr import register_formatter, TypeFormatter, FormattedOutput
+
+
+ @register_formatter
+ class MyArrayFormatter(TypeFormatter):
+ sections = ("obsm", "varm") # Only apply to obsm/varm
+
+ def can_format(self, obj, context):
+ return isinstance(obj, MyArrayType)
+
+ def format(self, obj, context):
+ return FormattedOutput(
+ type_name=f"MyArray {obj.shape}",
+ css_class="anndata-dtype--myarray",
+ # preview_html provides HTML for the preview column (rightmost)
+ preview_html=f'({obj.n_items} items)',
+ )
+
+ **Error handling**: Formatters can signal errors in two ways:
+
+ 1. **Raise an exception** - The registry catches it, emits a warning with the
+ full error message (for debugging), and continues to try other formatters.
+ If all formatters fail, the fallback formatter is used with the accumulated
+ errors (showing only exception types in HTML to avoid long messages).
+
+ 2. **Set ``error`` field explicitly** - For expected errors, set
+ ``FormattedOutput(error="reason")`` directly. The row will be highlighted
+ red and the error shown in the preview column.
+
+ When ``error`` is set, it takes precedence over ``preview`` and ``preview_html``.
+
+ Example - format by embedded type hint (for tagged data in uns)::
+
+ from anndata._repr import register_formatter, TypeFormatter, FormattedOutput
+ from anndata._repr import extract_uns_type_hint
+
+
+ @register_formatter
+ class MyConfigFormatter(TypeFormatter):
+ priority = 100 # Check before fallback
+ sections = ("uns",) # Only apply to uns
+
+ def can_format(self, obj, context):
+ hint, _ = extract_uns_type_hint(obj)
+ return hint == "mypackage.config"
+
+ def format(self, obj, context):
+ hint, data = extract_uns_type_hint(obj)
+ return FormattedOutput(
+ type_name="config",
+ preview_html="Custom config preview",
+ )
+
+ Data structure for type hints (works in any section)::
+
+ adata.uns["my_config"] = {
+ "__anndata_repr__": "mypackage.config",
+ "data": '{"setting": "value"}',
+ }
+
+ When a package registers a formatter and the user imports that package,
+ the formatter will automatically handle matching tagged data. Without
+ the import, a fallback shows: "[mypackage.config] (import mypackage)".
+
+ See :func:`extract_uns_type_hint` for full documentation on this pattern.
+
+ **The context parameter**: Both ``can_format()`` and ``format()`` receive a
+ :class:`FormatterContext` with useful attributes:
+
+ - ``context.section``: Current section ("obs", "var", "uns", etc.)
+ - ``context.key``: Current entry key (column name for obs/var, dict key for uns, etc.)
+ - ``context.adata_ref``: Reference to root AnnData (for uns lookups)
+
+ This enables context-aware formatting, e.g., looking up metadata in
+ ``context.adata_ref.uns`` based on ``context.key``. See
+ :class:`FormatterContext` for all available attributes.
+
+**SectionFormatter** (for adding new sections):
+ Register a formatter to add entirely new sections (like TreeData's obst/vart).
+
+ Example::
+
+ from anndata._repr import register_formatter, SectionFormatter
+ from anndata._repr import FormattedEntry, FormattedOutput
+
+
+ @register_formatter
+ class ObstSectionFormatter(SectionFormatter):
+ section_name = "obst"
+ after_section = "obsm" # Position after obsm
+
+ def should_show(self, obj):
+ return hasattr(obj, "obst") and len(obj.obst) > 0
+
+ def get_entries(self, obj, context):
+ return [
+ FormattedEntry(
+ key=k,
+ output=FormattedOutput(type_name=f"Tree ({v.n_nodes} nodes)"),
+ )
+ for k, v in obj.obst.items()
+ ]
+
+Building Custom _repr_html_
+---------------------------
+For packages with AnnData-adjacent objects (like SpatialData, MuData) that need
+their own ``_repr_html_``, you can reuse anndata's CSS, JavaScript, and helpers.
+
+**Basic structure**::
+
+ from anndata._repr import get_css, get_javascript
+
+
+ class MyData:
+ def _repr_html_(self):
+ container_id = f"mydata-{id(self)}"
+ return f'''
+ {get_css()}
+
+
+ MyData
+ 100 items
+
+
+
+
+
+ {get_javascript(container_id)}
+ '''
+
+**CSS classes** (stable, can be used directly):
+
+ Classes follow `BEM naming convention `_:
+ ``anndata-{block}__{element}--{modifier}``
+
+ **Blocks** (top-level components):
+ - ``anndata-repr``: Main container (required for JS and styling)
+ - ``anndata-header``: Header row (flexbox, contains type/shape/badges)
+ - ``anndata-footer``: Footer row (version, memory info)
+ - ``anndata-section``: Individual section wrapper
+ - ``anndata-entry``: Table row for data entries
+ - ``anndata-badge``: Status badges (View, Backed, Lazy)
+ - ``anndata-dtype``: Data type indicators
+
+ **Elements** (parts of blocks, use ``__``):
+ - ``anndata-header__type``: Type name span in header
+ - ``anndata-header__shape``: Shape/dimensions span in header
+ - ``anndata-section__content``: Section content (rows)
+ - ``anndata-entry__name``: Entry name cell
+ - ``anndata-entry__type``: Entry type cell
+ - ``anndata-entry__preview``: Entry preview cell
+
+ **Modifiers** (variants, use ``--``):
+ - ``anndata-section--collapsed``: Collapsed section state
+ - ``anndata-badge--view``: View badge variant
+ - ``anndata-dtype--category``: Categorical dtype styling
+
+**CSS variables** (set on ``.anndata-repr`` element):
+
+ - ``--anndata-name-col-width``: Width of name column (default: 150px)
+ - ``--anndata-type-col-width``: Width of type column (default: 220px)
+
+**Using render helpers** for consistent section rendering::
+
+ from anndata._repr import (
+ CSS_DTYPE_NDARRAY,
+ get_css,
+ get_javascript,
+ render_section,
+ render_formatted_entry,
+ render_badge,
+ render_search_box,
+ FormattedEntry,
+ FormattedOutput,
+ )
+
+
+ def _repr_html_(self):
+ container_id = f"mydata-{id(self)}"
+ parts = [get_css()]
+
+ # Header
+ parts.append(f'''
+
")
+ parts.append(get_javascript(container_id))
+ return "\\n".join(parts)
+
+**Embedding nested AnnData** with full interactivity::
+
+ from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput
+
+ nested_html = generate_repr_html(adata, depth=1, max_depth=3)
+ entry = FormattedEntry(
+ key="table",
+ output=FormattedOutput(
+ type_name=f"AnnData ({adata.n_obs} x {adata.n_vars})",
+ expanded_html=nested_html, # Collapsible content below the row
+ ),
+ )
+
+**Complete example**: See ``MockSpatialData`` in ``tests/visual_inspect_repr_html.py``
+for a full implementation with images, labels, points, shapes, and nested tables.
+"""
+
+from __future__ import annotations
+
+# Import constants from dedicated module (single source of truth)
+# Note: _repr_constants is outside _repr/ to avoid loading the full _repr
+# package when _settings.py imports constants at anndata import time.
+from .._repr_constants import (
+ CSS_DTYPE_ANNDATA,
+ CSS_DTYPE_NDARRAY,
+ DEFAULT_FOLD_THRESHOLD,
+ DEFAULT_MAX_CATEGORIES,
+ DEFAULT_MAX_DEPTH,
+ DEFAULT_MAX_FIELD_WIDTH,
+ DEFAULT_MAX_ITEMS,
+ DEFAULT_MAX_LAZY_CATEGORIES,
+ DEFAULT_MAX_STRING_LENGTH,
+ DEFAULT_PREVIEW_ITEMS,
+ DEFAULT_TYPE_WIDTH,
+ DEFAULT_UNIQUE_LIMIT,
+ NOT_SERIALIZABLE_MSG,
+)
+
+# Documentation base URL
+DOCS_BASE_URL = "https://anndata.readthedocs.io/en/latest/"
+
+
+def get_section_doc_url(section: str) -> str:
+ """Get documentation URL for a section.
+
+ Centralizes URL generation so the pattern can be changed in one place.
+ Uses /en/latest/ for discoverability (users can navigate to their version).
+
+ Parameters
+ ----------
+ section
+ Section name (e.g., "obs", "var", "uns", "obsm")
+
+ Returns
+ -------
+ URL to the section's documentation page
+ """
+ return f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html"
+
+
+# Import main functionality
+# Inline styles for graceful degradation (from single source of truth)
+from .._repr_constants import STYLE_HIDDEN # noqa: E402
+
+# Building blocks for packages that want to create their own _repr_html_
+# These allow reusing anndata's styling while building custom representations
+from .components import ( # noqa: E402
+ TypeCellConfig,
+ render_badge,
+ render_copy_button,
+ render_header_badges,
+ render_search_box,
+ render_warning_icon,
+)
+from .css import get_css # noqa: E402
+from .html import ( # noqa: E402
+ generate_repr_html,
+ render_formatted_entry,
+ render_section,
+)
+from .javascript import get_javascript # noqa: E402
+from .registry import ( # noqa: E402
+ UNS_TYPE_HINT_KEY,
+ FormattedEntry,
+ FormattedOutput,
+ FormatterContext,
+ # Type formatter registry
+ FormatterRegistry,
+ SectionFormatter,
+ TypeFormatter,
+ # Type hint extraction (for tagged data in uns)
+ extract_uns_type_hint,
+ formatter_registry,
+ register_formatter,
+)
+
+# HTML rendering helpers for building custom sections
+from .utils import ( # noqa: E402
+ escape_html,
+ format_memory_size,
+ format_number,
+ validate_key,
+)
+
+__all__ = [ # noqa: RUF022 # organized by category, not alphabetically
+ # Constants
+ "DEFAULT_FOLD_THRESHOLD",
+ "DEFAULT_MAX_DEPTH",
+ "DEFAULT_MAX_ITEMS",
+ "DEFAULT_MAX_STRING_LENGTH",
+ "DEFAULT_PREVIEW_ITEMS",
+ "DEFAULT_MAX_CATEGORIES",
+ "DEFAULT_MAX_LAZY_CATEGORIES",
+ "DEFAULT_UNIQUE_LIMIT",
+ "DEFAULT_MAX_FIELD_WIDTH",
+ "DEFAULT_TYPE_WIDTH",
+ "DOCS_BASE_URL",
+ "get_section_doc_url",
+ "NOT_SERIALIZABLE_MSG",
+ # CSS dtype constants for custom formatters
+ "CSS_DTYPE_NDARRAY",
+ "CSS_DTYPE_ANNDATA",
+ # Main function
+ "generate_repr_html",
+ # Registry for extensibility
+ "FormatterRegistry",
+ "formatter_registry",
+ "register_formatter",
+ "SectionFormatter",
+ "TypeFormatter",
+ "FormattedOutput",
+ "FormattedEntry",
+ "FormatterContext",
+ # Type hint extraction (for tagged data in uns)
+ "extract_uns_type_hint",
+ "UNS_TYPE_HINT_KEY",
+ # Building blocks for custom _repr_html_ implementations
+ "get_css",
+ "get_javascript",
+ "escape_html",
+ "format_number",
+ "format_memory_size",
+ "render_section",
+ "render_formatted_entry",
+ "STYLE_HIDDEN",
+ # UI component helpers
+ "render_search_box",
+ "render_copy_button",
+ "render_badge",
+ "render_header_badges",
+ "render_warning_icon",
+ "TypeCellConfig",
+ # Validation helpers
+ "validate_key",
+]
diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py
new file mode 100644
index 000000000..db2530117
--- /dev/null
+++ b/src/anndata/_repr/components.py
@@ -0,0 +1,608 @@
+"""
+Reusable UI components for HTML representation.
+
+This module provides building blocks for creating consistent HTML representations:
+- Warning/error icons with tooltips
+- Search box with filter toggles
+- Fold/expand icons for collapsible sections
+- Copy-to-clipboard buttons
+- Status badges (view, backed, sparse, etc.)
+
+These components are designed to be used by both anndata's internal repr
+and by external packages (MuData, SpatialData, TreeData) that want to
+build compatible representations.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from .._repr_constants import (
+ CSS_ENTRY,
+ CSS_TEXT_MUTED,
+ NOT_SERIALIZABLE_MSG,
+ STYLE_HIDDEN,
+)
+from .utils import escape_html, sanitize_css_color
+
+
+def render_entry_row_open(
+ key: str,
+ dtype: str,
+ *,
+ has_warnings: bool = False,
+ is_error: bool = False,
+ has_expandable_content: bool = False,
+ extra_classes: str = "",
+) -> str:
+ """Render the opening tag for an entry row.
+
+ For regular entries, returns ``
``.
+ For expandable entries, returns ````
+ so the whole row acts as the disclosure toggle.
+
+ Parameters
+ ----------
+ key
+ The entry key (column name, field name, etc.)
+ dtype
+ The data type string (for data-dtype attribute)
+ has_warnings
+ Whether the entry has warnings
+ is_error
+ Whether the entry has errors (not serializable, invalid key)
+ has_expandable_content
+ Whether this entry has nested content (uses ````/````)
+ extra_classes
+ Additional CSS classes to include
+
+ Returns
+ -------
+ Opening tag(s) with class and data attributes
+ """
+ # Build CSS class string
+ classes = [CSS_ENTRY]
+ if extra_classes:
+ classes.append(extra_classes)
+ if has_warnings:
+ classes.append("warning")
+ if is_error:
+ classes.append("error")
+ css_class = " ".join(classes)
+
+ escaped_key = escape_html(key)
+ escaped_dtype = escape_html(dtype)
+
+ if has_expandable_content:
+ return (
+ f''
+ f''
+ )
+ return f'
'
+
+
+def render_warning_icon(
+ warnings: list[str], *, is_not_serializable: bool = False
+) -> str:
+ """Render warning icon with tooltip if there are warnings or serialization issues.
+
+ Parameters
+ ----------
+ warnings
+ List of warning messages to show in tooltip.
+ is_not_serializable
+ If True, prepends "Not serializable to H5AD/Zarr" to warnings.
+
+ Returns
+ -------
+ HTML string for warning icon, or empty string if no warnings.
+ """
+ if not warnings and not is_not_serializable:
+ return ""
+
+ # Build the tooltip message
+ if is_not_serializable:
+ if warnings:
+ # "Not serializable: reason1; reason2"
+ reasons = "; ".join(warnings)
+ title = f"{NOT_SERIALIZABLE_MSG}: {reasons}"
+ else:
+ # Just "Not serializable to H5AD/Zarr"
+ title = NOT_SERIALIZABLE_MSG
+ else:
+ # Independent warnings joined with ";"
+ title = "; ".join(warnings)
+
+ title = escape_html(title)
+ return f'(!)'
+
+
+def render_search_box(container_id: str = "") -> str:
+ """
+ Render a search box with filter indicator and search mode toggles.
+
+ The search box is hidden by default and shown when JavaScript is enabled.
+ It filters entries across all sections by key, type, or content.
+ Includes toggle buttons for case-sensitive search and regex mode.
+
+ Parameters
+ ----------
+ container_id
+ Unique ID for the container (used for label association)
+
+ Returns
+ -------
+ HTML string for the search box
+
+ Example
+ -------
+ >>> container_id = "spatialdata-123"
+ >>> parts = ['
")
+ """
+ search_id = f"{container_id}-search" if container_id else "anndata-search"
+ return (
+ f''
+ f''
+ f''
+ f''
+ f''
+ f""
+ f""
+ f''
+ )
+
+
+def render_copy_button(text: str, tooltip: str = "Copy") -> str:
+ """
+ Render a copy-to-clipboard button.
+
+ The button is hidden by default and shown when JavaScript is enabled.
+ When clicked, it copies the specified text to the clipboard.
+
+ Parameters
+ ----------
+ text
+ The text to copy when clicked
+ tooltip
+ Tooltip text (default: "Copy")
+
+ Returns
+ -------
+ HTML string for the copy button
+
+ Example
+ -------
+ >>> name = "gene_expression"
+ >>> html = f"{name}{render_copy_button(name, 'Copy name')}"
+ """
+ escaped_text = escape_html(text)
+ escaped_tooltip = escape_html(tooltip)
+ return (
+ f''
+ )
+
+
+def _render_wrap_button(css_class: str) -> str:
+ """Render a wrap toggle button with the specified CSS class.
+
+ Internal helper used by render_categories_wrap_button and render_columns_wrap_button.
+ """
+ return f''
+
+
+def render_categories_wrap_button() -> str:
+ """Render a button to toggle category list between single-line and multi-line.
+
+ Returns
+ -------
+ HTML string for the wrap button (▼ expands, ▲ collapses)
+ """
+ return _render_wrap_button("anndata-categories__wrap")
+
+
+def render_columns_wrap_button() -> str:
+ """Render a button to toggle column list between single-line and multi-line.
+
+ Returns
+ -------
+ HTML string for the wrap button (▼ expands, ▲ collapses)
+ """
+ return _render_wrap_button("anndata-columns__wrap")
+
+
+def render_muted_span(text: str) -> str:
+ """Render text in a muted span (gray color).
+
+ Parameters
+ ----------
+ text
+ Text to render (will be HTML-escaped)
+
+ Returns
+ -------
+ HTML string with muted styling
+ """
+ return f'{escape_html(text)}'
+
+
+def render_nested_content(html_content: str) -> str:
+ """Render nested/expanded content inside an expandable entry.
+
+ The entry must have been opened with ``has_expandable_content=True``
+ (which makes it a ```` with ````). This function
+ closes the ```` and adds the nested content. The caller
+ must close the entry with ````.
+
+ Parameters
+ ----------
+ html_content
+ The HTML content to display when expanded
+
+ Returns
+ -------
+ HTML closing the summary and wrapping nested content
+ """
+ return (
+ f""
+ f'
'
+ f'
{html_content}
'
+ f"
"
+ )
+
+
+def render_badge(
+ text: str,
+ variant: str = "",
+ tooltip: str = "",
+) -> str:
+ """
+ Render a badge (pill-shaped label).
+
+ Parameters
+ ----------
+ text
+ Badge text
+ variant
+ Variant class for styling. Built-in variants:
+ - "" (default gray)
+ - "anndata-badge--view" (blue, for views)
+ - "anndata-badge--backed" (orange, for backed mode)
+ - "anndata-badge--sparse" (green, for sparse matrices)
+ - "anndata-badge--dask" (purple, for Dask arrays)
+ - "anndata-badge--extension" (for extension types)
+ tooltip
+ Tooltip text on hover
+
+ Returns
+ -------
+ HTML string for the badge
+
+ Example
+ -------
+ >>> badge = render_badge("Zarr", "anndata-badge--backed", "Backed by Zarr store")
+ """
+ escaped_text = escape_html(text)
+ title_attr = f' title="{escape_html(tooltip)}"' if tooltip else ""
+ # Always include base class, optionally add variant
+ css_class = f"anndata-badge {variant}".strip() if variant else "anndata-badge"
+ return f'{escaped_text}'
+
+
+def render_header_badges(
+ *,
+ is_view: bool = False,
+ is_backed: bool = False,
+ is_lazy: bool = False,
+ backing_path: str | None = None,
+ backing_format: str | None = None,
+) -> str:
+ """
+ Render standard header badges for view/backed/lazy status.
+
+ Parameters
+ ----------
+ is_view
+ Whether this is a view
+ is_backed
+ Whether this is backed by a file
+ is_lazy
+ Whether this uses lazy loading (experimental read_lazy)
+ backing_path
+ Path to the backing file (for tooltip)
+ backing_format
+ Format of the backing file ("H5AD", "Zarr", etc.)
+
+ Returns
+ -------
+ HTML string with badges
+
+ Example
+ -------
+ >>> badges = render_header_badges(
+ ... is_backed=True,
+ ... backing_path="/data/sample.zarr",
+ ... backing_format="Zarr",
+ ... )
+ """
+ parts = []
+ if is_view:
+ parts.append(
+ render_badge(
+ "View", "anndata-badge--view", "This is a view of another object"
+ )
+ )
+ if is_backed:
+ tooltip = f"Backed by {backing_path}" if backing_path else "Backed mode"
+ label = backing_format or "Backed"
+ parts.append(render_badge(label, "anndata-badge--backed", tooltip))
+ if is_lazy:
+ parts.append(
+ render_badge(
+ "Lazy", "anndata-badge--lazy", "Lazy loading (experimental read_lazy)"
+ )
+ )
+ return "".join(parts)
+
+
+def render_name_cell(name: str) -> str:
+ """Render a name cell with copy button and tooltip for truncated names.
+
+ The structure uses flexbox so the copy button stays visible even when
+ the name text overflows and shows ellipsis.
+
+ Parameters
+ ----------
+ name
+ The field name to display
+
+ Returns
+ -------
+ HTML string for the cell div
+ """
+ escaped_name = escape_html(name)
+ return (
+ f''
+ f''
+ f'{escaped_name}'
+ f"{render_copy_button(name, 'Copy name')}"
+ f""
+ f""
+ )
+
+
+def render_category_list(
+ categories: list,
+ colors: list[str] | None,
+ max_cats: int,
+ *,
+ n_hidden: int = 0,
+) -> str:
+ """Render a list of category values with optional color dots.
+
+ Parameters
+ ----------
+ categories
+ List of category values to display
+ colors
+ Optional list of colors matching categories
+ max_cats
+ Maximum number of categories to show
+ n_hidden
+ Number of additional hidden categories (for lazy truncation).
+ These are added to any truncation from max_cats.
+
+ Returns
+ -------
+ HTML string for the category list
+ """
+ parts = ['']
+ for i, cat in enumerate(categories[:max_cats]):
+ if i > 0:
+ parts.append(', ')
+ cat_name = escape_html(str(cat))
+ color = colors[i] if colors and i < len(colors) else None
+ parts.append('')
+ if color:
+ # Sanitize color to prevent CSS injection
+ safe_color = sanitize_css_color(str(color))
+ if safe_color:
+ parts.append(
+ f''
+ )
+ # Skip color dot if color is invalid/unsafe
+ parts.append(f"{cat_name}")
+ parts.append("")
+
+ # Calculate total hidden: from max_cats truncation + lazy truncation
+ hidden_from_max_cats = max(0, len(categories) - max_cats)
+ total_hidden = hidden_from_max_cats + n_hidden
+
+ if total_hidden > 0:
+ parts.append(f'...+{total_hidden}')
+ parts.append("")
+ return "".join(parts)
+
+
+@dataclass
+class TypeCellConfig:
+ """Configuration for rendering a type cell.
+
+ Groups the many parameters of render_entry_type_cell into a single object,
+ making call sites cleaner and easier to understand.
+
+ Attributes
+ ----------
+ type_name
+ The type name to display (e.g., "ndarray (100, 50) float32")
+ css_class
+ CSS class for the type span (e.g., "anndata-dtype--ndarray")
+ type_html
+ Optional custom HTML content for the type cell
+ tooltip
+ Optional tooltip for the type label
+ warnings
+ List of warning messages
+ is_not_serializable
+ Whether the data cannot be serialized to H5AD/Zarr
+ has_columns_list
+ Whether to show columns wrap button
+ has_categories_list
+ Whether to show categories wrap button
+ append_type_html
+ If True, type_html is appended below type_name instead of replacing it
+
+ Examples
+ --------
+ >>> config = TypeCellConfig(
+ ... type_name="ndarray (100, 50) float32",
+ ... css_class="anndata-dtype--ndarray",
+ ... tooltip="Dense array",
+ ... )
+ >>> html = render_entry_type_cell(config)
+
+ With warnings::
+
+ >>> config = TypeCellConfig(
+ ... type_name="object",
+ ... css_class="anndata-dtype--object",
+ ... warnings=["Custom warning"],
+ ... is_not_serializable=True,
+ ... )
+ """
+
+ type_name: str
+ css_class: str
+ type_html: str | None = None
+ tooltip: str = ""
+ warnings: list[str] = field(default_factory=list)
+ is_not_serializable: bool = False
+ has_columns_list: bool = False
+ has_categories_list: bool = False
+ append_type_html: bool = False
+
+
+def render_entry_type_cell(config: TypeCellConfig) -> str:
+ """Render the type cell for an entry row.
+
+ This is a unified helper that handles all type cell variations:
+ - Type label with optional tooltip
+ - Custom type_html (as replacement or appended content)
+ - Warning icon
+ - Expand/wrap buttons
+
+ The type_html and append_type_html config fields control content rendering:
+
+ 1. No type_html: Shows type_name in a styled span
+ ``type_name``
+
+ 2. type_html with append_type_html=False (default): type_html REPLACES type_name
+ Used for fully custom type content (e.g., category swatches instead of text)
+
+ 3. type_html with append_type_html=True: type_html is shown BELOW type_name
+ Used to add extra content while keeping the type label
+ (e.g., showing category list below "categorical" label)
+
+ Parameters
+ ----------
+ config
+ TypeCellConfig object with all rendering options
+
+ Returns
+ -------
+ HTML string for the complete type cell
+
+ Examples
+ --------
+ >>> config = TypeCellConfig(
+ ... type_name="ndarray (100, 50) float32",
+ ... css_class="anndata-dtype--ndarray",
+ ... tooltip="Dense array",
+ ... )
+ >>> html = render_entry_type_cell(config)
+ """
+ type_name = config.type_name
+ css_class = config.css_class
+ type_html = config.type_html
+ tooltip = config.tooltip
+ warnings = config.warnings
+ is_not_serializable = config.is_not_serializable
+ has_columns_list = config.has_columns_list
+ has_categories_list = config.has_categories_list
+ append_type_html = config.append_type_html
+
+ parts = [
+ ''
+ ]
+
+ # Type content: handle different cases
+ if type_html and not append_type_html:
+ # type_html replaces the type label entirely
+ parts.append(type_html)
+ elif tooltip:
+ parts.append(
+ f''
+ f"{escape_html(type_name)}"
+ )
+ else:
+ parts.append(f'{escape_html(type_name)}')
+
+ # Warning icon
+ parts.append(
+ render_warning_icon(warnings or [], is_not_serializable=is_not_serializable)
+ )
+
+ # Wrap buttons
+ if has_columns_list:
+ parts.append(render_columns_wrap_button())
+ if has_categories_list:
+ parts.append(render_categories_wrap_button())
+
+ # Appended type_html (for custom inline rendering below the type)
+ if type_html and append_type_html:
+ parts.append(f'{type_html}')
+
+ parts.append("")
+ return "".join(parts)
+
+
+def render_entry_preview_cell(
+ preview_html: str | None = None,
+ preview_text: str | None = None,
+) -> str:
+ """Render the preview cell (third column) for an entry row.
+
+ Formatters are responsible for producing complete preview content.
+ This function just wraps it in the appropriate cell element.
+
+ Parameters
+ ----------
+ preview_html
+ Raw HTML content for preview (highest priority)
+ preview_text
+ Plain text preview (will be escaped and muted)
+
+ Returns
+ -------
+ HTML string for the preview cell
+ """
+ parts = [
+ ''
+ ]
+
+ if preview_html:
+ parts.append(preview_html)
+ elif preview_text:
+ parts.append(render_muted_span(preview_text))
+
+ parts.append("")
+ return "".join(parts)
diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py
new file mode 100644
index 000000000..3546a0183
--- /dev/null
+++ b/src/anndata/_repr/core.py
@@ -0,0 +1,402 @@
+"""
+Core rendering primitives for AnnData HTML representation.
+
+This module contains shared rendering functions used by both:
+- html.py (main orchestration)
+- sections.py (section-specific renderers)
+
+By extracting these to a separate module, we avoid circular imports
+between html.py and sections.py.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from .._repr_constants import (
+ CSS_DTYPE_CATEGORY,
+ CSS_DTYPE_DATAFRAME,
+ CSS_TEXT_ERROR,
+ CSS_TEXT_MUTED,
+)
+from .components import (
+ TypeCellConfig,
+ render_entry_preview_cell,
+ render_entry_row_open,
+ render_entry_type_cell,
+ render_name_cell,
+ render_nested_content,
+)
+from .registry import formatter_registry
+from .utils import escape_html, format_number
+
+if TYPE_CHECKING:
+ from .registry import FormattedEntry, FormatterContext
+
+
+def render_section( # noqa: PLR0913
+ name: str,
+ entries_html: str,
+ *,
+ n_items: int,
+ doc_url: str | None = None,
+ tooltip: str = "",
+ should_collapse: bool = False,
+ section_id: str | None = None,
+ count_str: str | None = None,
+) -> str:
+ """
+ Render a complete section with header and content.
+
+ This is a public API for packages building their own _repr_html_.
+ It is also used internally for consistency.
+
+ Parameters
+ ----------
+ name
+ Display name for the section header (e.g., 'images', 'tables')
+ entries_html
+ HTML content for the section body (table rows)
+ n_items
+ Number of items (used for empty check and default count string)
+ doc_url
+ URL for the help link (? icon)
+ tooltip
+ Tooltip text for the help link
+ should_collapse
+ Whether this section should start collapsed
+ section_id
+ ID for the section in data-section attribute (defaults to name)
+ count_str
+ Custom count string for header (defaults to "(N items)")
+
+ Returns
+ -------
+ HTML string for the complete section
+
+ Examples
+ --------
+ ::
+
+ from anndata._repr import (
+ CSS_DTYPE_NDARRAY,
+ FormattedEntry,
+ FormattedOutput,
+ render_formatted_entry,
+ render_section,
+ )
+
+ rows = []
+ for key, info in items.items():
+ entry = FormattedEntry(
+ key=key,
+ output=FormattedOutput(
+ type_name=info["type"], css_class=CSS_DTYPE_NDARRAY
+ ),
+ )
+ rows.append(render_formatted_entry(entry))
+
+ html = render_section(
+ "images",
+ "\\n".join(rows),
+ n_items=len(items),
+ doc_url="https://docs.example.com/images",
+ tooltip="Image data",
+ )
+ """
+ if section_id is None:
+ section_id = name
+
+ if n_items == 0:
+ return render_empty_section(name, doc_url, tooltip)
+
+ if count_str is None:
+ count_str = f"({n_items} items)"
+
+ open_attr = " open" if not should_collapse else ""
+ parts = [
+ f''
+ ]
+
+ # Header
+ parts.append(_render_section_header(name, count_str, doc_url, tooltip))
+
+ # Content
+ parts.append('
") # anndata-repr__sections
+
+ # Footer with metadata (only at top level)
+ if depth == 0:
+ parts.append(_render_footer(adata))
+ # Degradation hints: visible only when CSS or JS is missing.
+ # No-CSS hint: visible by default, hidden by CSS.
+ parts.append(
+ '
'
+ "Styled representation available in Jupyter and trusted notebooks "
+ "(colors, search, type highlighting)."
+ "
"
+ )
+ # No-JS hint: hidden by default (no-CSS case already has its own hint),
+ # shown by CSS (for static HTML with styles but no JS),
+ # hidden again by JS on init.
+ parts.append(
+ '
'
+ "Interactive features (search, copy, category wrapping) "
+ "require JavaScript. Trust this notebook to enable them."
+ "
"
+ )
+
+ parts.append("
") # anndata-repr
+
+ # JavaScript (only at top level)
+ if depth == 0:
+ parts.append(get_javascript(container_id))
+
+ return "\n".join(parts)
+
+
+def _render_all_sections(
+ adata: AnnData,
+ context: FormatterContext,
+) -> list[str]:
+ """Render all standard and custom sections."""
+ parts: list[str] = []
+ custom_sections_after = _get_custom_sections_by_position(adata)
+
+ for section in get_literal_members(AnnDataElem):
+ parts.append(_render_section(adata, section, context))
+
+ # Render custom sections after this section
+ if section in custom_sections_after:
+ parts.extend(
+ _render_custom_section(adata, section_formatter, context)
+ for section_formatter in custom_sections_after[section]
+ )
+
+ # Custom sections at end (no specific position)
+ if None in custom_sections_after:
+ parts.extend(
+ _render_custom_section(adata, section_formatter, context)
+ for section_formatter in custom_sections_after[None]
+ )
+
+ # Detect and show unknown sections (attributes not in AnnDataElem)
+ unknown_sections = _detect_unknown_sections(adata)
+ if unknown_sections:
+ parts.append(_render_unknown_sections(unknown_sections))
+
+ return parts
+
+
+def _render_section(
+ adata: AnnData,
+ section: str,
+ context: FormatterContext,
+) -> str:
+ """Render a single standard section.
+
+ Attribute access happens inside the try/except so a broken section (one
+ whose ``getattr`` raises — e.g. a corrupt aligned mapping or a subclass
+ with a crashing property) renders as an error placeholder instead of
+ aborting the whole repr. This is why we iterate section names directly
+ via ``get_literal_members(AnnDataElem)`` rather than delegating to
+ ``iter_outer``, which propagates the first exception it hits.
+ """
+ try:
+ if section == "X":
+ return render_x_entry(adata, context)
+ elem = getattr(adata, section)
+ if section == "raw":
+ return _render_raw_section(elem, context)
+ if section in ("obs", "var"):
+ return _render_dataframe_section(section, elem, context)
+ if section == "uns":
+ return _render_uns_section(elem, context)
+ return _render_mapping_section(section, elem, context)
+ except Exception as e: # noqa: BLE001
+ # Show error instead of hiding the section
+ return _render_error_entry(section, f"{type(e).__name__}: {e}")
+
+
+def _get_custom_sections_by_position(
+ adata: object,
+) -> dict[str | None, list[SectionFormatter]]:
+ """
+ Get registered custom section formatters grouped by their position.
+
+ Returns a dict mapping after_section -> list of formatters.
+ None key contains formatters that should appear at the end.
+ """
+ from collections import defaultdict
+
+ result = defaultdict(list)
+ standard_section_names = set(get_literal_members(AnnDataElem))
+
+ for section_name in formatter_registry.get_registered_sections():
+ formatter = formatter_registry.get_section_formatter(section_name)
+ if formatter is None:
+ continue
+
+ # Skip standard sections (they're handled separately)
+ if section_name in standard_section_names:
+ continue
+
+ # Check if this section should be shown for this object
+ try:
+ if not formatter.should_show(adata):
+ continue
+ except Exception: # noqa: BLE001
+ # Intentional broad catch: custom formatters shouldn't break the repr
+ continue
+
+ # Group by position
+ after = getattr(formatter, "after_section", None)
+ result[after].append(formatter)
+
+ return dict(result)
+
+
+def _render_custom_section(
+ adata: AnnData,
+ formatter: SectionFormatter,
+ context: FormatterContext,
+) -> str:
+ """Render a custom section using its registered formatter.
+
+ If the formatter defines ``render_html(obj, context)``, it is tried
+ first and the result is used as-is (no ```` wrapping).
+ If ``render_html`` fails, falls back to the standard ``get_entries``
+ path so formatters can provide both an enhanced and a safe representation.
+ """
+ # Allow formatters to produce raw HTML (e.g., compact inline rows)
+ if hasattr(formatter, "render_html"):
+ try:
+ return formatter.render_html(adata, context)
+ except Exception as e: # noqa: BLE001
+ from .._warnings import warn
+
+ warn(
+ f"Custom section formatter '{formatter.section_name}' render_html failed, "
+ f"falling back to get_entries: {e}",
+ UserWarning,
+ )
+ # Fall through to get_entries below
+
+ try:
+ entries = formatter.get_entries(adata, context)
+ except Exception as e: # noqa: BLE001
+ # Intentional broad catch: custom formatters shouldn't crash the entire repr
+ from .._warnings import warn
+
+ warn(
+ f"Custom section formatter '{formatter.section_name}' failed: {e}",
+ UserWarning,
+ )
+ return ""
+
+ if not entries:
+ return ""
+
+ n_items = len(entries)
+ section_name = formatter.section_name
+
+ # Render entries (with truncation)
+ rows = []
+ for i, entry in enumerate(entries):
+ if i >= context.max_items:
+ rows.append(render_truncation_indicator(n_items - context.max_items))
+ break
+ rows.append(render_formatted_entry(entry, section_name))
+
+ # Use render_section for consistent structure
+ return render_section(
+ getattr(formatter, "display_name", section_name),
+ "\n".join(rows),
+ n_items=n_items,
+ doc_url=getattr(formatter, "doc_url", None),
+ tooltip=getattr(formatter, "tooltip", ""),
+ should_collapse=n_items > context.fold_threshold,
+ section_id=section_name,
+ )
+
+
+def _render_header(
+ adata: AnnData, *, show_search: bool = False, container_id: str = ""
+) -> str:
+ """Render the header with type, shape, badges, and optional search box."""
+ parts = ['
']
+
+ # Type name - allow for extension types
+ type_name = type(adata).__name__
+ parts.append(f'{escape_html(type_name)}')
+
+ # Shape
+ shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars"
+ parts.append(f'{shape_str}')
+
+ # Badges - use render_badge() helper
+ if is_view(adata):
+ parts.append(render_badge("View", CSS_BADGE_VIEW))
+
+ if is_backed(adata):
+ backing = get_backing_info(adata)
+ filename = backing.get("filename", "")
+ format_str = backing.get("format", "")
+ status = "Open" if backing.get("is_open") else "Closed"
+ parts.append(render_badge(f"{format_str} ({status})", CSS_BADGE_BACKED))
+ # Inline file path (full path, no truncation)
+ if filename:
+ parts.append(
+ f'{escape_html(filename)}'
+ )
+
+ if is_lazy_adata(adata):
+ lazy_info = get_lazy_backing_info(adata)
+ lazy_format = lazy_info.get("format", "")
+ if lazy_format:
+ parts.append(render_badge(f"Lazy ({lazy_format})", CSS_BADGE_LAZY))
+ else:
+ parts.append(render_badge("Lazy", CSS_BADGE_LAZY))
+ # Show file path for lazy AnnData (similar to backed)
+ lazy_filename = lazy_info.get("filename", "")
+ if lazy_filename:
+ path_style = (
+ "font-family:ui-monospace,monospace;font-size:11px;"
+ "color:var(--anndata-text-secondary, #6c757d);"
+ )
+ parts.append(
+ f''
+ f"{escape_html(lazy_filename)}"
+ f""
+ )
+
+ # Check for extension type (not standard AnnData)
+ if type_name != "AnnData":
+ parts.append(render_badge(type_name, CSS_BADGE_EXTENSION))
+
+ # README icon if uns["README"] exists with a string
+ readme_content = adata.uns.get("README") if hasattr(adata, "uns") else None
+ if isinstance(readme_content, str) and readme_content.strip():
+ # Check max README size setting (0 means no limit)
+ max_readme_size = get_setting(
+ "repr_html_max_readme_size", default=DEFAULT_MAX_README_SIZE
+ )
+ original_len = len(readme_content)
+ if max_readme_size > 0 and original_len > max_readme_size:
+ # Truncate and add note
+ readme_content = readme_content[:max_readme_size]
+ truncation_note = (
+ f"\n\n---\n*README truncated: showing {max_readme_size:,} of "
+ f"{original_len:,} characters*"
+ )
+ readme_content += truncation_note
+
+ escaped_readme = escape_html(readme_content)
+ # Truncate for no-JS tooltip (first 500 chars)
+ tooltip_text = readme_content[:TOOLTIP_TRUNCATE_LENGTH]
+ if len(readme_content) > TOOLTIP_TRUNCATE_LENGTH:
+ tooltip_text += "..."
+ escaped_tooltip = escape_html(tooltip_text)
+
+ parts.append(
+ f''
+ f"ⓘ"
+ f""
+ )
+
+ # Search box on the right (spacer pushes it right) - use render_search_box() helper
+ if show_search:
+ parts.append('')
+ parts.append(render_search_box(container_id))
+
+ parts.append("
")
+ return "\n".join(parts)
+
+
+def _render_footer(adata: AnnData) -> str:
+ """Render the footer with version and memory info."""
+ parts = ['")
+ return "\n".join(parts)
+
+
+def _render_index_preview(adata: AnnData) -> str:
+ """Render preview of obs_names and var_names."""
+ parts = ['
")
+ parts.append("")
+ parts.append("")
+
+ return "\n".join(parts)
+
+
+def _render_error_entry(section: str, error: str) -> str:
+ """Render an error indicator for a section that failed to render."""
+ error_str = str(error)
+ if len(error_str) > ERROR_TRUNCATE_LENGTH:
+ error_str = error_str[:ERROR_TRUNCATE_LENGTH] + "..."
+ error_escaped = escape_html(error_str)
+ return f"""
+
+
+ {escape_html(section)}
+ (error)
+
+
+
+ Failed to render: {error_escaped}
+
+
+
+"""
+
+
+# -----------------------------------------------------------------------------
+# Raw Section
+# -----------------------------------------------------------------------------
+
+
+def _safe_get_attr(obj: object, attr: str, default: object = "?") -> object:
+ """Safely get an attribute with fallback.
+
+ Parameters
+ ----------
+ obj
+ Object to get attribute from
+ attr
+ Attribute name
+ default
+ Default value if attribute is missing or access raises exception
+
+ Returns
+ -------
+ Attribute value or default
+ """
+ try:
+ val = getattr(obj, attr, None)
+ return val if val is not None else default
+ except Exception: # noqa: BLE001
+ return default
+
+
+def _get_raw_meta_parts(raw: object) -> list[str]:
+ """Build meta info parts for raw section.
+
+ Parameters
+ ----------
+ raw
+ Raw object to extract metadata from
+
+ Returns
+ -------
+ List of metadata strings like ["var: 5 cols", "varm: 2"]
+ """
+ meta_parts = []
+ try:
+ if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0:
+ meta_parts.append(f"var: {len(raw.var.columns)} cols")
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0:
+ meta_parts.append(f"varm: {len(raw.varm)}")
+ except Exception: # noqa: BLE001
+ pass
+ return meta_parts
+
+
+def _render_raw_section(
+ raw: object,
+ context: FormatterContext,
+) -> str:
+ """Render the raw section as a single expandable row.
+
+ The raw section shows unprocessed data that was saved before filtering/normalization.
+ It contains raw.X (the matrix), raw.var (variable annotations), and raw.varm
+ (multi-dimensional variable annotations).
+
+ Unlike the main AnnData, raw shares obs with the parent but has its own var
+ (which may have more variables than the filtered main data).
+
+ Rendered as a single row with an expand button (no section header).
+ When expanded, shows a full AnnData-like repr for Raw contents (X, var, varm).
+ The depth parameter prevents infinite recursion.
+ """
+ if raw is None:
+ return ""
+
+ # Safely get dimensions with fallbacks
+ n_obs = _safe_get_attr(raw, "n_obs", "?")
+ n_vars = _safe_get_attr(raw, "n_vars", "?")
+
+ # Check if we can expand (same logic as nested AnnData)
+ can_expand = context.depth < context.max_depth - 1
+
+ # Build meta info string safely
+ meta_parts = _get_raw_meta_parts(raw)
+ meta_text = ", ".join(meta_parts) if meta_parts else ""
+
+ # Single row container (like a minimal section with just one entry)
+ parts = ['
']
+ parts.append('
')
+
+ # Single row with raw info
+ type_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var"
+ parts.append(render_entry_row_open("raw", "Raw", has_expandable_content=can_expand))
+ parts.append(render_name_cell("raw"))
+ type_cell_config = TypeCellConfig(
+ type_name=type_str,
+ css_class=CSS_DTYPE_ANNDATA,
+ )
+ parts.append(render_entry_type_cell(type_cell_config))
+ parts.append(render_entry_preview_cell(preview_text=meta_text))
+
+ # Nested content (entry is / when can_expand)
+ if can_expand:
+ nested_html = _generate_raw_repr_html(raw, context.child("raw"))
+ # Wrap in anndata-entry__nested-anndata for specific styling
+ wrapped_html = f'
")
+
+ # X section - show matrix info (with error handling)
+ try:
+ if hasattr(raw, "X") and raw.X is not None:
+ parts.append(render_x_entry(raw, context))
+ except Exception as e: # noqa: BLE001
+ parts.append(_render_error_entry("X", str(e)))
+
+ # var section (like AnnData's var)
+ try:
+ if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0:
+ # Raw doesn't have the same structure as AnnData, so clear adata_ref
+ var_context = replace(context, adata_ref=None, section="var")
+ parts.append(_render_dataframe_section("var", raw.var, var_context))
+ except Exception as e: # noqa: BLE001
+ parts.append(_render_error_entry("var", str(e)))
+
+ # varm section (like AnnData's varm)
+ try:
+ if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0:
+ varm_context = replace(context, adata_ref=None, section="varm")
+ parts.append(_render_mapping_section("varm", raw.varm, varm_context))
+ except Exception as e: # noqa: BLE001
+ parts.append(_render_error_entry("varm", str(e)))
+
+ parts.append("
")
+
+ return "\n".join(parts)
diff --git a/src/anndata/_repr/static/__init__.py b/src/anndata/_repr/static/__init__.py
new file mode 100644
index 000000000..c4da67a5e
--- /dev/null
+++ b/src/anndata/_repr/static/__init__.py
@@ -0,0 +1 @@
+"""Static assets for AnnData HTML representation."""
diff --git a/src/anndata/_repr/static/css_colors.txt b/src/anndata/_repr/static/css_colors.txt
new file mode 100644
index 000000000..6be2db7bf
--- /dev/null
+++ b/src/anndata/_repr/static/css_colors.txt
@@ -0,0 +1,197 @@
+# CSS3 Named Colors for AnnData HTML Representation
+# ==================================================
+#
+# This file contains CSS named colors used to detect color lists in AnnData
+# .uns entries (e.g., cluster_colors, batch_colors). When a key ends with
+# "_colors" and contains values from this list (or hex/rgb values), it's
+# rendered with color swatches in the HTML repr.
+#
+# Source: CSS Color Module Level 3 (W3C Recommendation)
+# https://www.w3.org/TR/css-color-3/#svg-color
+#
+# Relation to Matplotlib
+# ----------------------
+# Matplotlib's CSS4_COLORS dictionary contains the same 148 colors (147 CSS3
+# + rebeccapurple from CSS4). Scanpy and other tools use matplotlib to generate
+# colors stored in adata.uns["{column}_colors"]. These are typically:
+#
+# - Hex colors: "#1f77b4", "#ff7f0e" (from matplotlib's default palettes)
+# - Named colors: "red", "blue", "cornflowerblue" (from CSS/matplotlib)
+#
+# This file ensures named colors are recognized WITHOUT requiring matplotlib
+# as a dependency. Hex and rgb() colors are always recognized regardless of
+# this file.
+#
+# Format
+# ------
+# - One color name per line, lowercase
+# - Lines starting with # are comments (ignored)
+# - Empty lines are ignored
+#
+# How to Update
+# -------------
+# Option 1: From W3C specification
+# Visit https://www.w3.org/TR/css-color-3/#svg-color
+#
+# Option 2: From MDN (more readable)
+# Visit https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
+#
+# Option 3: From matplotlib (if installed)
+# python -c "from matplotlib.colors import CSS4_COLORS; print('\n'.join(sorted(CSS4_COLORS.keys())))"
+#
+# After updating, run: pytest tests/repr/ -x -q
+#
+# Note: Hex (#RGB, #RRGGBB, #RRGGBBAA) and functional (rgb(), rgba()) color
+# formats are always recognized and don't need to be listed here.
+
+# Basic colors (HTML 4.01 / CSS 1)
+black
+white
+gray
+grey
+silver
+red
+green
+blue
+yellow
+cyan
+magenta
+maroon
+navy
+olive
+purple
+teal
+aqua
+lime
+fuchsia
+orange
+
+# Extended CSS3 colors (sorted alphabetically)
+aliceblue
+antiquewhite
+aquamarine
+azure
+beige
+bisque
+blanchedalmond
+blueviolet
+brown
+burlywood
+cadetblue
+chartreuse
+chocolate
+coral
+cornflowerblue
+cornsilk
+crimson
+darkblue
+darkcyan
+darkgoldenrod
+darkgray
+darkgrey
+darkgreen
+darkkhaki
+darkmagenta
+darkolivegreen
+darkorange
+darkorchid
+darkred
+darksalmon
+darkseagreen
+darkslateblue
+darkslategray
+darkslategrey
+darkturquoise
+darkviolet
+deeppink
+deepskyblue
+dimgray
+dimgrey
+dodgerblue
+firebrick
+floralwhite
+forestgreen
+gainsboro
+ghostwhite
+gold
+goldenrod
+greenyellow
+honeydew
+hotpink
+indianred
+indigo
+ivory
+khaki
+lavender
+lavenderblush
+lawngreen
+lemonchiffon
+lightblue
+lightcoral
+lightcyan
+lightgoldenrodyellow
+lightgray
+lightgrey
+lightgreen
+lightpink
+lightsalmon
+lightseagreen
+lightskyblue
+lightslategray
+lightslategrey
+lightsteelblue
+lightyellow
+limegreen
+linen
+mediumaquamarine
+mediumblue
+mediumorchid
+mediumpurple
+mediumseagreen
+mediumslateblue
+mediumspringgreen
+mediumturquoise
+mediumvioletred
+midnightblue
+mintcream
+mistyrose
+moccasin
+navajowhite
+oldlace
+olivedrab
+orangered
+orchid
+palegoldenrod
+palegreen
+paleturquoise
+palevioletred
+papayawhip
+peachpuff
+peru
+pink
+plum
+powderblue
+rebeccapurple
+rosybrown
+royalblue
+saddlebrown
+salmon
+sandybrown
+seagreen
+seashell
+sienna
+skyblue
+slateblue
+slategray
+slategrey
+snow
+springgreen
+steelblue
+tan
+thistle
+tomato
+turquoise
+violet
+wheat
+whitesmoke
+yellowgreen
diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css
new file mode 100644
index 000000000..4c49bb19c
--- /dev/null
+++ b/src/anndata/_repr/static/repr.css
@@ -0,0 +1,1097 @@
+/* AnnData HTML Representation Styles */
+/* Scoped to .anndata-repr to avoid conflicts */
+/* Uses native CSS nesting (Chrome 120+, Firefox 117+, Safari 17.2+) */
+/* Uses subgrid (Chrome 117+, Firefox 71+, Safari 16+) */
+/* Uses light-dark() (Chrome 123+, Firefox 120+, Safari 17.5+) — the bottleneck */
+
+.anndata-repr {
+ /* Hide the no-CSS hint when styles are loaded */
+ .anndata-repr__hint-nocss {
+ display: none;
+ }
+
+ /* Show the no-JS hint only when CSS loads but JS hasn't run.
+ JS adds .anndata-repr--js on init, so this rule only matches
+ in CSS-but-no-JS environments (e.g., nbconvert static HTML).
+ The hint is hidden by default (inline display:none) for the no-CSS case. */
+ &:not(.anndata-repr--js) .anndata-repr__hint-nojs {
+ display: block !important;
+ padding: 4px 12px;
+ font-size: 11px;
+ color: var(--anndata-text-muted);
+ }
+
+ /* Opt into light-dark(): responds to used color scheme, defaulting to light */
+ color-scheme: light dark;
+
+ /* --- Theme overrides ---
+ When an app explicitly sets a theme, override the color scheme so
+ light-dark() picks the correct value regardless of OS preference. */
+
+ body.light-mode &,
+ [data-theme="light"] &,
+ html[data-theme="light"] &,
+ [data-jp-theme-light="true"] &,
+ .jp-Theme-Light &,
+ body.vscode-light &,
+ body[data-vscode-theme-kind="vscode-light"] & {
+ color-scheme: light;
+ }
+
+ body.dark-mode &,
+ [data-theme="dark"] &,
+ html[data-theme="dark"] &,
+ [data-jp-theme-light="false"] &,
+ .jp-Theme-Dark &,
+ body.vscode-dark &,
+ body[data-vscode-theme-kind="vscode-dark"] & {
+ color-scheme: dark;
+ }
+
+ /* CSS Variables — each defined once via light-dark(light, dark) */
+ --anndata-bg-primary: light-dark(#ffffff, #1e1e1e);
+ --anndata-bg-secondary: light-dark(#f8f9fa, #252526);
+ --anndata-bg-tertiary: light-dark(#e9ecef, #2d2d2d);
+ --anndata-highlight: light-dark(#e7f1ff, #264f78);
+ --anndata-text-primary: light-dark(#212529, #e0e0e0);
+ --anndata-text-secondary: light-dark(#6c757d, #a0a0a0);
+ --anndata-text-muted: light-dark(#adb5bd, #707070);
+ --anndata-border-color: light-dark(#dee2e6, #404040);
+ --anndata-border-light: light-dark(#e9ecef, #333333);
+ --anndata-accent-color: light-dark(#0d6efd, #58a6ff);
+ --anndata-warning-color: light-dark(#ffc107, #d29922);
+ --anndata-warning-bg: light-dark(#fff3cd, #3d3200);
+ --anndata-error-color: light-dark(#dc3545, #f85149);
+ --anndata-error-bg: light-dark(#f8d7da, #3d1a1a);
+ --anndata-success-color: light-dark(#198754, #3fb950);
+ --anndata-info-color: light-dark(#0dcaf0, #58a6ff);
+ --anndata-link-color: light-dark(#0d6efd, #58a6ff);
+ --anndata-code-bg: light-dark(#f8f9fa, #2d2d2d);
+ --anndata-radius: 4px;
+ --anndata-font-mono:
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ --anndata-font-size: 13px;
+ --anndata-line-height: 1.4;
+ /* Dtype colors */
+ --anndata-dtype-category: light-dark(#8250df, #d2a8ff);
+ --anndata-dtype-int: light-dark(#0550ae, #79c0ff);
+ --anndata-dtype-float: light-dark(#0550ae, #79c0ff);
+ --anndata-dtype-bool: light-dark(#cf222e, #ff7b72);
+ --anndata-dtype-string: light-dark(#0a3069, #a5d6ff);
+ --anndata-dtype-object: light-dark(#6e7781, #a0a0a0);
+ --anndata-dtype-sparse: light-dark(#1a7f37, #7ee787);
+ --anndata-dtype-array: light-dark(#0550ae, #79c0ff);
+ --anndata-dtype-dataframe: light-dark(#8250df, #d2a8ff);
+ --anndata-dtype-anndata: light-dark(#cf222e, #ff7b72);
+ --anndata-dtype-unknown: light-dark(#6e7781, #a0a0a0);
+ --anndata-dtype-extension: light-dark(#8250df, #d2a8ff);
+ --anndata-dtype-dask: light-dark(#fb8500, #ffc168);
+ --anndata-dtype-gpu: light-dark(#76b900, #a0db63);
+ --anndata-dtype-tpu: light-dark(#0891b2, #67e8f9);
+ --anndata-dtype-awkward: light-dark(#e85d04, #ff9d76);
+ --anndata-dtype-array-api: light-dark(#9a6700, #e6c400);
+ /* Column widths are set dynamically via inline style on container */
+
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
+ sans-serif;
+ font-size: var(--anndata-font-size);
+ line-height: var(--anndata-line-height);
+ color: var(--anndata-text-primary);
+ background: var(--anndata-bg-primary);
+ border: 1px solid var(--anndata-border-color);
+ border-radius: var(--anndata-radius);
+ padding: 0;
+ margin: 8px 0;
+ max-width: 100%;
+ overflow: hidden;
+
+ /* --- JS-enabled overrides --- */
+
+ &.anndata-repr--js {
+ .anndata-entry__preview {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .anndata-categories {
+ display: inline;
+ white-space: nowrap;
+ word-break: normal;
+
+ &.anndata-categories--wrapped {
+ display: inline;
+ max-width: none;
+ white-space: normal;
+ word-break: break-word;
+ overflow: visible;
+ text-overflow: clip;
+ }
+ }
+
+ .anndata-columns {
+ display: inline;
+ white-space: nowrap;
+ word-break: normal;
+
+ &.anndata-columns--wrapped {
+ display: inline;
+ max-width: none;
+ white-space: normal;
+ word-break: break-word;
+ overflow: visible;
+ text-overflow: clip;
+ }
+ }
+
+ /* Allow preview cell to expand when wrap button is toggled */
+ .anndata-entry__preview.anndata-entry--expanded {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ }
+
+ /* Wrap buttons: visibility is managed by JS (updateWrapButtonVisibility).
+ JS sets inline style.display based on overflow detection.
+ No CSS override needed here — just ensure they're not display:none
+ from the base rule, so JS can take over. */
+ }
+
+ /* --- Header --- */
+
+ .anndata-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--anndata-bg-secondary, #f8f9fa);
+ border-bottom: 1px solid var(--anndata-border-color, #dee2e6);
+ }
+
+ .anndata-header__type {
+ font-weight: 600;
+ font-size: 14px;
+ color: var(--anndata-text-primary, #212529);
+ }
+
+ .anndata-header__shape {
+ font-family: var(
+ --anndata-font-mono,
+ ui-monospace,
+ SFMono-Regular,
+ "SF Mono",
+ Menlo,
+ Consolas,
+ monospace
+ );
+ font-size: 12px;
+ color: var(--anndata-text-secondary, #6c757d);
+ }
+
+ .anndata-header__index {
+ padding: 8px 12px;
+ font-size: 11px;
+ font-family: var(
+ --anndata-font-mono,
+ ui-monospace,
+ SFMono-Regular,
+ "SF Mono",
+ Menlo,
+ Consolas,
+ monospace
+ );
+ color: var(--anndata-text-secondary, #6c757d);
+ background: var(--anndata-bg-primary, #ffffff);
+ border-bottom: 1px solid var(--anndata-border-light, #e9ecef);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ strong {
+ color: var(--anndata-text-primary, #212529);
+ font-weight: 500;
+ }
+ }
+
+ /* --- Badges --- */
+
+ .anndata-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ border-radius: 10px;
+ white-space: nowrap;
+ }
+
+ /* Badge modifiers must be siblings, not nested children.
+ In CSS nesting, &--modifier inside a doubly-nested rule produces
+ :is(parent child)--modifier which is an invalid selector. */
+ .anndata-badge--view {
+ background: var(--anndata-info-color);
+ color: white;
+ }
+
+ .anndata-badge--backed {
+ background: var(--anndata-success-color);
+ color: white;
+ }
+
+ .anndata-badge--lazy {
+ background: var(--anndata-warning-color);
+ color: white;
+ }
+
+ .anndata-badge--extension {
+ background: var(--anndata-accent-color);
+ color: white;
+ }
+
+ /* --- README --- */
+
+ .anndata-readme__icon {
+ cursor: pointer;
+ font-size: 14px;
+ opacity: 0.7;
+ transition: opacity 0.15s;
+ margin-left: 4px;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ .anndata-readme__overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 20px;
+ }
+
+ .anndata-readme__modal {
+ background: var(--anndata-bg-primary);
+ border: 1px solid var(--anndata-border-color);
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
+ max-width: 700px;
+ max-height: 80vh;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+
+ .anndata-readme__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--anndata-border-color);
+ background: var(--anndata-bg-secondary);
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--anndata-text-primary);
+ }
+ }
+
+ .anndata-readme__close {
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+ color: var(--anndata-text-secondary);
+ padding: 0 4px;
+ line-height: 1;
+
+ &:hover {
+ color: var(--anndata-text-primary);
+ }
+ }
+
+ .anndata-readme__content {
+ padding: 16px;
+ overflow-y: auto;
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--anndata-text-primary);
+
+ pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ font-family: var(--anndata-font-mono);
+ font-size: 0.9em;
+ line-height: 1.5;
+ }
+ }
+
+ /* --- Search box --- */
+
+ .anndata-search__box {
+ display: inline-flex;
+ align-items: center;
+ max-width: 300px;
+ border: 1px solid var(--anndata-border-color);
+ border-radius: var(--anndata-radius);
+ background: var(--anndata-bg-primary);
+ transition: border-color 0.15s;
+
+ &:focus-within {
+ border-color: var(--anndata-accent-color);
+ }
+
+ &.anndata-search__box--error {
+ border-color: #dc3545;
+
+ .anndata-search__input {
+ background: rgba(220, 53, 69, 0.05);
+ }
+ }
+ }
+
+ .anndata-search__input {
+ flex: 1;
+ min-width: 120px;
+ padding: 6px 8px;
+ font-size: 12px;
+ border: none;
+ background: transparent;
+ color: var(--anndata-text-primary);
+ outline: none;
+
+ &::placeholder {
+ color: var(--anndata-text-muted);
+ }
+ }
+
+ .anndata-search__indicator {
+ display: none;
+ margin-left: 8px;
+ font-size: 11px;
+ color: var(--anndata-accent-color);
+
+ /* JS toggles .anndata--active (not BEM --active modifier) */
+ &.anndata--active {
+ display: inline;
+ }
+ }
+
+ .anndata-search__toggles {
+ display: flex;
+ gap: 1px;
+ padding-right: 4px;
+ border-left: 1px solid var(--anndata-border-light);
+ margin-left: 4px;
+ padding-left: 4px;
+ }
+
+ .anndata-search__toggle {
+ display: none; /* Hidden until JS enables */
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--anndata-text-muted);
+ font-size: 10px;
+ font-family: var(--anndata-font-mono);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s;
+
+ &:hover {
+ background: var(--anndata-bg-secondary);
+ color: var(--anndata-text-primary);
+ }
+
+ /* JS toggles .anndata--active (not BEM --active modifier) */
+ &.anndata--active {
+ background: var(--anndata-accent-color);
+ color: white;
+ }
+ }
+
+ /* --- Sections --- */
+
+ .anndata-section {
+ border-bottom: 1px solid var(--anndata-border-light, #e9ecef);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ .anndata-section > summary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ user-select: none;
+ background: var(--anndata-bg-primary, #ffffff);
+ transition: background-color 0.15s;
+ list-style: none;
+
+ &::-webkit-details-marker {
+ display: none;
+ }
+
+ &::before {
+ content: "\25BC"; /* ▼ */
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ font-size: 10px;
+ color: var(--anndata-text-muted, #adb5bd);
+ transition: transform 0.15s;
+ transform-origin: center;
+ flex-shrink: 0;
+ }
+
+ &:hover {
+ background: var(--anndata-bg-secondary, #f8f9fa);
+ }
+ }
+
+ .anndata-section:not([open]) > summary::before {
+ transform: rotate(-90deg);
+ }
+
+ .anndata-section__name {
+ font-weight: 600;
+ color: var(--anndata-text-primary, #212529);
+ }
+
+ .anndata-section__count {
+ font-size: 11px;
+ color: var(--anndata-text-secondary, #6c757d);
+ }
+
+ .anndata-section__help {
+ margin-left: auto;
+ padding: 2px 6px;
+ font-size: 11px;
+ color: var(--anndata-text-muted, #adb5bd);
+ text-decoration: none;
+ border-radius: var(--anndata-radius, 4px);
+ transition:
+ color 0.15s,
+ background-color 0.15s;
+
+ &:hover {
+ color: var(--anndata-accent-color, #0d6efd);
+ background: var(--anndata-bg-tertiary, #e9ecef);
+ }
+ }
+
+ .anndata-section__content {
+ padding: 0;
+ overflow: hidden;
+ }
+
+ .anndata-section__entries {
+ display: grid;
+ grid-template-columns: var(--anndata-name-col-width, 150px) var(
+ --anndata-type-col-width,
+ 180px
+ ) 1fr;
+ font-size: 12px;
+ width: 100%;
+ }
+
+ .anndata-section__truncated {
+ grid-column: 1 / -1;
+ padding: 8px 12px;
+ font-size: 11px;
+ color: var(--anndata-text-muted);
+ text-align: center;
+ font-style: italic;
+ }
+
+ .anndata-section__empty {
+ padding: 8px 12px;
+ font-size: 11px;
+ color: var(--anndata-text-muted);
+ font-style: italic;
+ }
+
+ /* --- Entries --- */
+
+ .anndata-entry {
+ grid-column: 1 / -1;
+ transition: background-color 0.1s;
+
+ &:nth-child(even of .anndata-entry:not(.anndata-entry--hidden)) {
+ background: var(--anndata-bg-secondary);
+ }
+
+ &:hover {
+ background: var(--anndata-bg-tertiary, #e9ecef);
+ }
+
+ &.anndata-entry--hidden {
+ display: none;
+ }
+
+ &.warning {
+ background: var(--anndata-warning-bg) !important;
+ }
+
+ &.error {
+ background: var(--anndata-error-bg) !important;
+ }
+ }
+
+ /* Copy button visibility on hover.
+ Using `.anndata-entry:hover` would propagate from any nested child row
+ up through every ancestor entry, revealing every ancestor's copy button
+ when a deeply-nested row is hovered. Scope the trigger to:
+ - the entry itself for plain (non-expandable) rows, and
+ - the for expandable rows, which doesn't contain nested
+ child entries (those live in `.anndata-entry__nested-content`). */
+ div.anndata-entry:hover > .anndata-entry__name .anndata-entry__copy,
+ details.anndata-entry
+ > summary.anndata-entry__summary:hover
+ .anndata-entry__copy {
+ opacity: 1;
+ }
+
+ /* Regular (non-expandable) entries use subgrid for column alignment */
+ div.anndata-entry {
+ display: grid;
+ grid-template-columns: subgrid;
+ }
+
+ /* Expandable entry summary — uses explicit columns (not subgrid)
+ so nested content below is a normal block child at full width */
+ details.anndata-entry > summary.anndata-entry__summary {
+ display: grid;
+ grid-template-columns: var(--anndata-name-col-width, 150px) var(
+ --anndata-type-col-width,
+ 180px
+ ) 1fr;
+ list-style: none;
+ cursor: pointer;
+ position: relative;
+
+ &::-webkit-details-marker {
+ display: none;
+ }
+
+ /* Expand indicator arrow */
+ &::after {
+ content: "\25B6"; /* ▶ */
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 8px;
+ color: var(--anndata-accent-color);
+ transition: transform 0.15s;
+ }
+ }
+
+ /* When open, rotate the arrow */
+ details.anndata-entry[open] > summary.anndata-entry__summary::after {
+ transform: translateY(-50%) rotate(90deg);
+ }
+
+ /* Reserve space for the expand indicator */
+ details.anndata-entry > summary.anndata-entry__summary
+ > .anndata-entry__preview {
+ padding-right: 24px;
+ }
+
+ /* Reset inline fallback styles on entry cells — grid controls sizing */
+ .anndata-entry__name,
+ .anndata-entry__type,
+ .anndata-entry__preview {
+ display: block !important;
+ min-width: 0 !important;
+ vertical-align: baseline !important;
+ }
+
+ .anndata-entry__name {
+ font-family: var(
+ --anndata-font-mono,
+ ui-monospace,
+ SFMono-Regular,
+ "SF Mono",
+ Menlo,
+ Consolas,
+ monospace
+ );
+ font-size: var(--anndata-font-size, 13px);
+ font-weight: 500;
+ color: var(--anndata-text-primary, #212529);
+ white-space: nowrap;
+ text-align: left;
+ padding: 6px 12px;
+ align-self: center;
+ }
+
+ .anndata-entry__name-inner {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0; /* Allow flex child to shrink below content size */
+ }
+
+ .anndata-entry__name-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0; /* Allow text to shrink and show ellipsis */
+ font-size: inherit; /* Prevent mobile browsers from auto-sizing truncated text */
+ }
+
+ .anndata-entry__type {
+ font-family: var(
+ --anndata-font-mono,
+ ui-monospace,
+ SFMono-Regular,
+ "SF Mono",
+ Menlo,
+ Consolas,
+ monospace
+ );
+ font-size: 11px;
+ color: var(--anndata-text-secondary, #6c757d);
+ text-align: left;
+ white-space: nowrap;
+ padding: 6px 12px;
+ align-self: center;
+ }
+
+ .anndata-entry__preview {
+ font-size: 11px;
+ color: var(--anndata-text-muted, #adb5bd);
+ text-align: right;
+ /* Default: allow wrapping for graceful no-JS degradation */
+ white-space: normal;
+ word-break: break-word;
+ padding: 6px 12px;
+ align-self: center;
+ min-width: 0;
+ }
+
+ /* Copy button */
+ .anndata-entry__copy {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 2px;
+ cursor: pointer;
+ opacity: 0;
+ transition:
+ opacity 0.15s,
+ background-color 0.15s;
+ flex-shrink: 0;
+ position: relative;
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ width: 7px;
+ height: 8px;
+ border: 1.5px solid var(--anndata-text-muted);
+ border-radius: 1px;
+ background: var(--anndata-bg-primary);
+ }
+
+ &::before {
+ top: 2px;
+ left: 2px;
+ }
+
+ &::after {
+ top: 5px;
+ left: 5px;
+ }
+
+ &:hover {
+ &::before,
+ &::after {
+ border-color: var(--anndata-accent-color);
+ }
+ }
+
+ /* When copied, hide squares and show checkmark */
+ &.anndata-entry__copy--copied {
+ &::before,
+ &::after {
+ display: none;
+ }
+
+ &::before {
+ display: block;
+ content: "\2713";
+ width: auto;
+ height: auto;
+ border: none;
+ background: none;
+ color: var(--anndata-success-color);
+ font-size: 12px;
+ font-weight: bold;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+ }
+ }
+
+ .anndata-entry__warning {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--anndata-warning-color);
+ cursor: pointer;
+ margin-left: 2px;
+ }
+
+ /* Nested content area (inside expandable entries) */
+ .anndata-entry__nested-content {
+ margin-left: 0;
+ padding: 12px;
+ background: var(--anndata-bg-secondary);
+ overflow: hidden;
+ }
+
+ .anndata-entry__expanded {
+ display: block;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 12px;
+ box-sizing: border-box;
+ -webkit-overflow-scrolling: touch;
+
+ /* Jupyter-like table styling for embedded DataFrames */
+ > div {
+ display: inline-block;
+ }
+
+ table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ border: none;
+ font-size: 12px;
+ font-family: var(--anndata-font-mono);
+ table-layout: auto;
+ margin: 0;
+ white-space: nowrap;
+ }
+
+ thead {
+ border-bottom: 1px solid var(--anndata-border-color);
+ vertical-align: bottom;
+ }
+
+ th,
+ td {
+ vertical-align: middle;
+ padding: 6px 10px;
+ line-height: normal;
+ border: none;
+ text-align: right;
+ }
+
+ th {
+ font-weight: 600;
+ color: var(--anndata-text-primary);
+ background: var(--anndata-bg-secondary);
+ }
+
+ td {
+ color: var(--anndata-text-primary);
+ }
+
+ tbody tr {
+ &:nth-child(odd) {
+ background: var(--anndata-bg-primary);
+ }
+
+ &:nth-child(even) {
+ background: var(--anndata-bg-secondary);
+ }
+
+ &:hover {
+ background: var(--anndata-highlight);
+ }
+ }
+
+ /* Nested AnnData wrapper — fill width, no extra padding */
+ &:has(> .anndata-entry__nested-anndata) {
+ padding: 0;
+ text-align: left;
+ }
+
+ > .anndata-entry__nested-anndata {
+ display: block;
+ width: 100%;
+ margin: 0;
+ box-sizing: border-box;
+ }
+ }
+
+ /* Nested .anndata-repr must fill its container */
+ .anndata-entry__nested-anndata > .anndata-repr {
+ margin: 0;
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ /* --- Dtype colors --- */
+
+ .anndata-dtype--category {
+ color: var(--anndata-dtype-category);
+ }
+ .anndata-dtype--int {
+ color: var(--anndata-dtype-int);
+ }
+ .anndata-dtype--float {
+ color: var(--anndata-dtype-float);
+ }
+ .anndata-dtype--bool {
+ color: var(--anndata-dtype-bool);
+ }
+ .anndata-dtype--string {
+ color: var(--anndata-dtype-string);
+ }
+ .anndata-dtype--object {
+ color: var(--anndata-dtype-object);
+ }
+ .anndata-dtype--sparse {
+ color: var(--anndata-dtype-sparse);
+ }
+ .anndata-dtype--ndarray {
+ color: var(--anndata-dtype-array);
+ }
+ .anndata-dtype--dataframe {
+ color: var(--anndata-dtype-dataframe);
+ }
+ .anndata-dtype--anndata {
+ color: var(--anndata-dtype-anndata);
+ font-weight: 600;
+ }
+ .anndata-dtype--unknown {
+ color: var(--anndata-dtype-unknown);
+ font-style: italic;
+ }
+ .anndata-dtype--extension {
+ color: var(--anndata-dtype-extension);
+ }
+ .anndata-dtype--dask {
+ color: var(--anndata-dtype-dask);
+ }
+ .anndata-dtype--gpu {
+ color: var(--anndata-dtype-gpu);
+ }
+ .anndata-dtype--tpu {
+ color: var(--anndata-dtype-tpu);
+ }
+ .anndata-dtype--awkward {
+ color: var(--anndata-dtype-awkward);
+ }
+ .anndata-dtype--array-api {
+ color: var(--anndata-dtype-array-api);
+ }
+
+ /* --- Color swatches --- */
+
+ .anndata-colors {
+ display: inline-flex;
+ gap: 2px;
+ margin-left: 6px;
+ vertical-align: middle;
+ }
+
+ .anndata-colors__swatch {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ border: 1px solid var(--anndata-border-color);
+ }
+
+ .anndata-colors__swatch--invalid {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: bold;
+ color: var(--anndata-text-muted);
+ background: var(--anndata-bg-tertiary);
+ cursor: help;
+ }
+
+ /* --- Category / Columns lists --- */
+
+ .anndata-categories {
+ display: inline;
+ white-space: normal;
+ word-break: break-word;
+ color: var(--anndata-text-muted);
+ }
+
+ .anndata-categories__sep {
+ display: none;
+ }
+
+ .anndata-categories__item {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-right: 8px;
+ }
+
+ .anndata-categories__wrap,
+ .anndata-columns__wrap {
+ display: none;
+ background: transparent;
+ border: none;
+ color: var(--anndata-text-muted);
+ cursor: pointer;
+ font-size: 11px;
+ padding: 0 4px;
+ margin-left: 4px;
+ transition: color 0.15s;
+ vertical-align: middle;
+
+ &:hover {
+ color: var(--anndata-accent-color);
+ }
+ }
+
+ .anndata-columns {
+ display: inline;
+ white-space: normal;
+ word-break: break-word;
+ color: var(--anndata-text-muted);
+ }
+
+ /* --- X entry row --- */
+
+ .anndata-x__entry {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--anndata-border-light, #e9ecef);
+ color: var(--anndata-text-secondary, #6c757d);
+
+ > span:first-child {
+ font-family: var(--anndata-font-mono);
+ font-weight: 600;
+ min-width: 60px;
+ }
+
+ > span:last-child {
+ font-family: var(--anndata-font-mono);
+ font-size: 11px;
+ }
+ }
+
+ /* --- Depth limit --- */
+
+ .anndata-depth-limit {
+ padding: 8px 12px;
+ font-size: 11px;
+ color: var(--anndata-text-muted);
+ background: var(--anndata-bg-tertiary);
+ border-radius: var(--anndata-radius);
+ text-align: center;
+ }
+
+ /* --- Header filepath --- */
+
+ .anndata-header__filepath {
+ font-family: var(--anndata-font-mono);
+ font-size: 11px;
+ color: var(--anndata-text-secondary, #6c757d);
+ }
+
+ /* --- Spacer (flex-grow pushes siblings apart) --- */
+
+ .anndata-spacer {
+ flex-grow: 1;
+ }
+
+ /* --- Category dot (color indicator) --- */
+
+ .anndata-categories__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+ }
+
+ /* --- Custom type content --- */
+
+ .anndata-entry__custom {
+ margin-top: 4px;
+ }
+
+ /* --- Error entry --- */
+
+ .anndata-entry--error {
+ color: var(--anndata-error-color, #dc3545);
+ padding: 4px 8px;
+ font-size: 12px;
+ }
+
+ .anndata-badge--error {
+ color: var(--anndata-error-color, #dc3545);
+ }
+
+ /* --- Footer --- */
+
+ .anndata-footer {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 12px;
+ font-size: 10px;
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ color: var(--anndata-text-muted);
+ border-top: 1px solid var(--anndata-border-light);
+ }
+
+ /* --- Text helpers --- */
+
+ .anndata-text--muted {
+ color: var(--anndata-text-muted);
+ }
+
+ .anndata-text--error {
+ color: var(--anndata-error-color);
+ font-weight: 500;
+ }
+
+ .anndata-text--warning {
+ color: var(--anndata-warning-color);
+ }
+}
diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js
new file mode 100644
index 000000000..e202be002
--- /dev/null
+++ b/src/anndata/_repr/static/repr.js
@@ -0,0 +1,477 @@
+// AnnData HTML Representation JavaScript
+// This file provides interactivity for the HTML repr.
+// The {container_id} placeholder is replaced at runtime.
+
+// Mark container as JS-enabled (shows interactive elements)
+container.classList.add("anndata-repr--js")
+
+// Show interactive elements (hidden by default for no-JS graceful degradation)
+for (const btn of container.querySelectorAll(".anndata-entry__copy")) {
+ btn.style.display = "inline-flex"
+}
+for (const box of container.querySelectorAll(".anndata-search__box")) {
+ box.style.display = "inline-flex"
+}
+for (const btn of container.querySelectorAll(".anndata-search__toggle")) {
+ btn.style.display = "inline-flex"
+}
+// Filter indicator is shown via CSS .active class, no need to set display here
+
+// Hide the no-JS hint now that JavaScript is running
+for (const el of container.querySelectorAll(".anndata-repr__hint-nojs")) {
+ el.style.display = "none"
+}
+
+// Section collapse is handled natively by / elements.
+// No JS needed for section toggle — the browser handles open/close state.
+
+// Search/filter functionality
+const searchBox = container.querySelector(".anndata-search__box")
+const searchInput = container.querySelector(".anndata-search__input")
+const filterIndicator = container.querySelector(".anndata-search__indicator")
+const caseToggle = container.querySelector(".anndata-search__toggle--case")
+const regexToggle = container.querySelector(".anndata-search__toggle--regex")
+
+// Search state
+let caseSensitive = false
+let useRegex = false
+
+if (searchInput) {
+ let debounceTimer
+
+ const triggerFilter = () => {
+ clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => {
+ filterEntries(searchInput.value.trim())
+ }, 150)
+ }
+
+ searchInput.addEventListener("input", triggerFilter)
+
+ // Clear on Escape
+ searchInput.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ searchInput.value = ""
+ filterEntries("")
+ }
+ })
+
+ // Toggle button handlers
+ if (caseToggle) {
+ caseToggle.addEventListener("click", (e) => {
+ e.stopPropagation()
+ caseSensitive = !caseSensitive
+ caseToggle.classList.toggle("anndata--active", caseSensitive)
+ caseToggle.setAttribute("aria-pressed", caseSensitive)
+ triggerFilter()
+ })
+ }
+
+ if (regexToggle) {
+ regexToggle.addEventListener("click", (e) => {
+ e.stopPropagation()
+ useRegex = !useRegex
+ regexToggle.classList.toggle("anndata--active", useRegex)
+ regexToggle.setAttribute("aria-pressed", useRegex)
+ triggerFilter()
+ })
+ }
+}
+
+// Helper: test if text matches query (respects case sensitivity and regex mode)
+function matchesQuery(text, query) {
+ if (!query) return true
+ if (useRegex) {
+ try {
+ const flags = caseSensitive ? "" : "i"
+ const regex = new RegExp(query, flags)
+ if (searchBox)
+ searchBox.classList.remove("anndata-search__box--error")
+ return regex.test(text)
+ } catch {
+ // Invalid regex - show error state but don't crash
+ if (searchBox) searchBox.classList.add("anndata-search__box--error")
+ return false
+ }
+ } else {
+ if (searchBox) searchBox.classList.remove("anndata-search__box--error")
+ if (caseSensitive) {
+ return text.includes(query)
+ } else {
+ return text.toLowerCase().includes(query.toLowerCase())
+ }
+ }
+}
+
+function filterEntries(query) {
+ let totalMatches = 0
+ let totalEntries = 0
+
+ // First pass: mark all entries as hidden or not based on direct match
+ const entries = container.querySelectorAll(".anndata-entry")
+ const directMatches = new Set()
+
+ for (const entry of entries) {
+ totalEntries++
+
+ const key = entry.dataset.key || ""
+ const dtype = entry.dataset.dtype || ""
+ const text = entry.textContent
+
+ const matches =
+ !query ||
+ matchesQuery(key, query) ||
+ matchesQuery(dtype, query) ||
+ matchesQuery(text, query)
+
+ if (matches) {
+ directMatches.add(entry)
+ entry.classList.remove("anndata-entry--hidden")
+ totalMatches++
+
+ // Expand parent sections to show match
+ const section = entry.closest(".anndata-section")
+ if (section && !section.open) {
+ section.open = true
+ }
+
+ // Expand nested content if match is inside nested area
+ const nestedContent = entry.closest(
+ ".anndata-entry__nested-content",
+ )
+ if (nestedContent) {
+ const expandableEntry = nestedContent.closest(
+ "details.anndata-entry",
+ )
+ if (expandableEntry && !expandableEntry.open) {
+ expandableEntry.open = true
+ }
+ }
+ } else {
+ entry.classList.add("anndata-entry--hidden")
+ }
+ }
+
+ // Second pass: if a nested entry matches, show all ancestor entry rows
+ // This ensures that when searching for something inside a nested AnnData,
+ // all parent rows remain visible so the user can expand them to see the match
+ if (query) {
+ for (const matchedEntry of directMatches) {
+ // Walk up the DOM tree to find and show all parent entry rows
+ // Safety limit prevents infinite loops (max nesting depth is typically 3)
+ let element = matchedEntry
+ let iterations = 0
+ const maxIterations = 20
+
+ while (
+ element &&
+ element !== container &&
+ iterations < maxIterations
+ ) {
+ iterations++
+ // Check if we're inside a nested content container
+ const nestedContainer = element.closest(
+ ".anndata-entry__nested-content",
+ )
+ if (!nestedContainer) break
+
+ // Find the parent entry that contains this nested content
+ // Structure: details.anndata-entry > .anndata-entry__nested-content
+ const parentEntry = nestedContainer.closest(".anndata-entry")
+ if (!parentEntry) break
+
+ if (parentEntry.classList.contains("anndata-entry--hidden")) {
+ parentEntry.classList.remove("anndata-entry--hidden")
+ totalMatches++
+ }
+
+ // Open the expandable entry so nested content is visible
+ const expandableEntry = nestedContainer.closest(
+ "details.anndata-entry",
+ )
+ if (expandableEntry && !expandableEntry.open) {
+ expandableEntry.open = true
+ }
+
+ // Continue searching from the parent entry's container
+ element = parentEntry.parentElement
+ }
+ }
+ }
+
+ // Also filter X entries in nested AnnData (they use anndata-x__entry class, not anndata-entry)
+ // This prevents orphaned X rows from showing when their sibling entries are hidden
+ if (query) {
+ for (const xEntry of container.querySelectorAll(
+ ".anndata-entry__nested-content .anndata-x__entry",
+ )) {
+ // Check if the nested AnnData has any visible entries
+ const nestedRepr = xEntry.closest(".anndata-repr")
+ if (nestedRepr) {
+ const hasVisibleEntries = nestedRepr.querySelector(
+ ".anndata-entry:not(.anndata-entry--hidden)",
+ )
+ xEntry.style.display = hasVisibleEntries ? "" : "none"
+ }
+ }
+ } else {
+ // Reset X entries when no query
+ for (const xEntry of container.querySelectorAll(
+ ".anndata-entry__nested-content .anndata-x__entry",
+ )) {
+ xEntry.style.display = ""
+ }
+ }
+
+ // Update filter indicator
+ if (filterIndicator) {
+ if (query) {
+ filterIndicator.classList.add("anndata--active")
+ filterIndicator.textContent = `Showing ${totalMatches} of ${totalEntries}`
+ } else {
+ filterIndicator.classList.remove("anndata--active")
+ }
+ }
+
+ // Hide sections with no visible entries
+ for (const section of container.querySelectorAll(".anndata-section")) {
+ const visibleEntries = section.querySelectorAll(
+ ".anndata-entry:not(.anndata-entry--hidden)",
+ )
+
+ if (query && visibleEntries.length === 0) {
+ section.style.display = "none"
+ } else {
+ section.style.display = ""
+ }
+ }
+}
+
+// Copy to clipboard
+for (const btn of container.querySelectorAll(".anndata-entry__copy")) {
+ btn.addEventListener("click", async (e) => {
+ e.stopPropagation()
+
+ const text = btn.dataset.copy
+ if (!text) return
+
+ try {
+ await navigator.clipboard.writeText(text)
+
+ // Visual feedback (icon turns green via CSS)
+ btn.classList.add("anndata-entry__copy--copied")
+ setTimeout(
+ () => btn.classList.remove("anndata-entry__copy--copied"),
+ 1500,
+ )
+ } catch {
+ // Fallback for older browsers
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.style.position = "fixed"
+ textarea.style.opacity = "0"
+ document.body.appendChild(textarea)
+ textarea.select()
+
+ try {
+ document.execCommand("copy")
+ btn.classList.add("anndata-entry__copy--copied")
+ setTimeout(
+ () => btn.classList.remove("anndata-entry__copy--copied"),
+ 1500,
+ )
+ } catch (e) {
+ console.error("Copy failed:", e)
+ }
+
+ document.body.removeChild(textarea)
+ }
+ })
+}
+
+// Helper to check if element is overflowing
+function isOverflowing(el) {
+ return el.scrollWidth > el.clientWidth
+}
+
+// Helper to update wrap button visibility based on overflow
+function updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) {
+ if (!list || !metaCell) {
+ btn.style.display = "none"
+ return
+ }
+ // Show button only if content is overflowing or currently wrapped
+ const isWrapped = list.classList.contains(wrappedClass)
+ const overflows = isOverflowing(metaCell)
+ btn.style.display = overflows || isWrapped ? "inline" : "none"
+}
+
+// Factory function to set up wrap button handlers (DRY pattern for cats/cols buttons)
+function setupWrapButtons(buttonSelector, listSelector, wrappedClass) {
+ for (const btn of container.querySelectorAll(buttonSelector)) {
+ const entry = btn.closest(".anndata-entry")
+ const metaCell = entry
+ ? entry.querySelector(".anndata-entry__preview")
+ : null
+ const list = metaCell ? metaCell.querySelector(listSelector) : null
+
+ // Initial visibility check
+ updateWrapButtonVisibility(btn, list, metaCell, wrappedClass)
+
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation()
+ if (!list || !metaCell) return
+
+ const isWrapped = list.classList.toggle(wrappedClass)
+ metaCell.classList.toggle("anndata-entry--expanded", isWrapped)
+ btn.textContent = isWrapped ? "▲" : "▼"
+ btn.title = isWrapped
+ ? "Collapse to single line"
+ : "Expand to multi-line view"
+ // Always show button when wrapped
+ btn.style.display = "inline"
+ })
+ }
+}
+
+// Set up wrap buttons for categories and columns lists
+setupWrapButtons(
+ ".anndata-categories__wrap",
+ ".anndata-categories",
+ "anndata-categories--wrapped",
+)
+setupWrapButtons(
+ ".anndata-columns__wrap",
+ ".anndata-columns",
+ "anndata-columns--wrapped",
+)
+
+// Update button visibility on container resize (works for JupyterLab panes too)
+// Uses the same selector pairs as setupWrapButtons for consistency
+function updateAllWrapButtons() {
+ for (const [btnSel, listSel, wrappedClass] of [
+ [
+ ".anndata-categories__wrap",
+ ".anndata-categories",
+ "anndata-categories--wrapped",
+ ],
+ [
+ ".anndata-columns__wrap",
+ ".anndata-columns",
+ "anndata-columns--wrapped",
+ ],
+ ]) {
+ for (const btn of container.querySelectorAll(btnSel)) {
+ const entry = btn.closest(".anndata-entry")
+ const metaCell = entry
+ ? entry.querySelector(".anndata-entry__preview")
+ : null
+ const list = metaCell ? metaCell.querySelector(listSel) : null
+ updateWrapButtonVisibility(btn, list, metaCell, wrappedClass)
+ }
+ }
+}
+
+// Use ResizeObserver for robust resize detection (pane resizes, not just window)
+if (typeof ResizeObserver !== "undefined") {
+ let resizeTimer
+ const resizeObserver = new ResizeObserver(() => {
+ clearTimeout(resizeTimer)
+ resizeTimer = setTimeout(updateAllWrapButtons, 100)
+ })
+ resizeObserver.observe(container)
+} else {
+ // Fallback for older browsers
+ let resizeTimer
+ window.addEventListener("resize", () => {
+ clearTimeout(resizeTimer)
+ resizeTimer = setTimeout(updateAllWrapButtons, 100)
+ })
+}
+
+// README modal functionality
+const readmeIcon = container.querySelector(".anndata-readme__icon")
+if (readmeIcon) {
+ // Ensure accessibility attributes
+ readmeIcon.setAttribute("role", "button")
+ readmeIcon.setAttribute("tabindex", "0")
+ readmeIcon.setAttribute("aria-label", "View README")
+
+ readmeIcon.addEventListener("click", (e) => {
+ e.stopPropagation()
+ const readmeContent = readmeIcon.dataset.readme
+ if (!readmeContent) return
+
+ // Create modal overlay
+ const overlay = document.createElement("div")
+ overlay.className = "anndata-readme__overlay"
+
+ // Create modal with accessibility attributes
+ // Use container.id to make IDs unique across multiple cells
+ const modalTitleId = `${container.id}-readme-modal-title`
+ const modal = document.createElement("div")
+ modal.className = "anndata-readme__modal"
+ modal.setAttribute("role", "dialog")
+ modal.setAttribute("aria-modal", "true")
+ modal.setAttribute("aria-labelledby", modalTitleId)
+
+ // Header
+ const header = document.createElement("div")
+ header.className = "anndata-readme__header"
+
+ const title = document.createElement("h3")
+ title.id = modalTitleId
+ title.textContent = "README"
+ header.appendChild(title)
+
+ const closeBtn = document.createElement("button")
+ closeBtn.className = "anndata-readme__close"
+ closeBtn.textContent = "×"
+ closeBtn.setAttribute("aria-label", "Close")
+ header.appendChild(closeBtn)
+
+ // Content — plain text (no markdown parsing, XSS-safe via textContent)
+ const content = document.createElement("div")
+ content.className = "anndata-readme__content"
+ const pre = document.createElement("pre")
+ pre.textContent = readmeContent
+ content.appendChild(pre)
+
+ modal.appendChild(header)
+ modal.appendChild(content)
+ overlay.appendChild(modal)
+
+ // Add to container (scoped styles apply)
+ container.appendChild(overlay)
+
+ // Close handlers
+ const closeModal = () => {
+ overlay.remove()
+ }
+
+ closeBtn.addEventListener("click", closeModal)
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) closeModal()
+ })
+
+ // Escape key closes modal
+ const escHandler = (e) => {
+ if (e.key === "Escape") {
+ closeModal()
+ document.removeEventListener("keydown", escHandler)
+ }
+ }
+ document.addEventListener("keydown", escHandler)
+
+ // Focus trap
+ closeBtn.focus()
+ })
+
+ // Keyboard accessibility for the icon
+ readmeIcon.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ readmeIcon.click()
+ }
+ })
+}
diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py
new file mode 100644
index 000000000..2a87f2595
--- /dev/null
+++ b/src/anndata/_repr/utils.py
@@ -0,0 +1,835 @@
+"""
+Utility functions for HTML representation.
+
+This module provides:
+- Serialization checking using the anndata IO registry
+- String-to-category warning detection
+- Color list detection and validation
+- HTML escaping and sanitization
+- Memory size formatting
+"""
+
+from __future__ import annotations
+
+import html
+import re
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+import numpy as np
+
+from .._repr_constants import (
+ DICT_PREVIEW_KEYS,
+ DICT_PREVIEW_KEYS_LARGE,
+ LIST_PREVIEW_ITEMS,
+ STRING_INLINE_LIMIT,
+)
+
+if TYPE_CHECKING:
+ import pandas as pd
+
+ from anndata import AnnData
+
+ from .registry import FormatterContext
+
+
+def _check_serializable_single(obj: object) -> tuple[bool, str]:
+ """Check if a single (non-container) object is serializable."""
+ # Handle None
+ if obj is None:
+ return True, ""
+
+ # Use the actual IO registry
+ try:
+ from .._io.specs.registry import _REGISTRY
+
+ _REGISTRY.get_spec(obj)
+ return True, ""
+ except (KeyError, TypeError):
+ pass
+
+ # Check for basic Python types that are serializable
+ if isinstance(obj, (bool, int, float, str, bytes)):
+ return True, ""
+
+ # Check numpy scalar types
+ if isinstance(obj, np.generic):
+ return True, ""
+
+ return (
+ False,
+ f"Type '{type(obj).__module__}.{type(obj).__name__}' has no registered writer",
+ )
+
+
+def is_serializable(
+ obj: object,
+ *,
+ _depth: int = 0,
+ _max_depth: int = 10,
+) -> tuple[bool, str]:
+ """
+ Check if an object can be serialized to H5AD/Zarr.
+
+ Uses the actual anndata IO registry to check if a type has a registered writer.
+ For containers (dict, list), recursively checks all elements.
+
+ Parameters
+ ----------
+ obj
+ Object to check
+ _depth
+ Current recursion depth (internal)
+ _max_depth
+ Maximum recursion depth to prevent infinite loops
+
+ Returns
+ -------
+ tuple of (is_serializable, reason_if_not)
+ """
+ if _depth > _max_depth:
+ return False, "Maximum nesting depth exceeded"
+
+ # Check containers recursively
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ ok, reason = is_serializable(v, _depth=_depth + 1, _max_depth=_max_depth)
+ if not ok:
+ return False, f"Key '{k}': {reason}"
+ return True, ""
+
+ if isinstance(obj, (list, tuple)):
+ for i, v in enumerate(obj):
+ ok, reason = is_serializable(v, _depth=_depth + 1, _max_depth=_max_depth)
+ if not ok:
+ return False, f"Index {i}: {reason}"
+ return True, ""
+
+ return _check_serializable_single(obj)
+
+
+def should_warn_string_column(
+ series: pd.Series, n_unique: int | None
+) -> tuple[bool, str]:
+ """
+ Check if a string column will be auto-converted to categorical on save.
+
+ This replicates the logic from AnnData.strings_to_categoricals()
+ (see _core/anndata.py:1249-1259):
+ - Column must be string type (infer_dtype == "string")
+ - Number of unique values must be less than total values
+
+ Parameters
+ ----------
+ series
+ Pandas Series to check
+ n_unique
+ Pre-computed nunique value (None if skipped due to unique_limit or lazy)
+
+ Returns
+ -------
+ tuple of (should_warn, warning_message)
+ """
+ # Can't check if n_unique wasn't computed
+ if n_unique is None:
+ return False, ""
+
+ from pandas.api.types import infer_dtype
+
+ # Same check as AnnData.strings_to_categoricals()
+ dtype_str = infer_dtype(series)
+ if dtype_str != "string":
+ return False, ""
+
+ n_total = len(series)
+ if n_unique < n_total:
+ return (
+ True,
+ f"String column ({n_unique} unique). "
+ f"Will be converted to categorical on save.",
+ )
+
+ return False, ""
+
+
+def _is_color_string(s: str) -> bool:
+ """Check if a string looks like a color value."""
+ if s.startswith("#"):
+ return True
+ s_lower = s.lower()
+ if s_lower in _NAMED_COLORS:
+ return True
+ return s_lower.startswith(("rgb(", "rgba("))
+
+
+def sanitize_css_color(color: str) -> str | None: # noqa: PLR0911
+ """
+ Sanitize a color string for safe use in CSS style attributes.
+
+ Returns the sanitized color if valid, or None if the color is invalid
+ or potentially dangerous (contains CSS injection attempts).
+
+ This is critical for security - color values go into style attributes
+ and must not allow CSS injection (e.g., "red; background-image: url(...)").
+
+ Note: Multiple returns are intentional for clarity in validating different
+ color formats (hex, named, rgb/rgba).
+
+ Parameters
+ ----------
+ color
+ The color string to sanitize
+
+ Returns
+ -------
+ The sanitized color string, or None if invalid/unsafe
+ """
+ if not isinstance(color, str):
+ return None
+
+ color = color.strip()
+ if not color:
+ return None
+
+ # Length limit to prevent DoS via very long strings
+ if len(color) > 50:
+ return None
+
+ # Hex colors: #RGB, #RRGGBB, or #RRGGBBAA (strict whitelist)
+ if color.startswith("#"):
+ hex_part = color[1:]
+ if len(hex_part) in (3, 4, 6, 8) and all(
+ c in "0123456789abcdefABCDEF" for c in hex_part
+ ):
+ return color
+ return None
+
+ # Named colors - must exactly match a known CSS color name (whitelist)
+ color_lower = color.lower()
+ if color_lower in _NAMED_COLORS:
+ return color_lower
+
+ # rgb() and rgba() - WHITELIST approach: only allow safe characters
+ if color_lower.startswith("rgb"):
+ # Only these characters can appear in valid rgb/rgba colors
+ safe_chars = set("rgbaRGBA0123456789(),. %")
+ if not all(c in safe_chars for c in color):
+ return None
+ # Validate rgb/rgba format strictly with regex
+ rgb_pattern = r"^rgba?\(\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*,\s*\d{1,3}%?\s*(,\s*(0|1|0?\.\d+))?\s*\)$"
+ if re.match(rgb_pattern, color_lower):
+ return color
+ return None
+
+ # Reject everything else - no hsl(), var(), url(), expression(), etc.
+ return None
+
+
+def is_color_list(key: str, value: object) -> bool:
+ """
+ Check if a value is a color list following the *_colors convention.
+
+ Parameters
+ ----------
+ key
+ The key name (should end with '_colors')
+ value
+ The value to check
+
+ Returns
+ -------
+ True if this appears to be a color list
+ """
+ if not isinstance(key, str) or not key.endswith("_colors"):
+ return False
+ if not isinstance(value, (list, np.ndarray, tuple)):
+ return False
+ # Empty list is valid
+ if len(value) == 0:
+ return True
+ # Check first element
+ first = value[0]
+ return isinstance(first, str) and _is_color_string(first)
+
+
+def _get_categories_from_column(col: object) -> list:
+ """
+ Get categories from a categorical column.
+
+ Works for both pandas Series (.cat.categories) and xarray DataArray
+ (dtype.categories). Returns empty list if categories cannot be extracted.
+ """
+ try:
+ # Pandas Series
+ if hasattr(col, "cat"):
+ return list(col.cat.categories)
+
+ # xarray DataArray or other objects with CategoricalDtype
+ if hasattr(col, "dtype") and hasattr(col.dtype, "categories"):
+ return list(col.dtype.categories)
+ except Exception as e: # noqa: BLE001
+ from .._warnings import warn
+
+ warn(
+ f"Failed to extract categories from column: {type(e).__name__}: {e}",
+ UserWarning,
+ )
+
+ return []
+
+
+def get_categories_for_display(
+ col: object,
+ context: FormatterContext,
+ *,
+ is_lazy: bool,
+) -> tuple[list, bool, int | None]:
+ """
+ Get categories for a column, handling lazy loading appropriately.
+
+ Parameters
+ ----------
+ col
+ The column to get categories from
+ context
+ FormatterContext with display settings
+ is_lazy
+ Whether this is a lazy column (from read_lazy())
+
+ Returns
+ -------
+ tuple of (categories_list, was_truncated, n_categories)
+ categories_list: List of category values
+ was_truncated: True if categories were truncated for lazy columns
+ n_categories: Total number of categories (if known)
+ """
+ if is_lazy:
+ from .lazy import get_lazy_categories
+
+ return get_lazy_categories(col, context)
+
+ # Non-lazy categorical - use unified accessor
+ categories = _get_categories_from_column(col)
+ return categories, False, len(categories) if categories else None
+
+
+def _compute_if_dask(obj: object) -> object:
+ """
+ Compute a dask array/object if it is one, otherwise return as-is.
+
+ For lazy AnnData, uns values may be dask arrays that need to be
+ computed to get the actual values.
+ """
+ if hasattr(obj, "compute"):
+ return obj.compute()
+ return obj
+
+
+def get_matching_column_colors(
+ adata: AnnData,
+ column_name: str,
+ *,
+ limit: int | None = None,
+) -> list[str] | None:
+ """
+ Get colors for a column from uns if they exist.
+
+ This function is called by CategoricalFormatter which already verified
+ the column is categorical. It just looks up and returns the colors.
+ Color count validation is done separately by check_color_category_mismatch.
+
+ Parameters
+ ----------
+ adata
+ AnnData object
+ column_name
+ Name of the column to get colors for
+ limit
+ If provided, only load the first `limit` colors. This avoids loading
+ all colors from disk when only displaying partial categories.
+
+ Returns
+ -------
+ List of color strings if colors exist, None otherwise
+ """
+ colors = _get_colors_from_uns(adata, column_name, limit=limit)
+ return list(colors) if colors is not None else None
+
+
+def check_color_category_mismatch(
+ adata: AnnData,
+ column_name: str,
+ n_categories: int,
+) -> str | None:
+ """
+ Check if colors exist but don't match category count.
+
+ Called by _render_dataframe_entry for categorical columns. The caller
+ already knows this is categorical and has the category count.
+
+ Parameters
+ ----------
+ adata
+ AnnData object (or object with .uns attribute)
+ column_name
+ Name of the column to check
+ n_categories
+ Number of categories in the column
+
+ Returns
+ -------
+ Warning message if mismatch, None otherwise
+ """
+ colors = _get_colors_from_uns(adata, column_name)
+ if colors is None:
+ return None
+
+ if len(colors) != n_categories:
+ return f"Color mismatch: {len(colors)} colors for {n_categories} categories"
+
+ return None
+
+
+def count_invalid_colors(colors: Sequence) -> int:
+ """
+ Count colors that fail sanitization.
+
+ Parameters
+ ----------
+ colors
+ Sequence of color values to check
+
+ Returns
+ -------
+ Number of colors that fail sanitize_css_color validation
+ """
+ return sum(1 for c in colors if sanitize_css_color(str(c)) is None)
+
+
+def format_invalid_colors_warning(invalid_count: int, *, has_more: bool = False) -> str:
+ """
+ Format a warning message for invalid colors.
+
+ Parameters
+ ----------
+ invalid_count
+ Number of invalid colors found
+ has_more
+ If True, adds "+" suffix to indicate more unchecked colors
+
+ Returns
+ -------
+ Formatted warning message like "2 invalid colors" or "2+ invalid colors"
+ """
+ suffix = "+" if has_more else ""
+ s = "s" if invalid_count > 1 else ""
+ return f"{invalid_count}{suffix} invalid color{s}"
+
+
+def check_invalid_colors(
+ adata: AnnData,
+ column_name: str,
+ limit: int | None = None,
+ n_total: int | None = None,
+) -> str | None:
+ """
+ Check if any colors in the color list are invalid or unsafe.
+
+ Called by CategoricalFormatter for categorical columns that have associated
+ colors in .uns.
+
+ Parameters
+ ----------
+ adata
+ AnnData object (or object with .uns attribute)
+ column_name
+ Name of the column to check
+ limit
+ If provided, only check the first `limit` colors (for lazy loading).
+ n_total
+ Total number of colors expected (e.g., n_categories). Used to determine
+ if there are unchecked colors beyond the limit.
+
+ Returns
+ -------
+ Warning message if invalid colors found, None otherwise
+ """
+ colors = _get_colors_from_uns(adata, column_name, limit=limit)
+ if colors is None:
+ return None
+
+ invalid_count = count_invalid_colors(colors)
+ if invalid_count == 0:
+ return None
+
+ has_more = n_total is not None and limit is not None and n_total > limit
+ return format_invalid_colors_warning(invalid_count, has_more=has_more)
+
+
+def _get_colors_from_uns(
+ adata: AnnData,
+ column_name: str,
+ limit: int | None = None,
+) -> object | None:
+ """Get colors from uns for a column, handling lazy loading.
+
+ Parameters
+ ----------
+ adata
+ AnnData object (or object with .uns attribute)
+ column_name
+ Name of the column (colors key will be "{column_name}_colors")
+ limit
+ If provided, only load the first `limit` colors (for dask arrays)
+
+ Returns
+ -------
+ Colors array/list if found, None otherwise
+ """
+ # Handle objects without .uns (e.g., Raw)
+ if not hasattr(adata, "uns"):
+ return None
+
+ color_key = f"{column_name}_colors"
+ if color_key not in adata.uns:
+ return None
+
+ colors = adata.uns[color_key]
+
+ # For lazy AnnData with dask arrays, slice before computing
+ if limit is not None and hasattr(colors, "compute"):
+ return colors[:limit].compute()
+
+ # Compute if dask array (for lazy AnnData)
+ return _compute_if_dask(colors)
+
+
+def format_index_preview(index: pd.Index, preview_n: int = 5) -> str:
+ """Format a preview of a pandas Index.
+
+ Shows first and last items with ellipsis in between for long indices.
+ Handles bytes index values (from older h5ad files) by decoding them.
+
+ Parameters
+ ----------
+ index
+ The pandas Index to preview
+ preview_n
+ Number of items to show at the start and end
+
+ Returns
+ -------
+ Comma-separated preview string, or ``empty`` for empty indices.
+ """
+ n = len(index)
+ if n == 0:
+ return "empty"
+
+ def _format_value(x: object) -> str:
+ """Format a single index value, decoding bytes if needed."""
+ if isinstance(x, bytes):
+ try:
+ return x.decode("utf-8")
+ except UnicodeDecodeError:
+ return x.decode("latin-1")
+ return str(x)
+
+ if n <= preview_n * 2:
+ items = [escape_html(_format_value(x)) for x in index]
+ else:
+ first = [escape_html(_format_value(x)) for x in index[:preview_n]]
+ last = [escape_html(_format_value(x)) for x in index[-preview_n:]]
+ items = [*first, "...", *last]
+
+ return ", ".join(items)
+
+
+def escape_html(text: str) -> str:
+ """Escape HTML special characters and replace null bytes.
+
+ Null bytes in user data (e.g., column names like ``"null\\x00byte"``)
+ break HTML parsers and cause truncated rendering. They are replaced
+ with the Unicode replacement character U+FFFD.
+ """
+ return html.escape(str(text).replace("\x00", "\ufffd"))
+
+
+def sanitize_for_id(text: str) -> str:
+ """Sanitize a string for use as an HTML id attribute."""
+ # Replace non-alphanumeric chars with underscore
+ sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", str(text))
+ # Ensure it starts with a letter
+ if sanitized and not sanitized[0].isalpha():
+ sanitized = "id_" + sanitized
+ return sanitized
+
+
+def truncate_string(text: str, max_length: int = 100) -> str:
+ """Truncate a string and add ellipsis if needed."""
+ text = str(text)
+ if len(text) <= max_length:
+ return text
+ return text[: max_length - 3] + "..."
+
+
+def format_memory_size(size_bytes: float) -> str:
+ """Format memory size in human-readable form."""
+ if size_bytes < 0:
+ return "Unknown"
+
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if abs(size_bytes) < 1024:
+ if unit == "B":
+ return f"{int(size_bytes)} {unit}"
+ return f"{size_bytes:.1f} {unit}"
+ size_bytes /= 1024
+
+ return f"{size_bytes:.1f} PB"
+
+
+def format_number(n: float | str) -> str:
+ """Format a number with thousand separators.
+
+ Accepts int, float, or str (for fallback values like "?").
+ """
+ if isinstance(n, str):
+ return n
+ if isinstance(n, float):
+ if n == int(n):
+ n = int(n)
+ else:
+ return f"{n:,.2f}"
+ return f"{n:,}"
+
+
+def get_anndata_version() -> str:
+ """Get the anndata version string."""
+ try:
+ from importlib.metadata import PackageNotFoundError, version
+
+ return version("anndata")
+ except PackageNotFoundError:
+ return "unknown"
+
+
+def is_view(obj: object) -> bool:
+ """Check if an object is a view (for AnnData-like objects)."""
+ try:
+ return getattr(obj, "is_view", False)
+ except Exception: # noqa: BLE001
+ return False
+
+
+def is_backed(obj: object) -> bool:
+ """Check if an object is backed (for AnnData-like objects)."""
+ try:
+ return getattr(obj, "isbacked", False)
+ except Exception: # noqa: BLE001
+ return False
+
+
+def get_backing_info(obj: object) -> dict[str, bool | str | None]:
+ """Get information about backing for an AnnData-like object."""
+ try:
+ if not is_backed(obj):
+ return {"backed": False}
+
+ filename = str(getattr(obj, "filename", None) or "")
+ info: dict[str, bool | str | None] = {
+ "backed": True,
+ "filename": filename,
+ }
+
+ # Try to get file status
+ file_obj = getattr(obj, "file", None)
+ if file_obj is not None:
+ info["is_open"] = getattr(file_obj, "is_open", None)
+
+ # Detect format from filename
+ if filename:
+ if filename.endswith(".h5ad"):
+ info["format"] = "H5AD"
+ elif ".zarr" in filename:
+ info["format"] = "Zarr"
+ else:
+ info["format"] = "Unknown"
+
+ return info
+ except Exception: # noqa: BLE001
+ return {"backed": False}
+
+
+def _load_css_colors() -> frozenset[str]:
+ """Load CSS named colors from static file.
+
+ The colors are loaded from static/css_colors.txt which contains the
+ 147 CSS3 named colors. This file can be easily updated if needed.
+
+ Returns
+ -------
+ frozenset of lowercase color names
+ """
+ from functools import cache
+ from importlib.resources import files
+
+ @cache
+ def _load() -> frozenset[str]:
+ content = (
+ files("anndata._repr.static")
+ .joinpath("css_colors.txt")
+ .read_text(encoding="utf-8")
+ )
+ colors = set()
+ for line in content.splitlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ colors.add(line.lower())
+ return frozenset(colors)
+
+ return _load()
+
+
+# CSS named colors for color detection in _is_color_string().
+# Loaded from static/css_colors.txt - see that file for the full list.
+# Colors can also be specified as hex (#RGB, #RRGGBB), rgb(), or rgba().
+_NAMED_COLORS = _load_css_colors()
+
+
+# -----------------------------------------------------------------------------
+# Value preview functions
+# -----------------------------------------------------------------------------
+
+
+def preview_string(value: str, max_len: int) -> str:
+ """Preview a string value."""
+ if len(value) <= max_len:
+ return f'"{value}"'
+ return f'"{value[:max_len]}..."'
+
+
+def preview_number(value: float | np.integer | np.floating) -> str:
+ """Preview a numeric value."""
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, (int, np.integer)):
+ return str(value)
+ # Float - format nicely
+ if value == int(value):
+ return str(int(value))
+ return f"{value:.6g}"
+
+
+def preview_dict(value: dict) -> str:
+ """Preview a dict value."""
+ n_keys = len(value)
+ if n_keys == 0:
+ return "{}"
+ if n_keys <= DICT_PREVIEW_KEYS:
+ keys_preview = ", ".join(str(k) for k in list(value.keys())[:DICT_PREVIEW_KEYS])
+ return f"{{{keys_preview}}}"
+ keys_preview = ", ".join(
+ str(k) for k in list(value.keys())[:DICT_PREVIEW_KEYS_LARGE]
+ )
+ return f"{{{keys_preview}, ...}} ({n_keys} keys)"
+
+
+def preview_sequence(value: list | tuple) -> str:
+ """Preview a list or tuple value."""
+ n_items = len(value)
+ bracket = "[]" if isinstance(value, list) else "()"
+ if n_items == 0:
+ return bracket
+ if n_items <= LIST_PREVIEW_ITEMS:
+ try:
+ items = [preview_item(v) for v in value[:LIST_PREVIEW_ITEMS]]
+ if all(items):
+ return f"{bracket[0]}{', '.join(items)}{bracket[1]}"
+ except Exception: # noqa: BLE001
+ # Intentional broad catch: preview generation is best-effort
+ pass
+ return f"({n_items} items)"
+
+
+def preview_item(value: object) -> str:
+ """Generate a short preview for a single item (for list/tuple previews)."""
+ if isinstance(value, str):
+ if len(value) <= STRING_INLINE_LIMIT:
+ return f'"{value}"'
+ truncate_at = STRING_INLINE_LIMIT - 3 # Leave room for "..."
+ return f'"{value[:truncate_at]}..."'
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, (int, float, np.integer, np.floating)):
+ return str(value)
+ if value is None:
+ return "None"
+ return "" # Empty string means skip
+
+
+def generate_value_preview(value: object, max_len: int = 100) -> str:
+ """Generate a human-readable preview of a value.
+
+ Returns empty string if no meaningful preview can be generated.
+ """
+ if value is None:
+ return "None"
+ if isinstance(value, str):
+ return preview_string(value, max_len)
+ if isinstance(value, (bool, int, float, np.integer, np.floating)):
+ return preview_number(value)
+ if isinstance(value, dict):
+ return preview_dict(value)
+ if isinstance(value, (list, tuple)):
+ return preview_sequence(value)
+ # No preview for complex types
+ return ""
+
+
+def get_setting(name: str, *, default: object) -> object:
+ """Get a setting value from anndata.settings, falling back to default.
+
+ Parameters
+ ----------
+ name
+ The setting name (e.g., "repr_html_max_items")
+ default
+ Default value if setting is not available
+
+ Returns
+ -------
+ The setting value or default
+ """
+ try:
+ from anndata import settings
+
+ return getattr(settings, name, default)
+ except (ImportError, AttributeError):
+ return default
+
+
+def validate_key(key: str) -> tuple[bool, str, bool]:
+ """Check if a key name is valid for HDF5/Zarr serialization.
+
+ Key names (column names, uns keys, etc.) are validated because certain
+ characters cause issues with the underlying storage formats (HDF5 and Zarr).
+
+ Parameters
+ ----------
+ key
+ Key name to validate
+
+ Returns
+ -------
+ tuple of (is_valid, reason, is_hard_error)
+ is_valid: False if there's an issue
+ reason: Description of the issue
+ is_hard_error: True means write fails NOW, False means deprecation warning
+ """
+ if not isinstance(key, str):
+ return False, f"Non-string key ({type(key).__name__})", True
+ # Slashes will be disallowed in h5 stores (FutureWarning)
+ if "/" in key:
+ return False, "Contains '/' (deprecated)", False
+ return True, "", False
diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py
new file mode 100644
index 000000000..9c4d0bfbe
--- /dev/null
+++ b/src/anndata/_repr_constants.py
@@ -0,0 +1,157 @@
+"""
+Constants for HTML representation.
+
+This module contains default values for repr_html settings.
+It is located outside the _repr/ package to avoid loading the full
+_repr module when _settings.py imports these constants at anndata
+import time. Python loads parent packages before submodules, so
+importing from _repr.constants would trigger _repr/__init__.py.
+"""
+
+from __future__ import annotations
+
+# Display behavior
+DEFAULT_FOLD_THRESHOLD = 5 # Auto-fold sections with more than N entries
+DEFAULT_MAX_DEPTH = 3 # Maximum recursion depth for nested objects
+DEFAULT_MAX_ITEMS = 200 # Maximum items to show per section
+DEFAULT_MAX_STRING_LENGTH = 100 # Truncate strings longer than this
+DEFAULT_PREVIEW_ITEMS = 5 # Number of items to show in previews (first/last)
+# Max category values to display inline (used by render_category_list in components.py)
+# Note: DataFrame columns in obsm/varm have no limit - see DF_COLS_PREVIEW_LIMIT comment
+DEFAULT_MAX_CATEGORIES = 100
+DEFAULT_MAX_LAZY_CATEGORIES = (
+ 100 # Max categories to load for lazy categoricals (0 to skip)
+)
+DEFAULT_UNIQUE_LIMIT = 1_000_000 # Max rows to compute unique counts (0 to disable)
+DEFAULT_MAX_README_SIZE = 100_000 # Max README size in chars (100KB, 0 to disable)
+
+# Column widths (pixels)
+DEFAULT_MAX_FIELD_WIDTH = 400 # Max width for field name column
+DEFAULT_TYPE_WIDTH = 220 # Width for type column
+
+# Field name column width calculation constants
+# These values are empirically tuned for the default 13px monospace font
+CHAR_WIDTH_PX = 8 # Average character width for monospace at 13px font-size
+COPY_BUTTON_PADDING_PX = (
+ 54 # Extra space for copy button + cell padding (24px for grid border-box)
+)
+MIN_FIELD_WIDTH_PX = 104 # Minimum column width (includes 24px cell padding)
+DEFAULT_FIELD_WIDTH_PX = (
+ 104 # Default when no field names exist (matches MIN_FIELD_WIDTH_PX)
+)
+
+# Inline style for graceful degradation (hidden until JS enables).
+# JS sets different display values per element, so this must stay inline.
+STYLE_HIDDEN = "display:none;"
+
+# Warning messages
+NOT_SERIALIZABLE_MSG = "Not serializable to H5AD/Zarr"
+
+# Preview truncation limits
+TOOLTIP_TRUNCATE_LENGTH = 500 # Max chars for tooltip full text
+ERROR_TRUNCATE_LENGTH = 200 # Max chars for error messages
+COLOR_PREVIEW_LIMIT = 15 # Max color swatches to show
+DICT_PREVIEW_KEYS = 3 # Keys to show in dict preview (small dicts)
+DICT_PREVIEW_KEYS_LARGE = 2 # Keys to show in dict preview (large dicts)
+LIST_PREVIEW_ITEMS = 3 # Items to show in list preview
+STRING_INLINE_LIMIT = 20 # Max string length before truncating inline
+
+# DataFrame column preview limits (for DataFrames in uns only)
+# These control the compact inline preview shown in the type cell, e.g. "[col1, col2, ...]"
+# Used by DataFrameFormatter in formatters.py for uns entries.
+#
+# Note: DataFrames in obsm/varm render ALL columns with CSS truncation + wrap button
+# (render_entry_preview_cell() in components.py). No constant limits this - all columns
+# are in the HTML, CSS truncates the display, and the wrap button expands to show all.
+# This differs from categories which are limited by DEFAULT_MAX_CATEGORIES below.
+DF_COLS_PREVIEW_LIMIT = 5 # Max columns to show in compact preview
+DF_COLS_PREVIEW_MAX_LEN = 40 # Max total chars for column list string
+
+# CSS class names for entry rows (BEM: anndata-entry block)
+CSS_ENTRY = "anndata-entry"
+CSS_ENTRY_NAME = "anndata-entry__name"
+CSS_ENTRY_TYPE = "anndata-entry__type"
+CSS_ENTRY_PREVIEW = "anndata-entry__preview"
+CSS_TEXT_MUTED = "anndata-text--muted"
+CSS_TEXT_ERROR = "anndata-text--error"
+CSS_TEXT_WARNING = "anndata-text--warning"
+CSS_NESTED_CONTENT = "anndata-entry__nested-content"
+CSS_NESTED_ANNDATA = "anndata-entry__nested-anndata"
+
+# CSS class names for dtype spans (BEM: anndata-dtype block with modifiers)
+# These provide visual differentiation for different data types
+# Basic types
+CSS_DTYPE_INT = "anndata-dtype--int"
+CSS_DTYPE_FLOAT = "anndata-dtype--float"
+CSS_DTYPE_BOOL = "anndata-dtype--bool"
+CSS_DTYPE_STRING = "anndata-dtype--string"
+CSS_DTYPE_OBJECT = "anndata-dtype--object"
+# Container/structured types
+CSS_DTYPE_CATEGORY = "anndata-dtype--category"
+CSS_DTYPE_DATAFRAME = "anndata-dtype--dataframe"
+CSS_DTYPE_ANNDATA = "anndata-dtype--anndata"
+# Array types
+CSS_DTYPE_NDARRAY = "anndata-dtype--ndarray"
+CSS_DTYPE_SPARSE = "anndata-dtype--sparse"
+# Specialized array types
+CSS_DTYPE_DASK = "anndata-dtype--dask"
+CSS_DTYPE_GPU = "anndata-dtype--gpu"
+CSS_DTYPE_TPU = "anndata-dtype--tpu"
+CSS_DTYPE_AWKWARD = "anndata-dtype--awkward"
+CSS_DTYPE_ARRAY_API = "anndata-dtype--array-api"
+# Extension/unknown
+CSS_DTYPE_EXTENSION = "anndata-dtype--extension"
+CSS_DTYPE_UNKNOWN = "anndata-dtype--unknown"
+
+# CSS class names for badges (BEM: anndata-badge block with modifiers)
+CSS_BADGE = "anndata-badge"
+CSS_BADGE_VIEW = "anndata-badge--view"
+CSS_BADGE_BACKED = "anndata-badge--backed"
+CSS_BADGE_LAZY = "anndata-badge--lazy"
+CSS_BADGE_EXTENSION = "anndata-badge--extension"
+
+# CSS class names for color swatches (BEM: anndata-colors block)
+CSS_COLORS = "anndata-colors"
+CSS_COLORS_SWATCH = "anndata-colors__swatch"
+CSS_COLORS_SWATCH_INVALID = "anndata-colors__swatch--invalid"
+
+# Section name constants (canonical strings used for data-section attributes and dispatch)
+SECTION_X = "X"
+SECTION_OBS = "obs"
+SECTION_VAR = "var"
+SECTION_UNS = "uns"
+SECTION_OBSM = "obsm"
+SECTION_VARM = "varm"
+SECTION_LAYERS = "layers"
+SECTION_OBSP = "obsp"
+SECTION_VARP = "varp"
+SECTION_RAW = "raw"
+
+# Internal AnnData attributes to skip when detecting unknown sections.
+#
+# Standard data sections (obs, var, uns, obsm, etc.) are discovered via
+# `anndata.utils.iter_outer`. Custom sections come from registered
+# SectionFormatter extensions. This frozenset lists non-data attributes
+# that would otherwise appear as "unknown sections" — shape/size metadata,
+# index accessors, file/backing info, and the transpose accessor.
+#
+# Keep NEW data slots OUT of this list. They should surface as "unknown"
+# until a proper renderer is implemented.
+INTERNAL_ANNDATA_ATTRS = frozenset({
+ # Shape/size metadata
+ "shape",
+ "n_obs",
+ "n_vars",
+ # Index accessors
+ "obs_names",
+ "var_names",
+ # File/backing info
+ "filename",
+ "file",
+ "isbacked",
+ # View status
+ "is_view",
+ "isview", # Deprecated alias, triggers warning if accessed
+ # Transpose accessor
+ "T",
+})
diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py
index a08c80d4f..1d22c4fff 100644
--- a/src/anndata/_settings.py
+++ b/src/anndata/_settings.py
@@ -12,6 +12,17 @@
from types import GenericAlias, NoneType
from typing import TYPE_CHECKING, Any, NamedTuple, cast
+from ._repr_constants import (
+ DEFAULT_FOLD_THRESHOLD,
+ DEFAULT_MAX_CATEGORIES,
+ DEFAULT_MAX_DEPTH,
+ DEFAULT_MAX_FIELD_WIDTH,
+ DEFAULT_MAX_ITEMS,
+ DEFAULT_MAX_LAZY_CATEGORIES,
+ DEFAULT_MAX_README_SIZE,
+ DEFAULT_TYPE_WIDTH,
+ DEFAULT_UNIQUE_LIMIT,
+)
from ._warnings import warn
from .compat import old_positionals
@@ -528,5 +539,109 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None:
)
+# HTML representation settings
+settings.register(
+ "repr_html_enabled",
+ default_value=True,
+ description="Whether to use rich HTML representation in Jupyter notebooks. Set to False to use plain text repr.",
+ validate=validate_bool,
+ get_from_env=check_and_get_bool,
+)
+
+settings.register(
+ "repr_html_fold_threshold",
+ default_value=DEFAULT_FOLD_THRESHOLD,
+ description="Auto-fold sections in HTML repr when they have more than this many entries.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_max_depth",
+ default_value=DEFAULT_MAX_DEPTH,
+ description="Maximum recursion depth for nested AnnData objects in HTML repr.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_max_items",
+ default_value=DEFAULT_MAX_ITEMS,
+ description="Maximum number of items to show per section in HTML repr.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_max_categories",
+ default_value=DEFAULT_MAX_CATEGORIES,
+ description="Maximum number of category values to display inline in HTML repr.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_max_lazy_categories",
+ default_value=DEFAULT_MAX_LAZY_CATEGORIES,
+ description=(
+ "Maximum categories to load for lazy categoricals in HTML repr. "
+ "For lazy AnnData (from read_lazy()), loading categories requires reading "
+ "from disk. This limit prevents loading too many categories. "
+ "Set to 0 to disable loading categories entirely (metadata-only mode)."
+ ),
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_unique_limit",
+ default_value=DEFAULT_UNIQUE_LIMIT,
+ description="Maximum number of rows to compute unique counts for in HTML repr. Set to 0 to disable.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_dataframe_expand",
+ default_value=False,
+ description=(
+ "Whether to show expandable pandas DataFrame previews in HTML repr. "
+ "When enabled, DataFrames in obsm/varm can be expanded to show their content "
+ "using pandas _repr_html_() (rich Jupyter-style output). Configure pandas "
+ "display options to control output: pd.set_option('display.max_rows', 10)"
+ ),
+ validate=validate_bool,
+ get_from_env=check_and_get_bool,
+)
+
+settings.register(
+ "repr_html_max_field_width",
+ default_value=DEFAULT_MAX_FIELD_WIDTH,
+ description="Maximum width in pixels for the field name column in HTML repr.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_type_width",
+ default_value=DEFAULT_TYPE_WIDTH,
+ description="Width in pixels for the type column in HTML repr.",
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+settings.register(
+ "repr_html_max_readme_size",
+ default_value=DEFAULT_MAX_README_SIZE,
+ description=(
+ "Maximum size in characters for README content in HTML repr. "
+ "READMEs larger than this will be truncated with a note. "
+ "Set to 0 to disable truncation (not recommended for very large READMEs)."
+ ),
+ validate=validate_int,
+ get_from_env=check_and_get_int,
+)
+
+
##################################################################################
##################################################################################
diff --git a/src/anndata/_settings.pyi b/src/anndata/_settings.pyi
index 775f20ba7..9139cfd77 100644
--- a/src/anndata/_settings.pyi
+++ b/src/anndata/_settings.pyi
@@ -47,5 +47,16 @@ class _AnnDataSettingsManager(SettingsManager):
disallow_forward_slash_in_h5ad: bool = False
write_csr_csc_indices_with_min_possible_dtype: bool = False
auto_shard_zarr_v3: bool | None = None
+ repr_html_enabled: bool = True
+ repr_html_fold_threshold: int = 5
+ repr_html_max_depth: int = 3
+ repr_html_max_items: int = 200
+ repr_html_max_categories: int = 20
+ repr_html_unique_limit: int = 1_000_000
+ repr_html_dataframe_expand: bool = False
+ repr_html_max_field_width: int = 400
+ repr_html_type_width: int = 220
+ repr_html_max_lazy_categories: int = 100
+ repr_html_max_readme_size: int = 100_000
settings: _AnnDataSettingsManager
diff --git a/tests/repr/__init__.py b/tests/repr/__init__.py
new file mode 100644
index 000000000..ebe87200d
--- /dev/null
+++ b/tests/repr/__init__.py
@@ -0,0 +1 @@
+"""Tests for anndata._repr module."""
diff --git a/tests/repr/conftest.py b/tests/repr/conftest.py
new file mode 100644
index 000000000..019ea654f
--- /dev/null
+++ b/tests/repr/conftest.py
@@ -0,0 +1,272 @@
+"""
+Shared fixtures for repr tests.
+"""
+
+from __future__ import annotations
+
+import re
+
+import numpy as np
+import pandas as pd
+import pytest
+import scipy.sparse as sp
+
+from anndata import AnnData
+
+# Import HTML validation utilities from separate module
+from .html_validator import (
+ HTMLValidator,
+ StrictHTMLParser,
+ validate_html5_strict,
+)
+
+# Re-export validation utilities for use by other test modules
+__all__ = ["HTMLValidator", "StrictHTMLParser", "validate_html5_strict"]
+
+
+def pytest_configure(config):
+ """Suppress ImplicitModificationWarning for all repr tests.
+
+ This warning is expected when AnnData transforms indices internally
+ during singledispatch in functools.
+ """
+ import warnings
+
+ from anndata._warnings import ImplicitModificationWarning
+
+ warnings.filterwarnings("ignore", category=ImplicitModificationWarning)
+
+
+# =============================================================================
+# Optional Dependencies
+# =============================================================================
+
+try:
+ import dask.array as da # noqa: F401
+
+ HAS_DASK = True
+except ImportError:
+ HAS_DASK = False
+
+try:
+ import cupy as cp # noqa: F401
+
+ HAS_CUPY = True
+except ImportError:
+ HAS_CUPY = False
+
+try:
+ import awkward as ak # noqa: F401
+
+ HAS_AWKWARD = True
+except ImportError:
+ HAS_AWKWARD = False
+
+try:
+ import xarray # noqa: F401
+
+ HAS_XARRAY = True
+except ImportError:
+ HAS_XARRAY = False
+
+
+# =============================================================================
+# HTML5 Validation Fixture
+# =============================================================================
+
+
+@pytest.fixture
+def validate_html5():
+ """
+ Fixture for strict HTML5 validation using Nu Html Checker.
+
+ Usage:
+ def test_valid_html5(validate_html5):
+ html = adata._repr_html_()
+ errors = validate_html5(html)
+ assert not errors, f"HTML5 validation errors: {errors}"
+
+ Skips validation if vnu is not installed.
+ Install via: brew install vnu OR pip install html5-validator
+ """
+
+ def _validate(html: str) -> list[str]:
+ return validate_html5_strict(html)
+
+ return _validate
+
+
+# =============================================================================
+# Optional JavaScript Validation
+# =============================================================================
+
+# Check for esprima (pure Python JS parser) availability
+try:
+ import esprima # noqa: F401
+
+ HAS_ESPRIMA = True
+except ImportError:
+ HAS_ESPRIMA = False
+
+
+def validate_javascript_syntax(html: str) -> list[str]:
+ """
+ Validate JavaScript syntax in HTML script tags.
+
+ Returns list of syntax errors.
+ Requires: pip install esprima
+
+ This is a lightweight alternative to ESLint that doesn't require Node.js.
+ """
+ if not HAS_ESPRIMA:
+ return []
+
+ import esprima
+
+ errors = []
+ script_pattern = r""
+ scripts = re.findall(script_pattern, html, re.DOTALL | re.I)
+
+ for i, script in enumerate(scripts):
+ if not script.strip():
+ continue
+ try:
+ esprima.parseScript(script, tolerant=True)
+ except esprima.Error as e:
+ errors.append(f"Script {i + 1}: {e}")
+
+ return errors
+
+
+@pytest.fixture
+def validate_js():
+ """
+ Fixture for JavaScript syntax validation.
+
+ Usage:
+ def test_valid_js(validate_js):
+ html = adata._repr_html_()
+ errors = validate_js(html)
+ assert not errors, f"JavaScript errors: {errors}"
+
+ Skips validation if esprima is not installed.
+ Install via: pip install esprima
+ """
+
+ def _validate(html: str) -> list[str]:
+ return validate_javascript_syntax(html)
+
+ return _validate
+
+
+# =============================================================================
+# Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def adata():
+ """Basic AnnData for testing."""
+ return AnnData(
+ np.random.randn(100, 50).astype(np.float32),
+ obs=pd.DataFrame(
+ {"batch": ["A", "B"] * 50}, index=[f"cell_{i}" for i in range(100)]
+ ),
+ var=pd.DataFrame(
+ {"gene_name": [f"gene_{i}" for i in range(50)]},
+ index=[f"gene_{i}" for i in range(50)],
+ ),
+ )
+
+
+@pytest.fixture
+def adata_full():
+ """AnnData with all attributes populated."""
+ n_obs, n_vars = 100, 50
+ adata = AnnData(
+ sp.random(n_obs, n_vars, density=0.1, format="csr", dtype=np.float32),
+ obs=pd.DataFrame({
+ "batch": pd.Categorical(["A", "B"] * (n_obs // 2)),
+ "n_counts": np.random.randint(1000, 10000, n_obs),
+ "cell_type": pd.Categorical(
+ ["T", "B", "NK"] * (n_obs // 3) + ["T"] * (n_obs % 3)
+ ),
+ }),
+ var=pd.DataFrame({
+ "gene_name": [f"gene_{i}" for i in range(n_vars)],
+ "highly_variable": np.random.choice([True, False], n_vars),
+ }),
+ )
+ adata.uns["neighbors"] = {"params": {"n_neighbors": 15}}
+ adata.uns["batch_colors"] = ["#FF0000", "#00FF00"]
+ adata.obsm["X_pca"] = np.random.randn(n_obs, 50).astype(np.float32)
+ adata.obsm["X_umap"] = np.random.randn(n_obs, 2).astype(np.float32)
+ adata.varm["PCs"] = np.random.randn(n_vars, 50).astype(np.float32)
+ adata.layers["raw"] = sp.random(n_obs, n_vars, density=0.1, format="csr")
+ adata.obsp["distances"] = sp.random(n_obs, n_obs, density=0.01, format="csr")
+ adata.varp["gene_corr"] = sp.random(n_vars, n_vars, density=0.1, format="csr")
+ return adata
+
+
+@pytest.fixture
+def adata_with_colors():
+ """AnnData with color annotations."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.obs["cluster"] = pd.Categorical(["A", "B", "C"] * 3 + ["A"])
+ adata.uns["cluster_colors"] = ["#FF0000", "#00FF00", "#0000FF"]
+ return adata
+
+
+@pytest.fixture
+def adata_with_nested():
+ """AnnData with nested AnnData in uns."""
+ inner = AnnData(np.zeros((5, 3)))
+ outer = AnnData(np.zeros((10, 5)))
+ outer.uns["nested_adata"] = inner
+ return outer
+
+
+@pytest.fixture
+def adata_with_special_chars():
+ """AnnData with special characters in names."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.obs["col"
+ scripts = re.findall(script_pattern, self.html, re.DOTALL | re.I)
+
+ for script in scripts:
+ if js_fragment in script:
+ return self
+
+ raise AssertionError(
+ msg or f"JavaScript fragment '{js_fragment}' not found in scripts"
+ )
+
+ def assert_collapse_functionality_present(
+ self, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert collapse/expand functionality is present in HTML.
+
+ Section collapse uses native / elements.
+ Entry-level expand uses JS classList.toggle.
+ """
+ has_details = " or entry JS) not found in HTML"
+ )
+ return self
+
+ def assert_section_initially_collapsed(
+ self, section_name: str, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert a section starts collapsed ( without open attribute)."""
+ # Match without an open attribute
+ pattern = rf']*data-section="{re.escape(section_name)}"[^>]*>'
+ match = re.search(pattern, self.html)
+ if not match:
+ raise AssertionError(
+ msg or f"Section '{section_name}' not found as element"
+ )
+ tag = match.group(0)
+ # The section should NOT have the open attribute
+ if re.search(r"\bopen\b", tag):
+ raise AssertionError(
+ msg
+ or f"Section '{section_name}' has 'open' attribute (expected collapsed)"
+ )
+ return self
+
+ def assert_section_not_initially_collapsed(
+ self, section_name: str, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert a section starts expanded ( with open attribute)."""
+ self.assert_section_exists(section_name)
+ # Match with an open attribute
+ pattern = rf']*data-section="{re.escape(section_name)}"[^>]*>'
+ match = re.search(pattern, self.html)
+ if not match:
+ raise AssertionError(
+ msg or f"Section '{section_name}' not found as element"
+ )
+ tag = match.group(0)
+ if not re.search(r"\bopen\b", tag):
+ raise AssertionError(
+ msg
+ or f"Section '{section_name}' missing 'open' attribute (expected expanded)"
+ )
+ return self
+
+ def assert_has_event_handler(
+ self, event: str, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert elements have specific event handlers."""
+ inline_pattern = rf"on{event}\s*="
+ listener_pattern = rf"addEventListener\s*\(\s*['\"]?{event}['\"]?"
+
+ has_handler = re.search(inline_pattern, self.html, re.I) or re.search(
+ listener_pattern, self.html
+ )
+
+ if not has_handler:
+ raise AssertionError(
+ msg or f"Event handler for '{event}' not found in HTML"
+ )
+ return self
+
+ def assert_has_data_attribute(
+ self,
+ attr_name: str,
+ expected_value: str | None = None,
+ *,
+ msg: str | None = None,
+ ) -> HTMLValidator:
+ """Assert data-* attributes exist with optional value check."""
+ if expected_value is not None:
+ pattern = (
+ rf'data-{re.escape(attr_name)}=["\']?{re.escape(expected_value)}["\']?'
+ )
+ else:
+ pattern = rf"data-{re.escape(attr_name)}="
+
+ if not re.search(pattern, self.html):
+ if expected_value:
+ raise AssertionError(
+ msg or f"data-{attr_name}='{expected_value}' not found in HTML"
+ )
+ raise AssertionError(msg or f"data-{attr_name} attribute not found in HTML")
+ return self
+
+ def assert_truncation_indicator(self, *, msg: str | None = None) -> HTMLValidator:
+ """Assert truncation is indicated with specific patterns.
+
+ Checks for actual truncation indicators used by the repr system:
+ - `...+{number}` pattern (e.g., "...+20" for categories)
+ - `... and {number} more` pattern (e.g., "... and 100 more" for rows)
+ - CSS class `anndata-section__truncated`
+ - Escaped ellipsis in category display
+ """
+ truncation_patterns = [
+ r"\.\.\.\+\d+", # ...+N pattern (categories)
+ r"\.\.\.\s+and\s+[\d,]+\s+more", # "... and N more" (rows)
+ r"anndata-section__truncated", # CSS class for truncation
+ r"…", # HTML entity for ellipsis
+ r"…", # Numeric HTML entity for ellipsis
+ ]
+ has_truncation = any(
+ re.search(pattern, self.html, re.I) for pattern in truncation_patterns
+ )
+ if not has_truncation:
+ raise AssertionError(msg or "No truncation indicator found in HTML")
+ return self
+
+ def assert_error_shown(
+ self, error_text: str | None = None, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert an error message is shown in the HTML output.
+
+ The repr system should show errors visibly, not hide them.
+ Errors are typically shown as:
+ - Text containing 'error:' or 'Error'
+ - Elements with 'error' class
+ - Text with exception names (e.g., 'AttributeError', 'RuntimeError')
+ """
+ if error_text:
+ if error_text not in self.html:
+ raise AssertionError(
+ msg or f"Error text '{error_text}' not found in HTML"
+ )
+ return self
+
+ # Check for generic error indicators
+ has_error = (
+ "error:" in self.html.lower()
+ or re.search(r"\bError\b", self.html)
+ or "anndata-entry--error" in self.html
+ or ("anndata-text--muted" in self.html and "error" in self.html.lower())
+ )
+ if not has_error:
+ raise AssertionError(msg or "No error indicator found in HTML")
+ return self
+
+ def assert_no_raw_xss(self, *, msg: str | None = None) -> HTMLValidator:
+ """Assert no raw XSS payloads exist in executable context.
+
+ Checks that common XSS attack vectors are properly escaped.
+ Note: Escaped content showing XSS strings as visible text is OK.
+ We only flag actual executable payloads (unescaped in HTML attributes).
+ """
+ # Get content after the style tag (where user content would be)
+ style_end = self.html.find("")
+ content = self.html[style_end:] if style_end > 0 else self.html
+
+ # Check for actual executable script injection
+ # Raw ", content, re.DOTALL | re.I
+ )
+ for script in scripts:
+ # Our own scripts have specific patterns
+ is_our_script = (
+ "anndata" in script.lower() or "toggle" in script.lower()
+ )
+ has_xss_pattern = "alert" in script or "eval(" in script
+ if not is_our_script and has_xss_pattern:
+ raise AssertionError(
+ msg or "Potential XSS: executable script tag found"
+ )
+
+ # Check for event handlers in HTML tags (actual attributes, not text)
+ # Pattern: ]+\s{handler}\s*="
+ if re.search(pattern, content, re.I):
+ # Verify it's not in our own legitimate HTML
+ match = re.search(pattern, content, re.I)
+ if match:
+ # Check if it's part of user content (escaped) or real attribute
+ context = content[max(0, match.start() - 50) : match.end() + 50]
+ if "<" not in context and """ not in context:
+ raise AssertionError(
+ msg or f"Potential XSS: {handler} handler in tag attribute"
+ )
+
+ # Check for javascript: URLs in href attributes
+ if re.search(r'href\s*=\s*["\']?\s*javascript:', content, re.I):
+ raise AssertionError(msg or "Potential XSS: javascript: URL in href")
+
+ return self
+
+ def assert_html_well_formed(self, *, msg: str | None = None) -> HTMLValidator:
+ """Assert HTML is well-formed (balanced tags, no duplicate IDs)."""
+ parser = StrictHTMLParser()
+ parser.feed(self.html)
+ if parser.errors:
+ raise AssertionError(msg or f"HTML is malformed: {parser.errors}")
+ if parser.tag_stack:
+ raise AssertionError(msg or f"Unclosed tags: {parser.tag_stack}")
+ return self
+
+ def assert_accessibility_attribute(
+ self, attr: str, *, msg: str | None = None
+ ) -> HTMLValidator:
+ """Assert accessibility attributes exist (aria-*, role, etc.)."""
+ pattern = rf"{re.escape(attr)}="
+ if not re.search(pattern, self.html):
+ raise AssertionError(
+ msg or f"Accessibility attribute '{attr}' not found in HTML"
+ )
+ return self
+
+ def count_elements(self, selector: str) -> int:
+ """Count elements matching selector."""
+ pattern = self._selector_to_pattern(selector)
+ return len(re.findall(pattern, self.html))
+
+ def get_text_content(self) -> str:
+ """Get visible text content (stripped of tags)."""
+ visible_html = re.sub(
+ r"<(style|script)[^>]*>.*?\1>", "", self.html, flags=re.DOTALL | re.I
+ )
+ return re.sub(r"<[^>]+>", " ", visible_html)
+
+ def _selector_to_pattern(self, selector: str) -> str: # noqa: PLR0911
+ """Convert CSS-like selector to regex pattern."""
+ if selector.startswith("#"):
+ id_val = selector[1:]
+ return rf'<[^>]+id=["\']?{re.escape(id_val)}["\']?[^>]*>'
+ elif selector.startswith("."):
+ class_val = selector[1:]
+ return (
+ rf'<[^>]+class=["\'][^"\']*\b{re.escape(class_val)}\b[^"\']*["\'][^>]*>'
+ )
+ elif "[" in selector:
+ match = re.match(r"\[(\w+)(?:=([^\]]+))?\]", selector)
+ if match:
+ attr, val = match.groups()
+ if val:
+ val = val.strip("\"'")
+ return rf'<[^>]+{attr}=["\']?{re.escape(val)}["\']?[^>]*>'
+ return rf"<[^>]+{attr}=[^>]*>"
+ elif "." in selector:
+ tag, class_val = selector.split(".", 1)
+ return rf'<{tag}[^>]+class=["\'][^"\']*\b{re.escape(class_val)}\b[^"\']*["\'][^>]*>'
+ else:
+ return rf"<{selector}[^>]*>"
+
+ return selector # Fallback
+
+ def _selector_to_content_pattern(self, selector: str) -> str:
+ """Convert selector to pattern that captures element content."""
+ if selector.startswith("."):
+ class_val = selector[1:]
+ return (
+ rf'<[^>]+class=["\'][^"\']*\b{re.escape(class_val)}\b[^"\']*["\'][^>]*>'
+ rf"(.*?)[^>]+>"
+ )
+ elif selector.startswith("#"):
+ id_val = selector[1:]
+ return rf'<[^>]+id=["\']?{re.escape(id_val)}["\']?[^>]*>(.*?)[^>]+>'
+ else:
+ return rf"<{selector}[^>]*>(.*?){selector}>"
+
+
+# =============================================================================
+# HTML Structure Validation
+# =============================================================================
+
+VOID_ELEMENTS = frozenset({
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr",
+})
+
+
+class StrictHTMLParser(HTMLParser):
+ """Validates HTML structure and catches common errors."""
+
+ def __init__(self):
+ super().__init__()
+ self.tag_stack = []
+ self.errors = []
+ self.ids_seen = set()
+
+ def handle_starttag(self, tag, attrs):
+ if tag not in VOID_ELEMENTS:
+ self.tag_stack.append(tag)
+
+ # Check for duplicate IDs
+ for name, value in attrs:
+ if name == "id":
+ if value in self.ids_seen:
+ self.errors.append(f"Duplicate ID: {value}")
+ self.ids_seen.add(value)
+
+ def handle_endtag(self, tag):
+ if tag not in VOID_ELEMENTS:
+ if not self.tag_stack or self.tag_stack[-1] != tag:
+ self.errors.append(f"Mismatched tag: {tag}>")
+ else:
+ self.tag_stack.pop()
+
+
+# =============================================================================
+# Optional W3C HTML5 Validation
+# =============================================================================
+
+# Check for vnu (Nu Html Checker) availability
+try:
+ import subprocess
+
+ result = subprocess.run(
+ ["vnu", "--version"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ HAS_VNU = result.returncode == 0
+except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
+ HAS_VNU = False
+
+
+def validate_html5_strict(html: str) -> list[str]:
+ """
+ Validate HTML5 using Nu Html Checker (vnu) if available.
+
+ Returns list of validation errors/warnings.
+ Requires: pip install html5-validator OR system vnu installation.
+ """
+ if not HAS_VNU:
+ return [] # Skip if not available
+
+ import json
+ import tempfile
+
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f:
+ full_html = f"""
+
+Test
+{html}
+"""
+ f.write(full_html)
+ f.flush()
+
+ try:
+ result = subprocess.run(
+ ["vnu", "--format", "json", f.name],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.stderr:
+ data = json.loads(result.stderr)
+ return [
+ f"{msg['type']}: {msg['message']}"
+ for msg in data.get("messages", [])
+ ]
+ except (subprocess.TimeoutExpired, json.JSONDecodeError):
+ pass
+ finally:
+ from pathlib import Path
+
+ Path(f.name).unlink()
+
+ return []
diff --git a/tests/repr/test_html_validator.py b/tests/repr/test_html_validator.py
new file mode 100644
index 000000000..d77c3b21c
--- /dev/null
+++ b/tests/repr/test_html_validator.py
@@ -0,0 +1,734 @@
+"""
+Tests for HTMLValidator and examples of proper HTML testing patterns.
+
+These tests demonstrate the recommended approach for testing HTML repr output:
+- Use structured assertions instead of string matching
+- Validate element presence and attributes
+- Check text appears in correct elements
+- Verify sections and entries are properly rendered
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+import pytest
+import scipy.sparse as sp
+
+from anndata import AnnData
+
+from .conftest import HTMLValidator
+
+
+def _get_top_level_selectors(css: str) -> list[str]:
+ """Extract only top-level CSS selectors (brace depth 0).
+
+ With native CSS nesting, nested selectors inherit scope from
+ their parent and don't need individual scope checking.
+ """
+ selectors = []
+ depth = 0
+ current: list[str] = []
+ for char in css:
+ if char == "{":
+ if depth == 0:
+ selector = "".join(current).strip()
+ if selector:
+ selectors.append(selector)
+ depth += 1
+ current = []
+ elif char == "}":
+ depth -= 1
+ depth = max(depth, 0)
+ current = []
+ elif depth == 0:
+ current.append(char)
+ return selectors
+
+
+class TestHTMLValidatorBasics:
+ """Tests for HTMLValidator functionality."""
+
+ def test_assert_element_exists_by_class(self):
+ """Test finding elements by class selector."""
+ html = '
content
'
+ v = HTMLValidator(html)
+ v.assert_element_exists(".my-class")
+
+ def test_assert_element_exists_by_id(self):
+ """Test finding elements by ID selector."""
+ html = '
content
'
+ v = HTMLValidator(html)
+ v.assert_element_exists("#my-id")
+
+ def test_assert_element_exists_by_tag(self):
+ """Test finding elements by tag selector."""
+ html = "content"
+ v = HTMLValidator(html)
+ v.assert_element_exists("span")
+
+ def test_assert_element_exists_by_attribute(self):
+ """Test finding elements by attribute selector."""
+ html = '
content
'
+ v = HTMLValidator(html)
+ v.assert_element_exists("[data-section=obs]")
+
+ def test_assert_element_not_exists(self):
+ """Test asserting element doesn't exist."""
+ html = '
content
'
+ v = HTMLValidator(html)
+ v.assert_element_not_exists(".missing-class")
+
+ def test_assert_element_exists_fails(self):
+ """Test assertion fails when element missing."""
+ html = "
content
"
+ v = HTMLValidator(html)
+ with pytest.raises(AssertionError, match="not found"):
+ v.assert_element_exists(".missing")
+
+ def test_assert_text_visible(self):
+ """Test asserting text is visible (not in style/script)."""
+ html = "
visible text
"
+ v = HTMLValidator(html)
+ v.assert_text_visible("visible text")
+ with pytest.raises(AssertionError):
+ v.assert_text_visible("hidden")
+
+ def test_assert_text_in_element(self):
+ """Test asserting text appears in specific element."""
+ html = '
expected text
other
'
+ v = HTMLValidator(html)
+ v.assert_text_in_element(".target", "expected text")
+
+ def test_assert_section_exists(self):
+ """Test asserting data section exists."""
+ html = '
'
+ v = HTMLValidator(html)
+ (
+ v
+ .assert_element_exists(".a")
+ .assert_section_exists("obs")
+ .assert_text_visible("text")
+ )
+
+
+class TestHTMLValidatorWithAnnData:
+ """Tests demonstrating HTMLValidator with actual AnnData repr."""
+
+ def test_basic_structure(self, validate_html):
+ """Test basic AnnData repr structure."""
+ adata = AnnData(np.zeros((100, 50)))
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ # Validate structure, not just string presence
+ v.assert_element_exists(".anndata-repr")
+ v.assert_shape_displayed(100, 50)
+
+ def test_sections_present(self, validate_html):
+ """Test all populated sections are present."""
+ adata = AnnData(
+ np.zeros((10, 5)),
+ obs=pd.DataFrame({"batch": ["A", "B"] * 5}),
+ var=pd.DataFrame({"gene": range(5)}),
+ )
+ adata.uns["key"] = "value"
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_exists("obs")
+ v.assert_section_exists("var")
+ v.assert_section_exists("uns")
+
+ def test_obs_entries_in_correct_section(self, validate_html):
+ """Test obs column names appear in obs section."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.obs["cell_type"] = pd.Categorical(["A", "B"] * 5)
+ adata.obs["n_counts"] = range(10)
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_contains_entry("obs", "cell_type")
+ v.assert_section_contains_entry("obs", "n_counts")
+
+ def test_view_badge_displayed_correctly(self, validate_html):
+ """Test View badge is shown for views."""
+ adata = AnnData(np.zeros((10, 5)))
+ view = adata[0:5, :]
+ html = view._repr_html_()
+ v = validate_html(html)
+
+ v.assert_badge_shown("view")
+ v.assert_shape_displayed(5, 5) # View shape, not original
+
+ def test_view_badge_not_shown_for_non_view(self, validate_html):
+ """Test View badge is NOT shown for non-views."""
+ adata = AnnData(np.zeros((10, 5)))
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_badge_not_shown("view")
+
+ def test_sparse_matrix_dtype_displayed(self, validate_html):
+ """Test sparse matrix shows dtype."""
+ X = sp.random(100, 50, density=0.1, format="csr", dtype=np.float32)
+ adata = AnnData(X)
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_dtype_displayed("float32")
+ v.assert_text_visible("csr")
+
+ def test_categorical_with_colors(self, validate_html):
+ """Test categorical with colors shows color values."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.obs["cluster"] = pd.Categorical(["A", "B"] * 5)
+ adata.uns["cluster_colors"] = ["#FF0000", "#00FF00"]
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_contains_entry("obs", "cluster")
+ v.assert_color_swatch("#FF0000")
+ v.assert_color_swatch("#00FF00")
+
+ def test_warning_for_unserializable(self, validate_html):
+ """Test warning indicator for unserializable objects."""
+
+ class CustomClass:
+ pass
+
+ adata = AnnData(np.zeros((5, 3)))
+ adata.uns["custom"] = CustomClass()
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_contains_entry("uns", "custom")
+ v.assert_warning_indicator()
+
+ def test_backed_badge(self, validate_html, tmp_path):
+ """Test backed badge is shown for backed AnnData with sparse X."""
+ from scipy import sparse
+
+ import anndata as ad
+
+ # Use sparse matrix to cover BackedSparseDatasetFormatter
+ adata = AnnData(sparse.random(100, 50, density=0.1, format="csr"))
+ path = tmp_path / "test.h5ad"
+ adata.write_h5ad(path)
+
+ backed = ad.read_h5ad(path, backed="r")
+ html = backed._repr_html_()
+ v = validate_html(html)
+
+ v.assert_badge_shown("backed")
+ # Verify sparse matrix is shown with "on disk" indicator
+ v.assert_text_visible("on disk")
+ backed.file.close()
+
+ def test_raw_section_visible(self, validate_html):
+ """Test raw section is visible when raw is set."""
+ adata = AnnData(np.zeros((10, 20)))
+ adata.raw = adata.copy()
+ adata = adata[:, :5]
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_exists("raw")
+ # Raw should show original var count
+ v.assert_text_visible("20")
+
+ def test_layers_section(self, validate_html):
+ """Test layers section shows layer names."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.layers["counts"] = np.ones((10, 5))
+ adata.layers["normalized"] = np.zeros((10, 5))
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ v.assert_section_exists("layers")
+ v.assert_section_contains_entry("layers", "counts")
+ v.assert_section_contains_entry("layers", "normalized")
+
+
+class TestMigrationExamples:
+ """Examples showing how to migrate old-style tests to HTMLValidator.
+
+ OLD STYLE (string matching):
+ html = adata._repr_html_()
+ assert "batch" in html
+ assert "View" in html
+
+ NEW STYLE (structured validation):
+ v = validate_html(html)
+ v.assert_section_contains_entry("obs", "batch")
+ v.assert_badge_shown("view")
+ """
+
+ def test_old_vs_new_section_check(self, validate_html):
+ """Compare old vs new section checking."""
+ adata = AnnData(np.zeros((10, 5)))
+ adata.obs["batch"] = ["A", "B"] * 5
+ html = adata._repr_html_()
+
+ # OLD: String in HTML (could match CSS class name, comment, etc.)
+ assert "batch" in html # Weak assertion
+
+ # NEW: Entry in correct section
+ v = validate_html(html)
+ v.assert_section_contains_entry("obs", "batch") # Strong assertion
+
+ def test_old_vs_new_badge_check(self, validate_html):
+ """Compare old vs new badge checking."""
+ adata = AnnData(np.zeros((10, 5)))
+ view = adata[0:5, :]
+ html = view._repr_html_()
+
+ # OLD: String matching (could match "View" in documentation, comments, etc.)
+ assert "View" in html # Weak assertion
+
+ # NEW: Check for actual badge element
+ v = validate_html(html)
+ v.assert_badge_shown("view") # Strong assertion - checks CSS class
+
+ def test_old_vs_new_shape_check(self, validate_html):
+ """Compare old vs new shape checking."""
+ adata = AnnData(np.zeros((123, 456)))
+ html = adata._repr_html_()
+
+ # OLD: Numbers anywhere in HTML
+ assert "123" in html # Could match anything
+ assert "456" in html
+
+ # NEW: Explicit shape check
+ v = validate_html(html)
+ v.assert_shape_displayed(123, 456)
+
+
+class TestJupyterNotebookCompatibility:
+ """Tests for Jupyter Notebook/Lab HTML compatibility.
+
+ These tests ensure the HTML repr works correctly when embedded
+ in Jupyter output cells, including:
+ - CSS scoping (no style leakage)
+ - JavaScript isolation (no global pollution)
+ - Multiple cell compatibility (unique IDs)
+ - Jupyter theming support
+ """
+
+ def test_css_scoped_to_anndata_repr(self, adata_full):
+ """Test CSS rules are scoped to .anndata-repr container.
+
+ Unscoped CSS could affect other notebook cells or UI elements.
+ With native CSS nesting, only top-level selectors need checking
+ since nested selectors inherit scope from their parent.
+ """
+ import re
+
+ html = adata_full._repr_html_()
+ style_match = re.search(r"", html, re.DOTALL)
+
+ if style_match:
+ css = style_match.group(1)
+
+ # Remove CSS comments
+ css_clean = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
+
+ # Extract only top-level selectors (brace depth 0).
+ # With native CSS nesting, nested selectors inherit scope
+ # from their parent and don't need individual checking.
+ selectors = _get_top_level_selectors(css_clean)
+
+ for selector in selectors:
+ # Skip :root (used for CSS variables)
+ if ":root" in selector:
+ continue
+ # Skip Jupyter theme selectors (these are intentionally global)
+ if "[data-jp-theme" in selector or ".jp-" in selector:
+ continue
+ # Skip @media / @keyframes (parsed separately)
+ if selector.startswith("@"):
+ continue
+
+ # All other selectors should be scoped to anndata-repr
+ assert ".anndata-repr" in selector or "anndata" in selector.lower(), (
+ f"CSS selector '{selector}' is not scoped to .anndata-repr. "
+ "This could affect other Jupyter cells."
+ )
+
+ def test_no_global_element_selectors(self, adata_full):
+ """Test no unscoped element selectors like 'div', 'span', etc.
+
+ Global element selectors would style ALL divs/spans in the notebook.
+ With native CSS nesting, only top-level selectors are checked since
+ nested element selectors (e.g., `td` inside `.anndata-repr`) are scoped.
+ """
+ import re
+
+ html = adata_full._repr_html_()
+ style_match = re.search(r"", html, re.DOTALL)
+
+ if style_match:
+ css = style_match.group(1)
+ css_clean = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
+
+ # Only check top-level selectors (brace depth 0)
+ selectors = _get_top_level_selectors(css_clean)
+
+ bare_elements = {
+ "div",
+ "span",
+ "table",
+ "tr",
+ "td",
+ "th",
+ "ul",
+ "li",
+ "p",
+ "a",
+ "button",
+ }
+ global_elements = [
+ s
+ for s in selectors
+ if s.strip().split(",")[0].strip().split()[0] in bare_elements
+ ]
+ assert not global_elements, (
+ f"Found global element selectors: {global_elements}. "
+ "These would affect the entire notebook."
+ )
+
+ def test_javascript_uses_iife_or_closure(self, adata_full):
+ """Test JavaScript is wrapped to avoid global scope pollution."""
+ import re
+
+ html = adata_full._repr_html_()
+ script_matches = re.findall(
+ r"", html, re.DOTALL | re.I
+ )
+
+ for script in script_matches:
+ script = script.strip()
+ if not script:
+ continue
+
+ # Check for IIFE pattern: (function() { ... })() or (() => { ... })()
+ has_iife = bool(
+ re.search(r"\(\s*function\s*\([^)]*\)\s*\{", script)
+ or re.search(r"\(\s*\([^)]*\)\s*=>\s*\{", script)
+ )
+
+ # Check for block scope (const/let at top level within braces)
+ has_block_scope = bool(re.search(r"\{\s*(const|let)\s+", script))
+
+ # Check for event handler inline (scoped to element)
+ is_event_handler = bool(re.search(r"\.addEventListener\s*\(", script))
+
+ # Should use one of these isolation patterns
+ assert has_iife or has_block_scope or is_event_handler, (
+ "JavaScript should use IIFE, block scope, or event handlers "
+ "to avoid polluting global scope in Jupyter."
+ )
+
+ def test_no_global_function_declarations(self, adata_full):
+ """Test no unscoped 'function name()' declarations.
+
+ Named function declarations at top level would pollute global scope.
+ """
+ import re
+
+ html = adata_full._repr_html_()
+ script_matches = re.findall(
+ r"", html, re.DOTALL | re.I
+ )
+
+ for script in script_matches:
+ # Look for "function name(" not inside another function/IIFE
+ # This is a simplified check - looks for function at start of line
+ global_funcs = re.findall(
+ r"^\s*function\s+(\w+)\s*\(", script, re.MULTILINE
+ )
+
+ # Filter out functions that are clearly inside IIFEs
+ # (script starts with "(function" or "(() =>")
+ if script.strip().startswith("("):
+ continue # Inside IIFE, OK
+
+ assert not global_funcs, (
+ f"Found global function declarations: {global_funcs}. "
+ "Use const name = function() or wrap in IIFE."
+ )
+
+ def test_unique_ids_per_render(self, adata):
+ """Test each render produces unique element IDs.
+
+ Multiple AnnData cells in same notebook must not have ID collisions.
+ """
+ import re
+
+ html1 = adata._repr_html_()
+ html2 = adata._repr_html_()
+
+ ids1 = set(re.findall(r'id=["\']([^"\']+)["\']', html1))
+ ids2 = set(re.findall(r'id=["\']([^"\']+)["\']', html2))
+
+ # If both have IDs, they should be different (use unique prefixes)
+ if ids1 and ids2:
+ # At least the container IDs should be different
+ overlap = ids1 & ids2
+ # Allow empty overlap or ensure IDs use unique suffixes
+ for id_val in overlap:
+ # IDs like "anndata-repr" without unique suffix are problematic
+ assert re.search(r"[a-f0-9]{6,}|_\d+$", id_val), (
+ f"ID '{id_val}' appears in both renders without unique suffix. "
+ "This could cause conflicts in Jupyter notebooks with multiple cells."
+ )
+
+ def test_jupyter_dark_mode_support(self, adata, validate_html):
+ """Test dark mode CSS uses Jupyter-compatible selectors."""
+ html = adata._repr_html_()
+
+ # Should use light-dark() with color-scheme for theming
+ assert "light-dark(" in html, "CSS should use light-dark() for theming"
+
+ # Should support at least one Jupyter dark mode detection method
+ has_jp_theme = "[data-jp-theme-light" in html
+ has_jp_dark_class = ".jp-Theme-Dark" in html
+
+ assert has_jp_theme or has_jp_dark_class, (
+ "HTML should support Jupyter dark mode via "
+ "[data-jp-theme-light] or .jp-Theme-Dark"
+ )
+
+ def test_no_document_level_operations(self, adata_full):
+ """Test JavaScript doesn't use document-level operations unsafely.
+
+ Operations like document.querySelector without scoping could
+ affect elements in other cells.
+ """
+ import re
+
+ html = adata_full._repr_html_()
+ script_matches = re.findall(
+ r"", html, re.DOTALL | re.I
+ )
+
+ for script in script_matches:
+ # Check for document.querySelector without container scoping
+ # OK: container.querySelector, element.querySelector
+ # Risky: document.querySelector(".class") without context
+
+ # This is a heuristic check - look for document.querySelector
+ # that doesn't immediately follow a variable assignment from
+ # a scoped search
+ if "document.querySelector" in script:
+ # Should be immediately scoped, e.g.:
+ # const container = document.getElementById('unique-id')
+ # container.querySelector(...)
+ has_scoped_search = bool(
+ re.search(r"getElementById\s*\(['\"][\w-]+['\"]", script)
+ )
+ assert has_scoped_search, (
+ "document.querySelector should be scoped to a container "
+ "obtained via getElementById with unique ID"
+ )
+
+ def test_html_valid_as_fragment(self, adata_full, validate_html5):
+ """Test HTML is valid when embedded in a typical Jupyter output div.
+
+ Jupyter wraps output in:
...
+ """
+ html = adata_full._repr_html_()
+
+ # Wrap in typical Jupyter output structure
+ jupyter_wrapper = f"""
+
+
+
+
+ {html}
+
+
+
+
+ """
+
+ errors = validate_html5(jupyter_wrapper)
+ # Filter expected fragment-related warnings
+ critical = [
+ e
+ for e in errors
+ if not e.startswith("info:")
+ and "style" not in e.lower()
+ and "script" not in e.lower()
+ # vnu's CSS parser doesn't support native CSS nesting
+ # https://github.com/w3c/css-validator/issues/431
+ and "parse error" not in e.lower()
+ ]
+ assert not critical, "HTML invalid in Jupyter context:\n" + "\n".join(critical)
+
+ def test_multiple_cells_valid_html(self, validate_html5):
+ """Test multiple AnnData reprs together produce valid HTML.
+
+ Simulates having multiple AnnData output cells in one notebook view.
+ """
+ # Create different AnnData objects
+ adata1 = AnnData(np.zeros((10, 5)))
+ adata1.obs["batch"] = ["A", "B"] * 5
+
+ adata2 = AnnData(np.zeros((20, 8)))
+ adata2.uns["key"] = "value"
+
+ adata3 = AnnData(sp.random(100, 50, density=0.1, format="csr"))
+
+ html1 = adata1._repr_html_()
+ html2 = adata2._repr_html_()
+ html3 = adata3._repr_html_()
+
+ # Combine as if in multiple notebook cells
+ combined = f"""
+
{html1}
+
{html2}
+
{html3}
+ """
+
+ errors = validate_html5(combined)
+ critical = [
+ e
+ for e in errors
+ if not e.startswith("info:")
+ and "style" not in e.lower()
+ and "script" not in e.lower()
+ # Duplicate styles are OK in fragments
+ and "duplicate" not in e.lower()
+ # vnu's CSS parser doesn't support native CSS nesting
+ # https://github.com/w3c/css-validator/issues/431
+ and "parse error" not in e.lower()
+ ]
+ assert not critical, "Combined cells invalid:\n" + "\n".join(critical)
+
+ def test_no_id_collisions_multiple_cells(self):
+ """Test no ID collisions when multiple cells are rendered."""
+ import re
+
+ adata1 = AnnData(np.zeros((10, 5)))
+ adata2 = AnnData(np.zeros((20, 8)))
+ adata3 = AnnData(np.zeros((15, 6)))
+
+ html1 = adata1._repr_html_()
+ html2 = adata2._repr_html_()
+ html3 = adata3._repr_html_()
+
+ ids1 = re.findall(r'id=["\']([^"\']+)["\']', html1)
+ ids2 = re.findall(r'id=["\']([^"\']+)["\']', html2)
+ ids3 = re.findall(r'id=["\']([^"\']+)["\']', html3)
+
+ all_ids = ids1 + ids2 + ids3
+ if all_ids:
+ # Check for duplicates
+ seen = set()
+ duplicates = []
+ for id_val in all_ids:
+ if id_val in seen:
+ duplicates.append(id_val)
+ seen.add(id_val)
+
+ assert not duplicates, (
+ f"Duplicate IDs across cells: {duplicates}. "
+ "Each cell must have unique IDs."
+ )
+
+ def test_css_variables_prefixed(self, adata):
+ """Test CSS variables use anndata prefix to avoid conflicts."""
+ import re
+
+ html = adata._repr_html_()
+
+ # Find all CSS variable definitions (not BEM modifiers like --copied)
+ # CSS vars are defined as: --name: value; (note single colon)
+ # BEM modifiers are: .block--modifier (in class names)
+ css_vars = re.findall(r"(? with unique ID prefix
+ # 3. Use CSS variables that cascade properly
+
+ # Check that the main container uses scoped class
+ assert 'class="anndata-repr' in html or "class='anndata-repr" in html, (
+ "Main container should use .anndata-repr class for CSS scoping"
+ )
+
+ def test_works_without_jupyter_css_variables(self, adata, validate_html):
+ """Test repr works even without Jupyter CSS variables defined.
+
+ The repr should have sensible defaults when --jp-* variables
+ are not available (e.g., when viewed outside Jupyter).
+ """
+ html = adata._repr_html_()
+ v = validate_html(html)
+
+ # Should still render all key elements
+ v.assert_element_exists(".anndata-repr")
+ v.assert_shape_displayed(100, 50)
+
+ # Should have fallback colors defined (not relying solely on --jp-* vars)
+ # Check that colors are defined in the CSS, not just referenced
+ assert "#" in html or "rgb" in html.lower(), (
+ "Should define fallback colors for non-Jupyter environments"
+ )
diff --git a/tests/repr/test_repr_core.py b/tests/repr/test_repr_core.py
new file mode 100644
index 000000000..63f8b8bd2
--- /dev/null
+++ b/tests/repr/test_repr_core.py
@@ -0,0 +1,1292 @@
+"""
+Core HTML repr tests: validation, basic generation, settings, header/footer.
+"""
+
+from __future__ import annotations
+
+import re
+
+import numpy as np
+import pandas as pd
+import scipy.sparse as sp
+
+from anndata import AnnData
+
+from .conftest import StrictHTMLParser
+
+
+class TestHTMLValidation:
+ """Validate generated HTML is well-formed and standards-compliant."""
+
+ def test_html_well_formed(self, adata):
+ """Test HTML is parseable and well-formed."""
+ html = adata._repr_html_()
+ parser = StrictHTMLParser()
+ parser.feed(html)
+ assert not parser.errors, f"HTML errors: {parser.errors}"
+ assert not parser.tag_stack, f"Unclosed tags: {parser.tag_stack}"
+
+ def test_no_duplicate_ids(self, adata_full):
+ """Test no duplicate element IDs within same repr."""
+ html = adata_full._repr_html_()
+ ids = re.findall(r'id=["\']([^"\']+)["\']', html)
+ assert len(ids) == len(set(ids)), f"Duplicate IDs found: {ids}"
+
+ def test_all_links_have_href(self, adata_full):
+ """Test all anchor tags have href attribute."""
+ html = adata_full._repr_html_()
+ a_without_href = re.search(r"]*href=)[^>]*>", html)
+ assert a_without_href is None, "Found without href"
+
+ def test_style_tag_valid_css(self, adata):
+ """Test inline CSS has balanced braces."""
+ html = adata._repr_html_()
+ style_match = re.search(r"", html, re.DOTALL)
+ if style_match:
+ css = style_match.group(1)
+ assert css.count("{") == css.count("}"), "Unbalanced CSS braces"
+
+ def test_escaped_user_content(self, adata_with_special_chars):
+ """Test that user-provided content is properly escaped."""
+ html = adata_with_special_chars._repr_html_()
+ # Should not contain raw ", "", stripped, flags=re.DOTALL)
+
+ # Rich HTML is visible (no display:none)
+ assert '
This page displays various AnnData configurations to visually verify the HTML representation.
+"""
+ ]
+
+ for item in sections:
+ title = item[0]
+ html_content = item[1]
+ description = item[2] if len(item) > 2 else None
+
+ # Create anchor ID from title (same logic as TOC generation)
+ anchor_id = title.lower().replace(" ", "-").replace("(", "").replace(")", "")
+ anchor_id = "".join(c for c in anchor_id if c.isalnum() or c == "-")
+
+ desc_html = ""
+ if description:
+ desc_html = f'
{description}
'
+
+ html_parts.append(f"""
+
+
{title}
+ {desc_html}
+
+ {html_content}
+
+
+""")
+
+ html_parts.append("""
+
+
+""")
+
+ return "".join(html_parts)
+
+
+def strip_script_tags(html: str) -> str:
+ """Remove tags from HTML to simulate no-JS environment."""
+ import re
+
+ return re.sub(r"", "", html, flags=re.DOTALL)
+
+
+def strip_style_and_script_tags(html: str) -> str:
+ """Remove ", "", html, flags=re.DOTALL)
+ html = re.sub(r"", "", html, flags=re.DOTALL)
+ return html
+
+
+def main(): # noqa: PLR0915, PLR0912
+ """Generate visual test HTML file."""
+ print("Generating visual test cases...")
+
+ sections = []
+
+ # Test 1: Full AnnData
+ print(" 1. Full AnnData with all features")
+ adata_full = create_test_anndata()
+ sections.append((
+ "1. Full AnnData (all features)",
+ adata_full._repr_html_(),
+ "A comprehensive AnnData with all standard attributes populated: X (sparse matrix), "
+ "obs/var with multiple columns including categoricals with colors, "
+ "obsm/varm with embeddings, uns with nested data, layers, and obsp/varp. "
+ "Use this as the baseline reference for a typical annotated dataset. "
+ "Each section header has a ? icon that links to the relevant anndata documentation, "
+ "and hovering over the section name shows a tooltip describing that attribute.",
+ ))
+
+ # Test 2: Empty AnnData
+ print(" 2. Empty AnnData")
+ adata_empty = AnnData()
+ sections.append((
+ "2. Empty AnnData",
+ adata_empty._repr_html_(),
+ "An AnnData with no data (0 × 0). Tests graceful handling of the edge case "
+ "where all sections are empty. Should show the header with shape and no sections.",
+ ))
+
+ # Test 3: Minimal AnnData
+ print(" 3. Minimal AnnData (just X)")
+ adata_minimal = AnnData(np.zeros((10, 5)))
+ sections.append((
+ "3. Minimal AnnData (just X matrix)",
+ adata_minimal._repr_html_(),
+ "Only an X matrix with no annotations. Tests the minimal case where only X section "
+ "is shown. obs/var exist with default integer indices but have no columns.",
+ ))
+
+ # Test 4: View
+ print(" 4. AnnData View")
+ view = adata_full[0:20, 0:10]
+ sections.append((
+ "4. AnnData View (subset)",
+ view._repr_html_(),
+ "A view (subset) of Test 1. Should display a 'View' badge in the header indicating "
+ "this is a reference to underlying data, not a copy. The shape shows the subset dimensions.",
+ ))
+
+ # Test 5: Dense matrix
+ print(" 5. Dense matrix")
+ adata_dense = AnnData(np.random.randn(50, 30).astype(np.float32))
+ adata_dense.obs["cluster"] = pd.Categorical(["A", "B", "C", "D", "E"] * 10)
+ adata_dense.uns["cluster_colors"] = [
+ "#e41a1c",
+ "#377eb8",
+ "#4daf4a",
+ "#984ea3",
+ "#ff7f00",
+ ]
+ sections.append((
+ "5. Dense Matrix with Categories",
+ adata_dense._repr_html_(),
+ "Dense numpy array X (not sparse). The X section shows 'ndarray' instead of CSR/CSC. "
+ "Also demonstrates categorical column with associated colors from uns (color dots appear).",
+ ))
+
+ # Test 6: Many columns (collapsed sections)
+ print(" 6. Many columns (tests folding)")
+ adata_many = AnnData(np.zeros((20, 10)))
+ for i in range(15):
+ adata_many.obs[f"column_{i}"] = list(range(20))
+ for i in range(12):
+ adata_many.obsm[f"X_embedding_{i}"] = np.random.randn(20, 2).astype(np.float32)
+ sections.append((
+ "6. Many Columns (tests auto-folding)",
+ adata_many._repr_html_(),
+ "Sections with many entries (15 obs columns, 12 obsm embeddings) to test auto-folding. "
+ "Sections with >8 items collapse by default and show a fold indicator. "
+ "Click the section header or fold icon to expand/collapse.",
+ ))
+
+ # Test 7: Special characters
+ print(" 7. Special characters in names")
+ adata_special = AnnData(np.zeros((5, 3)))
+ adata_special.obs["columnhtml"] = list(range(5))
+ adata_special.obs["column&ersand"] = list(range(5))
+ adata_special.uns["key\"with'quotes"] = "value"
+ adata_special.uns["unicode_日本語"] = "japanese"
+ sections.append((
+ "7. Special Characters (XSS/Unicode test)",
+ adata_special._repr_html_(),
+ "Tests proper HTML escaping and Unicode handling. Column names with <html> tags, "
+ "ampersands, quotes, and Japanese characters should render correctly without breaking "
+ "the layout or causing XSS vulnerabilities.",
+ ))
+
+ # Test 8a: Dask array (if available) - demonstrates lazy loading safety
+ if HAS_DASK:
+ print(" 8a. Dask array (lazy loading safety)")
+ # Create Dask arrays in multiple sections to show lazy handling
+ X_dask = da.random.random((1000, 500), chunks=(100, 100))
+ adata_dask = AnnData(X_dask)
+ adata_dask.obs["cluster"] = pd.Categorical(["A", "B", "C"] * 333 + ["A"])
+ adata_dask.var["gene_name"] = [f"gene_{i}" for i in range(500)]
+ # Dask arrays in layers and obsm
+ adata_dask.layers["counts"] = da.random.randint(
+ 0, 100, (1000, 500), chunks=(100, 100)
+ )
+ adata_dask.obsm["X_pca"] = da.random.random((1000, 50), chunks=(100, 50))
+ adata_dask.varm["loadings"] = da.random.random((500, 50), chunks=(100, 50))
+ sections.append((
+ "8a. Dask Arrays (Lazy Loading Safety)",
+ adata_dask._repr_html_(),
+ "Regular AnnData with Dask arrays — no .compute() triggered! "
+ "
This is a normal (in-memory) AnnData where X, layers, obsm, and varm "
+ "are Dask arrays. The repr reads only metadata attributes:
"
+ "
"
+ "
X: shape, dtype, chunks from Dask's lazy metadata
"
+ "
layers['counts']: Same — no computation
"
+ "
obsm['X_pca'], varm['loadings']: shape from .shape
"
+ "
"
+ "
Key distinction from 8b/8c: This object is not backed by "
+ "a file. The obs/var DataFrames are regular pandas objects in memory. "
+ "The 'lazy' aspect here refers only to Dask not computing array values.
",
+ ))
+
+ # Test 8b: Lazy AnnData (experimental) - fully lazy obs/var
+ # Tests the lazy category loading behavior:
+ # - Categorical columns with few categories: load and display categories
+ # - Categorical columns with too many categories: show "(lazy)" to avoid loading
+ # - Categorical columns with colors in uns: display color swatches
+ # - Non-categorical columns: show "(lazy)" for all
+ if HAS_XARRAY:
+ print(" 8b. Lazy AnnData (experimental read_lazy)")
+ with tempfile.NamedTemporaryFile(suffix=".h5ad", delete=False) as tmp:
+ tmp_path = tmp.name
+ adata_lazy = None
+ h5_file = None
+ try:
+ import h5py
+
+ # Create a comprehensive test file for lazy loading behavior
+ adata_to_save = AnnData(
+ sp.random(1000, 500, density=0.1, format="csr", dtype=np.float32)
+ )
+
+ # --- Categorical columns ---
+ # 1. Small categorical WITH colors (should show categories + color dots)
+ adata_to_save.obs["cell_type"] = pd.Categorical(
+ np.random.choice(["T cell", "B cell", "Monocyte", "NK cell"], 1000)
+ )
+ adata_to_save.uns["cell_type_colors"] = [
+ "#e41a1c", # T cell - red
+ "#377eb8", # B cell - blue
+ "#4daf4a", # Monocyte - green
+ "#984ea3", # NK cell - purple
+ ]
+
+ # 2. Small categorical WITHOUT colors (should show categories only)
+ adata_to_save.obs["cluster"] = pd.Categorical(
+ np.random.choice(["C0", "C1", "C2", "C3", "C4"], 1000)
+ )
+
+ # 3. Medium categorical (50 cats) - will show truncation with max_lazy_categories=30
+ medium_categories = [f"sample_{i}" for i in range(50)]
+ adata_to_save.obs["sample_id"] = pd.Categorical(
+ np.random.choice(medium_categories, 1000),
+ categories=medium_categories, # Ensure all 50 categories exist
+ )
+
+ # --- Non-categorical columns (all should show "(lazy)") ---
+ adata_to_save.obs["n_genes"] = np.random.randint(500, 5000, 1000)
+ adata_to_save.obs["total_counts"] = np.random.randint(1000, 50000, 1000)
+
+ # --- var columns ---
+ adata_to_save.var["gene_symbol"] = [f"GENE{i}" for i in range(500)]
+ adata_to_save.var["highly_variable"] = np.random.choice([True, False], 500)
+ adata_to_save.var["mean_expression"] = np.random.uniform(0, 10, 500)
+
+ # --- obsm/varm ---
+ adata_to_save.obsm["X_pca"] = np.random.randn(1000, 50).astype(np.float32)
+ adata_to_save.obsm["X_umap"] = np.random.randn(1000, 2).astype(np.float32)
+ adata_to_save.varm["PCs"] = np.random.randn(500, 50).astype(np.float32)
+
+ # --- uns with array (to show dask array WITH size in uns) ---
+ adata_to_save.uns["neighbors"] = {
+ "connectivities_key": "connectivities",
+ "distances_key": "distances",
+ }
+ adata_to_save.uns["pca_variance"] = np.random.rand(50).astype(np.float32)
+
+ adata_to_save.write_h5ad(tmp_path)
+
+ # Read with experimental lazy loading
+ h5_file = h5py.File(tmp_path, "r")
+ adata_lazy = read_lazy(h5_file)
+
+ # Use setting to demonstrate truncation behavior (default is 100)
+ # - cell_type (4 cats): all shown
+ # - cluster (5 cats): all shown
+ # - sample_id (50 cats): first 30 shown + "...+20"
+ original_max_lazy_cats = ad.settings.repr_html_max_lazy_categories
+ ad.settings.repr_html_max_lazy_categories = 30
+ custom_lazy_html = adata_lazy._repr_html_()
+ ad.settings.repr_html_max_lazy_categories = original_max_lazy_cats
+
+ sections.append((
+ "8b. Lazy AnnData (Experimental)",
+ custom_lazy_html,
+ "anndata.experimental.read_lazy() "
+ "
File-backed lazy AnnData — category labels loaded from disk!
"
+ "
"
+ "The header shows a Lazy (H5AD) badge and the file path (similar to backed mode). "
+ "Unlike 8a (in-memory) and 8c (metadata-only), this repr actually reads data from the HDF5 file:
"
+ "
What IS loaded from disk:
"
+ "
"
+ "
cell_type: 4 category labels + 4 colors from uns
"
+ "
cluster: 5 category labels (no colors)
"
+ "
sample_id: first 30 of 50 category labels (truncated by max_lazy_categories=30)
"
+ "
"
+ "
What is NOT loaded:
"
+ "
"
+ "
Numeric data (dask arrays not computed)
"
+ "
Category codes (only labels, not which cell has which category)
"
+ "
Categories beyond the max_lazy_categories limit
"
+ "
Non-categorical column values (show as '(lazy)')
"
+ "
"
+ "
"
+ "Compare with 8c to see the same object with zero disk I/O.
"
+ "Compare this output to 8b — this is the exact same lazy AnnData object, "
+ "but with max_lazy_categories=0 to prevent any data loading. "
+ "The header still shows the Lazy (H5AD) badge and file path.
"
+ "
What's NOT loaded (unlike 8b):
"
+ "
"
+ "
Category labels — only shows (N categories) count from dtype metadata
"
+ "
Colors from uns — no color dots displayed
"
+ "
"
+ "
What IS shown (from already-loaded metadata):
"
+ "
"
+ "
Category count (e.g., '4 categories') from the dtype (already in memory)
"
+ "
Column names and types
"
+ "
Array shapes and dtypes
"
+ "
"
+ "
Use case: Fastest possible repr when you want to avoid "
+ "all disk access (e.g., network-mounted storage, very large files).
",
+ ))
+
+ except (OSError, ImportError, TypeError) as e:
+ print(f" Warning: Failed to create lazy example: {e}")
+ finally:
+ if h5_file is not None:
+ h5_file.close()
+ Path(tmp_path).unlink()
+
+ # Test 8d: Lazy AnnData with Zarr format
+ print(" 8d. Lazy AnnData (Zarr format)")
+ import shutil
+
+ zarr_path = Path(tempfile.mkdtemp(suffix=".zarr"))
+ try:
+ import zarr
+
+ # Create test data for zarr
+ adata_zarr_save = AnnData(
+ sp.random(800, 400, density=0.1, format="csr", dtype=np.float32)
+ )
+ adata_zarr_save.obs["tissue"] = pd.Categorical(
+ np.random.choice(["Brain", "Heart", "Liver", "Lung", "Kidney"], 800)
+ )
+ adata_zarr_save.uns["tissue_colors"] = [
+ "#e41a1c",
+ "#377eb8",
+ "#4daf4a",
+ "#984ea3",
+ "#ff7f00",
+ ]
+ adata_zarr_save.obs["donor"] = pd.Categorical(
+ np.random.choice([f"D{i}" for i in range(10)], 800)
+ )
+ adata_zarr_save.obs["n_counts"] = np.random.randint(1000, 50000, 800)
+ adata_zarr_save.var["gene_name"] = [f"GENE{i}" for i in range(400)]
+ adata_zarr_save.obsm["X_umap"] = np.random.randn(800, 2).astype(np.float32)
+
+ # Write to zarr
+ adata_zarr_save.write_zarr(zarr_path)
+
+ # Read lazily from zarr
+ zarr_store = zarr.open_group(zarr_path, mode="r")
+ adata_lazy_zarr = read_lazy(zarr_store)
+
+ sections.append((
+ "8d. Lazy AnnData (Zarr Format)",
+ adata_lazy_zarr._repr_html_(),
+ "anndata.experimental.read_lazy(zarr_store) "
+ "
Lazy AnnData backed by Zarr storage
"
+ "
"
+ "The header shows a Lazy (Zarr) badge and the zarr directory path. "
+ "Zarr is particularly useful for cloud storage (S3, GCS) and parallel access.
"
+ "
Same lazy behavior as 8b/8c:
"
+ "
"
+ "
Category labels loaded on demand (respects max_lazy_categories)
Key difference from 8b (lazy): Backed mode loads obs/var "
+ "DataFrames fully into memory, while lazy mode keeps them as dask-backed xarray.
"
+ "
What the repr reads:
"
+ "
"
+ "
X.shape, X.dtype, X.nnz — from HDF5 attributes
"
+ "
obs/var DataFrames — fully loaded in memory
"
+ "
obsm/varm shapes — from HDF5 dataset attributes
"
+ "
"
+ "
What stays on disk:
"
+ "
"
+ "
The actual X matrix data (memory-mapped, not loaded)
"
+ "
",
+ ))
+ finally:
+ if adata_backed is not None:
+ adata_backed.file.close()
+ Path(tmp_path).unlink()
+
+ # Test 10: Nested AnnData at depth
+ print(" 10. Deeply nested AnnData")
+ inner3 = AnnData(np.zeros((3, 2)))
+ inner2 = AnnData(np.zeros((5, 3)))
+ inner2.uns["level3"] = inner3
+ inner1 = AnnData(np.zeros((10, 5)))
+ inner1.uns["level2"] = inner2
+ outer = AnnData(np.zeros((20, 10)))
+ outer.uns["level1"] = inner1
+ sections.append((
+ "10. Deeply Nested AnnData (tests max depth)",
+ outer._repr_html_(),
+ "AnnData with 3 levels of nesting in uns (outer → level1 → level2 → level3). "
+ "Tests the max_depth limit for nested repr. By default, nesting stops at depth 3, "
+ "so level3 should show as a collapsed entry without further expansion. "
+ "Click expand arrows to drill into the nested structure.",
+ ))
+
+ # Test 11: Many categories (tests truncation and wrap button)
+ # Default max_categories is 100, but we set it to 20 here to test truncation
+ print(" 11. Many categories (tests category truncation)")
+ adata_many_cats = AnnData(np.zeros((100, 10)))
+ # 30 categories - with max_categories=20 should show first 20 + '...+10'
+ many_cat_values = [f"type_{i}" for i in range(30)] * (100 // 30) + [
+ f"type_{i}" for i in range(100 % 30)
+ ]
+ adata_many_cats.obs["cell_type"] = pd.Categorical(many_cat_values)
+ # Add colors for the categories
+ adata_many_cats.uns["cell_type_colors"] = [
+ "#e41a1c",
+ "#377eb8",
+ "#4daf4a",
+ "#984ea3",
+ "#ff7f00",
+ "#ffff33",
+ "#a65628",
+ "#f781bf",
+ "#999999",
+ "#66c2a5",
+ "#fc8d62",
+ "#8da0cb",
+ "#e78ac3",
+ "#a6d854",
+ "#ffd92f",
+ "#e5c494",
+ "#b3b3b3",
+ "#1b9e77",
+ "#d95f02",
+ "#7570b3",
+ "#e7298a",
+ "#66a61e",
+ "#e6ab02",
+ "#a6761d",
+ "#666666",
+ "#8dd3c7",
+ "#ffffb3",
+ "#bebada",
+ "#fb8072",
+ "#80b1d3",
+ ]
+ # Also add a column with exactly 20 categories
+ adata_many_cats.obs["batch"] = pd.Categorical([f"batch_{i}" for i in range(20)] * 5)
+ adata_many_cats.uns["batch_colors"] = [
+ "#1f77b4",
+ "#ff7f0e",
+ "#2ca02c",
+ "#d62728",
+ "#9467bd",
+ "#8c564b",
+ "#e377c2",
+ "#7f7f7f",
+ "#bcbd22",
+ "#17becf",
+ "#aec7e8",
+ "#ffbb78",
+ "#98df8a",
+ "#ff9896",
+ "#c5b0d5",
+ "#c49c94",
+ "#f7b6d2",
+ "#c7c7c7",
+ "#dbdb8d",
+ "#9edae5",
+ ]
+ # Use lower max_categories (default is 100) to demonstrate truncation
+ original_max_cats = ad.settings.repr_html_max_categories
+ ad.settings.repr_html_max_categories = 20
+ sections.append((
+ "11. Many Categories (tests truncation)",
+ adata_many_cats._repr_html_(),
+ "
Category truncation with max_categories=20 (default: 100)
"
+ "
"
+ "
cell_type (30 cats): shows first 20 with colors, then '...+10' indicator
"
+ "
batch (20 cats): shows all 20 (exactly at limit)
"
+ "
"
+ "
Click the ▼ arrow button to expand and see all categories. "
+ "The expand button appears only when categories are truncated. "
+ "Colors are shown for all displayed categories from uns['{col}_colors'].
",
+ ))
+ ad.settings.repr_html_max_categories = original_max_cats
+
+ # Test 12: Uns value previews and custom TypeFormatter
+ print(" 12. Uns value previews and type hints")
+
+ # Register a custom TypeFormatter for tagged data in uns
+ @register_formatter
+ class AnalysisHistoryFormatter(TypeFormatter):
+ """Example TypeFormatter for analysis history data with embedded type hint."""
+
+ priority = 100 # High priority to check before fallback
+
+ def can_format(self, obj, context):
+ hint, _ = extract_uns_type_hint(obj)
+ return hint == "example.history"
+
+ def format(self, obj, context):
+ import json
+
+ _hint, value = extract_uns_type_hint(obj)
+
+ # Parse JSON if string, otherwise use as-is
+ if isinstance(value, str):
+ try:
+ data = json.loads(value)
+ except json.JSONDecodeError:
+ data = {"raw": value}
+ else:
+ data = value if isinstance(value, dict) else {"data": value}
+
+ # Build a rich HTML preview
+ runs = data.get("runs", [])
+ params = data.get("params", {})
+
+ html_parts = ['
']
+ if runs:
+ html_parts.append(f"{len(runs)} runs")
+ if params:
+ param_str = ", ".join(f"{k}={v}" for k, v in list(params.items())[:3])
+ if len(params) > 3:
+ param_str += "..."
+ html_parts.append(f" · params: {param_str}")
+ html_parts.append("
")
+
+ return FormattedOutput(
+ type_name="analysis history",
+ preview_html="".join(html_parts), # Use preview_html for inline preview
+ )
+
+ adata_uns = AnnData(np.zeros((10, 5)))
+ # Simple types with previews
+ adata_uns.uns["string_param"] = "A short string value"
+ adata_uns.uns["long_string"] = (
+ "This is a very long string that should be truncated in the preview because it exceeds the maximum length allowed for display in the meta column"
+ )
+ adata_uns.uns["int_param"] = 42
+ adata_uns.uns["float_param"] = 3.14159265359
+ adata_uns.uns["bool_param"] = True
+ adata_uns.uns["none_param"] = None
+ adata_uns.uns["small_list"] = [1, 2, 3]
+ adata_uns.uns["small_dict"] = {"a": 1, "b": 2}
+ adata_uns.uns["larger_dict"] = {
+ "key1": "val1",
+ "key2": "val2",
+ "key3": "val3",
+ "key4": "val4",
+ "key5": "val5",
+ }
+
+ # Type hint WITH registered renderer (shows custom HTML)
+ adata_uns.uns["analysis_history"] = {
+ "__anndata_repr__": "example.history",
+ "runs": [{"id": 1}, {"id": 2}, {"id": 3}],
+ "params": {"method": "umap", "n_neighbors": 15, "metric": "euclidean"},
+ }
+
+ # Type hint WITHOUT registered renderer (shows fallback with import hint)
+ adata_uns.uns["unregistered_data"] = {
+ "__anndata_repr__": "otherpackage.custom_type",
+ "data": {"some": "data", "values": [1, 2, 3]},
+ }
+ # String format type hint (also unregistered)
+ adata_uns.uns["string_hint"] = (
+ "__anndata_repr__:otherpackage.config::{'setting': 'value'}"
+ )
+
+ sections.append((
+ "12. Uns Value Previews and Type Hints",
+ adata_uns._repr_html_(),
+ "
Uns entries with value previews and type hint system
"
+ "
"
+ "
Simple types: strings, ints, floats, bools, None show inline previews
"
+ "
long_string: truncated with ellipsis when exceeding max length