code.pablomurad.com

skull-browser

Keyboard-driven browser for the small web, with native Gopher and Gemini

feat: own visual identity, and gemini over a shared small web layer

F7, the visual identity. The shared chrome stylesheet is now a set of
CSS custom properties on :root, with a light variant behind
prefers-color-scheme, and the nine internal pages draw their colours
from those tokens instead of hardcoding them. The GTK theme in
theme.lua is rebuilt around the same phosphor palette, private tabs
read as their own mode rather than a darker shade, and the new tab page
is an authored plate with the mark, the name and the keys worth
knowing, its colours taken from the theme.

F8, the small web. gopher and gemini now share lib/smallweb: one page
shell, one stylesheet, and a render mode setting that applies to both.
Terminal mode is full width and monospaced with a caret on every link;
page mode reads like a document. gopher was refactored onto it, so the
two schemes cannot drift.

gemini:// is new. It speaks the protocol directly over TLS through
LuaSec, renders gemtext to HTML, follows input and redirect status
codes, and escapes every field so a hostile page cannot become markup.
Trust is trust on first use, in lib/smallweb/tofu: the first
certificate a host presents is remembered and a change is reported, not
accepted, with the choice to trust the new one. Without LuaSec the
scheme still loads and every page says what to install.

skull://smallweb (gS) switches the render mode and lists the remembered
gemini hosts, forgetting any of them. Verified end to end against
geminiprotocol.net: the handshake completes and the fingerprint is
stored.

Tests cover gemtext rendering including markup escaping, url parsing
and link resolution, and the full tofu lifecycle. 97 passing.
author
Pablo Murad
date
commit
0f036e4ed91a805851b82836530766c519200b70
parent
15fab0933405beee5e82e180b446e4875992de21
changes
23 files +1502 −252
@@ -1,8 +1,9 @@ # Skull Browser
 
 A keyboard-driven web browser that treats the small web as a first-class
-citizen. Regular sites over `https://`, plus native `gopher://` support.
-Everything above the C core is Lua, and you can change all of it.
+citizen. Regular sites over `https://`, plus native `gopher://` and
+`gemini://` support. Everything above the C core is Lua, and you can change
+all of it.
 
 ![C](https://img.shields.io/badge/C-C11-555555?logo=c&logoColor=white)
 ![Lua](https://img.shields.io/badge/Lua-5.1%20%2F%20LuaJIT-2C2D72?logo=lua&logoColor=white)
@@ -28,7 +29,8 @@ GTK 3     WebKitGTK 4.1
     Lua 5.1 or LuaJIT
     lfs        (lua filesystem)
-    socket     (lua socket, for gopher)
+    socket     (lua socket, for gopher and gemini)
+    ssl        (luasec, for gemini TLS)
     sqlite3
     gstreamer  (video playback)
 
@@ -88,7 +90,31 @@ so copy it by hand as shown above. 
 Themes are a flat Lua table in `theme.lua` with a cascading naming scheme:
 `tab_selected_fg` falls back to `selected_fg`, then to `fg`. Change a handful
-of root keys and the whole interface follows.
+of root keys and the whole interface follows. The internal `skull://` pages
+read their colours from the same file through CSS custom properties, so one
+edit moves the whole browser.
+
+
+## Small web
+
+`gopher://` and `gemini://` are rendered by the browser itself, not proxied.
+Both share one presentation layer, so a setting applies to both.
+
+    skull gopher://gopher.floodgap.com
+    skull gemini://geminiprotocol.net
+
+Two render modes, switched at `skull://smallweb/` or with `gS`:
+
+    terminal   full width, monospaced, a caret on every link (default)
+    page       a comfortable measure, read like a document
+
+Gemini has no certificate authorities. The first time a host is seen its
+certificate is remembered, and a different one later is reported rather than
+accepted. This is trust on first use. `skull://smallweb/` lists the remembered
+hosts and forgets any of them, which trusts the next certificate afresh.
+
+Gemini needs LuaSec (the `ssl` module). Without it the scheme still loads but
+every page explains what to install. `setup-deps.sh` installs it.
 
 
 ## Tests
@@ -46,7 +46,7 @@ case "$FAMILY" in
 debian)
     PACKAGES="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.1-dev
-              libsqlite3-dev luajit libluajit-5.1-dev lua-filesystem lua-socket lua-luassert lua-check
+              libsqlite3-dev luajit libluajit-5.1-dev lua-filesystem lua-socket lua-sec lua-luassert lua-check
               libgstreamer1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good
               gstreamer1.0-plugins-bad gstreamer1.0-libav xvfb"
     INSTALL="sudo apt-get install -y"
@@ -54,7 +54,7 @@ REFRESH="sudo apt-get update"     ;;
 fedora)
     PACKAGES="gcc make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel
-              sqlite-devel luajit luajit-devel lua-filesystem lua-socket
+              sqlite-devel luajit luajit-devel lua-filesystem lua-socket lua-sec
               gstreamer1-devel gstreamer1-plugins-base-devel
               gstreamer1-plugins-bad-free xorg-x11-server-Xvfb"
     INSTALL="sudo dnf install -y"
@@ -62,7 +62,7 @@ REFRESH="sudo dnf makecache"     ;;
 arch)
     PACKAGES="base-devel pkgconf gtk3 webkit2gtk-4.1 sqlite luajit
-              lua51-filesystem lua51-socket lua51-luassert luacheck
+              lua51-filesystem lua51-socket lua51-sec lua51-luassert luacheck
               gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-libav
               xorg-server-xvfb"
     INSTALL="sudo pacman -S --needed --noconfirm"
@@ -114,11 +114,11 @@ fi 
 say "Lua modules"
 if [ -n "$LUA_BIN" ]; then
-    for mod in lfs socket; do
+    for mod in lfs socket ssl; do
         if "$LUA_BIN" -e "require('$mod')" >/dev/null 2>&1; then
             printf '  ok      %s\n' "$mod"
         else
-            printf '  MISSING %-8s (lfs is required; socket drives gopher)\n' "$mod"
+            printf '  MISSING %-8s (lfs is required; socket+ssl drive gopher and gemini)\n' "$mod"
             missing=1
         fi
     done
@@ -114,6 +114,10 @@ -- Add gopher protocol support (this module needs luasocket)
 local gopher = require "gopher"
 
+-- Gemini and the shared small web layer (page/terminal render, TOFU)
+local gemini = require "gemini"
+local smallweb_chrome = require "smallweb_chrome"
+
 -- Add greasemonkey-like javascript userscript support
 local userscripts = require "userscripts"
 
@@ -1,100 +1,119 @@---------------------------
--- Default luakit theme --
---------------------------
+-------------------------------
+-- Skull Browser theme       --
+-------------------------------
+--
+-- Names cascade: tab_selected_fg falls back to selected_fg, then to fg. Change
+-- the handful of keys under "Palette" and the rest of the interface follows.
 
 local theme = {}
 
+-- Palette
+local bg        = "#0b0d11"  -- window and status bar
+local bg_raised = "#11151b"  -- tab strip
+local bg_sunken = "#070910"  -- inactive surfaces
+local line      = "#1e242e"
+
+local fg        = "#e9e5d9"  -- bone
+local fg_dim    = "#98a0ae"
+local fg_faint  = "#5d6470"
+
+local phos      = "#3df07a"  -- phosphor green, the accent
+local cyan      = "#35d6c3"
+local amber     = "#e8b23a"
+local blood     = "#e05260"
+
 -- Default settings
-theme.font = "12px monospace"
-theme.fg   = "#fff"
-theme.bg   = "#000"
+theme.font = "11px monospace"
+theme.fg   = fg
+theme.bg   = bg
 
 -- General colours
-theme.success_fg = "#0f0"
-theme.loaded_fg  = "#33AADD"
-theme.error_fg = "#FFF"
-theme.error_bg = "#F00"
+theme.success_fg = phos
+theme.loaded_fg  = cyan
+theme.error_fg   = bg
+theme.error_bg   = blood
 
 -- Warning colours
-theme.warning_fg = "#F00"
-theme.warning_bg = "#FFF"
+theme.warning_fg = bg
+theme.warning_bg = amber
 
 -- Notification colours
-theme.notif_fg = "#444"
-theme.notif_bg = "#FFF"
+theme.notif_fg = phos
+theme.notif_bg = bg_raised
 
 -- Menu colours
-theme.menu_fg                   = "#000"
-theme.menu_bg                   = "#fff"
-theme.menu_selected_fg          = "#000"
-theme.menu_selected_bg          = "#FF0"
-theme.menu_title_bg             = "#fff"
-theme.menu_primary_title_fg     = "#f00"
-theme.menu_secondary_title_fg   = "#666"
+theme.menu_fg                   = fg
+theme.menu_bg                   = bg_raised
+theme.menu_selected_fg          = bg
+theme.menu_selected_bg          = phos
+theme.menu_title_bg             = bg_sunken
+theme.menu_primary_title_fg     = phos
+theme.menu_secondary_title_fg   = fg_faint
 
-theme.menu_disabled_fg = "#999"
+theme.menu_disabled_fg = fg_faint
 theme.menu_disabled_bg = theme.menu_bg
-theme.menu_enabled_fg = theme.menu_fg
-theme.menu_enabled_bg = theme.menu_bg
-theme.menu_active_fg = "#060"
-theme.menu_active_bg = theme.menu_bg
+theme.menu_enabled_fg  = theme.menu_fg
+theme.menu_enabled_bg  = theme.menu_bg
+theme.menu_active_fg   = cyan
+theme.menu_active_bg   = theme.menu_bg
 
 -- Proxy manager
-theme.proxy_active_menu_fg      = '#000'
-theme.proxy_active_menu_bg      = '#FFF'
-theme.proxy_inactive_menu_fg    = '#888'
-theme.proxy_inactive_menu_bg    = '#FFF'
+theme.proxy_active_menu_fg      = phos
+theme.proxy_active_menu_bg      = bg_raised
+theme.proxy_inactive_menu_fg    = fg_faint
+theme.proxy_inactive_menu_bg    = bg_raised
 
 -- Statusbar specific
-theme.sbar_fg         = "#fff"
-theme.sbar_bg         = "#000"
+theme.sbar_fg         = fg_dim
+theme.sbar_bg         = bg
 
 -- Downloadbar specific
-theme.dbar_fg         = "#fff"
-theme.dbar_bg         = "#000"
-theme.dbar_error_fg   = "#F00"
+theme.dbar_fg         = fg_dim
+theme.dbar_bg         = bg
+theme.dbar_error_fg   = blood
 
 -- Input bar specific
-theme.ibar_fg           = "#000"
+theme.ibar_fg           = fg
 theme.ibar_bg           = "rgba(0,0,0,0)"
 
 -- Tab label
-theme.tab_fg            = "#888"
-theme.tab_bg            = "#222"
-theme.tab_hover_bg      = "#292929"
-theme.tab_ntheme        = "#ddd"
-theme.selected_fg       = "#fff"
-theme.selected_bg       = "#000"
-theme.selected_ntheme   = "#ddd"
-theme.loading_fg        = "#33AADD"
-theme.loading_bg        = "#000"
+theme.tab_fg            = fg_faint
+theme.tab_bg            = bg_raised
+theme.tab_hover_bg      = line
+theme.tab_ntheme        = fg_dim
+theme.selected_fg       = fg
+theme.selected_bg       = bg
+theme.selected_ntheme   = phos
+theme.loading_fg        = cyan
+theme.loading_bg        = bg
 
-theme.selected_private_tab_bg = "#3d295b"
-theme.private_tab_bg    = "#22254a"
+-- Private tabs read as a different mode, not a different shade of the same one
+theme.selected_private_tab_bg = "#2a2340"
+theme.private_tab_bg          = "#1c1830"
 
--- Trusted/untrusted ssl colours
-theme.trust_fg          = "#0F0"
-theme.notrust_fg        = "#F00"
+-- Trusted, excused and untrusted connections
+theme.trust_fg          = phos
+theme.notrust_fg        = blood
 
 -- Follow mode hints
-theme.hint_font = "10px monospace, courier, sans-serif"
-theme.hint_fg = "#fff"
-theme.hint_bg = "#000088"
-theme.hint_border = "1px dashed #000"
-theme.hint_opacity = "0.3"
-theme.hint_overlay_bg = "rgba(255,255,153,0.3)"
-theme.hint_overlay_border = "1px dotted #000"
-theme.hint_overlay_selected_bg = "rgba(0,255,0,0.3)"
-theme.hint_overlay_selected_border = theme.hint_overlay_border
+theme.hint_font                    = "10px monospace"
+theme.hint_fg                      = bg
+theme.hint_bg                      = phos
+theme.hint_border                  = "1px solid " .. bg
+theme.hint_opacity                 = "0.92"
+theme.hint_overlay_bg              = "rgba(61,240,122,0.16)"
+theme.hint_overlay_border          = "1px solid rgba(61,240,122,0.5)"
+theme.hint_overlay_selected_bg     = "rgba(53,214,195,0.3)"
+theme.hint_overlay_selected_border = "1px solid " .. cyan
 
 -- General colour pairings
-theme.ok = { fg = "#000", bg = "#FFF" }
-theme.warn = { fg = "#F00", bg = "#FFF" }
-theme.error = { fg = "#FFF", bg = "#F00" }
+theme.ok    = { fg = fg,    bg = bg_raised }
+theme.warn  = { fg = bg,    bg = amber }
+theme.error = { fg = bg,    bg = blood }
 
--- Gopher page style (override defaults)
-theme.gopher_light = { bg = "#E8E8E8", fg = "#17181C", link = "#03678D" }
-theme.gopher_dark  = { bg = "#17181C", fg = "#E8E8E8", link = "#f90" }
+-- Small web page style. gopher:// and gemini:// share it.
+theme.gopher_light = { bg = "#f4f2ec", fg = "#15181d", link = "#0d7d71" }
+theme.gopher_dark  = { bg = bg,        fg = fg,        link = cyan }
 
 return theme
 
@@ -142,11 +142,11 @@ header > span {         padding: 1em;
     }
     .state_Enabled {
-        color: #799D6A;
+        color: var(--phos);
         font-weight: bold;
     }
     .state_Disabled {
-        color: #CF6A4C;
+        color: var(--blood);
         font-weight: bold;
     }
 ]===]
@@ -245,7 +245,7 @@ -- @type string -- @readwrite
 _M.navigation_blocked_css_tmpl = [===[
     body {
-        background-color: #ddd;
+        background-color: var(--line);
         margin: 0;
         padding: 0;
         display: flex;
@@ -254,11 +254,11 @@ justify-content: center;     }
 
     #errorContainer {
-        background: #fff;
+        background: var(--bg-raised);
         min-width: 35em;
         max-width: 35em;
         padding: 2.5em;
-        border: 2px solid #aaa;
+        border: 2px solid var(--fg-faint);
         -webkit-border-radius: 5px;
     }
 
@@ -39,7 +39,7 @@         header {
             padding: 0.5em 0 0.5em 0;
             margin: 2em 0 0.5em 0;
-            border-bottom: 1px solid #888;
+            border-bottom: 1px solid var(--fg-faint);
         }
 
         h1 {
@@ -53,7 +53,7 @@ h3.mode-name {             color: black;
             margin-bottom: 1.0em;
             line-height: 1.4em;
-            border-bottom: 1px solid #888;
+            border-bottom: 1px solid var(--fg-faint);
         }
 
         h1, h2, h3, h4 {
@@ -93,7 +93,7 @@ padding: 5px;         }
 
         .bind:hover {
-            background-color: #f8f8f8;
+            background-color: var(--bg-raised);
             -webkit-border-radius: 0.5em;
         }
 
@@ -105,19 +105,19 @@ text-decoration: none;         }
 
         .bind .link-box a {
-            color: #11c;
+            color: var(--cyan);
             text-decoration: none;
         }
 
         .bind .link-box a:hover {
-            color: #11c;
+            color: var(--cyan);
             text-decoration: underline;
         }
 
         .bind .key {
             font-family: monospace, sans-serif;
             float: left;
-            color: #2E4483;
+            color: var(--cyan);
             font-weight: bold;
             font-size: 0.8em;
         }
@@ -136,19 +136,19 @@ margin-bottom: 0;         }
 
         .bind code {
-            color: #2525ff;
+            color: var(--cyan);
             display: inline-block;
         }
 
         .bind pre {
             padding: 1rem 1.2rem;
-            border-left: 2px solid #69c;
-            background: #f5f7f9;
-            color: #334;
+            border-left: 2px solid var(--cyan);
+            background: var(--bg-sunken);
+            color: var(--fg-dim);
         }
 
         .bind pre code {
-            color: #000;
+            color: var(--fg);
         }
 
         .mode h4 {
@@ -166,7 +166,7 @@ border: none;         }
 
         .bind_type_any .key {
-            color: #888;
+            color: var(--fg-faint);
             float: left;
         }
 
@@ -39,7 +39,7 @@ margin: 0.4em 0;     padding: 0;
     left: 0;
     right: 0;
-    border: 1px solid #fff;
+    border: 1px solid var(--bg-raised);
     border-radius: 0.3em;
 }
 .bookmark:first-child { margin-top: 1em; }
@@ -69,7 +69,7 @@ display: none; }
 
 .bookmark .uri {
-    color: #aaa;
+    color: var(--fg-faint);
 }
 
 .bookmark .bottom {
@@ -87,21 +87,21 @@ cursor: pointer; }
 
 .bookmark .tags a {
-    color: #666;
-    background-color: #f6f6f6;
+    color: var(--fg-dim);
+    background-color: var(--bg-sunken);
     padding: 0.1em 0.4em;
     margin: 0 0.3em;
     -webkit-border-radius: 0.2em;
-    -webkit-box-shadow: 0 0.1em 0.1em #666;
+    -webkit-box-shadow: 0 0.1em 0.1em var(--fg-dim);
 }
 
 .bookmark .tags a:hover {
-    color: #111;
+    color: var(--fg);
 }
 
 .bookmark .desc {
-    color: #222;
-    border-left: 0.3em solid #ddd;
+    color: var(--fg);
+    border-left: 0.3em solid var(--line);
     margin: 0 0 0.2em 0.5em;
     padding: 0 0 0 0.5em;
     max-width: 60em;
@@ -121,19 +121,19 @@ margin-bottom: 0; }
 
 .bookmark .controls a {
-    color: #888;
+    color: var(--fg-faint);
     padding: 0.1em 0.4em;
     margin: 0 0;
 }
 
 .bookmark .controls a:hover {
-    background-color: #fff;
+    background-color: var(--bg-raised);
     -webkit-border-radius: 0.2em;
-    -webkit-box-shadow: 0 0.1em 0.1em #666;
+    -webkit-box-shadow: 0 0.1em 0.1em var(--fg-dim);
 }
 
 .bookmark .date {
-    color: #444;
+    color: var(--fg-dim);
     margin-right: 0.2em;
 }
 
@@ -148,7 +148,7 @@ right: 0;     top: 0;
     bottom: 0;
     opacity: 0.5;
-    background-color: #000;
+    background-color: var(--fg);
     z-index: 100;
 }
 
@@ -164,7 +164,7 @@ margin-bottom: 6em;     padding: 1em;
     width: 36.6em;
 
-    background-color: #eee;
+    background-color: var(--line);
     border-radius: 0.3em;
     box-shadow: 0 0.5em 2em rgba(0, 0, 0, 0.3);
 }
@@ -185,9 +185,9 @@ border: none;     outline: none;
     margin: 0;
     padding: 0;
-    background-color: #fff;
+    background-color: var(--bg-raised);
     border-radius: 0.25em;
-    box-shadow: 0 0.1em 0.1em #888;
+    box-shadow: 0 0.1em 0.1em var(--fg-faint);
 }
 
 #edit-dialog input[type="text"], #edit-dialog textarea {
@@ -198,7 +198,7 @@ #edit-dialog input[type="button"], #edit-dialog input[type="submit"] {
     padding: 0.5em 1em;
     margin-right: 0.5em;
-    color: #444;
+    color: var(--fg-dim);
 }
 
 #edit-dialog textarea {
@@ -215,7 +215,7 @@ } 
 .nav-button-box a {
     display: none;
-    border: 1px solid #aaa;
+    border: 1px solid var(--fg-faint);
     padding: 0.4em 1em;
 }
 ]===]
@@ -27,12 +27,12 @@ .header {         display: flex;
         align-items: baseline;
         justify-content: space-between;
-        border-bottom: 1px solid #d8d8d8;
+        border-bottom: 1px solid var(--line);
         padding-bottom: 0.6em;
         margin-bottom: 1.4em;
     }
     .header h1 { margin: 0; font-size: 1.4em; }
-    .header .count { color: #888; font-size: 0.9em; }
+    .header .count { color: var(--fg-faint); font-size: 0.9em; }
 
     .note {
         background: #fff8e1;
@@ -47,7 +47,7 @@ table { border-collapse: collapse; width: 100%; }     th {
         text-align: left;
         font-weight: normal;
-        color: #888;
+        color: var(--fg-faint);
         font-size: 0.82em;
         text-transform: uppercase;
         letter-spacing: 0.06em;
@@ -59,7 +59,7 @@ border-top: 1px solid #ececec;         vertical-align: baseline;
     }
     td.host { font-family: monospace; font-size: 1em; }
-    td.when { color: #666; white-space: nowrap; }
+    td.when { color: var(--fg-dim); white-space: nowrap; }
     td.act { text-align: right; width: 1%; }
 
     .revoke {
@@ -70,7 +70,7 @@ border-bottom: 1px solid transparent;     }
     .revoke:hover { border-bottom-color: #b00020; }
 
-    .empty { color: #888; padding: 2em 0; }
+    .empty { color: var(--fg-faint); padding: 2em 0; }
 ]===]
 
 local html_template = [==[
@@ -21,121 +21,208 @@ -- for a consitent looking theme. -- @type string
 -- @readwrite
 _M.stylesheet = [===[
-    * {
-        box-sizing: border-box;
+    /* The identity lives here. Every skull:// page pulls this sheet in first,
+       so changing a token below moves all of them at once. */
+    :root {
+        --bg:        #0b0d11;
+        --bg-raised: #11151b;
+        --bg-sunken: #070910;
+        --line:      #1e242e;
+        --line-soft: #161b23;
+
+        --fg:        #e9e5d9;
+        --fg-dim:    #98a0ae;
+        --fg-faint:  #5d6470;
+
+        --phos:      #3df07a;
+        --phos-dim:  #1f8f49;
+        --cyan:      #35d6c3;
+        --amber:     #e8b23a;
+        --blood:     #e05260;
+
+        --radius:    3px;
+        --mono:      ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
+        --pad:       1.5rem;
+        --header-h:  3.25rem;
     }
+
+    @media (prefers-color-scheme: light) {
+        :root:not([data-theme="dark"]) {
+            --bg:        #f4f2ec;
+            --bg-raised: #ffffff;
+            --bg-sunken: #e7e4db;
+            --line:      #d6d2c6;
+            --line-soft: #e3dfd4;
+
+            --fg:        #15181d;
+            --fg-dim:    #4c535e;
+            --fg-faint:  #848b96;
+
+            --phos:      #0f7a3c;
+            --phos-dim:  #16a64f;
+            --cyan:      #0d7d71;
+            --amber:     #9a6d05;
+            --blood:     #b3202f;
+        }
+    }
+
+    * { box-sizing: border-box; }
+
     body {
-        background-color: white;
-        color: #222;
+        background-color: var(--bg);
+        color: var(--fg);
         display: block;
         margin: 0;
         padding: 0;
-        font-family: sans-serif;
+        font-family: var(--mono);
+        font-size: 14px;
+        line-height: 1.55;
+        -webkit-font-smoothing: antialiased;
     }
+
+    a { color: var(--cyan); text-decoration: none; }
+    a:hover { color: var(--phos); }
+
+    code, pre { font-family: var(--mono); }
+
+    ::selection { background: var(--phos-dim); color: var(--bg); }
+
     #page-header {
         display: flex;
         -webkit-align-items: center;
-        background-color: #eee;
+        background-color: var(--bg-raised);
         position: fixed;
         top: 0;
         left: 0;
         width: 100%;
         margin: 0;
-        padding: 0 1.5em;
-        height: 3.5em;
-        border-bottom: 1px solid #ddd;
+        padding: 0 var(--pad);
+        height: var(--header-h);
+        border-bottom: 1px solid var(--line);
         -webkit-user-select: none;
         overflow-y: hidden;
         z-index: 100000;
     }
     #page-header > h1 {
-        font-size: 1.4rem;
-        margin: 0 1em;
-        color: #445;
+        font-size: 0.85rem;
+        font-weight: 600;
+        letter-spacing: 0.18em;
+        text-transform: uppercase;
+        margin: 0 1.5em 0 0;
+        color: var(--phos);
         cursor: default;
+        white-space: nowrap;
     }
-    #page-header > h1:first-child {
-        margin-left: 0;
+    /* The bar the title sits on, so the header reads as a console strip. */
+    #page-header > h1::before {
+        content: "";
+        display: inline-block;
+        width: 3px;
+        height: 0.9em;
+        margin-right: 0.7em;
+        background: var(--phos);
+        vertical-align: -0.05em;
     }
+    #page-header > h1:first-child { margin-left: 0; }
+
     .content-margin {
-        padding: 3.5em 1.5em 0 1.5em;
+        padding: calc(var(--header-h) + 1.5rem) var(--pad) 3rem var(--pad);
+        max-width: 68rem;
+    }
+
+    h2 {
+        font-size: 0.95rem;
+        font-weight: 600;
+        letter-spacing: 0.08em;
+        text-transform: uppercase;
+        color: var(--fg);
+    }
+    h3 {
+        font-size: 0.85rem;
+        font-weight: 600;
+        letter-spacing: 0.06em;
+        color: var(--fg-dim);
     }
-    h2 { font-size: 1.2rem; }
-    h3 { font-size: 1.1rem; color: #666; }
 
     #page-header input {
-        font-size: 0.8rem;
-        padding: 0.5rem 0.75rem;
+        font-family: var(--mono);
+        font-size: 0.82rem;
+        padding: 0.45rem 0.7rem;
         border: none;
         outline: none;
-        margin-top: 0;
-        margin-bottom: 0;
-        background-color: #fff;
+        margin: 0;
+        color: var(--fg);
+        background-color: var(--bg-sunken);
     }
+    #page-header input::placeholder { color: var(--fg-faint); }
 
     #page-header #search-box {
         display: flex;
         padding: 0;
-        background-color: #fff;
-        border-radius: 0.25rem;
-        box-shadow: 0 1px 1px #888;
+        background-color: var(--bg-sunken);
+        border: 1px solid var(--line);
+        border-radius: var(--radius);
     }
+    #page-header #search-box:focus-within { border-color: var(--phos-dim); }
 
     #page-header #search {
         width: 20em;
         font-weight: normal;
-        border-radius: 0.25rem 0 0 0.25rem;
+        border-radius: var(--radius) 0 0 var(--radius);
         margin: 0;
         padding-right: 0;
+        background: transparent;
     }
 
     #page-header #clear-button {
         margin: 0;
-        padding: 0.5rem 0.55rem;
-        border-radius: 0 0.25rem 0.25rem 0;
+        padding: 0.45rem 0.6rem;
+        border-radius: 0 var(--radius) var(--radius) 0;
         box-shadow: none;
-        font-size: 1rem;
+        font-size: 0.9rem;
         line-height: 1rem;
-    }
-
-    #page-header #clear-button:hover {
-        color: #000;
-    }
-
-    #page-header #clear-button:active {
-        background-color: #eee;
+        background: transparent;
+        border: none;
+        color: var(--fg-faint);
     }
+    #page-header #clear-button:hover { color: var(--blood); }
+    #page-header #clear-button:active { background-color: var(--line-soft); }
 
     .button {
-        box-shadow: 0 1px 1px #888;
-        margin: 1rem 0 1rem 0.5rem;
-        border-radius: 0.25em;
-        color: #888;
+        font-family: var(--mono);
+        font-size: 0.8rem;
+        letter-spacing: 0.04em;
+        margin: 0.75rem 0 0.75rem 0.5rem;
+        color: var(--fg-dim);
+        background-color: transparent;
         display: inline-block;
         line-height: 1.25;
         text-align: center;
         white-space: nowrap;
         vertical-align: middle;
         -webkit-user-select: none;
-        border: 1px solid transparent;
-        padding: .5rem 1rem;
-        font-size: 1rem;
-        border-radius: .25rem;
-        transition: color .1s ease-in-out, background-color .1s ease-in-out;
+        border: 1px solid var(--line);
+        padding: 0.45rem 0.9rem;
+        border-radius: var(--radius);
+        transition: color 80ms linear, border-color 80ms linear,
+                    background-color 80ms linear;
         cursor: pointer;
     }
 
     #page-header .button:hover, .button:hover {
-        color: #000;
+        color: var(--phos);
+        border-color: var(--phos-dim);
+        background-color: var(--line-soft);
     }
 
     #page-header .button:active, .button:active {
-        background-color: #eee;
+        background-color: var(--bg-sunken);
     }
 
     #page-header .button[disabled], .button[disabled] {
-        color: #888;
-        background-color: #eee;
+        color: var(--fg-faint);
+        border-color: var(--line-soft);
+        background-color: transparent;
         cursor: not-allowed;
     }
 
@@ -146,24 +233,16 @@ position: fixed;         top: 0;
         right: 0;
         margin: 0;
-        padding-right: 1.5em;
-        height: 3.5em;
-        margin: 0;
+        padding-right: var(--pad);
+        height: var(--header-h);
         background-color: inherit;
-        box-shadow: -1em 0 1em #eee;
     }
 
-    #page-header .rhs .button {
-        margin-bottom: 0;
-    }
+    #page-header .rhs .button { margin-bottom: 0; }
 
-    .license {
-        font-family: monospace;
-    }
+    .license { font-family: var(--mono); }
 
-    .hidden {
-        display: none;
-    }
+    .hidden { display: none; }
 ]===]
 
 -- skull:// page handlers
@@ -56,22 +56,22 @@ left: -90px;         width: 90px;
         position: absolute;
         display: block;
-        color: #888;
+        color: var(--fg-faint);
     }
 
     .download .title a {
-        color: #3F6EC2;
+        color: var(--cyan);
         padding-right: 16px;
     }
 
     .download .status {
         display: inline;
-        color: #999;
+        color: var(--fg-faint);
         white-space: nowrap;
     }
 
     .download .uri a {
-        color: #56D;
+        color: var(--cyan);
         text-overflow: ellipsis;
         display: inline-block;
         white-space: nowrap;
@@ -81,7 +81,7 @@ max-width: 500px;     }
 
     .download .controls a {
-        color: #777;
+        color: var(--fg-faint);
         margin-right: 16px;
     }
 ]==]
@@ -0,0 +1,507 @@+--- Add gemini:// scheme support.
+--
+-- Gemini is a small protocol: open TLS, send one line holding the URL, read
+-- back one status line and a body. There is no compression, no cookies, no
+-- scripting and no certificate authorities.
+--
+-- Trust works by trust on first use, handled by `smallweb.tofu`: the
+-- certificate a host presents the first time is remembered, and a different
+-- one later is reported rather than accepted.
+--
+-- Needs LuaSec, the `ssl` module. Without it the scheme is registered anyway
+-- and every request explains what is missing, rather than failing silently.
+--
+-- @module gemini
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local smallweb = require("smallweb")
+local tofu = require("smallweb.tofu")
+
+local socket_loaded, socket = pcall(require, "socket")
+local ssl_loaded, ssl = pcall(require, "ssl")
+
+local error_page = require("error_page")
+local webview = require("webview")
+
+local _M = {}
+
+luakit.register_scheme("gemini")
+
+--- Default port for gemini, fixed by the specification.
+-- @type number
+-- @readwrite
+_M.default_port = 1965
+
+--- How many redirects to follow before giving up.
+-- @type number
+-- @readwrite
+_M.max_redirects = 5
+
+--- How long to wait on a host, in seconds.
+-- @type number
+-- @readwrite
+_M.timeout = 30
+
+-- ---------------------------------------------------------------------------
+-- URL handling
+-- ---------------------------------------------------------------------------
+
+--- Parse a gemini URL.
+-- @tparam string url The URL to parse.
+-- @treturn table Fields `host`, `port`, `path`, `query` and `title`.
+_M.parse_url = function (url)
+    assert(type(url) == "string", "expected a url string")
+
+    local rest = url:match("^[Gg][Ee][Mm][Ii][Nn][Ii]://(.*)$")
+    if not rest then error("not a gemini url: " .. url, 0) end
+
+    local authority, path = rest:match("^([^/]*)(.*)$")
+    if not authority or authority == "" then
+        error("gemini url has no host: " .. url, 0)
+    end
+
+    local query
+    path, query = (path or ""):match("^([^?]*)%??(.*)$")
+    if path == "" then path = "/" end
+
+    local host, port = authority:match("^(.-):(%d+)$")
+    host = host or authority
+    port = tonumber(port) or _M.default_port
+
+    -- The host reaches a TLS handshake and a fingerprint store, so keep it to
+    -- what a host name can actually contain.
+    host = host:gsub("[^%w%.%-]", "")
+    if host == "" then error("gemini url has no usable host: " .. url, 0) end
+
+    return {
+        host = host,
+        port = port,
+        path = path,
+        query = query ~= "" and query or nil,
+        title = authority .. path,
+    }
+end
+
+local function url_string (url)
+    local port = url.port ~= _M.default_port and (":" .. url.port) or ""
+    return "gemini://" .. url.host .. port .. url.path
+        .. (url.query and ("?" .. url.query) or "")
+end
+
+--- Resolve a link target against the page it was found on.
+-- @tparam string target The link target, absolute or relative.
+-- @tparam table base The parsed url of the page holding the link.
+-- @treturn string An absolute url.
+_M.resolve = function (target, base)
+    if target:match("^%a[%w+.-]*:") then return target end
+
+    local port = base.port ~= _M.default_port and (":" .. base.port) or ""
+    local root = "gemini://" .. base.host .. port
+
+    if target:sub(1, 2) == "//" then return "gemini:" .. target end
+    if target:sub(1, 1) == "/" then return root .. target end
+
+    local dir = base.path:match("^(.*/)") or "/"
+    return root .. dir .. target
+end
+
+-- ---------------------------------------------------------------------------
+-- gemtext
+-- ---------------------------------------------------------------------------
+
+--- Render gemtext as HTML.
+--
+-- The whole format is six line types: links, three heading levels, list items,
+-- quotes, and a fence that toggles preformatted blocks.
+--
+-- @tparam string text The gemtext body.
+-- @tparam table base The parsed url it came from, for resolving links.
+-- @treturn string Markup for the document body.
+_M.gemtext_to_html = function (text, base)
+    local esc = smallweb.escape
+    local out, pre, list = {}, false, false
+
+    local function close_list ()
+        if list then out[#out+1] = "</ul>" list = false end
+    end
+
+    for line in (text .. "\n"):gmatch("(.-)\r?\n") do
+        if line:sub(1, 3) == "```" then
+            close_list()
+            if pre then
+                out[#out+1] = "</pre>"
+            else
+                local alt = line:sub(4)
+                out[#out+1] = alt ~= ""
+                    and ('<pre aria-label="' .. esc(alt) .. '">') or "<pre>"
+            end
+            pre = not pre
+        elseif pre then
+            out[#out+1] = esc(line)
+        elseif line:sub(1, 2) == "=>" then
+            close_list()
+            local target, label = line:match("^=>%s*(%S+)%s*(.-)$")
+            if target then
+                local href = _M.resolve(target, base)
+                out[#out+1] = '<div class="menu"><a href="' .. esc(href)
+                    .. '">' .. esc(label ~= "" and label or target) .. "</a></div>"
+            end
+        elseif line:sub(1, 1) == "#" then
+            close_list()
+            local hashes, body = line:match("^(#+)%s*(.*)$")
+            local level = math.min(#hashes, 3)
+            out[#out+1] = "<h" .. level .. ' class="heading">' .. esc(body)
+                .. "</h" .. level .. ">"
+        elseif line:sub(1, 2) == "* " then
+            if not list then out[#out+1] = "<ul>" list = true end
+            out[#out+1] = "<li>" .. esc(line:sub(3)) .. "</li>"
+        elseif line:sub(1, 1) == ">" then
+            close_list()
+            out[#out+1] = '<p class="quote">' .. esc((line:sub(2):gsub("^%s", "")))
+                .. "</p>"
+        elseif line == "" then
+            close_list()
+            out[#out+1] = "<br>"
+        else
+            close_list()
+            out[#out+1] = "<p>" .. esc(line) .. "</p>"
+        end
+    end
+
+    if pre then out[#out+1] = "</pre>" end
+    close_list()
+
+    return table.concat(out, "\n")
+end
+
+-- ---------------------------------------------------------------------------
+-- Transport
+-- ---------------------------------------------------------------------------
+
+local tls_params = {
+    mode = "client",
+    protocol = "any",
+    -- Verification is ours, not OpenSSL's: gemini has no certificate
+    -- authorities, so a chain to a known root is not the question. What
+    -- matters is whether the same certificate came back, which tofu answers.
+    verify = "none",
+    options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
+}
+
+local function fingerprint_of (conn)
+    local cert = conn.getpeercertificate and conn:getpeercertificate()
+    if not cert then error("the server sent no certificate", 0) end
+    local digest = cert:digest("sha256")
+    if not digest or digest == "" then
+        error("could not fingerprint the server certificate", 0)
+    end
+    return (digest:upper():gsub("[^%x]", ""))
+end
+
+-- One transaction, written so it can be driven a step at a time from a timer
+-- rather than blocking the interface.
+local function request (url)
+    local sock = socket.tcp()
+    sock:settimeout(0)
+
+    local deadline = os.time() + _M.timeout
+    local function expired ()
+        if os.time() > deadline then
+            error("timed out talking to " .. url.host, 0)
+        end
+    end
+
+    local ok, err = sock:connect(url.host, url.port)
+    if not ok and err ~= "timeout" and err ~= "Operation already in progress" then
+        error("cannot reach " .. url.host .. ": " .. tostring(err), 0)
+    end
+    while not ok do
+        if coroutine.yield() then sock:close() return end
+        expired()
+        local _, ready, serr = socket.select(nil, {sock}, 0)
+        if (ready or {})[sock] then break end
+        if serr and serr ~= "timeout" then
+            error("cannot reach " .. url.host .. ": " .. tostring(serr), 0)
+        end
+    end
+
+    local params = {}
+    for k, v in pairs(tls_params) do params[k] = v end
+    -- Without SNI a shared host cannot tell which certificate to send.
+    params.sni = url.host
+
+    local conn
+    conn, err = ssl.wrap(sock, params)
+    if not conn then
+        sock:close()
+        error("cannot start TLS: " .. tostring(err), 0)
+    end
+    conn:settimeout(0)
+    if conn.sni then pcall(conn.sni, conn, url.host) end
+
+    while true do
+        local done, herr = conn:dohandshake()
+        if done then break end
+        if herr ~= "wantread" and herr ~= "wantwrite" and herr ~= "timeout" then
+            conn:close()
+            error("TLS handshake failed: " .. tostring(herr), 0)
+        end
+        if coroutine.yield() then conn:close() return end
+        expired()
+    end
+
+    local status, stored = tofu.check(
+        url.host .. ":" .. url.port, fingerprint_of(conn))
+    if status == "changed" then
+        conn:close()
+        error("CERTIFICATE_CHANGED\t" .. tostring(stored), 0)
+    end
+
+    local message = url_string(url) .. "\r\n"
+    local sent = 0
+    while sent < #message do
+        local n, serr, last = conn:send(message, sent + 1)
+        if n then
+            sent = n
+        elseif serr == "wantwrite" or serr == "wantread" or serr == "timeout" then
+            sent = last or sent
+        else
+            conn:close()
+            error("could not send the request: " .. tostring(serr), 0)
+        end
+        if sent < #message then
+            if coroutine.yield() then conn:close() return end
+            expired()
+        end
+    end
+
+    local chunks = {}
+    while true do
+        local data, rerr, partial = conn:receive("*a")
+        if data then chunks[#chunks+1] = data break end
+        if rerr == "closed" then chunks[#chunks+1] = partial or "" break end
+        if rerr ~= "wantread" and rerr ~= "wantwrite" and rerr ~= "timeout" then
+            conn:close()
+            error("could not read the reply: " .. tostring(rerr), 0)
+        end
+        if partial and partial ~= "" then chunks[#chunks+1] = partial end
+        if coroutine.yield() then conn:close() return end
+        expired()
+    end
+
+    conn:close()
+
+    local body = table.concat(chunks)
+    local header, rest = body:match("^(.-)\r?\n(.*)$")
+    if not header then error("the reply had no status line", 0) end
+
+    local code, meta = header:match("^(%d%d)%s*(.*)$")
+    if not code then
+        error("the status line made no sense: " .. header:sub(1, 60), 0)
+    end
+
+    return { code = code, meta = meta or "", body = rest or "" }
+end
+
+-- ---------------------------------------------------------------------------
+-- Presentation
+-- ---------------------------------------------------------------------------
+
+local status_text = {
+    ["10"] = "The server is asking for input.",
+    ["11"] = "The server is asking for input, and will not echo it.",
+    ["40"] = "The server is temporarily unavailable.",
+    ["41"] = "The server is unavailable.",
+    ["42"] = "A CGI process on the server failed.",
+    ["43"] = "A proxy the server relies on failed.",
+    ["44"] = "The server is asking you to slow down.",
+    ["50"] = "The request was refused.",
+    ["51"] = "Not found.",
+    ["52"] = "Gone. The resource will not come back.",
+    ["53"] = "This server does not proxy for that host.",
+    ["59"] = "The server did not understand the request.",
+    ["60"] = "A client certificate is required, which Skull does not send yet.",
+    ["61"] = "The client certificate was not authorised.",
+    ["62"] = "The client certificate was not valid.",
+}
+
+local function input_page (url, meta, secret)
+    local prompt = smallweb.escape(meta ~= "" and meta or "Input")
+    local target = url_string({ host = url.host, port = url.port, path = url.path })
+    local body = table.concat({
+        '<h1 class="heading">', prompt, "</h1>",
+        '<form onsubmit="return go()">',
+        '<input id="q" type="', secret and "password" or "text",
+        '" size="48" autofocus>',
+        "</form>",
+        "<script>function go(){",
+        "var v=encodeURIComponent(document.getElementById('q').value);",
+        "window.location=", string.format("%q", target), "+'?'+v; return false;}",
+        "</script>",
+    })
+    return smallweb.document(url.title, body)
+end
+
+local function body_to_page (reply, url)
+    local mime = reply.meta:match("^([^;]*)") or "text/gemini"
+    mime = mime:gsub("%s", "")
+    if mime == "" then mime = "text/gemini" end
+
+    if mime == "text/gemini" then
+        return smallweb.document(url.title,
+            _M.gemtext_to_html(reply.body, url)), "text/html"
+    end
+
+    if mime:match("^text/") then
+        return smallweb.document(url.title,
+            "<pre>" .. smallweb.escape(reply.body) .. "</pre>"), "text/html"
+    end
+
+    -- Images and everything else go to WebKit untouched.
+    return reply.body, mime
+end
+
+-- ---------------------------------------------------------------------------
+-- Wiring
+-- ---------------------------------------------------------------------------
+
+local load_timer = timer{ interval = 50 }
+local loads = {}
+
+load_timer:add_signal("timeout", function ()
+    if not next(loads) then load_timer:stop() end
+end)
+
+local function remove_loader (v, loader)
+    local co = loads[v]
+    if co then
+        loads[v] = nil
+        coroutine.resume(co, "stop")
+    end
+    if loader then load_timer:remove_signal("timeout", loader) end
+end
+
+local function show_error (v, request_obj, heading, reason, buttons)
+    pcall(error_page.show_error_page, v, {
+        heading = heading,
+        content = smallweb.escape(reason),
+        buttons = buttons,
+        request = request_obj,
+    })
+end
+
+local function handle_reply (v, request_obj, reply, url)
+    local class = reply.code:sub(1, 1)
+
+    if class == "2" then
+        local html, mime = body_to_page(reply, url)
+        request_obj:finish(html, mime)
+        return
+    end
+
+    if class == "1" then
+        request_obj:finish(input_page(url, reply.meta, reply.code == "11"),
+            "text/html")
+        return
+    end
+
+    if class == "3" then
+        local target = _M.resolve(reply.meta, url)
+        -- The redirect is handed back to the browser rather than followed
+        -- here, so the address bar tells the truth and a redirect that leaves
+        -- gemini reaches the handler that can serve it.
+        request_obj:finish(smallweb.document(url.title,
+            '<p>Redirected to <a href="' .. smallweb.escape(target) .. '">'
+            .. smallweb.escape(target) .. "</a></p>"
+            .. "<script>window.location=" .. string.format("%q", target)
+            .. ";</script>"), "text/html")
+        return
+    end
+
+    show_error(v, request_obj, "Gemini Request Failed",
+        (status_text[reply.code] or ("The server answered " .. reply.code .. "."))
+        .. (reply.meta ~= "" and ("\n\n" .. reply.meta) or ""))
+end
+
+webview.add_signal("init", function (view)
+    view:add_signal("scheme-request::gemini", function (v, uri, request_obj)
+        if not (socket_loaded and ssl_loaded) then
+            local missing = {}
+            if not socket_loaded then missing[#missing+1] = "luasocket" end
+            if not ssl_loaded then missing[#missing+1] = "luasec" end
+            show_error(v, request_obj, "Gemini Support Is Not Installed",
+                "gemini:// needs " .. table.concat(missing, " and ")
+                .. ". Install it and restart:\n\n"
+                .. "  sh build-utils/setup-deps.sh")
+            return
+        end
+
+        local ok, url = pcall(_M.parse_url, uri)
+        if not ok or not url then
+            show_error(v, request_obj, "Gemini Address Not Understood",
+                tostring(url or uri))
+            return
+        end
+
+        local co = coroutine.create(function () return request(url) end)
+        if not load_timer.started then load_timer:start() end
+        loads[v] = co
+
+        local function loader ()
+            if loads[v] ~= co then return remove_loader(v, loader) end
+
+            local alive = pcall(function () return v.is_loading end)
+            local running, res = coroutine.resume(co, not alive)
+
+            if not running then
+                if not request_obj.finished then
+                    local stored = tostring(res):match("^CERTIFICATE_CHANGED\t(.*)$")
+                    if stored then
+                        local host = url.host .. ":" .. url.port
+                        show_error(v, request_obj,
+                            "This Host Changed Its Certificate",
+                            "The certificate " .. host .. " presents is not the"
+                            .. " one it presented before.\n\nGemini has no"
+                            .. " certificate authorities, so the only check is"
+                            .. " that the certificate stays the same. This can"
+                            .. " mean the operator rotated it, or that"
+                            .. " something is sitting between you and the"
+                            .. " host.\n\nOn record: " .. stored,
+                            {{
+                                label = "Trust the new certificate",
+                                callback = function (vv)
+                                    tofu.forget(host)
+                                    vv:reload()
+                                end,
+                            }})
+                    else
+                        show_error(v, request_obj, "Gemini Request Failed",
+                            tostring(res))
+                    end
+                end
+                return remove_loader(v, loader)
+            end
+
+            if not res then return end
+
+            if not request_obj.finished then
+                local handled, err = pcall(handle_reply, v, request_obj, res, url)
+                if not handled and not request_obj.finished then
+                    show_error(v, request_obj, "Gemini Reply Not Understood",
+                        tostring(err))
+                end
+            end
+            remove_loader(v, loader)
+        end
+
+        load_timer:add_signal("timeout", loader)
+    end)
+
+    view:add_signal("load-status", function (v, status)
+        if status == "failed" then remove_loader(v) end
+    end)
+end)
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -6,8 +6,7 @@ -- @module gopher -- @author Ygrex <ygrex@ygrex.ru>
 
 local lousy = require("lousy")
-local settings = require("settings")
-local theme = lousy.theme.get()
+local smallweb = require("smallweb")
 
 local socket_loaded, socket = pcall(require, "socket")
 if not socket_loaded then
@@ -149,39 +148,10 @@ return data end
 
 local function stylesheet()
-    local bg, fg, link;
-    if not (theme.gopher_dark and theme.gopher_light) then
-        msg.warn("Cannot find gopher styles. Please sync your theme.lua. Loading defaults.")
-        bg = "#E8E8E8"; fg = "#17181C"; link = "#03678D"
-    else
-        if settings.get_setting("application.prefer_dark_mode") then
-            bg   = theme.gopher_dark.bg;
-            fg   = theme.gopher_dark.fg;
-            link = theme.gopher_dark.link
-        else
-            bg   = theme.gopher_light.bg;
-            fg   = theme.gopher_light.fg;
-            link = theme.gopher_light.link
-        end
-    end
-
-    return [[
-        <style>
-            body, pre, input { font-family: monospace; }
-            body {
-                background-color: ]] .. bg .. [[;
-                color: ]] .. fg .. [[;
-                max-width: 80ex;
-                margin: 0;
-                padding: 5px;
-            }
-            a, a:active, a:visited {
-                text-decoration: none;
-                color: ]] .. link .. [[;
-            }
-        </style>
-    ]];
-end;
+    -- The look, and the page/terminal choice, live in the shared smallweb
+    -- layer so gopher and gemini stay in step.
+    return smallweb.stylesheet()
+end
 
 local function text_to_html(data, url)
     return [[
@@ -253,16 +253,16 @@ line-height: 1.1rem;                     font-weight: bold;
                 }
                 .status_indicator.active {
-                    border: 2px solid #008800;
-                    color: #008800;
+                    border: 2px solid var(--phos);
+                    color: var(--phos);
                 }
                 .status_indicator.inactive {
-                    border: 2px solid #880000;
-                    color: #880000;
+                    border: 2px solid var(--blood);
+                    color: var(--blood);
                 }
                 .status_indicator.builtin {
-                    border: 2px solid #444444;
-                    color: #444444;
+                    border: 2px solid var(--fg-dim);
+                    color: var(--fg-dim);
                 }
                 .status_indicator.active::before { content: "✓ "; }
                 .status_indicator.inactive::before { content: "✗ "; }
@@ -46,11 +46,11 @@ .item .time {
     -webkit-user-select: none;
     cursor: default;
-    color: #888;
+    color: var(--fg-faint);
     display: inline-block;
     width: 5em;
     text-align: right;
-    border-right: 1px solid #ddd;
+    border-right: 1px solid var(--line);
     padding-right: 0.5em;
     margin-right: 0.1em;
 }
@@ -60,15 +60,15 @@ text-decoration: none; }
 
 .item .domain a {
-    color: #aaa;
+    color: var(--fg-faint);
 }
 
 .item .domain a:hover {
-    color: #666;
+    color: var(--fg-dim);
 }
 
 .item.selected {
-    background-color: #eee;
+    background-color: var(--line);
 }
 
 .nav-button-box {
@@ -77,7 +77,7 @@ } 
 .nav-button-box a {
     display: none;
-    border: 1px solid #aaa;
+    border: 1px solid var(--fg-faint);
     padding: 0.4em 1em;
 }
 
@@ -94,14 +94,14 @@ th:nth-child(2), td:nth-child(2) { min-width: 80px; }     th:nth-child(3), td:nth-child(3) { min-width: 250px; }
     th:nth-child(4), td:nth-child(4) { width: 100%; }
 
-    tr:hover > td { background: #f8f8f8; }
-    tr > td { background: linear-gradient(180deg, #fafafa 0%, #fff 100%); }
+    tr:hover > td { background: var(--bg-raised); }
+    tr > td { background: linear-gradient(180deg, var(--bg-raised) 0%, var(--bg-raised) 100%); }
 
-    tr.level-verbose { color: #666; }
-    tr.level-warn > td { background: #FFB964; }
-    tr.level-warn:hover > td { background: #F9B561; }
-    tr.level-error > td { background: #D87050; }
-    tr.level-error:hover > td { background: #CF6A4C; }
+    tr.level-verbose { color: var(--fg-dim); }
+    tr.level-warn > td { background: var(--amber); }
+    tr.level-warn:hover > td { background: var(--amber); }
+    tr.level-error > td { background: var(--blood); }
+    tr.level-error:hover > td { background: var(--blood); }
 
     td.level { }
     td { font-family: monospace; }
@@ -207,14 +207,14 @@ -- This is passed to `string.format` with the number of errors as a -- numerical argument, the result of which is substituted into @ref{widget_format}.
 -- @type string
 -- @readwrite
-_M.widget_error_format = "<span color='#f00'>E: %d</span>"
+_M.widget_error_format = "<span color='#e05260'>E: %d</span>"
 
 --- Format string which defines the appearance of the error/warning widget.
 -- This is passed to `string.format` with the number of warnings as a
 -- numerical argument, the result of which is substituted into @ref{widget_format}.
 -- @type string
 -- @readwrite
-_M.widget_warning_format = "<span color='#f60'>W: %d</span>"
+_M.widget_warning_format = "<span color='#e8b23a'>W: %d</span>"
 
 --- Format string which defines the appearance of the error/warning widget.
 -- This combines the error and warning sub-format strings. `{errors}` will be
@@ -35,17 +35,104 @@ -- @type string -- @readwrite
 _M.new_tab_file = luakit.data_dir .. "/newtab.html"
 
---- HTML string to use for the new tab page, if no HTML file is specified.
--- The default value produces a page with no content and a single solid
--- background color. `theme.bg` is used as the background color.
+-- The mark is inlined because the new tab page has to render before anything
+-- else is ready to serve it a file.
+local mark_svg = [==[
+<svg class="mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" shape-rendering="crispEdges">
+  <g fill="#3df07a">
+    <rect x="9"  y="4"  width="14" height="2"/>
+    <rect x="7"  y="6"  width="18" height="2"/>
+    <rect x="6"  y="8"  width="20" height="10"/>
+    <rect x="7"  y="18" width="18" height="2"/>
+    <rect x="9"  y="20" width="14" height="2"/>
+    <rect x="11" y="22" width="10" height="4"/>
+  </g>
+  <g fill="#0b0d11">
+    <rect x="9"  y="10" width="5" height="5"/>
+    <rect x="18" y="10" width="5" height="5"/>
+    <rect x="15" y="15" width="2" height="3"/>
+    <rect x="12" y="22" width="2" height="4"/>
+    <rect x="15" y="22" width="2" height="4"/>
+    <rect x="18" y="22" width="2" height="4"/>
+  </g>
+</svg>
+]==]
+
+--- HTML string to use for the new tab page, when no HTML file is present.
+-- The default is the page Skull ships: the mark, the name, and the handful of
+-- keys worth knowing before anything is open. Colours come from `theme.lua`.
 -- @type string
 -- @readwrite
 _M.new_tab_src = ([==[
     <html>
-        <head><title>New Tab</title></head>
-        <body bgcolor="{bgcolor}"></body>
+    <head>
+        <title>New Tab</title>
+        <style>
+            :root {
+                --bg:   {bg};
+                --fg:   {fg};
+                --dim:  {dim};
+                --phos: {phos};
+            }
+            * { box-sizing: border-box; }
+            html, body { height: 100%; }
+            body {
+                margin: 0;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                background: var(--bg);
+                color: var(--fg);
+                font-family: ui-monospace, Menlo, Consolas, monospace;
+            }
+            .plate {
+                text-align: center;
+                opacity: 0.92;
+            }
+            .mark {
+                width: 84px;
+                height: 84px;
+                margin: 0 auto 1.4rem;
+                display: block;
+            }
+            .name {
+                font-size: 0.78rem;
+                letter-spacing: 0.42em;
+                text-indent: 0.42em;
+                text-transform: uppercase;
+                color: var(--phos);
+                margin: 0 0 0.5rem;
+            }
+            .hint {
+                font-size: 0.72rem;
+                color: var(--dim);
+                margin: 0;
+                line-height: 1.9;
+            }
+            .hint b {
+                color: var(--fg);
+                font-weight: 600;
+            }
+        </style>
+    </head>
+    <body>
+        <div class="plate">
+            {mark}
+            <p class="name">Skull</p>
+            <p class="hint">
+                <b>o</b> open &nbsp; <b>t</b> new tab &nbsp; <b>f</b> follow<br>
+                <b>:</b> command &nbsp; <b>gA</b> about &nbsp; <b>gC</b> certificates
+            </p>
+        </div>
+    </body>
     </html>
-]==]):gsub("{bgcolor}", theme.bg)
+]==]):gsub("{(%w+)}", {
+    bg   = theme.bg,
+    fg   = theme.fg,
+    dim  = theme.tab_ntheme or theme.fg,
+    phos = theme.selected_ntheme or theme.fg,
+    mark = mark_svg,
+})
 
 local function load_file_contents(file)
     if not file then return nil end
@@ -105,8 +105,8 @@ padding: 0.3em; }
 
 .setting.has-error input {
-    border: 1px solid #f34;
-    background: #fdd;
+    border: 1px solid var(--blood);
+    background: var(--bg-sunken);
 }
 .setting.has-error .tooltip {
     display: block;
@@ -123,15 +123,15 @@ .setting:not(.disabled) {     background: white;
 }
 .setting:hover {
-    background: #f6f6f6;
+    background: var(--bg-sunken);
 }
 .setting.disabled {
     background: transparent;
 }
 
 .tooltip {
-    background: #121215;
-    color: #f66;
+    background: var(--bg-sunken);
+    color: var(--blood);
     font-size: 0.8rem;
     line-height: 1;
     padding: 0.6em 0.75rem;
@@ -177,13 +177,13 @@ font-size: 0.9rem;     font-weight: normal;
     text-align: left;
     padding-bottom: 0.3rem;
-    border-bottom: 1px solid #777;
+    border-bottom: 1px solid var(--fg-faint);
 }
 
 .boolean > input { margin-left: 0; }
 .boolean > span { font-weight: bold; }
-.boolean > span[data-value=true] { color: #799D6A; }
-.boolean > span[data-value=false] { color: #CF6A4C; }
+.boolean > span[data-value=true] { color: var(--phos); }
+.boolean > span[data-value=false] { color: var(--blood); }
 ]===]
 
 local function build_settings_entry_table_html(meta)
@@ -0,0 +1,128 @@+--- Shared presentation layer for the small web.
+--
+-- `gopher://` and `gemini://` arrive as plain text over a plain socket and
+-- leave as HTML. The parts that are the same for both, meaning the page shell,
+-- the stylesheet and the choice between the page and terminal looks, live
+-- here so the two schemes cannot drift apart.
+--
+-- ## Render modes
+--
+-- `smallweb.render_mode` takes `page` or `terminal`.
+--
+-- `page` reads like a document: comfortable measure, ordinary link colour.
+--
+-- `terminal` reads like a terminal: full width, fixed columns, a caret on
+-- every link and a phosphor cast over the whole thing. It is the default,
+-- because it is what these protocols were written against.
+--
+-- @module smallweb
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local lousy = require("lousy")
+local settings = require("settings")
+
+local _M = {}
+
+settings.register_settings({
+    ["smallweb.render_mode"] = {
+        type = "enum",
+        options = {
+            page     = { desc = "Read as a document, with a comfortable measure." },
+            terminal = { desc = "Read as a terminal, full width and monospaced." },
+        },
+        default = "terminal",
+        desc = [=[
+            How `gopher://` and `gemini://` pages are presented.
+        ]=],
+    },
+})
+
+--- Return the palette for the current mode, taken from `theme.lua`.
+-- @treturn table Fields `bg`, `fg` and `link`.
+_M.palette = function ()
+    local theme = lousy.theme.get()
+    local dark = settings.get_setting("application.prefer_dark_mode")
+    local pal = dark and theme.gopher_dark or theme.gopher_light
+    if not pal then
+        msg.warn("smallweb: no small web palette in theme.lua, using defaults")
+        return dark and { bg = "#0b0d11", fg = "#e9e5d9", link = "#35d6c3" }
+            or { bg = "#f4f2ec", fg = "#15181d", link = "#0d7d71" }
+    end
+    return pal
+end
+
+--- The stylesheet for a small web page, including the `<style>` element.
+-- @treturn string
+_M.stylesheet = function ()
+    local pal = _M.palette()
+    local terminal = settings.get_setting("smallweb.render_mode") == "terminal"
+
+    local shell = terminal
+        and [[
+            body { max-width: none; padding: 1.25rem 1.5rem; }
+            pre, .menu { font-size: 13px; line-height: 1.45; }
+            a::before { content: "> "; opacity: 0.55; }
+            .heading { color: ]] .. pal.link .. [[; font-weight: 600; }
+            .quote { opacity: 0.75; border-left: 2px solid currentColor;
+                     padding-left: 0.9em; margin-left: 0; }
+        ]]
+        or [[
+            body { max-width: 80ex; padding: 1.5rem; margin: 0 auto; }
+            pre, .menu { font-size: 14px; line-height: 1.6; }
+            .heading { font-weight: 600; }
+            .quote { opacity: 0.8; border-left: 3px solid currentColor;
+                     padding-left: 1em; margin-left: 0; }
+        ]]
+
+    return [[
+        <style>
+            body, pre, input, .menu {
+                font-family: ui-monospace, Menlo, Consolas, monospace;
+            }
+            body {
+                background-color: ]] .. pal.bg .. [[;
+                color: ]] .. pal.fg .. [[;
+                margin: 0;
+            }
+            a, a:active, a:visited {
+                text-decoration: none;
+                color: ]] .. pal.link .. [[;
+            }
+            a:hover { text-decoration: underline; }
+            h1, h2, h3 { font-size: 1em; margin: 1.2em 0 0.4em; }
+            input {
+                background: transparent;
+                color: inherit;
+                border: 1px solid currentColor;
+                padding: 0.15em 0.4em;
+            }
+            ]] .. shell .. [[
+        </style>
+    ]]
+end
+
+--- Wrap rendered body markup in a complete document.
+-- @tparam string title The page title. Escaped here, pass it raw.
+-- @tparam string body Markup for the document body. Passed through as is.
+-- @tparam[opt] string head Extra markup for the document head.
+-- @treturn string
+_M.document = function (title, body, head)
+    return table.concat({
+        "<html><head><title>", lousy.util.escape(tostring(title or "")), "</title>",
+        '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />',
+        _M.stylesheet(), head or "", "</head><body>", body, "</body></html>",
+    })
+end
+
+--- Escape text for HTML, including the braces that `error_page` reads as
+-- placeholders.
+-- @tparam string text The text to escape.
+-- @treturn string
+_M.escape = function (text)
+    return (lousy.util.escape(tostring(text))
+        :gsub("{", "&#123;"):gsub("}", "&#125;"))
+end
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,116 @@+--- Trust on first use for the small web.
+--
+-- Gemini does not use certificate authorities. A server presents a
+-- self-signed certificate, the client remembers it, and what matters from then
+-- on is that the same certificate comes back. That is trust on first use.
+--
+-- The first visit to a host is trusted without asking, because there is
+-- nothing to compare against. Every visit after that compares fingerprints,
+-- and a change is reported rather than accepted.
+--
+-- @module smallweb.tofu
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local _M = {}
+
+--- Path to the fingerprint store.
+-- @type string
+-- @readwrite
+_M.db_path = luakit.data_dir .. "/smallweb_tofu.db"
+
+local db
+
+local function init_db ()
+    if db then return db end
+    db = sqlite3{ filename = _M.db_path }
+    db:exec [[
+        PRAGMA synchronous = OFF;
+        PRAGMA secure_delete = 1;
+
+        CREATE TABLE IF NOT EXISTS known_hosts (
+            id INTEGER PRIMARY KEY,
+            host TEXT NOT NULL,
+            fingerprint TEXT NOT NULL,
+            first_seen INTEGER NOT NULL,
+            last_seen INTEGER NOT NULL
+        );
+
+        CREATE UNIQUE INDEX IF NOT EXISTS idx_tofu_host ON known_hosts (host);
+    ]]
+    return db
+end
+
+--- Check a fingerprint against what is on record, and record it when the host
+-- is new.
+--
+-- @tparam string host The host, including the port.
+-- @tparam string fingerprint The certificate fingerprint presented now.
+-- @treturn string One of `"new"`, `"known"` or `"changed"`.
+-- @treturn string|nil The fingerprint on record, when the answer is
+--   `"changed"`.
+_M.check = function (host, fingerprint)
+    assert(type(host) == "string" and host ~= "", "expected a host")
+    assert(type(fingerprint) == "string" and fingerprint ~= "",
+        "expected a fingerprint")
+
+    local rows = init_db():exec(
+        "SELECT fingerprint FROM known_hosts WHERE host=?", {host})
+
+    if not rows or #rows == 0 then
+        local now = os.time()
+        db:exec("INSERT INTO known_hosts VALUES (NULL, ?, ?, ?, ?)",
+            {host, fingerprint, now, now})
+        return "new"
+    end
+
+    if rows[1].fingerprint == fingerprint then
+        db:exec("UPDATE known_hosts SET last_seen=? WHERE host=?",
+            {os.time(), host})
+        return "known"
+    end
+
+    return "changed", rows[1].fingerprint
+end
+
+--- Replace the fingerprint on record for a host.
+--
+-- Called when the user decides that a changed certificate is a legitimate
+-- rotation rather than someone in the middle.
+--
+-- @tparam string host The host, including the port.
+-- @tparam string fingerprint The fingerprint to trust from now on.
+_M.trust = function (host, fingerprint)
+    assert(type(host) == "string" and host ~= "", "expected a host")
+    assert(type(fingerprint) == "string" and fingerprint ~= "",
+        "expected a fingerprint")
+    local now = os.time()
+    init_db():exec("INSERT OR REPLACE INTO known_hosts VALUES "
+        .. "(NULL, ?, ?, COALESCE((SELECT first_seen FROM known_hosts "
+        .. "WHERE host=?), ?), ?)",
+        {host, fingerprint, host, now, now})
+end
+
+--- Forget a host, so the next visit is treated as a first one.
+-- @tparam string host The host, including the port.
+-- @treturn boolean Whether anything was removed.
+_M.forget = function (host)
+    assert(type(host) == "string" and host ~= "", "expected a host")
+    local rows = init_db():exec(
+        "SELECT COUNT(*) AS n FROM known_hosts WHERE host=?", {host})
+    local n = tonumber(rows and rows[1] and rows[1].n) or 0
+    if n == 0 then return false end
+    db:exec("DELETE FROM known_hosts WHERE host=?", {host})
+    return true
+end
+
+--- List every host on record.
+-- @treturn table Rows with `host`, `fingerprint`, `first_seen` and
+--   `last_seen`.
+_M.known_hosts = function ()
+    return init_db():exec("SELECT host, fingerprint, first_seen, last_seen "
+        .. "FROM known_hosts ORDER BY last_seen DESC") or {}
+end
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,175 @@+--- Small web settings and known-host manager.
+--
+-- This module provides <skull://smallweb/>, which switches between the page
+-- and terminal render modes and lists the gemini hosts remembered by
+-- `smallweb.tofu`, so any of them can be forgotten.
+--
+-- Forgetting a host means the next visit is treated as a first one and its
+-- certificate is trusted again without asking.
+--
+-- @module smallweb_chrome
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local chrome = require("chrome")
+local lousy = require("lousy")
+local settings = require("settings")
+local tofu = require("smallweb.tofu")
+local modes = require("modes")
+local add_binds, add_cmds = modes.add_binds, modes.add_cmds
+
+local _M = {}
+
+--- CSS applied to the small web page.
+-- @type string
+-- @readwrite
+_M.stylesheet = [===[
+    .modes { display: flex; gap: 0.75rem; margin: 0 0 2rem; }
+    .mode {
+        flex: 1;
+        border: 1px solid var(--line);
+        border-radius: var(--radius);
+        padding: 1rem 1.1rem;
+        cursor: pointer;
+        background: var(--bg-raised);
+    }
+    .mode[data-active="true"] {
+        border-color: var(--phos-dim);
+        box-shadow: inset 3px 0 0 var(--phos);
+    }
+    .mode h3 { margin: 0 0 0.3rem; color: var(--fg); }
+    .mode p { margin: 0; color: var(--fg-dim); font-size: 0.82rem; }
+
+    table { border-collapse: collapse; width: 100%; }
+    th {
+        text-align: left; font-weight: normal; color: var(--fg-faint);
+        font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em;
+        padding: 0 1em 0.5em 0;
+    }
+    td { padding: 0.6em 1em 0.6em 0; border-top: 1px solid var(--line-soft);
+         vertical-align: baseline; }
+    td.host { color: var(--fg); }
+    td.fp { color: var(--fg-faint); font-size: 0.78rem; word-break: break-all; }
+    td.when { color: var(--fg-dim); white-space: nowrap; }
+    td.act { text-align: right; width: 1%; }
+    .forget { color: var(--blood); cursor: pointer; }
+    .forget:hover { text-decoration: underline; }
+    .empty { color: var(--fg-faint); padding: 2em 0; }
+]===]
+
+local html_template = [==[
+<html>
+<head>
+    <title>Small web</title>
+    <style type="text/css">{%stylesheet}</style>
+</head>
+<body>
+    <div class="content-margin">
+        <h2>Render mode</h2>
+        <div class="modes">
+            <div class="mode" data-mode="terminal" data-active="{%terminal}">
+                <h3>Terminal</h3>
+                <p>Full width, monospaced, a caret on every link.</p>
+            </div>
+            <div class="mode" data-mode="page" data-active="{%page}">
+                <h3>Page</h3>
+                <p>A comfortable measure, read like a document.</p>
+            </div>
+        </div>
+
+        <h2>Known gemini hosts</h2>
+        <p style="color:var(--fg-dim);font-size:0.85rem;margin-top:0">
+            The certificate each host first presented, remembered so a change
+            can be spotted. Forget one and its next certificate is trusted
+            without asking.
+        </p>
+        {%rows}
+    </div>
+    <script type="text/javascript">{%javascript}</script>
+</body>
+</html>
+]==]
+
+local main_js = [=[
+    document.addEventListener("click", function (event) {
+        var el = event.target;
+        var mode = el.closest ? el.closest(".mode") : null;
+        if (mode) {
+            set_render_mode(mode.dataset.mode).then(function () {
+                window.location.reload();
+            });
+            return;
+        }
+        if (el.classList && el.classList.contains("forget")) {
+            event.preventDefault();
+            forget_host(el.dataset.host).then(function () {
+                window.location.reload();
+            });
+        }
+    });
+]=]
+
+local function format_time (t)
+    return os.date("%Y-%m-%d %H:%M", tonumber(t) or 0)
+end
+
+local function build_rows ()
+    local hosts = tofu.known_hosts()
+    if #hosts == 0 then
+        return "<p class=\"empty\">No gemini hosts remembered yet.</p>"
+    end
+    local out = {
+        "<table><tr><th>Host</th><th>Fingerprint (SHA-256)</th>",
+        "<th>First seen</th><th></th></tr>",
+    }
+    for _, h in ipairs(hosts) do
+        local fp = h.fingerprint or ""
+        local short = #fp > 24 and (fp:sub(1, 24) .. "...") or fp
+        out[#out+1] = string.format(
+            "<tr><td class=\"host\">%s</td><td class=\"fp\">%s</td>"
+            .. "<td class=\"when\">%s</td>"
+            .. "<td class=\"act\"><a class=\"forget\" href=\"#\" data-host=\"%s\">forget</a></td></tr>",
+            lousy.util.escape(h.host), lousy.util.escape(short),
+            format_time(h.first_seen), lousy.util.escape(h.host))
+    end
+    out[#out+1] = "</table>"
+    return table.concat(out)
+end
+
+local export_funcs = {
+    set_render_mode = function (mode)
+        if mode == "page" or mode == "terminal" then
+            settings.set_setting("smallweb.render_mode", mode)
+            return true
+        end
+        return false
+    end,
+    forget_host = function (host)
+        assert(type(host) == "string", "expected a host")
+        return tofu.forget(host)
+    end,
+}
+
+chrome.add("smallweb", function ()
+    local mode = settings.get_setting("smallweb.render_mode")
+    return (string.gsub(html_template, "{%%(%w+)}", {
+        stylesheet = chrome.stylesheet .. _M.stylesheet,
+        javascript = main_js,
+        rows = build_rows(),
+        terminal = tostring(mode == "terminal"),
+        page = tostring(mode == "page"),
+    }))
+end, nil, export_funcs)
+
+add_binds("normal", {
+    { "^gS$", "Open the small web settings.",
+        function (w) w:navigate("skull://smallweb/") end },
+})
+
+add_cmds({
+    { ":smallweb", "Open the small web settings.",
+        function (w) w:navigate("skull://smallweb/") end },
+})
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,79 @@+--- Test the gemini module's parsing and rendering.
+--
+-- The transport needs a network and a TLS library, so it is left to a live
+-- run. What is pinned here is the pure logic: url parsing, link resolution,
+-- and turning gemtext into HTML without letting hostile input become markup.
+
+local assert = require "luassert"
+local gemini = require "gemini"
+
+local T = {}
+
+T.test_parse_url_fills_defaults = function ()
+    local u = gemini.parse_url("gemini://example.org/")
+    assert.equal("example.org", u.host)
+    assert.equal(1965, u.port)
+    assert.equal("/", u.path)
+    assert.is_nil(u.query)
+end
+
+T.test_parse_url_keeps_port_and_query = function ()
+    local u = gemini.parse_url("gemini://example.org:1966/path?search")
+    assert.equal("example.org", u.host)
+    assert.equal(1966, u.port)
+    assert.equal("/path", u.path)
+    assert.equal("search", u.query)
+end
+
+T.test_parse_url_strips_a_hostile_host = function ()
+    -- Whatever survives reaches a TLS handshake and a fingerprint key.
+    local u = gemini.parse_url("gemini://ev il;rm$(id)/x")
+    assert.is_nil(u.host:match("[^%w%.%-]"))
+end
+
+T.test_resolve_handles_relative_and_absolute = function ()
+    local base = gemini.parse_url("gemini://h.org/dir/page.gmi")
+    assert.equal("gemini://h.org/dir/other.gmi", gemini.resolve("other.gmi", base))
+    assert.equal("gemini://h.org/root.gmi", gemini.resolve("/root.gmi", base))
+    assert.equal("https://elsewhere/x", gemini.resolve("https://elsewhere/x", base))
+    assert.equal("gemini://other.host/", gemini.resolve("//other.host/", base))
+end
+
+T.test_gemtext_links_and_headings = function ()
+    local base = gemini.parse_url("gemini://h.org/")
+    local html = gemini.gemtext_to_html(
+        "# Title\n=> /page.gmi A link\n* item\n> quoted\nplain\n", base)
+    assert.is_truthy(html:find("<h1", 1, true))
+    assert.is_truthy(html:find('href="gemini://h.org/page.gmi"', 1, true))
+    assert.is_truthy(html:find("<li>item</li>", 1, true))
+    assert.is_truthy(html:find("quoted", 1, true))
+end
+
+T.test_gemtext_escapes_markup = function ()
+    local base = gemini.parse_url("gemini://h.org/")
+    -- A link label, a heading and a plain line, each carrying markup and a
+    -- brace that error_page would otherwise read as a placeholder.
+    local html = gemini.gemtext_to_html(
+        "# <script>alert(1)</script>\n"
+        .. "=> /x <img onerror=go>{x}\n"
+        .. "plain <b>no</b> {y}\n", base)
+    assert.is_nil(html:find("<script>", 1, true))
+    assert.is_nil(html:find("<img onerror", 1, true))
+    assert.is_nil(html:find("<b>no</b>", 1, true))
+    assert.is_nil(html:find("{x}", 1, true))
+    assert.is_nil(html:find("{y}", 1, true))
+end
+
+T.test_gemtext_preformatted_is_literal = function ()
+    local base = gemini.parse_url("gemini://h.org/")
+    local html = gemini.gemtext_to_html(
+        "```\n=> not a link\n# not a heading\n```\n", base)
+    -- Inside a fence, nothing is interpreted as a link or heading.
+    assert.is_nil(html:find("<a ", 1, true))
+    assert.is_nil(html:find("<h1", 1, true))
+    assert.is_truthy(html:find("=&gt; not a link", 1, true))
+end
+
+return T
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,60 @@+--- Test trust on first use for the small web.
+--
+-- The whole point of TOFU is that the first certificate is remembered and a
+-- change is caught. These pin exactly that: new, then known, then changed,
+-- and the two ways out, trusting the new one or forgetting the host.
+
+local assert = require "luassert"
+local tofu = require "smallweb.tofu"
+
+local T = {}
+
+local function clean (host) tofu.forget(host) end
+
+T.test_first_visit_is_new_then_known = function ()
+    local host = "tofu-test-a:1965"
+    clean(host)
+
+    assert.equal("new", tofu.check(host, "AA:BB"))
+    -- Same certificate on the next visit is known, not new.
+    assert.equal("known", tofu.check(host, "AA:BB"))
+    clean(host)
+end
+
+T.test_a_changed_certificate_is_reported = function ()
+    local host = "tofu-test-b:1965"
+    clean(host)
+
+    tofu.check(host, "AA:BB")
+    local status, stored = tofu.check(host, "CC:DD")
+    assert.equal("changed", status)
+    assert.equal("AA:BB", stored)
+    clean(host)
+end
+
+T.test_trusting_the_new_certificate_replaces_it = function ()
+    local host = "tofu-test-c:1965"
+    clean(host)
+
+    tofu.check(host, "AA:BB")
+    tofu.trust(host, "CC:DD")
+    -- The new one is now the known one, and the old one would now be a change.
+    assert.equal("known", tofu.check(host, "CC:DD"))
+    clean(host)
+end
+
+T.test_forgetting_a_host_makes_it_new_again = function ()
+    local host = "tofu-test-d:1965"
+    clean(host)
+
+    tofu.check(host, "AA:BB")
+    assert.is_true(tofu.forget(host))
+    assert.equal("new", tofu.check(host, "ZZ:ZZ"))
+    -- Forgetting something that is not there says so.
+    clean(host)
+    assert.is_false(tofu.forget(host))
+end
+
+return T
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80