← The specification

Interface tiers

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

Status: Accepted · Audience: Both · Area: G — Cross-cutting UX


Purpose

Serve three different people through one program: someone scripting, someone at a terminal, and someone who would rather not be at a terminal at all.

The stated goal is that everyone can use this. A terminal UI alone excludes the audience most in need of help — the non-technical operator who can follow instructions but for whom a shell is itself a barrier. A web UI alone loses the operator working over SSH and anyone automating.

Behaviour

Four surfaces, one core

Surface Invocation For
CLI lemonfiber up tv Scripting, automation, remote shells
TUI lemonfiber Interactive use at a terminal
Web lemonfiber ui Anyone who’d rather use a browser
Mobile the companion app Whoever is not at the machine (N1)

All four drive the same logic. A surface is a rendering, never a capability.

The fourth one is not on the machine, and that changes two things

The first three run where the stack runs. The companion does not, and the parity rule has to be read against that rather than quietly broken by it (ADR-0017).

Reachability is a reported condition, not a missing capability. A surface on the host can always reach the core. A phone can reach it only where the operator has chosen to bind the admin surface and configure authentication (C6) — their decision, not the app’s. So the companion offers every action and reports honestly when it cannot carry one out, rather than hiding what it cannot currently do.

First-run setup does not cross the boundary, and that is the documented exception G1-R1 provides for. The thing that lets a phone reach the machine is created during setup, so a phone cannot perform the act that makes a phone able to perform acts. Reconfiguration is offered in full.

Feature parity is a requirement, not an aspiration

Every action is available from every surface, with a stated exception for things intrinsically unsuited to one — a live-refreshing dashboard has no meaningful CLI form, so the CLI offers a point-in-time equivalent.

Parity is what stops the surfaces diverging into a “real” interface and a crippled one — the usual fate of a GUI bolted onto a CLI.

The exceptions are enumerated below rather than left to judgement, because a requirement that permits exceptions and does not list them permits everything.

The exceptions, named

An exception to parity is a claim about the nature of an action on a surface. It is not a claim about the state of the code, and the two sentences are easy to say in the same breath: “a browser cannot do this” and “nobody has built this in the browser yet” look alike and mean opposite things. Only the first is an exception. The second is a gap, and gaps are tracked where work is tracked.

So an exception names three things — the action, the surface it is unsuited to, and the equivalent that surface offers instead. An action missing from a surface and missing from this table is unbuilt, not excepted. That is the whole use of writing the table down: it takes away the option of leaving something out quietly.

Action Unsuited to Why that is intrinsic The equivalent that surface offers
Serving the web UI (lemonfiber ui) Web A surface cannot start itself. The request can only reach a server that is already serving — where it means nothing — or it means starting a second server, which is a different request. And it would make a running server into an endpoint that mints and hands out a per-run token, reachable from any page the operator happens to visit. The address bar. Being able to ask is proof it is already running.
A live-refreshing dashboard CLI A command ends, and a view that refreshes does not. A command that never ended would not be a command; a script waiting on one would wait forever. lemonfiber ps — the same reading, taken once. The TUI and the web hold it open.
An open event stream CLI The same shape one layer down: a stream has no last element, so there is nothing for a command to answer with and exit on. lemonfiber logs --follow streams lines until interrupted; every other reading answers once.
Machine-readable output (--json) TUI, Web --json asks how this run’s answer is written, which only means something where an answer is written to a pipe. A screen is not a pipe. The web API is the machine-readable form — the identical envelope, byte for byte (web-api). A program wanting an answer from a terminal session uses the CLI, which is in the same binary.
Running the pass service (lemonfiber pass) Web, TUI It is not an action an operator takes: the project runs it as one of the stack’s own services, on the one network it shares with the stream guard, holding the pass key read-only (ARCH-R291). A surface starting it would be a second process holding the key outside that network, which is the one thing the service exists to prevent. None is needed. It runs when the stack runs, and lemonfiber ps, the dashboard and the web interface show it as they show every service.

Five rows is the whole list, and that is the point: almost nothing is genuinely unsuited. The pressure this table is under is the temptation to grow it, because every unbuilt thing looks like an exception from the side of not having built it.

Where a surface is poorer, not excepted

Three actions touch a path on the operator’s machine — taking a backup, restoring one, and writing a support bundle. It is tempting to call these web exceptions, and the argument does not survive being made: the server runs on the host, as the operator, so a path typed into a form is a path the server can read or write. Nothing about the operation needs a browser to reach the filesystem.

What a browser genuinely cannot do is browse to one — show what a directory already holds, or warn that a name is about to overwrite something — and for a restore, choosing the wrong archive is not a mistake that can be taken back.

That is a weaker claim than unsuitability and it gets a weaker remedy rather than an exemption. The browser offers the archives lemonfiber already knows about and states what it is about to overwrite before it does. The action is available; only the picker is poorer.

It does not take a typed path for anything else, which this page asked for once and no longer does. A name travels as a name and is resolved beneath the backups directory, so one carrying a path — or climbing out of that directory — is refused by name rather than followed. A surface that accepted one would be a way to name any file on the host from a browser, gated by nothing but the per-run token, and that is a larger thing to hand out than the convenience is worth. An operator whose archive is genuinely somewhere else has a shell, which is where a path is an ordinary thing to type and the person typing it can see what is around it.

The terminal interface is not exempt

A dashboard that only reads is a surface missing every action, not a surface whose nature is to watch. Nothing about a terminal stops a keypress from starting a form, and the operator the TUI exists for — the one on the far end of an SSH session, who cannot open a browser — is the one least able to reach another surface to act on what this one has just told them. A screen that says sonarr: unhealthy and offers nothing to do about it is the divergence parity exists to prevent, arriving one surface earlier than expected.

The counter-argument is that the shell is right there: an operator reading that line can simply type the restart. It is true, and it proves too much — by the same reasoning the web UI needs no actions either, since the operator could open a terminal. Every surface is somebody’s only surface, which is why parity is stated per surface rather than per person.

The default surface fits the situation

Bare lemonfiber opens the TUI when attached to a terminal, and prints help when piped. It never blocks waiting for input that cannot arrive.

The web UI is where the wizard shines

Setup is where the web UI earns its place. Form validation, inline explanation, links to provider documentation, and a QR code for household invitations are all substantially better in HTML than in a terminal — and setup is precisely where the least technical operator is.

Web UI is served on demand, not always

It runs when asked, bound to loopback by default, under C6’s policy. lemonfiber is not a daemon that happens to have a CLI.

CLI output is designed for both readers

Human-readable by default; machine-readable on request, as a stable versioned interface (F1). Exit codes are meaningful and documented.

The TUI degrades rather than demands

It works in a plain terminal without a true-colour palette, without a Nerd Font, and at modest dimensions. Requiring a specific terminal setup would exclude exactly the users this exists for.

States

State Meaning
cli Non-interactive invocation
tui Interactive terminal session
web-loopback Web UI on 127.0.0.1
web-lan Web UI on the LAN with authentication
piped stdout not a terminal; machine-oriented behaviour

Edge cases

Situation Behaviour
Bare invocation with stdout piped Print help; never open the TUI or block on input.
Terminal doesn’t support required features Degrade — ASCII borders, no colour — rather than refuse.
NO_COLOR set Honour it (G3).
Web UI requested, port in use Report the conflict and offer another port.
Web UI open while a CLI command runs Both act on the same state; reflect changes live. Serialise lifecycle operations.
Browser cannot be opened automatically Print the URL. Never fail because a browser couldn’t be launched.
An action has no sensible form on a surface State the equivalent explicitly rather than silently omitting it, in the exceptions table. Absent from that table means unbuilt.
Very small terminal Reduce by priority; state that the view is abbreviated.
Screen reader in use Prefer the web UI, which has real accessibility semantics; the TUI cannot match it (G3).
Session ends mid-operation Server-side work continues; on return the outcome is visible.
Non-interactive invocation missing required input Fail naming the flags needed. Never prompt.

Acceptance criteria

ID Requirement
G1-R1 Every action MUST be available from every surface, except where intrinsically unsuited, and such exceptions MUST be documented.
G1-R2 All surfaces MUST drive the same underlying logic; no surface MAY implement behaviour independently.
G1-R3 Bare invocation MUST open the TUI when attached to a terminal, and MUST print help when not.
G1-R4 lemonfiber MUST NOT block on input when stdin is not interactive.
G1-R5 The web UI MUST be started explicitly and MUST NOT run persistently by default.
G1-R6 The web UI MUST bind to loopback by default under the security policy.
G1-R7 CLI output MUST offer a machine-readable form as a stable, versioned interface.
G1-R8 Exit codes MUST be meaningful and documented.
G1-R9 The TUI MUST function without true-colour support, without special fonts, and at modest terminal sizes.
G1-R10 NO_COLOR MUST be honoured.
G1-R11 Failure to launch a browser MUST NOT fail the command; the URL MUST be printed.
G1-R12 Concurrent surfaces MUST reflect the same state, and lifecycle operations MUST be serialised across them.
G1-R13 Non-interactive invocation lacking required input MUST fail naming the required flags.
G1-R14 Setup MUST be completable from all three surfaces that run on the host machine. It is not required of the companion surface, for the reason recorded above and in N1-R4.
G1-R15 Every surface that shows pairing material, an invitation’s sign-in address, join link or decline address, the front door address, a device hand-off’s address or one of its clients’ codes, or a minted key’s secret MUST offer each as a QR code beside its text, drawn on that surface from the text exactly as the stack gave it.