diff --git a/CHANGELOG.json b/CHANGELOG.json
index 4375e3a7..f04cc0f2 100644
--- a/CHANGELOG.json
+++ b/CHANGELOG.json
@@ -8,6 +8,40 @@
"grokify"
],
"releases": [
+ {
+ "version": "v0.30.3",
+ "date": "2026-11-02",
+ "highlights": [
+ {
+ "description": "SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons",
+ "commit": "7564e10"
+ }
+ ],
+ "added": [
+ {
+ "description": "`/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash",
+ "commit": "7564e10"
+ }
+ ],
+ "changed": [
+ {
+ "description": "The sign-in page lists its options in a `nav`, as buttons",
+ "commit": "7564e10"
+ }
+ ],
+ "tests": [
+ {
+ "description": "The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link",
+ "commit": "1185a05"
+ }
+ ],
+ "documentation": [
+ {
+ "description": "The deployment guide's security headers section describes the stylesheet",
+ "commit": "160ff73"
+ }
+ ]
+ },
{
"version": "v0.30.2",
"date": "2026-11-02",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7134f51e..94b4bafb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,28 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
## [Unreleased]
+## [v0.30.3] - 2026-11-02
+
+### Highlights
+
+- SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons
+
+### Added
+
+- `/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10))
+
+### Changed
+
+- The sign-in page lists its options in a `nav`, as buttons ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10))
+
+### Documentation
+
+- The deployment guide's security headers section describes the stylesheet ([`160ff73`](https://github.com/plexusone/systemforge/commit/160ff73))
+
+### Tests
+
+- The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link ([`1185a05`](https://github.com/plexusone/systemforge/commit/1185a05))
+
## [v0.30.2] - 2026-11-02
### Highlights
@@ -1703,7 +1725,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
- GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5))
- Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5))
-[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.2...HEAD
+[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.3...HEAD
+[v0.30.3]: https://github.com/plexusone/systemforge/compare/v0.30.2...v0.30.3
[v0.30.2]: https://github.com/plexusone/systemforge/compare/v0.30.1...v0.30.2
[v0.30.1]: https://github.com/plexusone/systemforge/compare/v0.30.0...v0.30.1
[v0.30.0]: https://github.com/plexusone/systemforge/compare/v0.29.0...v0.30.0
diff --git a/docs/releases/changelog.md b/docs/releases/changelog.md
index 7134f51e..94b4bafb 100644
--- a/docs/releases/changelog.md
+++ b/docs/releases/changelog.md
@@ -9,6 +9,28 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
## [Unreleased]
+## [v0.30.3] - 2026-11-02
+
+### Highlights
+
+- SystemAuth's sign-in, sign-out, consent, sign-up and email pages are styled: a centered card in light and dark, with the sign-in options as buttons
+
+### Added
+
+- `/assets/systemauth.css` (`PageStylePath`): the pages' stylesheet, embedded and served from the issuer's origin, linked by every page with its content hash ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10))
+
+### Changed
+
+- The sign-in page lists its options in a `nav`, as buttons ([`7564e10`](https://github.com/plexusone/systemforge/commit/7564e10))
+
+### Documentation
+
+- The deployment guide's security headers section describes the stylesheet ([`160ff73`](https://github.com/plexusone/systemforge/commit/160ff73))
+
+### Tests
+
+- The stylesheet is served and cached by version, every page links it, and the no-inline-code check allows only that same-origin link ([`1185a05`](https://github.com/plexusone/systemforge/commit/1185a05))
+
## [v0.30.2] - 2026-11-02
### Highlights
@@ -1703,7 +1725,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro
- GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5))
- Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5))
-[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.2...HEAD
+[unreleased]: https://github.com/plexusone/systemforge/compare/v0.30.3...HEAD
+[v0.30.3]: https://github.com/plexusone/systemforge/compare/v0.30.2...v0.30.3
[v0.30.2]: https://github.com/plexusone/systemforge/compare/v0.30.1...v0.30.2
[v0.30.1]: https://github.com/plexusone/systemforge/compare/v0.30.0...v0.30.1
[v0.30.0]: https://github.com/plexusone/systemforge/compare/v0.29.0...v0.30.0
diff --git a/docs/releases/index.md b/docs/releases/index.md
index b96299fd..7603aceb 100644
--- a/docs/releases/index.md
+++ b/docs/releases/index.md
@@ -4,7 +4,11 @@ Release notes, one file per version, newest first. The
[changelog](changelog.md) is generated from `CHANGELOG.json` and lists every
change with its commit.
-The latest release, [v0.30.2](v0.30.2.md), keeps a signed-out browser
+The latest release, [v0.30.3](v0.30.3.md), styles SystemAuth's pages
+with a same-origin stylesheet, in light and dark. It adds no breaking
+change.
+
+Before it, [v0.30.2](v0.30.2.md) kept a signed-out browser
signed out with a single sign-in provider: the sign-in page waits for a
click instead of redirecting. Read its upgrade notes.
diff --git a/docs/releases/v0.30.3.md b/docs/releases/v0.30.3.md
new file mode 100644
index 00000000..d6e62ee6
--- /dev/null
+++ b/docs/releases/v0.30.3.md
@@ -0,0 +1,26 @@
+# v0.30.3
+
+**Release Date:** 2026-11-02
+
+SystemAuth's pages are styled:
+
+- **A plain, modern look.** The sign-in, sign-out, consent, sign-up,
+ waitlist, age and email pages were unstyled HTML. They now link one
+ stylesheet: a centered card on a soft background, in light and dark
+ following the browser, with the sign-in options as full-width buttons,
+ clear form fields, and notices in color.
+- **Nothing loosened.** The stylesheet is embedded in the binary and served
+ at `/assets/systemauth.css` from the issuer's origin, which the pages'
+ content security policy (`default-src 'self'`) already allows: still no
+ script and no inline style. The link carries the stylesheet's content
+ hash, so browsers cache it until it changes.
+
+It adds no breaking change.
+
+## Upgrade notes
+
+1. **Pass `/assets/` through** any proxy in front of SystemAuth, as it
+ passes the pages themselves.
+
+The pages are not themed per application yet. See
+[Security headers](../systemauth/deployment.md#security-headers).
diff --git a/docs/systemauth/deployment.md b/docs/systemauth/deployment.md
index 3e14ce89..43ab8384 100644
--- a/docs/systemauth/deployment.md
+++ b/docs/systemauth/deployment.md
@@ -508,7 +508,12 @@ pages, carries:
| `Referrer-Policy` | `same-origin` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains`, when the issuer is https |
-The pages run no script and no inline style. Browsers check `form-action`
+The pages run no script and no inline style. They link one stylesheet,
+`/assets/systemauth.css`, served by SystemAuth from its own origin, which
+`default-src 'self'` allows: a plain card in light and dark, with the
+sign-in options as buttons. The link carries the stylesheet's content hash,
+so a browser caches it until it changes. A proxy in front of SystemAuth must
+pass `/assets/` through. Browsers check `form-action`
on every redirect that follows a form submission, so a page whose form
ends at another origin adds it: the consent page adds the origins of the
client's redirect URIs, the logout page the `allowed_redirect_origins`,
diff --git a/identity/systemauth/assets/page.css b/identity/systemauth/assets/page.css
new file mode 100644
index 00000000..605a763f
--- /dev/null
+++ b/identity/systemauth/assets/page.css
@@ -0,0 +1,181 @@
+/* SystemAuth's pages: sign-in, sign-out, consent, sign-up and email. It styles
+ the pages' plain elements, with no classes, in light and dark. */
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9;
+ --card: #ffffff;
+ --text: #1f2328;
+ --muted: #59636e;
+ --border: #d1d9e0;
+ --accent: #1f2328;
+ --accent-text: #ffffff;
+ --accent-hover: #3d444d;
+ --focus: #0969da;
+ --alert-bg: #fff1f0;
+ --alert-text: #a40e26;
+ --status-bg: #eef6ff;
+ --status-text: #0b4a8b;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0d1117;
+ --card: #161b22;
+ --text: #e6edf3;
+ --muted: #9198a1;
+ --border: #30363d;
+ --accent: #e6edf3;
+ --accent-text: #0d1117;
+ --accent-hover: #c9d1d9;
+ --focus: #4493f8;
+ --alert-bg: #3c1618;
+ --alert-text: #ffa198;
+ --status-bg: #0c2d4f;
+ --status-text: #a5d6ff;
+ }
+}
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+html {
+ -webkit-text-size-adjust: 100%;
+}
+body {
+ margin: 0;
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 24px 16px;
+ background: var(--bg);
+ color: var(--text);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+main {
+ width: 100%;
+ max-width: 400px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 32px 28px;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.06), 0 8px 24px rgb(0 0 0 / 0.06);
+}
+main > img {
+ display: block;
+ margin: 0 auto 16px;
+ border-radius: 12px;
+}
+h1 {
+ margin: 0 0 8px;
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.3;
+ text-align: center;
+}
+h1 + p {
+ margin-top: 0;
+ color: var(--muted);
+ text-align: center;
+}
+p {
+ margin: 12px 0;
+}
+a {
+ color: var(--focus);
+}
+ul {
+ margin: 16px 0;
+ padding-left: 20px;
+}
+li + li {
+ margin-top: 4px;
+}
+nav {
+ display: grid;
+ gap: 10px;
+ margin-top: 24px;
+}
+nav a,
+button {
+ display: block;
+ width: 100%;
+ padding: 10px 16px;
+ border: 1px solid var(--accent);
+ border-radius: 8px;
+ background: var(--accent);
+ color: var(--accent-text);
+ font: inherit;
+ font-weight: 500;
+ text-align: center;
+ text-decoration: none;
+ cursor: pointer;
+}
+nav a:hover,
+button:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+button + button {
+ margin-top: 10px;
+}
+button[value="deny"],
+button[value="cancel"] {
+ background: transparent;
+ color: var(--text);
+ border-color: var(--border);
+}
+button[value="deny"]:hover,
+button[value="cancel"]:hover {
+ background: var(--bg);
+}
+form {
+ margin-top: 20px;
+}
+label {
+ display: block;
+ margin: 12px 0 4px;
+ font-weight: 500;
+}
+input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]),
+select,
+textarea {
+ display: block;
+ width: 100%;
+ padding: 9px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--card);
+ color: var(--text);
+ font: inherit;
+}
+input[type="checkbox"],
+input[type="radio"] {
+ margin-right: 6px;
+}
+input + button,
+select + button,
+textarea + button,
+label + button {
+ margin-top: 20px;
+}
+:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+}
+[role="alert"],
+[role="status"] {
+ padding: 10px 12px;
+ border-radius: 8px;
+}
+[role="alert"] {
+ background: var(--alert-bg);
+ color: var(--alert-text);
+}
+[role="status"] {
+ background: var(--status-bg);
+ color: var(--status-text);
+}
+small,
+.muted {
+ color: var(--muted);
+}
diff --git a/identity/systemauth/email_change.go b/identity/systemauth/email_change.go
index a0f13d74..c78b228e 100644
--- a/identity/systemauth/email_change.go
+++ b/identity/systemauth/email_change.go
@@ -64,7 +64,7 @@ func emailChangeCSRF(sessionToken string) string {
}
var accountEmailTemplate = template.Must(template.New("account-email").Parse(`
-
+ ` + pageStyleLink + `
{{.Title}}
{{.Title}}
{{if .Message}}{{.Message}}
{{end}}
diff --git a/identity/systemauth/email_verification.go b/identity/systemauth/email_verification.go
index cc14c5e4..1a101821 100644
--- a/identity/systemauth/email_verification.go
+++ b/identity/systemauth/email_verification.go
@@ -171,7 +171,7 @@ func (sl *socialLogin) issueSignInToken(ctx context.Context, cookie, email strin
}
var verifyEmailTemplate = template.Must(template.New("verify-email").Parse(`
-
+ ` + pageStyleLink + `
{{.Title}}
{{.Title}}
{{if .Message}}{{.Message}}
{{end}}
diff --git a/identity/systemauth/page_style.go b/identity/systemauth/page_style.go
new file mode 100644
index 00000000..05bd9ec9
--- /dev/null
+++ b/identity/systemauth/page_style.go
@@ -0,0 +1,42 @@
+package systemauth
+
+import (
+ "crypto/sha256"
+ _ "embed"
+ "encoding/hex"
+ "net/http"
+)
+
+// PageStylePath serves the stylesheet SystemAuth's own pages link: sign-in,
+// sign-out, consent, sign-up and the email pages. It is served from the
+// issuer's origin, which the pages' content security policy allows.
+const PageStylePath = "/assets/systemauth.css"
+
+//go:embed assets/page.css
+var pageStyle []byte
+
+// pageStyleVersion names this build's stylesheet in its link, so a browser
+// fetches it again when it changes and may cache it otherwise.
+var pageStyleVersion = func() string {
+ sum := sha256.Sum256(pageStyle)
+ return hex.EncodeToString(sum[:6])
+}()
+
+// pageStyleLink is the every page's head carries, after its
+// viewport.
+var pageStyleLink = ` `
+
+// handlePageStyle serves GET PageStylePath.
+func handlePageStyle(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ // The link names the content, so a versioned request may be cached
+ // for long; an unversioned one is checked again within the hour.
+ if r.URL.Query().Get("v") == pageStyleVersion {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ if _, err := w.Write(pageStyle); err != nil {
+ LoggerFromContext(r.Context()).Debug("writing the page stylesheet", "error", err)
+ }
+}
diff --git a/identity/systemauth/page_style_test.go b/identity/systemauth/page_style_test.go
new file mode 100644
index 00000000..585d37de
--- /dev/null
+++ b/identity/systemauth/page_style_test.go
@@ -0,0 +1,56 @@
+package systemauth
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestPageStyleIsServedAndLinked(t *testing.T) {
+ f := newFakeGitHub(t)
+ s := newSocialTestServer(t, f, NewMemoryPrincipalDirectory())
+
+ w := httptest.NewRecorder()
+ s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PageStylePath+"?v="+pageStyleVersion, nil))
+ if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/css") || !strings.Contains(w.Body.String(), "prefers-color-scheme") {
+ t.Fatalf("stylesheet: %d %q", w.Code, w.Header().Get("Content-Type"))
+ }
+ if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
+ t.Errorf("a versioned stylesheet is cached as %q", cc)
+ }
+ w = httptest.NewRecorder()
+ s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PageStylePath, nil))
+ if cc := w.Header().Get("Cache-Control"); strings.Contains(cc, "immutable") {
+ t.Errorf("an unversioned stylesheet is cached as %q", cc)
+ }
+
+ // The sign-in page links it, from the issuer's own origin, which its
+ // content security policy allows, and offers each provider in a nav.
+ w = httptest.NewRecorder()
+ s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/login", nil))
+ body := w.Body.String()
+ if !strings.Contains(body, ` `) {
+ t.Errorf("the sign-in page does not link the stylesheet: %s", body)
+ }
+ if !strings.Contains(body, ``) || !strings.Contains(body, `>Continue with GitHub`) {
+ t.Errorf("the sign-in options: %s", body)
+ }
+ if csp := w.Header().Get("Content-Security-Policy"); csp != "" && !strings.Contains(csp, "default-src 'self'") {
+ t.Errorf("the page's policy would block its stylesheet: %s", csp)
+ }
+}
+
+// Every page template links the stylesheet.
+func TestEveryPageLinksTheStylesheet(t *testing.T) {
+ for name, src := range map[string]string{
+ "login": chooserTemplate.Tree.Root.String(), "logout": logoutTemplate.Tree.Root.String(),
+ "consent": consentTemplate.Tree.Root.String(), "signup": signupCodeTemplate.Tree.Root.String(),
+ "signup-age": signupAgeTemplate.Tree.Root.String(), "waitlist": waitlistTemplate.Tree.Root.String(),
+ "account-email": accountEmailTemplate.Tree.Root.String(), "verify-email": verifyEmailTemplate.Tree.Root.String(),
+ } {
+ if !strings.Contains(src, pageStyleLink) {
+ t.Errorf("%s does not link the stylesheet", name)
+ }
+ }
+}
diff --git a/identity/systemauth/security_headers_test.go b/identity/systemauth/security_headers_test.go
index 4690e294..56e38864 100644
--- a/identity/systemauth/security_headers_test.go
+++ b/identity/systemauth/security_headers_test.go
@@ -41,13 +41,15 @@ func checkSecurityHeaders(t *testing.T, name string, w *httptest.ResponseRecorde
}
// checkNoInlineCode asserts that a page needs nothing the policy blocks:
-// no script, no style element or attribute, no event handler attribute.
+// no script, no style element or attribute, no event handler attribute,
+// and no link but the stylesheet SystemAuth serves from its own origin.
func checkNoInlineCode(t *testing.T, name string, w *httptest.ResponseRecorder) {
t.Helper()
if !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") {
t.Fatalf("%s: not an HTML page: %d %q %s", name, w.Code, w.Header().Get("Content-Type"), w.Body.String())
}
- body := strings.ToLower(w.Body.String())
+ // The page stylesheet is same-origin, which default-src 'self' allows.
+ body := strings.ToLower(strings.ReplaceAll(w.Body.String(), pageStyleLink, ""))
for _, banned := range []string{"