Terminal UI

Decisions — part 5 of 6

Interactive niceties, but the plain path stays authoritative. Arrow menus, single-key answers and colors appear only on a real TTY; everything the CLI does must still work identically when piped, and no escape code may ever reach a log. Cost: two code paths in the dialogs -- worth it, since cron and scripts drive the same commands humans do.

No curses, no dependencies. io/console plus VT100 sequences cover what this needs. Cost: no complex layouts -- deliberately not the goal.

A screen that repaints, but not the alternate screen. Every keypress in a dialog used to leave another full copy of it behind: walking three rows down the queue, opening the actions and moving a post three slots scrolled the view by dozens of lines and buried the terminal in identical screens. Frames are painted from the top of the viewport instead, so the same sequence scrolls it by none. The alternate screen (\e[?1049h) would have been the obvious way and is deliberately not used: it discards its plane on exit, and this CLI prints things worth keeping -- a draft's address, what a deploy uploaded, what refused. Painting in place keeps both properties, the screen holding still and the scrollback intact. Cost: a full frame per keypress is about a third more bytes over the wire than the old partial repaint; repainting only changed rows is the obvious answer if that ever matters.

Action rows fold; navigation keys are trimmed. The keys under a post run to 137 characters in German, so on 80 columns -- or a phone over SSH -- something has to give. browse's navigation line drops items from the middle, which is right for ways to move around: a reader can guess them. The rows under a post are the actions themselves, so they break between items instead and keep every one. Hiding [x] delete would hide it from exactly the reader least able to guess it is there. Cost: on a narrow terminal the keys take four lines instead of one.

Confirmations are graded by what disappears. Deleting a post and unpublishing one ask for the slug to be typed out. Restoring an earlier version asks for one key, because it loses nothing -- the current text is kept as a version of its own first, which is what the sentence above the prompt says. A prompt explaining that a move is reversible and then demanding a word be written out argues with itself. It stays a confirmation rather than becoming none: Enter in the list is a single keystroke and a restore overwrites the text being worked on.

The menu scrolls; the lists aren't capped to fit a screen. Pickers used to offer only the 10 most recent posts, because back when the menu printed every item it was handed, anything longer than the terminal broke the repaint. Scrolling a window moves that limit into the UI where it belongs -- on a blog with thousands of posts, a cap that small is a functional restriction, not tidiness. Cost: window arithmetic (and digits selecting within the visible window, not the whole list).

Site icons come from one PNG. Pages link assets/images/favicon.png directly, apple-touch-icon points at the same file (iOS scales it), and /favicon.ico is generated at build time by wrapping that PNG in an ICO container. One image to maintain instead of three, and the .ico exists only because a set of clients -- bots, feed readers, link-preview services, older browsers -- request the root path blindly and never read a <link>; without it each of those was a 404. Writing the 22-byte container ourselves keeps the no-gems rule intact, the same trade as the QR encoder below. Cost: an oversized source can't state its true size in ICO's one-byte dimension fields, so browsers report 256 -- invisible at the sizes a favicon renders.

Per-install graphics live outside git. The banner and favicon are per-site artwork, like config/site.yml is per-site identity -- so their live names (assets/images/header.png, favicon.png) are gitignored and the repo tracks only assets/images/defaults/, which the build copies to any live name that's missing. Before this split, a deployment that pulled the repo had its own artwork sitting on tracked paths: every git pull either refused ("would be overwritten") or silently reverted the site's face to the project's. Cost: replacing the shipped default requires deleting the live file, not just committing a new default -- an existing live file always wins.

A QR encoder in the repo rather than a gem or a web service. The draft-preview-on-a-phone workflow is the reason this engine looks the way it does, and a scannable code closes its last manual step. Sending the URL to an external QR service would leak an unlisted preview address; a gem would break "no gems". Cost: ~200 lines of spec implementation, kept honest by verifying every module against a reference encoder.