- JavaScript 90.2%
- CSS 8.5%
- Python 1%
- HTML 0.2%
- Shell 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .claude | ||
| src | ||
| test | ||
| tools | ||
| .gitignore | ||
| API-CONTRACT.md | ||
| apple-touch-icon.png | ||
| CLAUDE.md | ||
| CONFORMANCE-HISTORY.md | ||
| CONFORMANCE.md | ||
| deploy.sh | ||
| favicon.svg | ||
| icon-192.png | ||
| icon-512.png | ||
| icon-source.svg | ||
| index.html | ||
| manifest.json | ||
| package.json | ||
| README.md | ||
| serve.py | ||
| SERVER-CONFORMANCE.md | ||
| SPEC.md | ||
| sw.js | ||
| TASKS.md | ||
CasaFlux frontend
Client for CasaFlux, a targeted application for following and controlling energy-related appliances in the house: power, lights, A/C, gas and water.
What it covers and why is in SPEC.md. This file is only about working with this repository.
Documents
| File | Job |
|---|---|
| SPEC.md | What the client does, and why each choice was made |
| CONFORMANCE.md | Client rules, numbered CF-, derived from SPEC.md |
| API-CONTRACT.md | What goes over the wire — shared with the backend |
| SERVER-CONFORMANCE.md | What the backend must guarantee, numbered CFS- |
| CLAUDE.md | How to work on this repository |
SPEC.md and the conformance documents are implementation-agnostic on purpose: they should hold for a rewrite in another framework, or a native app. Anything that is true only of this web client belongs here.
Stack
Vanilla JavaScript, no build step. Libraries are added only where they earn
their place, mainly for graphs and graphics. JSDoc annotations with
tsc --checkJs --noEmit where type checking is useful.
The one generated thing is the home-screen icons. A host will not accept an
SVG there, so icon-source.svg is rasterized by tools/render-icons.sh
into the three PNGs, which are committed. It is not part of running or
testing the app — run it after editing the icon and commit what it writes.
It uses headless Chrome as the rasterizer rather than adding a dependency,
on the grounds that a build tool installed to produce three files that
change once a year is a poor trade.
Colour themes
Every colour lives in src/ui/theme.css as a custom property named for its
role. No colour literal belongs anywhere else — not in another stylesheet and
not in JavaScript. Adding a theme is a block of variables there plus a name in
THEMES in src/lib/theme.js; no drawing rule changes.
Which theme applies is resolved in src/lib/theme.js and published as
data-theme on the root element. When the preference is "follow the system"
that attribute is deliberately absent, so the prefers-color-scheme media
query decides and the page follows the OS live; an explicit choice sets the
attribute, which the media query is guarded against. First paint is therefore
correct before any script runs.
The theme is chosen on the Settings page, reachable from the section bar. It used to live in the debug panel and moved here when the page was built, which is what CF-DEBUG-012 asks of any control parked there. The histogram clip has since followed it, and the panel now carries no such control.
Settings live in one place rather than per subject area, including a setting that belongs to one area (the clip belongs to the History view). The reason is room: a per-area settings surface needs a way in from each area, and the top of a subject page is the most contested space in the interface.
Languages
The interface speaks English and Dutch. Every word it shows lives in
src/i18n/, one module per language, and nothing outside that directory holds
a user-facing string. Adding a language is a module there plus a name in
LANGUAGES in src/lib/language.js; no other code changes.
A catalogue exports three things: strings for fixed phrases, format for
phrases that are composed, and the month and weekday names. The format half
is why this is not a flat table of keys. Several names are built rather than
looked up, and the rules for building them are grammar:
- Dutch does not pluralise "uur" after a numeral — "24 uur", never "24 uren" — while "dag" and "minuut" do, so no suffix rule covers the ladder.
- "This/Last/Next" inflects for gender: "Deze week" but "Dit jaar".
- English writes "Last week" by dropping the count from "1 week".
A table would force every language into English's shape. Named functions let
each compose its own, and describeRange in src/lib/timeRange.js — which is
where most of this is used — decides only which phrase applies.
Read a string with t('key') and a composed one with f('name')(...), both
from src/i18n/index.js. A key missing from a translation falls back to
English and warns once to the console. test/i18n.test.js fails if the two
catalogues stop matching key for key, which is what catches a forgotten
translation — the fallback means it would otherwise never be noticed.
Numbers do not vary by language: the decimal separator is always a full stop and there is no thousands separator at all. Dates translate their month and weekday names only; day-month order and the 24-hour clock are the same in both. Units (W, kW, Wh, kWh, ct, %) are symbols and are never translated.
The language is chosen on the Settings page. Unlike the theme it is a user
preference — it is meant to follow the person across devices rather than
belong to one — so it is held by src/lib/userPreferences.js rather than by
windowStore.js. That service is one interface over two implementations: the
local one ships today, and the HTTP one replaces it when the backend has
somewhere to put preferences, with no caller changing.
Changing the language reloads the page. It is the only setting that does, and the reason is in SPEC.md §Locale: text is built into the DOM when a page is constructed, so applying it live would mean a re-render path on every part of the interface, carried permanently for a setting chosen about once.
Vocabulary
SPEC.md §Vocabulary fixes four terms and the code follows them:
| Term | Is | In the code |
|---|---|---|
| page | what a bar button brings to front | src/lib/pages.js, PageBar |
| view | one arrangement of a page's content | viewMode.js, ModeToggle |
| section | a titled block within a page | <section> on the Settings page |
| mode | a filter on what a view shows | valueMode.js, ValueModeToggle |
ModeToggle is a view switcher despite its name — it predates the vocabulary
and is worth renaming next time that file is opened.
Layout and navigation
src/ui/appShell.js owns the page bar and the pages, and is the only thing
that knows which page is showing. Pages are built the first time they are shown
and then kept and hidden, so a page returned to still has the view, range and
scroll position it was left in. Every built page keeps receiving payloads while
hidden; only its animations stop.
The page list and the rule for where the bar sits are in src/lib/pages.js,
which has no DOM access and is tested under node --test. Three placements:
bottom edge on a narrow window, left edge on a short one, top edge otherwise,
with width breaking the tie when a window is both. The first two fill their
edge; the top bar takes only the width its buttons need.
The bar publishes its own measured thickness as --page-bar-size on the root
element, and stamps data-bar-placement beside it. Both are read back by
src/lib/barInset.js. That indirection exists because the stylesheet and the
Power page's pinned elements both need the figure and only one of them can be
written in CSS — the lower time axis positions itself from window.innerHeight
in script. Measured rather than stated, because the bar's height is padding plus
an icon plus a word in the platform's own font, and a figure written twice is one
that will disagree.
At the top edge the pinned element is the Power page's head — the range controls, the range description and the upper time axis, fixed as one block — and it clears the bar from CSS. The axis then hangs off the head's own measured bottom rather than off the bar again, so the bar's extent is counted once.
Placing the flow picture by hand
Settings → Debug panel (at the bottom, under Developer) → Drag layout,
then go to the Power page. Drag the subject cards or the hub around the flow
view, then return to Settings — Copy layouts puts every placed position on
the clipboard (console if the clipboard is refused) as a block of fractions per
screen-size bucket, ready to read into SUBJECTS in src/lib/busGeometry.js.
The switch and the picture are on different pages since the panel stopped floating, so the tool stays on while you walk between them. That is the one control this arrangement made more awkward, and it was a deliberate trade for an interface with nothing permanently stuck to a corner of it.
Positions are bucketed by viewport size (src/lib/layoutOverride.js), so
layouts dragged at several sizes can be compared side by side — which is the
point: the computed layout needs one rule that works everywhere, and it is
easier to find that from a handful of hand-made answers than from first
principles. A card dragged past the hub flips the side it is approached from,
so its sockets and its flow follow it.
Switching the tool off restores the computed layout exactly. Nothing it stores reaches the normal client.
Running
python3 serve.py 8420
serve.py sends no-store, so a refresh always picks up what is on disk —
plain http.server caches ES modules hard enough to survive a reload.
Leave it running. With no server the service worker falls back to its cached copy — which is the offline shell working as designed — and serves the application as of its last successful load. That looks exactly like a feature never having been built, and has twice cost real time to diagnose.
Expect one stale reload after an edit. The worker answers from its cached copy and fetches the fresh file behind that answer (CF-OFF-016), so a change appears on the second reload, not the first. This is the trade made for startup speed and is not a fault. If something is still stale after two or three reloads, that is worth investigating; the first question is always whether the server is up, and the check is in TASKS.md T23.
Starting fast
An installed client starts from its own cached copy and only then looks for a newer one, which is what lets it open like an application instead of a page. The reason is round trips rather than bytes: the client is 84 ES modules with no build step, and a module cannot be requested until its importer has arrived and been parsed, so a network-first start cost 85 requests in 8 dependent waves. Bytes are cheap; a chain of round trips on a phone is not.
The cost is that a launch shows the application as it was. The client closes that gap itself — a newer version found in the first few seconds after launch restarts without asking, since nothing is yet in progress to lose (CF-OFF-014), and once per launch only, because a restart that does not resolve the difference would otherwise loop (CF-OFF-015).
Bundling would collapse the 8 waves into 1 and was considered and rejected: it would buy speed at the cost of the no-build-step property in §Stack. Worth reopening only with measurements from a real device.
Bump the version when you ship
CLIENT_VERSION in src/lib/clientVersion.js is what an installed client
compares against the version the backend reports. If it is not bumped, an
installed client has no way to know it is out of date, and the automatic
restart above never fires — a phone will go on running the cached copy until
something else displaces it.
An edit still reaches the device without a bump, but only on the second launch: the first is answered from the cache while the new files are fetched behind it. Bumping is what turns that into a restart the user does not have to know about.
Minor or patch for anything compatible; major only for a change that makes an older client unable to work with the backend, since a major difference forces a restart the user cannot decline (CF-OFF-011).
Testing on a phone
A service worker only registers in a secure context, and localhost is
the sole exemption. So opening the plain-http server from a phone at
192.168.1.x gives an app with no worker at all: the registration in
index.html fails, is swallowed on purpose, and neither the offline start nor
the cache-first start above happens. Nothing on screen says so, which is what
makes it worth stating here.
Testing either of those on a real device therefore needs TLS:
python3 tools/make-cert.py # once
python3 serve.py 8420 --https
This writes two certificates. The root, casaflux-dev.crt, is the one
installed on the phone; the leaf is what the server presents. They cover
localhost and this machine's current LAN address, so re-run it when that
address changes — and give the phone the new root, since the old one cannot
sign the new leaf. .devcert/ is git-ignored: it holds private keys and is
regenerated in seconds.
The server offers the root at /casaflux-dev.crt, over http as well as
https — the phone cannot trust the https connection until it has that file,
so requiring https to fetch it would be a circle with no way in. Only the
public certificate is served this way; the keys beside it are refused.
The service worker excludes that path, because otherwise it answers the
request itself and a typed url — a navigate request — falls back to
index.html, so asking for the certificate opens the app instead. If a
worker is already installed on the phone from an earlier attempt, that fix
cannot deliver itself: it only takes effect after a load, and the load is
what is failing. Use a second port, since a worker's scope includes the port
and a different port is an origin with no worker on it:
python3 serve.py 8421 # second terminal, plain http
then fetch http://<lan-address>:8421/casaflux-dev.crt on the phone.
Type the http:// prefix explicitly. Safari upgrades a bare typed address
to https, and a plain-http port answers nothing over TLS: the phone reports "no
secure connection possible", which reads as the server being down rather than
as the wrong scheme.
Why two and not one. A single self-signed certificate cannot be both the
trusted root and the certificate served on the connection: only a CA may be
installed as a root, and Apple's leaf rules — extendedKeyUsage=serverAuth,
keyEncipherment, CA:FALSE — are exactly what a CA is not. The first
version of this tooling used one certificate for both and produced a symptom
worth recognising, because it mimics the trust step failing: the profile
installs, the switch appears under Certificate Trust Settings and turns on,
and Safari still says "not secure". The trust had taken; the same
certificate then failed leaf validation one step later. The tell is that an
untrusted root gives Safari's full interstitial, while this gives no warning
at all — just a quiet "not secure" in the bar.
The same insecure context is why the home-screen icon is missing when this is
wrong: iOS only reads the manifest and apple-touch-icon for a page in a
valid secure context, so it falls back to a screenshot. A generic icon is a
useful signal that the certificate, not the icon, is the problem.
The root is name-constrained. iOS will only trust a root, and an
unconstrained trusted root can vouch for any host at all — so a forged
certificate for any site would be accepted by the phone, signed by a key
sitting on a laptop. The constraint limits it to localhost and the one LAN
address, so even fully trusted it is worthless for anything else.
On iOS, accepting Safari's warning is not enough. Tapping through loads the
page but leaves the context insecure, so the worker still refuses to register
and everything looks like it worked except the thing being tested. The root has
to be installed as a profile and switched on under Settings → General → About
→ Certificate Trust Settings. tools/make-cert.py prints the full sequence and
the one-liner that checks whether it took. If a root was installed before,
remove the old profile first — a stale root fails the same quiet way.
Tests
npm test
Read the counts carefully. The runner here is Node 18's, which reports one
line per file, not per test: a file of twenty tests that all pass prints
ok 1 and # pass 1. Individual tests are named in the output only when they
fail. So # pass 12 means twelve files, and a file whose tests never ran at
all looks exactly like one whose tests all passed.
The consequence for the "watch it fail" habit in CLAUDE.md §Testing: the
failure is the only output that names the test, which makes watching it fail
the only proof a test runs at all. A test asserted to pass on the strength of
# pass 1 has not been shown to exist.
Prefer injected clocks and schedulers over real timers in tests. A leaked
setTimeout — a retry loop that keeps rescheduling after its test ends — does
not fail the file, it cancels it: # cancelled 1, with no test named and
nothing to point at. test/connection.test.js drives time by hand for this
reason.
Backend
The client runs against a mock data service until the CasaFlux backend is ready. The mock is permanent — it stays after the backend arrives, for testing and for UI work that should not depend on live data. It also carries the scenarios that make the conformance rules checkable.
There are two things to talk to: a websocket carrying current values, and an HTTP endpoint serving timeline spans. Both sit behind one transport layer whose first implementation is the mock. When the backend exists, that layer gains a real implementation and the mock moves behind a flag rather than being deleted — debugging against known data, with no server running, stays possible.
The layer is where the mock/real choice lives so that nothing above it knows which it is talking to. A component that reached for the mock directly would have to be changed twice: once to use the real service and once more the next time somebody wanted to debug without one.