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>
| M | docs/ | 8 | +- |
| M | internal/ | 201 | +++++++++++++++++++----------- |
| M | internal/ | 157 | +++++++++++++++++++++++ |
| M | internal/ | 8 | +- |
| M | internal/ | 119 | ++++++++++++++++++ |
| M | internal/ | 15 | ++- |
| M | scripts/ | 91 | +++++++++----- |
@@ -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.@@ -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 })();@@ -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 })();@@ -1,5 +1,5 @@
11 {{define "page"}}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> » {{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> » {{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> » {{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> » {{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>@@ -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{})@@ -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}@@ -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 });