Panels for a ten-inch screen, and a motor button that says where it is

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>
This commit is contained in:
2026-08-22 18:50:03 +02:00
co-authored by Claude Opus 5
parent 44f2d3614c
commit 12057d83aa
3 changed files with 144 additions and 4 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* The house panels, at the size of the screen they hang on.
*
* `/view/{name}` is the wall-panel route: no sidebar, no editor. Shooting it
* at exactly the panel's pixels is the only way to see whether a tile fell off
* the bottom, because a panel does not scroll.
*
* Env: APP_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD,
* PANELS (comma-separated), PANEL_SIZE ("1280x800"), SCREENSHOT_DIR
*/
import { mkdir } from "node:fs/promises"
import { chromium } from "@playwright/test"
const APP_URL = process.env.APP_URL || "http://app.localhost"
const EMAIL = process.env.FIRST_SUPERUSER
const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD
const OUT = process.env.SCREENSHOT_DIR || "screenshots/panels"
const NAMES = (process.env.PANELS || "home,comfort,energy").split(",")
const [width, height] = (process.env.PANEL_SIZE || "1280x800")
.split("x")
.map(Number)
if (!EMAIL || !PASSWORD) {
console.error("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.")
process.exit(1)
}
/** What the bundle asks for, and where it is really answered. */
const API_PUBLIC = process.env.API_PUBLIC || "https://api.localhost"
const API_ORIGIN = process.env.API_ORIGIN || ""
/** Where the bundle is really served from, for fetches outside the browser. */
const APP_ORIGIN = process.env.APP_ORIGIN || ""
const RESOLVER = process.env.HOST_RESOLVER_RULES
const browser = await chromium.launch(
RESOLVER ? { args: [`--host-resolver-rules=${RESOLVER}`] } : {},
)
for (const theme of ["light", "dark"]) {
const dir = `${OUT}/${theme}`
await mkdir(dir, { recursive: true })
const context = await browser.newContext({
viewport: { width, height },
colorScheme: theme,
// A panel is a touch screen, and several controls draw a taller target
// for one. Shooting it as a mouse would misreport the layout.
hasTouch: true,
})
await context.addInitScript((t) => {
localStorage.setItem("fluksio-ui-theme", t)
}, theme)
// The bundle addresses the API at whatever origin it was built for, and
// this stack has no TLS in front of it — so the calls are re-issued at the
// address the container actually answers on. API_ORIGIN is that address;
// without it nothing is intercepted and the page is left alone.
if (API_ORIGIN) {
// The origin is baked into the bundle at build time, and this stack has no
// TLS in front of it. Rewriting it in the JavaScript as it is served fixes
// the websocket too — routing only the HTTP calls would leave every widget
// showing an em dash, since live values arrive over the socket.
const wsPublic = API_PUBLIC.replace(/^http/, "ws")
const wsOrigin = API_ORIGIN.replace(/^http/, "ws")
await context.route("**/*.js", async (route) => {
// route.fetch runs in Playwright's own process, which does not have the
// browser's resolver rules — so it is told the address directly.
const response = await route.fetch(
APP_ORIGIN
? { url: route.request().url().replace(APP_URL, APP_ORIGIN) }
: {},
)
const body = (await response.text())
.split(API_PUBLIC)
.join(API_ORIGIN)
.split(wsPublic)
.join(wsOrigin)
await route.fulfill({ response, body })
})
}
const page = await context.newPage()
page.on("console", (m) => {
if (m.type() === "error") console.log(` console: ${m.text()}`)
})
page.on("requestfailed", (r) =>
console.log(` failed: ${r.url()} ${r.failure()?.errorText}`),
)
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
await page.getByTestId("email-input").fill(EMAIL)
await page.getByTestId("password-input").fill(PASSWORD)
await page.getByRole("button", { name: /log in/i }).click()
await page.waitForURL(`${APP_URL}/`, { timeout: 20000 })
for (const name of NAMES) {
await page.goto(`${APP_URL}/view/${name}`, { waitUntil: "networkidle" })
// Widgets fetch their own values; the chart draws after its answer lands.
await page.waitForTimeout(3000)
await page.screenshot({ path: `${dir}/${name}.png` })
const overflow = await page.evaluate(() => ({
scrollH: document.documentElement.scrollHeight,
clientH: document.documentElement.clientHeight,
scrollW: document.documentElement.scrollWidth,
clientW: document.documentElement.clientWidth,
}))
const spills =
overflow.scrollH > overflow.clientH + 1 ||
overflow.scrollW > overflow.clientW + 1
console.log(
` ${theme}/${name}: ${spills ? "SPILLS " : "fits "} ` +
`${overflow.scrollW}x${overflow.scrollH} in ${overflow.clientW}x${overflow.clientH}`,
)
}
await context.close()
}
await browser.close()