No description
  • TypeScript 45%
  • JavaScript 23.8%
  • HTML 17.7%
  • CSS 10.2%
  • Shell 2%
  • Other 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
natsukium 6ca8770594
feat(nix): build the app as a package, so a server can be pointed at it
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
2026-08-22 14:15:19 +09:00
design feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
docs feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
nix feat(nix): build the app as a package, so a server can be pointed at it 2026-08-22 14:15:19 +09:00
public chore: rename pepe to felis-web 2026-08-09 10:53:06 +09:00
scripts fix: let the phone reach the rig gateway over IPv6 2026-08-19 10:13:53 +09:00
src feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
test feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
.gitignore feat: connect real sessions — broker core, tab store, and the one-bar shell 2026-08-07 23:17:27 +09:00
flake.lock feat(nix): build the app as a package, so a server can be pointed at it 2026-08-22 14:15:19 +09:00
flake.nix feat(nix): build the app as a package, so a server can be pointed at it 2026-08-22 14:15:19 +09:00
index.html chore: rename pepe to felis-web 2026-08-09 10:53:06 +09:00
package-lock.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
package.json feat: serve over HTTPS, so the secure-context APIs exist at all 2026-08-15 12:29:34 +09:00
README.md feat: give the grid one floating control instead of two 2026-08-22 02:11:53 +09:00
tsconfig.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
tsconfig.node.json chore: stop writing this machine's name into the dev server 2026-08-15 10:55:20 +09:00
vite.config.ts feat: serve over HTTPS, so the secure-context APIs exist at all 2026-08-15 12:29:34 +09:00

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.

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 Ctrl and typing a letter reaches every control key rather than the four that have buttons. A paste button, 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.