Fix page headings

This commit is contained in:
Jonathon Leight
2026-07-30 08:46:19 -04:00
parent f49b16e354
commit 557d2e55b5
51 changed files with 389 additions and 132 deletions
+9 -9
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your account</div>
<h2 class="page-title">Account settings</h2>
<h1 class="page-title fs-1">Account settings</h1>
</div>
</div>
{{end}}
@@ -12,7 +12,7 @@
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="card">
<div class="card-header"><h3 class="card-title">Username</h3></div>
<div class="card-header"><h2 class="card-title">Username</h2></div>
<div class="card-body">
<p class="text-secondary">Your username is your unique handle (332 chars: letters, digits, <code>_ . -</code>). You can change it once every 30 days. When you do, your old username is reserved for 90 days before anyone else can take it.</p>
<form method="post" action="/account/username">
@@ -36,7 +36,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Display name</h3></div>
<div class="card-header"><h2 class="card-title">Display name</h2></div>
<div class="card-body">
<p class="text-secondary">Your display name is what other people see — on shares and in organizations. It can be anything, and it isn't tied to sign-in.</p>
<form method="post" action="/account/profile">
@@ -54,7 +54,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Time zone</h3></div>
<div class="card-header"><h2 class="card-title">Time zone</h2></div>
<div class="card-body">
<p class="text-secondary">Dates and times across MeshTender are shown in this time zone (and in your browser's locale format). Leave it on <strong>Auto-detect</strong> to use whatever zone your browser reports.</p>
<form method="post" action="/account/timezone">
@@ -82,7 +82,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Public profile</h3></div>
<div class="card-header"><h2 class="card-title">Public profile</h2></div>
<div class="card-body">
<p class="text-secondary">These appear on your public page at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>, which anyone can view. Leave a field blank to keep it off your page.</p>
<form method="post" action="/account/profile-fields">
@@ -108,7 +108,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links <span class="text-secondary">(public)</span></h3></div>
<div class="card-header"><h2 class="card-title">Links <span class="text-secondary">(public)</span></h2></div>
<div class="card-body">
<p class="text-secondary">Ways to reach or find you — social media, a website, or your MeshCore public key (shown as a QR code). Mark one non-MeshCore link as your <strong>primary contact</strong> so people know the best way to reach you.</p>
<form method="post" action="/account/links" data-link-editor>
@@ -174,7 +174,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Passkeys</h3></div>
<div class="card-header"><h2 class="card-title">Passkeys</h2></div>
<div class="card-body">
<p class="text-secondary">Passkeys let you sign in with your device's fingerprint, face, or PIN — no password to remember or leak.</p>
{{if .PKErr}}<div class="alert alert-danger">{{.PKErr}}</div>{{end}}
@@ -217,7 +217,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Password</h3></div>
<div class="card-header"><h2 class="card-title">Password</h2></div>
<div class="card-body">
{{if .HasPassword}}
<p class="text-secondary">Change the password you use as a fallback to passkeys.</p>
@@ -264,7 +264,7 @@
<div class="modal-content">
<form method="post" action="/account/passkeys/rename">
<div class="modal-header">
<h5 class="modal-title">Rename passkey</h5>
<h2 class="modal-title">Rename passkey</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
+1 -1
View File
@@ -2,7 +2,7 @@
{{define "content"}}
<div class="card card-md">
<div class="card-body">
<h2 class="card-title text-center mb-4">Sign in to your account</h2>
<h1 class="card-title text-center mb-4">Sign in to your account</h1>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<form method="post" action="/login/password" id="signin-form">
+1 -1
View File
@@ -2,7 +2,7 @@
{{define "content"}}
<div class="card card-md">
<div class="card-body">
<h2 class="card-title text-center mb-2">Create your account</h2>
<h1 class="card-title text-center mb-2">Create your account</h1>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary text-center mb-4">No email required. Passkeys are the easiest, most secure option — your device unlocks the account with a fingerprint, face, or PIN.</p>
+189
View File
@@ -0,0 +1,189 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"regexp"
"strconv"
"strings"
"testing"
"github.com/jleight/meshtender/internal/store"
)
var headingRe = regexp.MustCompile(`(?i)<h([1-6])\b`)
// checkHeadingOutline asserts the two properties that make a page navigable by
// heading: it has exactly one <h1> naming the page, and no level is skipped on the
// way down (an h1 followed by an h3 leaves a hole in the outline that screen-reader
// users navigating by level fall through).
func checkHeadingOutline(t *testing.T, label, html string) {
t.Helper()
var levels []int
for _, m := range headingRe.FindAllStringSubmatch(html, -1) {
n, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("%s: unparseable heading %q", label, m[0])
}
levels = append(levels, n)
}
if len(levels) == 0 {
t.Errorf("%s: page has no headings at all", label)
return
}
h1s := 0
for _, l := range levels {
if l == 1 {
h1s++
}
}
switch {
case h1s == 0:
t.Errorf("%s: no <h1> — the page has no top-level heading (outline starts at h%d)",
label, levels[0])
case h1s > 1:
t.Errorf("%s: %d <h1> elements — exactly one should name the page; sequence: %s",
label, h1s, seq(levels))
}
if levels[0] != 1 {
t.Errorf("%s: outline starts at h%d, not h1 (sequence: %s)", label, levels[0], seq(levels))
}
for i := 1; i < len(levels); i++ {
if levels[i] > levels[i-1]+1 {
t.Errorf("%s: heading level jumps h%d → h%d (skips h%d); sequence: %s",
label, levels[i-1], levels[i], levels[i-1]+1, seq(levels))
break // one report per page is enough to act on
}
}
}
func seq(levels []int) string {
parts := make([]string, len(levels))
for i, l := range levels {
parts[i] = fmt.Sprintf("h%d", l)
}
return strings.Join(parts, " ")
}
// TestPageHeadingOutlines walks a representative page from every layout and header
// source — the default base header, the repeater-tabs header, the org-tabs header,
// the auth layout (which has no page header, so the card title carries it), the
// public root layout, and the branded error page — and checks each one's heading
// outline.
//
// Regression for audit findings A1 (28 pages had no <h1>; page titles were
// <h2 class="page-title">) and A2 (the landing page ran h1 → h3 → h2, skipping a
// level and then jumping backwards).
func TestPageHeadingOutlines(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar: %v", err)
}
user := seedSession(t, ts, st, ctx, jar, "headinguser")
cookies := jar.Cookies(mustURL(t, ts.URL))
rep, err := st.CreateRepeater(ctx, &store.Repeater{
OwnerID: user.ID, Name: "Heading Test Relay", PublicKeyHex: strings.Repeat("e", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
org, err := st.CreateOrg(ctx, "Heading Test Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
pages := []struct {
label, host, path string
anon bool // fetch without the session cookie
}{
// Root (public) host — the landing page is A2's subject.
{"root landing", h.root, "/", false},
{"root org directory", h.root, "/orgs", false},
{"root docs", h.root, "/docs", false},
{"root public org page", h.root, "/orgs/" + org.Slug, false},
{"root 404 (error page)", h.root, "/no-such-page", false},
// Auth host — authbase has no page header, so the card title is the heading.
{"auth login", h.auth, "/login", true},
{"auth signup", h.auth, "/signup", true},
// App host — covers base's own header, repeater-tabs and org-tabs headers.
{"app dashboard", h.app, "/", false},
{"app repeater list", h.app, "/repeaters", false},
{"app repeater overview", h.app, "/repeaters/" + rep.PublicID, false},
{"app repeater sharing", h.app, "/repeaters/" + rep.PublicID + "/share", false},
{"app my orgs", h.app, "/orgs", false},
{"app org member view", h.app, "/orgs/" + org.Slug, false},
{"app 404 (error page)", h.app, "/no-such-page", false},
}
for _, p := range pages {
send := cookies
if p.anon {
send = nil
}
resp := do(t, ts, p.host, p.path, send...)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("%s: read body: %v", p.label, err)
}
// A redirect would mean the fixture didn't grant access; the outline of a
// redirect body is meaningless, so surface it rather than silently passing.
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
t.Fatalf("%s: unexpected %d redirect to %q — fixture problem",
p.label, resp.StatusCode, resp.Header.Get("Location"))
}
checkHeadingOutline(t, p.label, string(body))
}
}
// TestModalFragmentHeadings covers the htmx modal fragments separately: they're
// swapped into an already-rendered page, so their heading has to fit under that
// page's outline (h1 page → h2 dialog title) rather than restart it.
func TestModalFragmentHeadings(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar: %v", err)
}
user := seedSession(t, ts, st, ctx, jar, "modaluser")
cookies := jar.Cookies(mustURL(t, ts.URL))
rep, err := st.CreateRepeater(ctx, &store.Repeater{
OwnerID: user.ID, Name: "Modal Test Relay", PublicKeyHex: strings.Repeat("f", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
// The invite modal is the representative fragment; all modal titles share the
// same convention.
resp := do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/share/link/new", cookies...)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("invite modal fragment = %d, want 200", resp.StatusCode)
}
found := headingRe.FindAllStringSubmatch(string(body), -1)
if len(found) == 0 {
t.Fatal("invite modal fragment has no heading — the dialog has nothing to label it")
}
if got := found[0][1]; got != "2" {
t.Errorf("modal title is h%s, want h2 (it sits under the page's h1; an h5 there "+
"would skip three levels)", got)
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
{{define "title"}}Add a repeater · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Add a repeater</h2></div>
<div class="col"><h1 class="page-title fs-1">Add a repeater</h1></div>
</div>
{{end}}
{{define "content"}}
@@ -27,7 +27,7 @@
<div class="col-md-6">
<div class="card card-link h-100">
<div class="card-body d-flex flex-column">
<h3 class="card-title">{{template "icon-antenna" "me-1"}}Add a repeater that's already on the network</h3>
<h2 class="card-title">{{template "icon-antenna" "me-1"}}Add a repeater that's already on the network</h2>
<p class="text-secondary">Register a repeater that's already configured and on the mesh, then control it
remotely through a <strong>KISS modem</strong> over the air. You'll grant MeshTender admin yourself first.
This is the usual choice.</p>
@@ -38,7 +38,7 @@
<div class="col-md-6">
<div class="card card-link h-100">
<div class="card-body d-flex flex-column">
<h3 class="card-title">{{template "icon-plug" "me-1"}}Set up a brand-new repeater</h3>
<h2 class="card-title">{{template "icon-plug" "me-1"}}Set up a brand-new repeater</h2>
<p class="text-secondary">Connect a freshly-flashed repeater over <strong>USB serial</strong> and let
MeshTender generate and run the whole setup — identity, radio, region, name, and the admin grant —
straight down the wire. Best for a repeater that isn't on the network yet.</p>
@@ -64,7 +64,7 @@
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h4 class="alert-title">This grants full admin control to MeshTender</h4>
<h3 class="alert-title">This grants full admin control to MeshTender</h3>
<ul class="mb-0 text-secondary">
<li>This grants admin to <strong>MeshTender itself</strong><strong>not</strong> to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
@@ -84,7 +84,7 @@
<div class="d-flex">
<div>{{template "icon-map-pin" "alert-icon"}}</div>
<div>
<h4 class="alert-title">MeshTender will read the repeater's location</h4>
<h3 class="alert-title">MeshTender will read the repeater's location</h3>
<p class="text-secondary mb-0">
When you confirm this repeater — or use “Fetch location” later — MeshTender reads its coordinates by
running <code>get lat</code> / <code>get lon</code> through your modem, <strong>on its own behalf</strong>
@@ -251,7 +251,7 @@
{{/* Review + run */}}
<div class="card mt-3" id="review-card" hidden>
<div class="card-header"><h3 class="card-title">Step 4 · Review &amp; run</h3></div>
<div class="card-header"><h2 class="card-title">Step 4 · Review &amp; run</h2></div>
<div class="card-body">
<p class="text-secondary">These commands will be sent to your repeater over USB, in order. The private key is
shown masked; it never leaves your browser.</p>
+5 -5
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Admin</h2>
<h1 class="page-title fs-1">Admin</h1>
</div>
</div>
{{end}}
@@ -13,7 +13,7 @@
<div class="col-md-6">
<a class="card card-link" href="/admin/catalog">
<div class="card-body">
<h3 class="card-title">{{template "icon-list" "me-1"}}Command catalog</h3>
<h2 class="card-title">{{template "icon-list" "me-1"}}Command catalog</h2>
<p class="text-secondary mb-0">Edit risk tags and the default command sets.</p>
</div>
</a>
@@ -23,7 +23,7 @@
<div class="col-md-6">
<a class="card card-link" href="/admin/users">
<div class="card-body">
<h3 class="card-title">{{template "icon-users" "me-1"}}Users &amp; capabilities</h3>
<h2 class="card-title">{{template "icon-users" "me-1"}}Users &amp; capabilities</h2>
<p class="text-secondary mb-0">Grant or revoke admin capabilities.</p>
</div>
</a>
@@ -32,7 +32,7 @@
<div class="col-md-6">
<a class="card card-link" href="/admin/analytics">
<div class="card-body">
<h3 class="card-title">{{template "icon-list" "me-1"}}Traffic analytics</h3>
<h2 class="card-title">{{template "icon-list" "me-1"}}Traffic analytics</h2>
<p class="text-secondary mb-0">Visits, unique visitors, busiest pages, and traffic by surface.</p>
</div>
</a>
@@ -40,7 +40,7 @@
<div class="col-md-6">
<a class="card card-link" href="/admin/proxy-test">
<div class="card-body">
<h3 class="card-title">{{template "icon-world" "me-1"}}Reverse proxy test</h3>
<h2 class="card-title">{{template "icon-world" "me-1"}}Reverse proxy test</h2>
<p class="text-secondary mb-0">Inspect forwarding headers and the client IP the app records.</p>
</div>
</a>
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Command catalog</h2>
<h1 class="page-title fs-1">Command catalog</h1>
</div>
</div>
{{end}}
@@ -23,7 +23,7 @@
{{range .Groups}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">{{.Name}}</h3></div>
<div class="card-header"><h2 class="card-title">{{.Name}}</h2></div>
<div class="table-responsive">
<table class="table table-vcenter card-table catalog-table">
<thead>
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Username history</h2>
<h1 class="page-title fs-1">Username history</h1>
</div>
</div>
{{end}}
@@ -35,7 +35,7 @@
{{/* history-modal is the htmx fragment swapped into the shared modal's content. */}}
{{define "history-modal"}}
<div class="modal-header">
<h5 class="modal-title">Username history — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h5>
<h2 class="modal-title">Username history — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -3,7 +3,7 @@
closes the modal — no extra client JS needed. */}}
{{define "permissions-modal"}}
<div class="modal-header">
<h5 class="modal-title">Permissions — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h5>
<h2 class="modal-title">Permissions — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/admin/users/{{.Account.ID}}">
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Users &amp; capabilities</h2>
<h1 class="page-title fs-1">Users &amp; capabilities</h1>
</div>
</div>
{{end}}
+7 -7
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Traffic Analytics</h2>
<h1 class="page-title fs-1">Traffic Analytics</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-group" role="group" aria-label="Time range">
@@ -47,7 +47,7 @@
<div class="row row-cards mt-1">
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Requests per day</h3></div>
<div class="card-header"><h2 class="card-title">Requests per day</h2></div>
<div class="card-body">
<div class="d-flex align-items-end gap-1" style="height:140px">
{{range .Bars}}<div class="flex-fill rounded-top" style="height:{{.ReqH}}%;min-height:1px;background:var(--tblr-primary)" title="{{.Label}}: {{.Requests}} requests"></div>{{end}}
@@ -57,7 +57,7 @@
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header"><h3 class="card-title">Unique visitors per day</h3></div>
<div class="card-header"><h2 class="card-title">Unique visitors per day</h2></div>
<div class="card-body">
<div class="d-flex align-items-end gap-1" style="height:140px">
{{range .Bars}}<div class="flex-fill rounded-top" style="height:{{.VisH}}%;min-height:1px;background:var(--tblr-green)" title="{{.Label}}: {{.Visitors}} visitors"></div>{{end}}
@@ -71,7 +71,7 @@
<div class="row row-cards mt-1">
<div class="col-lg-5">
<div class="card">
<div class="card-header"><h3 class="card-title">Traffic by surface</h3></div>
<div class="card-header"><h2 class="card-title">Traffic by surface</h2></div>
<div class="card-body">
{{if .Surfaces}}{{range .Surfaces}}{{template "value-bar" .}}{{end}}
{{else}}<div class="text-secondary">No data.</div>{{end}}
@@ -80,7 +80,7 @@
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header"><h3 class="card-title">Busiest pages</h3></div>
<div class="card-header"><h2 class="card-title">Busiest pages</h2></div>
<div class="card-body">
{{if .Paths}}{{range .Paths}}{{template "value-bar" .}}{{end}}
{{else}}<div class="text-secondary">No data.</div>{{end}}
@@ -93,7 +93,7 @@
<div class="row row-cards mt-1">
<div class="col-lg-5">
<div class="card">
<div class="card-header"><h3 class="card-title">Traffic by host</h3></div>
<div class="card-header"><h2 class="card-title">Traffic by host</h2></div>
<div class="card-body">
<p class="text-secondary small">“custom” is any request whose host isn't your configured app/auth/root host — e.g. the bare IP, a health probe, or a crawler. These are the actual hosts seen.</p>
{{if .Hosts}}{{range .Hosts}}{{template "value-bar" .}}{{end}}
@@ -103,7 +103,7 @@
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header"><h3 class="card-title">Top visitors</h3></div>
<div class="card-header"><h2 class="card-title">Top visitors</h2></div>
<div class="card-body">
<p class="text-secondary small">Anonymous, daily-rotating IDs (a salted hash of IP + browser). The same person gets a fresh ID each day, so a heavy daily visitor shows once per day.</p>
{{if .Visitors}}
+1 -1
View File
@@ -18,7 +18,7 @@
<div class="card timeline-event-card">
<div class="card-body">
<div class="text-secondary float-end">{{ts .StartedAt "datetime"}}{{if .EndedAt}}{{ts .EndedAt "time"}}{{else}} · in progress{{end}}</div>
<h4 class="mb-2">{{if .SenderUsername}}<a class="text-reset" href="{{$.RootURL}}/u/{{.SenderUsername}}">{{.SenderName}}</a>{{else}}{{.SenderName}}{{end}}</h4>
<h2 class="mb-2 fs-3">{{if .SenderUsername}}<a class="text-reset" href="{{$.RootURL}}/u/{{.SenderUsername}}">{{.SenderName}}</a>{{else}}{{.SenderName}}{{end}}</h2>
<div class="table-responsive">
<table class="table table-vcenter">
<thead><tr><th>When</th><th>Command</th><th>Confirmed</th><th>Response</th></tr></thead>
+3 -3
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Edit configuration</h2>
<h1 class="page-title fs-1">Edit configuration</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config">Back to configuration</a>
@@ -16,7 +16,7 @@
<div class="card">
<div class="card-header">
<div>
<h3 class="card-title">Profiles</h3>
<h2 class="card-title">Profiles</h2>
<div class="text-secondary small">Named sets of base settings — e.g. one per board family. Members pick the profile matching their use case.</div>
</div>
<div class="card-actions">
@@ -46,7 +46,7 @@
<div class="card mt-3">
<div class="card-header">
<div>
<h3 class="card-title">Regions</h3>
<h2 class="card-title">Regions</h2>
<div class="text-secondary small">Named areas drawn on a map. A repeater is auto-assigned to every region whose area contains it, forming its <code>region def</code>.</div>
</div>
<div class="card-actions">
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h2 class="page-title">{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}</h2>
<h1 class="page-title fs-1">{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}} · Configuration</div>
<h2 class="page-title">Edit regions</h2>
<h1 class="page-title fs-1">Edit regions</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.Slug}}/config/edit">Back to configuration</a>
+4 -4
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Console</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
<h1 class="page-title fs-1">{{.Repeater.Name}}</h1>
</div>
</div>
{{end}}
@@ -47,7 +47,7 @@ swap needs no new markup. The flex row is nested so the [hidden] attribute wins
<div class="row row-cards">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Console</h3></div>
<div class="card-header"><h2 class="card-title">Console</h2></div>
<div class="card-body">
<div id="unsupported" class="alert alert-danger" role="alert" hidden>
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
@@ -75,7 +75,7 @@ swap needs no new markup. The flex row is nested so the [hidden] attribute wins
data-config-url="/repeaters/{{.Repeater.PublicID}}/config.json"
data-location-url="/repeaters/{{.Repeater.PublicID}}/location">
<div class="card">
<div class="card-header"><h3 class="card-title">Apply organization configuration</h3></div>
<div class="card-header"><h2 class="card-title">Apply organization configuration</h2></div>
<div class="card-body">
<p class="text-secondary">
Pick an organization and profile to see its recommended configuration for this repeater. Every command is
@@ -109,7 +109,7 @@ swap needs no new markup. The flex row is nested so the [hidden] attribute wins
</div>
<div class="col-12 col-lg-4">
<div class="card" data-testid="allowed-commands">
<div class="card-header"><h3 class="card-title">Allowed commands</h3></div>
<div class="card-header"><h2 class="card-title">Allowed commands</h2></div>
<div class="card-body">
{{if .Commands}}
<div class="d-flex flex-wrap gap-1" style="max-height:360px;overflow-y:auto">
+4 -4
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Dashboard</div>
<h2 class="page-title">Welcome back{{if .UserName}}, {{.UserName}}{{end}}</h2>
<h1 class="page-title fs-1">Welcome back{{if .UserName}}, {{.UserName}}{{end}}</h1>
</div>
</div>
{{end}}
@@ -70,7 +70,7 @@
{{if .Onboarding}}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Get started</h3>
<h2 class="card-title">Get started</h2>
<div class="card-actions text-secondary small">{{.OnboardingDone}} of {{.OnboardingTotal}} done</div>
</div>
<div class="list-group list-group-flush">
@@ -95,7 +95,7 @@
<div class="col-lg-8">
<!-- Recent activity -->
<div class="card">
<div class="card-header"><h3 class="card-title">Recent activity</h3></div>
<div class="card-header"><h2 class="card-title">Recent activity</h2></div>
{{if .Recent}}
<div class="table-responsive">
<table class="table table-vcenter card-table">
@@ -123,7 +123,7 @@
<div class="col-lg-4">
<!-- Map -->
<div class="card">
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
<div class="card-header"><h2 class="card-title">Repeater map</h2></div>
{{if .Mapped}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
+2 -2
View File
@@ -1,7 +1,7 @@
{{define "title"}}Delete {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Delete “{{.Repeater.Name}}”?</h2></div>
<div class="col"><h1 class="page-title fs-1">Delete “{{.Repeater.Name}}”?</h1></div>
</div>
{{end}}
{{define "content"}}
@@ -18,7 +18,7 @@
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h4 class="alert-title">This does not revoke MeshTender's access</h4>
<h3 class="alert-title">This does not revoke MeshTender's access</h3>
<div class="text-secondary">Your repeater still trusts MeshTender's identity at the firmware level. If you want MeshTender to truly lose access, revoke it on the device too — see below.</div>
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Edit repeater</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
<h1 class="page-title fs-1">{{.Repeater.Name}}</h1>
</div>
</div>
{{end}}
+1 -1
View File
@@ -1,7 +1,7 @@
{{define "title"}}Repeater invite · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Repeater invite</h2></div>
<div class="col"><h1 class="page-title fs-1">Repeater invite</h1></div>
</div>
{{end}}
{{define "content"}}
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization</div>
<h2 class="page-title">Join {{.Org.Name}}</h2>
<h1 class="page-title fs-1">Join {{.Org.Name}}</h1>
</div>
</div>
{{end}}
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your network</div>
<h2 class="page-title">Organizations</h2>
<h1 class="page-title fs-1">Organizations</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organizations</div>
<h2 class="page-title">Create an organization</h2>
<h1 class="page-title fs-1">Create an organization</h1>
</div>
</div>
{{end}}
+4 -4
View File
@@ -56,7 +56,7 @@
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">About</h3>
<h2 class="card-title">About</h2>
{{if .IsAdmin}}
<div class="card-actions">
<button type="button" class="btn btn-sm" data-testid="edit-profile"
@@ -77,7 +77,7 @@
{{if or .Links .IsAdmin}}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Links</h3>
<h2 class="card-title">Links</h2>
{{if .IsAdmin}}
<div class="card-actions">
<button type="button" class="btn btn-sm" data-testid="edit-links"
@@ -114,7 +114,7 @@
place yet. Re-enable this card (plus the domains data in pageOrg and the
/domains routes in web.go) once it is.
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Custom domains</h3></div>
<div class="card-header"><h2 class="card-title">Custom domains</h2></div>
<div class="card-body">
<p class="text-secondary">
Point your own domain at MeshTender (CNAME it to this app). Once verified, visiting that
@@ -169,7 +169,7 @@
<div class="col-lg-4">
{{if .HasMap}}
<div class="card">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
<div class="card-header"><h2 class="card-title">Repeaters</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<div id="map" style="height:260px" role="region" aria-label="Map of organization repeater locations"></div>
+1 -1
View File
@@ -6,7 +6,7 @@
from the window.MESHTENDER_LINK_PLATFORMS global set on the page. */}}
{{define "org-links-modal"}}
<div class="modal-header">
<h5 class="modal-title">Edit links</h5>
<h2 class="modal-title">Edit links</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -4,7 +4,7 @@
HX-Redirect to reload the org page. The plain action= is the no-JS fallback. */}}
{{define "org-profile-modal"}}
<div class="modal-header">
<h5 class="modal-title">Edit profile</h5>
<h2 class="modal-title">Edit profile</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
+2 -2
View File
@@ -8,7 +8,7 @@
<!-- Left: member list -->
<div class="col-lg-8 col-xl-9">
<div class="card">
<div class="card-header"><h3 class="card-title">{{len .Members}} member{{if ne (len .Members) 1}}s{{end}}</h3></div>
<div class="card-header"><h2 class="card-title">{{len .Members}} member{{if ne (len .Members) 1}}s{{end}}</h2></div>
<div class="list-group list-group-flush" id="member-list">
{{range .Members}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2" data-role="{{.Role}}" data-name="{{.Name}} @{{.Username}}">
@@ -37,7 +37,7 @@
<!-- Right: search + filter sidebar -->
<div class="col-lg-4 col-xl-3">
<div class="card">
<div class="card-header"><h3 class="card-title">Find members</h3></div>
<div class="card-header"><h2 class="card-title">Find members</h2></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="member-search">Search</label>
+1 -1
View File
@@ -5,7 +5,7 @@
buttons reference their form via form=. No client JS. */}}
{{define "person-access-modal"}}
<div class="modal-header">
<h5 class="modal-title">Manage access — {{.Target.Name}} <span class="text-secondary">@{{.Target.Username}}</span></h5>
<h2 class="modal-title">Manage access — {{.Target.Name}} <span class="text-secondary">@{{.Target.Username}}</span></h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
+5 -5
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Reverse proxy test</h2>
<h1 class="page-title fs-1">Reverse proxy test</h1>
</div>
</div>
{{end}}
@@ -49,7 +49,7 @@
{{end}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Trusted-proxy resolution</h3></div>
<div class="card-header"><h2 class="card-title">Trusted-proxy resolution</h2></div>
<div class="card-body">
<div class="mb-3">
<div class="form-label">Connecting peer</div>
@@ -80,7 +80,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Forwarding headers</h3></div>
<div class="card-header"><h2 class="card-title">Forwarding headers</h2></div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Header</th><th>Value</th></tr></thead>
@@ -97,7 +97,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Connection</h3></div>
<div class="card-header"><h2 class="card-title">Connection</h2></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item"><div class="datagrid-title">Host</div><div class="datagrid-content font-monospace">{{.Host}}</div></div>
@@ -110,7 +110,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">All request headers</h3>
<div class="card-header"><h2 class="card-title">All request headers</h2>
<span class="card-subtitle ms-auto">credentials redacted</span>
</div>
<div class="table-responsive">
+5 -5
View File
@@ -9,7 +9,7 @@
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Details</h3>
<h2 class="card-title">Details</h2>
</div>
<div class="card-body">
<div class="datagrid">
@@ -57,7 +57,7 @@
{{if .IsOwner}}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Organizations</h3>
<h2 class="card-title">Organizations</h2>
<div class="card-actions"><a href="/repeaters/{{.Repeater.PublicID}}/share" class="btn btn-sm">Manage</a></div>
</div>
{{if .Orgs}}
@@ -85,7 +85,7 @@
<!-- Right column -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Identity</h3></div>
<div class="card-header"><h2 class="card-title">Identity</h2></div>
<div class="card-body">
<div class="form-label">Public key</div>
<div class="d-flex align-items-start gap-2">
@@ -107,7 +107,7 @@
{{if .IsOwner}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Public page</h3></div>
<div class="card-header"><h2 class="card-title">Public page</h2></div>
<div class="card-body">
{{if and .Repeater.ExposePublicPage .PublicPageURL}}
<p class="text-secondary small">Anyone with this link (or who taps an NFC tag / scans the QR inside the enclosure) sees the public documentation and stewards.</p>
@@ -133,7 +133,7 @@
{{if .Repeater.Latitude}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Location</h3></div>
<div class="card-header"><h2 class="card-title">Location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<div id="map" style="height:240px" role="region" aria-label="Map of this repeater's location"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{{define "title"}}Repeater added · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">“{{.Repeater.Name}}” added</h2></div>
<div class="col"><h1 class="page-title fs-1">“{{.Repeater.Name}}” added</h1></div>
</div>
{{end}}
{{define "content"}}
@@ -25,7 +25,7 @@
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Confirm it works <span class="text-secondary">(optional)</span></h3>
<h2 class="card-title">Confirm it works <span class="text-secondary">(optional)</span></h2>
<p class="text-secondary">
Connect a MeshCore KISS modem and have MeshTender login to verify it has admin access. Confirming
now marks it <span class="badge bg-azure-lt">Self-confirmed</span>. Later, if another member reaches it
@@ -42,7 +42,7 @@
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Organizations <span class="text-secondary">(optional)</span></h3>
<h2 class="card-title">Organizations <span class="text-secondary">(optional)</span></h2>
{{if .Orgs}}
<p class="text-secondary">
This repeater is now shared with the organizations you belong to, so their admins and members can run
+4 -4
View File
@@ -8,7 +8,7 @@
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/docs">
<div class="card">
<div class="card-header">
<h3 class="card-title">Public documentation {{if .Repeater.ExposePublicPage}}<span class="text-secondary">· visible on the public page</span>{{else}}<span class="text-secondary">· not published yet</span>{{end}}</h3>
<h2 class="card-title">Public documentation {{if .Repeater.ExposePublicPage}}<span class="text-secondary">· visible on the public page</span>{{else}}<span class="text-secondary">· not published yet</span>{{end}}</h2>
</div>
<div class="card-body">
<p class="text-secondary">
@@ -22,7 +22,7 @@
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Internal notes <span class="text-secondary">· people with access only</span></h3>
<h2 class="card-title">Internal notes <span class="text-secondary">· people with access only</span></h2>
</div>
<div class="card-body">
<p class="text-secondary">
@@ -39,7 +39,7 @@
</form>
{{else}}
<div class="card">
<div class="card-header"><h3 class="card-title">Documentation</h3></div>
<div class="card-header"><h2 class="card-title">Documentation</h2></div>
<div class="card-body">
{{if .Repeater.DocPublic}}
<div style="white-space: pre-wrap">{{.Repeater.DocPublic}}</div>
@@ -51,7 +51,7 @@
{{if .Repeater.DocInternal}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Internal notes <span class="text-secondary">· people with access only</span></h3></div>
<div class="card-header"><h2 class="card-title">Internal notes <span class="text-secondary">· people with access only</span></h2></div>
<div class="card-body">
<div style="white-space: pre-wrap">{{.Repeater.DocInternal}}</div>
</div>
@@ -6,7 +6,7 @@
<div class="card">
<div class="card-body">
<h3 class="card-title">Log maintenance</h3>
<h2 class="card-title">Log maintenance</h2>
<p class="text-secondary">
Record physical service work — antenna swaps, battery/solar changes, firmware updates, site visits —
so the history outlives any one person. Anyone with access can log an entry.
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your equipment</div>
<h2 class="page-title">Repeaters</h2>
<h1 class="page-title fs-1">Repeaters</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="/repeaters/add">{{template "icon-plus" "me-1"}}Add repeater</a>
@@ -31,7 +31,7 @@
<span class="avatar bg-blue-lt d-none d-sm-flex">{{template "icon-antenna" ""}}</span>
<div class="flex-fill" style="min-width:0">
<div class="d-flex align-items-center flex-wrap gap-2">
<h3 class="m-0 fs-3">{{.Name}}</h3>
<h2 class="m-0 fs-3">{{.Name}}</h2>
{{if .Shared}}<span class="badge bg-purple-lt">Shared with me</span>{{else}}<span class="badge bg-blue-lt">Owned</span>{{end}}
{{template "repstatus" .}}
</div>
+3 -3
View File
@@ -17,7 +17,7 @@
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<h3 class="card-title">Share links</h3>
<h2 class="card-title">Share links</h2>
<div class="card-actions">
<button type="button" class="btn btn-primary" data-testid="new-invite"
data-bs-toggle="modal" data-bs-target="#invite-modal"
@@ -76,7 +76,7 @@
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">People with access</h3>
<h2 class="card-title">People with access</h2>
<p class="text-secondary">
Mark a trusted person a <strong>steward</strong> to make them a co-operator: they can run
<strong>every</strong> command (including risky ones), just like you, and they're listed as a
@@ -107,7 +107,7 @@
<div class="col-lg-5">
<div class="card">
<div class="card-body">
<h3 class="card-title">Organizations</h3>
<h2 class="card-title">Organizations</h2>
<p class="text-secondary">
This repeater is shared with every organization you belong to, so their admins and members can run the
commands those organizations are permitted to run on it. Use <strong>Manage access</strong> to opt it
@@ -3,7 +3,7 @@
page), which also closes the modal — no client JS needed. */}}
{{define "invite-new-modal"}}
<div class="modal-header">
<h5 class="modal-title">Create single-use link</h5>
<h2 class="modal-title">Create single-use link</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -5,7 +5,7 @@
modal — no client JS needed. */}}
{{define "org-limits-modal"}}
<div class="modal-header">
<h5 class="modal-title">Manage access — {{.Org.Name}}</h5>
<h2 class="modal-title">Manage access — {{.Org.Name}}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@@ -30,7 +30,7 @@
<div class="d-flex">
<div class="me-2">{{template "icon-alert" ""}}</div>
<div>
<h4 class="alert-title">This repeater is opted out of {{.Org.Name}}</h4>
<h3 class="alert-title">This repeater is opted out of {{.Org.Name}}</h3>
<p class="mb-0">
While it's opted out, <strong>no one</strong> in {{.Org.Name}} can run <strong>any</strong>
command below. You can still set these limits now — turn on
+8 -8
View File
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Help</div>
<h2 class="page-title">How MeshTender works</h2>
<h1 class="page-title fs-1">How MeshTender works</h1>
</div>
</div>
{{end}}
@@ -19,7 +19,7 @@
</div>
<div class="card mt-3" id="setperm">
<div class="card-header"><h3 class="card-title">Granting MeshTender admin (<code>setperm</code>)</h3></div>
<div class="card-header"><h2 class="card-title">Granting MeshTender admin (<code>setperm</code>)</h2></div>
<div class="card-body">
<p>MeshTender signs in to a repeater as its server identity — one fixed public key. Until a repeater grants that key <strong>admin</strong> (permission level 3), MeshTender only has <strong>guest</strong> access — open to anyone with a blank password — and can't administer the repeater.</p>
<p>Run this on your repeater — through the <strong>repeater console in the MeshCore companion app</strong>, directly over USB serial, or let MeshTender run it for you during USB setup:</p>
@@ -36,7 +36,7 @@
</div>
<div class="card mt-3" id="add">
<div class="card-header"><h3 class="card-title">Adding a repeater</h3></div>
<div class="card-header"><h2 class="card-title">Adding a repeater</h2></div>
<div class="card-body">
<p>There are two ways in, matching how you reach the repeater:</p>
<ul class="mb-2">
@@ -49,7 +49,7 @@
</div>
<div class="card mt-3" id="kiss">
<div class="card-header"><h3 class="card-title">What's a KISS modem?</h3></div>
<div class="card-header"><h2 class="card-title">What's a KISS modem?</h2></div>
<div class="card-body">
<p>A <strong>KISS modem</strong> turns a MeshCore device into a plain radio for a host to drive: instead of acting as a repeater or companion, it just relays raw packets to and from whatever's connected to it. That's how MeshTender talks to your mesh <strong>over the air</strong> — you attach a KISS-modem device to your browser over USB serial, and MeshTender uses it as its radio to reach repeaters that are already deployed (the <a href="#add">over-the-air</a> flow above).</p>
<p>It's a relatively new addition to MeshCore, and it's a <strong>standard firmware variant</strong> — you flash it right from the standard MeshCore flasher, the same place you'd flash Repeater or a Companion. It's available for a <strong>large number</strong> of MeshCore devices, though <strong>not all</strong> of them — check the flasher for your specific board.</p>
@@ -58,7 +58,7 @@
</div>
<div class="card mt-3" id="config">
<div class="card-header"><h3 class="card-title">Organizations</h3></div>
<div class="card-header"><h2 class="card-title">Organizations</h2></div>
<div class="card-body">
<p>An <strong>organization</strong> is a community that coordinates repeaters together. It helps you generate the commands to run to configure your repeater properly, and share settings with others in your area.</p>
<p class="mb-0">Browse communities on the <a href="/orgs">Organizations</a> page.</p>
@@ -66,7 +66,7 @@
</div>
<div class="card mt-3" id="sharing">
<div class="card-header"><h3 class="card-title">Sharing &amp; stewards</h3></div>
<div class="card-header"><h2 class="card-title">Sharing &amp; stewards</h2></div>
<div class="card-body">
<p class="mb-0">You can share a repeater with other people so they can help operate it, and mark trusted helpers as <strong>stewards</strong> — co-maintainers listed on the repeater's public page. Public repeater and organization pages link to each person's <strong>public profile</strong>, so visitors can find a way to reach the people who keep the mesh running.</p>
</div>
@@ -76,7 +76,7 @@
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">On this page</h3></div>
<div class="card-header"><h2 class="card-title">On this page</h2></div>
<div class="list-group list-group-flush">
<a class="list-group-item list-group-item-action" href="#setperm">Granting admin (setperm)</a>
<a class="list-group-item list-group-item-action" href="#add">Adding a repeater</a>
@@ -87,7 +87,7 @@
</div>
{{if .ServerKey}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">MeshTender's key</h3></div>
<div class="card-header"><h2 class="card-title">MeshTender's key</h2></div>
<div class="card-body">
<div class="form-label">MeshTender server public key</div>
<code class="pk d-block">{{.ServerKey}}</code>
+6 -6
View File
@@ -33,7 +33,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-key" ""}}</span>
<h3 class="card-title m-0">No password sharing</h3>
<h2 class="card-title m-0">No password sharing</h2>
</div>
<p class="text-secondary mb-0">Grant MeshTender access with its own public key — your repeater's admin password never leaves your hands.</p>
</div>
@@ -44,7 +44,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-settings" ""}}</span>
<h3 class="card-title m-0">Recommended configuration</h3>
<h2 class="card-title m-0">Recommended configuration</h2>
</div>
<p class="text-secondary mb-0">Organizations publish recommended configuration profiles — including region-specific settings — so members can bring every repeater in spec and keep it there.</p>
</div>
@@ -55,7 +55,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-share" ""}}</span>
<h3 class="card-title m-0">Share with precision</h3>
<h2 class="card-title m-0">Share with precision</h2>
</div>
<p class="text-secondary mb-0">Hand out single-use links with per-command permissions. Join an organization and your repeaters share automatically — opt any out anytime.</p>
</div>
@@ -66,7 +66,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-terminal" ""}}</span>
<h3 class="card-title m-0">A console in your browser</h3>
<h2 class="card-title m-0">A console in your browser</h2>
</div>
<p class="text-secondary mb-0">Connect a MeshCore KISS modem and run CLI commands straight from your browser — no drivers and nothing to install locally.</p>
</div>
@@ -77,7 +77,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-antenna" ""}}</span>
<h3 class="card-title m-0">Confirm reachability</h3>
<h2 class="card-title m-0">Confirm reachability</h2>
</div>
<p class="text-secondary mb-0">Reach a repeater from your modem, and let others corroborate from theirs — proof it's really on the air, not just registered.</p>
</div>
@@ -88,7 +88,7 @@
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-list" ""}}</span>
<h3 class="card-title m-0">Full audit log</h3>
<h2 class="card-title m-0">Full audit log</h2>
</div>
<p class="text-secondary mb-0">Every command is logged and grouped by session — who ran what, when, and whether the repeater replied.</p>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{{define "title"}}Organizations · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Organizations</h2></div>
<div class="col"><h1 class="page-title fs-1">Organizations</h1></div>
{{if .UserName}}
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="{{.AppURL}}/orgs/new">{{template "icon-plus" "me-1"}}Create organization</a>
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Repeater</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
<h1 class="page-title fs-1">{{.Repeater.Name}}</h1>
</div>
{{if .CanManage}}
<div class="col-auto ms-auto d-print-none">
@@ -17,7 +17,7 @@
<!-- Left column -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Details</h3></div>
<div class="card-header"><h2 class="card-title">Details</h2></div>
<div class="card-body">
<div class="datagrid">
<div class="datagrid-item">
@@ -39,7 +39,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Documentation</h3></div>
<div class="card-header"><h2 class="card-title">Documentation</h2></div>
<div class="card-body">
{{if .DocPublicHTML}}
<div class="markdown">{{.DocPublicHTML}}</div>
@@ -53,7 +53,7 @@
<!-- Right column -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Identity</h3></div>
<div class="card-header"><h2 class="card-title">Identity</h2></div>
<div class="card-body">
<div class="form-label">Public key</div>
<code class="pk d-block">{{.Repeater.PublicKeyHex}}</code>
@@ -62,7 +62,7 @@
{{if .Repeater.Latitude}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Location</h3></div>
<div class="card-header"><h2 class="card-title">Location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<div id="map" style="height:240px" role="region" aria-label="Map of this repeater's location"></div>
@@ -77,7 +77,7 @@
{{end}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Stewards</h3></div>
<div class="card-header"><h2 class="card-title">Stewards</h2></div>
<div class="list-group list-group-flush">
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-user" ""}}</span>
@@ -96,7 +96,7 @@
{{if .Orgs}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Organizations</h3></div>
<div class="card-header"><h2 class="card-title">Organizations</h2></div>
<div class="list-group list-group-flush">
{{range .Orgs}}
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2" href="/orgs/{{.OrgSlug}}">{{template "icon-world" ""}}{{.OrgName}}</a>
@@ -3,7 +3,7 @@
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Profile</div>
<h2 class="page-title">{{.ProfileUser.Name}}</h2>
<h1 class="page-title fs-1">{{.ProfileUser.Name}}</h1>
<div class="text-secondary">@{{.ProfileUser.Username}}</div>
</div>
</div>
@@ -14,7 +14,7 @@
<!-- Left: about -->
<div class="{{if $hasSidebar}}col-lg-8{{else}}col-12{{end}}">
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-header"><h2 class="card-title">About</h2></div>
<div class="card-body">
{{if or .Location .Callsign}}
<div class="d-flex flex-wrap gap-3 mb-3 text-secondary">
@@ -34,7 +34,7 @@
{{if $hasSidebar}}
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Links</h3></div>
<div class="card-header"><h2 class="card-title">Links</h2></div>
<div class="list-group list-group-flush">
{{range $i, $l := .Links}}
{{if $l.IsMeshCore}}
+57
View File
@@ -135,6 +135,63 @@ func TestTemplatesUseAssetHelper(t *testing.T) {
}
}
// TestTemplatesHeadingConventions pins the heading levels the shared layouts assume,
// so a new page can't quietly reintroduce the outline problems from the audit (A1:
// no <h1> anywhere; A2: levels skipped or jumping backwards). The rendered-page
// counterpart is core.TestPageHeadingOutlines, which walks real responses; this one
// catches a bad heading the moment it's written, in whichever template it lands.
//
// The rules mirror the page structure: the page title is the h1, cards and modal
// dialogs are sections under it (h2), and alerts nest inside cards (h3).
func TestTemplatesHeadingConventions(t *testing.T) {
t.Parallel()
root := moduleRoot(t)
banned := []struct {
pattern *regexp.Regexp
why string
}{
{regexp.MustCompile(`(?i)<h[2-6][^>]*class="[^"]*\bpage-title\b`),
`page-title must be <h1> — it names the page`},
{regexp.MustCompile(`(?i)<h(?:[13-6])[^>]*class="[^"]*\bcard-title\b`),
`card-title must be <h2> — a card is a section under the page's h1 ` +
`(auth pages are the exception and are checked by the rendered test)`},
{regexp.MustCompile(`(?i)<h(?:1|[3-6])[^>]*class="[^"]*\bmodal-title\b`),
`modal-title must be <h2> — a dialog title sits under the page's h1`},
{regexp.MustCompile(`(?i)<h(?:[12]|[4-6])[^>]*class="[^"]*\balert-title\b`),
`alert-title must be <h3> — alerts nest inside cards`},
}
// login/signup have no page header, so their card title legitimately carries the
// page's h1.
authPages := map[string]bool{"login.html": true, "signup.html": true}
err := filepath.WalkDir(filepath.Join(root, "internal"), func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
rel, _ := filepath.Rel(root, path)
for _, rule := range banned {
if authPages[filepath.Base(path)] && strings.Contains(rule.why, "card-title") {
continue
}
if loc := rule.pattern.FindIndex(b); loc != nil {
t.Errorf("%s: %s — found %q", rel, rule.why, snippet(b, loc[0]))
}
}
return nil
})
if err != nil {
t.Fatalf("walk templates: %v", err)
}
}
func snippet(b []byte, at int) string {
end := at + 40
if end > len(b) {
+1 -1
View File
@@ -90,7 +90,7 @@
{{block "header" .}}
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">MeshTender</h2>
<h1 class="page-title fs-1">MeshTender</h1>
</div>
</div>
{{end}}
+1 -1
View File
@@ -12,7 +12,7 @@
{{range .Groups}}
<div class="card mt-3" data-check-scope>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">{{.Name}}</h3>
<h2 class="card-title mb-0">{{.Name}}</h2>
<div class="btn-list ms-auto">
<button type="button" class="btn btn-sm" data-check-all>Select all</button>
<button type="button" class="btn btn-sm" data-check-none>Select none</button>
+13 -2
View File
@@ -2,11 +2,22 @@
It renders on whichever base layout the surface defaults to, so the chrome
matches the rest of that host. Data: Status (int), Title, Message. */}}
{{define "title"}}{{.Status}} · MeshTender{{end}}
{{define "header"}}{{end}}
{{/* The error's own title is the page heading, rendered through the normal page
header so this page's h1 matches every other page's.
It must be a real (non-empty) definition: Go templates treat a define whose body
is only whitespace or comments as EMPTY and do NOT let it replace an existing
definition, so the previous `{{define "header"}}{{end}}` silently fell through to
base.html's default header — which is why this page used to render a generic
"MeshTender" title above the error. */}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h1 class="page-title fs-1">{{.Title}}</h1></div>
</div>
{{end}}
{{define "content"}}
<div class="empty py-6">
<div class="empty-header">{{.Status}}</div>
<p class="empty-title">{{.Title}}</p>
<p class="empty-subtitle text-secondary">{{.Message}}</p>
<div class="empty-action">
<a href="{{if .UserName}}{{.AppURL}}{{else}}{{.RootURL}}{{end}}/" class="btn btn-primary">
+4 -4
View File
@@ -13,14 +13,14 @@
<div class="col-lg-8">
{{if .Config.Profiles}}
<div class="card">
<div class="card-header"><h3 class="card-title">{{.Config.Selected}} · base settings</h3></div>
<div class="card-header"><h2 class="card-title">{{.Config.Selected}} · base settings</h2></div>
<div class="card-body">{{template "step-list" .Config.SelectedSteps}}</div>
</div>
{{end}}
{{if .Config.PreviewActive}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Regions</h3></div>
<div class="card-header"><h2 class="card-title">Regions</h2></div>
{{if .Config.RegionDef}}
<div class="card-body"><pre class="mb-0"><code>{{range .Config.RegionDef}}{{.}}
{{end}}</code></pre></div>
@@ -35,7 +35,7 @@
<div class="col-lg-4">
{{if .Config.Profiles}}
<div class="card">
<div class="card-header"><h3 class="card-title">Profile</h3></div>
<div class="card-header"><h2 class="card-title">Profile</h2></div>
<div class="card-body">
<form method="get" action="/orgs/{{.Org.Slug}}/config">
<select class="form-select" name="profile" aria-label="Choose a configuration profile" data-autosubmit>
@@ -53,7 +53,7 @@
{{if .Config.HasRegionShapes}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Preview a location</h3></div>
<div class="card-header"><h2 class="card-title">Preview a location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<div id="region-map" style="height:240px" role="region" aria-label="Click the map to preview a repeater's region config"></div>
+4 -4
View File
@@ -53,7 +53,7 @@
<!-- Left column: description (grows with content) -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-header"><h2 class="card-title">About</h2></div>
<div class="card-body">
{{if .Org.Description}}<div class="markdown">{{markdown .Org.Description}}</div>{{else}}<p class="text-secondary mb-0">This organization hasn't added a description yet.</p>{{end}}
</div>
@@ -63,7 +63,7 @@
<!-- Right column: repeater map, admins, links -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
<div class="card-header"><h2 class="card-title">Repeaters</h2></div>
{{if .HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
@@ -84,7 +84,7 @@
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Admins</h3></div>
<div class="card-header"><h2 class="card-title">Admins</h2></div>
{{if .Admins}}
<div class="list-group list-group-flush">
{{range .Admins}}
@@ -100,7 +100,7 @@
{{if .Links}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links</h3></div>
<div class="card-header"><h2 class="card-title">Links</h2></div>
<div class="list-group list-group-flush">
{{range .Links}}
{{if .Href}}
+2 -2
View File
@@ -43,7 +43,7 @@
<!-- Left: list -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Shared repeaters</h3></div>
<div class="card-header"><h2 class="card-title">Shared repeaters</h2></div>
{{if .Reps.Repeaters}}
<div class="list-group list-group-flush" id="rep-list">
{{template "rep-rows" .}}
@@ -61,7 +61,7 @@
<!-- Right: map -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">Map</h3></div>
<div class="card-header"><h2 class="card-title">Map</h2></div>
{{if .Reps.HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
+1 -1
View File
@@ -12,7 +12,7 @@ host (anonymous). */}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization{{if and (not .Nav.Manage) .Nav.CanGoToOrg}} · public preview{{end}}</div>
<h2 class="page-title">{{.Nav.Name}}</h2>
<h1 class="page-title fs-1">{{.Nav.Name}}</h1>
</div>
<div class="col-auto ms-auto d-print-none">
{{if .Nav.Manage}}
+1 -1
View File
@@ -7,7 +7,7 @@ for the owner. */}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Repeater{{if not .IsOwner}} · shared by {{.OwnerName}}{{end}}</div>
<h2 class="page-title">{{.Name}}</h2>
<h1 class="page-title fs-1">{{.Name}}</h1>
</div>
<div class="col-auto ms-auto d-print-none">
<div class="btn-list">