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]
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:
- team: Team line shown in
Cover,Intro, andClosingbanners. - date: Date string shown in
Cover(e.g."April 2026"). - icon: Icon applied to all slides. Pass a dict with keys
"src"(path, URL, or bytes) and optionally"size"(CSS height). - palette: Color palette applied to
Cover,Intro, andClosing. Pass anyPaletteinstance or predefined constant (BLUE,GREEN,PURPLE,GRAY). - background: Background applied to all slides. Pass a
Backgroundinstance (Background.color(),Background.gradient(), orBackground.image()). Individual slides can override this with their ownbackgroundargument.
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"), )
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 (
1frper column) regardless of type. Passcontent_kindto wrapTextitems in amo.calloutbox.
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, orGraphinstance, a list of them, or any marimo component. - palette: Color palette for the banner. Defaults to
ORANGE. - content_kind: Box style applied to
Textitems 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()
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
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.Htmlcomponent (viamo.vstack) ready to display in a marimo cell.
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, orGraphinstance, or a list of them. - palette: Color palette for the banner gradient. Defaults to
ORANGE. - content_kind: Box style for
Textlists ("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()
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")
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, orGraphinstance, or a list of them. - palette: Color palette for the banner gradient. Defaults to
ORANGE. - content_kind: Box style for
Textlists ("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
warncallout. - 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()
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")
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.Htmlcomponent ready to display in a marimo cell.
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 elif isinstance(palette, Palette) and not isinstance(palette, SectionPalette): 72 palette = palette.to_section_palette() 73 background = background if background is not None else _cfg.get("background") 74 super().__init__(title, subtitle, content, palette, content_kind, footer, background=background) 75 self.number = number if number is not None else _cfg._next_section_number() 76 self.icon = icon if icon is not None else _cfg.get("icon") 77 78 def render(self) -> mo.Html: 79 """Assemble banner, content, and footer. 80 81 Returns: 82 A `mo.Html` component ready to display in a marimo cell. 83 """ 84 banner = self._render_banner() 85 content_parts = self._render_content() + self._render_footer() 86 return self._wrap_slide(banner, content_parts) 87 88 def _render_banner(self) -> mo.Html: 89 p = self.palette 90 number_html = ( 91 f'<div style="font-size:2.5rem;font-weight:900;color:{p.border};' 92 f'line-height:1;margin-bottom:0.2rem;opacity:0.85;flex-shrink:0;">{self.number}</div>' 93 if self.number else "" 94 ) 95 subtitle_html = ( 96 f'<p style="font-size:0.95rem;color:{p.text_sub};margin:0.3rem 0 0;">{self.subtitle}</p>' 97 if self.subtitle else "" 98 ) 99 icon_html = self._icon_inline_html( 100 self.icon, default_size="32px", style="margin-left:auto;flex-shrink:0;opacity:0.8;" 101 ) 102 return mo.Html(f""" 103 <div style="padding:1.1rem 1.5rem;background:{p.gradient}; 104 border-left:5px solid {p.border};border-radius:0 0.5rem 0.5rem 0; 105 color:{p.text_main};margin-bottom:1.25rem;display:flex;align-items:center;gap:1.2rem;"> 106 {number_html} 107 <div style="flex:1;"> 108 <h3 style="font-size:1.5rem;font-weight:700;margin:0;color:{p.text_main};">{self.title}</h3> 109 {subtitle_html} 110 </div> 111 {icon_html} 112 </div> 113 """)
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, orGraphinstance, or a list of them. Lists that contain anyImageorGraphautomatically use an equal-width CSS grid layout. - palette:
SectionPaletteinstance controlling the border color and dark background gradient. Defaults to the orangeSectionPalette. - 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()
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 elif isinstance(palette, Palette) and not isinstance(palette, SectionPalette): 72 palette = palette.to_section_palette() 73 background = background if background is not None else _cfg.get("background") 74 super().__init__(title, subtitle, content, palette, content_kind, footer, background=background) 75 self.number = number if number is not None else _cfg._next_section_number() 76 self.icon = icon if icon is not None else _cfg.get("icon")
78 def render(self) -> mo.Html: 79 """Assemble banner, content, and footer. 80 81 Returns: 82 A `mo.Html` component ready to display in a marimo cell. 83 """ 84 banner = self._render_banner() 85 content_parts = self._render_content() + self._render_footer() 86 return self._wrap_slide(banner, content_parts)
Assemble banner, content, and footer.
Returns:
A
mo.Htmlcomponent ready to display in a marimo cell.
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, orGraphinstance, or a list of them. - palette: Color palette for the banner gradient. Defaults to
ORANGE. - content_kind: Box style for
Textlists ("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 borderedmo.statboxes. Keepvalueshort (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()
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")
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.Htmlcomponent ready to display in a marimo cell.
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 | """)
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.Htmlcomponent ready to display in a marimo cell.
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%")
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.Htmlcomponent ready to display in a marimo cell.
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] ''')
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.Htmlcomponent ready to display in a marimo cell.
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 LRorgraph TDsource. - 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] """)
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.
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)
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.Htmlcomponent ready to display in a marimo cell.
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
Figure→mo.as_html(fig) - plotly
Figure→mo.ui.plotly(fig)
Arguments:
- fig: A
matplotlib.figure.Figureorplotly.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)
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.Htmlcomponent ready to display in a marimo cell.
Raises:
- TypeError: If the figure type is not matplotlib or plotly.
26class Manim: 27 """Renders a Manim Scene and embeds the result inside a slide. 28 29 Requires the optional ``manim`` dependency:: 30 31 uv pip install "banners[manim]" 32 33 The scene is rendered on the first ``render()`` call and cached -- 34 subsequent calls return the same component without re-rendering. 35 36 Args: 37 scene: A Manim ``Scene`` subclass (pass the class, not an instance). 38 format: Output format. ``"gif"`` and ``"png"`` embed as ``mo.image``; 39 ``"mp4"`` embeds as ``mo.video``. Defaults to ``"gif"``. 40 quality: Render quality. One of ``"low"``, ``"medium"``, ``"high"``. 41 Defaults to ``"low"``. 42 interactive: When ``True``, the scene is rendered section by section 43 and a click-to-advance widget is returned. The scene must define 44 steps with ``self.next_section("name")``. 45 """ 46 47 _QUALITY_MAP = { 48 "low": "low_quality", 49 "medium": "medium_quality", 50 "high": "high_quality", 51 } 52 53 def __init__( 54 self, 55 scene, 56 format: str = "gif", 57 quality: str = "low", 58 interactive: bool = False, 59 width: str = "100%", 60 autoplay: bool = False, 61 ) -> None: 62 self.scene = scene 63 self.format = format 64 self.quality = quality 65 self.interactive = interactive 66 self.width = width 67 self.autoplay = autoplay 68 self._cached = None 69 70 def _cache_key(self) -> str: 71 try: 72 src = inspect.getsource(self.scene) 73 except OSError: 74 src = self.scene.__qualname__ 75 raw = f"{src}|{self.quality}|{self.format}|{self.interactive}" 76 return hashlib.sha256(raw.encode()).hexdigest()[:24] 77 78 @staticmethod 79 def _cache_root() -> Path: 80 d = Path.home() / ".cache" / "banners" / "manim" 81 d.mkdir(parents=True, exist_ok=True) 82 return d 83 84 def render(self): 85 """Render the scene and return a marimo component.""" 86 if self._cached is not None: 87 return self._cached 88 89 try: 90 from manim import tempconfig 91 except ImportError: 92 return mo.callout( 93 mo.md("`manim` is not installed. Run: `uv pip install 'banners[manim]'`"), 94 kind="warn", 95 ) 96 97 if self.interactive: 98 self._cached = self._render_interactive(tempconfig) 99 else: 100 self._cached = self._render_static(tempconfig) 101 102 return self._cached 103 104 @staticmethod 105 @contextlib.contextmanager 106 def _quiet(): 107 """Suppress all Manim console output.""" 108 import logging 109 sink = io.StringIO() 110 with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): 111 old_level = logging.root.manager.loggerDict.copy() 112 logging.disable(logging.CRITICAL) 113 try: 114 yield 115 finally: 116 logging.disable(logging.NOTSET) 117 118 def _render_static(self, tempconfig): 119 key = self._cache_key() 120 cache_file = self._cache_root() / f"{key}.{self.format}" 121 122 if cache_file.exists(): 123 data = cache_file.read_bytes() 124 else: 125 quality = self._QUALITY_MAP.get(self.quality, "low_quality") 126 with tempfile.TemporaryDirectory() as tmpdir: 127 with tempconfig({ 128 "media_dir": tmpdir, 129 "format": self.format, 130 "quality": quality, 131 "verbosity": "CRITICAL", 132 "disable_caching": True, 133 }): 134 scene_cls = self.scene if isinstance(self.scene, type) else self.scene 135 with self._quiet(): 136 instance = scene_cls() 137 instance.render() 138 data = self._find_output(tmpdir, self.format) 139 140 if data is None: 141 return mo.callout(mo.md("Manim rendered but no output file was found."), kind="danger") 142 cache_file.write_bytes(data) 143 144 return mo.image(data) if self.format in ("gif", "png") else mo.video(data) 145 146 def _render_interactive(self, tempconfig): 147 """Render each section as mp4 and return a click-to-advance anywidget.""" 148 key = self._cache_key() 149 cache_dir = self._cache_root() / key 150 cached_frames = sorted(cache_dir.glob("*.mp4")) if cache_dir.exists() else [] 151 152 if cached_frames: 153 frames = [p.read_bytes() for p in cached_frames] 154 else: 155 quality = self._QUALITY_MAP.get(self.quality, "low_quality") 156 frames: list[bytes] = [] 157 158 with tempfile.TemporaryDirectory() as tmpdir: 159 with tempconfig({ 160 "media_dir": tmpdir, 161 "save_sections": True, 162 "quality": quality, 163 "verbosity": "CRITICAL", 164 "disable_caching": True, 165 }): 166 scene_cls = self.scene if isinstance(self.scene, type) else self.scene 167 with self._quiet(): 168 instance = scene_cls() 169 instance.render() 170 171 section_dir = self._find_sections_dir(Path(tmpdir)) 172 if section_dir and section_dir.exists(): 173 for path in sorted(section_dir.glob("*.mp4")): 174 frames.append(path.read_bytes()) 175 176 if not frames: 177 return mo.callout( 178 mo.md("No sections found. Add `self.next_section()` calls to the scene."), 179 kind="warn", 180 ) 181 182 cache_dir.mkdir(parents=True, exist_ok=True) 183 for i, frame in enumerate(frames): 184 (cache_dir / f"{i:04d}.mp4").write_bytes(frame) 185 186 srcs = [ 187 f"data:video/mp4;base64,{base64.b64encode(f).decode()}" 188 for f in frames 189 ] 190 return _ManimInteractiveWidget(srcs=srcs, width=self.width, autoplay=self.autoplay) 191 192 @staticmethod 193 def _find_output(directory: str, fmt: str) -> "bytes | None": 194 for path in Path(directory).rglob(f"*.{fmt}"): 195 return path.read_bytes() 196 return None 197 198 @staticmethod 199 def _find_sections_dir(root: Path) -> "Path | None": 200 for d in root.rglob("sections"): 201 if d.is_dir(): 202 return d 203 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
Scenesubclass (pass the class, not an instance). - format: Output format.
"gif"and"png"embed asmo.image;"mp4"embeds asmo.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 withself.next_section("name").
53 def __init__( 54 self, 55 scene, 56 format: str = "gif", 57 quality: str = "low", 58 interactive: bool = False, 59 width: str = "100%", 60 autoplay: bool = False, 61 ) -> None: 62 self.scene = scene 63 self.format = format 64 self.quality = quality 65 self.interactive = interactive 66 self.width = width 67 self.autoplay = autoplay 68 self._cached = None
84 def render(self): 85 """Render the scene and return a marimo component.""" 86 if self._cached is not None: 87 return self._cached 88 89 try: 90 from manim import tempconfig 91 except ImportError: 92 return mo.callout( 93 mo.md("`manim` is not installed. Run: `uv pip install 'banners[manim]'`"), 94 kind="warn", 95 ) 96 97 if self.interactive: 98 self._cached = self._render_interactive(tempconfig) 99 else: 100 self._cached = self._render_static(tempconfig) 101 102 return self._cached
Render the scene and return a marimo component.
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%".
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()
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.
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()
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.
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 )
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()
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:
color()— solid colorgradient()— linear gradientimage()— image with optional dark overlay
Arguments:
- css: Ready-to-use CSS value for the
backgroundproperty.
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")
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)
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 rawbytes. - overlay: CSS color applied as a semi-transparent layer on top of
the image. Defaults to
"rgba(0,0,0,0.55)". PassNoneto 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)
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.