Web UI¶
The @ts-ha/web-ui package provides an optional browser-based dashboard served on the same port as the HTTP server. It registers itself with the engine as a ServicePlugin — the core engine contains no web-UI-specific code. It is a control-first interface: the landing view is an Overview control surface that summarizes the home (live device metrics, favorites, and room zones) before listing it, with engine internals (automations, state, logs, HomeKit, energy, weather, walls editor) demoted to a distinct operator section.
The interface is built on a single designed visual system — Lumen, a dark-first, light-designed semantic token layer declared once in the Tailwind CSS entry stylesheet (palette, typography, spacing, elevation, radii, motion), where the same token block is also the utility source. It is not component-library defaults, and it ships with a self-hosted display typeface. The full-bleed wall is the one exception: it is drawn in Hearth, its own wall-owned system, loaded on demand rather than in the entry stylesheet (see Wall displays).
Enabling¶
Navigate to http://your-host:8080/status.
WEB_UI_ENABLED and WEB_UI_PATH are parsed by @ts-ha/web-ui, not the core
engine. ts-ha run reads WEB_UI_ENABLED only as a raw import gate (so a
disabled run never loads the package), then lets the package parse its own
options. The auth token is supplied by the runner from the core config's
resolved HTTP_TOKEN; the package never reads HTTP_TOKEN itself.
The web UI is disabled by default and adds zero overhead when disabled — the
package is imported lazily only when enabled, and adding it to ts-ha run is a
dynamic import.
Embedding the UI in your own runner¶
ts-ha run registers the web UI plugin for you. If you build your own entry
point with createEngine() directly, setting WEB_UI_ENABLED has no effect
on its own — the core engine does not know the web UI exists. Register the
plugin explicitly, passing the core config's resolved httpServer.token as the
auth token:
import { createEngine, loadConfig } from "@ts-ha/core";
import { createWebUiService, parseWebUiOptions } from "@ts-ha/web-ui";
const config = loadConfig();
const options = parseWebUiOptions(); // reads WEB_UI_ENABLED / WEB_UI_PATH
const engine = createEngine({
automationsDir: "./automations",
services: options
? {
"web-ui": createWebUiService({
path: options.path,
token: config.httpServer.token,
}),
}
: {},
});
await engine.start();
The plugin is optional: a headless install does not need @ts-ha/web-ui, and a
run that leaves it out serves no UI routes. The onStart hook the engine calls
supplies the plugin's logger; request handlers read it lazily, so routes mounted
before onStart are safe.
Information architecture¶
The interface is split by audience, not by feature area (design.md D13):
- HOME — a control surface. Overview (landing view), a favorites group (shown when any device is favorited), each user-defined room, an Unassigned-devices bucket, and an all-devices list.
- ENGINE — an operator surface. Automations, State, Logs, HomeKit, Energy, Weather, Walls.
This one IA is rendered by two navigation components depending on viewport:
Desktop (md and up) |
Mobile | |
|---|---|---|
| Layout | Sidebar, collapsible HOME/ENGINE groups | Safe-area app bar plus a control-first bottom bar with an overflow affordance |
| Content | Both groups, favorites and rooms listed dynamically under HOME | Home / Rooms / Devices, plus the remaining control entries in the overflow menu |
| ENGINE reachable? | Yes, in the sidebar | Yes, by direct URL — deliberately not promoted |
The mobile bar's omission of ENGINE is a deliberate statement that a phone is a control surface, not a debugger. Every ENGINE view remains reachable by navigating directly to its URL on any viewport; nothing is unreachable, it is simply not promoted on the smallest screens.
Navigation indicates the active view, and a detail route keeps its parent
collection entry active: opening /devices/:qualifiedId leaves All
devices highlighted, and /automations/:name leaves Automations
highlighted.
Routes¶
The dashboard uses real URL paths (not hash routing), so every view is
deep-linkable and reload-safe. Each top-level view segment is registered
explicitly on the server — there is no catch-all beneath the UI's mount path,
which would otherwise shadow health probes, webhooks, and the API when the
UI is mounted at / (design.md D7):
Path (relative to WEB_UI_PATH) |
View |
|---|---|
/ |
Overview — at-a-glance control landing view |
/rooms |
Rooms index |
/rooms/:id |
Single room — members, room-level all on/off, assign/unassign, rename, delete |
/devices |
All devices |
/devices/unassigned |
Devices belonging to no room |
/devices/:qualifiedId |
Device detail — generic capability-driven controls |
/automations |
Automations list |
/automations/:name |
Automation detail — triggers, history, relationships, source |
/state |
Operator state view |
/logs |
Operator logs view |
/homekit |
Operator HomeKit view |
/energy |
Energy view — instantaneous power, cumulative total, per-device breakdown, trend |
/weather |
Weather view — current conditions and forecast |
/walls |
Wall editor — list, create, rename, delete, and configure wall profiles |
/wall/:name |
Wall display — a profile rendered full-bleed, with no application chrome |
/login, /logout |
Authentication |
A path beneath the UI's mount path that matches none of these — a typo, or a
segment the client has never had — falls through to a 404, not the
dashboard shell. :qualifiedId and :name segments are percent-encoded; see
API Reference for the qualified device
identifier's written form.
Realtime updates¶
The dashboard opens a single Server-Sent Events connection (GET /api/events)
after fetching an initial snapshot over the ordinary REST endpoints, and
applies incremental deltas from the stream rather than re-polling on an
interval (design.md D9). A header indicator shows the connection state:
- Live — the stream is connected; updates apply as they arrive.
- Connecting… — the initial connection or a reconnect is in progress.
- Degraded — polling — the stream could not be established or dropped; the dashboard falls back to re-fetching a full snapshot every 5 seconds until the stream recovers, and switches back to Live automatically.
If the server signals that this connection fell behind its per-connection buffer (a slow client under a burst of events), the dashboard re-reads the full snapshot rather than trying to reconcile a gap — the same recovery path used on an ordinary reconnect (design.md D28).
Device control¶
Every device — Zigbee, Shelly, Nanoleaf, and configured state toggles alike — is rendered from the same source-neutral capability schema (design.md D22), so there is no per-device-family UI code anywhere in the frontend.
- Tiles (overview, room, and device-list views) show one curated primary action and one primary readout, chosen by a fixed ranking — on/off leads, followed by position, brightness, and setpoint for actions; temperature, humidity, occupancy, contact, illuminance, water leak, and battery for readouts (design.md D16). A device matching no rank degrades to a read-only tile that still opens the detail view rather than failing to render. A tile carries an explicit open-detail affordance; an embedded control (the primary action, the favorite toggle, the visibility toggle) never triggers it. State is encoded by more than color alone — an on/off or reachable condition pairs its color with an icon and a label — so the interface stays usable without color discrimination.
- Device detail renders every declared capability generically — switches, sliders (bounded by the declared range and step), selects (for enumerated properties, including a Nanoleaf effect list), and read-only text — with no hardcoded knowledge of any device family (design.md D12, D17).
- Optimistic actuation: a control updates immediately on interaction and
reconciles against the next reported state. If no confirmation arrives
before a deadline, the control reverts and surfaces the error. The
deadline is derived from the device's own observation mode — short and
fixed for a push-backed device, at least its reported refresh interval
plus a margin for a polled one — so the client needs no per-family
knowledge of
SHELLY_POLL_MSorNANOLEAF_POLL_MS(design.md D21). Dragging a slider coalesces to at most one outstanding command per device and property, so an intermediate value never snaps the control backwards after the drag ends (design.md D31). - Reachability and freshness are shown per tile and in device detail: an unreachable device is badged distinctly, and a polled device shows its observation age while a push-backed one does not.
Favorites¶
A device can be marked or unmarked as a favorite from its own tile, without knowing its identifier. Favorites are persisted as an ordinary state key (a JSON array of qualified identifiers), not a device change, so they survive a reload and are reflected in every connected dashboard as the state stream delivers updates (design.md D9). A favorites group appears in navigation, and favorited devices are presented on the Overview, only when at least one favorite is set.
Rooms¶
Rooms are a user-defined grouping spanning every device source — one room can hold a Zigbee bulb, a Shelly plug, and a state toggle. A device belongs to at most one room; assigning it to a new room removes it from the old one. A device that becomes unavailable (unpaired, or its source disabled) is retained in its room and shown distinctly as unavailable, rather than dropped — it reappears automatically with no user action once its source recovers (design.md D14).
A room view offers an all on / all off action that commands every
actuatable member through POST /api/rooms/:id/command. The request is
optimistic per member: the requested change is reflected immediately, and
each member reconciles against its reported state. A member whose command
fails reverts and surfaces its error while the others remain commanded;
members that do not support the command (e.g. sensors) are reported as
skipped, distinctly from failures. The endpoint is best-effort and does not
claim atomicity across members (design.md D5).
For a room whose actuatable members all share one on/off property family
(canonical on, or Zigbee2MQTT's state), the view issues exactly one
request. A mixed room — some members declaring on and others state — is
the deliberate exception: the endpoint requires every requested property to
be writable by each targeted actuator and rejects a property a member does
not declare, so the view sends one batch request per property family
(e.g. one for on, one for state) and merges the per-member outcomes. It
is not one request per device; a room with any number of devices across the
two families costs at most two requests.
Wall displays¶
A wall display is a full-bleed surface for a wall-mounted touchscreen: a named
profile of curated widgets, configured once by the owner and then rendered as a
fixed, touch-operated composition. Unlike every other view, /wall/:name is
rendered outside the application shell, so it has no header, no navigation
rail, and no footer and its widgets use the whole panel (design.md D3).
A profile is addressed by its name and holds a composition of widgets on a
fixed 12-column grid; each widget declares its position and span ({x, y, w, h})
and may declare a zone label, and a profile may carry a per-orientation layout
so it reflows between portrait and landscape without a second profile. Profiles
are stored in the state store's reserved internal namespace
($internal:wall:<name>) through a dedicated /api/walls CRUD surface, so they
survive a restart, are rejected on write through the state API, and are not
listed by the State view (design.md D2). Creating or renaming a profile to a name
already in use is rejected; a saved configuration is validated against the shared
wall-profile schema on the server (and reused by the editor for client-side
validation), so a malformed composition is rejected rather than persisted
(design.md D10).
The wall is drawn in Hearth, the wall's own visual language, whose stylesheet
is loaded only with the wall and never by the dashboard (see
Asset delivery). Its eighteen widget types are: a clock/date
readout (client clock — no server data); current weather and, when the registered
service provides it, a forecast; instantaneous power and the home's recent
rolling-window usage, labelled with the span it covers, and no lifetime total (the
dashboard's own energy view keeps its Cumulative stat); a
temperature reading from a configured sensor, together with that sensor's humidity
where it declares one; a status/presence summary; a room's devices with an
all-on/all-off room command; the user's favorites; one or more
scene buttons that trigger an existing automation through the same manual-trigger
path the admin interface uses; a link that opens another named wall profile; a
device grid over an explicitly selected set of devices; and one typed device
widget per device class — light, cover, fan, climate, lock, number,
select, and the entity fallback (design.md D8). Each type has a fixed
grammar: ambient readouts (clock, weather, energy, temperature, status)
render chrome-less with large type directly on the background, while control
widgets (room, favorites, scene, link, devices, and every device widget) render as
surfaces whose whole card is the touch target — except a scene or a link at its
one-row minimum, and a read-only device widget at one row, which draw no surface and
are still the whole cell's touch target. A typed device widget's minimum is
device-aware: it is authorable at a single row exactly when its bound
device's card draws no control of its own (an entity, or a device whose only
writable state is its on/off toggle), while a card that draws one keeps a two-row
minimum; a widget placed below its effective minimum is replaced by the wall's
explanation rather than a clipped card. A widget whose backing feature is
unconfigured — for example weather with no weather
service registered — reports that inside its own area and never prevents the rest
of the profile from rendering.
A readout widget presents the readings its configured device declares, and presents one the device does not declare as absent rather than as an empty line or a placeholder. The temperature widget identifies its sensor by the device's room name where the store assigns it one, otherwise by the device's shortened name, keeping the full name as the label's tooltip. A temperature widget bound to a sensor that also declares a humidity reading presents both — the humidity beneath the temperature, as the widget's secondary reading, with its own unit — while a sensor that declares only a temperature presents only the temperature. Neither case needs a second device or a per-profile setting.
The weather widget composes for the box the frame hands it (design.md W7). Leading with the ported 36 px circular icon makes its stacked layout — condition and temperature, then the humidity/wind detail row, then the forecast — taller than its 3 × 3 minimum-span cell, and the frame clips rather than pages, so the widget resolves which of its parts to draw from that box: the condition and temperature always draw, the humidity/wind detail row draws while the box holds it, and the forecast draws only when the box holds both. At its default (and minimum) 3 × 3 span it therefore presents the current condition, temperature, humidity, and wind; the forecast appears from the span that holds it. Its declared minimum span is unchanged.
Every widget is composed from the wall's single card vocabulary — the ported card anatomy and its ported token table (see Wall device cards). An ambient readout's leading glyph is the ported tinted circular icon, a label and a caption sit on the ported primary and secondary type, and a control widget's surface, its controls and its control spacing are the ported ones. The widget frame and the zone band draw the ported card surface — the same radius and inset as a device card — and the panel's own chrome (the opt-in title, the zone caption, and a contained "too large for this panel" with its route to the editor) draws the ported treatment rather than a treatment of its own, so a panel reads as one system rather than a device card system sitting on top of a second one. No widget declares an icon shape, a surface radius or padding, a control treatment, or its own supporting type size.
A device widget is typed: its own type determines the card it presents, so the
wall renders one card per device class rather than one generic card for every
device. Each type is bound to a single device and carries the card's layout
(default, vertical, or horizontal) and a collapsible_controls flag that
hides the card's actions row while the device is inactive. Every device widget is
composed from the ported card anatomy — a tinted circular icon affordance, the
device's name over its state or primary reading, and, where the device has controls,
one actions row carrying the active control and the affordances for the others —
and every control actuates through the shared optimistic channel
(reflect → reconcile → revert), so a wall control reflects, reconciles, and
reverts exactly as it does elsewhere. A device widget whose bound device's card draws
no control of its own is authorable at a single row, composing the shared slim
card form — a single inline line (icon, name, state), the whole cell actuating a
toggle where the primary action is one, and no observation indicator; a widget whose
card draws one keeps a two-row minimum. A widget bound to a device whose
capabilities cannot drive its class reports that inside its own area instead of
presenting an inert card, which is also how a device class the device sources cannot
yet express behaves. See Wall device cards below.
Composition and panel fit¶
A wall is composed to be read from across a room. Card heights follow each
widget's declared span through a bounded row unit that reserves the panel padding
and inter-row gutters
(clamp(50px, (availableHeight − chrome) / occupiedRows, 56px)) rather than
stretching to fill the panel, so a small control is never inflated; chrome
reserves the panel padding and the inter-row gaps, and the unit is bounded at
50 px (ROW_UNIT_MIN, derived from the smallest card the ported card system
produces) and 56 px (ROW_UNIT_MAX, the density ceiling just above it). Cards
are separated from each other, and inset from the panel edge, by a single gutter of
clamp(16px, 2vmin, 24px). The row unit is only the vertical axis: a typed device
widget's minimum column span is 3 — its default span 3 × 2 — derived the
same way the row unit is: the fewest columns whose cell is at least the widest
bounded control row a card draws at its minimum span (the cover's Open/Stop/Close
transport), so 3 · 50 + 2 · 16 = 182 clears that row's 174 px — and a card at that
span never clips a control horizontally on either panel. A widget bound to a
control-less device is authorable at 3 × 1 instead; because that cell draws no
frame surface its content holds the wall's 42 px floor within one row unit, so the
derived floor is unchanged. A surfaced widget is padded by the
ported card surface's own inset — 10 px (--hearth-card-padding) — so a
control widget's tile and a device card are the same surface, and a paging widget
reserves that inset above and below plus its page-control row and the gap under it
— 70 px in all — before packing tiles. A widget title header is shown only
when the profile explicitly configures one; otherwise the widget's own content labels
it. The wall's density, row-unit bounds, gutter, and type ramp are recorded as a
design-system page override (design-system/ts-ha/pages/wall.md).
Fitting is a resolved outcome, not a guess. A profile that occupies fewer rows
than fill the panel keeps its natural height and is centered; a profile that
cannot fit the panel at the wall's floors renders a contained "Too large for
this panel" explanation with a route to the editor, rather than degrading to a
scroll. overflowY: hidden is set in every outcome: the wall never scrolls
(design.md D5/D10). A widget whose own content cannot fit the span it was
placed at does not scroll either — it pages its content and shows a page
affordance. The device grid, the favorites grid, and a room's tiles all page
through the same pure pagination rule, so the wall's only overflow responses are
an explanation (the authored composition) or a page (a widget's content). A paged
tile row is bounded against inflation as well
as against clipping: a row is drawn at the height the plan resolved — at least the
tile's own cell and at most 1.25× it — and the widget centres the space its
tiles do not use rather than stretching a tile into it, so a tile renders at a
predictable size on every panel.
Zones¶
A widget may carry an optional zone label. The widgets that share a label are
grouped into one labelled band — the bounding box of its members, drawn as a
tinted --hearth-band field behind the member cells and carrying the zone
caption. Grouping never moves a widget: each member still occupies the grid cells
its configuration declares, and a widget that declares no zone renders outside
every band (design.md D2). The band layer is inert to touch, so a tap between two
members passes through to the grid. A zone is an additive annotation rather than a
second layout model, so a disjoint zone — the same label at two unrelated regions
— yields one band spanning both; the editor's preview draws the draft's bands
before saving, so that is visible.
Span-aware cards and the type ramp¶
A card composes its content for the span it was placed at, in both directions:
it never overflows its cells, and a card at a larger span does not repeat the
composition its smaller span draws. The tier — compact, regular, or roomy —
is derived from the widget type's own registry spans (min, defaults, max, the
same values the editor's size rule enforces), so composition and legal spans
cannot drift (design.md D3).
The wall's type ramp is sized for viewing at distance and was reduced roughly 25–30 % from its former scale (design.md D10):
| Tier | Size | Use |
|---|---|---|
| Hero | clamp(36px, 5vmin, 76px) |
the clock time |
| Metric | clamp(26px, 3.6vmin, 54px) |
weather, energy, status values; a roomy readout |
| Band | clamp(20px, 2.4vmin, 32px) |
a grouped readout (the climate band, or a readout on a small card) |
| Title | clamp(18px, 1.8vmin, 22px) |
widget titles |
| Label | 14 px | a widget's label and every caption — the ported primary size |
| Body (floor) | 12 px | any other wall text — the ported secondary size |
| Chip | retired | the compact status-chip exception is gone with its --hearth-text-chip token: the floor is the ported secondary size, so there is nothing left below it to except |
On the wall's 1920 × 1080 panel vmin is 10.8 px, so the hero renders at 54 px
and the metric at 39 px — both above the dashboard's 34 px ceiling. The
band-readout tier sits between the label tier and the metric tier: a grouped
readout renders on it at a small or default span and grows to the metric tier
only on a genuinely roomy card, so a group of readouts does not compete with a
card's hero. The ramp owns its floor: no wall body text renders below the 12 px
body floor, the ramp declares no tier beneath it, and the label tier is the ported
primary size, so a widget's supporting text and a device card's are the same type.
A device card draws neither ramp tier: it carries the ported card system's own
type — 14 px/500 primary over 12 px/400 secondary, with a 42 px control
height — which is now the same scale the ramp's label and body tiers state. A
readout renders its value and unit as one non-breaking unit, and a name too long
for its card is shortened — the distinguishing trailing words — rather than
truncated to a prefix several cards share, with the full name kept in the card's
title/aria-label (design.md D6).
Wall device cards¶
A wall device card is typed: the widget's own type selects the card, and one
card per device class is offered rather than one card serving any device. The
catalogue is keyed by the device's capability profile, not a Home Assistant
domain (the capability vocabulary is open and each source labels its own kinds):
light, cover, fan, climate, lock, number, select, and entity — the
typed fallback that presents any device's name and state, so a switch, a read-only
sensor, and a device of no class all have an answer. The cards are ported from
Lovelace Mushroom (Apache-2.0) and
re-implemented in React; the attribution is recorded in
THIRD_PARTY_NOTICES.md.
Every card is composed from the ported anatomy: a circular icon affordance (36 px, with a 24 px glyph) drawn on a tinted shape of the device's own colour — which is the on/off control where the device has one — the device's name over its state or primary reading, and one actions row that uses the card's full width. A row of fixed-size controls — an icon control, or an enumerated control drawn as its segmented form of one option per permitted value — distributes across that width, while a row containing a flexible control — a slider, or an enumerated control drawn as its single dropdown — keeps that control growing and centres the fixed controls beside it, so no control is stretched into a different-looking affordance or left bunched at the card's left. Each control is bounded horizontally — an icon control is the ported 42 px control height wide, and an enumerated control draws one touch-sized option per permitted value, each label bounded to that same width with an ellipsis — so an actions row is bounded by its control count rather than by its labels, and a card at its minimum span never clips a control. An enumerated control whose permitted values do not fit the row at that 42 px target — a select card's values, a thermostat's mode, a fan's presets — draws a single dropdown affordance carrying them all instead, so no option shrinks below the target and every value stays reachable. Distribution only consumes width a card already has, so the ported 42 px control height and 12 px control radius are unchanged. The metrics are the ported ones, declared once as Hearth tokens: 42 px control height (the wall's touch target) and slider height, 12 px control radius, control spacing, card radius, and slider track radius, 10 px card padding, 14 px/500 primary over 12 px/400 secondary type, and a 10 px slider bar thumb. The slider keeps a native range input and redraws only its chrome, so the control stays keyboard- and touch-operable at the ported height.
- Light — the icon affordance is the on/off control (state carried by the
glyph's fill as well as its colour); the name sits over the brightness reading or
Off; and the actions row carries the active control as the slider — brightness, colour temperature, or colour, whichever the device declares — with the other declared controls beside it. Operating a control while the light is off turns it on first. - Cover — discrete, touch-friendly transport rather than a bare position slider: the position (when the device has one) is the primary reading, and the actions row carries Open, Stop, and Close, offering Stop only when a writable property can express it.
- Fan — the icon affordance is the on/off control, the name sits over the percentage reading, and the actions row carries the percentage as a slider with the presets beside it.
- Climate — the name sits over the temperature reading, and the actions row carries the setpoint as a slider with the thermostat's mode beside it.
- Lock — the name sits over its locked/unlocked reading, and the actions row carries Lock and Unlock, the one matching the reported state marked active.
- Number and Select — the bounded numeric control (a slider) and the enumerated control as the sole action: one touch-sized option per permitted value where they fit its row at the 42 px target, or a single dropdown affordance carrying every value where they do not. A control the device cannot express is not presented at all.
- Entity — the typed fallback: the icon affordance and the name over the state. Where the device's primary action is a pure toggle the whole card is the actuation target; where the device has no actuatable property the reading becomes the card's dominant content, with no control and no navigation affordance.
Each card resolves its composition from the box it is given, so a taller box
composes a richer form rather than inflating the smaller one, and a card's
layout and collapsible_controls options are honoured without reflowing the card
into a different one. The smallest form is slim: a single inline line — the
leading icon, the device's name, and its state — drawn only in a one-row read-only
cell, with the whole cell as the actuation target where the primary action is a
toggle and no observation indicator. Every read-only-capable card composes it, not
entity alone, so a read-only lock, climate, or fan reads the same as an
entity at one row. On the wall a device card still presents no favorite toggle,
no hide/unhide toggle, and no explicit open-detail affordance, and the observation
is demoted to a glyph-only non-textual indicator (its label and polled age stay in
title/aria-label) — or omitted entirely at one row. A device card inside a widget
frame — including each tile of a device grid, which renders the card of its own
device class — draws one surface, never a surface inside another.
Cross-wall navigation¶
A link widget ({ target, label? }) is a route, not a widget with its own view:
activating it navigates to the target profile's address through the shared router,
so the URL changes, the browser's own back button works, and an always-on panel
restored at that address opens exactly that wall (design.md D11). It renders the
ported labelled control — the same treatment a room's command row and the wall's
own Back and editor affordances draw — at the ported control height and corner
radius, carrying the configured label or the target profile's name. At its one-row
minimum (2 × 1) the frame draws no surface and the route draws flush — no
border, no raised background, no inset — so the whole cell is the link's touch
target, at or above the wall's 42 px floor; at two rows or more the framed labelled
route returns, and the shared route keeps its pill for the framed case and for the
wall's own Back affordance. A wall
opened from a link presents an on-screen Back affordance at the foot of the
panel, so the hierarchy is walkable by touch as well as by the browser; a
wall reached by a deep link offers none, because there is nowhere in-app to return
to. A link whose target names no saved profile is not a separate error path — the
target wall presents its own unknown-profile explanation, which itself offers the
way back. Walls therefore form a walkable hierarchy of separate profiles rather
than a second composition model.
Device grid¶
A devices widget ({ qualifiedIds, title? }) renders the device set the author
selected explicitly (never an enumeration of the whole inventory) as a uniform
grid of the typed wall device cards at their minimum span — a 3 × 2 cell, the
tile the packing counts — each tile presenting the card of its own device class
— so ranking, optimistic override, and state encoding stay single-source
(design.md D12). Its minimum span is 6 × 4 — two three-column tile columns by two
tile rows — so it cannot be placed too small to present a page. A span presents
floor(columns / 3) tiles across, so a twelve-column span presents four across,
an eight- or six-column span two, and a four-column span one.
It pages rather than scrolls: one page is drawn at a time with a ‹ n / m ›
page affordance, drawn only while the selection pages, and a selection that fits is
one page. Each tile row is drawn at the height the plan resolved — at least the
tile's own cell, at most 1.25× it — and the widget centres the space its tiles do
not use rather than inflating a tile into it. An empty selection explains itself
inside the widget's own area, and the editor reports how many pages a selection
implies so an author sees a large selection's cost before saving.
Editing¶
The wall itself is read-only: it presents no control for adding, removing, or
editing a widget. Profiles are created and configured at /walls, an ENGINE
navigation entry with its own reloadable URL. The editor configures a widget's
zone, a link's target profile, a device grid's selected devices, and a typed device
widget's bound device (offering only the devices whose capabilities can drive that
widget's class) together with the card's layout and inactive-controls setting, and
presents the draft at the wall's target panel aspect ratio (1920 × 1080) with a fit
verdict and a device grid's page count — refusing to save a draft the resolver
reports as unfittable. The editor's placement clamp and the save validation share the
wall's device-aware minimum, so a control-less device widget can be authored at
one row while a control-bearing one cannot. The editor reads and writes the current
profile version
(2); a profile stored at version 1 is migrated on load — a widget that named one
device becomes that device's typed widget at the same placement, and a widget that
named a set of devices keeps its selection — so no saved wall is lost. When a
profile is saved or deleted, the wall realtime event reaches
every open wall displaying it, which re-renders without a reload; a profile
deleted while open — or one that never existed — is replaced with an explanatory
state and a route back to the editor rather than a blank panel (design.md D9).
Kiosk limitations¶
A wall is expected to run unattended for long periods. It relies on the data layer's existing resilience — reconnect-then-resnapshot with a fallback poll, and bounded buffers — and its clock is derived from the client, so it keeps correct local time even with the engine unreachable (design.md D11). The remaining always-on concerns are deliberately left to the host device: screen wake-lock, a wall-specific fullscreen/PWA manifest, and burn-in mitigation are not provided by the application. The session cookie is also session-scoped, so a panel that is rebooted rather than kept open requires a re-login; a wall-specific long-lived, revocable credential is a possible follow-up, not part of this surface.
Operator views¶
Automations¶
List view with an enable/disable toggle per row. Detail view shows declared
triggers, an enable toggle, a manual "Trigger now" control (hidden while the
automation is disabled — attempting to trigger a disabled automation is
refused with 409, so the control's absence is a guard rather than an
expected error, design.md D27), recent execution history, and the
automation's source code with syntax highlighting (TypeScript/JavaScript via
a lazily-loaded Prism — never fetched on first paint, design.md D15).
Relationships are split into declared (required services, related devices, watched state keys — complete, taken from the automation's own declarations) and observed (state keys written since the engine started — grows with use, and is capped per automation with a "truncated" label once that cap is reached, design.md R12, R15). A never-run automation shows empty observed writes without implying it writes nothing.
State¶
Lists, edits, and deletes ordinary state keys, updating live from the event stream. Reserved internal keys (rooms, automation enabled flags — see State Management) are never listed and there is no free-text key field that could target one directly (design.md D20).
Logs¶
Filters by level, automation name, and free text, appending new entries as the stream delivers them rather than polling. An SSE delivery failure on the stream's own connection is deliberately logged to stdout only, not through the log category itself — see the note on log-delivery logging below.
HomeKit¶
Bridge status, pairing configuration, and accessory count, reading the same
GET /api/homekit/status endpoint used by the CLI dashboard. Reports the
service as "not configured" rather than erroring or rendering an empty
bridge when HomekitService is not registered.
Energy¶
Instantaneous home power, cumulative consumption (displayed in kWh), a
per-device breakdown, and a rolling recent trend when history is enabled,
backed by GET /api/energy and updated from the event stream. An
unreachable metering device is reported as unavailable, never counted as
zero. The view remains useful with history disabled (current totals and
breakdown, no trend) and on a deployment with no metering devices
(explanatory empty state, no error). The trend is a hand-rendered SVG
sparkline — no chart library is added. See
Configuration for
ENERGY_SAMPLE_MS and ENERGY_HISTORY_MINUTES.
Weather¶
Current conditions and a daily forecast, backed by GET /api/weather. The
location is the configured default (WEATHER_LATITUDE/WEATHER_LONGITUDE)
when the client supplies none. When no weather service is registered the view
reports the feature as unconfigured rather than presenting an error or an
empty forecast. See Configuration.
Authentication¶
When HTTP_TOKEN is set the web UI requires authentication. On first visit the browser redirects to /status/login (adjust for your WEB_UI_PATH):
- Enter the same token as
HTTP_TOKEN - A session cookie (
ts-ha-session,HttpOnly,SameSite=Strict) is set for the duration of the browser session - Navigate to
/status/logoutto clear the session
The dashboard's own fetch calls send Authorization: Bearer <token>; the
session cookie exists so a browser navigation to a plain HTML route (the
shell itself, which cannot attach a header) is also authenticated. The page
also works from a reverse proxy that injects the Authorization header.
When HTTP_TOKEN is empty the dashboard — and every /api/* route,
including GET /api/automations/:name/source — is publicly accessible with
no login. See Configuration
before relying on an empty token in an untrusted network.
Compiled JS/CSS assets and the PWA manifest/icon are served unauthenticated
and immutably cached regardless of HTTP_TOKEN — they carry no instance
data, and the shell that references them must be able to load before a
session exists.
Color scheme¶
The dashboard offers an explicit light / dark / system selection in the app
bar. The choice is persisted in localStorage and applied to the document's
data-theme attribute before the first paint, so a reload never flashes a scheme
other than the one selected; system follows prefers-color-scheme and reacts
to an OS change while it is selected. Dark is the authoritative scheme, and the
light scheme is designed in its own right rather than derived by inverting dark.
Data API¶
The dashboard is backed by the same top-level /api/* routes documented in
full in the API Reference — they are
not nested under WEB_UI_PATH; only the HTML shell, login/logout, and
static assets are. All routes require the bearer token or session cookie
when HTTP_TOKEN is set.
The dashboard fetches an initial snapshot from GET /api/status,
GET /api/automations, GET /api/state, GET /api/device-catalog,
GET /api/rooms, GET /api/logs, and GET /api/homekit/status (tolerating
a 404 there as "not configured"), then opens GET /api/events and applies
incremental deltas. The Energy and Weather views fetch their own endpoints
(GET /api/energy, GET /api/weather) lazily when opened — they are not part
of first paint — and refetch on stream reconnect. The wall editor and wall
display likewise fetch profiles from GET /api/walls / GET /api/walls/:name
only when opened, off first paint. See
Realtime updates above.
Asset delivery¶
The entire frontend (React + Tailwind CSS) is compiled by Bun.build at build
time into content-hashed, pre-compressed (gzip) asset entries embedded in
the package as generated module constants — there is no external static
file directory, so the package still ships as a single importable module
(design.md D8). At runtime, each asset is served from its own
content-addressed route ({WEB_UI_PATH}/assets/<hash>.<ext>) with
Cache-Control: public, max-age=31536000, immutable; the HTML shell itself
is small and served with Cache-Control: no-store, since it references the
current build's hashed asset URLs and must never go stale.
A rebuild changes an asset's content, which changes its hash, which changes
its URL — so a new deployment is picked up on the next page load with no
cache-busting query parameter and no version number to bump. bun run dev
runs a watcher that rebuilds the manifest on every frontend edit; combined
with bun --hot, this is the entire development workflow — there is no
separate dev server and no WEB_UI_DEV flag.
The frontend's display typeface (a self-hosted subset of Plus Jakarta Sans,
weights 500/700) is emitted as a content-hashed .woff2 and served from the
same content-addressed route as the scripts and stylesheets. It is never
fetched from a third-party origin, so the dashboard renders correctly on a
network with no internet access, and its transferred bytes are counted
against the first-paint budget.
First-paint budget: the JS, CSS, and font required for first paint
(excluding anything behind a dynamic import, such as the Prism syntax
highlighter, or the Energy and Weather views) is budgeted at 250 KB
transferred (gzip-compressed), asserted in bun test against the build
manifest (design.md D24, D26). Everything else — non-dashboard views, the
energy/weather views, Prism and its grammars — is lazily loaded behind
React.lazy() or a dynamic import() and never counted against first paint.
Breaking changes¶
The tabbed dashboard was replaced outright, not incrementally ported. Every operator view (state, logs, HomeKit) was rebuilt against the new snapshot-then-stream data layer rather than lifted from the old
useApiPoller-based tabs, because the data layer underneath them changed regardless of whether the view itself was redesigned (design.md D25). There is no compatibility mode or dual-UI selector — the cutover is a single merge once the rebuild reaches parity (design.md R8). If you have deep links or bookmarks to the old tab-based UI, they no longer resolve; see Routes above for the new paths.The device and HomeKit views degrade, rather than error, against a pre-rebuild engine. This is inherited from an earlier phase that removed
GET /api/devicesandGET /api/devices/:namein favour ofGET /api/device-catalog(design.md R13) — see Configuration for that migration note. It does not affect the rebuilt web UI itself, which was already written against the unified endpoints.The wall's device cards and its profile shape changed. The wall now renders one typed card per device class at the ported card system's own metrics — a 42 px control height, 14 px/500 primary over 12 px/400 secondary type, and a 36 px icon with a 24 px glyph — instead of the generic card at the wall's former 56 px touch target and 18 px device-card floor, so an existing wall renders smaller text and smaller drag targets. The wall profile contract went to version 2 (per-class device widget types, each with its own config); a stored version-1 profile is migrated on load rather than rejected, but a version-2 profile cannot be loaded by an older build — see Wall device cards above.
The wall carries one card vocabulary, and its supporting text is the ported card's. The wall's earlier widget vocabulary and its pill affordances are gone: every widget, the widget frame, the zone band and the panel's own explanations are now composed from the ported card anatomy and its token table. Two guarantees move with it — the ramp's body floor becomes the ported secondary size (12 px) and its label tier the ported primary size (14 px), and the compact status-chip exception and its
--hearth-text-chiptoken retire, since nothing is left below the floor to except. The frame's inset follows the ported card padding (16 → 10 px), which re-derives the paging reservation (70 px), the floors and the page counts the editor reports. The readout tiers are untouched, so what is read across a room is unchanged; see Span-aware cards and the type ramp above.
Implementation notes¶
- Served by a Hono sub-app mounted inside the existing
Bun.serve()instance — no extra port or process - No browser test harness exists (design.md D23): decision logic (routing, capability ranking, revert deadlines, command coalescing, reserved-key filtering, log filtering, payload normalisation) lives in pure modules covered by
bun test; component rendering itself is verified by hand - Works in air-gapped environments — the compiled bundle is embedded in the package, not fetched from a CDN