From b090231557e26006ff99076487473b4d7506802c Mon Sep 17 00:00:00 2001 From: Louis Simoneau Date: Wed, 8 Apr 2026 19:45:03 +1000 Subject: [PATCH] Initial commit: Hugo site with Caddy infra and deploy tooling Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 20 +++ CLAUDE.md | 102 ++++++++++++++++ Makefile | 26 ++++ deploy.sh | 33 +++++ infra/Caddyfile | 24 ++++ infra/ansible/playbook.yml | 188 ++++++++++++++++++++++++++++ infra/setup.sh | 97 +++++++++++++++ site/content/posts/hello-world.md | 7 ++ site/hugo.toml | 13 ++ site/layouts/_default/baseof.html | 35 ++++++ site/layouts/_default/list.html | 11 ++ site/layouts/_default/single.html | 9 ++ site/layouts/index.html | 10 ++ site/static/css/main.css | 195 ++++++++++++++++++++++++++++++ 14 files changed, 770 insertions(+) create mode 100644 .gitignore create mode 100755 CLAUDE.md create mode 100644 Makefile create mode 100755 deploy.sh create mode 100644 infra/Caddyfile create mode 100644 infra/ansible/playbook.yml create mode 100755 infra/setup.sh create mode 100644 site/content/posts/hello-world.md create mode 100644 site/hugo.toml create mode 100644 site/layouts/_default/baseof.html create mode 100644 site/layouts/_default/list.html create mode 100644 site/layouts/_default/single.html create mode 100644 site/layouts/index.html create mode 100644 site/static/css/main.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfaf5d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Hugo build output +site/public/ +site/resources/ +site/.hugo_build.lock + +# Claude Code +.claude/ + +# Environment +.env + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000..5ceac65 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# Monotrope + +Personal blog and server infrastructure for monotrope.au. + +## Project Structure + +``` +monotrope/ + site/ # Hugo site (content, templates, config) + infra/ # Server setup scripts and config files + deploy.sh # Build + rsync to production + Makefile # Common tasks +``` + +## Tech Stack + +- **Static site generator:** Hugo +- **Web server:** Caddy (automatic HTTPS via Let's Encrypt) +- **Hosting:** DigitalOcean droplet (Sydney region, Ubuntu 24.04 LTS) +- **Deployment:** `hugo build` then `rsync` to server +- **Future services:** Docker Compose for anything beyond the blog + +## Setup Tasks + +### 1. Initialise the repo + +- Create the directory structure above +- `git init` with a sensible `.gitignore` (Hugo output dir `public/`, `.env`, etc.) +- Initialise a new Hugo site inside `site/` +- Use a minimal theme or no theme — we'll build templates from scratch later +- Hugo config should set `baseURL = "https://monotrope.au"` + +### 2. Create a minimal Hugo site + +- A single layout (`layouts/_default/baseof.html`, `single.html`, `list.html`) +- Minimal, clean CSS — no framework. Readable prose typography, dark mode support via `prefers-color-scheme` +- A sample first post in `site/content/posts/` so we can test the build +- RSS feed enabled (Hugo does this by default) +- No JavaScript unless strictly necessary + +### 3. Server provisioning script + +Create `infra/setup.sh` — a bash script intended to be run once on a fresh Ubuntu 24.04 droplet via SSH. It should: + +- Update packages +- Install Caddy via the official apt repo +- Create a `www` user with no login shell to own the site files +- Create `/var/www/monotrope` owned by `www` +- Install a Caddyfile at `/etc/caddy/Caddyfile` that: + - Serves `monotrope.au` from `/var/www/monotrope` + - Enables gzip/zstd compression + - Sets sensible cache headers for static assets + - Handles `www.monotrope.au` redirect to apex +- Enable and start the Caddy service +- Set up UFW: allow SSH, HTTP, HTTPS, deny everything else +- Install Docker and Docker Compose (for future use, not needed yet) +- Create a deploy user with SSH key auth and permission to write to `/var/www/monotrope` + +Also create `infra/Caddyfile` as a standalone config file that the setup script copies into place. + +### 4. Deploy script + +Create `deploy.sh` at the repo root: + +- Run `hugo --minify` in `site/` +- `rsync -avz --delete site/public/ deploy@:/var/www/monotrope/` +- Print the URL on success + +The droplet IP should come from an environment variable `MONOTROPE_HOST` or a `.env` file (not committed). + +### 5. Makefile + +Targets: +- `make build` — build the site locally +- `make serve` — `hugo server` for local dev with live reload +- `make deploy` — run `deploy.sh` +- `make ssh` — SSH into the droplet as the deploy user +- `make setup` — run the provisioning script on a fresh droplet + +## Conventions + +- All shell scripts should use `set -euo pipefail` +- Prefer clarity over cleverness in all scripts +- No unnecessary dependencies — this should stay simple +- Australian English in all content and comments +- Markdown content lives in `site/content/`; one subdirectory per content type (e.g. `posts/`, `pages/`) + +## DNS + +The user will configure DNS separately via their registrar. The server expects: +- `monotrope.au` → droplet IP (A record) +- `www.monotrope.au` → droplet IP (A record) + +Caddy will handle certificate provisioning automatically once DNS is pointed. + +## What This Is Not + +- No CI/CD pipeline — deploy is manual via `make deploy` +- No containerisation of the blog itself — it's static files +- No database +- No analytics (for now) +- No comments system (for now) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8478c9d --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: build serve deploy ssh setup + +# Load .env if it exists +-include .env +export + +DEPLOY_USER := deploy +MONOTROPE_HOST ?= + +build: + cd site && hugo --minify + +serve: + cd site && hugo server --buildDrafts --disableFastRender + +deploy: + @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1) + bash deploy.sh + +ssh: + @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1) + ssh $(DEPLOY_USER)@$(MONOTROPE_HOST) + +setup: + @test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1) + ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..60d0437 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# deploy.sh — Build and deploy monotrope.au to the production droplet + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Load .env if present +if [[ -f "$SCRIPT_DIR/.env" ]]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/.env" +fi + +MONOTROPE_HOST="${MONOTROPE_HOST:-}" +DEPLOY_USER="deploy" +REMOTE_DIR="/var/www/monotrope" + +if [[ -z "$MONOTROPE_HOST" ]]; then + echo "Error: MONOTROPE_HOST is not set." + echo "Set it in your environment or in a .env file at the repo root." + exit 1 +fi + +echo "==> Building site" +cd "$SCRIPT_DIR/site" +hugo --minify + +echo "==> Deploying to ${DEPLOY_USER}@${MONOTROPE_HOST}:${REMOTE_DIR}" +rsync -avz --delete "$SCRIPT_DIR/site/public/" \ + "${DEPLOY_USER}@${MONOTROPE_HOST}:${REMOTE_DIR}/" + +echo "" +echo "==> Done. Live at https://monotrope.au" diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..fc86e6c --- /dev/null +++ b/infra/Caddyfile @@ -0,0 +1,24 @@ +monotrope.au { + root * /var/www/monotrope + file_server + + # Compression + encode zstd gzip + + # Cache headers for static assets + @static { + path *.css *.js *.ico *.gif *.jpg *.jpeg *.png *.webp *.svg *.woff *.woff2 *.ttf *.eot + } + header @static Cache-Control "public, max-age=31536000, immutable" + + # HTML and RSS — revalidate each time + @html { + path *.html / /posts/ /posts/* + } + header @html Cache-Control "public, max-age=0, must-revalidate" +} + +# Redirect www to apex +www.monotrope.au { + redir https://monotrope.au{uri} permanent +} diff --git a/infra/ansible/playbook.yml b/infra/ansible/playbook.yml new file mode 100644 index 0000000..6f2008f --- /dev/null +++ b/infra/ansible/playbook.yml @@ -0,0 +1,188 @@ +--- +- name: Provision monotrope.au server + hosts: all + become: true + + vars: + site_dir: /var/www/monotrope + deploy_user: deploy + deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}" + + tasks: + + # ── System ────────────────────────────────────────────────────────────── + + - name: Update apt cache and upgrade packages + apt: + update_cache: true + upgrade: dist + cache_valid_time: 3600 + + - name: Install common dependencies + apt: + name: + - debian-keyring + - debian-archive-keyring + - apt-transport-https + - curl + - ufw + state: present + + # ── Caddy ─────────────────────────────────────────────────────────────── + + - name: Add Caddy GPG key + shell: | + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + args: + creates: /usr/share/keyrings/caddy-stable-archive-keyring.gpg + + - name: Add Caddy apt repository + shell: | + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | tee /etc/apt/sources.list.d/caddy-stable.list + args: + creates: /etc/apt/sources.list.d/caddy-stable.list + + - name: Install Caddy + apt: + name: caddy + state: present + update_cache: true + + - name: Install Caddyfile + copy: + src: ../Caddyfile + dest: /etc/caddy/Caddyfile + owner: root + group: caddy + mode: '0640' + notify: Restart Caddy + + - name: Enable and start Caddy + systemd: + name: caddy + enabled: true + state: started + + # ── Site directory ─────────────────────────────────────────────────────── + + - name: Create www system user + user: + name: www + system: true + create_home: false + shell: /usr/sbin/nologin + state: present + + # ── UFW ───────────────────────────────────────────────────────────────── + + - name: Set UFW default incoming policy to deny + ufw: + default: deny + direction: incoming + + - name: Set UFW default outgoing policy to allow + ufw: + default: allow + direction: outgoing + + - name: Allow SSH + ufw: + rule: allow + name: OpenSSH + + - name: Allow HTTP + ufw: + rule: allow + port: '80' + proto: tcp + + - name: Allow HTTPS + ufw: + rule: allow + port: '443' + proto: tcp + + - name: Enable UFW + ufw: + state: enabled + + # ── Docker ────────────────────────────────────────────────────────────── + + - name: Create Docker keyring directory + file: + path: /etc/apt/keyrings + state: directory + mode: '0755' + + - name: Add Docker GPG key + shell: | + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + args: + creates: /etc/apt/keyrings/docker.gpg + + - name: Add Docker apt repository + shell: | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | tee /etc/apt/sources.list.d/docker.list + args: + creates: /etc/apt/sources.list.d/docker.list + + - name: Install Docker + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + update_cache: true + + - name: Enable Docker + systemd: + name: docker + enabled: true + state: started + + # ── Deploy user ────────────────────────────────────────────────────────── + + - name: Create deploy user + user: + name: "{{ deploy_user }}" + create_home: true + shell: /bin/bash + state: present + + - name: Set up deploy user SSH directory + file: + path: "/home/{{ deploy_user }}/.ssh" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0700' + + - name: Install deploy user SSH public key + ansible.posix.authorized_key: + user: "{{ deploy_user }}" + key: "{{ deploy_pubkey }}" + state: present + + - name: Create site directory + file: + path: "{{ site_dir }}" + state: directory + owner: "{{ deploy_user }}" + group: www + mode: '0775' + + handlers: + + - name: Restart Caddy + systemd: + name: caddy + state: restarted diff --git a/infra/setup.sh b/infra/setup.sh new file mode 100755 index 0000000..0ef12c6 --- /dev/null +++ b/infra/setup.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +# setup.sh — Provision a fresh Ubuntu 24.04 droplet for monotrope.au +# Run as root via: ssh root@ 'bash -s' < infra/setup.sh + +DEPLOY_USER="deploy" +SITE_DIR="/var/www/monotrope" +DEPLOY_PUBKEY="${DEPLOY_PUBKEY:-}" # Set this env var before running, or edit below + +echo "==> Updating packages" +apt-get update -y +apt-get upgrade -y + +# ── Caddy ───────────────────────────────────────────────────────────────── +echo "==> Installing Caddy" +apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl + +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | tee /etc/apt/sources.list.d/caddy-stable.list + +apt-get update -y +apt-get install -y caddy + +# ── Site directory ───────────────────────────────────────────────────────── +echo "==> Creating www user and site directory" +id -u www &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin www +mkdir -p "$SITE_DIR" +chown www:www "$SITE_DIR" +chmod 755 "$SITE_DIR" + +# ── Caddyfile ────────────────────────────────────────────────────────────── +echo "==> Installing Caddyfile" +cp "$(dirname "$0")/Caddyfile" /etc/caddy/Caddyfile +chown root:caddy /etc/caddy/Caddyfile +chmod 640 /etc/caddy/Caddyfile + +systemctl enable caddy +systemctl restart caddy + +# ── UFW ──────────────────────────────────────────────────────────────────── +echo "==> Configuring UFW" +apt-get install -y ufw +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow http +ufw allow https +ufw --force enable + +# ── Docker ──────────────────────────────────────────────────────────────── +echo "==> Installing Docker" +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | tee /etc/apt/sources.list.d/docker.list + +apt-get update -y +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +systemctl enable docker + +# ── Deploy user ─────────────────────────────────────────────────────────── +echo "==> Creating deploy user" +id -u "$DEPLOY_USER" &>/dev/null || useradd --create-home --shell /bin/bash "$DEPLOY_USER" + +# Give deploy user write access to the site directory +chown -R "$DEPLOY_USER":www "$SITE_DIR" +chmod 775 "$SITE_DIR" + +# Set up SSH key auth +DEPLOY_HOME="/home/$DEPLOY_USER" +mkdir -p "$DEPLOY_HOME/.ssh" +chmod 700 "$DEPLOY_HOME/.ssh" +touch "$DEPLOY_HOME/.ssh/authorized_keys" +chmod 600 "$DEPLOY_HOME/.ssh/authorized_keys" +chown -R "$DEPLOY_USER":"$DEPLOY_USER" "$DEPLOY_HOME/.ssh" + +if [[ -n "$DEPLOY_PUBKEY" ]]; then + echo "$DEPLOY_PUBKEY" >> "$DEPLOY_HOME/.ssh/authorized_keys" + echo "==> Deploy public key installed" +else + echo "WARNING: DEPLOY_PUBKEY not set. Add your public key to $DEPLOY_HOME/.ssh/authorized_keys manually." +fi + +echo "" +echo "==> Done. Checklist:" +echo " - Point DNS A records for monotrope.au and www.monotrope.au to this server's IP" +echo " - If DEPLOY_PUBKEY was not set, add your key to $DEPLOY_HOME/.ssh/authorized_keys" +echo " - Run 'make deploy' from your local machine to push the site" diff --git a/site/content/posts/hello-world.md b/site/content/posts/hello-world.md new file mode 100644 index 0000000..5d47743 --- /dev/null +++ b/site/content/posts/hello-world.md @@ -0,0 +1,7 @@ +--- +title: "Hello, World" +date: 2026-04-08 +draft: false +--- + +This is the first post on Monotrope. More to come. diff --git a/site/hugo.toml b/site/hugo.toml new file mode 100644 index 0000000..bd7555f --- /dev/null +++ b/site/hugo.toml @@ -0,0 +1,13 @@ +baseURL = "https://monotrope.au" +languageCode = "en-au" +title = "Monotrope" + +[markup.goldmark.renderer] + unsafe = false + +[outputs] + home = ["HTML", "RSS"] + section = ["HTML", "RSS"] + +[params] + description = "A personal blog." diff --git a/site/layouts/_default/baseof.html b/site/layouts/_default/baseof.html new file mode 100644 index 0000000..4139eed --- /dev/null +++ b/site/layouts/_default/baseof.html @@ -0,0 +1,35 @@ + + + + + + + {{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }} + + + {{ range .AlternativeOutputFormats -}} + {{ printf ` + ` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }} + {{ end -}} + + + +
+ + +
+ {{ block "main" . }}{{ end }} +
+ +
+

© {{ now.Year }} Monotrope. Built with Hugo.

+
+
+ + + \ No newline at end of file diff --git a/site/layouts/_default/list.html b/site/layouts/_default/list.html new file mode 100644 index 0000000..3450a38 --- /dev/null +++ b/site/layouts/_default/list.html @@ -0,0 +1,11 @@ +{{ define "main" }} +

{{ .Title }}

+
    + {{ range .Pages.ByDate.Reverse }} +
  • + + {{ .Title }} +
  • + {{ end }} +
+{{ end }} diff --git a/site/layouts/_default/single.html b/site/layouts/_default/single.html new file mode 100644 index 0000000..dfb5d4a --- /dev/null +++ b/site/layouts/_default/single.html @@ -0,0 +1,9 @@ +{{ define "main" }} +
+

{{ .Title }}

+ + {{ .Content }} +
+{{ end }} diff --git a/site/layouts/index.html b/site/layouts/index.html new file mode 100644 index 0000000..3caf0aa --- /dev/null +++ b/site/layouts/index.html @@ -0,0 +1,10 @@ +{{ define "main" }} +
    + {{ range (where .Site.RegularPages "Type" "posts").ByDate.Reverse }} +
  • + + {{ .Title }} +
  • + {{ end }} +
+{{ end }} diff --git a/site/static/css/main.css b/site/static/css/main.css new file mode 100644 index 0000000..f7935d0 --- /dev/null +++ b/site/static/css/main.css @@ -0,0 +1,195 @@ +/* ── Reset & base ─────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Colour tokens ────────────────────────────── */ +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --accent: #2563eb; + --border: #e5e7eb; + --code-bg: #f3f4f6; + --max-width: 680px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111111; + --fg: #e5e5e5; + --muted: #9ca3af; + --accent: #60a5fa; + --border: #2d2d2d; + --code-bg: #1e1e1e; + } +} + +/* ── Typography ───────────────────────────────── */ +html { + font-size: 18px; + -webkit-text-size-adjust: 100%; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: Georgia, "Times New Roman", serif; + line-height: 1.7; + padding: 2rem 1.25rem; +} + +/* ── Layout ───────────────────────────────────── */ +.site-wrap { + max-width: var(--max-width); + margin: 0 auto; +} + +/* ── Header ───────────────────────────────────── */ +.site-header { + margin-bottom: 3rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; +} + +.site-title { + font-size: 1.1rem; + font-weight: bold; + letter-spacing: 0.02em; + text-decoration: none; + color: var(--fg); +} + +.site-nav a { + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; + font-family: system-ui, sans-serif; + margin-left: 1.25rem; +} + +.site-nav a:hover { + color: var(--accent); +} + +/* ── Footer ───────────────────────────────────── */ +.site-footer { + margin-top: 4rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.8rem; + font-family: system-ui, sans-serif; +} + +/* ── Post list ────────────────────────────────── */ +.post-list { + list-style: none; +} + +.post-list li { + margin-bottom: 1.5rem; + display: flex; + align-items: baseline; + gap: 1rem; +} + +.post-list time { + color: var(--muted); + font-size: 0.85rem; + font-family: system-ui, sans-serif; + white-space: nowrap; + flex-shrink: 0; +} + +.post-list a { + color: var(--fg); + text-decoration: none; +} + +.post-list a:hover { + color: var(--accent); +} + +/* ── Article ──────────────────────────────────── */ +article h1 { + font-size: 1.75rem; + line-height: 1.25; + margin-bottom: 0.4rem; +} + +.post-meta { + color: var(--muted); + font-size: 0.85rem; + font-family: system-ui, sans-serif; + margin-bottom: 2rem; +} + +article h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; } +article h3 { font-size: 1.1rem; margin: 1.75rem 0 0.5rem; } + +article p { margin-bottom: 1.25rem; } + +article a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +article ul, +article ol { + padding-left: 1.5rem; + margin-bottom: 1.25rem; +} + +article li { margin-bottom: 0.25rem; } + +article blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + color: var(--muted); + margin: 1.5rem 0; +} + +article code { + background: var(--code-bg); + padding: 0.1em 0.35em; + border-radius: 3px; + font-size: 0.875em; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +article pre { + background: var(--code-bg); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin-bottom: 1.25rem; +} + +article pre code { + background: none; + padding: 0; +} + +article hr { + border: none; + border-top: 1px solid var(--border); + margin: 2rem 0; +} + +/* ── Page heading ─────────────────────────────── */ +.page-heading { + font-size: 1.1rem; + color: var(--muted); + font-family: system-ui, sans-serif; + font-weight: normal; + margin-bottom: 2rem; +}