Repository: juanfont/headscale

Issue:
[Improvement] use go-elem for HTML pages
### Use case

We currently have a couple of HTML files/template strings in the code using gotemplate and handcrafted HTML, its not a lot, and its fine, but a nice QoL improvement would be to make them nice, typed and generated.



### Description

I have had quite a good experience with [go-elem](https://github.com/chasefleming/elem-go) for this for some other projects, ergonomics are pretty good for writing HTML as functions, at least if we consider that it is Go and not something like Elm, Roc or Scala which might allow a bit nicer funcs.

### Contribution

- [ ] I can write the design doc for this feature
- [X] I can contribute this feature

### How can it be implemented?

I will eventually do this, but if someone is up for converting the HTML to go-elem, that would be a great PR/starter issue, make things neat and reusable.

Hints from issue comments:
@amha-mersha: Hello @kradalby , I really would like to take this and work on it.@kradalby: @amha-mersha sounds good, I think we will not merge anything until after 0.23.0 goes in, but I think a good approach would be to make a PR converting something small, like https://github.com/juanfont/headscale/blob/main/hscontrol/handlers.go#L141-L168 would be a good start!@amha-mersha: Sure @kradalby , do you need to assign it to me or do I just do it and make a PR@kradalby: I assigned it so people know, but other than that you can make a PR. @amha-mersha: Thank you @kradalby for assigning me my first open-source contribution, I have finished it and made a PR. Are there other things I can work on. @kradalby: You can also start on https://github.com/juanfont/headscale/blob/main/hscontrol/templates/windows.html, and where it is called in the code. the more you can make functions to reuse the better@kradalby: There is also styles and templates for OIDC here that needs to be rewritten: https://github.com/juanfont/headscale/blob/10a72e8d542af68c0c280f2a6ccc84849719b24c/hscontrol/oidc.go#L161-L166

This contains other styling than the current one, we should unify this so it all looks the same and can have the style reused.@jirutka: I would greatly appreciate it if these were simple HTML templates that can be read from the filesystem so admins can easily customize them to their organization’s visual style, add additional information etc.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15467f79..04a16156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,19 @@
 
 ## Next
 
+### Web registration templates redesign
+
+The OIDC callback and device registration web pages have been updated to use the
+Material for MkDocs design system from the official documentation. The templates
+now use consistent typography, spacing, and colours across all registration
+flows. External links are properly secured with noreferrer/noopener attributes.
+
 ### Changes
 
 - Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857)
 - Add favicon to webpages [#2858](https://github.com/juanfont/headscale/pull/2858)
 - Reclaim IPs from the IP allocator when nodes are deleted [#2831](https://github.com/juanfont/headscale/pull/2831)
+- Redesign OIDC callback and registration web templates [#2832](https://github.com/juanfont/headscale/pull/2832)
 
 ## 0.27.1 (2025-11-11)
 
diff --git a/flake.nix b/flake.nix
index 8d16f609..5f28d622 100644
--- a/flake.nix
+++ b/flake.nix
@@ -214,6 +214,7 @@
 
           shellHook = ''
             export PATH="$PWD/result/bin:$PATH"
+            export CGO_ENABLED=0
           '';
         };
 
diff --git a/hscontrol/assets/assets.go b/hscontrol/assets/assets.go
new file mode 100644
index 00000000..13904247
--- /dev/null
+++ b/hscontrol/assets/assets.go
@@ -0,0 +1,24 @@
+// Package assets provides embedded static assets for Headscale.
+// All static files (favicon, CSS, SVG) are embedded here for
+// centralized asset management.
+package assets
+
+import (
+	_ "embed"
+)
+
+// Favicon is the embedded favicon.png file served at /favicon.ico
+//
+//go:embed favicon.png
+var Favicon []byte
+
+// CSS is the embedded style.css stylesheet used in HTML templates.
+// Contains Material for MkDocs design system styles.
+//
+//go:embed style.css
+var CSS string
+
+// SVG is the embedded headscale.svg logo used in HTML templates.
+//
+//go:embed headscale.svg
+var SVG string
diff --git a/hscontrol/assets/headscale.svg b/hscontrol/assets/headscale.svg
new file mode 100644
index 00000000..caf19697
--- /dev/null
+++ b/hscontrol/assets/headscale.svg
@@ -0,0 +1 @@
+<svg class="headscale-logo" width="400" height="140" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="32.92 0 1247.08 640"><path d="M.08 0v-.736h.068v.3C.203-.509.27-.545.347-.545c.029 0 .055.005.079.015.024.01.045.025.062.045.017.02.031.045.041.075.009.03.014.065.014.105V0H.475v-.289C.475-.352.464-.4.443-.433.422-.466.385-.483.334-.483c-.027 0-.052.006-.075.017C.236-.455.216-.439.2-.419c-.017.02-.029.044-.038.072-.009.028-.014.059-.014.093V0H.08Z" style="fill:#f8b5cb;fill-rule:nonzero" transform="translate(32.92220721 521.8022953) scale(235.3092)"/><path d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(177.16674681 521.8022953) scale(235.3092)"/><path d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(327.76463481 521.8022953) scale(235.3092)"/><path d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.302h.068V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.011-.027.017-.056.017-.089 0-.031-.005-.059-.016-.086C.514-.375.5-.398.481-.417.462-.436.439-.452.414-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(488.71612761 521.8022953) scale(235.3092)"/><path d="m.034-.062.043-.049c.017.019.035.034.054.044.018.01.037.015.057.015.013 0 .026-.002.038-.007.011-.004.021-.01.031-.018.009-.008.016-.017.021-.028.005-.011.008-.022.008-.035 0-.019-.005-.034-.014-.047C.263-.199.248-.21.229-.221.205-.234.183-.247.162-.259.14-.271.122-.284.107-.298.092-.311.08-.327.071-.344.062-.361.058-.381.058-.404c0-.021.004-.04.012-.058.007-.016.018-.031.031-.044.013-.013.028-.022.046-.029.018-.007.037-.01.057-.01.029 0 .056.006.079.019s.045.031.068.053l-.044.045C.291-.443.275-.456.258-.465.241-.474.221-.479.2-.479c-.022 0-.041.007-.056.02C.128-.445.12-.428.12-.408c0 .019.006.035.017.048.011.013.027.026.048.037.027.015.05.028.071.04.021.013.038.026.052.039.014.013.025.028.032.044.007.016.011.035.011.057 0 .021-.004.041-.011.059-.008.019-.019.036-.033.05-.014.015-.031.026-.05.035C.237.01.215.014.191.014c-.03 0-.059-.006-.086-.02C.077-.019.053-.037.034-.062Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(649.90292961 521.8022953) scale(235.3092)"/><path d="M.051-.266c0-.04.007-.077.022-.111.014-.034.034-.063.059-.089.025-.025.054-.044.089-.058.035-.014.072-.021.113-.021.051 0 .098.01.139.03.041.021.075.049.1.085l-.05.043C.498-.418.47-.441.439-.456.408-.471.372-.479.331-.479c-.03 0-.058.005-.083.016C.222-.452.2-.436.181-.418.162-.399.148-.376.137-.35c-.011.026-.016.054-.016.084 0 .031.005.06.016.086.011.027.025.049.044.068.019.019.041.034.067.044.025.011.053.016.084.016.077 0 .141-.03.191-.09l.051.04c-.028.036-.062.064-.103.085C.43.004.384.014.332.014.291.014.254.007.219-.008.184-.022.155-.042.13-.067.105-.092.086-.121.072-.156.058-.19.051-.227.051-.266Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(741.20289921 521.8022953) scale(235.3092)"/><path d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(884.27089281 521.8022953) scale(235.3092)"/><path d="M.066-.736h.068V0H.066z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(1045.22238561 521.8022953) scale(235.3092)"/><path d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z" style="fill:#8d8d8d;fill-rule:nonzero" transform="translate(1092.28422561 521.8022953) scale(235.3092)"/><circle cx="141.023" cy="338.36" r="117.472" style="fill:#f8b5cb" transform="matrix(.581302 0 0 .58613 40.06479894 12.59842153)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(.59308 0 0 .58289 32.39345942 21.2386)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(.59308 0 0 .58289 32.39345942 88.80371146)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(.59308 0 0 .58289 120.7528627 88.80371146)"/><circle cx="352.014" cy="268.302" r="33.095" style="fill:#a2a2a2" transform="matrix(.59308 0 0 .58289 120.99825939 21.2386)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.5782 0 0 .58289 36.19871106 15.26642564)"/><circle cx="805.557" cy="336.915" r="118.199" style="fill:#8d8d8d" transform="matrix(.5782 0 0 .58289 183.24041937 15.26642564)"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030" transform="translate(34.2345 21.2386) scale(.58289)"/><path d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z" style="fill:#303030" transform="matrix(-.58289 0 0 .58289 1116.7719791 21.2386)"/></svg>
diff --git a/hscontrol/assets/oidc_callback_template.html b/hscontrol/assets/oidc_callback_template.html
deleted file mode 100644
index 2236f365..00000000
--- a/hscontrol/assets/oidc_callback_template.html
+++ /dev/null
@@ -1,307 +0,0 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <title>Headscale Authentication Succeeded</title>
-    <style>
-      body {
-        font-size: 14px;
-        font-family:
-          system-ui,
-          -apple-system,
-          BlinkMacSystemFont,
-          "Segoe UI",
-          "Roboto",
-          "Oxygen",
-          "Ubuntu",
-          "Cantarell",
-          "Fira Sans",
-          "Droid Sans",
-          "Helvetica Neue",
-          sans-serif;
-      }
-
-      hr {
-        border-color: #fdfdfe;
-        margin: 24px 0;
-      }
-
-      .container {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        height: 70vh;
-      }
-
-      #logo {
-        display: block;
-        margin-left: -20px;
-        margin-bottom: 16px;
-      }
-
-      .message {
-        display: flex;
-        min-width: 40vw;
-        background: #fafdfa;
-        border: 1px solid #c6e9c9;
-        margin-bottom: 12px;
-        padding: 12px 16px 16px 12px;
-        position: relative;
-        border-radius: 2px;
-        font-size: 14px;
-      }
-
-      .message-content {
-        margin-left: 4px;
-      }
-
-      .message #checkbox {
-        fill: #2eb039;
-      }
-
-      .message .message-title {
-        color: #1e7125;
-        font-size: 16px;
-        font-weight: 700;
-        line-height: 1.25;
-      }
-
-      .message .message-body {
-        border: 0;
-        margin-top: 4px;
-      }
-
-      .message p {
-        font-size: 12px;
-        margin: 0;
-        padding: 0;
-        color: #17421b;
-      }
-
-      a {
-        display: block;
-        margin: 8px 0;
-        color: #1563ff;
-        text-decoration: none;
-        font-weight: 600;
-      }
-
-      a:hover {
-        color: black;
-      }
-
-      a svg {
-        fill: currentcolor;
-      }
-
-      .icon {
-        align-items: center;
-        display: inline-flex;
-        justify-content: center;
-        height: 21px;
-        width: 21px;
-        vertical-align: middle;
-      }
-
-      h1 {
-        font-size: 17.5px;
-        font-weight: 700;
-        margin-bottom: 0;
-      }
-
-      h1 + p {
-        margin: 8px 0 16px 0;
-      }
-    </style>
-  </head>
-  <body translate="no">
-    <div class="container">
-      <div>
-        <svg
-          id="logo"
-          width="146"
-          height="51"
-          xmlns="http://www.w3.org/2000/svg"
-          xml:space="preserve"
-          style="
-            fill-rule: evenodd;
-            clip-rule: evenodd;
-            stroke-linejoin: round;
-            stroke-miterlimit: 2;
-          "
-          viewBox="0 0 1280 640"
-        >
-          <path
-            d="M.08 0v-.736h.068v.3C.203-.509.27-.545.347-.545c.029 0 .055.005.079.015.024.01.045.025.062.045.017.02.031.045.041.075.009.03.014.065.014.105V0H.475v-.289C.475-.352.464-.4.443-.433.422-.466.385-.483.334-.483c-.027 0-.052.006-.075.017C.236-.455.216-.439.2-.419c-.017.02-.029.044-.038.072-.009.028-.014.059-.014.093V0H.08Z"
-            style="fill: #f8b5cb; fill-rule: nonzero"
-            transform="translate(32.92220721 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(177.16674681 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(327.76463481 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.302h.068V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.011-.027.017-.056.017-.089 0-.031-.005-.059-.016-.086C.514-.375.5-.398.481-.417.462-.436.439-.452.414-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(488.71612761 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="m.034-.062.043-.049c.017.019.035.034.054.044.018.01.037.015.057.015.013 0 .026-.002.038-.007.011-.004.021-.01.031-.018.009-.008.016-.017.021-.028.005-.011.008-.022.008-.035 0-.019-.005-.034-.014-.047C.263-.199.248-.21.229-.221.205-.234.183-.247.162-.259.14-.271.122-.284.107-.298.092-.311.08-.327.071-.344.062-.361.058-.381.058-.404c0-.021.004-.04.012-.058.007-.016.018-.031.031-.044.013-.013.028-.022.046-.029.018-.007.037-.01.057-.01.029 0 .056.006.079.019s.045.031.068.053l-.044.045C.291-.443.275-.456.258-.465.241-.474.221-.479.2-.479c-.022 0-.041.007-.056.02C.128-.445.12-.428.12-.408c0 .019.006.035.017.048.011.013.027.026.048.037.027.015.05.028.071.04.021.013.038.026.052.039.014.013.025.028.032.044.007.016.011.035.011.057 0 .021-.004.041-.011.059-.008.019-.019.036-.033.05-.014.015-.031.026-.05.035C.237.01.215.014.191.014c-.03 0-.059-.006-.086-.02C.077-.019.053-.037.034-.062Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(649.90292961 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.266c0-.04.007-.077.022-.111.014-.034.034-.063.059-.089.025-.025.054-.044.089-.058.035-.014.072-.021.113-.021.051 0 .098.01.139.03.041.021.075.049.1.085l-.05.043C.498-.418.47-.441.439-.456.408-.471.372-.479.331-.479c-.03 0-.058.005-.083.016C.222-.452.2-.436.181-.418.162-.399.148-.376.137-.35c-.011.026-.016.054-.016.084 0 .031.005.06.016.086.011.027.025.049.044.068.019.019.041.034.067.044.025.011.053.016.084.016.077 0 .141-.03.191-.09l.051.04c-.028.036-.062.064-.103.085C.43.004.384.014.332.014.291.014.254.007.219-.008.184-.022.155-.042.13-.067.105-.092.086-.121.072-.156.058-.19.051-.227.051-.266Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(741.20289921 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.267c0-.038.007-.074.021-.108.014-.033.033-.063.058-.088.025-.025.054-.045.087-.06.033-.015.069-.022.108-.022.043 0 .083.009.119.027.035.019.066.047.093.084v-.097h.067V0H.537v-.091C.508-.056.475-.029.44-.013.404.005.365.013.323.013.284.013.248.006.215-.01.182-.024.153-.045.129-.071.104-.096.085-.126.072-.16.058-.193.051-.229.051-.267Zm.279.218c.027 0 .054-.005.079-.015.025-.01.048-.024.068-.043.019-.018.035-.04.047-.067.012-.027.018-.056.018-.089 0-.031-.005-.059-.016-.086C.515-.375.501-.398.482-.417.462-.436.44-.452.415-.463.389-.474.361-.479.331-.479c-.031 0-.059.006-.084.017C.221-.45.199-.434.18-.415c-.019.02-.033.043-.043.068-.011.026-.016.053-.016.082 0 .029.005.056.016.082.011.026.025.049.044.069.019.02.041.036.066.047.025.012.053.018.083.018Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(884.27089281 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.066-.736h.068V0H.066z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(1045.22238561 521.8022953) scale(235.3092)"
-          />
-          <path
-            d="M.051-.264c0-.036.007-.071.02-.105.013-.034.031-.064.055-.09.023-.026.052-.047.086-.063.033-.015.071-.023.112-.023.039 0 .076.007.109.021.033.014.062.033.087.058.025.025.044.054.058.088.014.035.021.072.021.113v.005H.121c.001.031.007.059.018.084.01.025.024.047.042.065.018.019.04.033.065.043.025.01.052.015.082.015.026 0 .049-.003.069-.01.02-.007.038-.016.054-.028C.466-.102.48-.115.492-.13c.011-.015.022-.03.032-.046l.057.03C.556-.097.522-.058.48-.03.437-.001.387.013.328.013.284.013.245.006.21-.01.175-.024.146-.045.123-.07.1-.095.082-.125.07-.159.057-.192.051-.227.051-.264ZM.128-.32h.396C.51-.375.485-.416.449-.441.412-.466.371-.479.325-.479c-.048 0-.089.013-.123.039-.034.026-.059.066-.074.12Z"
-            style="fill: #8d8d8d; fill-rule: nonzero"
-            transform="translate(1092.28422561 521.8022953) scale(235.3092)"
-          />
-          <circle
-            cx="141.023"
-            cy="338.36"
-            r="117.472"
-            style="fill: #f8b5cb"
-            transform="matrix(.581302 0 0 .58613 40.06479894 12.59842153)"
-          />
-          <circle
-            cx="352.014"
-            cy="268.302"
-            r="33.095"
-            style="fill: #a2a2a2"
-            transform="matrix(.59308 0 0 .58289 32.39345942 21.2386)"
-          />
-          <circle
-            cx="352.014"
-            cy="268.302"
-            r="33.095"
-            style="fill: #a2a2a2"
-            transform="matrix(.59308 0 0 .58289 32.39345942 88.80371146)"
-          />
-          <circle
-            cx="352.014"
-            cy="268.302"
-            r="33.095"
-            style="fill: #a2a2a2"
-            transform="matrix(.59308 0 0 .58289 120.7528627 88.80371146)"
-          />
-          <circle
-            cx="352.014"
-            cy="268.302"
-            r="33.095"
-            style="fill: #a2a2a2"
-            transform="matrix(.59308 0 0 .58289 120.99825939 21.2386)"
-          />
-          <circle
-            cx="805.557"
-            cy="336.915"
-            r="118.199"
-            style="fill: #8d8d8d"
-            transform="matrix(.5782 0 0 .58289 36.19871106 15.26642564)"
-          />
-          <circle
-            cx="805.557"
-            cy="336.915"
-            r="118.199"
-            style="fill: #8d8d8d"
-            transform="matrix(.5782 0 0 .58289 183.24041937 15.26642564)"
-          />
-          <path
-            d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
-            style="fill: #303030"
-            transform="translate(34.2345 21.2386) scale(.58289)"
-          />
-          <path
-            d="M680.282 124.808h-68.093v390.325h68.081v-28.23H640V153.228h40.282v-28.42Z"
-            style="fill: #303030"
-            transform="matrix(-.58289 0 0 .58289 1116.7719791 21.2386)"
-          />
-        </svg>
-        <div class="message is-success">
-          <svg
-            id="checkbox"
-            aria-hidden="true"
-            xmlns="http://www.w3.org/2000/svg"
-            width="20"
-            height="20"
-            viewBox="0 0 512 512"
-          >
-            <path
-              d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"
-            ></path>
-          </svg>
-          <div class="message-content">
-            <div class="message-title">Signed in via your OIDC provider</div>
-            <p class="message-body">
-              {{.Verb}} as {{.User}}, you can now close this window.
-            </p>
-          </div>
-        </div>
-        <hr />
-        <h1>Not sure how to get started?</h1>
-        <p class="learn">
-          Check out beginner and advanced guides on, or read more in the
-          documentation.
-        </p>
-        <a
-          href="https://github.com/juanfont/headscale/tree/main/docs"
-          rel="noreferrer noopener"
-          target="_blank"
-        >
-          <span class="icon">
-            <svg
-              width="16"
-              height="16"
-              viewBox="0 0 16 16"
-              xmlns="http://www.w3.org/2000/svg"
-            >
-              <path
-                d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
-              />
-            </svg>
-          </span>
-          View the headscale documentation
-        </a>
-        <a
-          href="https://tailscale.com/kb/"
-          rel="noreferrer noopener"
-          target="_blank"
-        >
-          <span class="icon">
-            <svg
-              width="16"
-              height="16"
-              viewBox="0 0 16 16"
-              xmlns="http://www.w3.org/2000/svg"
-            >
-              <path
-                d="M13.307 1H11.5a.5.5 0 1 1 0-1h3a.499.499 0 0 1 .5.65V3.5a.5.5 0 1 1-1 0V1.72l-1.793 1.774a.5.5 0 0 1-.713-.701L13.307 1zM12 14V8a.5.5 0 1 1 1 0v6.5a.5.5 0 0 1-.5.5H.563a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 .5-.5H8a.5.5 0 0 1 0 1H1v12h11zM4 6a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1H4zm0 2.5a.5.5 0 0 1 0-1h5a.5.5 0 0 1 0 1H4zM4 11a.5.5 0 1 1 0-1h5a.5.5 0 1 1 0 1H4z"
-              />
-            </svg>
-          </span>
-          View the tailscale documentation
-        </a>
-      </div>
-    </div>
-  </body>
-</html>
diff --git a/hscontrol/assets/style.css b/hscontrol/assets/style.css
new file mode 100644
index 00000000..d82bea12
--- /dev/null
+++ b/hscontrol/assets/style.css
@@ -0,0 +1,147 @@
+/* CSS Variables from Material for MkDocs */
+:root {
+  --md-default-fg-color: rgba(0, 0, 0, 0.87);
+  --md-default-fg-color--light: rgba(0, 0, 0, 0.54);
+  --md-default-fg-color--lighter: rgba(0, 0, 0, 0.32);
+  --md-default-fg-color--lightest: rgba(0, 0, 0, 0.07);
+  --md-code-fg-color: #36464e;
+  --md-code-bg-color: #f5f5f5;
+  --md-primary-fg-color: #4051b5;
+  --md-accent-fg-color: #526cfe;
+  --md-typeset-a-color: var(--md-primary-fg-color);
+  --md-text-font:
+    "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
+    Arial, sans-serif;
+  --md-code-font:
+    "Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New",
+    monospace;
+}
+
+/* Base Typography */
+.md-typeset {
+  font-size: 0.8rem;
+  line-height: 1.6;
+  color: var(--md-default-fg-color);
+  font-family: var(--md-text-font);
+  overflow-wrap: break-word;
+  text-align: left;
+}
+
+/* Headings */
+.md-typeset h1 {
+  color: var(--md-default-fg-color--light);
+  font-size: 2em;
+  line-height: 1.3;
+  margin: 0 0 1.25em;
+  font-weight: 300;
+  letter-spacing: -0.01em;
+}
+
+.md-typeset h1:not(:first-child) {
+  margin-top: 2em;
+}
+
+.md-typeset h2 {
+  font-size: 1.5625em;
+  line-height: 1.4;
+  margin: 2.4em 0 0.64em;
+  font-weight: 300;
+  letter-spacing: -0.01em;
+  color: var(--md-default-fg-color--light);
+}
+
+.md-typeset h3 {
+  font-size: 1.25em;
+  line-height: 1.5;
+  margin: 2em 0 0.8em;
+  font-weight: 400;
+  letter-spacing: -0.01em;
+  color: var(--md-default-fg-color--light);
+}
+
+/* Paragraphs and block elements */
+.md-typeset p {
+  margin: 1em 0;
+}
+
+.md-typeset blockquote,
+.md-typeset dl,
+.md-typeset figure,
+.md-typeset ol,
+.md-typeset pre,
+.md-typeset ul {
+  margin-bottom: 1em;
+  margin-top: 1em;
+}
+
+/* Lists */
+.md-typeset ol,
+.md-typeset ul {
+  padding-left: 2em;
+}
+
+/* Links */
+.md-typeset a {
+  color: var(--md-typeset-a-color);
+  text-decoration: none;
+  word-break: break-word;
+}
+
+.md-typeset a:hover,
+.md-typeset a:focus {
+  color: var(--md-accent-fg-color);
+}
+
+/* Code (inline) */
+.md-typeset code {
+  background-color: var(--md-code-bg-color);
+  color: var(--md-code-fg-color);
+  border-radius: 0.1rem;
+  font-size: 0.85em;
+  font-family: var(--md-code-font);
+  padding: 0 0.2941176471em;
+  word-break: break-word;
+}
+
+/* Code blocks (pre) */
+.md-typeset pre {
+  display: block;
+  line-height: 1.4;
+  margin: 1em 0;
+  overflow-x: auto;
+}
+
+.md-typeset pre > code {
+  background-color: var(--md-code-bg-color);
+  color: var(--md-code-fg-color);
+  display: block;
+  padding: 0.7720588235em 1.1764705882em;
+  font-family: var(--md-code-font);
+  font-size: 0.85em;
+  line-height: 1.4;
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+  white-space: pre-wrap;
+}
+
+/* Links in code */
+.md-typeset a code {
+  color: currentcolor;
+}
+
+/* Logo */
+.headscale-logo {
+  display: block;
+  width: 400px;
+  max-width: 100%;
+  height: auto;
+  margin: 0 0 3rem 0;
+  padding: 0;
+}
+
+@media (max-width: 768px) {
+  .headscale-logo {
+    width: 200px;
+    margin-left: 0;
+  }
+}
diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go
index d52b4d50..8af9f76e 100644
--- a/hscontrol/handlers.go
+++ b/hscontrol/handlers.go
@@ -2,7 +2,6 @@ package hscontrol
 
 import (
 	"bytes"
-	_ "embed"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -14,6 +13,7 @@ import (
 
 	"github.com/chasefleming/elem-go/styles"
 	"github.com/gorilla/mux"
+	"github.com/juanfont/headscale/hscontrol/assets"
 	"github.com/juanfont/headscale/hscontrol/templates"
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/rs/zerolog/log"
@@ -278,20 +278,21 @@ func (a *AuthProviderWeb) RegisterHandler(
 	writer.Write([]byte(templates.RegisterWeb(registrationId).Render()))
 }
 
-//go:embed assets/favicon.png
-var favicon []byte
-
 func FaviconHandler(writer http.ResponseWriter, req *http.Request) {
 	writer.Header().Set("Content-Type", "image/png")
-	http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(favicon))
+	http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(assets.Favicon))
 }
 
-// Returns a blank page with favicon linked.
+// BlankHandler returns a blank page with favicon linked.
 func BlankHandler(writer http.ResponseWriter, res *http.Request) {
 	writer.Header().Set("Content-Type", "text/html; charset=utf-8")
-	io.WriteString(writer, `
-		<!doctype html><html><head>
-		<link rel="icon" href="/favicon.ico">
-		</head><body></body></html>
-	`)
+	writer.WriteHeader(http.StatusOK)
+
+	_, err := writer.Write([]byte(templates.BlankPage().Render()))
+	if err != nil {
+		log.Error().
+			Caller().
+			Err(err).
+			Msg("Failed to write HTTP response")
+	}
 }
diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index 7c7895c6..2164215a 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -4,10 +4,8 @@ import (
 	"bytes"
 	"cmp"
 	"context"
-	_ "embed"
 	"errors"
 	"fmt"
-	"html/template"
 	"net/http"
 	"slices"
 	"strings"
@@ -16,6 +14,7 @@ import (
 	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/gorilla/mux"
 	"github.com/juanfont/headscale/hscontrol/db"
+	"github.com/juanfont/headscale/hscontrol/templates"
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/juanfont/headscale/hscontrol/types/change"
 	"github.com/juanfont/headscale/hscontrol/util"
@@ -191,13 +190,6 @@ type oidcCallbackTemplateConfig struct {
 	Verb string
 }
 
-//go:embed assets/oidc_callback_template.html
-var oidcCallbackTemplateContent string
-
-var oidcCallbackTemplate = template.Must(
-	template.New("oidccallback").Parse(oidcCallbackTemplateContent),
-)
-
 // OIDCCallbackHandler handles the callback from the OIDC endpoint
 // Retrieves the nkey from the state cache and adds the node to the users email user
 // TODO: A confirmation page for new nodes should be added to avoid phishing vulnerabilities
@@ -573,21 +565,12 @@ func (a *AuthProviderOIDC) handleRegistration(
 	return !nodeChange.Empty(), nil
 }
 
-// TODO(kradalby):
-// Rewrite in elem-go.
 func renderOIDCCallbackTemplate(
 	user *types.User,
 	verb string,
 ) (*bytes.Buffer, error) {
-	var content bytes.Buffer
-	if err := oidcCallbackTemplate.Execute(&content, oidcCallbackTemplateConfig{
-		User: user.Display(),
-		Verb: verb,
-	}); err != nil {
-		return nil, fmt.Errorf("rendering OIDC callback template: %w", err)
-	}
-
-	return &content, nil
+	html := templates.OIDCCallback(user.Display(), verb).Render()
+	return bytes.NewBufferString(html), nil
 }
 
 // getCookieName generates a unique cookie name based on a cookie value.
diff --git a/hscontrol/templates/apple.go b/hscontrol/templates/apple.go
index 84928ed5..3b120069 100644
--- a/hscontrol/templates/apple.go
+++ b/hscontrol/templates/apple.go
@@ -5,48 +5,43 @@ import (
 
 	"github.com/chasefleming/elem-go"
 	"github.com/chasefleming/elem-go/attrs"
+	"github.com/chasefleming/elem-go/styles"
 )
 
 func Apple(url string) *elem.Element {
 	return HtmlStructure(
 		elem.Title(nil,
 			elem.Text("headscale - Apple")),
-		elem.Body(attrs.Props{
-			attrs.Style: bodyStyle.ToInline(),
-		},
-			headerOne("headscale: iOS configuration"),
-			headerTwo("GUI"),
-			elem.Ol(nil,
+		mdTypesetBody(
+			headscaleLogo(),
+			H1(elem.Text("iOS configuration")),
+			H2(elem.Text("GUI")),
+			Ol(
 				elem.Li(
 					nil,
 					elem.Text("Install the official Tailscale iOS client from the "),
-					elem.A(
-						attrs.Props{
-							attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
-						},
-						elem.Text("App store"),
-					),
+					externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
 				),
 				elem.Li(
 					nil,
-					elem.Text("Open the Tailscale app"),
+					elem.Text("Open the "),
+					elem.Strong(nil, elem.Text("Tailscale")),
+					elem.Text(" app"),
 				),
 				elem.Li(
 					nil,
-					elem.Text(`Click the account icon in the top-right corner and select "Log in…".`),
+					elem.Text("Click the account icon in the top-right corner and select "),
+					elem.Strong(nil, elem.Text("Log in…")),
 				),
 				elem.Li(
 					nil,
-					elem.Text(`Tap the top-right options menu button and select "Use custom coordination server".`),
+					elem.Text("Tap the top-right options menu button and select "),
+					elem.Strong(nil, elem.Text("Use custom coordination server")),
 				),
 				elem.Li(
 					nil,
-					elem.Text(
-						fmt.Sprintf(
-							`Enter your instance URL: "%s"`,
-							url,
-						),
-					),
+					elem.Text("Enter your instance URL: "),
+					Code(elem.Text(url)),
 				),
 				elem.Li(
 					nil,
@@ -55,65 +50,50 @@ func Apple(url string) *elem.Element {
 					),
 				),
 			),
-			headerOne("headscale: macOS configuration"),
-			headerTwo("Command line"),
-			elem.P(nil,
+			H1(elem.Text("macOS configuration")),
+			H2(elem.Text("Command line")),
+			P(
 				elem.Text("Use Tailscale's login command to add your profile:"),
 			),
-			elem.Pre(nil,
-				elem.Code(nil,
-					elem.Text("tailscale login --login-server "+url),
-				),
-			),
-			headerTwo("GUI"),
-			elem.Ol(nil,
+			Pre(PreCode("tailscale login --login-server "+url)),
+			H2(elem.Text("GUI")),
+			Ol(
 				elem.Li(
 					nil,
-					elem.Text(
-						"Option + Click the Tailscale icon in the menu and hover over the Debug menu",
-					),
+					elem.Text("Option + Click the "),
+					elem.Strong(nil, elem.Text("Tailscale")),
+					elem.Text(" icon in the menu and hover over the "),
+					elem.Strong(nil, elem.Text("Debug")),
+					elem.Text(" menu"),
 				),
 				elem.Li(nil,
-					elem.Text(`Under "Custom Login Server", select "Add Account..."`),
+					elem.Text("Under "),
+					elem.Strong(nil, elem.Text("Custom Login Server")),
+					elem.Text(", select "),
+					elem.Strong(nil, elem.Text("Add Account...")),
 				),
 				elem.Li(
 					nil,
-					elem.Text(
-						fmt.Sprintf(
-							`Enter "%s" of the headscale instance and press "Add Account"`,
-							url,
-						),
-					),
+					elem.Text("Enter "),
+					Code(elem.Text(url)),
+					elem.Text(" of the headscale instance and press "),
+					elem.Strong(nil, elem.Text("Add Account")),
 				),
 				elem.Li(nil,
-					elem.Text(`Follow the login procedure in the browser`),
+					elem.Text("Follow the login procedure in the browser"),
 				),
 			),
-			headerTwo("Profiles"),
-			elem.P(
-				nil,
+			H2(elem.Text("Profiles")),
+			P(
 				elem.Text(
 					"Headscale can be set to the default server by installing a Headscale configuration profile:",
 				),
 			),
-			elem.P(
-				nil,
-				elem.A(
-					attrs.Props{
-						attrs.Href:     "/apple/macos-app-store",
-						attrs.Download: "headscale_macos.mobileconfig",
-					},
-					elem.Text("macOS AppStore profile "),
-				),
-				elem.A(
-					attrs.Props{
-						attrs.Href:     "/apple/macos-standalone",
-						attrs.Download: "headscale_macos.mobileconfig",
-					},
-					elem.Text("macOS Standalone profile"),
-				),
+			elem.Div(attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()},
+				downloadButton("/apple/macos-app-store", "macOS AppStore profile"),
+				downloadButton("/apple/macos-standalone", "macOS Standalone profile"),
 			),
-			elem.Ol(nil,
+			Ol(
 				elem.Li(
 					nil,
 					elem.Text(
@@ -121,105 +101,82 @@ func Apple(url string) *elem.Element {
 					),
 				),
 				elem.Li(nil,
-					elem.Text(`Open System Preferences and go to "Profiles"`),
-				),
-				elem.Li(nil,
-					elem.Text(`Find and install the Headscale profile`),
-				),
-				elem.Li(nil,
-					elem.Text(`Restart Tailscale.app and log in`),
-				),
-			),
-			elem.P(nil, elem.Text("Or")),
-			elem.P(
-				nil,
-				elem.Text(
-					"Use your terminal to configure the default setting for Tailscale by issuing:",
+					elem.Text("Open "),
+					elem.Strong(nil, elem.Text("System Preferences")),
+					elem.Text(" and go to "),
+					elem.Strong(nil, elem.Text("Profiles")),
 				),
-			),
-			elem.Ul(nil,
 				elem.Li(nil,
-					elem.Text(`for app store client:`),
-					elem.Code(
-						nil,
-						elem.Text(
-							"defaults write io.tailscale.ipn.macos ControlURL "+url,
-						),
-					),
+					elem.Text("Find and install the "),
+					elem.Strong(nil, elem.Text("Headscale")),
+					elem.Text(" profile"),
 				),
 				elem.Li(nil,
-					elem.Text(`for standalone client:`),
-					elem.Code(
-						nil,
-						elem.Text(
-							"defaults write io.tailscale.ipn.macsys ControlURL "+url,
-						),
-					),
+					elem.Text("Restart "),
+					elem.Strong(nil, elem.Text("Tailscale.app")),
+					elem.Text(" and log in"),
 				),
 			),
-			elem.P(nil,
-				elem.Text("Restart Tailscale.app and log in."),
-			),
-			headerThree("Caution"),
-			elem.P(
-				nil,
+			orDivider(),
+			P(
 				elem.Text(
-					"You should always download and inspect the profile before installing it:",
+					"Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:",
 				),
 			),
-			elem.Ul(nil,
-				elem.Li(nil,
-					elem.Text(`for app store client: `),
-					elem.Code(nil,
-						elem.Text(fmt.Sprintf(`curl %s/apple/macos-app-store`, url)),
-					),
-				),
-				elem.Li(nil,
-					elem.Text(`for standalone client: `),
-					elem.Code(nil,
-						elem.Text(fmt.Sprintf(`curl %s/apple/macos-standalone`, url)),
-					),
-				),
+			P(elem.Text("For app store client:")),
+			Pre(PreCode("defaults write io.tailscale.ipn.macos ControlURL "+url)),
+			P(elem.Text("For standalone client:")),
+			Pre(PreCode("defaults write io.tailscale.ipn.macsys ControlURL "+url)),
+			P(
+				elem.Text("Restart "),
+				elem.Strong(nil, elem.Text("Tailscale.app")),
+				elem.Text(" and log in."),
 			),
-			headerOne("headscale: tvOS configuration"),
-			headerTwo("GUI"),
-			elem.Ol(nil,
+			warningBox("Caution", "You should always download and inspect the profile before installing it."),
+			P(elem.Text("For app store client:")),
+			Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-app-store`, url))),
+			P(elem.Text("For standalone client:")),
+			Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-standalone`, url))),
+			H1(elem.Text("tvOS configuration")),
+			H2(elem.Text("GUI")),
+			Ol(
 				elem.Li(
 					nil,
 					elem.Text("Install the official Tailscale tvOS client from the "),
-					elem.A(
-						attrs.Props{
-							attrs.Href: "https://apps.apple.com/app/tailscale/id1470499037",
-						},
-						elem.Text("App store"),
-					),
+					externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"),
 				),
 				elem.Li(
 					nil,
-					elem.Text(
-						"Open Settings (the Apple tvOS settings) > Apps > Tailscale",
-					),
+					elem.Text("Open "),
+					elem.Strong(nil, elem.Text("Settings")),
+					elem.Text(" (the Apple tvOS settings) > "),
+					elem.Strong(nil, elem.Text("Apps")),
+					elem.Text(" > "),
+					elem.Strong(nil, elem.Text("Tailscale")),
 				),
 				elem.Li(
 					nil,
-					elem.Text(
-						fmt.Sprintf(
-							`Enter "%s" under "ALTERNATE COORDINATION SERVER URL"`,
-							url,
-						),
-					),
+					elem.Text("Enter "),
+					Code(elem.Text(url)),
+					elem.Text(" under "),
+					elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")),
 				),
 				elem.Li(nil,
-					elem.Text("Return to the tvOS Home screen"),
+					elem.Text("Return to the tvOS "),
+					elem.Strong(nil, elem.Text("Home")),
+					elem.Text(" screen"),
 				),
 				elem.Li(nil,
-					elem.Text("Open Tailscale"),
+					elem.Text("Open "),
+					elem.Strong(nil, elem.Text("Tailscale")),
 				),
 				elem.Li(nil,
-					elem.Text(`Select "Install VPN configuration"`),
+					elem.Text("Select "),
+					elem.Strong(nil, elem.Text("Install VPN configuration")),
 				),
 				elem.Li(nil,
-					elem.Text(`Select "Allow"`),
+					elem.Text("Select "),
+					elem.Strong(nil, elem.Text("Allow")),
 				),
 				elem.Li(nil,
 					elem.Text("Scan the QR code and follow the login procedure"),
@@ -228,6 +185,7 @@ func Apple(url string) *elem.Element {
 					elem.Text("Headscale should now be working on your tvOS device"),
 				),
 			),
+			pageFooter(),
 		),
 	)
 }
diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go
new file mode 100644
index 00000000..615c0e41
--- /dev/null
+++ b/hscontrol/templates/design.go
@@ -0,0 +1,482 @@
+package templates
+
+import (
+	elem "github.com/chasefleming/elem-go"
+	"github.com/chasefleming/elem-go/attrs"
+	"github.com/chasefleming/elem-go/styles"
+)
+
+// Design System Constants
+// These constants define the visual language for all Headscale HTML templates.
+// They ensure consistency across all pages and make it easy to maintain and update the design.
+
+// Color System
+// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css
+// Material for MkDocs design system - exact values from official docs.
+const (
+	// Text colors - from --md-default-fg-color CSS variables.
+	colorTextPrimary   = "#000000de" //nolint:unused // rgba(0,0,0,0.87) - Body text
+	colorTextSecondary = "#0000008a" //nolint:unused // rgba(0,0,0,0.54) - Headings (--md-default-fg-color--light)
+	colorTextTertiary  = "#00000052" //nolint:unused // rgba(0,0,0,0.32) - Lighter text
+	colorTextLightest  = "#00000012" //nolint:unused // rgba(0,0,0,0.07) - Lightest text
+
+	// Code colors - from --md-code-* CSS variables.
+	colorCodeFg = "#36464e" //nolint:unused // Code text color (--md-code-fg-color)
+	colorCodeBg = "#f5f5f5" //nolint:unused // Code background (--md-code-bg-color)
+
+	// Border colors.
+	colorBorderLight  = "#e5e7eb" //nolint:unused // Light borders
+	colorBorderMedium = "#d1d5db" //nolint:unused // Medium borders
+
+	// Background colors.
+	colorBackgroundPage = "#ffffff" //nolint:unused // Page background
+	colorBackgroundCard = "#ffffff" //nolint:unused // Card/content background
+
+	// Accent colors - from --md-primary/accent-fg-color.
+	colorPrimaryAccent = "#4051b5" //nolint:unused // Primary accent (links)
+	colorAccent        = "#526cfe" //nolint:unused // Secondary accent
+
+	// Success colors.
+	colorSuccess      = "#059669" //nolint:unused // Success states
+	colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds
+)
+
+// Spacing System
+// Based on 4px/8px base unit for consistent rhythm.
+// Uses rem units for scalability with user font size preferences.
+const (
+	spaceXS  = "0.25rem" //nolint:unused // 4px - Tight spacing
+	spaceS   = "0.5rem"  //nolint:unused // 8px - Small spacing
+	spaceM   = "1rem"    //nolint:unused // 16px - Medium spacing (base)
+	spaceL   = "1.5rem"  //nolint:unused // 24px - Large spacing
+	spaceXL  = "2rem"    //nolint:unused // 32px - Extra large spacing
+	space2XL = "3rem"    //nolint:unused // 48px - 2x extra large spacing
+	space3XL = "4rem"    //nolint:unused // 64px - 3x extra large spacing
+)
+
+// Typography System
+// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css
+// Material for MkDocs typography - exact values from .md-typeset CSS.
+const (
+	// Font families - from CSS custom properties.
+	fontFamilySystem = `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` //nolint:unused
+	fontFamilyCode   = `"Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace`        //nolint:unused
+
+	// Font sizes - from .md-typeset CSS rules.
+	fontSizeBase  = "0.8rem"   //nolint:unused // 12.8px - Base text (.md-typeset)
+	fontSizeH1    = "2em"      //nolint:unused // 2x base - Main headings
+	fontSizeH2    = "1.5625em" //nolint:unused // 1.5625x base - Section headings
+	fontSizeH3    = "1.25em"   //nolint:unused // 1.25x base - Subsection headings
+	fontSizeSmall = "0.8em"    //nolint:unused // 0.8x base - Small text
+	fontSizeCode  = "0.85em"   //nolint:unused // 0.85x base - Inline code
+
+	// Line heights - from .md-typeset CSS rules.
+	lineHeightBase = "1.6" //nolint:unused // Body text (.md-typeset)
+	lineHeightH1   = "1.3" //nolint:unused // H1 headings
+	lineHeightH2   = "1.4" //nolint:unused // H2 headings
+	lineHeightH3   = "1.5" //nolint:unused // H3 headings
+	lineHeightCode = "1.4" //nolint:unused // Code blocks (pre)
+)
+
+// Responsive Container Component
+// Creates a centered container with responsive padding and max-width.
+// Mobile-first approach: starts at 100% width with padding, constrains on larger screens.
+//
+//nolint:unused // Reserved for future use in Phase 4.
+func responsiveContainer(children ...elem.Node) *elem.Element {
+	return elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Width:    "100%",
+			styles.MaxWidth: "min(800px, 90vw)",         // Responsive: 90% of viewport or 800px max
+			styles.Margin:   "0 auto",                   // Center horizontally
+			styles.Padding:  "clamp(1rem, 5vw, 2.5rem)", // Fluid padding: 16px to 40px
+		}.ToInline(),
+	}, children...)
+}
+
+// Card Component
+// Reusable card for grouping related content with visual separation.
+// Parameters:
+//   - title: Optional title for the card (empty string for no title)
+//   - children: Content elements to display in the card
+//
+//nolint:unused // Reserved for future use in Phase 4.
+func card(title string, children ...elem.Node) *elem.Element {
+	cardContent := children
+	if title != "" {
+		// Prepend title as H3 if provided
+		cardContent = append([]elem.Node{
+			elem.H3(attrs.Props{
+				attrs.Style: styles.Props{
+					styles.MarginTop:    "0",
+					styles.MarginBottom: spaceM,
+					styles.FontSize:     fontSizeH3,
+					styles.LineHeight:   lineHeightH3, // 1.5 - H3 line height
+					styles.Color:        colorTextSecondary,
+				}.ToInline(),
+			}, elem.Text(title)),
+		}, children...)
+	}
+
+	return elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Background:   colorBackgroundCard,
+			styles.Border:       "1px solid " + colorBorderLight,
+			styles.BorderRadius: "0.5rem",                   // 8px rounded corners
+			styles.Padding:      "clamp(1rem, 3vw, 1.5rem)", // Responsive padding
+			styles.MarginBottom: spaceL,
+			styles.BoxShadow:    "0 1px 3px rgba(0,0,0,0.1)", // Subtle shadow
+		}.ToInline(),
+	}, cardContent...)
+}
+
+// Code Block Component
+// EXTRACTED FROM: .md-typeset pre CSS rules
+// Exact styling from Material for MkDocs documentation.
+//
+//nolint:unused // Used across apple.go, windows.go, register_web.go templates.
+func codeBlock(code string) *elem.Element {
+	return elem.Pre(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Display:         "block",
+			styles.Padding:         "0.77em 1.18em", // From .md-typeset pre
+			styles.Border:          "none",          // No border in original
+			styles.BorderRadius:    "0.1rem",        // From .md-typeset code
+			styles.BackgroundColor: colorCodeBg,     // #f5f5f5
+			styles.FontFamily:      fontFamilyCode,  // Roboto Mono
+			styles.FontSize:        fontSizeCode,    // 0.85em
+			styles.LineHeight:      lineHeightCode,  // 1.4
+			styles.OverflowX:       "auto",          // Horizontal scroll
+			"overflow-wrap":        "break-word",    // Word wrapping
+			"word-wrap":            "break-word",    // Legacy support
+			styles.WhiteSpace:      "pre-wrap",      // Preserve whitespace
+			styles.MarginTop:       spaceM,          // 1em
+			styles.MarginBottom:    spaceM,          // 1em
+			styles.Color:           colorCodeFg,     // #36464e
+			styles.BoxShadow:       "none",          // No shadow in original
+		}.ToInline(),
+	},
+		elem.Code(nil, elem.Text(code)),
+	)
+}
+
+// Base Typeset Styles
+// Returns inline styles for the main content container that matches .md-typeset.
+// EXTRACTED FROM: .md-typeset CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used in general.go for mdTypesetBody.
+func baseTypesetStyles() styles.Props {
+	return styles.Props{
+		styles.FontSize:   fontSizeBase,   // 0.8rem
+		styles.LineHeight: lineHeightBase, // 1.6
+		styles.Color:      colorTextPrimary,
+		styles.FontFamily: fontFamilySystem,
+		"overflow-wrap":   "break-word",
+		styles.TextAlign:  "left",
+	}
+}
+
+// H1 Styles
+// Returns inline styles for H1 headings that match .md-typeset h1.
+// EXTRACTED FROM: .md-typeset h1 CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used across templates for main headings.
+func h1Styles() styles.Props {
+	return styles.Props{
+		styles.Color:      colorTextSecondary, // rgba(0, 0, 0, 0.54)
+		styles.FontSize:   fontSizeH1,         // 2em
+		styles.LineHeight: lineHeightH1,       // 1.3
+		styles.Margin:     "0 0 1.25em",
+		styles.FontWeight: "300",
+		"letter-spacing":  "-0.01em",
+		styles.FontFamily: fontFamilySystem, // Roboto
+		"overflow-wrap":   "break-word",
+	}
+}
+
+// H2 Styles
+// Returns inline styles for H2 headings that match .md-typeset h2.
+// EXTRACTED FROM: .md-typeset h2 CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used across templates for section headings.
+func h2Styles() styles.Props {
+	return styles.Props{
+		styles.FontSize:   fontSizeH2,   // 1.5625em
+		styles.LineHeight: lineHeightH2, // 1.4
+		styles.Margin:     "1.6em 0 0.64em",
+		styles.FontWeight: "300",
+		"letter-spacing":  "-0.01em",
+		styles.Color:      colorTextSecondary, // rgba(0, 0, 0, 0.54)
+		styles.FontFamily: fontFamilySystem,   // Roboto
+		"overflow-wrap":   "break-word",
+	}
+}
+
+// H3 Styles
+// Returns inline styles for H3 headings that match .md-typeset h3.
+// EXTRACTED FROM: .md-typeset h3 CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used across templates for subsection headings.
+func h3Styles() styles.Props {
+	return styles.Props{
+		styles.FontSize:   fontSizeH3,   // 1.25em
+		styles.LineHeight: lineHeightH3, // 1.5
+		styles.Margin:     "1.6em 0 0.8em",
+		styles.FontWeight: "400",
+		"letter-spacing":  "-0.01em",
+		styles.Color:      colorTextSecondary, // rgba(0, 0, 0, 0.54)
+		styles.FontFamily: fontFamilySystem,   // Roboto
+		"overflow-wrap":   "break-word",
+	}
+}
+
+// Paragraph Styles
+// Returns inline styles for paragraphs that match .md-typeset p.
+// EXTRACTED FROM: .md-typeset p CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used for consistent paragraph spacing.
+func paragraphStyles() styles.Props {
+	return styles.Props{
+		styles.Margin:     "1em 0",
+		styles.FontFamily: fontFamilySystem, // Roboto
+		styles.FontSize:   fontSizeBase,     // 0.8rem - inherited from .md-typeset
+		styles.LineHeight: lineHeightBase,   // 1.6 - inherited from .md-typeset
+		styles.Color:      colorTextPrimary, // rgba(0, 0, 0, 0.87)
+		"overflow-wrap":   "break-word",
+	}
+}
+
+// Ordered List Styles
+// Returns inline styles for ordered lists that match .md-typeset ol.
+// EXTRACTED FROM: .md-typeset ol CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used for numbered instruction lists.
+func orderedListStyles() styles.Props {
+	return styles.Props{
+		styles.MarginBottom: "1em",
+		styles.MarginTop:    "1em",
+		styles.PaddingLeft:  "2em",
+		styles.FontFamily:   fontFamilySystem, // Roboto - inherited from .md-typeset
+		styles.FontSize:     fontSizeBase,     // 0.8rem - inherited from .md-typeset
+		styles.LineHeight:   lineHeightBase,   // 1.6 - inherited from .md-typeset
+		styles.Color:        colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
+		"overflow-wrap":     "break-word",
+	}
+}
+
+// Unordered List Styles
+// Returns inline styles for unordered lists that match .md-typeset ul.
+// EXTRACTED FROM: .md-typeset ul CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used for bullet point lists.
+func unorderedListStyles() styles.Props {
+	return styles.Props{
+		styles.MarginBottom: "1em",
+		styles.MarginTop:    "1em",
+		styles.PaddingLeft:  "2em",
+		styles.FontFamily:   fontFamilySystem, // Roboto - inherited from .md-typeset
+		styles.FontSize:     fontSizeBase,     // 0.8rem - inherited from .md-typeset
+		styles.LineHeight:   lineHeightBase,   // 1.6 - inherited from .md-typeset
+		styles.Color:        colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset
+		"overflow-wrap":     "break-word",
+	}
+}
+
+// Link Styles
+// Returns inline styles for links that match .md-typeset a.
+// EXTRACTED FROM: .md-typeset a CSS rule from Material for MkDocs.
+// Note: Hover states cannot be implemented with inline styles.
+//
+//nolint:unused // Used for text links.
+func linkStyles() styles.Props {
+	return styles.Props{
+		styles.Color:          colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color)
+		styles.TextDecoration: "none",
+		"word-break":          "break-word",
+		styles.FontFamily:     fontFamilySystem, // Roboto - inherited from .md-typeset
+	}
+}
+
+// Inline Code Styles (updated)
+// Returns inline styles for inline code that matches .md-typeset code.
+// EXTRACTED FROM: .md-typeset code CSS rule from Material for MkDocs.
+//
+//nolint:unused // Used for inline code snippets.
+func inlineCodeStyles() styles.Props {
+	return styles.Props{
+		styles.BackgroundColor: colorCodeBg, // #f5f5f5
+		styles.Color:           colorCodeFg, // #36464e
+		styles.BorderRadius:    "0.1rem",
+		styles.FontSize:        fontSizeCode,   // 0.85em
+		styles.FontFamily:      fontFamilyCode, // Roboto Mono
+		styles.Padding:         "0 0.2941176471em",
+		"word-break":           "break-word",
+	}
+}
+
+// Inline Code Component
+// For inline code snippets within text.
+//
+//nolint:unused // Reserved for future inline code usage.
+func inlineCode(code string) *elem.Element {
+	return elem.Code(attrs.Props{
+		attrs.Style: inlineCodeStyles().ToInline(),
+	}, elem.Text(code))
+}
+
+// orDivider creates a visual "or" divider between sections.
+// Styled with lines on either side for better visual separation.
+//
+//nolint:unused // Used in apple.go template.
+func orDivider() *elem.Element {
+	return elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Display:      "flex",
+			styles.AlignItems:   "center",
+			styles.Gap:          spaceM,
+			styles.MarginTop:    space2XL,
+			styles.MarginBottom: space2XL,
+			styles.Width:        "100%",
+		}.ToInline(),
+	},
+		elem.Div(attrs.Props{
+			attrs.Style: styles.Props{
+				styles.Flex:            "1",
+				styles.Height:          "1px",
+				styles.BackgroundColor: colorBorderLight,
+			}.ToInline(),
+		}),
+		elem.Strong(attrs.Props{
+			attrs.Style: styles.Props{
+				styles.Color:      colorTextSecondary,
+				styles.FontSize:   fontSizeBase,
+				styles.FontWeight: "500",
+				"text-transform":  "uppercase",
+				"letter-spacing":  "0.05em",
+			}.ToInline(),
+		}, elem.Text("or")),
+		elem.Div(attrs.Props{
+			attrs.Style: styles.Props{
+				styles.Flex:            "1",
+				styles.Height:          "1px",
+				styles.BackgroundColor: colorBorderLight,
+			}.ToInline(),
+		}),
+	)
+}
+
+// warningBox creates a warning message box with icon and content.
+//
+//nolint:unused // Used in apple.go template.
+func warningBox(title, message string) *elem.Element {
+	return elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Display:         "flex",
+			styles.AlignItems:      "flex-start",
+			styles.Gap:             spaceM,
+			styles.Padding:         spaceL,
+			styles.BackgroundColor: "#fef3c7",           // yellow-100
+			styles.Border:          "1px solid #f59e0b", // yellow-500
+			styles.BorderRadius:    "0.5rem",
+			styles.MarginTop:       spaceL,
+			styles.MarginBottom:    spaceL,
+		}.ToInline(),
+	},
+		elem.Raw(`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink: 0; margin-top: 2px;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`),
+		elem.Div(nil,
+			elem.Strong(attrs.Props{
+				attrs.Style: styles.Props{
+					styles.Display:      "block",
+					styles.Color:        "#92400e", // yellow-800
+					styles.FontSize:     fontSizeH3,
+					styles.MarginBottom: spaceXS,
+				}.ToInline(),
+			}, elem.Text(title)),
+			elem.Div(attrs.Props{
+				attrs.Style: styles.Props{
+					styles.Color:    colorTextPrimary,
+					styles.FontSize: fontSizeBase,
+				}.ToInline(),
+			}, elem.Text(message)),
+		),
+	)
+}
+
+// downloadButton creates a nice button-style link for downloads.
+//
+//nolint:unused // Used in apple.go template.
+func downloadButton(href, text string) *elem.Element {
+	return elem.A(attrs.Props{
+		attrs.Href:     href,
+		attrs.Download: "headscale_macos.mobileconfig",
+		attrs.Style: styles.Props{
+			styles.Display:         "inline-block",
+			styles.Padding:         "0.75rem 1.5rem",
+			styles.BackgroundColor: "#3b82f6", // blue-500
+			styles.Color:           "#ffffff",
+			styles.TextDecoration:  "none",
+			styles.BorderRadius:    "0.5rem",
+			styles.FontWeight:      "500",
+			styles.Transition:      "background-color 0.2s",
+			styles.MarginRight:     spaceM,
+			styles.MarginBottom:    spaceM,
+		}.ToInline(),
+	}, elem.Text(text))
+}
+
+// External Link Component
+// Creates a link with proper security attributes for external URLs.
+// Automatically adds rel="noreferrer noopener" and target="_blank".
+//
+//nolint:unused // Used in apple.go, oidc_callback.go templates.
+func externalLink(href, text string) *elem.Element {
+	return elem.A(attrs.Props{
+		attrs.Href:   href,
+		attrs.Rel:    "noreferrer noopener",
+		attrs.Target: "_blank",
+		attrs.Style: styles.Props{
+			styles.Color:          colorPrimaryAccent, // #4051b5 - base link color
+			styles.TextDecoration: "none",
+		}.ToInline(),
+	}, elem.Text(text))
+}
+
+// Instruction Step Component
+// For numbered instruction lists with consistent formatting.
+//
+//nolint:unused // Reserved for future use in Phase 4.
+func instructionStep(_ int, text string) *elem.Element {
+	return elem.Li(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.MarginBottom: spaceS,
+			styles.LineHeight:   lineHeightBase,
+		}.ToInline(),
+	}, elem.Text(text))
+}
+
+// Status Message Component
+// For displaying success/error/info messages with appropriate styling.
+//
+//nolint:unused // Reserved for future use in Phase 4.
+func statusMessage(message string, isSuccess bool) *elem.Element {
+	bgColor := colorSuccessLight
+	textColor := colorSuccess
+
+	if !isSuccess {
+		bgColor = "#fee2e2"   // red-100
+		textColor = "#dc2626" // red-600
+	}
+
+	return elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Padding:         spaceM,
+			styles.BackgroundColor: bgColor,
+			styles.Color:           textColor,
+			styles.BorderRadius:    "0.5rem",
+			styles.Border:          "1px solid " + textColor,
+			styles.MarginBottom:    spaceL,
+			styles.FontSize:        fontSizeBase,
+			styles.LineHeight:      lineHeightBase,
+		}.ToInline(),
+	}, elem.Text(message))
+}
diff --git a/hscontrol/templates/general.go b/hscontrol/templates/general.go
index 6e2af390..ccc5a360 100644
--- a/hscontrol/templates/general.go
+++ b/hscontrol/templates/general.go
@@ -4,40 +4,167 @@ import (
 	"github.com/chasefleming/elem-go"
 	"github.com/chasefleming/elem-go/attrs"
 	"github.com/chasefleming/elem-go/styles"
+	"github.com/juanfont/headscale/hscontrol/assets"
 )
 
-var bodyStyle = styles.Props{
-	styles.Margin:     "40px auto",
-	styles.MaxWidth:   "800px",
-	styles.LineHeight: "1.5",
-	styles.FontSize:   "16px",
-	styles.Color:      "#444",
-	styles.Padding:    "0 10px",
-	styles.FontFamily: "Sans-serif",
+// mdTypesetBody creates a body element with md-typeset styling
+// that matches the official Headscale documentation design.
+// Uses CSS classes with styles defined in assets.CSS.
+func mdTypesetBody(children ...elem.Node) *elem.Element {
+	return elem.Body(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.MinHeight:       "100vh",
+			styles.Display:         "flex",
+			styles.FlexDirection:   "column",
+			styles.AlignItems:      "center",
+			styles.BackgroundColor: "#ffffff",
+			styles.Padding:         "3rem 1.5rem",
+		}.ToInline(),
+		"translate": "no",
+	},
+		elem.Div(attrs.Props{
+			attrs.Class: "md-typeset",
+			attrs.Style: styles.Props{
+				styles.MaxWidth: "min(800px, 90vw)",
+				styles.Width:    "100%",
+			}.ToInline(),
+		}, children...),
+	)
+}
+
+// Styled Element Wrappers
+// These functions wrap elem-go elements using CSS classes.
+// Styling is handled by the CSS in assets.CSS.
+
+// H1 creates a H1 element styled by .md-typeset h1
+func H1(children ...elem.Node) *elem.Element {
+	return elem.H1(nil, children...)
+}
+
+// H2 creates a H2 element styled by .md-typeset h2
+func H2(children ...elem.Node) *elem.Element {
+	return elem.H2(nil, children...)
+}
+
+// H3 creates a H3 element styled by .md-typeset h3
+func H3(children ...elem.Node) *elem.Element {
+	return elem.H3(nil, children...)
+}
+
+// P creates a paragraph element styled by .md-typeset p
+func P(children ...elem.Node) *elem.Element {
+	return elem.P(nil, children...)
+}
+
+// Ol creates an ordered list element styled by .md-typeset ol
+func Ol(children ...elem.Node) *elem.Element {
+	return elem.Ol(nil, children...)
 }
 
-var headerStyle = styles.Props{
-	styles.LineHeight: "1.2",
+// Ul creates an unordered list element styled by .md-typeset ul
+func Ul(children ...elem.Node) *elem.Element {
+	return elem.Ul(nil, children...)
 }
 
+// A creates a link element styled by .md-typeset a
+func A(href string, children ...elem.Node) *elem.Element {
+	return elem.A(attrs.Props{attrs.Href: href}, children...)
+}
+
+// Code creates an inline code element styled by .md-typeset code
+func Code(children ...elem.Node) *elem.Element {
+	return elem.Code(nil, children...)
+}
+
+// Pre creates a preformatted text block styled by .md-typeset pre
+func Pre(children ...elem.Node) *elem.Element {
+	return elem.Pre(nil, children...)
+}
+
+// PreCode creates a code block inside Pre styled by .md-typeset pre > code
+func PreCode(code string) *elem.Element {
+	return elem.Code(nil, elem.Text(code))
+}
+
+// Deprecated: use H1, H2, H3 instead
 func headerOne(text string) *elem.Element {
-	return elem.H1(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
+	return H1(elem.Text(text))
 }
 
+// Deprecated: use H1, H2, H3 instead
 func headerTwo(text string) *elem.Element {
-	return elem.H2(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
+	return H2(elem.Text(text))
 }
 
+// Deprecated: use H1, H2, H3 instead
 func headerThree(text string) *elem.Element {
-	return elem.H3(attrs.Props{attrs.Style: headerStyle.ToInline()}, elem.Text(text))
+	return H3(elem.Text(text))
+}
+
+// contentContainer wraps page content with proper width.
+// Content inside is left-aligned by default.
+func contentContainer(children ...elem.Node) *elem.Element {
+	containerStyle := styles.Props{
+		styles.MaxWidth:      "720px",
+		styles.Width:         "100%",
+		styles.Display:       "flex",
+		styles.FlexDirection: "column",
+		styles.AlignItems:    "flex-start", // Left-align all children
+	}
+
+	return elem.Div(attrs.Props{attrs.Style: containerStyle.ToInline()}, children...)
 }
 
+// headscaleLogo returns the Headscale SVG logo for consistent branding across all pages.
+// The logo is styled by the .headscale-logo CSS class.
+func headscaleLogo() elem.Node {
+	// Return the embedded SVG as-is
+	return elem.Raw(assets.SVG)
+}
+
+// pageFooter creates a consistent footer for all pages.
+func pageFooter() *elem.Element {
+	footerStyle := styles.Props{
+		styles.MarginTop:  space3XL,
+		styles.TextAlign:  "center",
+		styles.FontSize:   fontSizeSmall,
+		styles.Color:      colorTextSecondary,
+		styles.LineHeight: lineHeightBase,
+	}
+
+	linkStyle := styles.Props{
+		styles.Color:          colorTextSecondary,
+		styles.TextDecoration: "underline",
+	}
+
+	return elem.Div(attrs.Props{attrs.Style: footerStyle.ToInline()},
+		elem.Text("Powered by "),
+		elem.A(attrs.Props{
+			attrs.Href:   "https://github.com/juanfont/headscale",
+			attrs.Rel:    "noreferrer noopener",
+			attrs.Target: "_blank",
+			attrs.Style:  linkStyle.ToInline(),
+		}, elem.Text("Headscale")),
+	)
+}
+
+// listStyle provides consistent styling for ordered and unordered lists
+// EXTRACTED FROM: .md-typeset ol, .md-typeset ul CSS rules
+var listStyle = styles.Props{
+	styles.LineHeight:   lineHeightBase,               // 1.6 - From .md-typeset
+	styles.MarginTop:    "1em",                        // From CSS: margin-top: 1em
+	styles.MarginBottom: "1em",                        // From CSS: margin-bottom: 1em
+	styles.PaddingLeft:  "clamp(1.5rem, 5vw, 2.5rem)", // Responsive indentation
+}
+
+// HtmlStructure creates a complete HTML document structure with proper meta tags
+// and semantic HTML5 structure. The head and body elements are passed as parameters
+// to allow for customization of each page.
+// Styling is provided via a CSS stylesheet (Material for MkDocs design system) with
+// minimal inline styles for layout and positioning.
 func HtmlStructure(head, body *elem.Element) *elem.Element {
-	return elem.Html(nil,
-		elem.Head(
-			attrs.Props{
-				attrs.Lang: "en",
-			},
+	return elem.Html(attrs.Props{attrs.Lang: "en"},
+		elem.Head(nil,
 			elem.Meta(attrs.Props{
 				attrs.Charset: "UTF-8",
 			}),
@@ -50,11 +177,40 @@ func HtmlStructure(head, body *elem.Element) *elem.Element {
 				attrs.Content: "width=device-width, initial-scale=1.0",
 			}),
 			elem.Link(attrs.Props{
-				attrs.Rel: "icon",
+				attrs.Rel:  "icon",
 				attrs.Href: "/favicon.ico",
 			}),
+			// Google Fonts for Roboto and Roboto Mono
+			elem.Link(attrs.Props{
+				attrs.Rel:     "preconnect",
+				attrs.Href:    "https://fonts.gstatic.com",
+				"crossorigin": "",
+			}),
+			elem.Link(attrs.Props{
+				attrs.Rel:  "stylesheet",
+				attrs.Href: "https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;700&display=swap",
+			}),
+			// Material for MkDocs CSS styles
+			elem.Style(attrs.Props{attrs.Type: "text/css"}, elem.Raw(assets.CSS)),
 			head,
 		),
 		body,
 	)
 }
+
+// BlankPage creates a minimal blank HTML page with favicon.
+// Used for endpoints that need to return a valid HTML page with no content.
+func BlankPage() *elem.Element {
+	return elem.Html(attrs.Props{attrs.Lang: "en"},
+		elem.Head(nil,
+			elem.Meta(attrs.Props{
+				attrs.Charset: "UTF-8",
+			}),
+			elem.Link(attrs.Props{
+				attrs.Rel:  "icon",
+				attrs.Href: "/favicon.ico",
+			}),
+		),
+		elem.Body(nil),
+	)
+}
diff --git a/hscontrol/templates/oidc_callback.go b/hscontrol/templates/oidc_callback.go
new file mode 100644
index 00000000..2b68b703
--- /dev/null
+++ b/hscontrol/templates/oidc_callback.go
@@ -0,0 +1,69 @@
+package templates
+
+import (
+	"github.com/chasefleming/elem-go"
+	"github.com/chasefleming/elem-go/attrs"
+	"github.com/chasefleming/elem-go/styles"
+)
+
+// checkboxIcon returns the success checkbox SVG icon as raw HTML.
+func checkboxIcon() elem.Node {
+	return elem.Raw(`<svg id="checkbox" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 512 512">
+  <path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm114.9 149.1L231.8 359.6c-1.1 1.1-2.9 3.5-5.1 3.5-2.3 0-3.8-1.6-5.1-2.9-1.3-1.3-78.9-75.9-78.9-75.9l-1.5-1.5c-.6-.9-1.1-2-1.1-3.2 0-1.2.5-2.3 1.1-3.2.4-.4.7-.7 1.1-1.2 7.7-8.1 23.3-24.5 24.3-25.5 1.3-1.3 2.4-3 4.8-3 2.5 0 4.1 2.1 5.3 3.3 1.2 1.2 45 43.3 45 43.3l111.3-143c1-.8 2.2-1.4 3.5-1.4 1.3 0 2.5.5 3.5 1.3l30.6 24.1c.8 1 1.3 2.2 1.3 3.5.1 1.3-.4 2.4-1 3.3z"></path>
+</svg>`)
+}
+
+// OIDCCallback renders the OIDC authentication success callback page.
+func OIDCCallback(user, verb string) *elem.Element {
+	// Success message box
+	successBox := elem.Div(attrs.Props{
+		attrs.Style: styles.Props{
+			styles.Display:         "flex",
+			styles.AlignItems:      "center",
+			styles.Gap:             spaceM,
+			styles.Padding:         spaceL,
+			styles.BackgroundColor: colorSuccessLight,
+			styles.Border:          "1px solid " + colorSuccess,
+			styles.BorderRadius:    "0.5rem",
+			styles.MarginBottom:    spaceXL,
+		}.ToInline(),
+	},
+		checkboxIcon(),
+		elem.Div(nil,
+			elem.Strong(attrs.Props{
+				attrs.Style: styles.Props{
+					styles.Display:      "block",
+					styles.Color:        colorSuccess,
+					styles.FontSize:     fontSizeH3,
+					styles.MarginBottom: spaceXS,
+				}.ToInline(),
+			}, elem.Text("Signed in successfully")),
+			elem.P(attrs.Props{
+				attrs.Style: styles.Props{
+					styles.Margin:   "0",
+					styles.Color:    colorTextPrimary,
+					styles.FontSize: fontSizeBase,
+				}.ToInline(),
+			}, elem.Text(verb), elem.Text(" as "), elem.Strong(nil, elem.Text(user)), elem.Text(". You can now close this window.")),
+		),
+	)
+
+	return HtmlStructure(
+		elem.Title(nil, elem.Text("Headscale Authentication Succeeded")),
+		mdTypesetBody(
+			headscaleLogo(),
+			successBox,
+			H2(elem.Text("Getting started")),
+			P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")),
+			Ul(
+				elem.Li(nil,
+					externalLink("https://github.com/juanfont/headscale/tree/main/docs", "Headscale documentation"),
+				),
+				elem.Li(nil,
+					externalLink("https://tailscale.com/kb/", "Tailscale knowledge base"),
+				),
+			),
+			pageFooter(),
+		),
+	)
+}
diff --git a/hscontrol/templates/register_web.go b/hscontrol/templates/register_web.go
index 967b6573..829af7fb 100644
--- a/hscontrol/templates/register_web.go
+++ b/hscontrol/templates/register_web.go
@@ -4,32 +4,18 @@ import (
 	"fmt"
 
 	"github.com/chasefleming/elem-go"
-	"github.com/chasefleming/elem-go/attrs"
-	"github.com/chasefleming/elem-go/styles"
 	"github.com/juanfont/headscale/hscontrol/types"
 )
 
-var codeStyleRegisterWebAPI = styles.Props{
-	styles.Display:         "block",
-	styles.Padding:         "20px",
-	styles.Border:          "1px solid #bbb",
-	styles.BackgroundColor: "#eee",
-}
-
 func RegisterWeb(registrationID types.RegistrationID) *elem.Element {
 	return HtmlStructure(
 		elem.Title(nil, elem.Text("Registration - Headscale")),
-		elem.Body(attrs.Props{
-			attrs.Style: styles.Props{
-				styles.FontFamily: "sans",
-			}.ToInline(),
-		},
-			elem.H1(nil, elem.Text("headscale")),
-			elem.H2(nil, elem.Text("Machine registration")),
-			elem.P(nil, elem.Text("Run the command below in the headscale server to add this machine to your network: ")),
-			elem.Code(attrs.Props{attrs.Style: codeStyleRegisterWebAPI.ToInline()},
-				elem.Text(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String())),
-			),
+		mdTypesetBody(
+			headscaleLogo(),
+			H1(elem.Text("Machine registration")),
+			P(elem.Text("Run the command below in the headscale server to add this machine to your network:")),
+			Pre(PreCode(fmt.Sprintf("headscale nodes register --key %s --user USERNAME", registrationID.String()))),
+			pageFooter(),
 		),
 	)
 }
diff --git a/hscontrol/templates/windows.go b/hscontrol/templates/windows.go
index ecf7d77c..f649509a 100644
--- a/hscontrol/templates/windows.go
+++ b/hscontrol/templates/windows.go
@@ -2,7 +2,6 @@ package templates
 
 import (
 	"github.com/chasefleming/elem-go"
-	"github.com/chasefleming/elem-go/attrs"
 )
 
 func Windows(url string) *elem.Element {
@@ -10,28 +9,19 @@ func Windows(url string) *elem.Element {
 		elem.Title(nil,
 			elem.Text("headscale - Windows"),
 		),
-		elem.Body(attrs.Props{
-			attrs.Style: bodyStyle.ToInline(),
-		},
-			headerOne("headscale: Windows configuration"),
-			elem.P(nil,
+		mdTypesetBody(
+			headscaleLogo(),
+			H1(elem.Text("Windows configuration")),
+			P(
 				elem.Text("Download "),
-				elem.A(attrs.Props{
-					attrs.Href:   "https://tailscale.com/download/windows",
-					attrs.Rel:    "noreferrer noopener",
-					attrs.Target: "_blank",
-				},
-					elem.Text("Tailscale for Windows ")),
-				elem.Text("and install it."),
+				externalLink("https://tailscale.com/download/windows", "Tailscale for Windows"),
+				elem.Text(" and install it."),
 			),
-			elem.P(nil,
-				elem.Text("Open a Command Prompt or Powershell and use Tailscale's login command to connect with headscale: "),
-			),
-			elem.Pre(nil,
-				elem.Code(nil,
-					elem.Text("tailscale login --login-server "+url),
-				),
+			P(
+				elem.Text("Open a Command Prompt or PowerShell and use Tailscale's login command to connect with headscale:"),
 			),
+			Pre(PreCode("tailscale login --login-server "+url)),
+			pageFooter(),
 		),
 	)
 }



Test patch:
diff --git a/hscontrol/oidc_template_test.go b/hscontrol/oidc_template_test.go
new file mode 100644
index 00000000..4c5ecaa7
--- /dev/null
+++ b/hscontrol/oidc_template_test.go
@@ -0,0 +1,63 @@
+package hscontrol
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/juanfont/headscale/hscontrol/templates"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestOIDCCallbackTemplate(t *testing.T) {
+	tests := []struct {
+		name     string
+		userName string
+		verb     string
+	}{
+		{
+			name:     "logged_in_user",
+			userName: "test@example.com",
+			verb:     "Logged in",
+		},
+		{
+			name:     "registered_user",
+			userName: "newuser@example.com",
+			verb:     "Registered",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Render using the elem-go template
+			html := templates.OIDCCallback(tt.userName, tt.verb).Render()
+
+			// Verify the HTML contains expected elements
+			assert.Contains(t, html, "<!DOCTYPE html>")
+			assert.Contains(t, html, "<title>Headscale Authentication Succeeded</title>")
+			assert.Contains(t, html, tt.verb)
+			assert.Contains(t, html, tt.userName)
+			assert.Contains(t, html, "You can now close this window")
+
+			// Verify Material for MkDocs design system CSS is present
+			assert.Contains(t, html, "Material for MkDocs")
+			assert.Contains(t, html, "Roboto")
+			assert.Contains(t, html, ".md-typeset")
+
+			// Verify SVG elements are present
+			assert.Contains(t, html, "<svg")
+			assert.Contains(t, html, "class=\"headscale-logo\"")
+			assert.Contains(t, html, "id=\"checkbox\"")
+
+			// Save the output for manual inspection
+			testDataDir := filepath.Join("testdata", "oidc_templates")
+			err := os.MkdirAll(testDataDir, 0o755)
+			require.NoError(t, err)
+
+			outputFile := filepath.Join(testDataDir, tt.name+".html")
+			err = os.WriteFile(outputFile, []byte(html), 0o600)
+			require.NoError(t, err)
+		})
+	}
+}
diff --git a/hscontrol/templates_consistency_test.go b/hscontrol/templates_consistency_test.go
new file mode 100644
index 00000000..65d14346
--- /dev/null
+++ b/hscontrol/templates_consistency_test.go
@@ -0,0 +1,213 @@
+package hscontrol
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/juanfont/headscale/hscontrol/templates"
+	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTemplateHTMLConsistency(t *testing.T) {
+	// Test all templates produce consistent modern HTML
+	testCases := []struct {
+		name string
+		html string
+	}{
+		{
+			name: "OIDC Callback",
+			html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
+		},
+		{
+			name: "Register Web",
+			html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
+		},
+		{
+			name: "Windows Config",
+			html: templates.Windows("https://example.com").Render(),
+		},
+		{
+			name: "Apple Config",
+			html: templates.Apple("https://example.com").Render(),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Check DOCTYPE
+			assert.True(t, strings.HasPrefix(tc.html, "<!DOCTYPE html>"),
+				"%s should start with <!DOCTYPE html>", tc.name)
+
+			// Check HTML5 lang attribute
+			assert.Contains(t, tc.html, `<html lang="en">`,
+				"%s should have html lang=\"en\"", tc.name)
+
+			// Check UTF-8 charset
+			assert.Contains(t, tc.html, `charset="UTF-8"`,
+				"%s should have UTF-8 charset", tc.name)
+
+			// Check viewport meta tag
+			assert.Contains(t, tc.html, `name="viewport"`,
+				"%s should have viewport meta tag", tc.name)
+
+			// Check IE compatibility meta tag
+			assert.Contains(t, tc.html, `X-UA-Compatible`,
+				"%s should have X-UA-Compatible meta tag", tc.name)
+
+			// Check closing tags
+			assert.Contains(t, tc.html, "</html>",
+				"%s should have closing html tag", tc.name)
+			assert.Contains(t, tc.html, "</head>",
+				"%s should have closing head tag", tc.name)
+			assert.Contains(t, tc.html, "</body>",
+				"%s should have closing body tag", tc.name)
+		})
+	}
+}
+
+func TestTemplateModernHTMLFeatures(t *testing.T) {
+	testCases := []struct {
+		name string
+		html string
+	}{
+		{
+			name: "OIDC Callback",
+			html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
+		},
+		{
+			name: "Register Web",
+			html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
+		},
+		{
+			name: "Windows Config",
+			html: templates.Windows("https://example.com").Render(),
+		},
+		{
+			name: "Apple Config",
+			html: templates.Apple("https://example.com").Render(),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Check no deprecated tags
+			assert.NotContains(t, tc.html, "<font",
+				"%s should not use deprecated <font> tag", tc.name)
+			assert.NotContains(t, tc.html, "<center",
+				"%s should not use deprecated <center> tag", tc.name)
+
+			// Check modern structure
+			assert.Contains(t, tc.html, "<head>",
+				"%s should have <head> section", tc.name)
+			assert.Contains(t, tc.html, "<body",
+				"%s should have <body> section", tc.name)
+			assert.Contains(t, tc.html, "<title>",
+				"%s should have <title> tag", tc.name)
+		})
+	}
+}
+
+func TestTemplateExternalLinkSecurity(t *testing.T) {
+	// Test that all external links (http/https) have proper security attributes
+	testCases := []struct {
+		name         string
+		html         string
+		externalURLs []string // URLs that should have security attributes
+	}{
+		{
+			name: "OIDC Callback",
+			html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
+			externalURLs: []string{
+				"https://github.com/juanfont/headscale/tree/main/docs",
+				"https://tailscale.com/kb/",
+			},
+		},
+		{
+			name:         "Register Web",
+			html:         templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
+			externalURLs: []string{}, // No external links
+		},
+		{
+			name: "Windows Config",
+			html: templates.Windows("https://example.com").Render(),
+			externalURLs: []string{
+				"https://tailscale.com/download/windows",
+			},
+		},
+		{
+			name: "Apple Config",
+			html: templates.Apple("https://example.com").Render(),
+			externalURLs: []string{
+				"https://apps.apple.com/app/tailscale/id1470499037",
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			for _, url := range tc.externalURLs {
+				// Find the link tag containing this URL
+				if !strings.Contains(tc.html, url) {
+					t.Errorf("%s should contain external link %s", tc.name, url)
+					continue
+				}
+
+				// Check for rel="noreferrer noopener"
+				// We look for the pattern: href="URL"...rel="noreferrer noopener"
+				// The attributes might be in any order, so we check within a reasonable window
+				idx := strings.Index(tc.html, url)
+				if idx == -1 {
+					continue
+				}
+
+				// Look for the closing > of the <a> tag (within 200 chars should be safe)
+				endIdx := strings.Index(tc.html[idx:idx+200], ">")
+				if endIdx == -1 {
+					endIdx = 200
+				}
+
+				linkTag := tc.html[idx : idx+endIdx]
+
+				assert.Contains(t, linkTag, `rel="noreferrer noopener"`,
+					"%s external link %s should have rel=\"noreferrer noopener\"", tc.name, url)
+				assert.Contains(t, linkTag, `target="_blank"`,
+					"%s external link %s should have target=\"_blank\"", tc.name, url)
+			}
+		})
+	}
+}
+
+func TestTemplateAccessibilityAttributes(t *testing.T) {
+	// Test that all templates have proper accessibility attributes
+	testCases := []struct {
+		name string
+		html string
+	}{
+		{
+			name: "OIDC Callback",
+			html: templates.OIDCCallback("test@example.com", "Logged in").Render(),
+		},
+		{
+			name: "Register Web",
+			html: templates.RegisterWeb(types.RegistrationID("test-key-123")).Render(),
+		},
+		{
+			name: "Windows Config",
+			html: templates.Windows("https://example.com").Render(),
+		},
+		{
+			name: "Apple Config",
+			html: templates.Apple("https://example.com").Render(),
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			// Check for translate="no" on body tag to prevent browser translation
+			// This is important for technical documentation with commands
+			assert.Contains(t, tc.html, `translate="no"`,
+				"%s should have translate=\"no\" attribute on body tag", tc.name)
+		})
+	}
+}