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.Text("Open "),
+ elem.Strong(nil, elem.Text("System Preferences")),
+ elem.Text(" and go to "),
+ elem.Strong(nil, elem.Text("Profiles")),
),
elem.Li(nil,
- elem.Text(`Find and install the Headscale profile`),
+ elem.Text("Find and install the "),
+ elem.Strong(nil, elem.Text("Headscale")),
+ elem.Text(" profile"),
),
elem.Li(nil,
- elem.Text(`Restart Tailscale.app and log in`),
+ elem.Text("Restart "),
+ elem.Strong(nil, elem.Text("Tailscale.app")),
+ elem.Text(" and log in"),
),
),
- elem.P(nil, elem.Text("Or")),
- elem.P(
- nil,
+ orDivider(),
+ P(
elem.Text(
- "Use your terminal to configure the default setting for Tailscale by issuing:",
+ "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(
- "defaults write io.tailscale.ipn.macos ControlURL "+url,
- ),
- ),
- ),
- elem.Li(nil,
- elem.Text(`for standalone client:`),
- elem.Code(
- nil,
- elem.Text(
- "defaults write io.tailscale.ipn.macsys ControlURL "+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."),
),
- elem.P(nil,
- elem.Text("Restart Tailscale.app and log in."),
- ),
- headerThree("Caution"),
- elem.P(
- nil,
- elem.Text(
- "You should always download and inspect the profile before installing it:",
- ),
- ),
- 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)),
- ),
- ),
- ),
- 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/general.go b/hscontrol/templates/general.go
index a6051d58..2d3ea791 100644
--- a/hscontrol/templates/general.go
+++ b/hscontrol/templates/general.go
@@ -1,46 +1,176 @@
package templates
import (
+ _ "embed"
+
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
"github.com/chasefleming/elem-go/styles"
)
-// bodyStyle provides consistent body styling across all templates with
-// a centered, readable layout and appropriate spacing.
-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",
+//go:embed style.css
+var headscaleCSS string
+
+//go:embed headscale.svg
+var headscaleSVG string
+
+
+
+// mdTypesetBody creates a body element with md-typeset styling
+// that matches the official Headscale documentation design.
+// Uses CSS classes with styles defined in headscaleCSS.
+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...),
+ )
}
-// headerStyle provides consistent header styling with improved line height
-var headerStyle = styles.Props{
- styles.LineHeight: "1.2",
+// Styled Element Wrappers
+// These functions wrap elem-go elements using CSS classes.
+// Styling is handled by the CSS in headscaleCSS.
+
+// H1 creates a H1 element styled by .md-typeset h1
+func H1(children ...elem.Node) *elem.Element {
+ return elem.H1(nil, children...)
}
-// headerOne creates a level 1 heading with consistent styling
+// 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...)
+}
+
+// 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))
}
-// headerTwo creates a level 2 heading with consistent styling
+// 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))
}
-// headerThree creates a level 3 heading with consistent styling
+// 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(headscaleSVG)
+}
+
+// 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(attrs.Props{attrs.Lang: "en"},
elem.Head(nil,
@@ -56,9 +186,21 @@ 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(headscaleCSS)),
head,
),
body,
diff --git a/hscontrol/templates/oidc_callback.go b/hscontrol/templates/oidc_callback.go
index 97462956..2b68b703 100644
--- a/hscontrol/templates/oidc_callback.go
+++ b/hscontrol/templates/oidc_callback.go
@@ -3,221 +3,67 @@ package templates
import (
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
+ "github.com/chasefleming/elem-go/styles"
)
-// headscaleLogo returns the Headscale SVG logo as raw HTML
-func headscaleLogo() elem.Node {
- return elem.Raw(``)
-}
-
-// checkboxIcon returns the success checkbox SVG icon as raw HTML
+// checkboxIcon returns the success checkbox SVG icon as raw HTML.
func checkboxIcon() elem.Node {
- return elem.Raw(`