Reopened with the actual trigger: the base compose builds the frontend
against https://api.${DOMAIN} and only compose.local.yml overrides it,
so any build without that layer breaks login locally. I closed this
earlier on the mistaken belief a rebuild had settled it.
Eleven "to be sorted" entries are done. Three that survive are amended
rather than removed: the house panel's chart budget (the range picker
gave its row back), layoutGraph (height is derived now, width is still
guessed), and the dark:-variant scope, which turned out to share a root
with the React Flow colorMode bug. Adds the portal's missing
not-reporting state, and drops the frontend-origin entry the rebuild
settled.
Both screens the house is looked at on are 1280x800, so that is what the three
dashboards are laid out for: twelve columns of 96px, twelve rows of 51px, and
nothing past the bottom, because a panel does not scroll.
The motors are one control each instead of three buttons. A button could only
publish; a segmented control reads back as well — so the motor writes what it
is doing to the same message the control sets, and the segment that is held is
the direction it actually went. Up, Stop, Down for the shutters; Close/Open for
the window and In/Out for the awning, which is what those two are for.
A run stopped part way now leaves the position unknown rather than claiming the
target it never reached, so the next command in either direction moves it.
The preflight gained the two checks this needed. One runs each sample shape
past the port that would receive it. The other is arithmetic: every tile inside
the panel and none on top of another — both silent failures on a screen with no
scrollbar, and both caught before anything is written.
Sizes were settled by looking. A slider needs three rows or its tick labels
fall off; a status icon needs three or it loses the word under the glyph; a
gauge in two rows has no arc worth reading, so the battery is a bar on Home and
a gauge on Energy where there is height for one. A chart spends eighty pixels
on its chrome whatever it is given, so two of them read on this panel and three
did not — the temperature history is the one that went, and `history` still
answers for it.
`capture-panels.mjs` is how that was checked: the three panels at the screen's
own pixels, in both themes, reporting whether anything spilled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the installation found that the checks did not.
A script with three `from()` statements in it produces three results all
called `_result`, and InfluxDB refuses that outright — so the measurements go
into one filter and the rows come back tagged with which one they are.
And the answer a database node hands back holds a *list* of rows, which a
record may not: a record is flat scalars. It was declared one, so every chart
failed on the type check the moment a real answer arrived.
The second one is now caught before anything is pushed: the preflight runs
each sample shape past the port that would receive it, which is what turns
"expected record, got dict" from a runtime surprise into a line of output.
Also records the two engine faults this seeding session surfaced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twenty-three items are done: the dashboard draft/publish split and panel
freshness, the canvas clipping warning, the inject node's per-port payloads,
the sticky failure and the brain click-through, and the lint and DOMAIN
tooling fixes.
The ChartWidget entry is rewritten rather than closed. Its ring buffer would
only remove the cheap half of the cost, and it is the shape most likely to
tempt someone into re-adding the dependency array that made a full window of
chart data go stale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
Twenty-one items are done: the canvas viewport and logs panel, the widget
overflow and stacking work, the backup and queue-namespace changes, and the
observability fold.
What replaced them is mostly the residue of doing them — a legend styled in the
wrong CSS chunk, two overflow floors that nothing would notice regressing, and
artifact blobs that outlive the rows pointing at them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
Three things the security pass on the portal pairing turned up. The first two
were already true of a screen on the local network; what changed is that a
panel credential is now presentable from the internet, which is what makes
them worth closing rather than recording.
The artifact endpoint authenticates for itself, because a worker's credential
has to open it and that token is no use anywhere else. It resolved the caller
without handing over the request, so the one credential that is scoped by
route was judged by no route at all — a panel could read and write the store
as whoever approved it. It passes the request it already holds now.
The websocket has no route to judge either, and there the bound has to be on
what is sent: a panel is given the values its own dashboards draw and nothing
else — no node status, no logs, no shape of the graph. The keys stay in the
message, emptied, because a screen on a wall runs the bundle it was paired
with. `messages_for` reads that set off the published dashboards, and is the
walk the `/messages/` allowlist has wanted for a while.
And locality is no longer a header anyone can type. The marker the connector
stamps is a value minted per process, so reaching this API directly cannot buy
a device the credential meant for one that cannot reach it at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
A screen somewhere this installation is not reachable from asks the portal for
a code instead, and the portal mints its credential — because a token signed
here is one such a device could never present.
Where it was minted changes nothing about what it may do. The panel gate moved
off the branch that decodes a local panel token and onto whatever claims name
a panel, so the portal's and this installation's are bounded by the same check
against the same panel's dashboards. A token of that scope naming no panel is
refused rather than left holding the account it borrows.
The connector marks what arrives on its socket, since that is the only thing
that makes it true, and the approval screen now names what is holding a code —
approving adopts whatever answers, so it is worth a look first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
Four controls and the unit's own answer beside them. Two catches, both on:
the flow is seeded stopped and the node's commands setting is off. The initial
values are read off the unit when the script runs, so starting the flow asks
for what it was already doing rather than commanding it to something else.
Two things stopped the engine commanding this house. ConnectorNode hardwired
its node function to a no-op, so an input message reaching a connector was
discarded and Art-Net's packet builder was unreachable; write() now carries
the input ports, which is additive so the contract version holds. And the MQTT
publisher JSON-encoded every payload, so "ON" went on the wire quoted and the
devices on a shared broker, which speak bare values, ignored it.
seed_house_control.py is the rig: a flow that drives the washing machine plug,
a dimmer and a colour fixture over MQTT, carries the same two as DMX on an
Art-Net node with transmit still off, and a dashboard to drive it by hand.
`tests/utils/api.ts` took the API origin from `VITE_API_URL`, which
`tests/config.ts` loads out of `app/.env`. In a checkout configured for a
deployment that names the deployment — so the browser went to the local stack
while every setup and teardown call, `deleteAll` included, went to the live
one. `privateApi.ts` had the same reading, and it creates users.
Both origins now come from one place: `PLAYWRIGHT_BASE_URL`, with the API
derived from it (`app.<domain>` → `api.<domain>`) or named outright by
`PLAYWRIGHT_API_URL`, which is what CI and the compose service set. Nothing in
the suite reads `VITE_API_URL` any more.
Belt and braces, since a stack served under a real domain answers to the same
names its production instance does: a global setup resolves both origins and
refuses anything that is not loopback or a private range, before a test runs.
`PLAYWRIGHT_ALLOW_PUBLIC=1` says you meant it.
`make test-frontend` is now that safe run — the Playwright image on the proxy
network with both names mapped onto Traefik by address, as the host user so it
does not leave root-owned results behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
A sweep against the code. Removed as resolved: the worker pool's `_running`
is keyed by (run, node) now, `WorkItem.kind` documents only the three kinds
that exist, `routeTree.gen.ts` matches its generator, and the host's Node is
well past the version the Vite 7 note was about. Removed as disproved: the
collector's redelivery branch is reachable — Redis reclaims an entry this
process is still running.
Corrected: the demo places six of the fifteen node types, not thirteen — the
count was the widget types. Narrowed to what is left: the header/config sync
now covers settings but only on an untouched scaffold and only one way; a
declared flow input is what the dashboard-input node asked for; the flow
boundary already refits the canvas where node ports do not.
The Playwright hazard is not resolved — both names still resolve to
production — and the fix as written is incomplete: `VITE_API_URL` has to be
overridden to `http://` or every setup call fails the TLS handshake.
Also: one `asText` instead of two, and the queue docstring no longer refers
to a node item that never existed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
A flow's inputs are the messages it takes from outside — a dashboard control,
a run, the API — and its outputs are what a batch run reports. Both existed in
the document and in the engine, and neither had any UI: the values looked
hard-coded on the canvas and the Run button always used the declared defaults.
The canvas now draws each as a labelled endpoint, the way it already draws a
dashboard tile or another flow, skipping an input something else already
accounts for. The flow panel edits them — mode, name, type, starting value,
and for a live flow the value it currently holds with a way to put a new one
in. Pressing Run on a batch flow asks for its parameters first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
A python node's settings are constants of its own function, so they are passed
the way its ports are: by name. The controller binds them to the compiled
function, the `params` field is gone from the worker and remote protocols, and
a setting sharing a port's name is reported as a node error rather than
shadowing it. The panel's scaffold follows suit and keeps the header in step
with both ports and settings.
The demo's `pace` moves from a flow input to a setting of the training node,
which is what it always was.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
A panel is one screen and the ordered set of whole dashboards it shows, so a
hallway tablet and a workshop tablet carry different sets without either
dashboard knowing about the other. More than one and the device draws a rail
to switch between them — the same rail the editor puts on screen, because the
wall has it and it takes room off the canvas.
A screen has no keyboard, so it pairs rather than logs in: it shows a
six-character code, somebody approves it against a panel from the dashboards
overview, and the credential that mints reaches that panel's published
dashboards and the message endpoints its widgets speak, and nothing else.
Deleting the panel revokes it.
Closes the per-device view and the kiosk credential; supersedes the
multi-page/multi-section UI, since a page is now a dashboard of its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
Two findings from fixing the value overflow and the empty figures: the edge
popover renders ValuePreview twice, and UplotChart only pushes readings when
the point count changes, so a saturated rolling window stops following.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
Give the Home flow-activity name cell a `sm:min-w-32` floor beside its
`max-w-0` cap, so an ordinary name reads in full where there is room
while the phone keeps today's truncation. Name the bar widget's nested
reading by its port rather than its qualified message. Correct
`MarkdownWidget`'s docstring to the subset it actually renders.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
Reaching Home from another page builds its charts in the same commit their
theme effect first fires in, with the readings already cached. uPlot ranges its
scales in a microtask, so `redraw()` re-set the x scale from the chart's own —
still empty — bounds before that ran, and the pending range taken from the data
was lost: axes without ticks, no lines, and no way back but the range control,
which rebuilds the chart. Redrawing without the paths is all a colour swap
needs and leaves the scales alone.
The cards also carried "Nothing has run yet." while the first readings were
still on their way; they now carry the skeleton the rest of Home uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
React Flow's selection changes had nowhere to go: the canvas passed
edges but no onEdgesChange, so no edge ever carried .selected and both
the selected-edge stroke and its rest colour were unreachable. The edges
are derived from the bindings, so the selected ids are held on their own
and marked on after the layout has had the array — a rebuild cannot drop
the selection, and selecting cannot make the graph lay itself out again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKL7sUgNWhukDEz95vSMQv
The brain now opens sorted (the snapshot carries emit counts, and values
always carried timestamps), the hosted demo is the range-aware dashboard
that was missing, and the demo is hosted-only — so a server-side route
seeding one per signed-in user is not wanted rather than not done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA