clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

navigate within the shell and open the phone menu without JavaScript

Dami committed 2500b72
Internal links and GET forms swap the rail, content, panel and footer in
place with history, titles and focus handled; modified clicks, downloads
and external links keep browser behaviour, and failures report in the
footer. The phone menu is a details element so it works without
JavaScript. Signer controls rebind after each swap and submit once.
commit
2500b72813d5a2192d95a9e0ab9bb81dbcaea5d8
parent
411567228bc1d930b30bcd3cfc4d95ef1a49aa1c
author
Dami <dami.etoile@gmail.com>
7 files changed, +482 -117
Mdocs/fixi.md8+-
Minternal/webui/bridge.js201+++++++++++++++++++-----------
Minternal/webui/components.js157+++++++++++++++++++++++
Minternal/webui/page.html8+-
Minternal/webui/shell_test.go119++++++++++++++++++
Minternal/webui/style.css15++-
Mscripts/signer.test.mjs91+++++++++-----
M docs/fixi.md +6 -2
@@ -10,6 +10,10 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
1010 1111 `/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Connect page uses Fixi's `fx-action` fragment request for a read-only connection preview; `/connect/fragment` renders server-escaped table HTML, while `/connect.json` remains a JSON API. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.1212 13-The existing signer bridge still owns NIP-07, NIP-46, Nostr Connect, Amber links, session login, and management RPC. Fixi's fragment swaps are optional progressive enhancement: links and read-only navigation continue to work if JavaScript is unavailable; signed management actions require the signer bridge JavaScript. Paxi's focus-preserving `morph` swap is used by the read-only connection fragment. Ssexi consumes the authenticated `/manage/jobs/status` stream; the handler resolves the actor before opening it, passes that actor to every `listjobs` query, emits only changed escaped HTML table fragments into the existing job table, and closes on request cancellation. The `rpc-form` element disables its buttons while a signed call is in flight and re-enables them after completion. The current backend contract remains `Query(ctx, method, params, actor)`.13+The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.1414 15-The layout remains a semantic table with fixed 960px desktop and 360px mobile variants. Fixi does not add branding or layout CSS.15+Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.16+17+On phones, navigation starts closed and opens from the menu control, with or without JavaScript. Opening the menu leaves content in place. Desktop pages retain the sidebar.18+19+The layout remains a semantic table: a rail, a content column and a panel on desktop, stacked with a slide-in menu on phones. Fixi does not add branding or layout CSS.
M internal/webui/bridge.js +126 -75
@@ -13,12 +13,19 @@
1313 pubkey: storagePrefix + ".identity",1414 remote: storagePrefix + ".remote"1515 };16- const status = document.getElementById("signer-status") || document.getElementById("session-status");17- const say = text => { if (status) status.textContent = text; };16+ const say = text => {17+ const status = document.getElementById("signer-status") || document.getElementById("session-status");18+ if (status) status.textContent = text;19+ };20+ const errorText = err => err instanceof Error ? err.message : String(err);1821 const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");1922 const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));2023 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };2124 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");25+ const validRelay = value => {26+ try { const parsed = new URL(value); return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && parsed.hostname !== ""; } catch { return false; }27+ };28+ const validBunker = pointer => pointer && /^[0-9a-f]{64}$/i.test(pointer.pubkey || "") && Array.isArray(pointer.relays) && pointer.relays.length > 0 && pointer.relays.every(validRelay);2229 2330 // authorization builds a NIP-98 header for one request body. The body is2431 // hashed exactly once and the same bytes must be sent.
@@ -52,31 +59,48 @@
5259 5360 // Shell: theme toggle, the phone menu, and the installable app worker.5461 const root_ = document.documentElement;55- const themeButton = document.getElementById("theme");5662 const currentTheme = () => root_.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");5763 const savedTheme = localStorage.getItem("tiny.theme");5864 if (savedTheme) root_.dataset.theme = savedTheme;5965 // The installed app's title bar follows the theme-color meta, so keep it6066 // in step with the rail colour of whichever theme is active.61- const themeMeta = document.querySelector('meta[name="theme-color"]');6267 const paintChrome = () => {6368 const rail = getComputedStyle(root_).getPropertyValue("--rail").trim();69+ const themeMeta = document.querySelector('meta[name="theme-color"]');6470 if (themeMeta && rail) themeMeta.content = rail;6571 };6672 paintChrome();6773 matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintChrome);68- if (themeButton) {69- const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; };70- label();71- themeButton.addEventListener("click", () => {72- root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark";73- localStorage.setItem("tiny.theme", root_.dataset.theme);74+ const setMenu = open => {75+ const menu = document.getElementById("nav-menu");76+ if (menu) menu.open = open;77+ };78+ const shellBindings = new WeakSet();79+ const bindShell = () => {80+ const rail = document.getElementById("rail");81+ const themeButton = document.getElementById("theme");82+ if (rail && !shellBindings.has(rail)) {83+ shellBindings.add(rail);84+ rail.addEventListener("click", event => { if (event.target.closest("a")) setMenu(false); });85+ }86+ if (themeButton && !shellBindings.has(themeButton)) {87+ shellBindings.add(themeButton);88+ const label = () => { themeButton.textContent = currentTheme() === "dark" ? "light" : "dark"; };7489 label();75- paintChrome();76- });77- }78- document.getElementById("menu")?.addEventListener("click", () => { root_.dataset.menu = root_.dataset.menu === "open" ? "" : "open"; });79- document.getElementById("rail")?.addEventListener("click", event => { if (event.target.closest("a")) root_.dataset.menu = ""; });90+ themeButton.addEventListener("click", () => {91+ root_.dataset.theme = currentTheme() === "dark" ? "light" : "dark";92+ localStorage.setItem("tiny.theme", root_.dataset.theme);93+ label();94+ paintChrome();95+ });96+ }97+ };98+ bindShell();99+ document.addEventListener("keydown", event => {100+ if (event.key !== "Escape" || !document.getElementById("nav-menu")?.open) return;101+ setMenu(false);102+ document.getElementById("menu")?.focus();103+ });80104 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});81105 window.tiny = {root, localPath, sha256hex, authorization, signedFetch};82106 window.tinySignedFetch = signedFetch;
@@ -108,69 +132,96 @@
108132 }109133 };110134 111- document.getElementById("session-login")?.addEventListener("click", async () => {112- try { await signIn(); } catch (err) { say("Sign-in error: " + err.message); }113- });135+ // Fixi can morph the content column without reloading this script. Bind136+ // controls by element identity so navigation can safely discover new ones.137+ const boundControls = new WeakSet();138+ const bindControl = (id, event, handler) => {139+ const element = document.getElementById(id);140+ if (!element || boundControls.has(element)) return;141+ boundControls.add(element);142+ element.addEventListener(event, handler);143+ };144+ const busy = new WeakSet();145+ const once = (element, action) => {146+ if (busy.has(element)) return;147+ busy.add(element);148+ const button = element.matches?.("button, input[type=submit]") ? element : element.querySelector?.("button, input[type=submit]");149+ if (button) button.disabled = true;150+ return Promise.resolve().then(action).finally(() => { busy.delete(element); if (button) button.disabled = false; });151+ };114152 115- document.getElementById("session-logout")?.addEventListener("click", async () => {116- try {117- say("Signing out…");118- const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});119- if (!response.ok) throw Error(await response.text());120- forgetRemote();121- say("Signed out.");122- location.reload();123- } catch (err) {124- say("Sign-out error: " + err.message);125- }126- });153+ const bindControls = () => {154+ bindControl("session-login", "click", event => once(event.currentTarget, async () => {155+ try { await signIn(); } catch (err) { say("Sign-in error: " + errorText(err)); }156+ }));157+158+ bindControl("session-logout", "click", event => once(event.currentTarget, async () => {159+ try {160+ say("Signing out…");161+ const response = await fetch(localPath("/session/logout"), {method: "POST", credentials: "same-origin"});162+ if (!response.ok) throw Error(await response.text());163+ forgetRemote();164+ say("Signed out.");165+ location.reload();166+ } catch (err) {167+ say("Sign-out error: " + errorText(err));168+ }169+ }));127170 128- // Nostr Connect: show a QR code and wait for a phone signer such as Amber.129- document.getElementById("nostrconnect")?.addEventListener("click", async ev => {130- ev.preventDefault();131- const key = window.NostrSigner.generateSecretKey();132- const uri = window.NostrSigner.createNostrConnectURI({133- clientPubkey: window.NostrSigner.getPublicKey(key),134- relays: [location.origin.replace(/^http/,"ws")+root],135- secret: randomHex(),136- name: "tiny",137- url: location.origin,138- perms: ["sign_event:27235"]171+ // Nostr Connect: show a QR code and wait for a phone signer such as Amber.172+ bindControl("nostrconnect", "click", event => {173+ event.preventDefault();174+ return once(event.currentTarget, async () => {175+ try {176+ const key = window.NostrSigner.generateSecretKey();177+ const uri = window.NostrSigner.createNostrConnectURI({178+ clientPubkey: window.NostrSigner.getPublicKey(key),179+ relays: [location.origin.replace(/^http/,"ws")+root],180+ secret: randomHex(),181+ name: "tiny",182+ url: location.origin,183+ perms: ["sign_event:27235"]184+ });185+ const open = document.getElementById("nostrconnect-open");186+ if (open) { open.href = uri; open.hidden = false; }187+ const qr = document.getElementById("signer-qr");188+ if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }189+ navigator.clipboard?.writeText(uri).catch(() => {});190+ say("Scan the QR code or open the link in your signer.");191+ window.tinyConnect = {key, uri};192+ const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);193+ setSigner(signer);194+ await rememberRemote(uri, key, signer);195+ await signIn();196+ } catch (err) {197+ say("Nostr Connect error: " + errorText(err));198+ }199+ });139200 });140- const open = document.getElementById("nostrconnect-open");141- if (open) { open.href = uri; open.hidden = false; }142- const qr = document.getElementById("signer-qr");143- if (qr) { qr.src = root + "/qr.svg?text=" + encodeURIComponent(uri); qr.hidden = false; }144- navigator.clipboard?.writeText(uri).catch(() => {});145- say("Scan the QR code or open the link in your signer.");146- window.tinyConnect = {key, uri};147- try {148- const signer = await window.NostrSigner.BunkerSigner.fromURI(key, uri);149- setSigner(signer);150- await rememberRemote(uri, key, signer);151- await signIn();152- } catch (err) {153- say("Nostr Connect error: " + err.message);154- }155- });156201 157- // Bunker URL: connect to a remote signer the user pastes in.158- document.getElementById("bunker")?.addEventListener("submit", async ev => {159- ev.preventDefault();160- const value = document.getElementById("bunker-url").value.trim();161- if (!value) return;162- try {163- const secret = window.NostrSigner.generateSecretKey();164- const pointer = await window.NostrSigner.parseBunkerInput(value);165- const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);166- await signer.connect();167- setSigner(signer);168- await rememberRemote(value, secret, signer);169- await signIn();170- } catch (err) {171- say("Remote signer error: " + err.message);172- }173- });202+ // Bunker URL: connect to a remote signer the user pastes in.203+ bindControl("bunker", "submit", event => {204+ event.preventDefault();205+ return once(event.currentTarget, async () => {206+ const value = document.getElementById("bunker-url")?.value.trim();207+ if (!value) return;208+ try {209+ const secret = window.NostrSigner.generateSecretKey();210+ const pointer = await window.NostrSigner.parseBunkerInput(value);211+ if (!validBunker(pointer)) throw Error("Enter a valid bunker URL with at least one relay.");212+ const signer = window.NostrSigner.BunkerSigner.fromBunker(secret, pointer);213+ await signer.connect();214+ setSigner(signer);215+ await rememberRemote(value, secret, signer);216+ await signIn();217+ } catch (err) {218+ say("Remote signer error: " + errorText(err));219+ }220+ });221+ });222+ };223+ bindControls();224+ document.addEventListener("tiny:navigation", () => { bindShell(); bindControls(); });174225 175226 // Resume a remote signer stored for this tab so page loads keep working.176227 (async () => {
@@ -189,7 +240,7 @@
189240 setSigner(signer);190241 } catch (err) {191242 forgetRemote();192- say("Remote signer resume error: " + err.message);243+ say("Remote signer resume error: " + errorText(err));193244 }194245 })();195246 })();
M internal/webui/components.js +157 -0
@@ -43,12 +43,15 @@
4343 this.append(form, this.output);4444 form.addEventListener("submit", async event => {4545 event.preventDefault();46+ if (this.submitting) return;47+ this.submitting = true;4648 this.busy(true);4749 try {4850 await this.submit(form);4951 } catch (err) {5052 this.report("Error: " + err.message, true);5153 } finally {54+ this.submitting = false;5255 this.busy(false);5356 }5457 });
@@ -298,4 +301,158 @@
298301 customElements.define("publish-list", PublishList);299302 customElements.define("nostr-key", NostrKey);300303 customElements.define("json-view", JsonView);304+305+ // Fixi normally swaps a small target. Internal page links return the full306+ // shell, so use one shared swap that keeps the shell's live nodes and makes307+ // navigation behave like a normal document request. The links retain their308+ // hrefs, which is the no-JavaScript fallback and keeps them bookmarkable.309+ (() => {310+ document.documentElement.setAttribute("data-js", "yes");311+ const navTargets = ["#topbar", "#railbox", "#content", "#panel", "#footer"];312+ const status = () => document.querySelector("#navigation-status");313+ const sameDocument = href => {314+ let url;315+ try { url = new URL(href, location.href); } catch { return false; }316+ if (url.origin !== location.origin || url.hash || url.protocol !== location.protocol) return false;317+ if (!allowedRoute(routePath(url))) return false;318+ return !/\.(?:json|xml|rss|atom|txt|svg|ico|webmanifest|js|css|png|jpg|jpeg|gif|webp|zip|tar)$/i.test(url.pathname);319+ };320+ const setBusy = busy => {321+ document.querySelector("#content")?.setAttribute("aria-busy", busy ? "true" : "false");322+ const node = status();323+ if (node && (busy || node.textContent === "loading")) node.textContent = busy ? "loading" : "";324+ };325+ const routePath = url => {326+ const root = (location.pathname.match(/^\/r\/[^/]+/) || [""])[0];327+ if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;328+ return root ? url.pathname.slice(root.length) || "/" : url.pathname;329+ };330+ const allowedRoute = path => /^(?:\/(?:inbox|outbox|search|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files)?\/?|\/manage(?:\/(?:people|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a)\/.+)$/.test(path || "");331+ let navigationSerial = 0, activeAbort;332+ const streams = new Set();333+ const closeStreams = () => {334+ streams.forEach(cfg => cfg.sse.close());335+ streams.clear();336+ };337+ document.addEventListener("fx:sse:open", event => {338+ const cfg = event.detail.cfg;339+ if (!cfg.target.isConnected) { cfg.sse.close(); event.preventDefault(); return; }340+ streams.add(cfg);341+ });342+ document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));343+ window.addEventListener("pagehide", closeStreams);344+ const repairComponents = () => {345+ document.querySelectorAll("rpc-form,signed-form,publish-list").forEach(node => {346+ if (node.form?.isConnected) return;347+ node.form = null;348+ node.output = null;349+ node.connectedCallback();350+ if (node.result && !node.result.isConnected) node.result = null;351+ });352+ document.querySelectorAll("json-view").forEach(node => {353+ if (!node.rendered || !node.querySelector(":scope > pre")) return;354+ node.rendered = false;355+ node.connectedCallback();356+ });357+ };358+ const swapShell = (text, url, push) => {359+ if (url.__tinyNavigationSerial && url.__tinyNavigationSerial !== navigationSerial) return false;360+ const parsed = new DOMParser().parseFromString(text, "text/html");361+ const incoming = new Map(navTargets.map(selector => [selector, parsed.querySelector(selector)]));362+ if ([...incoming.values()].some(node => !node)) return false;363+ const focusID = document.activeElement?.id;364+ closeStreams();365+ navTargets.forEach(selector => morph(document.querySelector(selector), incoming.get(selector).outerHTML));366+ repairComponents();367+ document.title = parsed.title || document.title;368+ if (push) history.pushState({}, "", url.href);369+ const focus = focusID && document.getElementById(focusID);370+ if (focus && typeof focus.focus === "function") focus.focus({preventScroll: true});371+ else document.querySelector("#content")?.focus({preventScroll: true});372+ document.dispatchEvent(new CustomEvent("tiny:navigation", {detail: {url: url.href}}));373+ decorate();374+ decorateForms();375+ document.dispatchEvent(new CustomEvent("fx:process", {detail: {url: url.href}, bubbles: false}));376+ return true;377+ };378+ const load = async (url, push) => {379+ const serial = ++navigationSerial, controller = new AbortController();380+ activeAbort?.();381+ activeAbort = () => controller.abort();382+ url.__tinyNavigationSerial = serial;383+ setBusy(true);384+ try {385+ const response = await fetch(url.href, {headers: {"FX-Request": "true", Accept: "text/html"}, credentials: "same-origin", signal: controller.signal});386+ if (!response.ok) throw Error("Navigation failed (" + response.status + ")");387+ if (!swapShell(await response.text(), url, push)) throw Error("Page response did not contain the UI shell");388+ } catch (error) {389+ const node = status();390+ if (serial === navigationSerial && node) node.textContent = "Unable to load page: " + error.message;391+ throw error;392+ } finally { if (serial === navigationSerial) setBusy(false); }393+ };394+ document.addEventListener("fx:config", event => {395+ const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");396+ if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;397+ if ((cfg.trigger.type === "click" && cfg.trigger.button !== 0) || cfg.trigger.metaKey || cfg.trigger.ctrlKey || cfg.trigger.shiftKey || cfg.trigger.altKey) { cfg.drop = 1; cfg.preventTrigger = false; return; }398+ const serial = ++navigationSerial;399+ activeAbort?.();400+ activeAbort = cfg.abort;401+ cfg.__tinyNavigationSerial = serial;402+ cfg.target = document.querySelector("#content");403+ cfg.transition = false;404+ cfg.swap = async current => {405+ if (serial !== navigationSerial) return;406+ const url = new URL(cfg.action, location.href);407+ url.__tinyNavigationSerial = serial;408+ if (!cfg.response.ok || !swapShell(cfg.text, url, true)) {409+ const node = status();410+ if (node) node.textContent = "Unable to load page";411+ }412+ };413+ });414+ document.addEventListener("fx:before", event => {415+ if (event.detail.cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]")) setBusy(true);416+ });417+ document.addEventListener("fx:finally", event => {418+ if (event.detail.cfg.__tinyNavigationSerial === navigationSerial) setBusy(false);419+ });420+ document.addEventListener("fx:after", event => {421+ const cfg = event.detail.cfg;422+ if (cfg.__tinyNavigationSerial || cfg.response.ok) return;423+ event.preventDefault();424+ const node = status();425+ if (node) node.textContent = cfg.response.status === 401 ? "Sign in again to load this preview." : "Unable to load preview (" + cfg.response.status + ").";426+ });427+ document.addEventListener("fx:error", event => {428+ const cfg = event.detail.cfg;429+ if (cfg.__tinyNavigationSerial && cfg.__tinyNavigationSerial !== navigationSerial) return;430+ const node = status();431+ if (node) node.textContent = "Unable to load page: " + event.detail.error;432+ });433+ const decorate = () => document.querySelectorAll("a[href]").forEach(link => {434+ if (link.hasAttribute("fx-action") || link.closest("[fx-ignore]") || link.target || link.hasAttribute("download") || !sameDocument(link.href)) return;435+ link.setAttribute("data-fixi-nav", "");436+ link.setAttribute("fx-action", link.href);437+ link.setAttribute("fx-method", "get");438+ link.setAttribute("fx-trigger", "click");439+ });440+ const decorateForms = () => document.querySelectorAll("form[method=get][action]").forEach(form => {441+ if (form.hasAttribute("fx-action") || !sameDocument(form.action)) return;442+ form.setAttribute("data-fixi-nav", "");443+ form.setAttribute("fx-action", form.action);444+ form.setAttribute("fx-method", "get");445+ form.setAttribute("fx-trigger", "submit");446+ });447+ const prepare = () => {448+ decorate();449+ decorateForms();450+ // Fixi's DOMContentLoaded handler runs before this footer script. Ask451+ // it to initialize the attributes added after the initial scan.452+ document.dispatchEvent(new CustomEvent("fx:process"));453+ };454+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", prepare, {once: true});455+ else prepare();456+ window.addEventListener("popstate", () => load(new URL(location.href), false).catch(() => {}));457+ })();301458 })();
M internal/webui/page.html +4 -4
@@ -1,5 +1,5 @@
11 {{define "page"}}<!doctype html>2-<html lang="en">2+<html lang="en" data-js="no">33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -14,10 +14,10 @@
1414 <script src="/webmcp.js"></script>1515 </head>1616 <body>17-<header id="topbar"><button id="menu" type="button">menu</button> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>17+<header id="topbar"><details id="nav-menu"><summary id="menu" aria-controls="rail">menu</summary></details> <span><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></header>1818 <table id="layout" aria-label="{{.Slug}}"><tbody><tr>1919 <td id="rail"><div id="railbox">{{template "rail" .}}</div></td>20-<td id="main"><div id="content">20+<td id="main"><div id="content" tabindex="-1">2121 {{if .Private}}{{template "signin" .}}2222 {{else if eq .Tab "home"}}{{template "home" .}}2323 {{else if eq .Tab "inbox"}}{{template "inbox" .}}
@@ -55,7 +55,7 @@
5555 {{end}}</div></td>5656 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>5757 </tr></tbody></table>58-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>58+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>5959 <script>{{script "bridge.js"}}</script>6060 <script>{{script "components.js"}}</script>6161 </body>
M internal/webui/shell_test.go +119 -0
@@ -9,6 +9,8 @@ import (
99 "strings"1010 "testing"1111 12+ "golang.org/x/net/html"13+1214 "github.com/FelineStateMachine/tinyrelay/internal/policy"1315 )1416
@@ -50,6 +52,123 @@ func TestShellRendersRailPanelAndPrompt(t *testing.T) {
5052 }5153 }5254 55+func TestShellNavigationIsProgressiveAndAccessible(t *testing.T) {56+ backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}57+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})58+ if err != nil {59+ t.Fatal(err)60+ }61+ recorder := httptest.NewRecorder()62+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))63+ body := recorder.Body.String()64+ for _, want := range []string{65+ `data-js="no"`, `id="content" tabindex="-1"`,66+ `id="navigation-status" role="status" aria-live="polite"`,67+ } {68+ if !strings.Contains(body, want) {69+ t.Errorf("shell missing progressive navigation marker %q", want)70+ }71+ }72+ assertSharedNativeMenu(t, body, "/")73+74+ // The menu is part of the shell, so each viewer and rail context retains75+ // one native control and one shared navigation container.76+ for _, path := range []string{"/search", "/articles", "/files", "/repo?owner=alice&repo=notes&view=history", "/e/" + strings.Repeat("1", 64), "/manage/rules"} {77+ recorder := httptest.NewRecorder()78+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))79+ if recorder.Code != http.StatusOK {80+ t.Fatalf("%s: status=%d", path, recorder.Code)81+ }82+ assertSharedNativeMenu(t, recorder.Body.String(), path)83+ }84+85+ privatePolicy := policy.Defaults(strings.Repeat("b", 64))86+ privatePolicy.Reads = "members"87+ private := &privateFakeBackend{fakeBackend: &fakeBackend{policy: privatePolicy}, allowed: map[string]bool{}}88+ privateApp, err := New(private, Options{Actor: func(*http.Request) (string, error) { return "", nil }})89+ if err != nil {90+ t.Fatal(err)91+ }92+ recorder = httptest.NewRecorder()93+ privateApp.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/identity", nil))94+ if recorder.Code != http.StatusOK {95+ t.Fatalf("private shell: status=%d", recorder.Code)96+ }97+ assertSharedNativeMenu(t, recorder.Body.String(), "private")98+}99+100+func assertSharedNativeMenu(t *testing.T, body, path string) {101+ t.Helper()102+ doc, err := html.Parse(strings.NewReader(body))103+ if err != nil {104+ t.Fatalf("%s: parse shell: %v", path, err)105+ }106+ var details, summary, rail *html.Node107+ detailsCount, summaryCount, railCount := 0, 0, 0108+ var walk func(*html.Node)109+ walk = func(node *html.Node) {110+ if node.Type == html.ElementNode {111+ switch node.Data {112+ case "details":113+ if attr(node, "id") != "nav-menu" {114+ break115+ }116+ detailsCount++117+ if details == nil {118+ details = node119+ }120+ case "summary":121+ if attr(node, "id") != "menu" {122+ break123+ }124+ summaryCount++125+ if summary == nil {126+ summary = node127+ }128+ case "td":129+ if attr(node, "id") == "rail" {130+ railCount++131+ rail = node132+ }133+ }134+ }135+ for child := node.FirstChild; child != nil; child = child.NextSibling {136+ walk(child)137+ }138+ }139+ walk(doc)140+ if detailsCount != 1 || summaryCount != 1 || railCount != 1 {141+ t.Fatalf("%s: expected one details, summary and rail; got %d, %d, %d", path, detailsCount, summaryCount, railCount)142+ }143+ if hasAttr(details, "open") {144+ t.Errorf("%s: menu details must be nav-menu without open", path)145+ }146+ if summary.Parent != details || attr(summary, "id") != "menu" || attr(summary, "aria-controls") != "rail" {147+ t.Errorf("%s: summary must be the menu details child controlling rail", path)148+ }149+ if attr(rail, "id") != "rail" {150+ t.Errorf("%s: menu target must be the rail", path)151+ }152+}153+154+func attr(node *html.Node, name string) string {155+ for _, attribute := range node.Attr {156+ if attribute.Key == name {157+ return attribute.Val158+ }159+ }160+ return ""161+}162+163+func hasAttr(node *html.Node, name string) bool {164+ for _, attribute := range node.Attr {165+ if attribute.Key == name {166+ return true167+ }168+ }169+ return false170+}171+53172 func TestRepositoryHomeRendersReadmeAsMarkdown(t *testing.T) {54173 b := &browseBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}55174 app, err := New(b, Options{})
M internal/webui/style.css +11 -4
@@ -52,6 +52,8 @@ img{max-width:100%}
5252 5353 /* Panel */5454 #panel section{padding:10px 14px;border-bottom:1px solid var(--line);font:12px var(--mono)}55+#content:focus{outline:2px solid var(--accent);outline-offset:-2px}56+#navigation-status{color:var(--muted);font-size:11px}5557 #panel h4{margin:0 0 6px;font:12px var(--mono);color:var(--muted)}5658 #panel table{width:100%;border-collapse:collapse;margin:0}5759 #panel th{text-align:left;font-weight:normal;color:var(--muted);padding:2px 1ch 2px 0;border:0;white-space:nowrap;vertical-align:top}
@@ -171,15 +173,20 @@ diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-
171173 @media(max-width:720px){172174 html,body{overflow:auto}173175 #topbar{display:block;position:fixed;top:0;left:0;right:0;height:40px;padding:0 2.5ch;background:var(--rail);border-bottom:1px solid var(--line);font:13px/40px var(--mono);color:var(--muted);z-index:3;box-sizing:border-box;white-space:nowrap;overflow:hidden}174-#topbar b{color:var(--ink);font-weight:500}#menu{border:1px solid var(--line);padding:2px 10px;margin-right:10px;line-height:1.4}176+#topbar b{color:var(--ink);font-weight:500}177+#nav-menu{display:inline-block;margin-right:10px}178+#menu{display:block;list-style:none;border:1px solid var(--line);padding:2px 10px;line-height:1.4;cursor:pointer}179+#menu::-webkit-details-marker{display:none}175180 #layout,#layout>tbody,#layout>tbody>tr,#layout>tbody>tr>td{display:block;width:auto;height:auto}176181 #layout{padding:40px 0 28px}177182 #railbox,#content,#panel{position:static;overflow:visible}178-#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);transition:transform .15s;z-index:2;overflow:auto}179-html[data-menu=open] #layout>tbody>tr>td#rail{transform:none;box-shadow:8px 0 24px rgba(0,0,0,.15)}183+#layout>tbody>tr>td#rail{position:fixed;top:40px;bottom:28px;left:0;width:240px;height:auto;border-right:1px solid var(--line);transform:translateX(-102%);visibility:hidden;transition:transform .15s;z-index:2;overflow:auto}184+#topbar:has(#nav-menu[open]) + #layout>tbody>tr>td#rail{transform:none;visibility:visible;box-shadow:8px 0 24px rgba(0,0,0,.15)}180185 #layout>tbody>tr>td#aside{width:auto;border-left:0;border-top:1px solid var(--line)}181186 #content>section{padding:14px 2.5ch 20px}182-#prompt{display:none}#footer td:last-child{text-align:left}187+#prompt{display:none}#footer td:last-child{text-align:left;white-space:nowrap}188+#footer{padding:0 12px}#who{font-size:11px}189+#navigation-status:not(:empty),#session-status:not(:empty){position:absolute;bottom:100%;left:0;right:0;padding:6px 12px;background:var(--rail);border-top:1px solid var(--line);white-space:normal}183190 #split,#split>tbody,#split>tbody>tr,#split>tbody>tr>td{display:block;width:auto}184191 #tree{border-right:0;border-bottom:1px solid var(--line)}185192 #rows{table-layout:fixed}#rows td{white-space:normal;overflow-wrap:anywhere;height:auto;padding:8px 6px;line-height:1.35}#rows td:first-child{width:90px}#rows td:last-child{width:80px}
M scripts/signer.test.mjs +59 -32
@@ -4,44 +4,71 @@ import {readFile} from "node:fs/promises";
44 import test from "node:test";55 import vm from "node:vm";66 7-const source=await readFile(new URL("../internal/webui/webui.go",import.meta.url),"utf8");8-const bridge=source.split('const signerBridgeJS = `')[1].split('`')[0];7+const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");98 10-async function page({signedIn=false}={}) {11- const elements=new Map();12- for(const id of ["session-login","nostrconnect","nostrconnect-open","signer-qr","signer-status",...(signedIn?["session-logout"]:[])]) {13- elements.set(id,{hidden:true,addEventListener(event,handler){this[event]=handler;}});9+async function page({signedIn = false, bunkerResult = null} = {}) {10+ const elements = new Map();11+ for (const id of ["session-login", "nostrconnect", "nostrconnect-open", "signer-qr", "signer-status", "bunker", "bunker-url", ...(signedIn ? ["session-logout"] : [])]) {12+ elements.set(id, {hidden: true, value: "", addEventListener(event, handler) { this[event] = handler; }, setAttribute() {}, removeAttribute() {}});1413 }15- const saved=new Map();16- const requests=[];17- const signer={bp:{pubkey:"a".repeat(64),relays:["wss://tiny.example/r/work"]},getPublicKey:async()=>"a".repeat(64),signEvent:async event=>({...event,pubkey:"a".repeat(64),sig:"test"})};18- const sandbox={document:{getElementById:id=>elements.get(id)},crypto:webcrypto,TextEncoder,URL,Uint8Array,ArrayBuffer,19- btoa:value=>Buffer.from(value).toString("base64"),20- location:{pathname:"/r/work/signin",href:"https://tiny.example/r/work/signin",origin:"https://tiny.example",assign(url){sandbox.opened=url},reload(){sandbox.reloaded=true}},21- navigator:{clipboard:{writeText:async()=>{}}},22- sessionStorage:{getItem:key=>saved.get(key),setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)},23- NostrSigner:{generateSecretKey:()=>new Uint8Array(32),getPublicKey:()=>"b".repeat(64),createNostrConnectURI:()=>"nostrconnect://test",bytesToHex:()=>"test-key",BunkerSigner:{fromURI:async()=>signer}},24- fetch:async(url,options)=>{requests.push({url,options});return new Response("{}")}14+ const saved = new Map();15+ const requests = [];16+ const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};17+ const sandbox = {18+ document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, documentElement: {dataset: {}}},19+ crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,20+ btoa: value => Buffer.from(value).toString("base64"),21+ location: {pathname: "/r/work/signin", href: "https://tiny.example/r/work/signin", origin: "https://tiny.example", assign(url) { sandbox.opened = url; }, reload() { sandbox.reloaded = true; }},22+ navigator: {clipboard: {writeText: async () => {} }},23+ getComputedStyle: () => ({getPropertyValue: () => ""}),24+ matchMedia: () => ({matches: false, addEventListener() {}}),25+ localStorage: {getItem: () => null, setItem() {}},26+ sessionStorage: {getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value), removeItem: key => saved.delete(key)},27+ NostrSigner: {28+ generateSecretKey: () => new Uint8Array(32), getPublicKey: () => "b".repeat(64), createNostrConnectURI: () => "nostrconnect://test",29+ bytesToHex: () => "test-key", hexToBytes: () => new Uint8Array(32), parseBunkerInput: async () => bunkerResult,30+ BunkerSigner: {fromURI: async () => signer, fromBunker: () => signer}31+ },32+ fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }2533 };26- sandbox.window=sandbox;27- vm.runInNewContext(bridge,sandbox);28- return {elements,saved,requests,sandbox};34+ sandbox.window = sandbox;35+ vm.runInNewContext(bridge, sandbox);36+ await new Promise(resolve => setTimeout(resolve, 0));37+ return {elements, saved, requests, sandbox};2938 }3039 31-test("approving a phone signer signs in and opens the tenant homepage",async()=>{32- const {elements,saved,requests,sandbox}=await page();33- await elements.get("nostrconnect").click({preventDefault(){}});34- assert.equal(requests.length,1);35- assert.equal(requests[0].url,"/r/work/session");40+test("approving a phone signer signs in and opens the tenant homepage", async () => {41+ const {elements, saved, requests, sandbox} = await page();42+ await elements.get("nostrconnect").click({currentTarget: elements.get("nostrconnect"), preventDefault() {}});43+ assert.equal(requests.length, 1);44+ assert.equal(requests[0].url, "/r/work/session");3645 assert.ok(requests[0].options.headers.authorization.startsWith("Nostr "));37- assert.equal(sandbox.opened,"/r/work/");38- assert.equal(saved.get("tiny.bunker/r/work.identity"),"a".repeat(64));46+ assert.equal(sandbox.opened, "/r/work/");47+ assert.equal(saved.get("tiny.bunker/r/work.identity"), "a".repeat(64));3948 });4049 41-test("sign out revokes the session without requiring a connected phone",async()=>{42- const {elements,requests,sandbox}=await page({signedIn:true});43- await elements.get("session-logout").click();44- assert.equal(requests[0].url,"/r/work/session/logout");45- assert.equal(requests[0].options.credentials,"same-origin");46- assert.equal(sandbox.reloaded,true);50+test("invalid bunker input reports a useful error without connecting", async () => {51+ const {elements, requests} = await page({bunkerResult: null});52+ elements.get("bunker-url").value = "not-a-bunker";53+ await elements.get("bunker").submit({currentTarget: elements.get("bunker"), preventDefault() {}});54+ assert.equal(requests.length, 0);55+ assert.equal(elements.get("signer-status").textContent, "Remote signer error: Enter a valid bunker URL with at least one relay.");56+});57+58+test("NIP-07 signer can sign in directly", async () => {59+ const {elements, requests, sandbox} = await page();60+ sandbox.nostr = {signEvent: async event => ({...event, pubkey: "c".repeat(64), sig: "extension"})};61+ await elements.get("session-login").click({currentTarget: elements.get("session-login")});62+ assert.equal(requests.length, 1);63+ assert.equal(requests[0].url, "/r/work/session");64+ assert.match(elements.get("signer-status").textContent, /Signing in|Signed in/);65+ assert.equal(sandbox.opened, "/r/work/");66+});67+68+test("sign out revokes the session without requiring a connected phone", async () => {69+ const {elements, requests, sandbox} = await page({signedIn: true});70+ await elements.get("session-logout").click({currentTarget: elements.get("session-logout")});71+ assert.equal(requests[0].url, "/r/work/session/logout");72+ assert.equal(requests[0].options.credentials, "same-origin");73+ assert.equal(sandbox.reloaded, true);4774 });