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/&lt;id&gt;/"] --> 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).

  1. 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'],
    ];
    
  2. 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"/>
    
  3. Create frame/home.php, the markup that goes inside the widget's home cell:

    php
    <span id="greeter-text" class="greeter-text"></span>
    
  4. Create frame/widget.js. The shape is fixed: strict mode, one function that calls itself, one F.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;
        },
      });
    })();
    
  5. Create frame/widget.css. Every selector starts with .w-greeter:

    css
    .w-greeter .greeter-text { font-size: 28px; color: var(--soft); }
    
  6. Run php tests/run.php and vendor/bin/phpstan analyse from web/. The registry, scope, security and isolation tests pick up the new folder by themselves.

  7. 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, secrets and pageIf run only for enabled widgets of active frames whose id is still in the registry.
  • $cfg is the widget's saved settings for that frame, decoded from JSON.
  • self and secrets are wrapped so that an exception is logged and the widget contributes nothing; the page and the other widgets are unaffected.
  • data returning [] 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:

php
'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}):

  1. on_save($incoming, $stored) runs first, if the widget has it.
  2. Secrets that the request left out are copied back from the stored config.
  3. 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.
  4. The result is stored and the frame's config_version goes 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").

  • self returns keys merged into the body. A null value omits the key. The core default for photos is [], 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.
  • origins returns 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.
  • secrets returns values placed under widget_secrets.<id> in the self GET, and only when the request carried the Authorization header. 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

js
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 registers id: "<the folder name>".
  • It does not use document.write, eval( or innerHTML.
  • Core frame.js never 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) or body.w-<id>-<name>.
  • @keyframes are named w-<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.

js
(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
<?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 a GET. You must still call admin_require_reauth() yourself for destructive actions, and use admin_json_body(), admin_invalid() and admin_unique() for input and errors.
  • Frame routes authenticate themselves. Call frame_auth($db), then frame_require_self($row, $id) and frame_require_active($row). Public pages that use a secret link do their own token check and rate limits; copy photos/handlers.php or notes/handlers.php.
  • Read input with request_json($allowed, $maxBytes), request_body() or request_image(). They give you 415, 413 and 422 for 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
<?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:

  1. Create widgets/<id>/ with widget.php and icon.svg.
  2. Add frame/widget.js, frame/widget.css and frame/home.php and/or frame/pages.php as needed.
  3. Add lib.php, fetch.php, routes.php, admin_routes.php or admin/widget.js only if you need them.
  4. Put the tests in widgets/<id>/tests/.
  5. Run the tests and PHPStan. Turn the widget on per frame in the Widgets panel.
  6. The only core file you may touch is config.php for deployment values.