---
title: Shape
description: The 12 data-shape variants that connect fetchers and renderers, with their payload structure, compatible renderers, and typical fetchers.
---
A **shape** is the structural contract between a fetcher and a
renderer. Neither side knows about the other directly — they both know
shapes. A fetcher declares the shapes it can emit via `shapes()`. A
renderer declares the shapes it can draw via `accepts()`. The runtime
pairs them at dispatch time and surfaces a placeholder if they don't
match.
```
fetchers emitting Text renderers accepting Text
────────────────────── ─────────────────────────
clock ──────┐ ┌────────── text_plain
basic_static┤ ├────────── text_ascii
git_repo_name┤── Shape::Text ──┤
... ┤ ├──── animated_typewriter
└─────────────────┘
Any fetcher on the left can drive any renderer on the right.
Adding one more fetcher that emits Text unlocks all of them.
```
Shapes are the *only* coupling between the two halves. Adding a new
fetcher that emits an existing shape unlocks every compatible renderer
for free; adding a new renderer that accepts an existing shape unlocks
every fetcher emitting it.
## Overview
| [`Text`](#text) | `text_plain` | `text_ascii`, `animated_typewriter`, `animated_postfx`, `animated_figlet_morph`, `animated_boot`, `animated_scanlines`, `animated_splitflap`, `animated_wave` | single-string values |
| [`TextBlock`](#textblock) | `text_plain` | `list_plain` | zero or more lines |
| [`Entries`](#entries) | `grid_table` | — | key / value rows |
| [`Ratio`](#ratio) | `gauge_circle` | `gauge_line` | a `0..=1` progress |
| [`NumberSeries`](#numberseries) | `chart_sparkline` | `chart_bar` | sequence of integers |
| [`PointSeries`](#pointseries) | `chart_line` | `chart_scatter` | (x, y) samples |
| [`Bars`](#bars) | `chart_bar` | `chart_pie` | labeled magnitudes |
| [`Image`](#image) | `media_image` | — | PNG / JPEG path |
| [`Calendar`](#calendar) | `grid_calendar` | — | month view |
| [`Heatmap`](#heatmap) | `grid_heatmap` | — | 2D intensity grid |
| [`Badge`](#badge) | `status_badge` | — | traffic-light status |
| [`Timeline`](#timeline) | `list_timeline` | — | chronological events |
The authoritative compatibility matrix is in the
[reference overview](/reference/matrix/) — generated from
each renderer's `accepts()`, so it can't drift.
## `Text`
A single string. The workhorse — anything that reduces to one line.
```rust
Body::Text(TextData { value: String })
```
```json
{ "shape": "text", "data": { "value": "14:32" } }
```
Renderers: `text_plain` (default), `text_ascii` (figlet / block letters),
`animated_typewriter`, `animated_postfx`, `animated_figlet_morph`,
`animated_boot`, `animated_scanlines`, `animated_splitflap`,
`animated_wave`.
Typical fetchers: `clock`, `clock_derived`, `git_repo_name`, `git_status`,
`github_repo_stars`, `basic_static`.
## `TextBlock`
Zero or more lines of text.
```rust
Body::TextBlock(TextBlockData { lines: Vec<String> })
```
```json
{ "shape": "text_block", "data": { "lines": ["feat: add foo", "fix: bar"] } }
```
Renderers: `text_plain` (default), `list_plain`.
Typical fetchers: `git_recent_commits`, `github_user`, `github_repo`,
`basic_static` (when format contains newlines).
## `Entries`
Key/value rows with optional per-row status (colours the value).
```rust
Body::Entries(EntriesData { items: Vec<Entry { key, value, status }> })
```
```json
{
"shape": "entries",
"data": { "items": [
{ "key": "os", "value": "macOS 14.3" },
{ "key": "load", "value": "1.24", "status": "ok" },
{ "key": "disk", "value": "91%", "status": "warn" }
]}
}
```
Renderers: `grid_table` (default).
Typical fetchers: `system`, `clock_almanac`, `clock_timezones`,
`github_repo_stars`.
## `Ratio`
A single `0.0..=1.0` progress value. Never considered empty (`0%` is
legitimate).
```rust
Body::Ratio(RatioData { value: f64, label: Option<String>, denominator: Option<u64> })
```
```json
{ "shape": "ratio", "data": { "value": 0.32, "label": "year", "denominator": 365 } }
```
Renderers: `gauge_circle` (default), `gauge_line`.
Typical fetchers: `clock_ratio` (year / quarter / month / week / day
progress), `system_monitor_cpu`, `system_monitor_memory`, `system_monitor_disk`.
## `NumberSeries`
A sequence of unsigned integers. Good for anything "counts over time".
```rust
Body::NumberSeries(NumberSeriesData { values: Vec<u64> })
```
```json
{ "shape": "number_series", "data": { "values": [2, 3, 1, 5, 8, 13, 4] } }
```
Renderers: `chart_sparkline` (default), `chart_bar`.
Typical fetchers: `git_commits_activity` (when rendered as a sparkline),
`basic_read_store`.
## `PointSeries`
One or more named series of `(x, y)` samples. Good for continuous data.
```rust
Body::PointSeries(PointSeriesData {
series: Vec<PointSeries { name: String, points: Vec<(f64, f64)> }>,
})
```
```json
{
"shape": "point_series",
"data": { "series": [
{ "name": "temp", "points": [[0, 20.0], [1, 21.5], [2, 22.1]] }
]}
}
```
Renderers: `chart_line` (default), `chart_scatter`.
Typical fetchers: `basic_read_store` (any custom metric with an X axis).
## `Bars`
Labeled, non-negative magnitudes.
```rust
Body::Bars(BarsData { bars: Vec<Bar { label: String, value: u64 }> })
```
```json
{
"shape": "bars",
"data": { "bars": [
{ "label": "Rust", "value": 87000 },
{ "label": "TOML", "value": 8000 }
]}
}
```
Renderers: `chart_bar` (default), `chart_pie`.
Typical fetchers: `git_contributors`, `github_languages`.
## `Image`
Path to an image on disk. Terminal renders it via
[viuer](https://github.com/atanunq/viuer) (kitty / iTerm2 / sixel
protocols where supported, falling back to unicode blocks).
```rust
Body::Image(ImageData { path: String })
```
```json
{ "shape": "image", "data": { "path": "/home/you/.cache/splashboard/avatar.png" } }
```
Renderers: `media_image` (default).
Typical fetchers: `github_avatar`, `basic_read_store`.
## `Calendar`
A month view. Never considered empty (the anchor month is always
present).
```rust
Body::Calendar(CalendarData {
year: i32,
month: u8, // 1..=12
day: Option<u8>, // today / focus highlight
events: Vec<u8>, // extra highlighted days (1..=31)
})
```
```json
{ "shape": "calendar", "data": { "year": 2026, "month": 4, "day": 23, "events": [1, 15, 30] } }
```
Renderers: `grid_calendar` (default).
Typical fetchers: `clock` (current month).
## `Heatmap`
2D intensity grid, row-major. The renderer buckets cells via explicit
`thresholds` or auto-quartiles; edge labels render when there's room.
```rust
Body::Heatmap(HeatmapData {
cells: Vec<Vec<u32>>,
thresholds: Option<Vec<u32>>,
row_labels: Option<Vec<String>>,
col_labels: Option<Vec<String>>,
})
```
```json
{
"shape": "heatmap",
"data": {
"cells": [[0, 1, 3], [2, 5, 1], [0, 0, 4]],
"thresholds": [1, 3, 5]
}
}
```
Renderers: `grid_heatmap` (default).
Typical fetchers: `git_commits_activity`, `github_contributions`,
`git_blame_heatmap`.
## `Badge`
A single traffic-light status with a short label. One indicator per
widget; row-of-badges is a layout concern, not a shape.
```rust
Body::Badge(BadgeData { status: Status, label: String })
```json
{ "shape": "badge", "data": { "status": "warn", "label": "deploy degraded" } }
```
Renderers: `status_badge` (default), `grid_table` (as a one-row table).
Typical fetchers: — (no built-in Badge emitter today; user-written
ReadStore widgets are the main consumer).
## `Timeline`
Time-stamped events, newest first. Timestamps are raw unix seconds
UTC; the renderer formats `"3h ago"` / `"yesterday"` at draw time so
cached payloads don't freeze stale relative labels.
```rust
Body::Timeline(TimelineData {
events: Vec<TimelineEvent { timestamp: i64, title: String, detail: Option<String>, status: Option<Status> }>,
})
```
```json
{
"shape": "timeline",
"data": { "events": [
{ "timestamp": 1700000000, "title": "merged #42", "detail": "feat: heatmap", "status": "ok" },
{ "timestamp": 1699990000, "title": "opened #41" }
]}
}
```
Renderers: `list_timeline` (default).
Typical fetchers: `github_notifications`, `github_recent_releases`,
`git_recent_commits` (when rendered as a timeline).
## Empty-state rules
Most shapes have a natural "empty" state that the runtime detects
before renderer dispatch, substituting the shared "nothing here yet"
placeholder:
- `Text` — `value.is_empty()`
- `TextBlock` — no lines or all lines blank
- `Entries`, `NumberSeries`, `PointSeries`, `Bars`, `Timeline` — empty
collection
- `Image` — empty path
- `Heatmap` — no cells
- `Badge` — empty label
`Ratio` and `Calendar` are never considered empty — `0%` and "this
month" are both legitimate values.
## Adding a new shape
Most widgets can be expressed with an existing shape. Add a new one
only when none of the 12 carries the structural information the
renderer needs. The process:
1. Add a new `Body::XXX(XxxData)` variant in `src/payload.rs` with a
serde-serializable struct.
2. Add a matching `Shape::XXX` variant in `src/render/mod.rs` and
handle it in `shape_of()`.
3. Add an entry in `default_renderer_for()`.
4. Handle the new variant in `is_empty_body()`.
5. Implement at least one renderer that lists the new shape in its
`accepts()`.
Shapes are a hard coupling — adding one obliges every touching side to
know about it. "This renderer needs the raw fetcher data" is almost
never a reason to add a shape; it's a reason to add a new renderer
that draws an existing shape differently.