Developers
Write a widget
On this page
This page is for developers who want to add a feature to the frame, or understand how the existing ones (Clock, Weather, Calendar, Photos, Home Assistant and Cards) are built. When you finish it you will have a working widget, and you will know every hook the server, the frame page and the admin offer, and the rules that keep widgets independent of each other and of the core.
A widget is one folder, web/app/widgets/<id>/. Put the folder there and the widget exists on the server, in the cron fetcher, on the frame and in the admin. Delete it and it is gone from all four. No core file names a widget, and a test makes sure none ever does.
How a widget is wired in
flowchart TD
dir["web/app/widgets/<id>/"] --> wp["widget.php<br/>the definition"]
dir --> icon["icon.svg"]
dir --> frame["frame/<br/>home.php, pages.php, overlay.php,<br/>widget.js, *.css"]
dir --> adminf["admin/<br/>widget.js, *.css"]
dir --> routes["routes.php, admin_routes.php"]
dir --> fetch["fetch.php, fetch_lib.php"]
dir --> tests["tests/*_test.php"]
wp --> reg["Registry: widgets.php<br/>glob widgets/*/widget.php"]
reg --> dash["Dashboard JSON<br/>data, page, pageIf"]
reg --> self["Kiosk self GET<br/>self, origins, secrets"]
reg --> adminlist["Admin widget list and save<br/>settings, types, info, on_save"]
reg --> csp["Page CSP<br/>csp"]
reg --> cron["Cron needs<br/>needs"]
fetch --> cronloop["Cron loop<br/>fetch/fetch.php"]
frame --> view["views/dashboard.php<br/>home cells, pages, overlay,<br/>script tags, icon sprite, CSS bundle"]
adminf --> adminshell["views/admin.php<br/>script tags, CSS bundle"]
routes --> fc["Frame front controller"]
routes --> ac["Admin front controller"]
The registry (widget_names()) is glob(app/widgets/*/widget.php), sorted, with the id checked against ^[a-z][a-z0-9_]{0,31}$. Everything else is looked up by that id. File paths are built only by widget_path($id, $file), which throws for an unknown id, so no request can reach a path outside a registered widget.
Build your first widget
You will make a widget called Greeter that shows a line of text sent by the server. It needs five files and no core change.
Before you start, set up a development copy and sign in to the admin with a frame claimed (see the API reference for enrolling a frame without a device).
-
Create the folder
web/app/widgets/greeter/and, inside it,widget.php:php<?php declare(strict_types=1); return [ 'label' => 'Greeter', 'description' => 'Shows a line of text.', 'home' => ['row' => 2, 'order' => 99, 'span' => 1], 'settings' => [], 'needs' => fn(array $cfg): array => [], 'data' => fn(array $cfg, Cache $c, Frame $f): array => ['text' => 'Hello from Greeter'], ]; -
Add
icon.svg. It holds only the inside of a 24 by 24 stroke icon, with no<svg>wrapper:xml<circle cx="12" cy="12" r="9"/><path d="M8 12h8"/> -
Create
frame/home.php, the markup that goes inside the widget's home cell:php<span id="greeter-text" class="greeter-text"></span> -
Create
frame/widget.js. The shape is fixed: strict mode, one function that calls itself, oneF.widget({...}).js"use strict"; (() => { const F = window.Frame; F.widget({ id: "greeter", update(data) { if (data === null) return false; F.$("greeter-text").textContent = data.text; }, }); })(); -
Create
frame/widget.css. Every selector starts with.w-greeter:css.w-greeter .greeter-text { font-size: 28px; color: var(--soft); } -
Run
php tests/run.phpandvendor/bin/phpstan analysefromweb/. The registry, scope, security and isolation tests pick up the new folder by themselves. -
In the admin, open the frame and its Widgets panel and switch on Greeter. Saving bumps the frame's
config_version, so the dashboard reloads on its next poll (within 20 seconds). A Greeter card appears in the second row of the home grid with your text.
You just added a widget without editing a core file. Switch it off in the admin, and the card goes. Delete the folder, and the frame, the admin and the cron job forget it; its leftover frame_widgets rows are ignored.
Directory layout
Path under widgets/<id>/ |
Needed | Loaded by | What it holds |
|---|---|---|---|
widget.php |
Yes | Web, admin, cron | The definition: returns an array of hooks (see The definition) |
icon.svg |
Yes | Dashboard and admin views | Inner markup of a 24 by 24 stroke icon. Becomes the sprite symbol #icon-w-<id> |
lib.php |
No | Your own requires |
Domain functions. No outbound requests |
routes.php |
No | Frame front controller | Public and frame-authenticated routes |
admin_routes.php |
No | Admin front controller | Admin API routes |
handlers.php |
No | routes.php, tests |
Handlers for the routes, so tests can load them without registering routes |
fetch.php |
No | Cron loop only | The fetcher definition |
fetch_lib.php |
No | fetch.php, tests |
Fetch functions, so tests can load them without the definition |
frame/home.php |
If home |
views/dashboard.php |
Markup for the home cell |
frame/pages.php |
If the widget has pages | views/dashboard.php |
One or more <section class="page" data-page="…"> |
frame/overlay.php |
No | views/dashboard.php |
Markup placed after the main stage (for example a dialog) |
frame/widget.js |
No | The frame page | Registers with Frame.widget() |
frame/*.css |
No | The frame CSS bundle | Styles, scoped to the widget |
admin/widget.js |
No | The admin page | Registers with Admin.widget() |
admin/*.css |
No | The admin CSS bundle | Styles for the widget's admin panel |
public/ |
No | Your own routes | Public page files (the sender and drop pages) |
tests/*_test.php |
No | tests/run.php |
The widget's tests. They vanish with the widget |
sources/<id>/ |
Photos only | The photos widget | Photo sources (see Photo sources) |
Fragments (home.php, pages.php, overlay.php) are plain PHP files that print HTML. They may not contain style=, an on…= handler or <script>. Element ids must be unique across the whole page, so prefix yours with the widget id.
A widget that needs its own table must add a core migration. Per-widget migrations do not exist yet.
The definition: widget.php
widget.php returns an array. Only a few keys are required; the rest are optional hooks. A hook that is absent is not called.
| Key | Required | Shape | What it does |
|---|---|---|---|
label |
Yes | string |
Name in the admin |
description |
Yes | string |
One line under the name in the admin |
settings |
Yes | array<string, string> |
Setting key to setting type. [] for none. Keys match ^[a-z][a-z0-9_]{0,31}$ |
needs |
Yes | fn(array $cfg): list<string> |
Cache keys the cron fetcher must keep fresh for a frame that has this widget on |
data |
Yes | fn(array $cfg, Cache $c, Frame $f): array |
The widget's slice of the dashboard JSON |
types |
No | array<string, array> |
Widget-local setting types |
default |
No | ['position' => int, 'enabled' => fn(PDO): bool, 'config' => fn(PDO): array] |
Row seeded when a frame is claimed |
home |
No | See Home cell | Where the widget's card sits on the home screen |
page |
No | [string $label, string $icon] |
A rail item that opens the widget's page |
pageIf |
No | fn(array $cfg, Cache $c): bool |
Show the rail item only when true |
info |
No | fn(array $cfg, PDO $db, string $frameId): array |
Extra facts for the admin, returned as info on the widget row |
self |
No | fn(array $cfg, PDO $db, array $frameRow): array |
Values added to the kiosk's self GET |
origins |
No | fn(array $cfg): list<string> |
Origins the kiosk may navigate to. Only one widget may declare it |
secrets |
No | fn(array $cfg): array |
Values for the kiosk's native code only |
on_save |
No | fn(array $incoming, array $stored): array |
Adjusts a config before it is validated and saved |
csp |
No | array<string, list<string>> |
Extra Content Security Policy sources |
checks |
No | fn(): list<string> |
Host problems for bin/migrate.php to print |
Hooks run with these guarantees:
needs,data,self,origins,secretsandpageIfrun only for enabled widgets of active frames whose id is still in the registry.$cfgis the widget's saved settings for that frame, decoded from JSON.selfandsecretsare wrapped so that an exception is logged and the widget contributes nothing; the page and the other widgets are unaffected.datareturning[]is sent as{}. A widget that is off is absent from the dashboard JSON, which is how the frame page tells "off" from "on but empty".
Settings and setting types
settings maps each key to a type. A type says how a value is validated and what its default is. Three core types are always available:
| Type | Stored as | Rule | Default |
|---|---|---|---|
location |
integer | The id of an existing Library location | none (required) |
calendars |
list of integers | At most 50 ids of existing Library calendars | [] |
seconds |
integer | 5 to 3600 | 30 |
A widget adds its own types under types:
'types' => [
'my_text' => [
'default' => '',
'secret' => false, // optional
'check' => fn(mixed $v, string $key, PDO $db): string =>
is_string($v) && mb_strlen($v) <= 40 ? $v : throw new FieldError('config', "$key must be at most 40 characters"),
],
],
check returns the clean value or throws FieldError('config', $message), which the admin API turns into a 422. Type names are global: two widgets declaring the same name throws a LogicException. A new type also needs a control in the widget's admin module, or the admin shows no field for it.
How a save is processed (PUT /api/frames/{id}/widgets/{widget}):
on_save($incoming, $stored)runs first, if the widget has it.- Secrets that the request left out are copied back from the stored config.
- Every declared setting is validated: a missing key takes its type's default and is checked like any other value, so a type whose default fails its own check makes that setting required. An undeclared key is a
422. - The result is stored and the frame's
config_versiongoes up by one, but only if something changed.
Secret settings ('secret' => true) are never returned by the admin API. Omit the key in a PUT to keep the stored value; send "" to clear it. Use the info hook to tell the admin whether one is set (for example token_set). The Home Assistant token and the Nextcloud album link work this way.
Seeding on claim: default
When an admin claims a frame, every widget with a default hook gets a frame_widgets row (ON CONFLICT DO NOTHING, so it never overwrites). enabled and config are functions of the database, so a default can depend on existing Library records. Clock, Weather and Calendar use it. Widgets without it start absent, which is off.
Home cell
home places the widget's card on the home screen and needs frame/home.php. The grid has four columns and two rows.
| Row | Keys | Behavior |
|---|---|---|
1 |
col (1 to 4), span |
A fixed slot. With no data the slot stays as an empty card, so the layout does not jump. Two widgets may not claim the same column |
2 |
order, span |
Flows left to right by order, packed against the right edge. Hidden while the widget has nothing to show |
2 |
grow => true |
Takes whatever columns the other row 2 cells leave, on their left. Only one cell can grow |
Pages: page and pageIf
'page' => ['Week', 'calendar'] adds a rail item labelled "Week" with the icon calendar. The item opens the page whose data-page equals the widget id, so frame/pages.php needs a <section data-page="<widget id>">. A widget can instead register a launch hook, which gives a rail item with no page of its own (Home Assistant does this). pageIf hides the rail item when it returns false; the dashboard JSON lists the visible items in pages as {widget, label, icon}. A widget may have more pages than the one on the rail (the calendar has a second one that its home card opens). Page ids must be unique across widgets and may not be home.
Facts for the admin: info
Whatever info returns is added to the widget's row in GET /api/frames/{id}/widgets as info. Use it for state the admin should show but the config does not hold: the effective URL, whether a secret is set, a count, an error.
Values for the kiosk: self, origins, secrets
These feed the kiosk's GET /api/v1/frames/{id} (the "self GET").
-
selfreturns keys merged into the body. Anullvalue omits the key. The core default forphotosis[], meaning "clear your cache", so a deleted or disabled photos widget degrades the same way. Changing the shape of the self GET is a change to a frozen contract: add keys, never rename or remove them. Shipped kiosks ignore keys they do not know. -
originsreturns the origins the kiosk may navigate to besides the frames site. The kiosk takes the first entry as the Home Assistant origin, so only one widget may declare this hook. A test fails if a second does. -
secretsreturns values placed underwidget_secrets.<id>in the self GET, and only when the request carried theAuthorizationheader. They never appear in the dashboard JSON or in any admin response. The kiosk reads only the Home Assistant token today; a new key needs a kiosk change before it does anything.
Page security policy: csp
csp adds sources to the dashboard and admin Content Security Policy, for example ['img-src' => ['https://photos.example.org']]. A test limits this to img-src, media-src and connect-src, and each source must be a plain https origin. A widget can never loosen script-src or style-src.
Host checks: checks
bin/migrate.php calls each widget's checks and prints the strings as WARNING (<id>): …. A string that starts with NOTE: prints as information. Use it for a missing system tool or an unsupported setting. It does not fail the migration.
Options from config.php
An operator can pass options to a widget under widgets.<id> in config.php. They arrive untouched in the constant WIDGET_CONFIG['<id>']. Validate them yourself when you read them, and throw ConfigError for a bad value, as photos_limits() does.
The frame page
The core page (frame.js) owns the rail, the Dim button, the Back button, the idle timer, the offline badge, the 20-second poll, reloading when the code changes, and the home grid. A widget supplies markup and a small script.
What the server composes
views/dashboard.php walks the registry and writes, for each widget that has the files:
| File | Becomes |
|---|---|
frame/home.php |
<button id="cell-<id>" class="card home-cell w-<id> shortcut" data-part="home" data-widget="<id>" data-row … data-span …> containing your markup |
frame/pages.php |
<div class="w-<id>" data-part="pages" data-widget="<id>"> inside the stage; its <section data-page> elements are the pages |
frame/overlay.php |
<div class="w-<id>" data-part="overlay" data-widget="<id>"> after the stage |
frame/widget.js |
<script src="/frames/assets/w/<id>/widget.js?v=…" defer> |
icon.svg |
<symbol id="icon-w-<id>"> in a hidden sprite |
frame/*.css |
Part of /frames/assets/frame.css?v=… |
Every script is defer, so all of them have run before the core starts on DOMContentLoaded.
Registering: Frame.widget
F.widget({
id: "photos",
pages: { photos: { fullscreen: true, sticky: true } },
init(ctx) {},
update(data, ctx) {},
tick(now) {},
onShow(pageId) {},
onHide(pageId) {},
homeTarget() {},
idlePage() {},
busy() {},
railItems() {},
launch(pageId) {},
layout(span) {},
});
Every hook is optional, and every call is wrapped in try/catch. A widget that throws is marked failed: its cell goes empty and the other widgets keep running. Check the browser console on the device or in a desktop browser for widget <id> failed in <hook>.
| Hook | Called | Notes |
|---|---|---|
init(ctx) |
Once, when the core starts | ctx has id, home (the cell), root (the pages wrapper), page(id) (a page you own) and slot(pageId) (your slot in another widget's page; null if that page does not exist, which is how soft dependencies work) |
update(data, ctx) |
Every poll | data is dashboard.widgets[id], or null when the widget is off or absent. Return false to hide the cell |
tick(now) |
Every second | now is server-corrected milliseconds. Keep it cheap |
onShow(pageId), onHide(pageId) |
After a page of yours becomes visible or hidden | |
homeTarget() |
When the cell is tapped | Return the page id to open, or null for none. The default is the first page in pages |
idlePage() |
When the idle timer expires | Return a page id to show instead of Home. The first non-null answer wins |
busy() |
Idle and reload checks | Return true to block returning home and reloading (for example while a dialog is open) |
railItems() |
Each poll | Extra rail entries: [{id, label, icon, badge}] |
launch(pageId) |
A rail item with no page is tapped | Navigate away (for example location.assign(url)); return false to cancel |
layout(span) |
When your cell's width changes | span is the number of columns the cell now has |
pages is an object keyed by page id with options:
| Option | Effect |
|---|---|
fullscreen |
The rail is hidden while the page shows; a tap brings it back for five seconds. No Back button |
sticky |
The page survives idle return and a reload, and stays until someone picks another page |
listed |
The page is open only while the server's pages list names this widget |
avail |
A function that returns whether the page may open |
Helpers on window.Frame
| Helper | What it gives you |
|---|---|
$, el, reflow |
getElementById, a createElement shortcut that sets class and textContent, and a forced layout |
svgIcon(key, size), icons.add(key, nodes) |
An SVG icon. Unknown keys fall back to your sprite (icon-w-<key>) |
nowMs(), tz(), parts(), clock12(), ymd(), parseYmd(), utcFmt(), deg() |
Time in the frame's zone and small formatters |
changed(key, signature) |
true once when the signature differs from the last call. Use it to skip redundant redraws |
touch(), idleMs() |
Reset or read the idle timer |
toast(text) |
A brief message |
frameId, api(path) |
The frame id and a URL under /frames/api/v1/frames/<id>/ |
showPage(id, fromCell), current(), available(id), busy(), loaded(), refresh(), slot(pageId, ownerId) |
Page control and state |
Rules that tests enforce on frame/widget.js:
- The file is
"use strict";, then one(() => { … })();, then ends. - It contains exactly one
.widget(call (do not write that text in a comment), and it registersid: "<the folder name>". - It does not use
document.write,eval(orinnerHTML. - Core
frame.jsnever names a widget.
Write text with textContent and build elements with createElement. Everything the server sends is data, not markup.
CSS
All frame/*.css files are concatenated into one frame.css: the core file first, then each widget's files, widgets and files sorted by name. The version is a hash over every part's path, modification time and size, so a change reaches the frame on its next reload.
Because a rule in one widget must not change another, a test checks that:
- Every selector starts with
.w-<id>(not followed by a word character or hyphen) orbody.w-<id>-<name>. @keyframesare namedw-<id>-<name>. No other at-rule is allowed.- No rule uses
!important.
You may use the core variables --bg, --surface, --line, --fg, --dim, --past, --accent, --accent-bg, --warn, --warn-bg, --soft, --font, and the core classes .card, .sect-label, .page-title, .page-empty, .hscroll and .xfade. body.page-fullscreen and .offline are hooks you can style against.
The admin
Most widgets need no admin code. The admin builds a settings panel for each widget from its settings, using its own controls for the three core types. You need admin/widget.js only when a widget has a custom control, an extra panel, or its own top-level view.
(function () {
"use strict";
const { h, rowField } = Admin;
Admin.widget({
id: "photos",
controls: { my_text: (w, key, ctx) => control }, // one per widget-local setting type
load: (frame) => Promise, // extra data per frame; arrives as ctx.extra[id]
panel: (frame, w, ctx) => Panel, // replaces the generic settings panel
views: [{ path: "/photos", nav: "Photos", render: async () => ({ title, nodes }) }],
});
})();
| Part | What it does |
|---|---|
controls |
Maps a setting type name to a function that builds a control. w is the widget row (with config, settings, info, enabled). A control is { key, node, field, val(), reset(), commit(), validate() }. val() returns the value to save; returning undefined omits the key, which keeps a stored secret. Set fn.secret = true on the function of a secret type |
load |
Runs when a frame page opens. The result is ctx.extra[<your id>] |
panel |
Returns a Panel that replaces the generic one |
views |
Adds a top-level page with a navigation link. render returns { title, nodes } |
A control that is missing for a widget-local type renders nothing, so the admin cannot set that setting. Add one.
window.Admin is the frozen toolbox the panels use: h (element builder), fill, append, btn, pill, svgIcon, icons, uid, api, act, toast, modal, confirmDialog, formField, groupField, rowField, chipField, panelHead, Panel, showSwitch, makeForm, formDialog, ago, when, fmtTz, clockTime, dayLabel, widgetsHref, addLocationDialog, addCalendarDialog, route, upload and control. Read the existing modules for examples: ha/admin/widget.js for custom controls, photos/admin/widget.js and notes/admin/widget.js for panels and views.
The same rules apply as on the frame: build DOM with h(), never innerHTML, no inline styles. Admin CSS goes in admin/*.css, is bundled into admin.css the same way, and should be scoped to your widget.
Routes
A widget can add HTTP routes. routes.php and admin_routes.php each return a function that builds a list of routes. A route is [METHOD, regex, handler], and a handler takes the regex matches and never returns: it ends with json_out(), empty_out(), an asset response or an exception.
| File | Signature | Registered with |
|---|---|---|
routes.php |
function (PDO $db, string $frameIdRegex): array |
frames_dispatch() under /frames. Paths are relative to /frames. $frameIdRegex is ([0-9a-f]{32}), already a capture group |
admin_routes.php |
function (PDO $db, string $base): array |
admin_api() under /frames/admin. Paths start with /api/ |
<?php
declare(strict_types=1);
return function (PDO $db, string $base): array {
return [
['GET', '#^/api/greeters$#', fn() => json_out(200, ['items' => []])],
['POST', '#^/api/greeters$#', fn() => greeter_create($db, $base)],
];
};
Rules:
- Core routes match first. A test checks that no widget route regex matches any core path, for any method.
- Admin routes are protected before your code runs.
admin_api()has already required a signed-in session and a same-origin request, and requires the CSRF token for anything that is not aGET. You must still calladmin_require_reauth()yourself for destructive actions, and useadmin_json_body(),admin_invalid()andadmin_unique()for input and errors. - Frame routes authenticate themselves. Call
frame_auth($db), thenframe_require_self($row, $id)andframe_require_active($row). Public pages that use a secret link do their own token check and rate limits; copyphotos/handlers.phpornotes/handlers.php. -
Read input with
request_json($allowed, $maxBytes),request_body()orrequest_image(). They give you415,413and422for free. -
Return errors by throwing
HttpError. See the API conventions. - New routes need a security review, and a review of the REST shape. See the security page.
The Apache .htaccess files cap request bodies at 64 KiB (frames) and 16 KiB (admin). An endpoint that takes a large upload needs a narrow LimitRequestBody exception keyed on THE_REQUEST, the way the photo and kiosk-release endpoints have. Keep it as narrow as they are.
Cron fetchers
A widget that needs data from the internet declares cache keys in needs and supplies a fetch.php. Only the cron loop (app/fetch/fetch.php) loads fetch.php, and only fetch code makes outbound requests. A test fails if any widget file calls cURL or opens a socket directly, or if a widget file other than fetch.php requires something from app/fetch/. Go out through http_get() (below).
<?php
declare(strict_types=1);
require_once __DIR__ . '/fetch_lib.php';
return [
'prefix' => 'gr',
'expand' => fn(string $need): array => preg_match('/^gr:(\d+)$/', $need, $m) === 1 ? ["gr:{$m[1]}:now"] : [],
'ttl' => fn(string $key): int => 600,
'fetch' => fn(string $key, PDO $cache, PDO $frames): mixed => gr_fetch($key),
'due' => null, // optional
'everyTick' => false,
'crashGuard' => false,
'backoff' => 60,
];
| Key | Meaning |
|---|---|
prefix |
The text before the first : in your keys. It must be unique across widgets |
expand(need) |
Turns one need from needs into the concrete cache keys. Return [] for a need you do not understand |
ttl(key) |
Seconds a cached value stays fresh |
fetch(key, $cache, $frames) |
Returns the value to store (any JSON-encodable value). Throwing marks the key failed |
due(key, $cache, $frames, $now) |
Optional. Return the key's last fetch time (0 if never) when it should be fetched now, or null. Call key_due() for the standard rule |
everyTick |
Fetch on every 15-second tick when due, ahead of the one-item budget |
crashGuard |
Record a failure before fetching, so a parser that dies on hostile input leaves a back-off instead of a retry loop |
backoff |
Base seconds for exponential back-off on failure, capped at one hour |
Each 15-second tick runs every everyTick key that is due and at most one other stale key, the one fetched longest ago. Read stored values in data with $c->get($key), which returns ['v' => decoded, 'at' => time] or null.
To go out, use http_get($url, $hosts, [], $accept, $maxBytes) or http_get_json() from app/fetch/http.php. They enforce HTTPS, a host allow-list, no redirects, a size cap and timeouts. Take the host list from config.php (CALENDAR_HOSTS, PHOTO_HOSTS) or declare a fixed one for a public API.
Photo sources
The photos widget shows pictures from one source per frame. A source is a folder, widgets/photos/sources/<id>/, found the same way widgets are. The widget merges every source's settings, types and csp, so no other file names a source. Delete a source folder and the frames that used it show no photos. Today the sources are nextcloud (a public album link) and local (an album in the photo library).
sources/<id>/source.php returns:
| Key | Shape | Meaning |
|---|---|---|
label |
string |
Name in the source picker |
settings |
array<string, string> |
Setting key to type, merged into the photos widget's settings |
types |
array |
Setting types, like a widget's types |
key |
fn(array $cfg): ?string |
The catalog key stored with the frame's daily set. null means not configured |
catalog |
fn(array $cfg, Cache $c, PDO $frames): ?array |
['ids' => list<int>, 'added' => array<int, int>] (photo id to the time it became visible), or null when not ready |
url |
fn(array $cfg, int $id, string $size, string $frameId): string |
Address of a photo. $size is frame or thumb |
serve |
?fn(array $cfg, int $id): never |
Streams a photo from this server. null means the frame loads url() directly |
needs |
fn(array $cfg): list<string> |
Cron keys |
csp |
array<string, list<string>> |
Extra img-src origins |
info |
fn(array $cfg, PDO $frames): array |
Admin facts: album_set, host, name, count, pending, error |
Photo ids are integers. A source that needs the cron fetcher also adds sources/<id>/fetch.php returning ['fetch' => fn(string $key, array $cfg, PDO $cache, ?callable $http): list<array{id: int, mtime: ?int}>]. Its keys must look like ph:<16 lowercase hex>, because the photos widget's cron prefix is ph and it accepts only that shape. The widget picks 50 photos a day per frame from the catalog, favoring recently added ones.
A source that serves photos itself (serve) must keep every query to status = 'approved'. See the security page.
Isolation rules
The point of the design is that you can remove or add a widget without touching anything else. These tests keep it true. They run with php tests/run.php:
| Test | What it proves |
|---|---|
| Registry integrity | Every widget has a label, description, needs, data and a valid icon; page ids are unique and not home; setting types and fetch prefixes are unique; only one widget declares origins |
| Core names no widget | No quoted widget id in app/*.php, routes/, fetch/, views/, bin/ or public/, apart from two documented exceptions |
| No outbound calls | No widget file calls cURL or sockets directly; only a widget's fetch.php may require from app/fetch/; only the cron loop requires fetch.php |
| Route shadowing | No widget route matches a core path |
| Fragments, scripts, CSS | The rules in this page for fragments, widget.js, icon.svg and CSS scoping |
| Deletion | For each widget, a copy of the app without that widget starts a server over a database that still holds its rows. The frame, the dashboard, the admin and cron must all work, and the error log must stay empty |
| Addition | A new widget folder, with no core edit, shows up in the admin list, the dashboard JSON and HTML, the CSS bundle and the icon sprite |
| Photo source deletion | Removing sources/local leaves frames showing no photos and everything else working |
When you delete a widget, expect: the frame page reloads (the code version includes the widgets folder's modification time), its rail item and pages vanish, its home cell is empty (row 1) or closes up (row 2), its admin row and panel disappear, cron stops fetching its keys, and the kiosk's self GET falls back to the core defaults. Its frame_widgets rows, tables and cache rows stay and are ignored; delete them by hand if you want them gone.
The checklist for a new widget:
- Create
widgets/<id>/withwidget.phpandicon.svg. - Add
frame/widget.js,frame/widget.cssandframe/home.phpand/orframe/pages.phpas needed. - Add
lib.php,fetch.php,routes.php,admin_routes.phporadmin/widget.jsonly if you need them. - Put the tests in
widgets/<id>/tests/. - Run the tests and PHPStan. Turn the widget on per frame in the Widgets panel.
- The only core file you may touch is
config.phpfor deployment values.
Related
- Developer start: set up, tests and the build
- API reference: the dashboard JSON, the self GET and the widget endpoints
- Database: the
frame_widgetstable - Security: the rules that apply to routes, uploads and secrets