banners

Composable slides for marimo presentations.

Build a presentation by combining slide classes with content elements:

from banners.slides import Cover, Intro, Section, Closing
from banners.content import Text, Image, Graph
from banners.palette import Palette, BLUE, GREEN

Cover(
    title="My Project",
    subtitle="Context in one line.",
    date="April 2026",
    content=[
        Text("**Point 1** — Something relevant."),
        Text("**Point 2** — Concrete result."),
        Text("**Point 3** — Team impact."),
    ],
    content_kind="neutral",
).render()
 1"""Composable slides for marimo presentations.
 2
 3Build a presentation by combining slide classes with content elements:
 4
 5```python
 6from banners.slides import Cover, Intro, Section, Closing
 7from banners.content import Text, Image, Graph
 8from banners.palette import Palette, BLUE, GREEN
 9
10Cover(
11    title="My Project",
12    subtitle="Context in one line.",
13    date="April 2026",
14    content=[
15        Text("**Point 1** — Something relevant."),
16        Text("**Point 2** — Concrete result."),
17        Text("**Point 3** — Team impact."),
18    ],
19    content_kind="neutral",
20).render()
21```
22"""
23
24from .slide import Slide
25from .slides import Cover, Intro, Section, Closing
26from .content import Text, Image, Graph, AnimatedGraph, Table, Plot, Manim, FlowAnimation
27from .palette import Palette, SectionPalette, ORANGE, BLUE, GREEN, PURPLE, GRAY
28from .background import Background
29from .config import configure
30
31__all__ = [
32    "configure",
33    "Slide",
34    "Cover", "Intro", "Section", "Closing",
35    "Text", "Image", "Graph", "AnimatedGraph", "Table", "Plot", "Manim", "FlowAnimation",
36    "Palette", "SectionPalette", "ORANGE", "BLUE", "GREEN", "PURPLE", "GRAY",
37    "Background",
38]
def configure( team: str = <object object>, date: str = <object object>, icon=<object object>, palette=<object object>, background=<object object>) -> None:
16def configure(
17    team: str = _UNSET,
18    date: str = _UNSET,
19    icon=_UNSET,
20    palette=_UNSET,
21    background=_UNSET,
22) -> None:
23    """Set global defaults shared by all slides and reset the section counter.
24
25    Call this once at the top of the notebook. Any slide parameter left at its
26    default will inherit the value set here. Calling `configure()` without
27    arguments only resets the section counter.
28
29    Args:
30        team: Team line shown in `Cover`, `Intro`, and `Closing` banners.
31        date: Date string shown in `Cover` (e.g. `"April 2026"`).
32        icon: Icon applied to all slides. Pass a dict with keys `"src"`
33            (path, URL, or bytes) and optionally `"size"` (CSS height).
34        palette: Color palette applied to `Cover`, `Intro`, and `Closing`.
35            Pass any `Palette` instance or predefined constant
36            (`BLUE`, `GREEN`, `PURPLE`, `GRAY`).
37        background: Background applied to all slides. Pass a `Background`
38            instance (`Background.color()`, `Background.gradient()`, or
39            `Background.image()`). Individual slides can override this with
40            their own `background` argument.
41
42    Example:
43        ```python
44        from banners import configure, Background
45        from banners.palette import BLUE
46
47        configure(
48            team="Analytics Team",
49            date="April 2026",
50            palette=BLUE,
51            icon={"src": "img/logo.png"},
52            background=Background.gradient("#0f172a", "#1e3a5f"),
53        )
54        ```
55    """
56    if team is not _UNSET:
57        _state["team"] = team
58    if date is not _UNSET:
59        _state["date"] = date
60    if icon is not _UNSET:
61        _state["icon"] = icon
62    if palette is not _UNSET:
63        _state["palette"] = palette
64    if background is not _UNSET:
65        _state["background"] = background
66    _state["_section_counter"] = 0

Set global defaults shared by all slides and reset the section counter.

Call this once at the top of the notebook. Any slide parameter left at its default will inherit the value set here. Calling configure() without arguments only resets the section counter.

Arguments:
Example:
from banners import configure, Background
from banners.palette import BLUE

configure(
    team="Analytics Team",
    date="April 2026",
    palette=BLUE,
    icon={"src": "img/logo.png"},
    background=Background.gradient("#0f172a", "#1e3a5f"),
)
class Slide:
 25class Slide:
 26    """Abstract base class for all slide types.
 27
 28    Subclasses must implement `_render_banner` to produce the banner HTML.
 29    The `render` method assembles the full slide as a `mo.vstack`.
 30
 31    Content layout is resolved automatically based on what is passed:
 32
 33    - **Single item** — rendered at full width.
 34    - **List of N items** — rendered as an equal-width CSS grid (`1fr` per
 35      column) regardless of type. Pass `content_kind` to wrap `Text` items
 36      in a `mo.callout` box.
 37
 38    Args:
 39        title: Main heading of the slide.
 40        subtitle: Support phrase displayed inside the banner.
 41        content: Content elements below the banner. Accepts a single
 42            `Text`, `Image`, or `Graph` instance, a list of them, or any
 43            marimo component.
 44        palette: Color palette for the banner. Defaults to `ORANGE`.
 45        content_kind: Box style applied to `Text` items when the content is
 46            a list of text-only elements. Accepted values: `"neutral"`,
 47            `"info"`, `"success"`, `"warn"`, `"danger"`.
 48
 49    Example:
 50        ```python
 51        from banners.slides import Section
 52        from banners.content import Text, Graph
 53
 54        Section(
 55            title="Pipeline",
 56            content=[
 57                Text("Each stage runs independently."),
 58                Graph("graph LR\\n  A --> B --> C"),
 59            ],
 60        ).render()
 61        ```
 62    """
 63
 64    def __init__(
 65        self,
 66        title: str,
 67        subtitle: str = "",
 68        content: "list[ContentItem] | ContentItem | None" = None,
 69        palette: Palette | None = None,
 70        content_kind: "str | None" = None,
 71        footer: str = "",
 72        background=None,
 73    ) -> None:
 74        self.title = title
 75        self.subtitle = subtitle
 76        self.content = content
 77        self.palette = palette or Palette()
 78        self.content_kind = content_kind
 79        self.footer = footer
 80        self.background = background
 81
 82    def render(self) -> mo.Html:
 83        """Assemble and return the complete slide as a marimo component.
 84
 85        Returns:
 86            A `mo.Html` component (via `mo.vstack`) ready to display in a
 87            marimo cell.
 88        """
 89        banner = self._render_banner()
 90        content_parts = self._render_content() + self._render_footer()
 91        return self._wrap_slide(banner, content_parts)
 92
 93    def _wrap_slide(self, banner, content_parts: list):
 94        if self.background is None:
 95            return mo.vstack([banner] + content_parts)
 96        bg_css = self.background.css if hasattr(self.background, "css") else str(self.background)
 97        banner_html = mo.as_html(banner).text
 98        if content_parts:
 99            content_inner = mo.as_html(mo.vstack(content_parts)).text
100            tc = self.background.text_color() if (
101                self.content_kind is None and hasattr(self.background, "text_color")
102            ) else None
103            if tc is not None:
104                uid = f"sbg{id(self):x}"
105                tags = (
106                    f".{uid} h1,.{uid} h2,.{uid} h3,.{uid} h4,.{uid} h5,.{uid} h6,"
107                    f".{uid} p,.{uid} li,.{uid} blockquote,.{uid} code,.{uid} pre,"
108                    f".{uid} em,.{uid} strong,.{uid} td,.{uid} th"
109                )
110                content_html = (
111                    f"<style>{tags}{{color:{tc}!important;}}</style>"
112                    f'<div class="{uid}" style="color:{tc};">{content_inner}</div>'
113                )
114            else:
115                content_html = content_inner
116        else:
117            content_html = ""
118        return mo.Html(
119            f'<div style="background:{bg_css};padding:1rem;border-radius:0.75rem;">'
120            f"{banner_html}{content_html}</div>"
121        )
122
123    def _render_footer(self) -> list:
124        if self.footer:
125            return [mo.md(f"> {self.footer}")]
126        return []
127
128    def _display_(self):
129        return self.render()
130
131    def _render_banner(self) -> mo.Html:
132        """Produce the banner HTML for this slide type.
133
134        Raises:
135            NotImplementedError: Must be implemented by each subclass.
136        """
137        raise NotImplementedError(f"{type(self).__name__} must implement _render_banner()")
138
139    def _render_content(self) -> list:
140        """Resolve content items into marimo components and apply layout.
141
142        Returns:
143            A list of marimo components to be stacked below the banner.
144        """
145        if self.content is None:
146            return []
147
148        items = self.content if isinstance(self.content, list) else [self.content]
149
150        if len(items) == 1:
151            return [self._render_item(items[0])]
152
153        rendered = [
154            self._render_item(item, wrap_kind=self.content_kind)
155            for item in items
156        ]
157
158        return [self._css_grid(rendered)]
159
160    def _render_item(self, item: ContentItem, wrap_kind: "str | None" = None):
161        """Render a single content item into a marimo component.
162
163        Args:
164            item: A `Text`, `Image`, `Graph`, plain string, or marimo component.
165            wrap_kind: If set and the item is `Text` or `str`, wraps the
166                rendered markdown in a `mo.callout` with this kind.
167
168        Returns:
169            A marimo component.
170        """
171        from .content.text import Text as _Text
172
173        if isinstance(item, (_Text, str)):
174            md = mo.md(item.body if isinstance(item, _Text) else item)
175            return mo.callout(md, kind=wrap_kind) if wrap_kind else md
176
177        if hasattr(item, "render") and callable(item.render):
178            return item.render()
179
180        return item
181
182    @staticmethod
183    def _css_grid(items: list) -> mo.Html:
184        """Wrap rendered items in an equal-width CSS grid container.
185
186        Args:
187            items: List of marimo `mo.Html` components.
188
189        Returns:
190            A `mo.Html` component with a CSS grid layout (`1fr` per column).
191        """
192        cols = " ".join(["1fr"] * len(items))
193        parts = []
194        for item in items:
195            html = mo.as_html(item).text
196            parts.append(f'<div style="min-width:0;">{html}</div>')
197        inner = "".join(parts)
198        return mo.Html(
199            f'<div style="display:grid;grid-template-columns:{cols};'
200            f'gap:1rem;align-items:start;">{inner}</div>'
201        )
202
203    @staticmethod
204    def _tag_line(text: str, color: str) -> str:
205        return (
206            f'<div style="font-size:0.85rem;letter-spacing:0.2em;text-transform:uppercase;'
207            f'color:{color};margin-bottom:0.5rem;">{text}</div>'
208        )
209
210    @staticmethod
211    def _icon_src(icon) -> str:
212        """Convert a path, URL, or bytes to a base64 data URI.
213
214        Args:
215            icon: File path (`str` or `Path`), URL string, or raw `bytes`.
216
217        Returns:
218            A `data:image/<mime>;base64,...` string.
219        """
220        _EXT_MIME = {
221            "jpg": "jpeg", "jpeg": "jpeg", "png": "png",
222            "svg": "svg+xml", "gif": "gif", "webp": "webp",
223        }
224        if isinstance(icon, str) and icon.startswith(("http://", "https://")):
225            ext = icon.split("?")[0].rsplit(".", 1)[-1].lower()
226            mime = _EXT_MIME.get(ext, "png")
227            req = urllib.request.Request(icon, headers={"User-Agent": "Mozilla/5.0"})
228            ctx = ssl.create_default_context()
229            ctx.check_hostname = False
230            ctx.verify_mode = ssl.CERT_NONE
231            with urllib.request.urlopen(req, timeout=10, context=ctx) as r:
232                data = r.read()
233                ct = r.headers.get("Content-Type", "")
234            if "svg" in ct:
235                mime = "svg+xml"
236            elif "jpeg" in ct or "jpg" in ct:
237                mime = "jpeg"
238            elif "png" in ct:
239                mime = "png"
240            b64 = base64.b64encode(data).decode()
241            return f"data:image/{mime};base64,{b64}"
242
243        if isinstance(icon, (str, Path)):
244            p = Path(icon)
245            mime = _EXT_MIME.get(p.suffix.lower().lstrip("."), "png")
246            b64 = base64.b64encode(p.read_bytes()).decode()
247            return f"data:image/{mime};base64,{b64}"
248
249        b64 = base64.b64encode(icon).decode()
250        return f"data:image/png;base64,{b64}"
251
252    def _icon_corner_html(self, icon: "dict | None", bottom: str, right: str, default_size: str) -> str:
253        if icon is None:
254            return ""
255        try:
256            size = icon.get("size", default_size)
257            src = self._icon_src(icon["src"])
258            return (
259                f'<img src="{src}" '
260                f'style="position:absolute;bottom:{bottom};right:{right};'
261                f'height:{size};opacity:0.85;border-radius:4px;">'
262            )
263        except Exception:
264            return ""
265
266    def _icon_inline_html(self, icon: "dict | None", default_size: str, style: str = "") -> str:
267        if icon is None:
268            return ""
269        try:
270            size = icon.get("size", default_size)
271            src = self._icon_src(icon["src"])
272            return f'<img src="{src}" style="height:{size};{style}border-radius:4px;">'
273        except Exception:
274            return ""

Abstract base class for all slide types.

Subclasses must implement _render_banner to produce the banner HTML. The render method assembles the full slide as a mo.vstack.

Content layout is resolved automatically based on what is passed:

  • Single item — rendered at full width.
  • List of N items — rendered as an equal-width CSS grid (1fr per column) regardless of type. Pass content_kind to wrap Text items in a mo.callout box.
Arguments:
  • title: Main heading of the slide.
  • subtitle: Support phrase displayed inside the banner.
  • content: Content elements below the banner. Accepts a single Text, Image, or Graph instance, a list of them, or any marimo component.
  • palette: Color palette for the banner. Defaults to ORANGE.
  • content_kind: Box style applied to Text items when the content is a list of text-only elements. Accepted values: "neutral", "info", "success", "warn", "danger".
Example:
from banners.slides import Section
from banners.content import Text, Graph

Section(
    title="Pipeline",
    content=[
        Text("Each stage runs independently."),
        Graph("graph LR\n  A --> B --> C"),
    ],
).render()
Slide( title: str, subtitle: str = '', content: list[object] | object | None = None, palette: Palette | None = None, content_kind: str | None = None, footer: str = '', background=None)
64    def __init__(
65        self,
66        title: str,
67        subtitle: str = "",
68        content: "list[ContentItem] | ContentItem | None" = None,
69        palette: Palette | None = None,
70        content_kind: "str | None" = None,
71        footer: str = "",
72        background=None,
73    ) -> None:
74        self.title = title
75        self.subtitle = subtitle
76        self.content = content
77        self.palette = palette or Palette()
78        self.content_kind = content_kind
79        self.footer = footer
80        self.background = background
title
subtitle
content
palette
content_kind
footer
background
def render(self) -> marimo._output.hypertext.Html:
82    def render(self) -> mo.Html:
83        """Assemble and return the complete slide as a marimo component.
84
85        Returns:
86            A `mo.Html` component (via `mo.vstack`) ready to display in a
87            marimo cell.
88        """
89        banner = self._render_banner()
90        content_parts = self._render_content() + self._render_footer()
91        return self._wrap_slide(banner, content_parts)

Assemble and return the complete slide as a marimo component.

Returns:

A mo.Html component (via mo.vstack) ready to display in a marimo cell.

class Cover(banners.Slide):
11class Cover(Slide):
12    """Title slide with a large centered gradient banner.
13
14    Intended as the **first slide** of every presentation.
15
16    Args:
17        title: Main project title.
18        subtitle: Short context phrase displayed inside the banner.
19        date: Date string shown at the bottom of the banner (e.g. `"April 2026"`).
20        content: Elements displayed below the banner. Accepts a single
21            `Text`, `Image`, or `Graph` instance, or a list of them.
22        palette: Color palette for the banner gradient. Defaults to `ORANGE`.
23        content_kind: Box style for `Text` lists (`"neutral"`, `"info"`,
24            `"success"`, `"warn"`, `"danger"`).
25        team: Team line shown in small caps above the title.
26        icon: Institutional icon placed in the bottom-right corner of the
27            banner. Pass a dict with keys `"src"` (path, URL, or bytes) and
28            optionally `"size"` (CSS height, default `"44px"`).
29
30    Example:
31        ```python
32        from banners.slides import Cover
33        from banners.content import Text
34        from banners.palette import BLUE
35
36        Cover(
37            title="Churn Prediction v2",
38            subtitle="Model migration and deployment.",
39            date="April 2026",
40            palette=BLUE,
41            content=[
42                Text("**Accuracy** — 91% AUC on holdout."),
43                Text("**Latency** — p99 under 30 ms."),
44                Text("**Coverage** — All active segments."),
45            ],
46            content_kind="neutral",
47        ).render()
48        ```
49    """
50
51    def __init__(
52        self,
53        title: str,
54        subtitle: str = "",
55        date: str = "",
56        content=None,
57        palette: Palette | None = None,
58        content_kind: "str | None" = None,
59        footer: str = "",
60        team: str = "",
61        icon: "dict | None" = None,
62        background=None,
63    ) -> None:
64        background = background if background is not None else _cfg.get("background")
65        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
66        self.date = date or _cfg.get("date", "")
67        self.team = team or _cfg.get("team", "")
68        self.icon = icon if icon is not None else _cfg.get("icon")
69
70    def _render_banner(self) -> mo.Html:
71        p = self.palette
72        team_html = self._tag_line(self.team, p.text_tag) if self.team else ""
73        subtitle_html = (
74            f'<p style="font-size:1.3rem;color:{p.text_sub};margin:1rem 0 0.25rem;">{self.subtitle}</p>'
75            if self.subtitle else ""
76        )
77        date_html = (
78            f'<p style="font-size:0.9rem;color:{p.text_muted};margin-top:0.5rem;">{self.date}</p>'
79            if self.date else ""
80        )
81        icon_html = self._icon_corner_html(self.icon, bottom="1rem", right="1.5rem", default_size="44px")
82        return mo.Html(f"""
83        <div style="position:relative;text-align:center;padding:3rem 2rem 1.5rem;
84                    background:{p.gradient};border-radius:1rem;color:{p.text_main};margin-bottom:1.5rem;">
85            {team_html}
86            <h1 style="font-size:3.2rem;font-weight:800;margin:0.5rem 0;color:{p.text_main};line-height:1.15;">{self.title}</h1>
87            {subtitle_html}
88            {date_html}
89            {icon_html}
90        </div>
91        """)

Title slide with a large centered gradient banner.

Intended as the first slide of every presentation.

Arguments:
  • title: Main project title.
  • subtitle: Short context phrase displayed inside the banner.
  • date: Date string shown at the bottom of the banner (e.g. "April 2026").
  • content: Elements displayed below the banner. Accepts a single Text, Image, or Graph instance, or a list of them.
  • palette: Color palette for the banner gradient. Defaults to ORANGE.
  • content_kind: Box style for Text lists ("neutral", "info", "success", "warn", "danger").
  • team: Team line shown in small caps above the title.
  • icon: Institutional icon placed in the bottom-right corner of the banner. Pass a dict with keys "src" (path, URL, or bytes) and optionally "size" (CSS height, default "44px").
Example:
from banners.slides import Cover
from banners.content import Text
from banners.palette import BLUE

Cover(
    title="Churn Prediction v2",
    subtitle="Model migration and deployment.",
    date="April 2026",
    palette=BLUE,
    content=[
        Text("**Accuracy** — 91% AUC on holdout."),
        Text("**Latency** — p99 under 30 ms."),
        Text("**Coverage** — All active segments."),
    ],
    content_kind="neutral",
).render()
Cover( title: str, subtitle: str = '', date: str = '', content=None, palette: Palette | None = None, content_kind: str | None = None, footer: str = '', team: str = '', icon: dict | None = None, background=None)
51    def __init__(
52        self,
53        title: str,
54        subtitle: str = "",
55        date: str = "",
56        content=None,
57        palette: Palette | None = None,
58        content_kind: "str | None" = None,
59        footer: str = "",
60        team: str = "",
61        icon: "dict | None" = None,
62        background=None,
63    ) -> None:
64        background = background if background is not None else _cfg.get("background")
65        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
66        self.date = date or _cfg.get("date", "")
67        self.team = team or _cfg.get("team", "")
68        self.icon = icon if icon is not None else _cfg.get("icon")
date
team
icon
class Intro(banners.Slide):
 11class Intro(Slide):
 12    """Compact gradient banner for slides that open a thematic block.
 13
 14    Typically used for slides 2–3 to set context or frame the problem.
 15    Supports an optional `summary` callout block between the banner and
 16    the content area, and an optional `footer` blockquote.
 17
 18    Args:
 19        title: Slide heading.
 20        subtitle: Support phrase displayed inside the banner below the title.
 21        content: Elements displayed below the summary block. Accepts a single
 22            `Text`, `Image`, or `Graph` instance, or a list of them.
 23        palette: Color palette for the banner gradient. Defaults to `ORANGE`.
 24        content_kind: Box style for `Text` lists (`"neutral"`, `"info"`,
 25            `"success"`, `"warn"`, `"danger"`).
 26        tag: Short label shown in small caps above the title
 27            (e.g. `"Context"`, `"Problem"`). Falls back to the team line
 28            when empty.
 29        summary: Full-width highlighted block rendered between the banner
 30            and the content area. Displayed as a yellow `warn` callout.
 31        footer: Closing quote shown at the bottom of the slide as a blockquote.
 32        team: Team line used as the tag fallback.
 33        icon: Icon placed in the bottom-right corner of the banner. Pass a
 34            dict with keys `"src"` (path, URL, or bytes) and optionally
 35            `"size"` (CSS height, default `"36px"`).
 36
 37    Example:
 38        ```python
 39        from banners.slides import Intro
 40        from banners.content import Text
 41
 42        Intro(
 43            title="Why we're here",
 44            subtitle="What was at stake and what motivated this work.",
 45            tag="Context",
 46            summary="Models were running on servers with a confirmed shutdown date.",
 47            content=[
 48                Text("**Operational risk** — No fallback if the server goes down."),
 49                Text("**No traceability** — No record of the active version."),
 50                Text("**Manual process** — Undocumented and person-dependent."),
 51            ],
 52            content_kind="warn",
 53            footer="The question wasn't whether to migrate — but how.",
 54        ).render()
 55        ```
 56    """
 57
 58    def __init__(
 59        self,
 60        title: str,
 61        subtitle: str = "",
 62        content=None,
 63        palette: Palette | None = None,
 64        content_kind: "str | None" = None,
 65        tag: str = "",
 66        summary: str = "",
 67        footer: str = "",
 68        team: str = "",
 69        icon: "dict | None" = None,
 70        background=None,
 71    ) -> None:
 72        background = background if background is not None else _cfg.get("background")
 73        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
 74        self.tag = tag
 75        self.summary = summary
 76        self.team = team or _cfg.get("team", "")
 77        self.icon = icon if icon is not None else _cfg.get("icon")
 78
 79    def render(self) -> mo.Html:
 80        """Assemble banner, summary callout, content, and footer.
 81
 82        Returns:
 83            A `mo.Html` component ready to display in a marimo cell.
 84        """
 85        banner = self._render_banner()
 86        content_parts = []
 87        if self.summary:
 88            content_parts.append(mo.callout(mo.md(self.summary), kind="warn"))
 89        content_parts.extend(self._render_content())
 90        content_parts.extend(self._render_footer())
 91        return self._wrap_slide(banner, content_parts)
 92
 93    def _render_banner(self) -> mo.Html:
 94        p = self.palette
 95        label = self.tag if self.tag else self.team
 96        top_html = self._tag_line(label, p.text_tag)
 97        subtitle_html = (
 98            f'<p style="font-size:1.1rem;color:{p.text_sub};margin:0.5rem 0 0;">{self.subtitle}</p>'
 99            if self.subtitle else ""
100        )
101        icon_html = self._icon_corner_html(self.icon, bottom="0.75rem", right="1.25rem", default_size="36px")
102        return mo.Html(f"""
103        <div style="position:relative;padding:1.6rem 2rem;background:{p.gradient};
104                    border-radius:0.75rem;color:{p.text_main};margin-bottom:1.25rem;">
105            {top_html}
106            <h2 style="font-size:2rem;font-weight:700;margin:0;color:{p.text_main};line-height:1.2;">{self.title}</h2>
107            {subtitle_html}
108            {icon_html}
109        </div>
110        """)

Compact gradient banner for slides that open a thematic block.

Typically used for slides 2–3 to set context or frame the problem. Supports an optional summary callout block between the banner and the content area, and an optional footer blockquote.

Arguments:
  • title: Slide heading.
  • subtitle: Support phrase displayed inside the banner below the title.
  • content: Elements displayed below the summary block. Accepts a single Text, Image, or Graph instance, or a list of them.
  • palette: Color palette for the banner gradient. Defaults to ORANGE.
  • content_kind: Box style for Text lists ("neutral", "info", "success", "warn", "danger").
  • tag: Short label shown in small caps above the title (e.g. "Context", "Problem"). Falls back to the team line when empty.
  • summary: Full-width highlighted block rendered between the banner and the content area. Displayed as a yellow warn callout.
  • footer: Closing quote shown at the bottom of the slide as a blockquote.
  • team: Team line used as the tag fallback.
  • icon: Icon placed in the bottom-right corner of the banner. Pass a dict with keys "src" (path, URL, or bytes) and optionally "size" (CSS height, default "36px").
Example:
from banners.slides import Intro
from banners.content import Text

Intro(
    title="Why we're here",
    subtitle="What was at stake and what motivated this work.",
    tag="Context",
    summary="Models were running on servers with a confirmed shutdown date.",
    content=[
        Text("**Operational risk** — No fallback if the server goes down."),
        Text("**No traceability** — No record of the active version."),
        Text("**Manual process** — Undocumented and person-dependent."),
    ],
    content_kind="warn",
    footer="The question wasn't whether to migrate — but how.",
).render()
Intro( title: str, subtitle: str = '', content=None, palette: Palette | None = None, content_kind: str | None = None, tag: str = '', summary: str = '', footer: str = '', team: str = '', icon: dict | None = None, background=None)
58    def __init__(
59        self,
60        title: str,
61        subtitle: str = "",
62        content=None,
63        palette: Palette | None = None,
64        content_kind: "str | None" = None,
65        tag: str = "",
66        summary: str = "",
67        footer: str = "",
68        team: str = "",
69        icon: "dict | None" = None,
70        background=None,
71    ) -> None:
72        background = background if background is not None else _cfg.get("background")
73        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
74        self.tag = tag
75        self.summary = summary
76        self.team = team or _cfg.get("team", "")
77        self.icon = icon if icon is not None else _cfg.get("icon")
tag
summary
team
icon
def render(self) -> marimo._output.hypertext.Html:
79    def render(self) -> mo.Html:
80        """Assemble banner, summary callout, content, and footer.
81
82        Returns:
83            A `mo.Html` component ready to display in a marimo cell.
84        """
85        banner = self._render_banner()
86        content_parts = []
87        if self.summary:
88            content_parts.append(mo.callout(mo.md(self.summary), kind="warn"))
89        content_parts.extend(self._render_content())
90        content_parts.extend(self._render_footer())
91        return self._wrap_slide(banner, content_parts)

Assemble banner, summary callout, content, and footer.

Returns:

A mo.Html component ready to display in a marimo cell.

class Section(banners.Slide):
 11class Section(Slide):
 12    """Intermediate section slide with a left color border and dark background.
 13
 14    Takes less vertical space than `Cover` or `Closing`, leaving more room
 15    for content. Intended for **all middle slides** of a presentation.
 16
 17    Args:
 18        title: Section heading.
 19        subtitle: Short support phrase shown to the right of the title.
 20        content: Elements displayed below the banner. Accepts a single
 21            `Text`, `Image`, or `Graph` instance, or a list of them.
 22            Lists that contain any `Image` or `Graph` automatically use
 23            an equal-width CSS grid layout.
 24        palette: `SectionPalette` instance controlling the border color and
 25            dark background gradient. Defaults to the orange `SectionPalette`.
 26        content_kind: Box style for `Text`-only lists (`"neutral"`, `"info"`,
 27            `"success"`, `"warn"`, `"danger"`).
 28        number: Section number displayed large on the left of the banner
 29            (e.g. `"01"`, `"02"`). Omit to hide.
 30        footer: Closing quote shown at the bottom of the slide as a blockquote.
 31        icon: Icon placed at the right end of the banner, vertically centered.
 32            Pass a dict with keys `"src"` (path, URL, or bytes) and optionally
 33            `"size"` (CSS height, default `"32px"`).
 34
 35    Example:
 36        ```python
 37        from banners.slides import Section
 38        from banners.content import Text, Graph
 39
 40        Section(
 41            title="Pipeline architecture",
 42            subtitle="How data flows end to end.",
 43            number="02",
 44            content=[
 45                Text("Each stage is independently versioned and monitored."),
 46                Graph(\"\"\"
 47                graph LR
 48                    A[Ingest] --> B[Transform] --> C[Model] --> D[Serve]
 49                \"\"\"),
 50            ],
 51            footer="All stages emit structured logs to the observability stack.",
 52        ).render()
 53        ```
 54    """
 55
 56    def __init__(
 57        self,
 58        title: str,
 59        subtitle: str = "",
 60        content=None,
 61        palette: SectionPalette | None = None,
 62        content_kind: "str | None" = None,
 63        number: "str | None" = None,
 64        footer: str = "",
 65        icon: "dict | None" = None,
 66        background=None,
 67    ) -> None:
 68        if palette is None:
 69            global_palette = _cfg.get("palette")
 70            palette = global_palette.to_section_palette() if global_palette is not None else SectionPalette()
 71        background = background if background is not None else _cfg.get("background")
 72        super().__init__(title, subtitle, content, palette, content_kind, footer, background=background)
 73        self.number = number if number is not None else _cfg._next_section_number()
 74        self.icon = icon if icon is not None else _cfg.get("icon")
 75
 76    def render(self) -> mo.Html:
 77        """Assemble banner, content, and footer.
 78
 79        Returns:
 80            A `mo.Html` component ready to display in a marimo cell.
 81        """
 82        banner = self._render_banner()
 83        content_parts = self._render_content() + self._render_footer()
 84        return self._wrap_slide(banner, content_parts)
 85
 86    def _render_banner(self) -> mo.Html:
 87        p = self.palette
 88        number_html = (
 89            f'<div style="font-size:2.5rem;font-weight:900;color:{p.border};'
 90            f'line-height:1;margin-bottom:0.2rem;opacity:0.85;flex-shrink:0;">{self.number}</div>'
 91            if self.number else ""
 92        )
 93        subtitle_html = (
 94            f'<p style="font-size:0.95rem;color:{p.text_sub};margin:0.3rem 0 0;">{self.subtitle}</p>'
 95            if self.subtitle else ""
 96        )
 97        icon_html = self._icon_inline_html(
 98            self.icon, default_size="32px", style="margin-left:auto;flex-shrink:0;opacity:0.8;"
 99        )
100        return mo.Html(f"""
101        <div style="padding:1.1rem 1.5rem;background:{p.gradient};
102                    border-left:5px solid {p.border};border-radius:0 0.5rem 0.5rem 0;
103                    color:{p.text_main};margin-bottom:1.25rem;display:flex;align-items:center;gap:1.2rem;">
104            {number_html}
105            <div style="flex:1;">
106                <h3 style="font-size:1.5rem;font-weight:700;margin:0;color:{p.text_main};">{self.title}</h3>
107                {subtitle_html}
108            </div>
109            {icon_html}
110        </div>
111        """)

Intermediate section slide with a left color border and dark background.

Takes less vertical space than Cover or Closing, leaving more room for content. Intended for all middle slides of a presentation.

Arguments:
  • title: Section heading.
  • subtitle: Short support phrase shown to the right of the title.
  • content: Elements displayed below the banner. Accepts a single Text, Image, or Graph instance, or a list of them. Lists that contain any Image or Graph automatically use an equal-width CSS grid layout.
  • palette: SectionPalette instance controlling the border color and dark background gradient. Defaults to the orange SectionPalette.
  • content_kind: Box style for Text-only lists ("neutral", "info", "success", "warn", "danger").
  • number: Section number displayed large on the left of the banner (e.g. "01", "02"). Omit to hide.
  • footer: Closing quote shown at the bottom of the slide as a blockquote.
  • icon: Icon placed at the right end of the banner, vertically centered. Pass a dict with keys "src" (path, URL, or bytes) and optionally "size" (CSS height, default "32px").
Example:
from banners.slides import Section
from banners.content import Text, Graph

Section(
    title="Pipeline architecture",
    subtitle="How data flows end to end.",
    number="02",
    content=[
        Text("Each stage is independently versioned and monitored."),
        Graph("""
        graph LR
            A[Ingest] --> B[Transform] --> C[Model] --> D[Serve]
        """),
    ],
    footer="All stages emit structured logs to the observability stack.",
).render()
Section( title: str, subtitle: str = '', content=None, palette: SectionPalette | None = None, content_kind: str | None = None, number: str | None = None, footer: str = '', icon: dict | None = None, background=None)
56    def __init__(
57        self,
58        title: str,
59        subtitle: str = "",
60        content=None,
61        palette: SectionPalette | None = None,
62        content_kind: "str | None" = None,
63        number: "str | None" = None,
64        footer: str = "",
65        icon: "dict | None" = None,
66        background=None,
67    ) -> None:
68        if palette is None:
69            global_palette = _cfg.get("palette")
70            palette = global_palette.to_section_palette() if global_palette is not None else SectionPalette()
71        background = background if background is not None else _cfg.get("background")
72        super().__init__(title, subtitle, content, palette, content_kind, footer, background=background)
73        self.number = number if number is not None else _cfg._next_section_number()
74        self.icon = icon if icon is not None else _cfg.get("icon")
number
icon
def render(self) -> marimo._output.hypertext.Html:
76    def render(self) -> mo.Html:
77        """Assemble banner, content, and footer.
78
79        Returns:
80            A `mo.Html` component ready to display in a marimo cell.
81        """
82        banner = self._render_banner()
83        content_parts = self._render_content() + self._render_footer()
84        return self._wrap_slide(banner, content_parts)

Assemble banner, content, and footer.

Returns:

A mo.Html component ready to display in a marimo cell.

class Closing(banners.Slide):
 11class Closing(Slide):
 12    """Closing slide with a large centered banner and an optional metrics row.
 13
 14    Same visual weight as `Cover`. Intended as the **last slide** of every
 15    presentation. The `stats` parameter renders a row of `mo.stat` boxes
 16    useful for summarizing key numbers.
 17
 18    Args:
 19        title: Main closing message. Keep it conclusive and direct.
 20        subtitle: Support phrase displayed inside the banner below the title.
 21        content: Elements displayed above the stats row. Accepts a single
 22            `Text`, `Image`, or `Graph` instance, or a list of them.
 23        palette: Color palette for the banner gradient. Defaults to `ORANGE`.
 24        content_kind: Box style for `Text` lists (`"neutral"`, `"info"`,
 25            `"success"`, `"warn"`, `"danger"`).
 26        team: Team line shown in small caps at the bottom of the banner.
 27        stats: List of metric tuples `(value, label, caption)` rendered as
 28            a horizontal row of bordered `mo.stat` boxes. Keep `value` short
 29            (a number, symbol, or 1–3 words). Comfortable range: 2–4 items.
 30        footer: Closing quote shown at the bottom of the slide as a blockquote.
 31        icon: Icon placed in the bottom-right corner of the banner. Pass a
 32            dict with keys `"src"` (path, URL, or bytes) and optionally
 33            `"size"` (CSS height, default `"44px"`).
 34
 35    Example:
 36        ```python
 37        from banners.slides import Closing
 38
 39        Closing(
 40            title="One framework for all models.",
 41            subtitle="Infrastructure solved. Team focuses on data science.",
 42            stats=[
 43                ("Low",   "Effort per new model", "Just configure data"),
 44                ("↑ ROI", "Cumulative return",     "Each migration amortizes more"),
 45                ("100%",  "Traceability",          "Automatic end-to-end tracking"),
 46                ("1",     "Shared codebase",       "Everyone contributes"),
 47            ],
 48        ).render()
 49        ```
 50    """
 51
 52    def __init__(
 53        self,
 54        title: str,
 55        subtitle: str = "",
 56        content=None,
 57        palette: Palette | None = None,
 58        content_kind: "str | None" = None,
 59        footer: str = "",
 60        team: str = "",
 61        stats: "list[tuple[str, str, str]] | None" = None,
 62        icon: "dict | None" = None,
 63        background=None,
 64    ) -> None:
 65        background = background if background is not None else _cfg.get("background")
 66        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
 67        self.team = team or _cfg.get("team", "")
 68        self.stats = stats
 69        self.icon = icon if icon is not None else _cfg.get("icon")
 70
 71    def render(self) -> mo.Html:
 72        """Assemble banner, content, stats row, and footer.
 73
 74        Returns:
 75            A `mo.Html` component ready to display in a marimo cell.
 76        """
 77        banner = self._render_banner()
 78        content_parts = self._render_content()
 79        if self.stats:
 80            content_parts.append(
 81                mo.hstack(
 82                    [mo.stat(v, label=l, caption=c, bordered=True) for v, l, c in self.stats],
 83                    justify="space-around",
 84                )
 85            )
 86        content_parts.extend(self._render_footer())
 87        return self._wrap_slide(banner, content_parts)
 88
 89    def _render_banner(self) -> mo.Html:
 90        p = self.palette
 91        subtitle_html = (
 92            f'<p style="font-size:1.2rem;color:{p.text_sub};margin:1rem 0 1.5rem;">{self.subtitle}</p>'
 93            if self.subtitle else ""
 94        )
 95        team_html = (
 96            f'<div style="font-size:0.78rem;letter-spacing:0.15em;text-transform:uppercase;'
 97            f'color:{p.text_muted};margin-top:1rem;">{self.team}</div>'
 98            if self.team else ""
 99        )
100        icon_html = self._icon_corner_html(self.icon, bottom="1rem", right="1.5rem", default_size="44px")
101        return mo.Html(f"""
102        <div style="position:relative;text-align:center;padding:3rem 2rem 2rem;
103                    background:{p.gradient};border-radius:1rem;color:{p.text_main};margin-bottom:1.5rem;">
104            <h1 style="font-size:2.6rem;font-weight:800;color:{p.text_main};margin-bottom:0.5rem;line-height:1.2;">{self.title}</h1>
105            {subtitle_html}
106            {team_html}
107            {icon_html}
108        </div>
109        """)

Closing slide with a large centered banner and an optional metrics row.

Same visual weight as Cover. Intended as the last slide of every presentation. The stats parameter renders a row of mo.stat boxes useful for summarizing key numbers.

Arguments:
  • title: Main closing message. Keep it conclusive and direct.
  • subtitle: Support phrase displayed inside the banner below the title.
  • content: Elements displayed above the stats row. Accepts a single Text, Image, or Graph instance, or a list of them.
  • palette: Color palette for the banner gradient. Defaults to ORANGE.
  • content_kind: Box style for Text lists ("neutral", "info", "success", "warn", "danger").
  • team: Team line shown in small caps at the bottom of the banner.
  • stats: List of metric tuples (value, label, caption) rendered as a horizontal row of bordered mo.stat boxes. Keep value short (a number, symbol, or 1–3 words). Comfortable range: 2–4 items.
  • footer: Closing quote shown at the bottom of the slide as a blockquote.
  • icon: Icon placed in the bottom-right corner of the banner. Pass a dict with keys "src" (path, URL, or bytes) and optionally "size" (CSS height, default "44px").
Example:
from banners.slides import Closing

Closing(
    title="One framework for all models.",
    subtitle="Infrastructure solved. Team focuses on data science.",
    stats=[
        ("Low",   "Effort per new model", "Just configure data"),
        ("↑ ROI", "Cumulative return",     "Each migration amortizes more"),
        ("100%",  "Traceability",          "Automatic end-to-end tracking"),
        ("1",     "Shared codebase",       "Everyone contributes"),
    ],
).render()
Closing( title: str, subtitle: str = '', content=None, palette: Palette | None = None, content_kind: str | None = None, footer: str = '', team: str = '', stats: list[tuple[str, str, str]] | None = None, icon: dict | None = None, background=None)
52    def __init__(
53        self,
54        title: str,
55        subtitle: str = "",
56        content=None,
57        palette: Palette | None = None,
58        content_kind: "str | None" = None,
59        footer: str = "",
60        team: str = "",
61        stats: "list[tuple[str, str, str]] | None" = None,
62        icon: "dict | None" = None,
63        background=None,
64    ) -> None:
65        background = background if background is not None else _cfg.get("background")
66        super().__init__(title, subtitle, content, palette or _cfg.get("palette"), content_kind, footer, background=background)
67        self.team = team or _cfg.get("team", "")
68        self.stats = stats
69        self.icon = icon if icon is not None else _cfg.get("icon")
team
stats
icon
def render(self) -> marimo._output.hypertext.Html:
71    def render(self) -> mo.Html:
72        """Assemble banner, content, stats row, and footer.
73
74        Returns:
75            A `mo.Html` component ready to display in a marimo cell.
76        """
77        banner = self._render_banner()
78        content_parts = self._render_content()
79        if self.stats:
80            content_parts.append(
81                mo.hstack(
82                    [mo.stat(v, label=l, caption=c, bordered=True) for v, l, c in self.stats],
83                    justify="space-around",
84                )
85            )
86        content_parts.extend(self._render_footer())
87        return self._wrap_slide(banner, content_parts)

Assemble banner, content, stats row, and footer.

Returns:

A mo.Html component ready to display in a marimo cell.

class Text:
 9class Text:
10    """Markdown text element for use inside a slide's content area.
11
12    Renders using marimo's `mo.md`, so the full Markdown spec is supported:
13    paragraphs, **bold**, *italic*, lists, tables, code blocks, etc.
14
15    Args:
16        body: Markdown string to render.
17
18    Example:
19        ```python
20        from banners.content import Text
21
22        Text("**Key finding** — The model improved accuracy by 12 pp.")
23        Text(\"\"\"
24        | Metric | Value |
25        |--------|-------|
26        | AUC    | 0.91  |
27        | F1     | 0.87  |
28        \"\"\")
29        ```
30    """
31
32    def __init__(self, body: str) -> None:
33        self.body = body
34
35    def render(self) -> mo.Html:
36        """Render the Markdown body as a marimo HTML component.
37
38        The body is dedented automatically, so Python indentation in
39        triple-quoted strings does not affect the rendered output.
40
41        Returns:
42            A `mo.Html` component ready to display in a marimo cell.
43        """
44        return mo.md(textwrap.dedent(self.body))

Markdown text element for use inside a slide's content area.

Renders using marimo's mo.md, so the full Markdown spec is supported: paragraphs, bold, italic, lists, tables, code blocks, etc.

Arguments:
  • body: Markdown string to render.
Example:
from banners.content import Text

Text("**Key finding** — The model improved accuracy by 12 pp.")
Text("""
| Metric | Value |
|--------|-------|
| AUC    | 0.91  |
| F1     | 0.87  |
""")
Text(body: str)
32    def __init__(self, body: str) -> None:
33        self.body = body
body
def render(self) -> marimo._output.hypertext.Html:
35    def render(self) -> mo.Html:
36        """Render the Markdown body as a marimo HTML component.
37
38        The body is dedented automatically, so Python indentation in
39        triple-quoted strings does not affect the rendered output.
40
41        Returns:
42            A `mo.Html` component ready to display in a marimo cell.
43        """
44        return mo.md(textwrap.dedent(self.body))

Render the Markdown body as a marimo HTML component.

The body is dedented automatically, so Python indentation in triple-quoted strings does not affect the rendered output.

Returns:

A mo.Html component ready to display in a marimo cell.

class Image:
 7class Image:
 8    """Image element for use inside a slide's content area.
 9
10    Wraps marimo's `mo.image`, so it accepts the same source formats:
11    a file path (`str` or `Path`), raw `bytes`, or a URL string.
12
13    Args:
14        src: Image source — file path, bytes, or URL.
15        width: CSS width value applied to the image. Defaults to `"100%"`.
16        alt: Alternative text for accessibility. Defaults to `""`.
17
18    Example:
19        ```python
20        from banners.content import Image
21
22        # From a local path
23        Image("img/architecture.png", width="80%")
24
25        # From bytes — more portable across machines
26        from pathlib import Path
27        Image(Path("img/architecture.png").read_bytes(), width="80%")
28        ```
29    """
30
31    def __init__(self, src, width: str = "100%", alt: str = "") -> None:
32        self.src = src
33        self.width = width
34        self.alt = alt
35
36    def render(self) -> mo.Html:
37        """Render the image as a marimo HTML component.
38
39        Returns:
40            A `mo.Html` component ready to display in a marimo cell.
41        """
42        return mo.image(self.src, width=self.width, alt=self.alt)

Image element for use inside a slide's content area.

Wraps marimo's mo.image, so it accepts the same source formats: a file path (str or Path), raw bytes, or a URL string.

Arguments:
  • src: Image source — file path, bytes, or URL.
  • width: CSS width value applied to the image. Defaults to "100%".
  • alt: Alternative text for accessibility. Defaults to "".
Example:
from banners.content import Image

# From a local path
Image("img/architecture.png", width="80%")

# From bytes — more portable across machines
from pathlib import Path
Image(Path("img/architecture.png").read_bytes(), width="80%")
Image(src, width: str = '100%', alt: str = '')
31    def __init__(self, src, width: str = "100%", alt: str = "") -> None:
32        self.src = src
33        self.width = width
34        self.alt = alt
src
width
alt
def render(self) -> marimo._output.hypertext.Html:
36    def render(self) -> mo.Html:
37        """Render the image as a marimo HTML component.
38
39        Returns:
40            A `mo.Html` component ready to display in a marimo cell.
41        """
42        return mo.image(self.src, width=self.width, alt=self.alt)

Render the image as a marimo HTML component.

Returns:

A mo.Html component ready to display in a marimo cell.

class Graph:
 7class Graph:
 8    """Mermaid diagram element for use inside a slide's content area.
 9
10    Wraps marimo's `mo.mermaid`. Supports all standard Mermaid diagram types:
11    `graph LR`, `graph TD`, `sequenceDiagram`, `gantt`, `pie`, etc.
12
13    Args:
14        diagram: Mermaid diagram definition string.
15
16    Example:
17        ```python
18        from banners.content import Graph
19
20        Graph('''
21        graph LR
22            A[Ingest] --> B[Transform]
23            B --> C[Model]
24            C --> D[Predictions]
25        ''')
26        ```
27    """
28
29    def __init__(self, diagram: str) -> None:
30        self.diagram = diagram
31
32    def render(self) -> mo.Html:
33        """Render the Mermaid diagram as a marimo HTML component.
34
35        Returns:
36            A `mo.Html` component ready to display in a marimo cell.
37        """
38        return mo.mermaid(self.diagram)

Mermaid diagram element for use inside a slide's content area.

Wraps marimo's mo.mermaid. Supports all standard Mermaid diagram types: graph LR, graph TD, sequenceDiagram, gantt, pie, etc.

Arguments:
  • diagram: Mermaid diagram definition string.
Example:
from banners.content import Graph

Graph('''
graph LR
    A[Ingest] --> B[Transform]
    B --> C[Model]
    C --> D[Predictions]
''')
Graph(diagram: str)
29    def __init__(self, diagram: str) -> None:
30        self.diagram = diagram
diagram
def render(self) -> marimo._output.hypertext.Html:
32    def render(self) -> mo.Html:
33        """Render the Mermaid diagram as a marimo HTML component.
34
35        Returns:
36            A `mo.Html` component ready to display in a marimo cell.
37        """
38        return mo.mermaid(self.diagram)

Render the Mermaid diagram as a marimo HTML component.

Returns:

A mo.Html component ready to display in a marimo cell.

class AnimatedGraph:
213class AnimatedGraph:
214    """Interactive graph where nodes highlight on click.
215
216    Accepts ``graph LR`` / ``graph TD`` Mermaid syntax. Rendered as an
217    anywidget — no external JS libraries, no shadow DOM issues.
218
219    Args:
220        diagram: Mermaid ``graph LR`` or ``graph TD`` source.
221        highlight_color: CSS color applied to highlighted nodes.
222            Defaults to ``"#ea580c"``.
223
224    Example:
225        ```python
226        from banners.content import AnimatedGraph
227
228        AnimatedGraph(\"\"\"
229        graph LR
230        A[Ingest] --> B[Validate]
231        B --> C[Transform]
232        C --> D[Load]
233        \"\"\")
234        ```
235    """
236
237    def __init__(self, diagram: str, highlight_color: str = "#ea580c") -> None:
238        self.diagram = diagram
239        self.highlight_color = highlight_color
240
241    def render(self):
242        """Render the interactive graph as a marimo widget."""
243        uid = uuid.uuid4().hex[:8]
244        nodes, edges, direction = _parse(self.diagram)
245        pos, node_w, node_h = _layout(nodes, edges, direction)
246        svg = _build_svg(nodes, edges, pos, node_w, node_h, uid)
247        return _GraphWidget(svg=svg, color=self.highlight_color)

Interactive graph where nodes highlight on click.

Accepts graph LR / graph TD Mermaid syntax. Rendered as an anywidget — no external JS libraries, no shadow DOM issues.

Arguments:
  • diagram: Mermaid graph LR or graph TD source.
  • highlight_color: CSS color applied to highlighted nodes. Defaults to "#ea580c".
Example:
from banners.content import AnimatedGraph

AnimatedGraph("""
graph LR
A[Ingest] --> B[Validate]
B --> C[Transform]
C --> D[Load]
""")
AnimatedGraph(diagram: str, highlight_color: str = '#ea580c')
237    def __init__(self, diagram: str, highlight_color: str = "#ea580c") -> None:
238        self.diagram = diagram
239        self.highlight_color = highlight_color
diagram
highlight_color
def render(self):
241    def render(self):
242        """Render the interactive graph as a marimo widget."""
243        uid = uuid.uuid4().hex[:8]
244        nodes, edges, direction = _parse(self.diagram)
245        pos, node_w, node_h = _layout(nodes, edges, direction)
246        svg = _build_svg(nodes, edges, pos, node_w, node_h, uid)
247        return _GraphWidget(svg=svg, color=self.highlight_color)

Render the interactive graph as a marimo widget.

class Table:
 7class Table:
 8    """Renders a pandas DataFrame as an HTML table inside a slide.
 9
10    Args:
11        df: A pandas DataFrame to display.
12
13    Example:
14        ```python
15        import pandas as pd
16        from banners.content import Table
17
18        df = pd.DataFrame({"Model": ["A", "B"], "AUC": [0.91, 0.87]})
19        Table(df)
20        ```
21    """
22
23    def __init__(self, df) -> None:
24        self.df = df
25
26    def render(self) -> mo.Html:
27        """Render the DataFrame as a marimo HTML component.
28
29        Returns:
30            A `mo.Html` component ready to display in a marimo cell.
31        """
32        return mo.as_html(self.df)

Renders a pandas DataFrame as an HTML table inside a slide.

Arguments:
  • df: A pandas DataFrame to display.
Example:
import pandas as pd
from banners.content import Table

df = pd.DataFrame({"Model": ["A", "B"], "AUC": [0.91, 0.87]})
Table(df)
Table(df)
23    def __init__(self, df) -> None:
24        self.df = df
df
def render(self) -> marimo._output.hypertext.Html:
26    def render(self) -> mo.Html:
27        """Render the DataFrame as a marimo HTML component.
28
29        Returns:
30            A `mo.Html` component ready to display in a marimo cell.
31        """
32        return mo.as_html(self.df)

Render the DataFrame as a marimo HTML component.

Returns:

A mo.Html component ready to display in a marimo cell.

class Plot:
 7class Plot:
 8    """Renders a matplotlib or plotly figure inside a slide.
 9
10    Detects the figure type automatically:
11    - **matplotlib** `Figure` → `mo.as_html(fig)`
12    - **plotly** `Figure` → `mo.ui.plotly(fig)`
13
14    Args:
15        fig: A `matplotlib.figure.Figure` or `plotly.graph_objects.Figure`.
16
17    Example:
18        ```python
19        import matplotlib.pyplot as plt
20        from banners.content import Plot
21
22        fig, ax = plt.subplots()
23        ax.plot([1, 2, 3], [4, 5, 6])
24        Plot(fig)
25        ```
26    """
27
28    def __init__(self, fig) -> None:
29        self.fig = fig
30
31    def render(self) -> mo.Html:
32        """Render the figure as a marimo HTML component.
33
34        Returns:
35            A `mo.Html` component ready to display in a marimo cell.
36
37        Raises:
38            TypeError: If the figure type is not matplotlib or plotly.
39        """
40        cls_module = type(self.fig).__module__ or ""
41        if cls_module.startswith("matplotlib"):
42            return mo.as_html(self.fig)
43        if cls_module.startswith("plotly"):
44            return mo.ui.plotly(self.fig)
45        raise TypeError(f"Unsupported figure type: {type(self.fig)}")

Renders a matplotlib or plotly figure inside a slide.

Detects the figure type automatically:

  • matplotlib Figuremo.as_html(fig)
  • plotly Figuremo.ui.plotly(fig)
Arguments:
  • fig: A matplotlib.figure.Figure or plotly.graph_objects.Figure.
Example:
import matplotlib.pyplot as plt
from banners.content import Plot

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [4, 5, 6])
Plot(fig)
Plot(fig)
28    def __init__(self, fig) -> None:
29        self.fig = fig
fig
def render(self) -> marimo._output.hypertext.Html:
31    def render(self) -> mo.Html:
32        """Render the figure as a marimo HTML component.
33
34        Returns:
35            A `mo.Html` component ready to display in a marimo cell.
36
37        Raises:
38            TypeError: If the figure type is not matplotlib or plotly.
39        """
40        cls_module = type(self.fig).__module__ or ""
41        if cls_module.startswith("matplotlib"):
42            return mo.as_html(self.fig)
43        if cls_module.startswith("plotly"):
44            return mo.ui.plotly(self.fig)
45        raise TypeError(f"Unsupported figure type: {type(self.fig)}")

Render the figure as a marimo HTML component.

Returns:

A mo.Html component ready to display in a marimo cell.

Raises:
  • TypeError: If the figure type is not matplotlib or plotly.
class Manim:
162class Manim:
163    """Renders a Manim Scene and embeds the result inside a slide.
164
165    Requires the optional ``manim`` dependency::
166
167        uv pip install "banners[manim]"
168
169    The scene is rendered on the first ``render()`` call and cached --
170    subsequent calls return the same component without re-rendering.
171
172    Args:
173        scene: A Manim ``Scene`` subclass (pass the class, not an instance).
174        format: Output format. ``"gif"`` and ``"png"`` embed as ``mo.image``;
175            ``"mp4"`` embeds as ``mo.video``. Defaults to ``"gif"``.
176        quality: Render quality. One of ``"low"``, ``"medium"``, ``"high"``.
177            Defaults to ``"low"``.
178        interactive: When ``True``, the scene is rendered section by section
179            and a click-to-advance widget is returned. The scene must define
180            steps with ``self.next_section("name")``.
181    """
182
183    _QUALITY_MAP = {
184        "low": "low_quality",
185        "medium": "medium_quality",
186        "high": "high_quality",
187    }
188
189    def __init__(
190        self,
191        scene,
192        format: str = "gif",
193        quality: str = "low",
194        interactive: bool = False,
195        width: str = "100%",
196        autoplay: bool = False,
197    ) -> None:
198        self.scene = scene
199        self.format = format
200        self.quality = quality
201        self.interactive = interactive
202        self.width = width
203        self.autoplay = autoplay
204        self._cached = None
205
206    def render(self):
207        """Render the scene and return a marimo component."""
208        if self._cached is not None:
209            return self._cached
210
211        try:
212            from manim import tempconfig
213        except ImportError:
214            return mo.callout(
215                mo.md("`manim` is not installed. Run: `uv pip install 'banners[manim]'`"),
216                kind="warn",
217            )
218
219        if self.interactive:
220            self._cached = self._render_interactive(tempconfig)
221        else:
222            self._cached = self._render_static(tempconfig)
223
224        return self._cached
225
226    @staticmethod
227    @contextlib.contextmanager
228    def _quiet():
229        """Suppress all Manim console output."""
230        import logging
231        sink = io.StringIO()
232        with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
233            old_level = logging.root.manager.loggerDict.copy()
234            logging.disable(logging.CRITICAL)
235            try:
236                yield
237            finally:
238                logging.disable(logging.NOTSET)
239
240    def _render_static(self, tempconfig):
241        quality = self._QUALITY_MAP.get(self.quality, "low_quality")
242        with tempfile.TemporaryDirectory() as tmpdir:
243            with tempconfig({
244                "media_dir": tmpdir,
245                "format": self.format,
246                "quality": quality,
247                "verbosity": "CRITICAL",
248                "disable_caching": True,
249            }):
250                scene_cls = self.scene if isinstance(self.scene, type) else self.scene
251                with self._quiet():
252                    instance = scene_cls()
253                    instance.render()
254                data = self._find_output(tmpdir, self.format)
255
256        if data is None:
257            return mo.callout(mo.md("Manim rendered but no output file was found."), kind="danger")
258
259        return mo.image(data) if self.format in ("gif", "png") else mo.video(data)
260
261    def _render_interactive(self, tempconfig):
262        """Render each section as mp4 and return a click-to-advance anywidget."""
263        quality = self._QUALITY_MAP.get(self.quality, "low_quality")
264        frames: list[bytes] = []
265
266        with tempfile.TemporaryDirectory() as tmpdir:
267            with tempconfig({
268                "media_dir": tmpdir,
269                "save_sections": True,
270                "quality": quality,
271                "verbosity": "CRITICAL",
272                "disable_caching": True,
273            }):
274                scene_cls = self.scene if isinstance(self.scene, type) else self.scene
275                with self._quiet():
276                    instance = scene_cls()
277                    instance.render()
278
279            section_dir = self._find_sections_dir(Path(tmpdir))
280            if section_dir and section_dir.exists():
281                for path in sorted(section_dir.glob("*.mp4")):
282                    frames.append(path.read_bytes())
283
284        if not frames:
285            return mo.callout(
286                mo.md("No sections found. Add `self.next_section()` calls to the scene."),
287                kind="warn",
288            )
289
290        srcs = [
291            f"data:video/mp4;base64,{base64.b64encode(f).decode()}"
292            for f in frames
293        ]
294        return _ManimInteractiveWidget(srcs=srcs, width=self.width, autoplay=self.autoplay)
295
296    @staticmethod
297    def _find_output(directory: str, fmt: str) -> "bytes | None":
298        for path in Path(directory).rglob(f"*.{fmt}"):
299            return path.read_bytes()
300        return None
301
302    @staticmethod
303    def _find_sections_dir(root: Path) -> "Path | None":
304        for d in root.rglob("sections"):
305            if d.is_dir():
306                return d
307        return None

Renders a Manim Scene and embeds the result inside a slide.

Requires the optional manim dependency::

uv pip install "banners[manim]"

The scene is rendered on the first render() call and cached -- subsequent calls return the same component without re-rendering.

Arguments:
  • scene: A Manim Scene subclass (pass the class, not an instance).
  • format: Output format. "gif" and "png" embed as mo.image; "mp4" embeds as mo.video. Defaults to "gif".
  • quality: Render quality. One of "low", "medium", "high". Defaults to "low".
  • interactive: When True, the scene is rendered section by section and a click-to-advance widget is returned. The scene must define steps with self.next_section("name").
Manim( scene, format: str = 'gif', quality: str = 'low', interactive: bool = False, width: str = '100%', autoplay: bool = False)
189    def __init__(
190        self,
191        scene,
192        format: str = "gif",
193        quality: str = "low",
194        interactive: bool = False,
195        width: str = "100%",
196        autoplay: bool = False,
197    ) -> None:
198        self.scene = scene
199        self.format = format
200        self.quality = quality
201        self.interactive = interactive
202        self.width = width
203        self.autoplay = autoplay
204        self._cached = None
scene
format
quality
interactive
width
autoplay
def render(self):
206    def render(self):
207        """Render the scene and return a marimo component."""
208        if self._cached is not None:
209            return self._cached
210
211        try:
212            from manim import tempconfig
213        except ImportError:
214            return mo.callout(
215                mo.md("`manim` is not installed. Run: `uv pip install 'banners[manim]'`"),
216                kind="warn",
217            )
218
219        if self.interactive:
220            self._cached = self._render_interactive(tempconfig)
221        else:
222            self._cached = self._render_static(tempconfig)
223
224        return self._cached

Render the scene and return a marimo component.

class FlowAnimation:
 19class FlowAnimation:
 20    """Step-by-step pipeline animation with optional detail text.
 21
 22    Delegates all rendering to ``Manim``. Requires the optional ``manim``
 23    dependency.
 24
 25    Three input formats are supported:
 26
 27    **Flat list** -- linear chain::
 28
 29        FlowAnimation(["S3", "Glue", "Athena"])
 30
 31        FlowAnimation([
 32            {"name": "S3",   "detail": "raw data"},
 33            {"name": "Glue", "detail": "ETL job"},
 34        ])
 35
 36    **Nested list** -- multi-row layered layout (each outer item is a layer;
 37    every node in layer *i* connects to every node in layer *i+1*)::
 38
 39        FlowAnimation([
 40            ["Ingest"],
 41            [{"name": "Transform A", "detail": "features"},
 42             {"name": "Transform B", "detail": "filters"}],
 43            ["Load"],
 44        ])
 45
 46    **Explicit graph** -- arbitrary connections via ``edges``::
 47
 48        FlowAnimation(
 49            nodes=["A", "B", "C", "D", "E"],
 50            edges=[
 51                ("A", "C"), ("A", "D"),
 52                ("B", "D"), ("B", "E"),
 53            ],
 54        )
 55
 56    Args:
 57        nodes: Node list -- ``str``, ``{"name", "detail"}`` dicts, or a
 58            nested list of those for the multi-layer format.
 59        edges: Optional list of ``(source_name, target_name)`` tuples. When
 60            provided the flat/nested-list layout is replaced by an automatic
 61            BFS layout that respects the given connections.
 62        direction: Flow direction -- ``"LR"`` (left-to-right) or ``"TD"``
 63            (top-down). Defaults to ``"LR"`` for flat lists and graph mode,
 64            ``"TD"`` for nested lists.
 65        quality: Render quality -- ``"low"``, ``"medium"``, or ``"high"``.
 66        width: Max width of the player widget. Any CSS value (``"800px"``,
 67            ``"60%"``, ``"100%"``). Defaults to ``"100%"``.
 68    """
 69
 70    def __init__(
 71        self,
 72        nodes: list,
 73        edges=None,
 74        direction: str = None,
 75        quality: str = "low",
 76        width: str = "100%",
 77        autoplay: bool = False,
 78    ) -> None:
 79        self.quality = quality
 80        self.width = width
 81        self.autoplay = autoplay
 82        self._cached = None
 83
 84        if edges is not None:
 85            self._mode = "graph"
 86            self._node_specs = {}
 87            for n in nodes:
 88                spec = _norm(n)
 89                self._node_specs[spec["name"]] = spec
 90            self._edges = list(edges)
 91        elif nodes and isinstance(nodes[0], list):
 92            self._mode = "vertical"
 93            self._rows = [[_norm(n) for n in row] for row in nodes]
 94        else:
 95            self._mode = "horizontal"
 96            self._rows = [[_norm(n)] for n in nodes]
 97
 98        # Default direction: preserve current behaviour for each mode
 99        if direction is None:
100            self._direction = "TD" if self._mode == "vertical" else "LR"
101        else:
102            self._direction = direction.upper()
103
104    def render(self):
105        """Build the scene and delegate rendering to Manim."""
106        if self._cached is not None:
107            return self._cached
108        self._cached = Manim(
109            self._build_scene(), interactive=True, quality=self.quality, width=self.width, autoplay=self.autoplay
110        ).render()
111        return self._cached
112
113    # ------------------------------------------------------------------
114    # Scene builder dispatcher
115    # ------------------------------------------------------------------
116
117    def _build_scene(self):
118        if self._mode == "graph":
119            return self._build_graph_scene()
120        return self._build_rows_scene()
121
122    # ------------------------------------------------------------------
123    # Rows scene (horizontal + vertical modes, LR + TD directions)
124    # ------------------------------------------------------------------
125
126    def _build_rows_scene(self):
127        rows      = self._rows
128        direction = self._direction     # "LR" | "TD"
129        n_rows    = len(rows)
130        max_cols  = max(len(r) for r in rows) if rows else 1
131        has_any_detail = any(n["detail"] for r in rows for n in r)
132
133        # Sequential color assignment
134        k = 0
135        node_colors = []
136        for row in rows:
137            rc = []
138            for _ in row:
139                rc.append(_COLORS[k % len(_COLORS)])
140                k += 1
141            node_colors.append(rc)
142
143        # Geometry -- layer_sp: spacing between layers, node_sp: within a layer
144        if direction == "LR":
145            layer_sp = min(3.2, 12.0 / max(n_rows, 1))
146            node_sp  = min(2.0,  7.0 / max(max_cols, 1))
147            node_w   = layer_sp * 0.72
148        else:  # TD
149            layer_sp = min(2.4,  7.0 / max(n_rows, 1))
150            node_sp  = min(3.2, 12.0 / max(max_cols, 1))
151            node_w   = node_sp * 0.72
152
153        node_h  = 1.2 if has_any_detail else 0.85
154        name_sz = max(12, min(20, int(node_w * 7.0)))
155        det_sz  = max(9,  min(13, int(node_w * 5.5)))
156
157        try:
158            from manim import (
159                Scene, RoundedRectangle, Arrow,
160                Write, FadeIn, GrowArrow,
161                GRAY as MG, Text as MText, UP,
162            )
163        except ImportError:
164            return None
165
166        class _FlowScene(Scene):
167            BG = "#0f172a"
168
169            def construct(self):
170                self.camera.background_color = self.BG
171
172                boxes    = {}
173                name_lbl = {}
174                det_lbl  = {}
175                arrows   = {}
176
177                for r, row in enumerate(rows):
178                    for c, node in enumerate(row):
179                        color = node_colors[r][c]
180
181                        if direction == "LR":
182                            x = (r - (n_rows - 1) / 2.0) * layer_sp
183                            y = ((len(row) - 1) / 2.0 - c) * node_sp
184                        else:  # TD
185                            y = ((n_rows - 1) / 2.0 - r) * layer_sp
186                            x = (c - (len(row) - 1) / 2.0) * node_sp
187
188                        rect = RoundedRectangle(
189                            width=node_w, height=node_h, corner_radius=0.12,
190                            fill_color=color, fill_opacity=0.2,
191                            stroke_color=color, stroke_width=2,
192                        ).move_to([x, y, 0])
193
194                        lbl = MText(node["name"], font_size=name_sz,
195                                    color=color, weight="BOLD")
196
197                        det = None
198                        if node["detail"]:
199                            det = MText(node["detail"], font_size=det_sz,
200                                        color="#9ca3af")
201                            lbl.move_to(rect.get_center() + UP * (node_h * 0.22))
202                            det.move_to(rect.get_center() - UP * (node_h * 0.22))
203                        else:
204                            lbl.move_to(rect)
205
206                        boxes[(r, c)]    = rect
207                        name_lbl[(r, c)] = lbl
208                        det_lbl[(r, c)]  = det
209
210                        inc = []
211                        if r > 0:
212                            for pc in range(len(rows[r - 1])):
213                                pb = boxes.get((r - 1, pc))
214                                if pb is None:
215                                    continue
216                                if direction == "LR":
217                                    start, end = pb.get_right(), rect.get_left()
218                                else:
219                                    start, end = pb.get_bottom(), rect.get_top()
220                                inc.append(Arrow(
221                                    start, end,
222                                    buff=0.06, color=MG, stroke_width=2,
223                                    tip_length=0.15,
224                                ))
225                        arrows[(r, c)] = inc
226
227                for r, row in enumerate(rows):
228                    for c in range(len(row)):
229                        self.next_section(f"step_{r}_{c}")
230                        anims = [FadeIn(boxes[(r, c)], scale=0.85),
231                                 Write(name_lbl[(r, c)])]
232                        if det_lbl.get((r, c)) is not None:
233                            anims.append(FadeIn(det_lbl[(r, c)]))
234                        grow = [GrowArrow(a) for a in arrows.get((r, c), [])]
235                        self.play(*(grow + anims), run_time=0.5)
236                        self.wait(0.2)
237
238        return _FlowScene
239
240    # ------------------------------------------------------------------
241    # Graph scene (explicit edges, BFS layout, LR + TD directions)
242    # ------------------------------------------------------------------
243
244    def _build_graph_scene(self):
245        node_specs = self._node_specs   # name -> {name, detail}
246        edges      = self._edges        # list of (src, dst)
247        direction  = self._direction    # "LR" | "TD"
248
249        # Adjacency
250        succ = {}
251        pred = {}
252        for name in node_specs:
253            succ[name] = []
254            pred[name] = []
255        for s, d in edges:
256            succ.setdefault(s, []).append(d)
257            pred.setdefault(d, []).append(s)
258
259        # BFS level assignment
260        roots = [n for n in node_specs if not pred.get(n)]
261        if not roots:
262            roots = [next(iter(node_specs))]
263
264        levels = {n: 0 for n in roots}
265        queue  = list(roots)
266        guard  = len(node_specs)
267        while queue:
268            n = queue.pop(0)
269            for nb in succ.get(n, []):
270                lv = levels[n] + 1
271                if lv > guard:
272                    continue
273                if nb not in levels or levels[nb] < lv:
274                    levels[nb] = lv
275                    queue.append(nb)
276        for n in node_specs:
277            if n not in levels:
278                levels[n] = 0
279
280        n_levels = max(levels.values()) + 1
281        by_level = {lv: [] for lv in range(n_levels)}
282        for name in node_specs:           # preserve insertion order
283            by_level[levels[name]].append(name)
284
285        max_per_level  = max(len(v) for v in by_level.values())
286        has_any_detail = any(node_specs[n]["detail"] for n in node_specs)
287
288        # Geometry
289        if direction == "LR":
290            col_sp = min(3.2, 12.0 / max(n_levels, 1))
291            row_sp = min(2.0,  7.0 / max(max_per_level, 1))
292            node_w = col_sp * 0.72
293        else:  # TD
294            col_sp = min(2.4,  7.0 / max(n_levels, 1))
295            row_sp = min(3.2, 12.0 / max(max_per_level, 1))
296            node_w = row_sp * 0.72
297
298        node_h  = 1.15 if has_any_detail else 0.85
299        name_sz = max(12, min(20, int(node_w * 7.0)))
300        det_sz  = max(9,  min(13, int(node_w * 5.5)))
301
302        # Positions
303        positions = {}
304        for lv in range(n_levels):
305            names_lv = by_level[lv]
306            for i, name in enumerate(names_lv):
307                if direction == "LR":
308                    x = (lv - (n_levels - 1) / 2.0) * col_sp
309                    y = ((len(names_lv) - 1) / 2.0 - i) * row_sp
310                else:  # TD
311                    y = ((n_levels - 1) / 2.0 - lv) * col_sp
312                    x = (i - (len(names_lv) - 1) / 2.0) * row_sp
313                positions[name] = (x, y)
314
315        # Colors
316        k = 0
317        node_colors = {}
318        for lv in range(n_levels):
319            for name in by_level[lv]:
320                node_colors[name] = _COLORS[k % len(_COLORS)]
321                k += 1
322
323        # Animation order: level by level
324        anim_order = [name for lv in range(n_levels) for name in by_level[lv]]
325
326        try:
327            from manim import (
328                Scene, RoundedRectangle, Arrow,
329                Write, FadeIn, GrowArrow,
330                GRAY as MG, Text as MText, UP,
331            )
332        except ImportError:
333            return None
334
335        class _FlowScene(Scene):
336            BG = "#0f172a"
337
338            def construct(self):
339                self.camera.background_color = self.BG
340
341                boxes    = {}
342                name_lbl = {}
343                det_lbl  = {}
344                arrows   = {}
345
346                for name, spec in node_specs.items():
347                    x, y  = positions[name]
348                    color = node_colors[name]
349
350                    rect = RoundedRectangle(
351                        width=node_w, height=node_h, corner_radius=0.12,
352                        fill_color=color, fill_opacity=0.2,
353                        stroke_color=color, stroke_width=2,
354                    ).move_to([x, y, 0])
355
356                    lbl = MText(spec["name"], font_size=name_sz,
357                                color=color, weight="BOLD")
358
359                    det = None
360                    if spec["detail"]:
361                        det = MText(spec["detail"], font_size=det_sz,
362                                    color="#9ca3af")
363                        lbl.move_to(rect.get_center() + UP * (node_h * 0.22))
364                        det.move_to(rect.get_center() - UP * (node_h * 0.22))
365                    else:
366                        lbl.move_to(rect)
367
368                    boxes[name]    = rect
369                    name_lbl[name] = lbl
370                    det_lbl[name]  = det
371
372                # Build incoming arrows (direction determined by position delta)
373                for name in node_specs:
374                    inc = []
375                    for src in pred.get(name, []):
376                        if src not in boxes:
377                            continue
378                        sx, sy = positions[src]
379                        dx, dy = positions[name]
380                        sb = boxes[src]
381                        db = boxes[name]
382                        if abs(sx - dx) >= abs(sy - dy):
383                            start = sb.get_right() if sx < dx else sb.get_left()
384                            end   = db.get_left()  if sx < dx else db.get_right()
385                        else:
386                            start = sb.get_bottom() if sy > dy else sb.get_top()
387                            end   = db.get_top()    if sy > dy else db.get_bottom()
388                        inc.append(Arrow(
389                            start, end,
390                            buff=0.06, color=MG, stroke_width=2,
391                            tip_length=0.15,
392                        ))
393                    arrows[name] = inc
394
395                for name in anim_order:
396                    self.next_section(f"step_{name}")
397                    anims = [FadeIn(boxes[name], scale=0.85),
398                             Write(name_lbl[name])]
399                    if det_lbl.get(name) is not None:
400                        anims.append(FadeIn(det_lbl[name]))
401                    grow = [GrowArrow(a) for a in arrows.get(name, [])]
402                    self.play(*(grow + anims), run_time=0.5)
403                    self.wait(0.2)
404
405        return _FlowScene

Step-by-step pipeline animation with optional detail text.

Delegates all rendering to Manim. Requires the optional manim dependency.

Three input formats are supported:

Flat list -- linear chain::

FlowAnimation(["S3", "Glue", "Athena"])

FlowAnimation([
    {"name": "S3",   "detail": "raw data"},
    {"name": "Glue", "detail": "ETL job"},
])

Nested list -- multi-row layered layout (each outer item is a layer; every node in layer i connects to every node in layer i+1)::

FlowAnimation([
    ["Ingest"],
    [{"name": "Transform A", "detail": "features"},
     {"name": "Transform B", "detail": "filters"}],
    ["Load"],
])

Explicit graph -- arbitrary connections via edges::

FlowAnimation(
    nodes=["A", "B", "C", "D", "E"],
    edges=[
        ("A", "C"), ("A", "D"),
        ("B", "D"), ("B", "E"),
    ],
)
Arguments:
  • nodes: Node list -- str, {"name", "detail"} dicts, or a nested list of those for the multi-layer format.
  • edges: Optional list of (source_name, target_name) tuples. When provided the flat/nested-list layout is replaced by an automatic BFS layout that respects the given connections.
  • direction: Flow direction -- "LR" (left-to-right) or "TD" (top-down). Defaults to "LR" for flat lists and graph mode, "TD" for nested lists.
  • quality: Render quality -- "low", "medium", or "high".
  • width: Max width of the player widget. Any CSS value ("800px", "60%", "100%"). Defaults to "100%".
FlowAnimation( nodes: list, edges=None, direction: str = None, quality: str = 'low', width: str = '100%', autoplay: bool = False)
 70    def __init__(
 71        self,
 72        nodes: list,
 73        edges=None,
 74        direction: str = None,
 75        quality: str = "low",
 76        width: str = "100%",
 77        autoplay: bool = False,
 78    ) -> None:
 79        self.quality = quality
 80        self.width = width
 81        self.autoplay = autoplay
 82        self._cached = None
 83
 84        if edges is not None:
 85            self._mode = "graph"
 86            self._node_specs = {}
 87            for n in nodes:
 88                spec = _norm(n)
 89                self._node_specs[spec["name"]] = spec
 90            self._edges = list(edges)
 91        elif nodes and isinstance(nodes[0], list):
 92            self._mode = "vertical"
 93            self._rows = [[_norm(n) for n in row] for row in nodes]
 94        else:
 95            self._mode = "horizontal"
 96            self._rows = [[_norm(n)] for n in nodes]
 97
 98        # Default direction: preserve current behaviour for each mode
 99        if direction is None:
100            self._direction = "TD" if self._mode == "vertical" else "LR"
101        else:
102            self._direction = direction.upper()
quality
width
autoplay
def render(self):
104    def render(self):
105        """Build the scene and delegate rendering to Manim."""
106        if self._cached is not None:
107            return self._cached
108        self._cached = Manim(
109            self._build_scene(), interactive=True, quality=self.quality, width=self.width, autoplay=self.autoplay
110        ).render()
111        return self._cached

Build the scene and delegate rendering to Manim.

@dataclass
class Palette:
 7@dataclass
 8class Palette:
 9    """Three-stop linear gradient palette for full-banner slides (Cover, Intro, Closing).
10
11    Attributes:
12        start: Gradient start color (hex). Default is deep brown-red.
13        mid: Gradient mid color (hex).
14        end: Gradient end color (hex). Default is bright orange.
15        text_main: Primary text color on the banner.
16        text_sub: Subtitle text color.
17        text_muted: Muted text color (dates, captions).
18        text_tag: Small-caps tag/team line color.
19
20    Example:
21        ```python
22        from banners.palette import Palette, BLUE
23
24        # Use a predefined palette
25        Cover(title="My Project", palette=BLUE).render()
26
27        # Build a custom palette
28        custom = Palette(start="#0f172a", mid="#1e40af", end="#3b82f6")
29        Cover(title="My Project", palette=custom).render()
30        ```
31    """
32
33    start: str = "#7c2d12"
34    mid: str = "#c2410c"
35    end: str = "#ea580c"
36
37    text_main: str = "#ffffff"
38    text_sub: str = "#ffedd5"
39    text_muted: str = "#fdba74"
40    text_tag: str = "#fed7aa"
41
42    @property
43    def gradient(self) -> str:
44        """CSS linear-gradient string built from start, mid and end stops."""
45        return f"linear-gradient(135deg, {self.start} 0%, {self.mid} 50%, {self.end} 100%)"
46
47    def to_section_palette(self) -> "SectionPalette":
48        """Derive a SectionPalette from this palette.
49
50        Uses `end` as the border accent and a darkened `start` for the background.
51        """
52        def _darken(hex_color: str, factor: float) -> str:
53            h = hex_color.lstrip("#")
54            r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
55            return f"#{int(r * factor):02x}{int(g * factor):02x}{int(b * factor):02x}"
56
57        return SectionPalette(
58            border=self.end,
59            bg_start=_darken(self.start, 0.30),
60            bg_end=_darken(self.start, 0.42),
61        )

Three-stop linear gradient palette for full-banner slides (Cover, Intro, Closing).

Attributes:
  • start: Gradient start color (hex). Default is deep brown-red.
  • mid: Gradient mid color (hex).
  • end: Gradient end color (hex). Default is bright orange.
  • text_main: Primary text color on the banner.
  • text_sub: Subtitle text color.
  • text_muted: Muted text color (dates, captions).
  • text_tag: Small-caps tag/team line color.
Example:
from banners.palette import Palette, BLUE

# Use a predefined palette
Cover(title="My Project", palette=BLUE).render()

# Build a custom palette
custom = Palette(start="#0f172a", mid="#1e40af", end="#3b82f6")
Cover(title="My Project", palette=custom).render()
Palette( start: str = '#7c2d12', mid: str = '#c2410c', end: str = '#ea580c', text_main: str = '#ffffff', text_sub: str = '#ffedd5', text_muted: str = '#fdba74', text_tag: str = '#fed7aa')
start: str = '#7c2d12'
mid: str = '#c2410c'
end: str = '#ea580c'
text_main: str = '#ffffff'
text_sub: str = '#ffedd5'
text_muted: str = '#fdba74'
text_tag: str = '#fed7aa'
gradient: str
42    @property
43    def gradient(self) -> str:
44        """CSS linear-gradient string built from start, mid and end stops."""
45        return f"linear-gradient(135deg, {self.start} 0%, {self.mid} 50%, {self.end} 100%)"

CSS linear-gradient string built from start, mid and end stops.

def to_section_palette(self) -> SectionPalette:
47    def to_section_palette(self) -> "SectionPalette":
48        """Derive a SectionPalette from this palette.
49
50        Uses `end` as the border accent and a darkened `start` for the background.
51        """
52        def _darken(hex_color: str, factor: float) -> str:
53            h = hex_color.lstrip("#")
54            r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
55            return f"#{int(r * factor):02x}{int(g * factor):02x}{int(b * factor):02x}"
56
57        return SectionPalette(
58            border=self.end,
59            bg_start=_darken(self.start, 0.30),
60            bg_end=_darken(self.start, 0.42),
61        )

Derive a SectionPalette from this palette.

Uses end as the border accent and a darkened start for the background.

@dataclass
class SectionPalette:
64@dataclass
65class SectionPalette:
66    """Palette for Section slides: left border accent over a dark background.
67
68    Attributes:
69        border: Left-border accent color (hex).
70        bg_start: Dark background gradient start color (hex).
71        bg_end: Dark background gradient end color (hex).
72        text_main: Primary text color.
73        text_sub: Subtitle text color.
74
75    Example:
76        ```python
77        from banners.palette import SectionPalette
78
79        blue_section = SectionPalette(
80            border="#3b82f6",
81            bg_start="#0f172a",
82            bg_end="#1e293b",
83        )
84        Section(title="Results", palette=blue_section).render()
85        ```
86    """
87
88    border: str = "#ea580c"
89    bg_start: str = "#1c0a00"
90    bg_end: str = "#1f1210"
91
92    text_main: str = "#ffffff"
93    text_sub: str = "#d1d5db"
94
95    @property
96    def gradient(self) -> str:
97        """CSS linear-gradient string for the dark background."""
98        return f"linear-gradient(90deg, {self.bg_start} 0%, {self.bg_end} 100%)"

Palette for Section slides: left border accent over a dark background.

Attributes:
  • border: Left-border accent color (hex).
  • bg_start: Dark background gradient start color (hex).
  • bg_end: Dark background gradient end color (hex).
  • text_main: Primary text color.
  • text_sub: Subtitle text color.
Example:
from banners.palette import SectionPalette

blue_section = SectionPalette(
    border="#3b82f6",
    bg_start="#0f172a",
    bg_end="#1e293b",
)
Section(title="Results", palette=blue_section).render()
SectionPalette( border: str = '#ea580c', bg_start: str = '#1c0a00', bg_end: str = '#1f1210', text_main: str = '#ffffff', text_sub: str = '#d1d5db')
border: str = '#ea580c'
bg_start: str = '#1c0a00'
bg_end: str = '#1f1210'
text_main: str = '#ffffff'
text_sub: str = '#d1d5db'
gradient: str
95    @property
96    def gradient(self) -> str:
97        """CSS linear-gradient string for the dark background."""
98        return f"linear-gradient(90deg, {self.bg_start} 0%, {self.bg_end} 100%)"

CSS linear-gradient string for the dark background.

ORANGE = Palette(start='#7c2d12', mid='#c2410c', end='#ea580c', text_main='#ffffff', text_sub='#ffedd5', text_muted='#fdba74', text_tag='#fed7aa')
BLUE = Palette(start='#1e3a5f', mid='#1d4ed8', end='#3b82f6', text_main='#ffffff', text_sub='#ffedd5', text_muted='#fdba74', text_tag='#fed7aa')
GREEN = Palette(start='#14532d', mid='#15803d', end='#22c55e', text_main='#ffffff', text_sub='#ffedd5', text_muted='#fdba74', text_tag='#fed7aa')
PURPLE = Palette(start='#3b0764', mid='#7e22ce', end='#a855f7', text_main='#ffffff', text_sub='#ffedd5', text_muted='#fdba74', text_tag='#fed7aa')
GRAY = Palette(start='#111827', mid='#374151', end='#6b7280', text_main='#ffffff', text_sub='#ffedd5', text_muted='#fdba74', text_tag='#fed7aa')
@dataclass
class Background:
 67@dataclass
 68class Background:
 69    """Encapsulates a CSS background value for use as a slide backdrop.
 70
 71    Do not instantiate directly — use the factory methods:
 72
 73    - :meth:`color` — solid color
 74    - :meth:`gradient` — linear gradient
 75    - :meth:`image` — image with optional dark overlay
 76
 77    Args:
 78        css: Ready-to-use CSS value for the ``background`` property.
 79    """
 80
 81    css: str
 82
 83    @staticmethod
 84    def color(c: str) -> "Background":
 85        """Solid background color.
 86
 87        Args:
 88            c: Any CSS color value (hex, rgb, named).
 89
 90        Example::
 91
 92            Background.color("#0f172a")
 93        """
 94        return Background(css=c)
 95
 96    @staticmethod
 97    def gradient(
 98        start: str,
 99        end: str,
100        *,
101        mid: str = None,
102        angle: int = 135,
103    ) -> "Background":
104        """Linear gradient background.
105
106        Args:
107            start: Start color (0%).
108            end: End color (100%).
109            mid: Optional mid-stop color (50%).
110            angle: Gradient angle in degrees. Defaults to 135.
111
112        Example::
113
114            Background.gradient("#0d1b2a", "#4a1a6e")
115            Background.gradient("#0d1b2a", "#4a1a6e", mid="#1a2a5e", angle=45)
116        """
117        if mid:
118            return Background(
119                css=f"linear-gradient({angle}deg,{start} 0%,{mid} 50%,{end} 100%)"
120            )
121        return Background(css=f"linear-gradient({angle}deg,{start} 0%,{end} 100%)")
122
123    @staticmethod
124    def image(src, *, overlay: str = "rgba(0,0,0,0.55)") -> "Background":
125        """Image background, with an optional dark overlay for text legibility.
126
127        Args:
128            src: File path (``str`` / ``Path``), URL, or raw ``bytes``.
129            overlay: CSS color applied as a semi-transparent layer on top of
130                the image. Defaults to ``"rgba(0,0,0,0.55)"``. Pass ``None``
131                to disable the overlay entirely.
132
133        Example::
134
135            Background.image("assets/photo.jpg")
136            Background.image("https://example.com/bg.jpg", overlay="rgba(0,0,0,0.7)")
137            Background.image(raw_bytes, overlay=None)
138        """
139        from banners.slide import Slide
140        data_uri = Slide._icon_src(src)
141        img_css = f"url({data_uri}) center/cover no-repeat"
142        if overlay:
143            return Background(css=f"linear-gradient({overlay},{overlay}),{img_css}")
144        return Background(css=img_css)
145
146    def text_color(self) -> "str | None":
147        """Return a contrasting text color based on the background luminance.
148
149        Returns ``"#ffffff"`` for dark backgrounds, ``"#1f2937"`` for light
150        ones, using the WCAG relative luminance formula. Returns ``None`` when
151        the color cannot be parsed (unrecognized format) — in that case no
152        automatic color is applied.
153        """
154        rgb = _parse_first_color(self.css)
155        if rgb is None:
156            return None
157        lum = _relative_luminance(*rgb)
158        return "#ffffff" if lum < 0.2 else "#1f2937"

Encapsulates a CSS background value for use as a slide backdrop.

Do not instantiate directly — use the factory methods:

Arguments:
  • css: Ready-to-use CSS value for the background property.
Background(css: str)
css: str
@staticmethod
def color(c: str) -> Background:
83    @staticmethod
84    def color(c: str) -> "Background":
85        """Solid background color.
86
87        Args:
88            c: Any CSS color value (hex, rgb, named).
89
90        Example::
91
92            Background.color("#0f172a")
93        """
94        return Background(css=c)

Solid background color.

Arguments:
  • c: Any CSS color value (hex, rgb, named).

Example::

Background.color("#0f172a")
@staticmethod
def gradient( start: str, end: str, *, mid: str = None, angle: int = 135) -> Background:
 96    @staticmethod
 97    def gradient(
 98        start: str,
 99        end: str,
100        *,
101        mid: str = None,
102        angle: int = 135,
103    ) -> "Background":
104        """Linear gradient background.
105
106        Args:
107            start: Start color (0%).
108            end: End color (100%).
109            mid: Optional mid-stop color (50%).
110            angle: Gradient angle in degrees. Defaults to 135.
111
112        Example::
113
114            Background.gradient("#0d1b2a", "#4a1a6e")
115            Background.gradient("#0d1b2a", "#4a1a6e", mid="#1a2a5e", angle=45)
116        """
117        if mid:
118            return Background(
119                css=f"linear-gradient({angle}deg,{start} 0%,{mid} 50%,{end} 100%)"
120            )
121        return Background(css=f"linear-gradient({angle}deg,{start} 0%,{end} 100%)")

Linear gradient background.

Arguments:
  • start: Start color (0%).
  • end: End color (100%).
  • mid: Optional mid-stop color (50%).
  • angle: Gradient angle in degrees. Defaults to 135.

Example::

Background.gradient("#0d1b2a", "#4a1a6e")
Background.gradient("#0d1b2a", "#4a1a6e", mid="#1a2a5e", angle=45)
@staticmethod
def image( src, *, overlay: str = 'rgba(0,0,0,0.55)') -> Background:
123    @staticmethod
124    def image(src, *, overlay: str = "rgba(0,0,0,0.55)") -> "Background":
125        """Image background, with an optional dark overlay for text legibility.
126
127        Args:
128            src: File path (``str`` / ``Path``), URL, or raw ``bytes``.
129            overlay: CSS color applied as a semi-transparent layer on top of
130                the image. Defaults to ``"rgba(0,0,0,0.55)"``. Pass ``None``
131                to disable the overlay entirely.
132
133        Example::
134
135            Background.image("assets/photo.jpg")
136            Background.image("https://example.com/bg.jpg", overlay="rgba(0,0,0,0.7)")
137            Background.image(raw_bytes, overlay=None)
138        """
139        from banners.slide import Slide
140        data_uri = Slide._icon_src(src)
141        img_css = f"url({data_uri}) center/cover no-repeat"
142        if overlay:
143            return Background(css=f"linear-gradient({overlay},{overlay}),{img_css}")
144        return Background(css=img_css)

Image background, with an optional dark overlay for text legibility.

Arguments:
  • src: File path (str / Path), URL, or raw bytes.
  • overlay: CSS color applied as a semi-transparent layer on top of the image. Defaults to "rgba(0,0,0,0.55)". Pass None to disable the overlay entirely.

Example::

Background.image("assets/photo.jpg")
Background.image("https://example.com/bg.jpg", overlay="rgba(0,0,0,0.7)")
Background.image(raw_bytes, overlay=None)
def text_color(self) -> str | None:
146    def text_color(self) -> "str | None":
147        """Return a contrasting text color based on the background luminance.
148
149        Returns ``"#ffffff"`` for dark backgrounds, ``"#1f2937"`` for light
150        ones, using the WCAG relative luminance formula. Returns ``None`` when
151        the color cannot be parsed (unrecognized format) — in that case no
152        automatic color is applied.
153        """
154        rgb = _parse_first_color(self.css)
155        if rgb is None:
156            return None
157        lum = _relative_luminance(*rgb)
158        return "#ffffff" if lum < 0.2 else "#1f2937"

Return a contrasting text color based on the background luminance.

Returns "#ffffff" for dark backgrounds, "#1f2937" for light ones, using the WCAG relative luminance formula. Returns None when the color cannot be parsed (unrecognized format) — in that case no automatic color is applied.