← All docs

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:

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": [] }

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.