JSON URL
Describe what to show; Still decides how it looks. The JSON URL widget fetches a block document from any URL you control and sets it in Still's own type, spacing and marks, in pure black and white. The same document renders correctly on every screen size: sizes follow the zone, not the document.
Configuring the widget
Pick JSON URL for a zone in the layout editor and give it one field, url: an http or https address that answers a GET with the document.
On every scheduled render Still fetches the URL with Accept: application/json. The fetch:
- gives up after 10 seconds,
- refuses a body larger than 1 MB,
- reuses a response for up to a minute when the same URL is fetched again, so two zones pointing at one URL cost one request,
- refuses private and link-local addresses (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,169.254.0.0/16,0.0.0.0/8,::1,fc00::/7,fe80::/10) andlocalhost, checked when you save and again after DNS resolution at every render, including every redirect.
The URL has to be reachable from the internet. A service on your home network needs a public address, a tunnel, or a push to somewhere public.
The document
{ "version": 1, "blocks": [] }
version: always1.blocks: a list of blocks, drawn top to bottom. Every block has atypeand the fields listed under it below. Every field is a string unless noted.
Every optional field can also be sent as null, which Still reads as "not set" and draws exactly like a field that was left out, so rows straight out of a database need no cleaning up first.
Text is clipped at the edge of its block; nothing shrinks to fit. Put what matters first.
Inline content
Every field below that holds words takes more than a plain string. Anywhere you can write
{ "type": "header", "left": "Torsdag 28 augusti", "right": "17:02" }
you can instead write a list, and mix text with things Still fills in:
{
"type": "header",
"left": "Torsdag 28 augusti",
"right": ["Morgon · ", { "type": "battery" }, " · uppdaterad 12:05"]
}
A slot takes a string, one inline object, or a list mixing the two. Strings are set as
written; objects are replaced at render time. Today the only inline object is
{ "type": "battery" }, which takes the same optional show as the block of the same
name and draws the glyph at the size of the text around it.
The one field this does not apply to is text on qr: that is the payload the code
encodes, not something drawn, so it stays a plain string.
Inline batteries leave out the "Charge me" warning and the "No battery reading" line —
there is no room for them on a line of text. Use the battery block where that matters.
header
A bold uppercase line with a mono note on the right and a thick rule under it.
| Field | Required | Meaning |
|---|---|---|
left |
yes | Heading, set in uppercase |
right |
no | Short note in mono, such as a time |
{ "type": "header", "left": "Torsdag 28 augusti", "right": "17:02" }
hero
The one thing to read from across the room. The title is set large and wraps to at most two lines; an optional QR code sits to its right.
| Field | Required | Meaning |
|---|---|---|
kicker |
no | Small uppercase line above the title |
title |
yes | Large title, at most two lines |
meta |
no | One line under the title |
qr |
no | Object: a QR code to the right |
qr.text |
yes, in qr |
What the QR code encodes, usually a URL, up to 512 characters |
qr.caption |
no | Mono caption under the code |
{
"type": "hero",
"kicker": "Ikväll",
"title": "Ugnsbakad lax med potatis",
"meta": "45 min · 4 port · allt hemma",
"qr": { "text": "https://middag.local/r/lax-potatis", "caption": "Recept" }
}
columns
Two or three equal columns with a 1 px rule between them. The block fills the height left over by the blocks around it.
| Field | Required | Meaning |
|---|---|---|
columns |
yes | List of 2 or 3 columns, each a list of blocks |
{
"type": "columns",
"columns": [
[{ "type": "label", "text": "Kommande" }],
[{ "type": "label", "text": "Tar slut" }]
]
}
label
A small uppercase, letter-spaced heading for what follows.
| Field | Required | Meaning |
|---|---|---|
text |
yes | The label |
style |
no | section (default) or sub, which adds a rule above to split a column |
{ "type": "label", "text": "I poolen", "style": "sub" }
list
One row per item.
| Field | Required | Meaning |
|---|---|---|
items |
yes | List of rows |
items[].text |
yes | The row's text |
items[].when |
no | Short mono prefix, such as a day or a time |
items[].tag |
no | Mono note, right-aligned |
items[].tagStyle |
no | plain (default) or boxed, which draws a box around the tag |
items[].mark |
no | A square before the row: filled or empty |
{
"type": "list",
"items": [
{ "mark": "filled", "when": "Fre", "text": "Köttbullar med mos" },
{ "mark": "empty", "text": "Pasta carbonara" },
{ "text": "Smör", "tag": "Slut", "tagStyle": "boxed" },
{ "text": "Mjölk", "tag": "Går ut ons" }
]
}
text
A paragraph, drawn exactly like the Text widget.
| Field | Required | Meaning |
|---|---|---|
text |
yes | The paragraph |
size |
no | s, m (default) or l |
{ "type": "text", "text": "Grattis på födelsedagen!", "size": "l" }
qr
A QR code on its own, drawn with error correction level M and every module on whole pixels so a phone reads it off the panel.
| Field | Required | Meaning |
|---|---|---|
text |
yes | What the code encodes, up to 512 characters |
caption |
no | Mono caption under the code |
{ "type": "qr", "text": "https://middag.local/lista", "caption": "Inköpslista" }
battery
How much charge the screen itself has left, as an outlined battery glyph filled in five steps. The value is not yours to set: Still takes it from the voltage the device reported when it last fetched an image, so the reading is one schedule interval old. A device that sends no voltage gets an empty outline and "No battery reading". At a tenth left or less the block adds "Charge me".
The voltage is rounded to a hundredth before anything is drawn and the percent is read from that, so jitter below a hundredth of a volt cannot change the image.
| Field | Required | Meaning |
|---|---|---|
show |
no | icon-and-percent (default), icon for the glyph alone, or volts for the raw voltage |
{ "type": "battery", "show": "icon-and-percent" }
footer
A mono line with a thick rule above, pinned to the bottom of the zone.
| Field | Required | Meaning |
|---|---|---|
left |
yes | Main note |
right |
no | Note on the right, such as the next update |
{ "type": "footer", "left": "Inköpslista · 7 varor väntar", "right": "Nästa uppdatering 21:00" }
A full example
The kitchen screen this widget was built for: tonight's dish with a QR code to the recipe, the next meals, and what is running out. Serve it from any URL and point a zone at it.
{
"version": 1,
"blocks": [
{ "type": "header", "left": "Torsdag 28 augusti", "right": "17:02" },
{
"type": "hero",
"kicker": "Ikväll",
"title": "Ugnsbakad lax med potatis",
"meta": "45 min · 4 port · allt hemma",
"qr": { "text": "https://middag.local/r/lax-potatis", "caption": "Recept" }
},
{
"type": "columns",
"columns": [
[
{ "type": "label", "text": "Kommande" },
{ "type": "list", "items": [
{ "mark": "filled", "when": "Fre", "text": "Köttbullar med mos" },
{ "mark": "filled", "when": "Lör", "text": "Tacos" }
]},
{ "type": "label", "text": "I poolen", "style": "sub" },
{ "type": "list", "items": [
{ "mark": "empty", "text": "Pasta carbonara" },
{ "mark": "empty", "text": "Linsgryta med spenat" },
{ "mark": "empty", "text": "Kycklingwok" }
]}
],
[
{ "type": "label", "text": "Tar slut · går ut" },
{ "type": "list", "items": [
{ "text": "Smör", "tag": "Slut", "tagStyle": "boxed" },
{ "text": "Mjölk", "tag": "Går ut ons" },
{ "text": "Grädde", "tag": "Går ut fre" },
{ "text": "Gul lök", "tag": "1 kvar" },
{ "text": "Ägg", "tag": "2 kvar" }
]}
]
]
},
{ "type": "footer", "left": "Inköpslista · 7 varor väntar", "right": "Nästa uppdatering 21:00" }
]
}
The npm package
If you produce the document from TypeScript, @ahl-berg-innovation-ab/still-block-document gives you the types, one builder per block and the JSON Schema, so a change to the format reaches you as a version bump instead of a broken screen. It is published to GitHub Packages and readable by repositories in the org. Tell npm where the scope lives:
@ahl-berg-innovation-ab:registry=https://npm.pkg.github.com
Then install it and build the document with the builders, which take your data directly:
import { columns, doc, footer, header, hero, section } from "@ahl-berg-innovation-ab/still-block-document";
const document = doc(
header("Torsdag 28 augusti", "17:02"),
tonight && hero({ kicker: "Ikväll", title: tonight.name, qr: { text: tonight.url } }),
columns(
[section("Kommande", upcoming.map((meal) => ({ when: meal.day, text: meal.name })))],
[section("Tar slut", lowStock.map((item) => ({ text: item.name, tag: item.note })))],
),
footer("Inköpslista · 7 varor väntar"),
);
doc stamps the version, and columns takes the two or three columns the format allows, so a wrong count is a type error rather than a blank zone. Both take a block, a list of blocks, or nothing at all — null, undefined or false — so a block you draw only some days needs no branch around it. section is a label with its list under it, and is nothing at all when the list is empty. Optional fields take null as "not set", so your rows go in as they come out of the database.
The package also ships the JSON Schema, draft 2020-12, generated from the schema Still itself validates with, as a typed module, @ahl-berg-innovation-ab/still-block-document/schema, and as plain JSON, @ahl-berg-innovation-ab/still-block-document/schema.json. Still serves the same schema at https://still.rcusahlberg.se/schema/block-document-v1.json, the URL it carries in $id, so a hand-written document can name it in $schema and any validator can fetch it.
When something is wrong
If the fetch fails, times out, returns something other than a 2xx, is larger than 1 MB, is not JSON, or does not match the schema, the zone shows "JSON URL unavailable" with the reason under it, for a schema mistake the path to the offending field, such as blocks[2].columns[0][1].type: Invalid discriminator value. Render a preview in the layout editor to see it, fix the document, and render again.