- TypeScript 45%
- JavaScript 23.8%
- HTML 17.7%
- CSS 10.2%
- Shell 2%
- Other 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The flake had a dev shell and nothing else, which meant the only way to have felis-web running anywhere was a checkout somebody had built by hand — the shape that just cost a dev server falling over and taking the phone's URL with it. A module cannot be written on top of that; it needs a store path. The obstacle was never vite, it was the `file:../felis-web-component/web` dependency. That path is a *sibling checkout*, and the files npm resolves through it — the wasm and the tsgo output — are gitignored artifacts, so the dependency is only valid after someone has run the component's build by hand. felis-web-component's flake already publishes exactly that build as `packages.web`, so it becomes a flake input and the build materialises it at the path the lockfile already names, leaving `package.json` and `package-lock.json` byte-identical to the ones the dev shell resolves. Rewriting them to a store path was tried first and is worse twice over: npm keys a `link` dependency by its path relative to the project, so an absolute one is a different kind of entry, and every edit to the lock is a fresh `npmDepsHash`. The system list stops being `eachDefaultSystem` because the component does not carry x86_64-darwin, and a system offered here that the input cannot answer for should be an evaluation error rather than a package that is silently absent. The lock still pins the component at the revision before its bundle learned to ship felis-glyphs.js, without which this build fails at vite's resolver; update it once that fix is published. Assisted-by: Claude Code Opus 5 |
||
| design | ||
| docs | ||
| nix | ||
| public | ||
| scripts | ||
| src | ||
| test | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
felis-web
A mobile web terminal client — a phone-first PWA over
felis-web-component's
<felis-terminal> component and its multiplexed felis-broker.v1
gateway.
felis-web-component is the terminal; felis-web is the app around it — tabs, a customizable shortcut bar, a touch model, settings, and an installable shell.
- Architecture and the reasoning behind it:
docs/design.md - The design system — tokens, components, and the mobile layout rules:
design/, guide indesign/readme.md
Open design/templates/shell.html in a browser to see the whole screen;
the pages are plain HTML over one stylesheet and need no build.
Develop
felis-web consumes felis-web-component as a file: dependency, so felis-web-component has to be
built first — the .js and web/pkg/ that felis-web imports are gitignored
build artifacts of that repo:
cd ../felis-web-component && nix develop -c sh -c '
cargo build --lib --target wasm32-unknown-unknown --release \
&& wasm-bindgen target/wasm32-unknown-unknown/release/felis_web_component.wasm --out-dir web/pkg --target web \
&& tsgo'
Then:
nix develop
npm install
npm run dev
The dev server binds every interface on port 5180, because the only
honest test of this app is a real phone. It answers to this machine's own
hostname, to .local and to .ts.net; reaching it by any other name —
through a reverse proxy, say — needs that name declared, because Vite
rejects Host headers it does not know and this server holds a shell
credential:
FELIS_WEB_PORT=5180 FELIS_WEB_HOSTS=proxy.example.com,other.name npm run dev
HTTPS
A service worker, navigator.clipboard, Screen Wake Lock and
crypto.randomUUID are secure-context APIs — off localhost they are
absent, not merely permission-gated — so the installable PWA does not
exist over plain HTTP however well the page renders. tailscale cert
provisions a publicly trusted certificate for this machine's tailnet
name, which a phone both resolves and already trusts:
sudo tailscale set --operator=$USER # once, so refreshes need no root
npm run cert # mint into $XDG_RUNTIME_DIR/felis-web-tls
npm run dev:tls # https://<host>.<tailnet>.ts.net:5180
TLS is opt-in and never inferred from the certificate being on disk:
plain HTTP is not the lesser mode here — it is what the browser suite
requires, and switching the whole dev loop over on the appearance of a
file would break npm test from a directory nobody was editing. Asking
for TLS without a readable pair is a hard failure rather than a silent
downgrade. npm run rig reads the same FELIS_WEB_TLS switch, because a
page served over https may not open a ws:// socket — the two ends
cannot be allowed to disagree.
Test
npm test # 32 unit checks, then 50 in a browser
npm run test:unit # label and state parsing only, no browser, no daemon
npm run test:android # the same 50 on the phone itself, over adb
The browser suite drives a real app against a real daemon, so it needs
one running behind a gateway. npm run rig boots both — a throwaway
daemon and a gateway on port 8781 — and prints the gateway and token to
enter on a phone's connect screen. It runs in the foreground and tears
them down on Ctrl-C:
npm run rig
With that up, and the dev server:
ORIGIN=http://host:5180 TOKEN=… FELIS_SOCKET=/run/user/1000/felis-web-test/d.sock npm test
All three have defaults. ORIGIN is this machine's own hostname on
FELIS_WEB_PORT — deliberately not localhost, which is a secure
context and would hide the whole class of failure the suite exists to
catch. The daemon socket and the token come from a throwaway rig at
$XDG_RUNTIME_DIR/felis-web-test/ — not from felis's own default socket,
because the suite creates and kills sessions and must never be pointed at
the daemon you are working in. FELIS_WEB_RIG moves it.
GATEWAY overrides the gateway the app would pick for itself
(ws://<the page's own host>:8771), so the suite can be aimed at one
running on a test port while a phone stays pointed at another. It has to
be paired with the FELIS_SOCKET of the daemon that gateway is
attached to — the suite now refuses to continue if the two turn out to be
different daemons, which is the one rig failure that otherwise reads as a
green run.
For test:android, ORIGIN has to be an address the phone can reach.
Nearly nothing is unit-tested, on purpose — the reasoning is in
docs/design.md, along with why a green
suite is not the same as a looked-at screen.
Status
Usable. Connects to a felis-broker.v1 gateway with a token, keeps tabs
across a dead radio, and carries:
- Sessions — a list per daemon the gateway knows, swipe to close or kill, hold to peek at what one is showing without switching to it.
- Touch — scroll, select and mouse-reporting modes, with the mode visible in the chrome and following the running program by default.
- Scrollback — search that seeks the viewport to a hit, and OSC 133 prompt jumps.
- The dial — the one control that floats over the grid. A tap goes
back to the newest output; holding it fans out the prompt jumps,
search and copy-last-output, and releasing on one fires it. It
replaced a stacked pair of jump buttons, which was twice the cover for
one idea — see
docs/design.md. - Keys — a customizable shortcut bar with prefix modifiers, so
holding
Ctrland typing a letter reaches every control key rather than the four that have buttons. Apastebutton, because the phone's soft keyboard has no paste key and the grid is not a text field the OS's own paste menu appears over. - Gestures — hold and drag to move the cursor along the line or back
through history; hold and let go for the actions sheet under the thumb;
flick sideways for the next session; double tap for Tab; pinch for the
text size. All five in scroll mode only, all five written out in
settings, because a gesture nobody was told about does not exist. What
the other phone terminals do, and why these five, is in
docs/design.md. - Links — a tap on a URL, a path or a SHA the program printed, because a phone points well and types badly. OSC 8 links work as themselves; everything else is matched by rules you can edit, and a tap copies, inserts or opens.
- Chrome — six themes, with the whole app palette derived from the terminal's own, and a PWA shell: manifest, service worker, safe areas.
- The face — 0xProto Nerd Font Mono, served by the app. The terminal component ships no font on purpose, so without one a phone renders the grid in its system monospace and every Nerd Font glyph a prompt draws comes out as tofu. Bold and italic are fetched only if a program emits them.
- Text size — the cell, not the page: settings steps it and the daemon is told, so smaller text is more columns rather than the same wrapped lines drawn smaller. The row says how many columns the screen will get, since that is the thing being chosen.
HTTPS is reachable now (see above), which is what the secure-context APIs — service worker, clipboard, wake lock — were waiting on, and the app now runs on a phone as an installed PWA.
A survey of what Termux, Blink, Termius, Secure ShellFish and Prompt 3
put on top of a terminal turned into the gesture layer above, and the
gestures in turn found three bugs that had been reachable by hand all
along — a sheet opened by a gesture firing one of its own rows, a finger
that left the terminal never being released, and select mode handing the
pinch to the browser. A fourth — Chrome holding the first tap after a
flick that switched session — went when the flick got an axis lock, which
it needed anyway: judged only at its release a sideways flick was not
exclusive with the scroll it travelled through, so one thumb got both.
All of it is written up in
docs/design.md.
The touch model has now been felt, which it never had been — every constant behind it came from arithmetic in a desktop browser. A thumb accepted the distance and the friction and rejected the ending: because the viewport moves in whole rows, the decay's last row used to arrive up to a second after the one before it. That is fixed and now has a check of its own; the reasoning is in What a thumb found.
What still has not happened is the suite on the phone — the checks
below have only ever run against emulated touch. The roadmap at the end
of docs/design.md says what is
left.