Skip to content

ReadStore

ReadStore is splashboard's one escape hatch for "I want a widget no built-in fetcher provides". You write the payload to a file, point a basic_read_store widget at it, and splashboard deserialises the file into whatever shape the paired renderer expects.

  • No code.
  • No subprocess.
  • Fixed path under $HOME/.splashboard/store/ — config can't redirect the read, so it's Safe even in an untrusted local dashboard.

Use it when curated UX (auth, rate limits, stateful update) isn't warranted — habit trackers, goal progress, custom metrics that cron / a shell function can emit.

Point a widget at a store id, pair it with a renderer, write the file.

# $HOME/.splashboard/home.dashboard.toml
[[widget]]
id = "habit"
fetcher = "basic_read_store"
file_format = "json"
render = "gauge_line"
// $HOME/.splashboard/store/habit.json
{ "value": 0.6, "label": "habit · 6 of 10 days" }

The filename matches the widget id (habit.json), the shape comes from the renderer (gauge_line accepts Ratio), and the file body is just the inner data — no wrapping envelope.

This is the one detail that trips everyone up. ReadStore reads the file as the inner data struct for the target shape, not the Body JSON envelope that splashboard uses internally.

// ✅ ReadStore expects this (the inner RatioData)
{ "value": 0.6, "label": "year progress" }
// ❌ Don't use the Body envelope — ReadStore won't deserialise it
{
"shape": "ratio",
"data": { "value": 0.6, "label": "year progress" }
}

The envelope form is what you see on the Shape page and inside splashboard's cache files. ReadStore strips it so your file stays as short as possible — one concept per file, matching what you'd write if you'd just invented the format yourself.

The shape isn't in the file — it's picked at runtime from:

  1. The renderer's accepts() list. gauge_line accepts Ratio, so a basic_read_store paired with gauge_line always emits Ratio.
  2. If no renderer is pinned, the fetcher falls back to TextBlock (safe default). Practically always pin a renderer so ReadStore knows what shape to read.

Which means: the widget's render field decides what structure your file must have.

file_format picks how the file is parsed:

formatextensionshapes
"json".jsonevery structured shape (the default for non-text shapes)
"toml".tomlsame shapes as JSON
"text".txtText (whole file = one string) and TextBlock (one line per row) only

Omit file_format for Text / TextBlock → defaults to "text". Omit it for everything else → defaults to "json".

If the file doesn't exist, ReadStore returns an empty body for the declared shape (empty cells, 0.0 ratio, no entries, …) — the splash stays quiet rather than erroring. The shared empty-state placeholder paints in the widget's slot.

ReadStore supports every non-dynamic shape — 10 of the 12 variants. The two exceptions:

  • Badge — status pills are always "right now" values; use a built-in fetcher.
  • Timeline — event streams assume dynamic data; ReadStore is for snapshots.

Concrete JSON per supported shape:

// or plain text file with file_format = "text"
{ "value": "deploy ready" }
{ "lines": ["feat: foo", "fix: bar", "chore: baz"] }

With file_format = "text", one line of the file becomes one entry:

feat: foo
fix: bar
chore: baz
{
"items": [
{ "key": "env", "value": "prod" },
{ "key": "replicas", "value": "3", "status": "ok" },
{ "key": "p99", "value": "420ms", "status": "warn" }
]
}

status is optional ("ok" / "warn" / "error") and colours the value in renderers that honour it.

{ "value": 0.47, "label": "reading · 14 of 30 books", "denominator": 30 }

label and denominator are both optional.

{ "values": [2, 3, 1, 5, 8, 13, 4] }
{
"series": [
{ "name": "temp", "points": [[0, 20.0], [1, 21.5], [2, 22.1]] }
]
}

Multiple series are allowed — each appears as its own line / scatter cluster depending on the renderer.

{
"bars": [
{ "label": "rust", "value": 87000 },
{ "label": "toml", "value": 8000 },
{ "label": "sh", "value": 500 }
]
}
{ "path": "/home/you/.cache/me/avatar.png" }

Path is absolute — media_image opens the file directly.

{ "year": 2026, "month": 4, "day": 23, "events": [1, 15, 30] }

day highlights the focus / "today" cell. events is a list of extra highlighted days (1..=31). Both optional.

{
"cells": [[0, 1, 3], [2, 5, 1], [0, 0, 4]],
"thresholds": [1, 3, 5],
"row_labels": ["Mon", "Tue", "Wed"],
"col_labels": ["w17", "w18", "w19"]
}

Everything except cells is optional. Without thresholds the renderer buckets auto-quartiles over the cell values.

Tick today off, recompute value = completed / elapsed, write the ratio.

~/.local/bin/habit-tick
#!/usr/bin/env bash
store="$HOME/.splashboard/store/habit.json"
log="$HOME/.splashboard/habit.log"
touch "$log"
date +%Y-%m-%d >> "$log"
sort -u -o "$log" "$log"
count=$(grep -c "^$(date +%Y-%m)" "$log")
days=$(date +%d)
value=$(awk "BEGIN { printf \"%.3f\", $count / $days }")
cat > "$store" <<EOF
{ "value": $value, "label": "this month · $count of $days days" }
EOF

Paired with:

[[widget]]
id = "habit"
fetcher = "basic_read_store"
file_format = "json"
render = { type = "gauge_line", label = "habit" }

A yearly target, updated by hand whenever a book lands.

// $HOME/.splashboard/store/reading.json
{ "value": 0.47, "label": "reading · 14 of 30 books" }
[[widget]]
id = "reading"
fetcher = "basic_read_store"
file_format = "json"
render = "gauge_circle"

Emit a commit-count-per-day number series from a post-commit hook:

// $HOME/.splashboard/store/commits.json
{ "values": [3, 1, 0, 5, 2, 8, 4, 0, 1, 7] }
[[widget]]
id = "commits"
fetcher = "basic_read_store"
file_format = "json"
render = "chart_sparkline"
// $HOME/.splashboard/store/workouts.json
{
"cells": [
[0, 1, 0, 1, 2, 0, 1],
[1, 0, 2, 0, 1, 0, 2],
[2, 1, 0, 1, 0, 2, 1]
],
"row_labels": ["wk17", "wk18", "wk19"],
"col_labels": ["M", "T", "W", "T", "F", "S", "S"]
}
[[widget]]
id = "workouts"
fetcher = "basic_read_store"
file_format = "json"
render = "grid_heatmap"
  • Files live at $HOME/.splashboard/store/<widget-id>.<ext>.
  • The widget id is sanitised — only [A-Za-z0-9_-] survive, so a hostile repo-local config can't traverse out of the store directory.
  • Classified Safe in the trust model — always runs, even from an untrusted local dashboard, because the read path is fixed.
  • Not a subprocess interface. There's no command = "..." or plugin protocol; splashboard is a curated renderer, not a shell-script host.
  • Not a remote fetcher. The file path is fixed under $HOME. If you need a URL, that's a built-in Network fetcher territory.
  • Not a full dashboard backend. If your widget deserves curated UX (auth flow, rate limits, stateful update, server-side pagination) it lands as a built-in fetcher PR instead.