Nimbin2git.christianimmanuel.de / Tools / Browser / commits / 5cbe2d3

Browser git · main

git clone https://git.christianimmanuel.de/tools/Browser.gitwget https://git.christianimmanuel.de/tools/Browser/archive/Browser.tar.gz

Rename browser And fix ctr+a not marking And stuff

Christian Immanuel · 2026-09-04 12:50 · 5cbe2d3b38c2a06f59d4f8efd2ef6a4e4ff5dc84

 Makefile       |   25 +-
 README.md      |   58 ++-
 browser-big.c  | 1357 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 browser-mini.c |   22 +
 browser_core.c |  823 ++++++++++++++++++++++++++--------
 browser_core.h |    1 +
 6 files changed, 2084 insertions(+), 202 deletions(-)

diff --git a/Makefile b/Makefile
index becfbf4..3e16300 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ CC      ?= gcc
 CFLAGS  ?= -O2 -Wall -Wextra
 PREFIX  ?= /usr/local
 
-VERSION  := 1.1.2
-SOURCES  := browser_core.c browser_core.h minibrowser.c bigbrowser.c Makefile
+VERSION  := 2.3.0
+SOURCES  := browser_core.c browser_core.h browser-mini.c browser-big.c Makefile
 BUILD_ID := $(shell cat $(SOURCES) 2>/dev/null | md5sum | cut -c1-7)
 
 CORE_PKGS = gtk4 webkitgtk-6.0 libsoup-3.0
@@ -16,24 +16,27 @@ BIG_LIBS    := $(shell pkg-config --libs   $(BIG_PKGS))
 
 VERFLAGS := -DBROWSER_VERSION='"$(VERSION)"' -DBROWSER_BUILD='"$(BUILD_ID)"'
 
-BINS = minibrowser bigbrowser
+BINS = browser-mini browser-big
 
 all: $(BINS)
 
-minibrowser: minibrowser.c browser_core.c browser_core.h
-	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ minibrowser.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
+browser-mini: browser-mini.c browser_core.c browser_core.h
+	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ browser-mini.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
 
-bigbrowser: bigbrowser.c browser_core.c browser_core.h
-	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ bigbrowser.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
+browser-big: browser-big.c browser_core.c browser_core.h
+	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ browser-big.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
 
 version:
 	@echo "$(VERSION) (build $(BUILD_ID))"
 
 install: all
-	install -Dm755 minibrowser $(DESTDIR)$(PREFIX)/bin/minibrowser
-	install -Dm755 bigbrowser  $(DESTDIR)$(PREFIX)/bin/bigbrowser
+	install -Dm755 browser-mini $(DESTDIR)$(PREFIX)/bin/browser-mini
+	install -Dm755 browser-big  $(DESTDIR)$(PREFIX)/bin/browser-big
+
+uninstall:
+	rm -f $(DESTDIR)$(PREFIX)/bin/browser-mini $(DESTDIR)$(PREFIX)/bin/browser-big
 
 clean:
-	rm -f $(BINS)
+	rm -f $(BINS) minibrowser bigbrowser
 
-.PHONY: all install clean version
+.PHONY: all install uninstall clean version
diff --git a/README.md b/README.md
index 14452fb..e5fb5c1 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# minibrowser / bigbrowser
+# browser-mini / browser-big
 
 Two WebKitGTK 6.0 (GTK4) page viewers sharing one core.
 
@@ -11,15 +11,15 @@ GTK 4.14 / WebKitGTK 2.52.3, and run headless under Xvfb.
 | --- | --- |
 | `browser_core.h` | Window state, the `BrowserApp` hook struct, shared helpers |
 | `browser_core.c` | Window, popup, toast, zoom, downloads, profiles, keys, history |
-| `minibrowser.c` | 20 lines. Fills in four fields and calls `browser_main()` |
-| `bigbrowser.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core |
+| `browser-mini.c` | 20 lines. Fills in four fields and calls `browser_main()` |
+| `browser-big.c` | Camera / GStreamer / watchdogs / diagnostics on top of the core |
 
-minibrowser adds nothing to the core. Every hook is optional.
+browser-mini adds nothing to the core. Every hook is optional.
 
 ## Version
 
 ```
-minibrowser --version     # minibrowser 1.1.2 (build 060a193)
+browser-mini --version     # browser-mini 2.3.0 (build bcff1fa)
 make version
 ```
 
@@ -36,18 +36,21 @@ apt install build-essential pkg-config libgtk-4-dev libwebkitgtk-6.0-dev \
 make
 ```
 
-`libgstreamer1.0-dev` is only needed for bigbrowser. `make install` puts both
+`libgstreamer1.0-dev` is only needed for browser-big. `make install` puts both
 in `/usr/local/bin`.
 
 ## Usage
 
 ```
-minibrowser [URL] [options]
-bigbrowser  [URL|PATH|diag] [options]
+browser-mini [URL] [options]
+browser-big  [URL|PATH|diag] [options]
 ```
 
-With no address the window comes up on `about:blank` with the history popup
-open, so there is something to pick from rather than an empty window. A local
+With no address the window comes up on a start page: the program's name set
+large, a rule under it, the version and build id, and the three keys worth
+knowing. It is drawn from the same palette as the panels, so the browser
+looks like one thing from the first frame, and it is served without a base
+URI so it stays out of the history by itself. A local
 file needs three slashes — `file:///tmp/index.html` — though a bare path
 works too (`./index.html`, `/tmp/index.html`), and anything without a
 scheme is tried as https.
@@ -62,7 +65,7 @@ Run either with `-h` for the full option and key list.
 | `Ctrl+J` | Back |
 | `Ctrl+Shift+J` | Forward |
 | `Ctrl+H` | The same popup, opened on the history |
-| `Ctrl+F` | Find in page. `Enter` next, `Shift+Enter` previous, `Esc` out |
+| `Ctrl+F` | Find in page. `Enter`/`Down`/`Ctrl+N` next, `Shift+Enter`/`Up`/`Ctrl+Shift+N` previous, `Esc` out |
 | `Ctrl+S` | Download directory: this page, this site, or everything |
 | `Ctrl+K` | Add a search keyword |
 | `F1`, `Ctrl+/` | The key list, on screen |
@@ -78,7 +81,7 @@ Run either with `-h` for the full option and key list.
 Use `--mod alt|super|meta` to move the modifier off Ctrl. Nothing else is
 intercepted, so copy/paste keeps working inside the page.
 
-bigbrowser adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the
+browser-big adds `Ctrl+Shift+M` (diagnostics page), `Ctrl+Shift+C` (warm the
 camera), `Ctrl+Shift+X` (release it), `Ctrl+Shift+V` (dump video state).
 
 ## History
@@ -99,8 +102,10 @@ Trimmed to the last 2000 lines at startup.
 One popup does both jobs, because they are the same job: an input line with
 the matching history under it.
 
-- `Ctrl+O` types an address. The list stays out of the way until `Tab` or
-  `Down` asks for it — with nothing typed yet, that is the whole history.
+- `Ctrl+O` types an address. The history is listed straight away — eight
+  lines by default, `list_rows` in a config file — and narrows as you type,
+  so the two are one thing: pick a line, or press `Enter` and what you typed
+  is loaded.
 - `Ctrl+H` opens the same popup on the history.
 
 Every panel — address, history, find, download directory, key list — is the
@@ -127,6 +132,15 @@ brightens on hover. When the directory popup asks about a clash the same two
 buttons become `Drop that rule` and `Keep both`, so both answers are on
 screen rather than implied.
 
+While a page loads, its address appears top left and becomes the page title
+as soon as one arrives, with a thin accent line across the very top showing
+progress.
+
+`Ctrl+A` selects all — in a popup's input, and in the page. WebKitGTK maps
+that key to move-to-start-of-line, an Emacs habit that surprises anyone
+typing into a web text field, so it is turned into WebKit's own select-all
+command. `select_all = no` gives the key back to the page.
+
 `Esc` closes whatever is on screen — popup, key list, download list or a
 message — and reaches the page only when nothing of ours is up.
 
@@ -150,7 +164,9 @@ read the same rules, since which directory a site's files go in has nothing
 to do with which cookie jar is in use. Rules written by an older build are
 carried over from the profile on first run.
 
-The key is a whole address or a bare host. The scopes are independent and the
+The key is a whole address or a bare host. The `Ctrl+S` popup lists everything
+stored, so a directory can be picked with the arrows or the mouse instead
+of typed, and the rule under the cursor dropped with `Delete` or `Ctrl+X`. The scopes are independent and the
 narrower one always wins: a rule on `a.de/b` keeps its own directory when
 `a.de` gets one. Setting a rule only ever writes the key for the scope you
 picked. A rule set on a page also covers
@@ -194,6 +210,11 @@ a file is going before it lands.
 
 ## Search keywords
 
+`Ctrl+K` opens a popup listing the keywords it knows. Add one by typing
+`g https://google.com/search?q={}`, pick an existing one with the arrows or
+the mouse to edit it, drop it with `Delete` or `Ctrl+X`.
+
+
 A single word in front of the text in the address popup runs a search:
 
 ```
@@ -229,7 +250,8 @@ Every overlay — the popup, toast, downloads — is drawn from
 swov's palette and geometry, so the two programs read as one set. Defaults
 match swov value for value: `tile` panels at `radius 14` with a `border 3`
 outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret,
-the download bar and, at a third of its weight, for selections, `urgent` for failures.
+the download bar and, at a third of its weight, for selections, and `find_hl`
+(a pale blue) for the match `Ctrl+F` is sitting on, `urgent` for failures.
 
 Nothing is hardcoded. `ui_css_install()` builds the stylesheet from the
 theme, so changing `hl` in a config file moves every accent at once.
@@ -253,7 +275,7 @@ Files are read in this order, later wins, command line on top of both:
 
 ```
 ${XDG_CONFIG_HOME:-~/.config}/swov/config          shared palette
-${XDG_CONFIG_HOME:-~/.config}/minibrowser/config   our own settings
+${XDG_CONFIG_HOME:-~/.config}/browser-mini/config   our own settings
 ```
 
 So the palette lives once in swov's file and drives all three programs.
@@ -269,7 +291,7 @@ what makes one file safe to share.
 Every key is also a command line option:
 
 ```
-minibrowser https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2
+browser-mini https://example.com --hl=ff8800 -s radius=6 ui_scale=1.2
 ```
 
 **Look keys** (shared with swov): `bg tile tile_sel tile_hover card
diff --git a/browser-big.c b/browser-big.c
new file mode 100644
index 0000000..56de3ae
--- /dev/null
+++ b/browser-big.c
@@ -0,0 +1,1357 @@
+/*
+ * browser-big - WebKitGTK 6.0 (GTK4) page viewer for camera and streaming
+ *
+ * The window, URL bar, downloads, profiles, history and keys all come
+ * from browser_core.c. What is left here is the part that makes camera
+ * capture and video playback actually come up on the first try:
+ *
+ *   - a getUserMedia() shim that relaxes over-tight constraints, retries
+ *     with progressively looser ones, and keeps the capture stream alive
+ *     so the second and third call return instantly
+ *   - a startup warm-up of the GStreamer/V4L2 capture stack, so the
+ *     page's own getUserMedia() does not pay for device probing
+ *   - a media watchdog that notices a stalled <video> and recovers it,
+ *     and a load watchdog that reloads a page that never commits
+ *   - a built-in diagnostics page, reachable as "diag"
+ *
+ * Run with -h for the full option and key list.
+ */
+
+#include "browser_core.h"
+
+#include <gst/gst.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/types.h>
+
+/* watchdogs */
+#define RELOAD_MIN_GAP_SECONDS 5  /* never auto-reload faster than this */
+
+/* -------------------------------------------------------------- globals */
+
+static gboolean g_media_debug;
+
+/* camera / media behaviour */
+static gboolean g_cam_fix       = TRUE;    /* install the getUserMedia shim */
+static gboolean g_cam_relax     = TRUE;    /* loosen the page's constraints */
+static gboolean g_cam_keepalive = TRUE;    /* reuse and hold the capture stream */
+static int      g_cam_retries   = 3;
+static int      g_cam_max_w     = 1280;
+static int      g_cam_max_h     = 720;
+static int      g_cam_max_fps   = 30;
+static char    *g_cam_match;               /* prefer this camera label */
+static gboolean g_prewarm       = TRUE;    /* probe capture devices at startup */
+static gboolean g_warm_cam;                /* open the camera before page one */
+static int      g_warm_timeout  = 10;
+
+static gboolean g_media_watchdog = TRUE;
+static int      g_stall_timeout  = 6;      /* seconds of frozen playback */
+static gboolean g_auto_reload    = TRUE;
+static int      g_load_timeout   = 25;     /* seconds to commit a load, 0 = off */
+static int      g_max_reloads    = 3;
+
+static char    *g_shim_js;                 /* built once at startup */
+static char    *g_pending_url;             /* held back while warming up */
+static guint    g_warm_timeout_id;
+static gboolean g_first_load_done;
+
+/* rendering / debugging switches that only touch the environment */
+static gboolean    g_no_gpu, g_no_webrtc, g_no_mediastream;
+static gboolean    g_no_hw_decode, g_no_dmabuf, g_no_compositing;
+static const char *g_gst_debug, *g_gst_dbgfile, *g_webkit_dbg;
+static int         g_gst_level = -1;
+
+/* Per-window watchdog bookkeeping, hung off Win.ext. */
+typedef struct {
+    guint   load_wd_id;      /* "load did not commit" timer      */
+    char   *reload_uri;      /* URI the reload budget belongs to */
+    int     reloads;         /* auto-reloads spent on that URI   */
+    gint64  last_reload_us;
+} BigWin;
+
+#define EXT(w) ((BigWin *) (w)->ext)
+
+static void load_diag_page (WebKitWebView *view);
+
+/* Wall-clock stamp, so the UI process, the web process and GStreamer logs
+ * can be lined up against each other. */
+static void
+tstamp (char *buf, gsize n)
+{
+    GDateTime *dt  = g_date_time_new_now_local ();
+    char      *hms = g_date_time_format (dt, "%H:%M:%S");
+
+    g_snprintf (buf, n, "%s.%03d", hms, g_date_time_get_microsecond (dt) / 1000);
+    g_free (hms);
+    g_date_time_unref (dt);
+}
+
+static void G_GNUC_PRINTF (1, 2)
+mlog (const char *fmt, ...)
+{
+    if (g_quiet)
+        return;
+
+    char ts[32];
+    tstamp (ts, sizeof ts);
+
+    va_list ap;
+    va_start (ap, fmt);
+    char *msg = g_strdup_vprintf (fmt, ap);
+    va_end (ap);
+
+    g_printerr ("[%s] %s\n", ts, msg);
+    g_free (msg);
+}
+
+static gboolean
+is_diag_request (const char *s)
+{
+    return !g_strcmp0 (s, "diag") || !g_strcmp0 (s, "about:diag");
+}
+
+/* "1280x720@30", "640x480", "@15" - every part optional. */
+static gboolean
+parse_cam_max (const char *spec)
+{
+    int w = 0, h = 0, fps = 0;
+
+    if (sscanf (spec, "%dx%d@%d", &w, &h, &fps) >= 2) {
+        g_cam_max_w = w;
+        g_cam_max_h = h;
+        if (fps > 0)
+            g_cam_max_fps = fps;
+        return TRUE;
+    }
+    if (sscanf (spec, "@%d", &fps) == 1 && fps > 0) {
+        g_cam_max_fps = fps;
+        return TRUE;
+    }
+    return FALSE;
+}
+
+/* ================================================================== */
+/*                    injected JavaScript                             */
+/* ================================================================== */
+
+/*
+ * The shim runs at document-start in every frame. It does four things:
+ *
+ *  1. relaxes the constraints a page asks for. Exact 1280x720@30 forces the
+ *     V4L2 source into one specific mode; if the camera only offers it as
+ *     MJPEG the pipeline has to bring up a decoder before the promise can
+ *     resolve, which is a large part of the "my cam is too slow" delay.
+ *     Turning exact into ideal lets the source pick a mode it already has.
+ *
+ *  2. retries with progressively looser constraints instead of handing the
+ *     page a NotReadableError it will usually turn into a permanent failure.
+ *
+ *  3. keeps the acquired tracks and hands the page *clones*. The page can
+ *     stop its clone (leave a call, switch view) without closing the device,
+ *     so the next getUserMedia() resolves in milliseconds instead of seconds.
+ *
+ *  4. watches <video>/<audio> for playback that has stopped advancing and
+ *     tries play() -> seek -> load() -> reload before giving up.
+ *
+ * Configuration arrives as window.__bbCfg, built in main().
+ */
+static const char *SHIM_JS =
+"(function(){"
+"if(window.__bbShim)return; window.__bbShim=true;"
+"var C=window.__bbCfg||{};"
+"function post(o){try{o.t=Date.now();"
+"  var h=window.webkit&&webkit.messageHandlers&&webkit.messageHandlers.bbEvent;"
+"  if(h)h.postMessage(JSON.stringify(o));else if(C.debug)console.log('[bb]',JSON.stringify(o));"
+"}catch(e){}}"
+"window.__bbPost=post;"
+
+/* ---- media watchdog ---- */
+"function watchdog(){"
+"  var seen=new WeakMap();"
+"  function attach(v){"
+"    ['error','stalled','abort','emptied','suspend'].forEach(function(n){"
+"      v.addEventListener(n,function(){"
+"        var code=(v.error&&v.error.code)||0;"
+"        post({ev:'media-event',name:n,code:code,readyState:v.readyState,networkState:v.networkState});"
+"        if(n==='error'&&(code===3||code===4))post({ev:'media-reload',reason:'media-error-'+code});"
+"      });});"
+"    if(C.debug)['waiting','playing','canplay','loadeddata'].forEach(function(n){"
+"      v.addEventListener(n,function(){post({ev:'media-event',name:n,readyState:v.readyState});});});"
+"  }"
+"  function recover(v,s){"
+"    post({ev:'media-stall',tries:s.tries,readyState:v.readyState,networkState:v.networkState,"
+"          w:v.videoWidth||0,src:(v.currentSrc||'').slice(0,140)});"
+"    try{"
+"      if(s.tries===1){if(v.play)v.play().catch(function(){});return;}"
+"      if(s.tries===2){"
+"        if(v.seekable&&v.seekable.length){"
+"          try{v.currentTime=Math.max(0,v.seekable.end(v.seekable.length-1)-0.5);}catch(e){}}"
+"        if(v.play)v.play().catch(function(){});return;}"
+"      if(s.tries===3&&v.currentSrc&&v.currentSrc.indexOf('blob:')!==0){"
+"        v.load();if(v.play)v.play().catch(function(){});return;}"
+"    }catch(e){}"
+"    if(s.tries>=4){post({ev:'media-reload',reason:'stalled'});s.tries=0;}"
+"  }"
+"  function check(){"
+"    var l=document.querySelectorAll('video,audio'),now=Date.now();"
+"    for(var i=0;i<l.length;i++){var v=l[i],s=seen.get(v);"
+"      if(!s){s={ct:-1,since:now,tries:0};seen.set(v,s);attach(v);}"
+"      if(v.paused||v.ended||!v.currentSrc&&!v.srcObject){s.ct=v.currentTime;s.since=now;continue;}"
+"      if(v.currentTime!==s.ct){s.ct=v.currentTime;s.since=now;s.tries=0;continue;}"
+"      if(now-s.since<C.stall*1000)continue;"
+"      s.since=now;s.tries++;recover(v,s);"
+"    }"
+"  }"
+"  setInterval(check,1000);"
+"}"
+"if(C.watchdog)watchdog();"
+
+/* ---- getUserMedia ---- */
+"var md=navigator.mediaDevices;"
+"if(!C.camfix||!md||!md.getUserMedia){post({ev:'shim',camfix:false});return;}"
+"var origGUM=md.getUserMedia.bind(md);"
+"var origEnum=md.enumerateDevices?md.enumerateDevices.bind(md):null;"
+"var cachedV=null,cachedA=null,pinned=null,pinTried=false;"
+"function now(){return (window.performance&&performance.now)?performance.now():Date.now();}"
+"function safe(o){try{return JSON.parse(JSON.stringify(o));}catch(e){return String(o);}}"
+"function num(v){if(v==null)return null;if(typeof v==='number')return v;"
+"  return v.exact!=null?v.exact:v.ideal!=null?v.ideal:v.max!=null?v.max:v.min!=null?v.min:null;}"
+"function relaxVideo(v,level){"
+"  if(v===true||v==null)return true;"
+"  if(typeof v!=='object')return v;"
+"  var o={},k;for(k in v)o[k]=v[k];"
+"  if(level>=1){"
+"    if(o.aspectRatio!=null)delete o.aspectRatio;"
+"    ['width','height','frameRate'].forEach(function(k){"
+"      if(o[k]==null)return;var n=num(o[k]);if(n==null){delete o[k];return;}o[k]={ideal:n};});"
+"    if(o.width&&C.maxW&&o.width.ideal>C.maxW)o.width={ideal:C.maxW};"
+"    if(o.height&&C.maxH&&o.height.ideal>C.maxH)o.height={ideal:C.maxH};"
+"    if(o.frameRate&&C.maxFps&&o.frameRate.ideal>C.maxFps)o.frameRate={ideal:C.maxFps};"
+"    if(o.deviceId&&o.deviceId.exact)o.deviceId={ideal:o.deviceId.exact};"
+"    if(o.facingMode&&o.facingMode.exact)o.facingMode={ideal:o.facingMode.exact};"
+"  }"
+"  if(level>=2){delete o.width;delete o.height;delete o.frameRate;delete o.facingMode;}"
+"  if(level>=3)return true;"
+"  return Object.keys(o).length?o:true;"
+"}"
+"function relax(c,level){"
+"  if(!c)return c;var o={},k;for(k in c)o[k]=c[k];"
+"  if(o.video)o.video=relaxVideo(o.video,level);"
+"  if(level>=2&&o.audio&&typeof o.audio==='object')o.audio=true;"
+"  return o;"
+"}"
+"function withPin(c){"
+"  if(!pinned||!c||!c.video)return c;"
+"  var o={},k;for(k in c)o[k]=c[k];"
+"  var v=(o.video===true||typeof o.video!=='object')?{}:o.video;"
+"  var nv={};for(k in v)nv[k]=v[k];"
+"  if(!nv.deviceId)nv.deviceId={ideal:pinned};"
+"  o.video=nv;return o;"
+"}"
+"function pinDevice(){"
+"  if(pinTried||!C.match||!origEnum)return Promise.resolve(null);"
+"  pinTried=true;"
+"  return origEnum().then(function(l){"
+"    var m=C.match.toLowerCase();"
+"    for(var i=0;i<l.length;i++)"
+"      if(l[i].kind==='videoinput'&&(l[i].label||'').toLowerCase().indexOf(m)>=0){pinned=l[i].deviceId;break;}"
+"    post({ev:'cam-pin',match:C.match,found:!!pinned});return pinned;"
+"  }).catch(function(){return null;});"
+"}"
+"function dumpTrack(t){"
+"  var s={};try{s=t.getSettings?t.getSettings():{};}catch(e){}"
+"  post({ev:'track',kind:t.kind,label:t.label,readyState:t.readyState,settings:s});"
+"}"
+"function wants(c,k){return !!(c&&c[k]);}"
+"function deviceOk(track,c){"
+"  if(!track||!c||typeof c.video!=='object'||!c.video.deviceId)return true;"
+"  var want=c.video.deviceId.exact||c.video.deviceId;"
+"  if(typeof want!=='string')return true;"
+"  try{return track.getSettings().deviceId===want;}catch(e){return true;}"
+"}"
+"function serve(c){"
+"  if(!C.cache)return null;"
+"  var out=new MediaStream(),ok=true;"
+"  if(wants(c,'video')){"
+"    if(cachedV&&cachedV.readyState==='live'&&deviceOk(cachedV,c)){"
+"      var v=cachedV.clone();"
+"      if(typeof c.video==='object')try{v.applyConstraints(relaxVideo(c.video,1)).catch(function(){});}catch(e){}"
+"      out.addTrack(v);"
+"    }else ok=false;"
+"  }"
+"  if(wants(c,'audio')){"
+"    if(cachedA&&cachedA.readyState==='live')out.addTrack(cachedA.clone());else ok=false;"
+"  }"
+"  return (ok&&out.getTracks().length)?out:null;"
+"}"
+"function keep(st){"
+"  if(!C.cache)return st;"
+"  var v=st.getVideoTracks()[0],a=st.getAudioTracks()[0];"
+"  if(v)cachedV=v;if(a)cachedA=a;"
+"  var out=new MediaStream();"
+"  if(v)out.addTrack(v.clone());"
+"  if(a)out.addTrack(a.clone());"
+"  return out.getTracks().length?out:st;"
+"}"
+"function retryable(e){"
+"  var n=e&&e.name;"
+"  return n==='NotReadableError'||n==='AbortError'||n==='OverconstrainedError'||"
+"         n==='TimeoutError'||n==='NotFoundError'||n==='TypeError'||!n;"
+"}"
+"md.getUserMedia=function(c){"
+"  var t0=now();"
+"  post({ev:'gum-call',constraints:safe(c)});"
+"  var hit=serve(c);"
+"  if(hit){post({ev:'gum-cache-hit'});return Promise.resolve(hit);}"
+"  return pinDevice().then(function(){"
+"    var start=C.relax?1:0,levels=[],l;"
+"    for(l=start;l<=3&&levels.length<=C.retries;l++)levels.push(l);"
+"    if(!levels.length)levels=[start];"
+"    var i=0;"
+"    function attempt(){"
+"      var cc=withPin(relax(c,levels[i]));"
+"      post({ev:'gum-try',level:levels[i],constraints:safe(cc)});"
+"      return origGUM(cc).then(function(st){"
+"        post({ev:'gum-ok',ms:Math.round(now()-t0),level:levels[i]});"
+"        st.getTracks().forEach(dumpTrack);"
+"        return keep(st);"
+"      }).catch(function(err){"
+"        post({ev:'gum-error',name:err&&err.name,message:err&&err.message,"
+"              constraint:err&&err.constraint,level:levels[i],ms:Math.round(now()-t0)});"
+"        i++;"
+"        if(i<levels.length&&retryable(err))"
+"          return new Promise(function(r){setTimeout(r,250);}).then(attempt);"
+"        throw err;"
+"      });"
+"    }"
+"    return attempt();"
+"  });"
+"};"
+"if(origEnum)md.enumerateDevices=function(){"
+"  return origEnum().then(function(l){"
+"    if(C.debug)post({ev:'devices',list:l.map(function(d){return {kind:d.kind,label:d.label};})});"
+"    return l;});};"
+"window.__bbWarm=function(){"
+"  return md.getUserMedia({video:true}).then(function(s){"
+"    s.getTracks().forEach(function(t){t.stop();});"   /* stops the clone only */
+"    post({ev:'warm-ok'});return true;"
+"  }).catch(function(e){post({ev:'warm-error',name:e&&e.name,message:e&&e.message});return false;});"
+"};"
+"window.__bbRelease=function(){"
+"  [cachedV,cachedA].forEach(function(t){if(t)try{t.stop();}catch(e){}});"
+"  cachedV=cachedA=null;post({ev:'released'});return true;"
+"};"
+"window.__bbHeld=function(){"
+"  return !!(cachedV&&cachedV.readyState==='live')||!!(cachedA&&cachedA.readyState==='live');"
+"};"
+"post({ev:'shim',camfix:true,url:location.href});"
+"})();";
+
+/* Dumps the state of every media element; used by <mod>+Shift+V. */
+static const char *MEDIA_DUMP_JS =
+"(function(){var o=[],l=document.querySelectorAll('video,audio');"
+"for(var i=0;i<l.length;i++){var v=l[i];o.push({i:i,tag:v.tagName,readyState:v.readyState,"
+"networkState:v.networkState,paused:v.paused,muted:v.muted,"
+"currentTime:+(v.currentTime||0).toFixed(2),"
+"duration:isFinite(v.duration)?+v.duration.toFixed(2):null,"
+"w:v.videoWidth||0,h:v.videoHeight||0,err:(v.error&&v.error.code)||0,"
+"src:(v.currentSrc||(v.srcObject?'[srcObject]':'')).slice(0,140)});}"
+"if(window.__bbPost)window.__bbPost({ev:'media-dump',held:!!(window.__bbHeld&&__bbHeld()),elements:o});"
+"else console.log('[bb] media-dump',JSON.stringify(o));})();";
+
+/* Loaded before the real page when --warm-cam is on. Its only job is to make
+ * the capture stack pay its startup cost here instead of inside the site. */
+static const char *WARM_HTML =
+"<!doctype html><meta charset='utf-8'><title>warming camera</title>"
+"<style>body{font:14px system-ui,sans-serif;background:#111;color:#ccc;margin:24px}</style>"
+"<p>warming up the camera…</p>"
+"<script>"
+"function done(r){try{webkit.messageHandlers.bbEvent.postMessage("
+"  JSON.stringify({ev:'warm-page-done',result:r}));}catch(e){}}"
+"if(navigator.mediaDevices&&navigator.mediaDevices.getUserMedia){"
+"  navigator.mediaDevices.getUserMedia({video:true}).then(function(s){"
+"    s.getTracks().forEach(function(t){t.stop();});done('ok');"
+"  }).catch(function(e){done('error:'+e.name);});"
+"}else done('unsupported');"
+"</script>";
+
+/* Built-in getUserMedia diagnostics, loaded with an https base URI so it
+ * counts as a secure context. */
+static const char *DIAG_HTML =
+"<!doctype html><meta charset='utf-8'>"
+"<title>bigbrowser media diagnostics</title>"
+"<style>"
+"body{font:14px/1.4 system-ui,sans-serif;margin:16px;background:#111;color:#eee}"
+"button{font:inherit;margin:2px;padding:6px 10px}"
+"video{width:640px;max-width:100%;background:#000;display:block;margin:8px 0}"
+"pre{white-space:pre-wrap;background:#000;color:#8f8;padding:8px;max-height:40vh;overflow:auto}"
+"h1{font-size:16px}"
+"</style>"
+"<h1>bigbrowser media diagnostics</h1>"
+"<div>"
+"<button id='enum'>enumerateDevices</button>"
+"<button id='v'>video: true</button>"
+"<button id='vhd'>video 1280x720@30 (ideal)</button>"
+"<button id='vexact'>video 1280x720@30 (exact)</button>"
+"<button id='ar'>video aspectRatio 16:9 (exact)</button>"
+"<button id='a'>audio: true</button>"
+"<button id='warm'>warm</button>"
+"<button id='rel'>release</button>"
+"<button id='stop'>stop</button>"
+"<button id='clr'>clear log</button>"
+"</div>"
+"<video id='vid' autoplay playsinline muted></video>"
+"<pre id='log'></pre>"
+"<script>"
+"var L=document.getElementById('log');"
+"var V=document.getElementById('vid');"
+"var cur=null,fpsTimer=null;"
+"function log(){var a=[].slice.call(arguments).map(function(x){"
+"  return (typeof x==='string')?x:JSON.stringify(x);}).join(' ');"
+"  L.textContent+=(new Date().toISOString().substr(11,12))+' '+a+'\\n';"
+"  L.scrollTop=L.scrollHeight;}"
+"function stop(){if(cur){cur.getTracks().forEach(function(t){t.stop();});cur=null;}"
+"  if(fpsTimer){clearInterval(fpsTimer);fpsTimer=null;}V.srcObject=null;log('stopped');}"
+"function dumpTrack(t){var s={},c={};"
+"  try{s=t.getSettings?t.getSettings():{};}catch(e){}"
+"  try{c=t.getCapabilities?t.getCapabilities():{};}catch(e){}"
+"  log('track',t.kind,'label='+JSON.stringify(t.label),'readyState='+t.readyState);"
+"  log('  settings',s);log('  capabilities',c);"
+"  t.onended=function(){log('track ended',t.kind);};"
+"  t.onmute=function(){log('track mute',t.kind);};"
+"  t.onunmute=function(){log('track unmute',t.kind);};}"
+"function watchFps(){"
+"  if(!('requestVideoFrameCallback' in HTMLVideoElement.prototype)){"
+"    log('note: requestVideoFrameCallback unavailable, cannot measure FPS');return;}"
+"  var n=0,last=performance.now();"
+"  function cb(){n++;V.requestVideoFrameCallback(cb);}V.requestVideoFrameCallback(cb);"
+"  fpsTimer=setInterval(function(){var t=performance.now();"
+"    log('measured FPS ~'+(n*1000/(t-last)).toFixed(1));n=0;last=t;},2000);}"
+"function go(c){stop();var t0=performance.now();log('getUserMedia',c);"
+"  navigator.mediaDevices.getUserMedia(c).then(function(st){"
+"    cur=st;V.srcObject=st;"
+"    log('OK in '+Math.round(performance.now()-t0)+'ms, tracks='+st.getTracks().length);"
+"    st.getTracks().forEach(dumpTrack);if(c.video)watchFps();"
+"  }).catch(function(e){log('ERROR',e.name,e.message||'',e.constraint?('constraint='+e.constraint):'',"
+"    'after '+Math.round(performance.now()-t0)+'ms');});}"
+"document.getElementById('enum').onclick=function(){"
+"  navigator.mediaDevices.enumerateDevices().then(function(ds){"
+"    log('devices:');ds.forEach(function(d){log('  ',d.kind,JSON.stringify(d.label));});"
+"    try{log('supportedConstraints',navigator.mediaDevices.getSupportedConstraints());}catch(e){}});};"
+"document.getElementById('v').onclick=function(){go({video:true});};"
+"document.getElementById('vhd').onclick=function(){go({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30}}});};"
+"document.getElementById('vexact').onclick=function(){go({video:{width:{exact:1280},height:{exact:720},frameRate:{exact:30}}});};"
+"document.getElementById('ar').onclick=function(){go({video:{aspectRatio:{exact:1.7777777778}}});};"
+"document.getElementById('a').onclick=function(){go({audio:true});};"
+"document.getElementById('warm').onclick=function(){"
+"  if(window.__bbWarm)__bbWarm().then(function(r){log('warm',r);});else log('shim disabled');};"
+"document.getElementById('rel').onclick=function(){"
+"  if(window.__bbRelease){__bbRelease();log('released held tracks');}else log('shim disabled');};"
+"document.getElementById('stop').onclick=stop;"
+"document.getElementById('clr').onclick=function(){L.textContent='';};"
+"V.addEventListener('error',function(){log('video element error');});"
+"log('ready. origin='+location.origin+' secureContext='+window.isSecureContext"
+"    +' shim='+(window.__bbShim?'on':'off'));"
+"</script>";
+
+/* ------------------------------------------------- process inventory */
+
+/* WebKitGTK 6.0 exposes no UI-process API for the auxiliary process PIDs, so
+ * read them from /proc: descendant PIDs of ours whose comm starts with
+ * "WebKit" (comm is truncated to 15 chars, e.g. "WebKitGPUProces"). */
+static gboolean
+proc_ppid_comm (pid_t pid, pid_t *ppid, char *comm, gsize commlen)
+{
+    char *p    = g_strdup_printf ("/proc/%d/status", (int) pid);
+    char *data = NULL;
+    gboolean ok = g_file_get_contents (p, &data, NULL, NULL);
+    g_free (p);
+    if (!ok)
+        return FALSE;
+
+    *ppid = 0;
+    comm[0] = '\0';
+    for (char *line = data; line && *line; ) {
+        char *nl = strchr (line, '\n');
+        if (nl) *nl = '\0';
+        if (g_str_has_prefix (line, "Name:"))
+            g_strlcpy (comm, g_strchomp (line + 5 + strspn (line + 5, " \t")), commlen);
+        else if (g_str_has_prefix (line, "PPid:"))
+            *ppid = (pid_t) atoi (line + 5);
+        if (!nl) break;
+        line = nl + 1;
+    }
+    g_free (data);
+    return TRUE;
+}
+
+static gboolean
+is_descendant (GHashTable *parent, pid_t pid, pid_t ancestor)
+{
+    for (int guard = 0; pid > 1 && guard < 128; guard++) {
+        gpointer pp = g_hash_table_lookup (parent, GINT_TO_POINTER ((int) pid));
+        if (!pp)
+            return FALSE;
+        pid_t par = (pid_t) GPOINTER_TO_INT (pp);
+        if (par == ancestor)
+            return TRUE;
+        pid = par;
+    }
+    return FALSE;
+}
+
+static void
+dump_child_processes (const char *why)
+{
+    DIR *d = opendir ("/proc");
+    if (!d) {
+        mlog ("proc: cannot open /proc (%s)", g_strerror (errno));
+        return;
+    }
+
+    GHashTable *parent = g_hash_table_new (g_direct_hash, g_direct_equal);
+    GHashTable *comm   = g_hash_table_new_full (g_direct_hash, g_direct_equal,
+                                                NULL, g_free);
+    struct dirent *e;
+    while ((e = readdir (d))) {
+        char *end;
+        long pid = strtol (e->d_name, &end, 10);
+        if (*end != '\0' || pid <= 0)
+            continue;
+        pid_t ppid;
+        char  cm[64];
+        if (!proc_ppid_comm ((pid_t) pid, &ppid, cm, sizeof cm))
+            continue;
+        g_hash_table_insert (parent, GINT_TO_POINTER ((int) pid),
+                             GINT_TO_POINTER ((int) ppid));
+        g_hash_table_insert (comm, GINT_TO_POINTER ((int) pid), g_strdup (cm));
+    }
+    closedir (d);
+
+    pid_t self = getpid ();
+    GHashTableIter it;
+    gpointer k, v;
+    int found = 0;
+    g_hash_table_iter_init (&it, comm);
+    while (g_hash_table_iter_next (&it, &k, &v)) {
+        pid_t pid = (pid_t) GPOINTER_TO_INT (k);
+        const char *cm = v;
+        if (!g_str_has_prefix (cm, "WebKit"))
+            continue;
+        if (pid == self || !is_descendant (parent, pid, self))
+            continue;
+        pid_t ppid = (pid_t) GPOINTER_TO_INT (
+            g_hash_table_lookup (parent, GINT_TO_POINTER ((int) pid)));
+        mlog ("proc: %-16s pid=%d ppid=%d", cm, (int) pid, (int) ppid);
+        found++;
+    }
+    if (!found)
+        mlog ("proc: no WebKit auxiliary processes found yet (%s)", why);
+
+    g_hash_table_destroy (parent);
+    g_hash_table_destroy (comm);
+}
+
+/* --------------------------------------------------- capture diagnostics */
+
+static void
+dump_env (const char *name)
+{
+    const char *v = g_getenv (name);
+    mlog ("env: %-28s %s", name, v ? v : "(unset)");
+}
+
+static void
+print_versions (void)
+{
+    char *gstv = gst_version_string ();
+    mlog ("bigbrowser starting (pid %d)", (int) getpid ());
+    mlog ("versions: GTK %u.%u.%u | GLib %u.%u.%u | WebKitGTK %u.%u.%u | %s",
+          gtk_get_major_version (), gtk_get_minor_version (), gtk_get_micro_version (),
+          glib_major_version, glib_minor_version, glib_micro_version,
+          webkit_get_major_version (), webkit_get_minor_version (),
+          webkit_get_micro_version (),
+          gstv ? gstv : "GStreamer ?");
+    g_free (gstv);
+}
+
+static void
+dump_gstreamer_env (void)
+{
+    mlog ("---- GStreamer environment ----");
+    dump_env ("GST_PLUGIN_PATH");
+    dump_env ("GST_PLUGIN_SYSTEM_PATH_1_0");
+    dump_env ("GST_PLUGIN_SCANNER");
+    dump_env ("GST_REGISTRY_1_0");
+    dump_env ("GST_DEBUG");
+    dump_env ("GST_DEBUG_FILE");
+    dump_env ("WEBKIT_DEBUG");
+    dump_env ("WEBKIT_GST_ENABLE_HW_DECODERS");
+    dump_env ("WEBKIT_DISABLE_DMABUF_RENDERER");
+    dump_env ("WEBKIT_DISABLE_COMPOSITING_MODE");
+
+    mlog ("---- capture / WebRTC element check ----");
+    static const char *els[] = {
+        "v4l2src", "pipewiresrc", "videoconvert", "videoscale", "videorate",
+        "capsfilter", "jpegdec", "vah264dec",
+        "vp8enc", "vp8dec", "vp9enc", "vp9dec",
+        "openh264enc", "openh264dec", "x264enc", "avdec_h264", "avdec_aac",
+        "webrtcbin", "rtpvp8pay", "opusenc", "opusdec",
+        "matroskademux", "qtdemux", "hlsdemux", "dashdemux", NULL
+    };
+    for (int i = 0; els[i]; i++) {
+        GstElementFactory *f = gst_element_factory_find (els[i]);
+        mlog ("gst-element: %-16s %s", els[i], f ? "present" : "MISSING");
+        if (f) gst_object_unref (f);
+    }
+    mlog ("---------------------------------------");
+}
+
+/*
+ * Startup warm-up, step one: enumerate the capture devices ourselves.
+ *
+ * This builds the GStreamer plugin registry, walks udev and asks every
+ * /dev/video* node for its caps - exactly the work WebKit would otherwise do
+ * inside the page's first getUserMedia() call, where a site's own timeout is
+ * already running. Doing it here moves that cost off the critical path and,
+ * as a bonus, prints which nodes are real cameras and which are the metadata
+ * nodes UVC devices expose next to them.
+ *
+ * Runs in an idle callback so the window is up first.
+ */
+static gboolean
+capture_prewarm (gpointer u)
+{
+    (void) u;
+
+    gint64 t0 = g_get_monotonic_time ();
+    GstDeviceMonitor *mon = gst_device_monitor_new ();
+
+    /* NULL caps: a camera that only offers MJPEG must not be filtered out */
+    gst_device_monitor_add_filter (mon, "Video/Source", NULL);
+    gst_device_monitor_add_filter (mon, "Audio/Source", NULL);
+
+    if (!gst_device_monitor_start (mon)) {
+        mlog ("prewarm: device monitor failed to start");
+        gst_object_unref (mon);
+        return G_SOURCE_REMOVE;
+    }
+
+    GList *devs = gst_device_monitor_get_devices (mon);
+    int n = 0;
+    for (GList *l = devs; l; l = l->next, n++) {
+        GstDevice *dev   = l->data;
+        char      *name  = gst_device_get_display_name (dev);
+        char      *klass = gst_device_get_device_class (dev);
+        GstCaps   *caps  = gst_device_get_caps (dev);
+
+        mlog ("prewarm: %-12s %-32s %u caps", klass ? klass : "?",
+              name ? name : "?", caps ? gst_caps_get_size (caps) : 0);
+
+        if (caps && g_media_debug) {
+            char *s = gst_caps_to_string (caps);
+            mlog ("prewarm:   %s", s);
+            g_free (s);
+        }
+        if (caps) gst_caps_unref (caps);
+        g_free (name);
+        g_free (klass);
+    }
+    g_list_free_full (devs, gst_object_unref);
+
+    gst_device_monitor_stop (mon);
+    gst_object_unref (mon);
+
+    mlog ("prewarm: %d capture device(s) probed in %d ms", n,
+          (int) ((g_get_monotonic_time () - t0) / 1000));
+    return G_SOURCE_REMOVE;
+}
+
+/* ------------------------------------------------- capture state signals */
+
+static const char *
+capture_state_name (WebKitMediaCaptureState s)
+{
+    switch (s) {
+        case WEBKIT_MEDIA_CAPTURE_STATE_NONE:   return "none";
+        case WEBKIT_MEDIA_CAPTURE_STATE_ACTIVE: return "active";
+        case WEBKIT_MEDIA_CAPTURE_STATE_MUTED:  return "muted";
+        default:                                return "?";
+    }
+}
+
+static void
+on_capture_notify (GObject *obj, GParamSpec *ps, gpointer u)
+{
+    (void) u;
+    WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
+    const char *name = g_param_spec_get_name (ps);
+    WebKitMediaCaptureState st = WEBKIT_MEDIA_CAPTURE_STATE_NONE;
+
+    if (!g_strcmp0 (name, "camera-capture-state"))
+        st = webkit_web_view_get_camera_capture_state (view);
+    else if (!g_strcmp0 (name, "microphone-capture-state"))
+        st = webkit_web_view_get_microphone_capture_state (view);
+    else if (!g_strcmp0 (name, "display-capture-state"))
+        st = webkit_web_view_get_display_capture_state (view);
+
+    mlog ("capture: %s -> %s", name, capture_state_name (st));
+    if (g_media_debug)
+        dump_child_processes ("capture change");
+}
+
+/* ------------------------------------------------------------ watchdogs */
+
+/* Spend one auto-reload from this window's budget, if there is one left. */
+static gboolean
+reload_budget_take (Win *w, const char *why)
+{
+    if (!g_auto_reload || !w)
+        return FALSE;
+
+    const char *uri = webkit_web_view_get_uri (w->view);
+    if (g_strcmp0 (uri, EXT (w)->reload_uri) != 0) {
+        g_free (EXT (w)->reload_uri);
+        EXT (w)->reload_uri = g_strdup (uri);
+        EXT (w)->reloads    = 0;
+    }
+
+    gint64 nowus = g_get_monotonic_time ();
+    if (nowus - EXT (w)->last_reload_us < (gint64) RELOAD_MIN_GAP_SECONDS * G_USEC_PER_SEC)
+        return FALSE;
+
+    if (EXT (w)->reloads >= g_max_reloads) {
+        mlog ("watchdog: %s, but the reload budget for this page is spent", why);
+        return FALSE;
+    }
+
+    EXT (w)->reloads++;
+    EXT (w)->last_reload_us = nowus;
+    return TRUE;
+}
+
+static void
+auto_reload (Win *w, const char *why)
+{
+    if (!reload_budget_take (w, why))
+        return;
+
+    mlog ("watchdog: %s -> reload %d/%d", why, EXT (w)->reloads, g_max_reloads);
+    toast_show (w, "reloading (watchdog)", 2);
+    webkit_web_view_reload (w->view);
+}
+
+static gboolean
+load_watchdog_fire (gpointer u)
+{
+    Win *w = u;
+    EXT (w)->load_wd_id = 0;
+
+    if (!webkit_web_view_is_loading (w->view))
+        return G_SOURCE_REMOVE;
+
+    auto_reload (w, "page did not finish loading in time");
+    return G_SOURCE_REMOVE;
+}
+
+static void
+load_watchdog_arm (Win *w)
+{
+    if (EXT (w)->load_wd_id) {
+        g_source_remove (EXT (w)->load_wd_id);
+        EXT (w)->load_wd_id = 0;
+    }
+    if (g_load_timeout > 0 && g_auto_reload)
+        EXT (w)->load_wd_id = g_timeout_add_seconds (g_load_timeout, load_watchdog_fire, w);
+}
+
+static void
+load_watchdog_disarm (Win *w)
+{
+    if (EXT (w)->load_wd_id) {
+        g_source_remove (EXT (w)->load_wd_id);
+        EXT (w)->load_wd_id = 0;
+    }
+}
+
+static void
+on_load_changed (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
+{
+    (void) u;
+    Win *w = win_of (view);
+
+    const char *n = "?";
+    switch (ev) {
+        case WEBKIT_LOAD_STARTED:    n = "started";    break;
+        case WEBKIT_LOAD_REDIRECTED: n = "redirected"; break;
+        case WEBKIT_LOAD_COMMITTED:  n = "committed";  break;
+        case WEBKIT_LOAD_FINISHED:   n = "finished";   break;
+    }
+
+    if (g_media_debug) {
+        const char *uri = webkit_web_view_get_uri (view);
+        mlog ("load: %-10s %s", n, uri ? uri : "");
+    }
+
+    if (!w)
+        return;
+
+    if (ev == WEBKIT_LOAD_STARTED)
+        load_watchdog_arm (w);
+    else if (ev == WEBKIT_LOAD_FINISHED)
+        load_watchdog_disarm (w);
+}
+
+static gboolean
+on_load_failed (WebKitWebView *view, WebKitLoadEvent ev,
+                gchar *uri, GError *error, gpointer u)
+{
+    (void) ev; (void) u;
+
+    /* a user-cancelled load is not a failure worth reloading */
+    if (error && error->domain == WEBKIT_NETWORK_ERROR &&
+        error->code == WEBKIT_NETWORK_ERROR_CANCELLED)
+        return FALSE;
+
+    mlog ("load: FAILED %s: %s", uri ? uri : "",
+          error ? error->message : "(no message)");
+
+    Win *w = win_of (view);
+    if (w) {
+        load_watchdog_disarm (w);
+        auto_reload (w, "load failed");
+    }
+    return FALSE;   /* let WebKit show its default error page */
+}
+
+static void
+on_web_process_terminated (WebKitWebView *view,
+                           WebKitWebProcessTerminationReason reason, gpointer u)
+{
+    (void) u;
+    const char *r = "unknown";
+    switch (reason) {
+        case WEBKIT_WEB_PROCESS_CRASHED:                r = "crashed"; break;
+        case WEBKIT_WEB_PROCESS_EXCEEDED_MEMORY_LIMIT:  r = "exceeded-memory-limit"; break;
+        case WEBKIT_WEB_PROCESS_TERMINATED_BY_API:      r = "terminated-by-api"; break;
+    }
+    mlog ("WEB PROCESS TERMINATED: %s", r);
+
+    Win *w = win_of (view);
+    if (w && reason != WEBKIT_WEB_PROCESS_TERMINATED_BY_API)
+        auto_reload (w, "web process died");
+}
+
+/* ------------------------------------------------- script message routing */
+
+static gboolean
+ev_is (const char *json, const char *ev)
+{
+    char    *needle = g_strdup_printf ("\"ev\":\"%s\"", ev);
+    gboolean hit    = json && strstr (json, needle) != NULL;
+    g_free (needle);
+    return hit;
+}
+
+static void
+warm_finish (const char *why);
+
+static void
+on_script_message (WebKitUserContentManager *ucm, JSCValue *value, gpointer u)
+{
+    (void) u;
+
+    char *s = jsc_value_to_string (value);
+    if (!s)
+        return;
+
+    WebKitWebView *view = g_object_get_data (G_OBJECT (ucm), "view");
+    Win           *w    = view ? win_of (view) : NULL;
+
+    /* Everything is interesting under --media-debug; without it only the
+     * events that say something went wrong or slow. */
+    if (g_media_debug ||
+        ev_is (s, "gum-ok")     || ev_is (s, "gum-error") ||
+        ev_is (s, "gum-cache-hit") ||
+        ev_is (s, "media-stall")|| ev_is (s, "media-reload") ||
+        ev_is (s, "media-dump") || ev_is (s, "warm-ok") ||
+        ev_is (s, "warm-error") || ev_is (s, "cam-pin"))
+        mlog ("js: %s", s);
+
+    if (ev_is (s, "warm-page-done")) {
+        warm_finish ("camera warm-up finished");
+    } else if (ev_is (s, "media-reload")) {
+        if (w)
+            auto_reload (w, "video stalled");
+    } else if (ev_is (s, "media-stall") && w) {
+        toast_show (w, "video stalled - recovering", 2);
+    }
+
+    g_free (s);
+}
+
+/* ------------------------------------------------------------ diag page */
+
+static void
+load_diag_page (WebKitWebView *view)
+{
+    mlog ("loading built-in media diagnostics page");
+    webkit_web_view_load_html (view, DIAG_HTML, "https://bigbrowser.diag/");
+}
+
+/* ------------------------------------------------------------ warm-up */
+
+/* Called when the warm-up page reports back, or when it takes too long. */
+static void
+warm_finish (const char *why)
+{
+    if (!g_pending_url)
+        return;
+
+    if (g_warm_timeout_id) {
+        g_source_remove (g_warm_timeout_id);
+        g_warm_timeout_id = 0;
+    }
+
+    char *url = g_pending_url;
+    g_pending_url = NULL;
+
+    mlog ("warm: %s -> loading %s", why, url);
+
+    if (g_wins->len > 0) {
+        Win *w = g_ptr_array_index (g_wins, 0);
+        if (is_diag_request (url))
+            load_diag_page (w->view);
+        else
+            webkit_web_view_load_uri (w->view, url);
+    }
+    g_free (url);
+}
+
+static gboolean
+warm_timeout (gpointer u)
+{
+    (void) u;
+    g_warm_timeout_id = 0;
+    warm_finish ("warm-up timed out");
+    return G_SOURCE_REMOVE;
+}
+
+/* JSON string literal for the shim config. */
+static char *
+js_quote (const char *s)
+{
+    if (!s)
+        return g_strdup ("null");
+
+    GString *out = g_string_new ("\"");
+    for (const char *p = s; *p; p++) {
+        if (*p == '"' || *p == '\\')
+            g_string_append_c (out, '\\');
+        if ((unsigned char) *p < 0x20)
+            continue;
+        g_string_append_c (out, *p);
+    }
+    g_string_append_c (out, '"');
+    return g_string_free (out, FALSE);
+}
+
+static char *
+build_shim (void)
+{
+    char *match = js_quote (g_cam_match);
+    char *cfg   = g_strdup_printf (
+        "window.__bbCfg={camfix:%s,relax:%s,cache:%s,retries:%d,"
+        "maxW:%d,maxH:%d,maxFps:%d,match:%s,watchdog:%s,stall:%d,debug:%s};",
+        g_cam_fix        ? "true" : "false",
+        g_cam_relax      ? "true" : "false",
+        g_cam_keepalive  ? "true" : "false",
+        g_cam_retries,
+        g_cam_max_w, g_cam_max_h, g_cam_max_fps,
+        match,
+        g_media_watchdog ? "true" : "false",
+        g_stall_timeout,
+        g_media_debug    ? "true" : "false");
+
+    char *js = g_strconcat (cfg, SHIM_JS, NULL);
+    g_free (match);
+    g_free (cfg);
+    return js;
+}
+
+/* ---------------------------------------------------------------- hooks */
+
+static void
+big_usage_options (GString *s)
+{
+    g_string_append_printf (s,
+"camera:\n"
+"  --warm-cam          open and release the camera before the first page\n"
+"                      loads, so the page's getUserMedia() is not the one\n"
+"                      paying for device probing (the LED blinks once)\n"
+"  --warm-timeout SEC  give up warming after SEC seconds (default: %d)\n"
+"  --no-prewarm        skip the GStreamer capture-device probe at startup\n"
+"  --no-cam-fix        do not touch getUserMedia() at all\n"
+"  --no-cam-relax      pass the page's constraints through unchanged\n"
+"  --no-cam-keepalive  do not reuse or hold on to the capture stream\n"
+"  --cam-max WxH@FPS   cap relaxed constraints (default: %dx%d@%d)\n"
+"  --cam-match TEXT    prefer the camera whose label contains TEXT\n"
+"  --cam-retries N     retries with looser constraints (default: %d)\n"
+"\n"
+"video:\n"
+"  --no-media-watchdog do not watch <video> elements for stalls\n"
+"  --stall-timeout SEC frozen playback counts as stalled after SEC (default: %d)\n"
+"  --no-auto-reload    never reload a page on our own\n"
+"  --load-timeout SEC  reload if a page does not commit in SEC (0 = off,\n"
+"                      default: %d)\n"
+"  --max-reloads N     auto-reloads per URL (default: %d)\n"
+"  --no-gpu            hardware acceleration policy NEVER\n"
+"  --no-hw-decode      WEBKIT_GST_ENABLE_HW_DECODERS=0\n"
+"  --no-dmabuf         WEBKIT_DISABLE_DMABUF_RENDERER=1\n"
+"  --no-compositing    WEBKIT_DISABLE_COMPOSITING_MODE=1\n"
+"  --no-webrtc         disable WebRTC only\n"
+"  --no-mediastream    disable MediaStream / getUserMedia only\n"
+"\n"
+"diagnostics:\n"
+"  --media-debug       verbose media, permission and capture logging\n"
+"  --gst-debug SPEC    set GST_DEBUG (e.g. 'v4l2*:6,webrtc*:5')\n"
+"  --gst-debug-level N set GST_DEBUG to a global level\n"
+"  --gst-debug-file P  set GST_DEBUG_FILE\n"
+"  --webkit-debug SPEC set WEBKIT_DEBUG (e.g. 'Media' or 'all')\n"
+"\n",
+        g_warm_timeout, g_cam_max_w, g_cam_max_h, g_cam_max_fps, g_cam_retries,
+        g_stall_timeout, g_load_timeout, g_max_reloads);
+}
+
+static void
+big_usage_keys (GString *s)
+{
+    g_string_append (s,
+"  <mod>+Shift+M       open the built-in media diagnostics page\n"
+"  <mod>+Shift+C       warm the camera now (before you press \"join\")\n"
+"  <mod>+Shift+X       release the held capture stream\n"
+"  <mod>+Shift+V       dump the state of every <video> to stderr\n");
+}
+
+/* Returns TRUE when argv[*i] belonged to us. */
+static gboolean
+big_parse_arg (int argc, char **argv, int *i)
+{
+    const char *a = argv[*i];
+
+#define NEXT(opt) \
+    do { if (*i + 1 >= argc) { \
+             g_printerr ("%s: %s needs an argument\n", argv[0], opt); \
+             exit (1); \
+         } } while (0)
+
+    /* ---- camera ---- */
+    if (!strcmp (a, "--warm-cam"))            { g_warm_cam = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-prewarm"))          { g_prewarm = FALSE; return TRUE; }
+    if (!strcmp (a, "--no-cam-fix"))          { g_cam_fix = FALSE; return TRUE; }
+    if (!strcmp (a, "--no-cam-relax"))        { g_cam_relax = FALSE; return TRUE; }
+    if (!strcmp (a, "--no-cam-keepalive"))    { g_cam_keepalive = FALSE; return TRUE; }
+
+    if (!strcmp (a, "--warm-timeout")) {
+        NEXT ("--warm-timeout");
+        g_warm_timeout = MAX (1, atoi (argv[++(*i)]));
+        return TRUE;
+    }
+    if (!strcmp (a, "--cam-max")) {
+        NEXT ("--cam-max");
+        if (!parse_cam_max (argv[++(*i)])) {
+            g_printerr ("%s: bad --cam-max %s (want WxH@FPS)\n", argv[0], argv[*i]);
+            exit (1);
+        }
+        return TRUE;
+    }
+    if (!strcmp (a, "--cam-match")) {
+        NEXT ("--cam-match");
+        g_free (g_cam_match);
+        g_cam_match = g_strdup (argv[++(*i)]);
+        return TRUE;
+    }
+    if (!strcmp (a, "--cam-retries")) {
+        NEXT ("--cam-retries");
+        g_cam_retries = CLAMP (atoi (argv[++(*i)]), 0, 3);
+        return TRUE;
+    }
+
+    /* ---- video / rendering ---- */
+    if (!strcmp (a, "--no-media-watchdog"))   { g_media_watchdog = FALSE; return TRUE; }
+    if (!strcmp (a, "--no-auto-reload"))      { g_auto_reload = FALSE; return TRUE; }
+    if (!strcmp (a, "--no-gpu"))              { g_no_gpu = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-hw-decode"))        { g_no_hw_decode = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-dmabuf"))           { g_no_dmabuf = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-compositing"))      { g_no_compositing = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-webrtc"))           { g_no_webrtc = TRUE; return TRUE; }
+    if (!strcmp (a, "--no-mediastream"))      { g_no_mediastream = TRUE; return TRUE; }
+
+    if (!strcmp (a, "--stall-timeout")) {
+        NEXT ("--stall-timeout");
+        g_stall_timeout = MAX (2, atoi (argv[++(*i)]));
+        return TRUE;
+    }
+    if (!strcmp (a, "--load-timeout")) {
+        NEXT ("--load-timeout");
+        g_load_timeout = MAX (0, atoi (argv[++(*i)]));
+        return TRUE;
+    }
+    if (!strcmp (a, "--max-reloads")) {
+        NEXT ("--max-reloads");
+        g_max_reloads = MAX (0, atoi (argv[++(*i)]));
+        return TRUE;
+    }
+
+    /* ---- diagnostics ---- */
+    if (!strcmp (a, "--media-debug"))         { g_media_debug = TRUE; return TRUE; }
+
+    if (!strcmp (a, "--gst-debug")) {
+        NEXT ("--gst-debug");
+        g_gst_debug = argv[++(*i)];
+        return TRUE;
+    }
+    if (g_str_has_prefix (a, "--gst-debug=")) {
+        g_gst_debug = a + strlen ("--gst-debug=");
+        return TRUE;
+    }
+    if (!strcmp (a, "--gst-debug-level")) {
+        NEXT ("--gst-debug-level");
+        g_gst_level = atoi (argv[++(*i)]);
+        return TRUE;
+    }
+    if (g_str_has_prefix (a, "--gst-debug-level=")) {
+        g_gst_level = atoi (a + strlen ("--gst-debug-level="));
+        return TRUE;
+    }
+    if (!strcmp (a, "--gst-debug-file")) {
+        NEXT ("--gst-debug-file");
+        g_gst_dbgfile = argv[++(*i)];
+        return TRUE;
+    }
+    if (g_str_has_prefix (a, "--gst-debug-file=")) {
+        g_gst_dbgfile = a + strlen ("--gst-debug-file=");
+        return TRUE;
+    }
+    if (!strcmp (a, "--webkit-debug")) {
+        NEXT ("--webkit-debug");
+        g_webkit_dbg = argv[++(*i)];
+        return TRUE;
+    }
+    if (g_str_has_prefix (a, "--webkit-debug=")) {
+        g_webkit_dbg = a + strlen ("--webkit-debug=");
+        return TRUE;
+    }
+
+#undef NEXT
+    return FALSE;
+}
+
+/* Environment has to be set before any process is spawned, so the web and
+ * GPU processes - where GStreamer actually runs - inherit it. */
+static void
+big_pre_gtk (void)
+{
+    if (g_gst_debug) {
+        g_setenv ("GST_DEBUG", g_gst_debug, TRUE);
+    } else if (g_gst_level >= 0) {
+        char *s = g_strdup_printf ("%d", g_gst_level);
+        g_setenv ("GST_DEBUG", s, TRUE);
+        g_free (s);
+    }
+    if (g_gst_dbgfile)    g_setenv ("GST_DEBUG_FILE", g_gst_dbgfile, TRUE);
+    if (g_webkit_dbg)     g_setenv ("WEBKIT_DEBUG", g_webkit_dbg, TRUE);
+    if (g_no_hw_decode)   g_setenv ("WEBKIT_GST_ENABLE_HW_DECODERS", "0", TRUE);
+    if (g_no_dmabuf)      g_setenv ("WEBKIT_DISABLE_DMABUF_RENDERER", "1", TRUE);
+    if (g_no_compositing) g_setenv ("WEBKIT_DISABLE_COMPOSITING_MODE", "1", TRUE);
+}
+
+static void
+big_startup (void)
+{
+    gst_init (NULL, NULL);
+
+    print_versions ();
+    if (g_media_debug)
+        dump_gstreamer_env ();
+
+    if (g_cam_fix || g_media_watchdog)
+        g_shim_js = build_shim ();
+
+    /* runs once the main loop starts, i.e. after the window is up */
+    if (g_prewarm)
+        g_idle_add (capture_prewarm, NULL);
+}
+
+static void
+big_settings_ready (WebKitSettings *s)
+{
+    if (g_no_mediastream) {
+        webkit_settings_set_enable_media_stream (s, FALSE);
+        LOG ("media: MediaStream disabled\n");
+    }
+    if (g_no_webrtc) {
+        webkit_settings_set_enable_webrtc (s, FALSE);
+        LOG ("media: WebRTC disabled\n");
+    }
+    if (g_no_gpu) {
+        webkit_settings_set_hardware_acceleration_policy (
+            s, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
+        LOG ("gpu: hardware acceleration policy = NEVER\n");
+    }
+}
+
+static void
+big_ucm_ready (WebKitUserContentManager *ucm)
+{
+    /* connect before registering, to avoid a race (per WebKit docs) */
+    g_signal_connect (ucm, "script-message-received::bbEvent",
+                      G_CALLBACK (on_script_message), NULL);
+    webkit_user_content_manager_register_script_message_handler (ucm, "bbEvent", NULL);
+
+    if (g_shim_js) {
+        WebKitUserScript *script =
+            webkit_user_script_new (g_shim_js,
+                                    WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+                                    WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+                                    NULL, NULL);
+        webkit_user_content_manager_add_script (ucm, script);
+        webkit_user_script_unref (script);
+    }
+}
+
+static void
+big_view_ready (WebKitWebView *view)
+{
+    g_signal_connect (view, "load-changed", G_CALLBACK (on_load_changed), NULL);
+    g_signal_connect (view, "load-failed",  G_CALLBACK (on_load_failed), NULL);
+    g_signal_connect (view, "web-process-terminated",
+                      G_CALLBACK (on_web_process_terminated), NULL);
+
+    g_signal_connect (view, "notify::camera-capture-state",
+                      G_CALLBACK (on_capture_notify), NULL);
+    g_signal_connect (view, "notify::microphone-capture-state",
+                      G_CALLBACK (on_capture_notify), NULL);
+    g_signal_connect (view, "notify::display-capture-state",
+                      G_CALLBACK (on_capture_notify), NULL);
+}
+
+static void
+big_win_ready (Win *w)
+{
+    w->ext = g_new0 (BigWin, 1);
+}
+
+static void
+big_win_gone (Win *w)
+{
+    BigWin *e = EXT (w);
+    if (!e)
+        return;
+
+    if (e->load_wd_id)
+        g_source_remove (e->load_wd_id);
+    g_free (e->reload_uri);
+    g_free (e);
+    w->ext = NULL;
+}
+
+static gboolean
+big_key (Win *w, guint key, gboolean shift)
+{
+    if (!shift)
+        return FALSE;
+
+    switch (key) {
+    case GDK_KEY_m:
+        load_diag_page (w->view);
+        return TRUE;
+    case GDK_KEY_c:
+        toast_show (w, "warming camera", 2);
+        view_eval (w->view, "window.__bbWarm&&window.__bbWarm();");
+        return TRUE;
+    case GDK_KEY_x:
+        toast_show (w, "releasing camera", 2);
+        view_eval (w->view, "window.__bbRelease&&window.__bbRelease();");
+        return TRUE;
+    case GDK_KEY_v:
+        toast_show (w, "media state -> stderr", 2);
+        view_eval (w->view, MEDIA_DUMP_JS);
+        return TRUE;
+    default:
+        return FALSE;
+    }
+}
+
+/* "diag" is ours, and --warm-cam holds the first address back until the
+ * camera has been opened once. */
+static gboolean
+big_load_uri (WebKitWebView *view, const char *raw)
+{
+    gboolean first = !g_first_load_done;
+    g_first_load_done = TRUE;
+
+    if (first && g_warm_cam && !g_deny_media) {
+        g_pending_url = is_diag_request (raw) ? g_strdup ("diag") : normalize_uri (raw);
+        g_warm_timeout_id = g_timeout_add_seconds (g_warm_timeout, warm_timeout, NULL);
+        mlog ("warm: opening the camera before %s", g_pending_url);
+        webkit_web_view_load_html (view, WARM_HTML, "https://bigbrowser.warm/");
+        return TRUE;
+    }
+
+    if (is_diag_request (raw)) {
+        load_diag_page (view);
+        return TRUE;
+    }
+
+    return FALSE;
+}
+
+/* our own pages are served from load_html, they are not links */
+static gboolean
+big_history_skip (const char *uri)
+{
+    return g_str_has_prefix (uri, "https://bigbrowser.diag/") ||
+           g_str_has_prefix (uri, "https://bigbrowser.warm/");
+}
+
+static void
+big_cleanup (void)
+{
+    g_free (g_shim_js);
+    g_free (g_cam_match);
+}
+
+/* ----------------------------------------------------------------- main */
+
+static const BrowserApp browser_big = {
+    .tagline        = "WebKitGTK 6.0 / GTK4 page viewer with camera and streaming fixes",
+    .default_title  = "Browser Big",
+    .default_app_id = "browser-big",
+    .usage_arg      = "URL|PATH|diag",
+
+    .usage_options  = big_usage_options,
+    .usage_keys     = big_usage_keys,
+    .parse_arg      = big_parse_arg,
+    .pre_gtk        = big_pre_gtk,
+    .startup        = big_startup,
+    .settings_ready = big_settings_ready,
+    .ucm_ready      = big_ucm_ready,
+    .view_ready     = big_view_ready,
+    .win_ready      = big_win_ready,
+    .win_gone       = big_win_gone,
+    .key            = big_key,
+    .load_uri       = big_load_uri,
+    .history_skip   = big_history_skip,
+    .cleanup        = big_cleanup,
+};
+
+int
+main (int argc, char **argv)
+{
+    return browser_main (argc, argv, &browser_big);
+}
diff --git a/browser-mini.c b/browser-mini.c
new file mode 100644
index 0000000..7f00264
--- /dev/null
+++ b/browser-mini.c
@@ -0,0 +1,22 @@
+/*
+ * browser-mini - minimal WebKitGTK 6.0 (GTK4) page viewer
+ *
+ * All of the behaviour lives in browser_core.c. This file exists to say
+ * that minibrowser adds nothing to it. Run with -h for the option and
+ * key list.
+ */
+
+#include "browser_core.h"
+
+static const BrowserApp browser_mini = {
+    .tagline        = "minimal WebKitGTK 6.0 / GTK4 page viewer",
+    .default_title  = "Browser Mini",
+    .default_app_id = "browser-mini",
+    .usage_arg      = "URL",
+};
+
+int
+main (int argc, char **argv)
+{
+    return browser_main (argc, argv, &browser_mini);
+}
diff --git a/browser_core.c b/browser_core.c
index 93b4b19..fffe7a0 100644
--- a/browser_core.c
+++ b/browser_core.c
@@ -64,6 +64,7 @@ static const BrowserApp *g_app;
 static GPtrArray  *g_ucms;         /* WebKitUserContentManager*, unowned */
 static const char *g_css_path;
 static char       *g_css_data;
+static gboolean    g_find_css_on;   /* the find highlight sheet is in */
 static int         g_windows;
 
 static char       *g_download_dir;
@@ -74,6 +75,7 @@ static char       *g_user_agent;
 static gboolean    g_follow_page_title = TRUE; /* see --title / --page-title */
 static char       *g_clip_cmd;     /* --clip-cmd, NULL -> wl-copy  */
 static gboolean    g_no_middle_paste = TRUE;  /* --enable-middle-click-paste */
+static gboolean    g_fix_select_all = TRUE;   /* see on_key */
 static gboolean    g_want_page_title;
 static char       *g_css_owned;    /* when --css came from the config */
 
@@ -133,6 +135,8 @@ static void       downloads_reveal  (void);
 static void       downloads_tick_start (void);
 static void       dl_panel_rebuild  (Win *w);
 static void       css_apply_one     (WebKitUserContentManager *ucm);
+static void       css_apply_all     (void);
+static void       find_highlight    (gboolean on);
 static void       history_note      (Win *w);
 
 static gboolean   parse_mod         (const char *name);
@@ -910,8 +914,10 @@ typedef struct { double r, g, b, a; } Color;
 
 typedef struct {
     /* palette, swov names */
-    Color bg, tile, tile_sel, tile_hover, card, card_hover;
+    Color bg, tile, tile_sel, tile_hover, mini_bg, card, card_hover, card_focus;
     Color text, subtext, dim, accent, hl, hltext, hint, urgent, outline;
+    Color find_hl;          /* the match <mod>+F is sitting on */
+    Color current, match, shadow;   /* carried for interchange */
 
     /* geometry */
     double radius, border, pad, gap, win_gap, ui_scale;
@@ -921,6 +927,7 @@ typedef struct {
 
     /* how wide a popup wants to be, before the window has its say */
     int   popup_w;
+    int   list_rows;        /* how many lines a popup list shows */
     char *font;        /* "" -> whatever GTK is themed with */
     char *font_mono;   /* URLs and file names read better fixed width */
 } Theme;
@@ -991,7 +998,9 @@ theme_defaults (void)
     t->tile       = rgba_hex (0x1e2733f2);
     t->tile_sel   = rgba_hex (0x26313ff2);
     t->tile_hover = rgba_hex (0x2b3644f2);
+    t->mini_bg    = rgba_hex (0x11171f9e);
     t->card       = rgba_hex (0x33404ff7);
+    t->card_focus = rgba_hex (0x3b4a5bf7);
     t->card_hover = rgba_hex (0x46566af7);
     t->hl         = rgba_hex (0xcb9b00ff);
     t->text       = rgba_hex (0xe8e8e8ff);
@@ -1002,6 +1011,10 @@ theme_defaults (void)
     t->hint       = rgba_hex (0xa7b5c4ff);
     t->urgent     = rgba_hex (0xe0533cff);
     t->outline    = rgba_hex (0x0a0e1499);
+    t->find_hl    = rgba_hex (0x9fd6f5ff);   /* pale blue, not the orange */
+    t->current    = rgba_hex (0x4fb3a5ff);
+    t->match      = rgba_hex (0xb58ae0ff);
+    t->shadow     = rgba_hex (0x00000073);
 
     t->radius   = 14.0;
     t->border   = 3.0;
@@ -1010,6 +1023,7 @@ theme_defaults (void)
     t->win_gap  = 5.0;
     t->ui_scale = 1.0;
 
+    t->list_rows = 8;
     t->popup_w  = 550;         /* about 50 characters of the mono font */
     t->ws_px    = 26;
     t->label_px = 16;
@@ -1049,11 +1063,19 @@ cfg_set (const char *k, const char *v)
 
     /* ---- palette, shared with swov ---- */
     if (key_is (k, "bg"))         return parse_color (v, &t->bg);
-    if (key_is (k, "tile") || key_is (k, "panel"))
+    if (key_is (k, "tile") || key_is (k, "panel") || key_is (k, "ring"))
                                   return parse_color (v, &t->tile);
-    if (key_is (k, "tile_sel"))   return parse_color (v, &t->tile_sel);
+    if (key_is (k, "tile_sel") || key_is (k, "ring2"))
+                                  return parse_color (v, &t->tile_sel);
     if (key_is (k, "tile_hover")) return parse_color (v, &t->tile_hover);
     if (key_is (k, "card"))       return parse_color (v, &t->card);
+    if (key_is (k, "card_focus"))  return parse_color (v, &t->card_focus);
+    if (key_is (k, "mini_bg") || key_is (k, "center"))
+                                   return parse_color (v, &t->mini_bg);
+    if (key_is (k, "current"))     return parse_color (v, &t->current);
+    if (key_is (k, "match"))       return parse_color (v, &t->match);
+    if (key_is (k, "shadow_color") || key_is (k, "shadow"))
+                                   return parse_color (v, &t->shadow);
     if (key_is (k, "card_hover") || key_is (k, "hover"))
                                   return parse_color (v, &t->card_hover);
     if (key_is (k, "text"))       return parse_color (v, &t->text);
@@ -1065,6 +1087,8 @@ cfg_set (const char *k, const char *v)
     if (key_is (k, "hint"))       return parse_color (v, &t->hint);
     if (key_is (k, "urgent"))     return parse_color (v, &t->urgent);
     if (key_is (k, "outline"))    return parse_color (v, &t->outline);
+    if (key_is (k, "find_hl") || key_is (k, "find"))
+                                  return parse_color (v, &t->find_hl);
 
     /* ---- geometry, shared with swov ---- */
     if (key_is (k, "radius") || key_is (k, "corner")) { t->radius = g_ascii_strtod (v, NULL); return TRUE; }
@@ -1080,6 +1104,7 @@ cfg_set (const char *k, const char *v)
 
     /* ---- text, shared with swov ---- */
     if (key_is (k, "popup_w") || key_is (k, "popup_width")) { t->popup_w = atoi (v); return TRUE; }
+    if (key_is (k, "list_rows") || key_is (k, "rows")) { t->list_rows = atoi (v); return TRUE; }
     if (key_is (k, "ws_px") || key_is (k, "workspace_px")) { t->ws_px    = atoi (v); return TRUE; }
     if (key_is (k, "label_px") || key_is (k, "app_px"))    { t->label_px = atoi (v); return TRUE; }
     if (key_is (k, "title_px"))                            { t->title_px = atoi (v); return TRUE; }
@@ -1100,6 +1125,7 @@ cfg_set (const char *k, const char *v)
     if (key_is (k, "no_media"))      { g_deny_media = truthy (v); return TRUE; }
     if (key_is (k, "quiet"))         { g_quiet = truthy (v); return TRUE; }
     if (key_is (k, "page_title"))    { g_want_page_title = truthy (v); return TRUE; }
+    if (key_is (k, "select_all"))    { g_fix_select_all = truthy (v); return TRUE; }
     if (key_is (k, "middle_click_paste")) { g_no_middle_paste = !truthy (v); return TRUE; }
     if (key_is (k, "always_overwrite")) {
         g_always_overwrite     = truthy (v);
@@ -1239,9 +1265,11 @@ usage (const char *argv0, gboolean to_stdout)
 
     if (hpath)
         hblock = g_strdup_printf (
-            "  Every address that came up is appended to\n"
+            "  Every address that came up is kept in\n"
             "    %s\n"
-            "  as a tab separated line: timestamp, URL, page title.\n", hpath);
+            "  as a tab separated line: timestamp, URL, page title. One line per\n"
+"  address: visiting a page again moves it to the end instead of adding\n"
+"  a second copy.\n", hpath);
     else
         hblock = g_strdup ("  Off, because --private was given.\n");
     const char *arg   = g_app->usage_arg ? g_app->usage_arg : "URL";
@@ -1249,7 +1277,10 @@ usage (const char *argv0, gboolean to_stdout)
     g_string_append_printf (s, "%s - %s\n\n", argv0, g_app->tagline);
     g_string_append_printf (s, "usage: %s [%s] [options]\n"
 "\n"
-"  With no address the window comes up blank with the history open.\n"
+"  With no address the window comes up on a start page carrying the\n"
+"  program's name, version and the three keys worth knowing. It is drawn\n"
+"  from the same palette as the panels.\n"
+"\n"
 "  A local file needs three slashes: file:///tmp/index.html. A bare path\n"
 "  works too, ./index.html or /tmp/index.html, and anything without a\n"
 "  scheme is tried as https.\n"
@@ -1306,8 +1337,8 @@ usage (const char *argv0, gboolean to_stdout)
 "                      in ~/.local/share/wkview/download-dirs.tsv, which\n"
 "                      belongs to you rather than to a profile: every\n"
 "                      profile and both browsers share it. Search keywords\n"
-"                      are per profile, in searches.tsv. A directory that does not exist is\n"
-"                      created group writable (0770)\n"
+"                      are per profile, in searches.tsv. A directory that\n"
+"                      does not exist is created group writable (0770)\n"
 "  --profile NAME      named profile, persists cookies (default: \"default\")\n"
 "  --clear-data        wipe the profile's data and cache before starting\n"
 "  --private           ephemeral session, no stored history, ignores\n"
@@ -1334,12 +1365,14 @@ usage (const char *argv0, gboolean to_stdout)
 "  ignored with a note on stderr.\n"
 "\n"
 "  look:    popup_width (how wide the panels want to be, %d)\n"
+"           list_rows (lines a popup list shows, %d)\n"
 "           bg tile tile_sel tile_hover card card_hover text subtext dim\n"
-"           accent hl hltext hint urgent outline\n"
+"           accent hl hltext hint urgent outline find_hl\n"
 "           radius border pad gap win_gap ui_scale\n"
 "           font font_mono label_px title_px hint_px\n"
 "  ours:    title app_id zoom mod clip_cmd download_dir profile private\n"
-"           no_media page_title middle_click_paste css user_agent quiet\n"
+"           no_media page_title middle_click_paste select_all css\n"
+"           user_agent quiet\n"
 "           always_overwrite\n"
 "           search_KEY (e.g. search_s = https://google.com/search?q={})\n"
 "\n"
@@ -1349,16 +1382,23 @@ usage (const char *argv0, gboolean to_stdout)
 "  -V, --version       version and build id, to tell two builds apart\n"
 "\n"
 "key bindings (<mod> = %s):\n"
-"  <mod>+O  or <mod>+L type an address. Tab or Down brings up the matching\n"
-"                      history, and with nothing typed yet, all of it.\n"
+"  <mod>+O  or <mod>+L type an address. The history is listed straight\n"
+"                      away and narrows as you type, so the two are one\n"
+"                      thing: pick a line, or press Enter and what you\n"
+"                      typed is loaded\n"
 "                      A search keyword in front runs a search instead:\n"
 "                      \"s tree\" with s = https://google.com/search?q={}\n"
 "  <mod>+H             the same popup, opened on the history\n"
-"  <mod>+F             find in the page. Enter for the next hit,\n"
-"                      Shift+Enter for the previous, Esc to stop\n"
+"  <mod>+F             find in the page. Enter, Down or <mod>+N for the\n"
+"                      next hit, Shift+Enter, Up or <mod>+Shift+N for the\n"
+"                      previous, Esc to stop. The hit you are on is shown\n"
+"                      in find_hl, a pale blue by default\n"
 "  <mod>+S             download directory. The select decides how far it\n"
 "                      reaches: this address only, everything on the site,\n"
-"                      or everything. The narrower rule wins, and a rule\n"
+"                      or everything. The popup lists what is already\n"
+"                      stored: arrows or the mouse pick one, Delete or\n"
+"                      <mod>+X drops the rule under the cursor.\n"
+"                      The narrower rule wins, and a rule\n"
 "                      set on a page also covers the files it hands out.\n"
 "                      If something else already points at that directory\n"
 "                      you are asked: Enter drops the older rule, Esc lets\n"
@@ -1367,8 +1407,11 @@ usage (const char *argv0, gboolean to_stdout)
 "                      ask for, with no question. \"everything\" lasts for\n"
 "                      this run only; put download_dir in a config file to\n"
 "                      keep it\n"
-"  <mod>+K             add a search keyword, as \"g URL\" with {} where the\n"
-"                      words go\n"
+"  <mod>+K             search keywords. The popup lists the ones it knows;\n"
+"                      add one as \"g URL\" with {} where the words go, and\n"
+"                      drop the one under the cursor with Delete or\n"
+"                      <mod>+X. Then \"g tree\" in the address popup\n"
+"                      searches for tree\n"
 "  F1  or  <mod>+/     the key list, on screen\n"
 "  <mod>+J             back: one page back, then on into the stored history\n"
 "  <mod>+Shift+J       forward, the same way round\n"
@@ -1377,8 +1420,8 @@ usage (const char *argv0, gboolean to_stdout)
 "  keys named on them, so Enter is never the only way in.\n"
 "\n"
 "  In the popup: Tab and Down walk forward through the matches, Shift+Tab\n"
-"  and Up back, the wheel scrolls, a click opens, Delete removes the entry\n"
-"  from the history. Whatever is selected is written into the input, so\n"
+"  and Up back, the wheel scrolls, a click opens, Delete or <mod>+X removes\n"
+"  the entry. Whatever is selected is written into the input, so\n"
 "  Enter always loads what you can read. Esc, <mod>+H or a click outside\n"
 "  closes it.\n"
 "\n"
@@ -1394,7 +1437,7 @@ usage (const char *argv0, gboolean to_stdout)
 "  <mod>+minus         zoom out\n"
 "  <mod>+0             reset zoom to 100%%\n"
 "  <mod>+Y             copy the current URL, and show what was copied\n",
-        hblock, HIST_KEEP, cfg_shared, cfg_own, g_theme.popup_w,
+        hblock, HIST_KEEP, cfg_shared, cfg_own, g_theme.popup_w, g_theme.list_rows,
         g_mod_name, DL_HISTORY_SECONDS, URL_TOAST_SECONDS);
 
     if (g_app->usage_keys)
@@ -1406,8 +1449,10 @@ usage (const char *argv0, gboolean to_stdout)
 "  message - and reaches the page only when nothing of ours is up.\n"
 "\n"
 "  Only the combinations listed above are intercepted; everything else\n"
-"  (<mod>+A, <mod>+C, <mod>+V, ...) goes straight to the page, so select-all\n"
-"  and copy/paste keep working inside input fields. While the URL bar is\n"
+"  (<mod>+C, <mod>+V, ...) goes straight to the page, so copy and paste\n"
+"  keep working inside input fields. <mod>+A is turned into WebKit's\n"
+"  select-all, because WebKit itself reads it as move-to-start-of-line;\n"
+"  set select_all=no if a page needs the key for itself. While the URL bar is\n"
 "  open every key except Esc belongs to it, so <mod>+A selects its text.\n"
 "%s",
         g_mod == GDK_CONTROL_MASK
@@ -1438,10 +1483,43 @@ usage_short (const char *argv0)
 
 /* User stylesheet injected into the pages (--css). One content manager
  * per view, so the sheet has to be applied to each of them. */
+/*
+ * WebKit shows the match it is sitting on as the document selection, so
+ * colouring ::selection is what colours the current hit. The sheet only
+ * goes in while <mod>+F is open, so ordinary selections keep the page's
+ * own colours the rest of the time.
+ */
+static void
+css_apply_find (WebKitUserContentManager *ucm)
+{
+    if (!g_find_css_on)
+        return;
+
+    char *bg  = css_rgba (g_theme.find_hl);
+    char *fg  = css_rgba (g_theme.hltext);
+    char *css = g_strdup_printf ("::selection{background-color:%s;color:%s}"
+                                 "::-moz-selection{background-color:%s;color:%s}",
+                                 bg, fg, bg, fg);
+
+    WebKitUserStyleSheet *ss =
+        webkit_user_style_sheet_new (css,
+                                     WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+                                     WEBKIT_USER_STYLE_LEVEL_USER,
+                                     NULL, NULL);
+    webkit_user_content_manager_add_style_sheet (ucm, ss);
+    webkit_user_style_sheet_unref (ss);
+
+    g_free (css);
+    g_free (fg);
+    g_free (bg);
+}
+
 static void
 css_apply_one (WebKitUserContentManager *ucm)
 {
     webkit_user_content_manager_remove_all_style_sheets (ucm);
+    css_apply_find (ucm);
+
     if (!g_css_data)
         return;
 
@@ -1454,6 +1532,13 @@ css_apply_one (WebKitUserContentManager *ucm)
     webkit_user_style_sheet_unref (ss);
 }
 
+static void
+css_apply_all (void)
+{
+    for (guint i = 0; i < g_ucms->len; i++)
+        css_apply_one (g_ptr_array_index (g_ucms, i));
+}
+
 void
 css_reload (void)
 {
@@ -1470,8 +1555,7 @@ css_reload (void)
         }
     }
 
-    for (guint i = 0; i < g_ucms->len; i++)
-        css_apply_one (g_ptr_array_index (g_ucms, i));
+    css_apply_all ();
 }
 
 /*
@@ -1489,6 +1573,8 @@ ui_css_install (void)
     char *c_sel     = css_rgba (t->tile_sel);
     char *c_hover   = css_rgba (t->tile_hover);
     char *c_card    = css_rgba (t->card);
+    char *c_mini    = css_rgba (t->mini_bg);
+    char *c_current = css_rgba (t->current);
     char *c_text    = css_rgba (t->text);
     char *c_subtext = css_rgba (t->subtext);
     char *c_dim     = css_rgba (t->dim);
@@ -1561,6 +1647,7 @@ ui_css_install (void)
         "label.br-pick-sub { color: %s; %s font-size: %.0fpx; }"
         "label.br-dl-name { color: %s; %s font-size: %.0fpx; }"
         "label.br-dl-status { color: %s; %s font-size: %.0fpx; }"
+        "label.br-dl-live { color: %s; %s font-size: %.0fpx; }"
         "label.br-dl-ask { color: %s; %s font-size: %.0fpx; }"
         "label.br-dl-failed { color: %s; %s font-size: %.0fpx; }"
         "label.br-dl-dir { color: %s; %s font-size: %.0fpx; }"
@@ -1588,6 +1675,7 @@ ui_css_install (void)
         c_subtext, mono_font, t->title_px * s,
         c_text, mono_font, t->label_px * s,
         c_subtext, mono_font, t->title_px * s,
+        c_current, mono_font, t->title_px * s,
         c_hl, mono_font, t->title_px * s,
         c_urgent, mono_font, t->title_px * s,
         c_dim, mono_font, t->title_px * s,
@@ -1641,9 +1729,22 @@ ui_css_install (void)
         "  background-color: %s; border: none;"
         "  border-radius: %.0fpx; min-height: %.0fpx;"
         "}",
-        c_card, t->radius / 3.0, 6.0 * s,
+        c_mini, t->radius / 3.0, 6.0 * s,
         c_hl,   t->radius / 3.0, 6.0 * s);
 
+    /* ---- the loading hairline, across the very top ---- */
+    g_string_append_printf (css,
+        "progressbar.br-load, progressbar.br-load > trough {"
+        "  background-color: transparent; background-image: none;"
+        "  border: none; border-radius: 0; min-height: %.0fpx;"
+        "  padding: 0; margin: 0;"
+        "}"
+        "progressbar.br-load > trough > progress {"
+        "  background-color: %s; border: none; border-radius: 0;"
+        "  min-height: %.0fpx;"
+        "}",
+        3.0 * s, c_hl, 3.0 * s);
+
     /* ---- picker rows ---- */
     g_string_append_printf (css,
         "scrolledwindow.br-pick-scroll,"
@@ -1680,6 +1781,7 @@ ui_css_install (void)
     g_free (c_dim);     g_free (c_hl);      g_free (c_hint);
     g_free (c_urgent);  g_free (c_outline);  g_free (c_hltext);
     g_free (c_hl_soft); g_free (c_hl_dim);
+    g_free (c_mini);    g_free (c_current);
 }
 
 /* ----------------------------------------------------------- clipboard */
@@ -2273,108 +2375,54 @@ history_load (void)
     if (n > HIST_KEEP)
         first = n - HIST_KEEP;
 
-    GString *kept = g_string_new (NULL);
-
-    for (guint i = first; i < n; i++) {
-        char **f = g_strsplit (lines[i], "\t", 3);
-        if (g_strv_length (f) >= 2 && history_uri_ok (f[1])) {
-            hist_add (f[1], g_strv_length (f) >= 3 ? f[2] : "");
-            g_string_append (kept, lines[i]);
-            g_string_append_c (kept, '\n');
+    /*
+     * One line per address, keeping the newest visit. Files written before
+     * this rule existed are full of repeats, so the cleanup happens here
+     * rather than only on the way in - the file is tidied on first read.
+     */
+    GHashTable *seen = g_hash_table_new (g_str_hash, g_str_equal);
+    GPtrArray  *keep = g_ptr_array_new ();   /* line indices, newest first */
+
+    for (guint i = n; i > first; i--) {
+        char **f = g_strsplit (lines[i - 1], "\t", 3);
+
+        if (g_strv_length (f) >= 2 && history_uri_ok (f[1]) &&
+            !g_hash_table_contains (seen, f[1])) {
+            g_hash_table_add (seen, g_strdup (f[1]));
+            g_ptr_array_add (keep, GUINT_TO_POINTER (i - 1));
         }
         g_strfreev (f);
     }
 
-    if (first > 0)
-        g_file_set_contents (g_hist_path, kept->str, -1, NULL);
-
-    g_string_free (kept, TRUE);
-    g_strfreev (lines);
-    g_free (data);
-
-    LOG ("history: %u entries from %s\n", g_hist->len, g_hist_path);
-}
-
-static void
-history_append (const char *uri, const char *title)
-{
-    if (!g_hist_path)
-        return;
-
-    /* reloads and in-page jumps should not pile up */
-    if (g_hist->len && !g_strcmp0 (hist_at (g_hist->len - 1)->uri, uri))
-        return;
-
-    char *t = history_clean (title);
-    hist_add (uri, t);
-
-    GDateTime *now  = g_date_time_new_now_local ();
-    char      *when = g_date_time_format_iso8601 (now);
-    FILE      *f    = g_fopen (g_hist_path, "a");
-
-    if (f) {
-        fprintf (f, "%s\t%s\t%s\n", when, uri, t);
-        fclose (f);
-    } else {
-        g_printerr ("history: cannot write %s: %s\n", g_hist_path, g_strerror (errno));
-    }
-
-    g_free (t);
-    g_free (when);
-    g_date_time_unref (now);
-}
+    GString *kept  = g_string_new (NULL);
+    guint    dropped = 0;
 
-static void
-history_setup (const char *data_dir)
-{
-    g_hist = g_ptr_array_new_with_free_func (hist_entry_free);
+    for (guint i = keep->len; i > 0; i--) {          /* back into file order */
+        guint  idx = GPOINTER_TO_UINT (g_ptr_array_index (keep, i - 1));
+        char **f   = g_strsplit (lines[idx], "\t", 3);
 
-    if (g_private || !data_dir) {
-        LOG ("history: off\n");
-        return;
+        hist_add (f[1], g_strv_length (f) >= 3 ? f[2] : "");
+        g_string_append (kept, lines[idx]);
+        g_string_append_c (kept, '\n');
+        g_strfreev (f);
     }
 
-    g_hist_path = g_build_filename (data_dir, HIST_FILE, NULL);
-    history_load ();
-}
-
-/* The <title> is often still empty when the load finishes, so the record
- * is written a moment later. It also debounces redirect chains. */
-static gboolean
-history_write (gpointer u)
-{
-    Win *w = u;
-    w->hist_id = 0;
-
-    const char *uri = webkit_web_view_get_uri (w->view);
-    if (!history_uri_ok (uri))
-        return G_SOURCE_REMOVE;
-    if (g_app->history_skip && g_app->history_skip (uri))
-        return G_SOURCE_REMOVE;         /* a front-end's internal page */
+    dropped = (n - first) - keep->len;
 
-    history_append (uri, webkit_web_view_get_title (w->view));
-    w->hist_pos = (int) g_hist->len - 1;
-    return G_SOURCE_REMOVE;
-}
+    if (first > 0 || dropped > 0)
+        g_file_set_contents (g_hist_path, kept->str, -1, NULL);
 
-/* Called when a load settles. Records the page unless we navigated there
- * ourselves, in which case the cursor is already where it belongs. */
-static void
-history_note (Win *w)
-{
-    if (w->hist_loading) {
-        w->hist_loading = FALSE;
-        return;
-    }
+    if (dropped)
+        LOG ("history: dropped %u duplicate lines\n", dropped);
 
-    w->hist_walk = FALSE;               /* the user went somewhere new */
+    g_ptr_array_free (keep, TRUE);
+    g_hash_table_destroy (seen);
 
-    if (w->load_failed)
-        return;
+    g_string_free (kept, TRUE);
+    g_strfreev (lines);
+    g_free (data);
 
-    if (w->hist_id)
-        g_source_remove (w->hist_id);
-    w->hist_id = g_timeout_add (HIST_SETTLE_MS, history_write, w);
+    LOG ("history: %u entries from %s\n", g_hist->len, g_hist_path);
 }
 
 /*
@@ -2486,6 +2534,36 @@ urltoast_show (Win *w, const char *uri)
     w->urltoast_id = g_timeout_add_seconds (URL_TOAST_SECONDS, urltoast_timeout, w);
 }
 
+/* The title only turns up part way through the load, so the label starts
+ * as the address and is upgraded in place when the name arrives. */
+static void
+on_title_toast (GObject *obj, GParamSpec *ps, gpointer u)
+{
+    (void) ps; (void) u;
+    WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
+    Win           *w    = win_of (view);
+    const char    *t    = webkit_web_view_get_title (view);
+
+    if (w && t && *t && gtk_widget_get_visible (w->urltoast))
+        gtk_label_set_text (GTK_LABEL (w->urltoast), t);
+}
+
+static void
+on_load_progress (GObject *obj, GParamSpec *ps, gpointer u)
+{
+    (void) ps; (void) u;
+    WebKitWebView *view = WEBKIT_WEB_VIEW (obj);
+    Win           *w    = win_of (view);
+
+    if (!w)
+        return;
+
+    double p = webkit_web_view_get_estimated_load_progress (view);
+
+    gtk_progress_bar_set_fraction (GTK_PROGRESS_BAR (w->loadbar), p);
+    gtk_widget_set_visible (w->loadbar, p > 0.0 && p < 1.0);
+}
+
 static void
 on_load_core (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
 {
@@ -2500,6 +2578,7 @@ on_load_core (WebKitWebView *view, WebKitLoadEvent ev, gpointer u)
     } else if (ev == WEBKIT_LOAD_COMMITTED) {
         urltoast_show (w, webkit_web_view_get_uri (view));   /* after redirects */
     } else if (ev == WEBKIT_LOAD_FINISHED) {
+        gtk_widget_set_visible (w->loadbar, FALSE);
         history_note (w);
     }
 }
@@ -2545,6 +2624,124 @@ omni_matches (const HistEntry *e, const char *needle)
     return hit;
 }
 
+/* One row shape for both lists: a strong line and a quiet one under it. */
+static GtkWidget *
+omni_row_new (const char *main_text, const char *sub_text)
+{
+    GtkWidget *row = gtk_list_box_row_new ();
+    GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
+
+    GtkWidget *main_l = gtk_label_new (main_text);
+    gtk_widget_add_css_class (main_l, "br-pick-main");
+    gtk_label_set_xalign (GTK_LABEL (main_l), 0.0);
+    gtk_label_set_ellipsize (GTK_LABEL (main_l), PANGO_ELLIPSIZE_MIDDLE);
+    gtk_box_append (GTK_BOX (box), main_l);
+
+    if (sub_text && *sub_text) {
+        GtkWidget *sub = gtk_label_new (sub_text);
+        gtk_widget_add_css_class (sub, "br-pick-sub");
+        gtk_label_set_xalign (GTK_LABEL (sub), 0.0);
+        gtk_label_set_ellipsize (GTK_LABEL (sub), PANGO_ELLIPSIZE_MIDDLE);
+        gtk_box_append (GTK_BOX (box), sub);
+    }
+
+    gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), box);
+    return row;
+}
+
+static void
+omni_empty_row (Win *w, const char *text)
+{
+    GtkWidget *row = gtk_list_box_row_new ();
+    GtkWidget *l   = gtk_label_new (text);
+
+    gtk_widget_add_css_class (l, "br-pick-sub");
+    gtk_label_set_xalign (GTK_LABEL (l), 0.0);
+    gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), l);
+    gtk_list_box_row_set_selectable (GTK_LIST_BOX_ROW (row), FALSE);
+    gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+}
+
+/* The directory popup lists what is already stored, so a directory can be
+ * picked instead of typed, and dropped with Delete. */
+static void
+omni_rebuild_rules (Win *w, const char *needle)
+{
+    GList *keys = g_hash_table_get_keys (g_dlrules);
+    guint  shown = 0;
+
+    keys = g_list_sort (keys, (GCompareFunc) g_strcmp0);
+
+    for (GList *l = keys; l; l = l->next) {
+        const char *key = l->data;
+        const char *dir = g_hash_table_lookup (g_dlrules, key);
+
+        if (needle && *needle) {
+            char    *k = g_utf8_casefold (key, -1);
+            char    *d = g_utf8_casefold (dir, -1);
+            gboolean hit = strstr (k, needle) || strstr (d, needle);
+            g_free (k);
+            g_free (d);
+            if (!hit)
+                continue;
+        }
+
+        gboolean always = dl_always_for_key (key);
+        char    *sub    = g_strdup_printf ("%s%s", key, always ? "   (always replaces)" : "");
+        GtkWidget *row  = omni_row_new (dir, sub);
+
+        g_object_set_data_full (G_OBJECT (row), "rule", g_strdup (key), g_free);
+        gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+        g_free (sub);
+        shown++;
+    }
+
+    g_list_free (keys);
+
+    if (shown == 0)
+        omni_empty_row (w, g_hash_table_size (g_dlrules)
+                        ? "no match" : "no directories stored yet");
+}
+
+/* <mod>+K lists the keywords it already knows, the same way the directory
+ * popup lists its rules. */
+static void
+omni_rebuild_searches (Win *w, const char *needle)
+{
+    GList *keys  = g_searches ? g_hash_table_get_keys (g_searches) : NULL;
+    guint  shown = 0;
+
+    keys = g_list_sort (keys, (GCompareFunc) g_strcmp0);
+
+    for (GList *l = keys; l; l = l->next) {
+        const char *key = l->data;
+        const char *url = g_hash_table_lookup (g_searches, key);
+
+        if (needle && *needle) {
+            char    *k = g_utf8_casefold (key, -1);
+            char    *u = g_utf8_casefold (url, -1);
+            gboolean hit = strstr (k, needle) || strstr (u, needle);
+            g_free (k);
+            g_free (u);
+            if (!hit)
+                continue;
+        }
+
+        char      *main_text = g_strdup_printf ("%s   %s", key, url);
+        GtkWidget *row       = omni_row_new (main_text, NULL);
+
+        g_object_set_data_full (G_OBJECT (row), "search", g_strdup (key), g_free);
+        gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
+        g_free (main_text);
+        shown++;
+    }
+
+    g_list_free (keys);
+
+    if (shown == 0)
+        omni_empty_row (w, "no keywords yet - type one as: g https://host/?q={}");
+}
+
 static void
 omni_rebuild (Win *w)
 {
@@ -2555,44 +2752,33 @@ omni_rebuild (Win *w)
     char *needle = g_utf8_casefold (w->omni_needle ? w->omni_needle : "", -1);
     guint shown  = 0;
 
+    if (w->omni_mode == OMNI_DLDIR) {
+        omni_rebuild_rules (w, needle);
+        g_free (needle);
+        return;
+    }
+
+    if (w->omni_mode == OMNI_SEARCH) {
+        omni_rebuild_searches (w, needle);
+        g_free (needle);
+        return;
+    }
+
     /* newest first: what you want in a history list */
     for (guint i = g_hist->len; i > 0 && shown < PICK_ROWS; i--) {
         HistEntry *e = hist_at (i - 1);
         if (!omni_matches (e, needle))
             continue;
 
-        GtkWidget *row = gtk_list_box_row_new ();
-        GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
-
-        GtkWidget *main_l = gtk_label_new (*e->title ? e->title : e->uri);
-        gtk_widget_add_css_class (main_l, "br-pick-main");
-        gtk_label_set_xalign (GTK_LABEL (main_l), 0.0);
-        gtk_label_set_ellipsize (GTK_LABEL (main_l), PANGO_ELLIPSIZE_END);
-        gtk_box_append (GTK_BOX (box), main_l);
-
-        if (*e->title) {
-            GtkWidget *sub = gtk_label_new (e->uri);
-            gtk_widget_add_css_class (sub, "br-pick-sub");
-            gtk_label_set_xalign (GTK_LABEL (sub), 0.0);
-            gtk_label_set_ellipsize (GTK_LABEL (sub), PANGO_ELLIPSIZE_MIDDLE);
-            gtk_box_append (GTK_BOX (box), sub);
-        }
-
-        gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), box);
+        GtkWidget *row = omni_row_new (*e->title ? e->title : e->uri,
+                                       *e->title ? e->uri : NULL);
         g_object_set_data (G_OBJECT (row), "pos", GINT_TO_POINTER ((int) i - 1));
         gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
         shown++;
     }
 
-    if (shown == 0) {
-        GtkWidget *row = gtk_list_box_row_new ();
-        GtkWidget *l   = gtk_label_new (g_hist->len ? "no match" : "no history yet");
-        gtk_widget_add_css_class (l, "br-pick-sub");
-        gtk_label_set_xalign (GTK_LABEL (l), 0.0);
-        gtk_list_box_row_set_child (GTK_LIST_BOX_ROW (row), l);
-        gtk_list_box_row_set_selectable (GTK_LIST_BOX_ROW (row), FALSE);
-        gtk_list_box_append (GTK_LIST_BOX (w->omnilist), row);
-    }
+    if (shown == 0)
+        omni_empty_row (w, g_hist->len ? "no match" : "no history yet");
 
     g_free (needle);
 }
@@ -2620,6 +2806,100 @@ omni_scroll_to (Win *w, GtkListBoxRow *row)
         gtk_adjustment_set_value (adj, bot - page);
 }
 
+static void
+history_append (const char *uri, const char *title)
+{
+    if (!g_hist_path)
+        return;
+
+    /* One line per address. Visiting a page again moves it to the end
+     * rather than adding a second copy, so the list stays a set and the
+     * newest visit is the one you scroll to first. */
+    int seen = -1;
+    for (guint i = 0; i < g_hist->len; i++)
+        if (!g_strcmp0 (hist_at (i)->uri, uri)) {
+            seen = (int) i;
+            break;
+        }
+
+    if (seen >= 0 && seen == (int) g_hist->len - 1)
+        return;                        /* already the newest, nothing to do */
+
+    if (seen >= 0)
+        history_delete (seen);         /* drops it from the file too */
+
+    char *t = history_clean (title);
+    hist_add (uri, t);
+
+    GDateTime *now  = g_date_time_new_now_local ();
+    char      *when = g_date_time_format_iso8601 (now);
+    FILE      *f    = g_fopen (g_hist_path, "a");
+
+    if (f) {
+        fprintf (f, "%s\t%s\t%s\n", when, uri, t);
+        fclose (f);
+    } else {
+        g_printerr ("history: cannot write %s: %s\n", g_hist_path, g_strerror (errno));
+    }
+
+    g_free (t);
+    g_free (when);
+    g_date_time_unref (now);
+}
+
+static void
+history_setup (const char *data_dir)
+{
+    g_hist = g_ptr_array_new_with_free_func (hist_entry_free);
+
+    if (g_private || !data_dir) {
+        LOG ("history: off\n");
+        return;
+    }
+
+    g_hist_path = g_build_filename (data_dir, HIST_FILE, NULL);
+    history_load ();
+}
+
+/* The <title> is often still empty when the load finishes, so the record
+ * is written a moment later. It also debounces redirect chains. */
+static gboolean
+history_write (gpointer u)
+{
+    Win *w = u;
+    w->hist_id = 0;
+
+    const char *uri = webkit_web_view_get_uri (w->view);
+    if (!history_uri_ok (uri))
+        return G_SOURCE_REMOVE;
+    if (g_app->history_skip && g_app->history_skip (uri))
+        return G_SOURCE_REMOVE;         /* a front-end's internal page */
+
+    history_append (uri, webkit_web_view_get_title (w->view));
+    w->hist_pos = (int) g_hist->len - 1;
+    return G_SOURCE_REMOVE;
+}
+
+/* Called when a load settles. Records the page unless we navigated there
+ * ourselves, in which case the cursor is already where it belongs. */
+static void
+history_note (Win *w)
+{
+    if (w->hist_loading) {
+        w->hist_loading = FALSE;
+        return;
+    }
+
+    w->hist_walk = FALSE;               /* the user went somewhere new */
+
+    if (w->load_failed)
+        return;
+
+    if (w->hist_id)
+        g_source_remove (w->hist_id);
+    w->hist_id = g_timeout_add (HIST_SETTLE_MS, history_write, w);
+}
+
 /* Delete in the popup: drop the selected row and keep the place. */
 static void
 omni_delete_selected (Win *w)
@@ -2630,8 +2910,22 @@ omni_delete_selected (Win *w)
     if (!row || !gtk_list_box_row_get_selectable (row))
         return;
 
-    int idx = gtk_list_box_row_get_index (row);
-    history_delete (GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos")));
+    int         idx  = gtk_list_box_row_get_index (row);
+    const char *rule = g_object_get_data (G_OBJECT (row), "rule");
+
+    const char *search = g_object_get_data (G_OBJECT (row), "search");
+
+    if (search) {
+        LOG ("search: dropped %s\n", search);
+        g_hash_table_remove (g_searches, search);
+        searches_save ();
+    } else if (rule) {
+        LOG ("download: dropped rule %s\n", rule);
+        dl_dir_forget (rule);
+        dlrules_save ();
+    } else {
+        history_delete (GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos")));
+    }
 
     omni_rebuild (w);
 
@@ -2652,30 +2946,68 @@ omni_reflect (Win *w, GtkListBoxRow *row)
     if (!row || !gtk_list_box_row_get_selectable (row))
         return;
 
-    int pos = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos"));
-    if (pos < 0 || pos >= (int) g_hist->len)
-        return;
+    const char *text;
+    char       *owned  = NULL;
+    const char *search = g_object_get_data (G_OBJECT (row), "search");
+    const char *rule   = g_object_get_data (G_OBJECT (row), "rule");
+
+    if (search) {
+        const char *url = g_hash_table_lookup (g_searches, search);
+        if (!url)
+            return;
+        owned = g_strdup_printf ("%s %s", search, url);
+        text  = owned;
+    } else if (rule) {
+        text = g_hash_table_lookup (g_dlrules, rule);   /* its directory */
+        if (!text)
+            return;
+    } else {
+        int pos = GPOINTER_TO_INT (g_object_get_data (G_OBJECT (row), "pos"));
+        if (pos < 0 || pos >= (int) g_hist->len)
+            return;
+        text = hist_at (pos)->uri;
+    }
 
     w->omni_setting = TRUE;
-    gtk_editable_set_text (GTK_EDITABLE (w->omnientry), hist_at (pos)->uri);
+    gtk_editable_set_text (GTK_EDITABLE (w->omnientry), text);
     gtk_editable_set_position (GTK_EDITABLE (w->omnientry), -1);
     w->omni_setting = FALSE;
+
+    g_free (owned);
 }
 
-/* The list is worth as much of the window as it can decently have: a
- * fixed cap looked cramped on anything but a small screen. */
+/*
+ * A fixed number of lines, measured from a real row rather than guessed,
+ * so the box is the same height whether the history has four entries or
+ * four hundred. Min and max are set together: a list that grows and
+ * shrinks as you type is harder to read than one that stays put.
+ */
 static void
 omni_size_list (Win *w)
 {
-    int h = gtk_widget_get_height (w->win);
+    int rows  = g_theme.list_rows > 0 ? g_theme.list_rows : 8;
+    int row_h = 0;
+
+    GtkListBoxRow *first = gtk_list_box_get_row_at_index (GTK_LIST_BOX (w->omnilist), 0);
+    if (first) {
+        int min_h, nat_h;
+        gtk_widget_measure (GTK_WIDGET (first), GTK_ORIENTATION_VERTICAL, -1,
+                            &min_h, &nat_h, NULL, NULL);
+        row_h = nat_h;
+    }
+
+    if (row_h <= 0)                    /* nothing built yet, estimate */
+        row_h = (int) ((g_theme.label_px + g_theme.title_px) * g_theme.ui_scale
+                       + g_theme.win_gap);
+
+    int want = rows * row_h;
+    int win_h = gtk_widget_get_height (w->win);
 
-    if (h <= 0)
-        h = 800;                        /* not mapped yet, assume the default */
+    if (win_h > 0)
+        want = MIN (want, (int) (win_h * 0.7));
 
-    gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
-                                                MIN (320, (int) (h * 0.35)));
-    gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
-                                                (int) (h * 0.68));
+    gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (w->omniscroll), want);
+    gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll), want);
 }
 
 static void
@@ -2684,8 +3016,8 @@ omni_list_show (Win *w)
     if (w->omni_list)
         return;
 
-    omni_size_list (w);
     omni_rebuild (w);
+    omni_size_list (w);                /* after the rows exist, so one can be measured */
     gtk_widget_set_visible (w->omniscroll, TRUE);
     w->omni_list = TRUE;
 }
@@ -2723,8 +3055,10 @@ omni_hide (Win *w)
     if (!gtk_widget_get_visible (w->omni))
         return;
 
-    if (w->omni_mode == OMNI_FIND)
+    if (w->omni_mode == OMNI_FIND) {
         webkit_find_controller_search_finish (webkit_web_view_get_find_controller (w->view));
+        find_highlight (FALSE);
+    }
 
     gtk_widget_set_visible (w->omni, FALSE);
     gtk_widget_set_visible (w->omniscroll, FALSE);
@@ -2816,8 +3150,14 @@ omni_show (Win *w, OmniMode mode)
 
     if (mode == OMNI_URL)
         gtk_editable_select_region (GTK_EDITABLE (w->omnientry), 0, -1);
-    else if (mode == OMNI_HISTORY)
-        omni_list_show (w);
+
+    if (mode == OMNI_URL || mode == OMNI_HISTORY)
+        omni_list_show (w);            /* the history is worth seeing unasked */
+
+    if (mode == OMNI_FIND)
+        find_highlight (TRUE);
+    else if (mode == OMNI_DLDIR || mode == OMNI_SEARCH)
+        omni_list_show (w);            /* what is already stored */
 }
 
 static void
@@ -2870,6 +3210,17 @@ omni_go (Win *w)
                    WEBKIT_FIND_OPTIONS_WRAP_AROUND)
 
 /* WebKit scrolls the hit into view for us. */
+/* The sheet is only worth injecting while the find bar is up. */
+static void
+find_highlight (gboolean on)
+{
+    if (g_find_css_on == on)
+        return;
+
+    g_find_css_on = on;
+    css_apply_all ();
+}
+
 static void
 find_run (Win *w)
 {
@@ -3102,7 +3453,12 @@ on_omni_changed (GtkEditable *e, gpointer u)
         return;
     }
 
-    if (w->omni_list)
+    /* Typing an address searches the history at the same time: the two
+     * were never really different jobs. Enter still loads what is typed,
+     * so a page that is not in the list is one keystroke away either way. */
+    if (!w->omni_list && w->omni_mode == OMNI_URL && *w->omni_needle)
+        omni_list_show (w);
+    else if (w->omni_list)
         omni_rebuild (w);
 }
 
@@ -3113,7 +3469,11 @@ on_omni_row_activated (GtkListBox *list, GtkListBoxRow *row, gpointer u)
     Win *w = u;
 
     omni_reflect (w, row);
-    omni_go (w);
+
+    /* in the directory popup a click chooses the directory; Save still
+     * has to be pressed, since the scope has to be right too */
+    if (w->omni_mode != OMNI_DLDIR)
+        omni_go (w);
 }
 
 /* --------------------------------------------------------------- toast */
@@ -3240,6 +3600,7 @@ dl_row_new (Dl *d)
     gtk_widget_add_css_class (status_l,
                               d->state == DL_FAILED ? "br-dl-failed"
                             : d->state == DL_ASK    ? "br-dl-ask"
+                            : d->state == DL_ACTIVE ? "br-dl-live"
                                                     : "br-dl-status");
     gtk_label_set_xalign (GTK_LABEL (status_l), 1.0);
     gtk_widget_set_halign (status_l, GTK_ALIGN_END);
@@ -3634,6 +3995,15 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
     }
 
     if (gtk_widget_get_visible (w->omni)) {
+        /* Select-all, whatever GTK's key theme has to say about it: with
+         * the Emacs bindings <mod>+A is beginning-of-line, which is not
+         * what anyone expects from a text field. */
+        if (mod && !shift && key == GDK_KEY_a) {
+            gtk_editable_select_region (GTK_EDITABLE (w->omnientry), 0, -1);
+            return TRUE;
+        }
+
+
         if ((mod && ((key == GDK_KEY_h && w->omni_mode == OMNI_HISTORY) ||
                      (key == GDK_KEY_f && w->omni_mode == OMNI_FIND) ||
                      (key == GDK_KEY_s && w->omni_mode == OMNI_DLDIR) ||
@@ -3642,20 +4012,33 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
             return TRUE;
         }
 
-        /* finding: Enter walks the hits, there is no list to steer */
+        /* finding: several ways to walk the hits, since the hands are
+         * already on the keyboard and everyone reaches for a different one */
         if (w->omni_mode == OMNI_FIND) {
-            if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) {
+            if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter ||
+                (mod && key == GDK_KEY_n)) {
                 find_step (w, shift ? -1 : +1);
                 return TRUE;
             }
+            if (keyval == GDK_KEY_Down || keyval == GDK_KEY_KP_Down) {
+                find_step (w, +1);
+                return TRUE;
+            }
+            if (keyval == GDK_KEY_Up || keyval == GDK_KEY_KP_Up) {
+                find_step (w, -1);
+                return TRUE;
+            }
             return FALSE;
         }
 
-        /* settings modes: no list to steer, Enter applies */
-        if (w->omni_mode == OMNI_DLDIR || w->omni_mode == OMNI_SEARCH)
+        /* the keyword popup has no list to steer */
+        if (w->omni_mode == OMNI_SEARCH)
             return FALSE;
 
-        if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_KP_Delete) {
+        /* Delete is missing from plenty of keyboards, so <mod>+X does the
+         * same thing wherever an entry can be dropped. */
+        if (keyval == GDK_KEY_Delete || keyval == GDK_KEY_KP_Delete ||
+            (mod && key == GDK_KEY_x)) {
             omni_delete_selected (w);
             return TRUE;
         }
@@ -3771,6 +4154,17 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
         clipboard_copy (w, webkit_web_view_get_uri (w->view));
         return TRUE;
 
+    /* WebKitGTK maps <mod>+A to MoveToBeginningOfLine, an Emacs habit that
+     * surprises everyone typing into a web text field. The editing command
+     * is what the key is supposed to do, so run that instead. Turn it off
+     * with select_all=no for pages that bind <mod>+A themselves. */
+    case GDK_KEY_a:
+        if (shift || !g_fix_select_all)
+            break;
+        webkit_web_view_execute_editing_command (w->view,
+                                                 WEBKIT_EDITING_COMMAND_SELECT_ALL);
+        return TRUE;
+
     /* '+' usually needs Shift, and some layouts send '=' or the keypad key */
     case GDK_KEY_plus:
     case GDK_KEY_equal:
@@ -3822,11 +4216,13 @@ static const KeyRow g_keyrows[] = {
     { "Tab / Down",   "next match" },
     { "Shift+Tab / Up", "previous match" },
     { "Enter",        "open" },
-    { "Delete",       "remove the entry from the history" },
+    { "Delete / %s+X", "remove the entry under the cursor" },
     { "Esc",          "close" },
 
     { NULL, "page" },
-    { "%s+F",         "find, Enter next, Shift+Enter previous" },
+    { "%s+F",         "find in the page" },
+    { "Enter / Down / %s+N", "next match, while finding" },
+    { "Shift+Enter / Up",    "previous match" },
     { "%s+G",         "scroll to the top" },
     { "%s+Shift+G",   "scroll to the bottom" },
     { "%s+plus / minus / 0", "zoom in, out, reset" },
@@ -4253,6 +4649,15 @@ window_new (WebKitWebView *view, gboolean primary)
     gtk_label_set_max_width_chars (GTK_LABEL (w->urltoast), 70);
     gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->urltoast);
 
+    /* a hairline across the very top, so a slow page still says something */
+    w->loadbar = gtk_progress_bar_new ();
+    gtk_widget_add_css_class (w->loadbar, "br-load");
+    gtk_widget_set_halign (w->loadbar, GTK_ALIGN_FILL);
+    gtk_widget_set_valign (w->loadbar, GTK_ALIGN_START);
+    gtk_widget_set_can_target (w->loadbar, FALSE);
+    gtk_widget_set_visible (w->loadbar, FALSE);
+    gtk_overlay_add_overlay (GTK_OVERLAY (overlay), w->loadbar);
+
     /* Top right column: URL toast on top, downloads below. The whole
      * column is click-through, so it never swallows a click on the page. */
     w->topright = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.gap);
@@ -4374,6 +4779,9 @@ view_wire (WebKitWebView *view)
     g_signal_connect (view, "create",             G_CALLBACK (on_create), NULL);
     g_signal_connect (view, "load-changed",       G_CALLBACK (on_load_core), NULL);
     g_signal_connect (view, "load-failed",        G_CALLBACK (on_load_failed_core), NULL);
+    g_signal_connect (view, "notify::title",     G_CALLBACK (on_title_toast), NULL);
+    g_signal_connect (view, "notify::estimated-load-progress",
+                      G_CALLBACK (on_load_progress), NULL);
 
     WebKitFindController *fc = webkit_web_view_get_find_controller (view);
     g_signal_connect (fc, "counted-matches",     G_CALLBACK (on_found_count), NULL);
@@ -4499,6 +4907,75 @@ setup_settings (void)
         g_app->settings_ready (g_settings);
 }
 
+/* ------------------------------------------------------------ start page */
+
+/*
+ * With no address to open, the window would otherwise come up empty. This
+ * is drawn from the same palette as everything else, so the browser looks
+ * like one thing from the first frame. It is served from load_html with no
+ * base URI, which makes it about:blank as far as the rest of the code is
+ * concerned - so it stays out of the history by itself.
+ */
+static char *
+start_page_html (void)
+{
+    Theme *t = &g_theme;
+
+    char *c_bg      = css_rgba (t->bg);
+    char *c_tile    = css_rgba (t->tile);
+    char *c_text    = css_rgba (t->text);
+    char *c_dim     = css_rgba (t->dim);
+    char *c_subtext = css_rgba (t->subtext);
+    char *c_hl      = css_rgba (t->hl);
+    char *c_glow    = css_rgba_at (t->hl, 0.22);
+
+    /* "browser-mini" reads better as browser + mini */
+    const char *name = g_app->default_app_id;
+    const char *dash = strchr (name, '-');
+    char       *head = dash ? g_strndup (name, (gsize) (dash - name)) : g_strdup (name);
+    const char *tail = dash ? dash + 1 : NULL;
+
+    char *html = g_strdup_printf (
+"<!doctype html><meta charset=\"utf-8\"><title>%s</title><style>"
+"html,body{height:100%%;margin:0}"
+"body{display:flex;align-items:center;justify-content:center;"
+"background:radial-gradient(circle at 50%% 38%%,%s 0%%,%s 72%%);"
+"font-family:%s,sans-serif;color:%s;"
+"-webkit-font-smoothing:antialiased}"
+".c{text-align:center;transform:translateY(-5vh)}"
+"h1{margin:0;font-size:11vmin;font-weight:200;letter-spacing:.18em;"
+"color:%s;text-shadow:0 0 60px %s}"
+"h1 b{color:%s;font-weight:600}"
+".r{width:9em;height:2px;margin:1.1em auto 1.4em;"
+"background:linear-gradient(90deg,transparent,%s,transparent)}"
+".v{font-family:%s,monospace;font-size:.85rem;color:%s;letter-spacing:.1em}"
+".k{margin-top:2.6em;font-family:%s,monospace;font-size:.95rem;color:%s}"
+".k b{color:%s;font-weight:600}"
+"</style>"
+"<div class=c><h1>%s%s<b>%s</b></h1><div class=r></div>"
+"<div class=v>%s &middot; build %s</div>"
+"<div class=k><b>%s+O</b> address &nbsp;&nbsp; <b>%s+H</b> history"
+" &nbsp;&nbsp; <b>F1</b> keys</div></div>",
+        name,
+        c_tile, c_bg,
+        *t->font ? t->font : "system-ui", c_text,
+        c_text, c_glow,
+        c_hl,
+        c_hl,
+        t->font_mono, c_dim,
+        t->font_mono, c_subtext,
+        c_hl,
+        head, tail ? " " : "", tail ? tail : "",
+        BROWSER_VERSION, BROWSER_BUILD,
+        g_mod_name, g_mod_name);
+
+    g_free (head);
+    g_free (c_bg);   g_free (c_tile); g_free (c_text);
+    g_free (c_dim);  g_free (c_subtext);
+    g_free (c_hl);   g_free (c_glow);
+    return html;
+}
+
 /* ----------------------------------------------------------------- main */
 
 int
@@ -4697,12 +5174,12 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     GMainLoop *loop = g_main_loop_new (NULL, FALSE);
     g_object_set_data (G_OBJECT (win), "loop", loop);
 
-    /* No address given: come up blank with the history open, so there is
-     * something to pick from rather than an empty window. */
+    /* No address given: the start page, rather than an empty window. */
     if (!url_arg) {
-        webkit_web_view_load_uri (view, "about:blank");
+        char *html = start_page_html ();
+        webkit_web_view_load_html (view, html, NULL);
+        g_free (html);
         gtk_window_present (GTK_WINDOW (win));
-        omni_show (win_of (view), OMNI_HISTORY);
     } else {
         /* the front-end may claim the address, e.g. bigbrowser's "diag" */
         if (!(app->load_uri && app->load_uri (view, url_arg))) {
diff --git a/browser_core.h b/browser_core.h
index 002ecb6..f955afc 100644
--- a/browser_core.h
+++ b/browser_core.h
@@ -45,6 +45,7 @@ typedef struct {
     GtkWidget     *omnisave;
     GtkWidget     *omnicancel;
     GtkWidget     *urltoast;    /* the address, top left, on a new page     */
+    GtkWidget     *loadbar;     /* a thin line across the top while loading */
     guint          urltoast_id;
     GtkWidget     *keys;        /* key reference, hidden by default         */
     GtkWidget     *topright;    /* box holding the toast and the downloads */