We’ll build worldclock: a page widget showing a city’s local time with an analog face, a settings page, and a compact notch pill. It exercises the entire loop — declaration, displays, logic, data flow, settings — in under a hundred lines.
The package
- worldclock/
- mod.jsonthe manifest — pure data
- main.luathe entry — wires displays to logic
- displays.luathe visual trees
- logic.luathe handlers
Only mod.json and the entry are contractual. Splitting the entry into
displays.lua + logic.lua via require is convention — the loader runs
one entry file (default main.lua) and expects it to return
{ displays = <table of trees>, on_* = <handlers> }.
1 — Declare it
{
"id": "worldclock",
"title": "World Clock",
"version": "1.0",
"api": 1,
"scopes": ["timer"],
"palette": { "brand": "#88c0d0" },
"settings": [
{ "key": "zone", "label": "Time zone", "kind": "text", "default": "Asia/Tokyo" },
{ "key": "h24", "label": "24-hour", "kind": "toggle", "default": true }
],
"widgets": [
{
"id": "worldclock", "label": "World clock", "urgency": "ambient",
"contexts": {
"expanded": { "display": "card" },
"notch_secondary": { "display": "pill" }
},
"preview": { "city": "Tokyo", "time": "17:42", "sub": "UTC+9" }
}
]
}
Reading it:
scopesis your grant list, shown verbatim on the Workshop page. We needtimerfor the tick;perch.time.*is pure and always granted.palettedeclares up to 4 identity colors. Trees reference them ascolor = "@brand"; the active theme may remap them.settingsbecomes a real settings-rail page, stored per-mod, live-applied throughon_configure.previewis demo data for the settings picker’s live miniature.
2 — The displays
return {
-- the 370×100 expanded slot
card = {
type = "row", gap = 14, align = "center", pad = { 0, 16, 0, 16 },
children = {
{ type = "draw", w = 64, h = 64, bind = "hands", on_draw = require("logic").draw_face },
{ type = "col", gap = 2, flex = 1, children = {
{ type = "text", bind = "city", role = "title" },
{ type = "text", bind = "time", size = 25, weight = 700, color = "@brand" },
{ type = "text", bind = "sub", size = 11, color = "tertiary" },
} },
},
},
-- the trailing notch pill
pill = {
type = "row", gap = 6, align = "center",
children = {
{ type = "icon", value = "clock", size = "s", color = "half" },
{ type = "text", bind = "time", size = 13, weight = 700 },
},
},
}
Everything here is structure: which nodes exist, how they lay out, what field each one binds. There is no formatting logic, no conditionals beyond data, no DOM. If a value needs computing, Lua computes it and pushes the answer.
3 — The logic
local M = {}
local zone, h24 = "Asia/Tokyo", true
local function payload()
local t = perch.time.now(zone) -- {hour,min,sec,year,mon,day,dow,off}
local city = zone:match("([^/]+)$"):gsub("_", " ")
local off = t.off >= 0 and ("UTC+" .. t.off // 60) or ("UTC" .. -(-t.off // 60))
return {
city = city,
time = perch.time.format(h24 and "%H:%M" or "%I:%M %p", zone),
sub = off,
hands = { h = t.hour % 12, m = t.min },
}
end
function M.on_start(host)
host.set_presence("worldclock", true, payload())
host.timer("tick", 30 * 1000) -- repeating; floored at 1000 ms
end
function M.on_timer(host)
host.push_data("worldclock", payload())
end
function M.on_configure(host, s)
zone = s.zone or zone
h24 = s.h24
host.push_data("worldclock", payload()) -- live-apply
end
-- Runs ONLY when `hands` changes — draw is data-driven, never per-frame.
function M.draw_face(host, d, w, h)
local g = host.g
g.circle(w / 2, h / 2, w / 2 - 2, { fill = "raised", stroke = "rim" })
local ha = (d.hands.h + d.hands.m / 60) / 12 * 2 * math.pi
local ma = d.hands.m / 60 * 2 * math.pi
g.line(w / 2, h / 2, w / 2 + 14 * math.sin(ha), h / 2 - 14 * math.cos(ha),
{ stroke = "primary", width = 3, cap = "round" })
g.line(w / 2, h / 2, w / 2 + 22 * math.sin(ma), h / 2 - 22 * math.cos(ma),
{ stroke = "@brand", width = 2, cap = "round" })
end
return M
The shape of every handler: receive host, compute view-ready values
(finished strings, angles, booleans), push them. The perch global and the
host argument are the same table — two spellings of one API.
4 — The entry
local displays = require("displays")
local logic = require("logic")
local package = { displays = displays }
for name, handler in pairs(logic) do
if name:sub(1, 3) == "on_" then package[name] = handler end
end
return package
5 — Run it
PERCH_MOD_DEV=~/perch-dev perch
Place the widget on a page. Change the zone in its settings page and watch
the card re-render instantly — on_configure fires, pushes, and only the
changed fields repaint. That’s the whole loop.
What you now know
| Concept | Where it goes deeper |
|---|---|
| Manifest fields, palette, settings | The package format |
Contexts beyond expanded | Widgets & contexts |
| The 20 display components | Components |
Every perch.* function | Lua API |