Add GoatCounter analytics, Miniflux, and update CLAUDE.md
- Add self-hosted GoatCounter via systemd binary service (stats.monotrope.au) - Add Miniflux RSS reader via Docker Compose (reader.monotrope.au) - Extend Ansible playbook with goatcounter and miniflux tags; all provisioning is idempotent - Add Caddy reverse proxy blocks for both new services - Inject GoatCounter script in baseof.html (production builds only) - Add goatcounter and miniflux Makefile targets - Rewrite CLAUDE.md to reflect actual project state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
137
CLAUDE.md
137
CLAUDE.md
@@ -6,97 +6,98 @@ Personal blog and server infrastructure for monotrope.au.
|
||||
|
||||
```
|
||||
monotrope/
|
||||
site/ # Hugo site (content, templates, config)
|
||||
infra/ # Server setup scripts and config files
|
||||
deploy.sh # Build + rsync to production
|
||||
Makefile # Common tasks
|
||||
site/ # Hugo site (content, templates, CSS)
|
||||
infra/
|
||||
ansible/playbook.yml # Single playbook for all server provisioning
|
||||
miniflux/ # Docker Compose for Miniflux RSS reader
|
||||
Caddyfile # Copied to /etc/caddy/Caddyfile by Ansible
|
||||
deploy.sh # Build + rsync to production
|
||||
Makefile # Common tasks
|
||||
.env # Local secrets (not committed)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Static site generator:** Hugo
|
||||
- **Static site generator:** Hugo (no theme — templates built from scratch)
|
||||
- **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
|
||||
- **Deployment:** `hugo --minify` then `rsync` to `/var/www/monotrope`
|
||||
- **Provisioning:** Ansible (`infra/ansible/playbook.yml`)
|
||||
|
||||
## Setup Tasks
|
||||
## Services
|
||||
|
||||
### 1. Initialise the repo
|
||||
| Service | URL | How it runs | Port |
|
||||
|-------------|-----------------------------|-----------------|------|
|
||||
| Blog | https://monotrope.au | Static files | — |
|
||||
| Miniflux | https://reader.monotrope.au | Docker Compose | 8080 |
|
||||
| GoatCounter | https://stats.monotrope.au | systemd binary | 8081 |
|
||||
|
||||
- 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"`
|
||||
## Ansible Playbook
|
||||
|
||||
### 2. Create a minimal Hugo site
|
||||
**All server changes must go through Ansible.** Everything must be idempotent — no ad-hoc SSH changes.
|
||||
|
||||
- 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
|
||||
The playbook is at `infra/ansible/playbook.yml`. Tags let individual services be re-provisioned without touching the rest.
|
||||
|
||||
### 3. Server provisioning script
|
||||
| Tag | What it covers |
|
||||
|---------------|------------------------------------------------------|
|
||||
| `miniflux` | Miniflux Docker Compose + Caddyfile update |
|
||||
| `goatcounter` | GoatCounter binary, systemd service + Caddyfile |
|
||||
| (no tag) | Full provisioning (system, Caddy, Docker, UFW, users)|
|
||||
|
||||
Create `infra/setup.sh` — a bash script intended to be run once on a fresh Ubuntu 24.04 droplet via SSH. It should:
|
||||
### Secrets
|
||||
|
||||
- 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`
|
||||
Pulled from environment variables, loaded from `.env` via Makefile:
|
||||
|
||||
Also create `infra/Caddyfile` as a standalone config file that the setup script copies into place.
|
||||
```
|
||||
MONOTROPE_HOST
|
||||
MINIFLUX_DB_PASSWORD
|
||||
MINIFLUX_ADMIN_USER
|
||||
MINIFLUX_ADMIN_PASSWORD
|
||||
GOATCOUNTER_ADMIN_EMAIL
|
||||
GOATCOUNTER_ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
### 4. Deploy script
|
||||
### GoatCounter
|
||||
|
||||
Create `deploy.sh` at the repo root:
|
||||
Runs as a systemd service (not Docker) using the pre-built binary from GitHub releases.
|
||||
Version is pinned via `goatcounter_version` var in the playbook.
|
||||
Initial site/user creation is gated on a `/var/lib/goatcounter/.admin_created` marker file
|
||||
so re-running the playbook never attempts to recreate the user.
|
||||
|
||||
- Run `hugo --minify` in `site/`
|
||||
- `rsync -avz --delete site/public/ deploy@<DROPLET_IP>:/var/www/monotrope/`
|
||||
- Print the URL on success
|
||||
## Makefile Targets
|
||||
|
||||
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/`)
|
||||
```
|
||||
make build # hugo --minify
|
||||
make serve # hugo server --buildDrafts (local dev)
|
||||
make deploy # build + rsync to production
|
||||
make ssh # SSH as deploy user
|
||||
make setup # Full Ansible provisioning (fresh droplet)
|
||||
make miniflux # Ansible --tags miniflux
|
||||
make goatcounter # Ansible --tags goatcounter
|
||||
```
|
||||
|
||||
## 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)
|
||||
- `www.monotrope.au` → droplet IP (A record, redirects to apex via Caddy)
|
||||
- `reader.monotrope.au` → droplet IP (A record)
|
||||
- `stats.monotrope.au` → droplet IP (A record)
|
||||
|
||||
Caddy will handle certificate provisioning automatically once DNS is pointed.
|
||||
## Site Layout
|
||||
|
||||
## What This Is Not
|
||||
Content lives in `site/content/`:
|
||||
- `posts/` — writing
|
||||
- `reviews/` — book reviews
|
||||
- `about.md` — about page (uses `layouts/page/single.html`)
|
||||
|
||||
- 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)
|
||||
Templates are in `site/layouts/`. No JavaScript unless strictly necessary.
|
||||
The GoatCounter analytics script is injected in `baseof.html` and only loads
|
||||
in production builds (`hugo.IsProduction`).
|
||||
|
||||
## Conventions
|
||||
|
||||
- All shell scripts use `set -euo pipefail`
|
||||
- All server changes go through Ansible — no one-off SSH commands
|
||||
- Ansible tasks must be idempotent
|
||||
- Australian English in content and comments
|
||||
- No CI/CD — deploys are manual via `make deploy`
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build serve deploy ssh setup
|
||||
.PHONY: build serve deploy ssh setup miniflux goatcounter
|
||||
|
||||
# Load .env if it exists
|
||||
-include .env
|
||||
@@ -24,3 +24,11 @@ ssh:
|
||||
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
|
||||
|
||||
miniflux:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags miniflux
|
||||
|
||||
goatcounter:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags goatcounter
|
||||
|
||||
@@ -22,3 +22,17 @@ monotrope.au {
|
||||
www.monotrope.au {
|
||||
redir https://monotrope.au{uri} permanent
|
||||
}
|
||||
|
||||
# Miniflux RSS reader
|
||||
reader.monotrope.au {
|
||||
reverse_proxy localhost:8080
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
# GoatCounter analytics
|
||||
stats.monotrope.au {
|
||||
reverse_proxy localhost:8081
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
site_dir: /var/www/monotrope
|
||||
deploy_user: deploy
|
||||
deploy_pubkey: "{{ lookup('file', lookup('env', 'HOME') + '/.ssh/id_ed25519.pub') }}"
|
||||
miniflux_db_password: "{{ lookup('env', 'MINIFLUX_DB_PASSWORD') }}"
|
||||
miniflux_admin_user: "{{ lookup('env', 'MINIFLUX_ADMIN_USER') | default('admin') }}"
|
||||
miniflux_admin_password: "{{ lookup('env', 'MINIFLUX_ADMIN_PASSWORD') }}"
|
||||
goatcounter_version: "2.7.0"
|
||||
goatcounter_admin_email: "{{ lookup('env', 'GOATCOUNTER_ADMIN_EMAIL') }}"
|
||||
goatcounter_admin_password: "{{ lookup('env', 'GOATCOUNTER_ADMIN_PASSWORD') }}"
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -58,6 +64,9 @@
|
||||
group: caddy
|
||||
mode: '0640'
|
||||
notify: Restart Caddy
|
||||
tags:
|
||||
- miniflux
|
||||
- goatcounter
|
||||
|
||||
- name: Enable and start Caddy
|
||||
systemd:
|
||||
@@ -149,6 +158,142 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# ── Miniflux ────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create Miniflux directory
|
||||
file:
|
||||
path: /opt/miniflux
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0750'
|
||||
tags: miniflux
|
||||
|
||||
- name: Copy Miniflux docker-compose.yml
|
||||
copy:
|
||||
src: ../miniflux/docker-compose.yml
|
||||
dest: /opt/miniflux/docker-compose.yml
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
tags: miniflux
|
||||
|
||||
- name: Write Miniflux .env
|
||||
copy:
|
||||
dest: /opt/miniflux/.env
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0600'
|
||||
content: |
|
||||
MINIFLUX_DB_PASSWORD={{ miniflux_db_password }}
|
||||
MINIFLUX_ADMIN_USER={{ miniflux_admin_user }}
|
||||
MINIFLUX_ADMIN_PASSWORD={{ miniflux_admin_password }}
|
||||
no_log: true
|
||||
tags: miniflux
|
||||
|
||||
- name: Pull and start Miniflux
|
||||
command: docker compose up -d --pull always
|
||||
args:
|
||||
chdir: /opt/miniflux
|
||||
tags: miniflux
|
||||
|
||||
# ── GoatCounter ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create goatcounter system user
|
||||
user:
|
||||
name: goatcounter
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/sbin/nologin
|
||||
state: present
|
||||
tags: goatcounter
|
||||
|
||||
- name: Create GoatCounter data directory
|
||||
file:
|
||||
path: /var/lib/goatcounter
|
||||
state: directory
|
||||
owner: goatcounter
|
||||
group: goatcounter
|
||||
mode: '0750'
|
||||
tags: goatcounter
|
||||
|
||||
- name: Download GoatCounter binary
|
||||
get_url:
|
||||
url: "https://github.com/arp242/goatcounter/releases/download/v{{ goatcounter_version }}/goatcounter-v{{ goatcounter_version }}-linux-amd64.gz"
|
||||
dest: /tmp/goatcounter.gz
|
||||
mode: '0644'
|
||||
tags: goatcounter
|
||||
|
||||
- name: Decompress GoatCounter binary
|
||||
shell: gunzip -f /tmp/goatcounter.gz && mv /tmp/goatcounter /usr/local/bin/goatcounter && chmod 0755 /usr/local/bin/goatcounter
|
||||
args:
|
||||
creates: /usr/local/bin/goatcounter
|
||||
tags: goatcounter
|
||||
|
||||
- name: Check if GoatCounter admin user has been created
|
||||
stat:
|
||||
path: /var/lib/goatcounter/.admin_created
|
||||
register: goatcounter_admin_marker
|
||||
tags: goatcounter
|
||||
|
||||
- name: Create GoatCounter admin user
|
||||
command: >
|
||||
goatcounter db create site
|
||||
-db sqlite+/var/lib/goatcounter/goatcounter.sqlite3
|
||||
-createdb
|
||||
-vhost stats.monotrope.au
|
||||
-user.email {{ goatcounter_admin_email }}
|
||||
-user.password {{ goatcounter_admin_password }}
|
||||
become_user: goatcounter
|
||||
when: not goatcounter_admin_marker.stat.exists
|
||||
no_log: true
|
||||
tags: goatcounter
|
||||
|
||||
- name: Mark GoatCounter admin user as created
|
||||
file:
|
||||
path: /var/lib/goatcounter/.admin_created
|
||||
state: touch
|
||||
owner: goatcounter
|
||||
group: goatcounter
|
||||
mode: '0600'
|
||||
when: not goatcounter_admin_marker.stat.exists
|
||||
tags: goatcounter
|
||||
|
||||
- name: Install GoatCounter systemd service
|
||||
copy:
|
||||
dest: /etc/systemd/system/goatcounter.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
[Unit]
|
||||
Description=GoatCounter analytics
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=goatcounter
|
||||
Group=goatcounter
|
||||
ExecStart=/usr/local/bin/goatcounter serve \
|
||||
-listen localhost:8081 \
|
||||
-db sqlite+/var/lib/goatcounter/goatcounter.sqlite3 \
|
||||
-tls none \
|
||||
-domain stats.monotrope.au
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
notify: Restart GoatCounter
|
||||
tags: goatcounter
|
||||
|
||||
- name: Enable and start GoatCounter
|
||||
systemd:
|
||||
name: goatcounter
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
tags: goatcounter
|
||||
|
||||
# ── Deploy user ──────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create deploy user
|
||||
@@ -186,3 +331,8 @@
|
||||
systemd:
|
||||
name: caddy
|
||||
state: restarted
|
||||
|
||||
- name: Restart GoatCounter
|
||||
systemd:
|
||||
name: goatcounter
|
||||
state: restarted
|
||||
|
||||
38
infra/miniflux/docker-compose.yml
Normal file
38
infra/miniflux/docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
miniflux:
|
||||
image: miniflux/miniflux:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
environment:
|
||||
DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable"
|
||||
RUN_MIGRATIONS: "1"
|
||||
CREATE_ADMIN: "1"
|
||||
ADMIN_USERNAME: "${MINIFLUX_ADMIN_USER}"
|
||||
ADMIN_PASSWORD: "${MINIFLUX_ADMIN_PASSWORD}"
|
||||
BASE_URL: "https://reader.monotrope.au"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- miniflux_db:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: miniflux
|
||||
POSTGRES_USER: miniflux
|
||||
POSTGRES_PASSWORD: "${MINIFLUX_DB_PASSWORD}"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "miniflux"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
miniflux_db:
|
||||
10
site/content/about.md
Normal file
10
site/content/about.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: About
|
||||
type: page
|
||||
---
|
||||
|
||||
Monotrope is a play on the idea of [monotropism](https://en.wikipedia.org/wiki/Monotropism). I created this site as an experiment in writing regularly, and also in trying to own things instead of putting them on corporate platforms. Monotropism relates to the autistic experience, but I also just like the idea of deep singular focus and flow states. Modern work, especially with the use of AI, is increasingly fragmented and alienating.
|
||||
|
||||
I read across all genres, and post my reviews here because I want to own them.
|
||||
|
||||
I live on Djadjawurrung and Taungurong land.
|
||||
@@ -4,4 +4,4 @@ date: 2026-04-08
|
||||
draft: false
|
||||
---
|
||||
|
||||
This is the first post on Monotrope. More to come.
|
||||
This is the first post on Monotrope. My intent is to write here as frequently as possible. In part this is to practice the craft of writing directly, and in part it is to ensure I own the stuff I'm writing.
|
||||
16
site/content/posts/thoughts-on-ai-apr-26.md
Normal file
16
site/content/posts/thoughts-on-ai-apr-26.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Thoughts on AI, circa April 2026"
|
||||
date: 2026-04-08
|
||||
draft: true
|
||||
---
|
||||
|
||||
Part of the reason I wanted to make this site is to practice writing more, and in part that's because the writing I do at work (project plans, technical outlines, etc.) are increasingly written with the help of AI.
|
||||
|
||||
This opens up a can of worms, of which some I will attempt to dissect here. A disclaimer: I don't think my views on this topic are currently ideologically consistent. Broadly I think this is ok, and I think with nascent and rapidly changing areas it's valuable to be able to hold opposing views in your mind for awhile. As the title of the post indicates, these thoughts are also current as of mid-April 2026, and are very likely to change dramatically.
|
||||
|
||||
Firstly, I think AI is, in many contexts, useful. I have spent a bit of time on Bluesky and a lot of anti-AI sentiment there revolves around "AI is dumb, it makes stuff up, no one actually gets value from it." As someone who works in software it is incredibly obvious to me that AI _for coding_ is increidbly useful if you know what you're doing. But I think AI can also be genuinely useful for writing, researching, and ideating. Some of the "AI is not useful" critique will be rooted in a view of AI that is a year or more out of date. "Asking ChatGPT for something instead of Googling for a simple answer" _might have been_ dumb when dealing with early versions of the models that couldn't use tools or search the web, but an AI that can run multiple web searches and consolidate the results is a real value-add, especially when you're not wading through pages of ads and fluff in the search results.
|
||||
|
||||
However, AI in the workplace has not been all sunshine and rainbows. The teams are writing code more quickly, routine work like version upgrades is _much_ quicker, and my personal Claude/Obsidian setup has been good for my own context and productivity. But holy shit the documents. You get out of a meeting where someone takes an action to write a plan and BAM! 7 minutes later they've published a detailed, polished plan that you have to read.
|
||||
|
||||
But the issue
|
||||
|
||||
3
site/content/reviews/_index.md
Normal file
3
site/content/reviews/_index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Reading
|
||||
---
|
||||
26
site/content/reviews/the-compound.md
Normal file
26
site/content/reviews/the-compound.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "The Compound"
|
||||
book_author: "Aisling Rawle"
|
||||
date: 2026-03-01
|
||||
date_read: "March 2026"
|
||||
rating: 4
|
||||
tags: ["dystopia", "satire"]
|
||||
---
|
||||
|
||||
I liked this book _a lot_. I'm giving it 4 stars because I'm not sure I can fully decipher what it was trying to say, or if it was trying to say anything.
|
||||
|
||||
The pitch is "Love Island meets Lord of the Flies", and that probably about sums it up. Its setting is a realtiy TV show in a very ambiguous future dystopia (there are references to "the wars", but really we get no detail of the world outside the show).
|
||||
|
||||
In a sense maybe it's a bit of a mess, it's a satire but it's not obvious exactly what it's satirising. There's an ambient theme of consumerism and late-stage capitalism, but it's never really a plot point, it's just there. It doesn't really make you think so much as make you vaguely uneasy. As an example, the contestants on the show get rewards for completing tasks, and when they get the reward they go to the camera and thank the brand that provided the reward. Nothing else happens about this, and the narrator (whose name is Lilly) doesn't really reflect on it much either. But it creates this sense of the absurd that contrasts with the darkness of what's going on between the characters.
|
||||
|
||||
The narrator is an interesting point of view as well, because she's deliberately written as a bit dumb, which isn't something I've encountered before in a first person narrative.
|
||||
|
||||
Most of the time all of the internal monologue just relates to the immediate situation in the compound, but there are a small number of times when we get a bit more reflection. There's a bit I highlighted where Lilly talks about dreading leaving the show and going home, and it ends with:
|
||||
|
||||
> What did it matter to wake up at the same time every morning and wear the same clothes and try to eat more protein but less sugar, when an earthquake or a tsunami or a bomb might end it all at any minute? Or maybe we would all continue to boil, slowly but surely, in the mess that we pretended was an acceptable place to live.
|
||||
|
||||
I loved the writing style: tight pace, not overly flowery, but it had this creeping sense of unease or dread that made me want to keep reading, even when not much was happening. I haven't read that many thriller type books but I think this is something I really enjoy, when something manages to be a page turner without relying on plot twists or crazy stuff happening all the time.
|
||||
|
||||
Didn't overstay its welcome, the ending was ambiguous and open ended but I thought it was everything it needed to be.
|
||||
|
||||
In writing this I've _very nearly_ convinced myself to bump it up to five stars. Maybe I'll come back to this one.
|
||||
@@ -10,4 +10,4 @@ title = "Monotrope"
|
||||
section = ["HTML", "RSS"]
|
||||
|
||||
[params]
|
||||
description = "A personal blog."
|
||||
description = "writing on software, books, and ideas."
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title>
|
||||
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
{{ printf `
|
||||
<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
{{ printf `<link rel="%s" type="%s" href="%s" title="%s">` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
{{ end -}}
|
||||
{{ if hugo.IsProduction }}
|
||||
<script data-goatcounter="https://stats.monotrope.au/count" async src="https://stats.monotrope.au/count.js"></script>
|
||||
{{ end -}}
|
||||
</head>
|
||||
|
||||
@@ -18,7 +23,9 @@
|
||||
<header class="site-header">
|
||||
<a class="site-title" href="/">{{ .Site.Title }}</a>
|
||||
<nav class="site-nav">
|
||||
<a href="/posts/">Writing</a>
|
||||
<a href="/about/"{{ if hasPrefix .RelPermalink "/about" }} class="active"{{ end }}>about</a>
|
||||
<a href="/posts/"{{ if hasPrefix .RelPermalink "/posts" }} class="active"{{ end }}>writing</a>
|
||||
<a href="/reviews/"{{ if hasPrefix .RelPermalink "/reviews" }} class="active"{{ end }}>reading</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -27,9 +34,10 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>© {{ now.Year }} Monotrope. Built with <a href="https://gohugo.io">Hugo</a>.</p>
|
||||
<span>monotrope.au</span>
|
||||
<span>© {{ now.Year }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
{{ define "main" }}
|
||||
<h2 class="page-heading">{{ .Title }}</h2>
|
||||
<p class="section-label">{{ .Title | lower }}</p>
|
||||
<ul class="post-list">
|
||||
{{ range .Pages.ByDate.Reverse }}
|
||||
<li>
|
||||
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
|
||||
<a href="{{ .Permalink }}">{{ .Title }}</a>
|
||||
<div class="post-entry">
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
{{ with .Params.tags }}
|
||||
<div class="tags">
|
||||
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
|
||||
39
site/layouts/_default/rss.xml
Normal file
39
site/layouts/_default/rss.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
{{- $pctx := . -}}
|
||||
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
|
||||
{{- $pages := slice -}}
|
||||
{{- if or $.IsHome $.IsSection -}}
|
||||
{{- $pages = $pctx.RegularPages -}}
|
||||
{{- else -}}
|
||||
{{- $pages = $pctx.Pages -}}
|
||||
{{- end -}}
|
||||
{{- $pages = where $pages "Type" "ne" "page" -}}
|
||||
{{- $limit := .Site.Config.Services.RSS.Limit -}}
|
||||
{{- if ge $limit 1 -}}
|
||||
{{- $pages = $pages | first $limit -}}
|
||||
{{- end -}}
|
||||
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{ . }} on {{ end }}{{ .Site.Title }}{{ end }}</title>
|
||||
<link>{{ .Permalink }}</link>
|
||||
<description>Recent content {{ if ne .Title .Site.Title }}in {{ .Title }} {{ end }}on {{ .Site.Title }}</description>
|
||||
<generator>Hugo</generator>
|
||||
<language>{{ site.Language.LanguageCode }}</language>
|
||||
{{ with .Site.Copyright }}<copyright>{{ . }}</copyright>{{ end }}
|
||||
{{- if not .Date.IsZero -}}
|
||||
<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
|
||||
{{- end -}}
|
||||
{{- with .OutputFormats.Get "RSS" -}}
|
||||
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
|
||||
{{- end -}}
|
||||
{{ range $pages }}
|
||||
<item>
|
||||
<title>{{ .Title }}</title>
|
||||
<link>{{ .Permalink }}</link>
|
||||
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
|
||||
<guid>{{ .Permalink }}</guid>
|
||||
<description>{{ with .Description }}{{ . | html }}{{ else }}{{ .Summary | html }}{{ end }}</description>
|
||||
</item>
|
||||
{{ end }}
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,9 +1,17 @@
|
||||
{{ define "main" }}
|
||||
<article>
|
||||
<h1>{{ .Title }}</h1>
|
||||
<p class="post-meta">
|
||||
<div class="post-meta">
|
||||
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2 January 2006" }}</time>
|
||||
</p>
|
||||
{{ with .ReadingTime }}<span>{{ . }} min read</span>{{ end }}
|
||||
</div>
|
||||
{{ .Content }}
|
||||
{{ with .Params.tags }}
|
||||
<div class="post-footer">
|
||||
<div class="tags">
|
||||
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
{{ define "main" }}
|
||||
<ul class="post-list">
|
||||
{{ range (where .Site.RegularPages "Type" "posts").ByDate.Reverse }}
|
||||
<li>
|
||||
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
|
||||
<a href="{{ .Permalink }}">{{ .Title }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
<div class="home-prompt">
|
||||
<span class="prompt-line">whoami</span>
|
||||
<span class="prompt-response">{{ .Site.Params.description | default "writing on technology, books, and ideas."
|
||||
}}</span>
|
||||
<span class="prompt-line"><span class="cursor"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="home-sections">
|
||||
|
||||
<section>
|
||||
<div class="home-section-head">
|
||||
<h2>writing</h2>
|
||||
<a class="see-all" href="/posts/">all posts</a>
|
||||
</div>
|
||||
<ul class="post-list">
|
||||
{{ range first 5 (where .Site.RegularPages "Section" "posts").ByDate.Reverse }}
|
||||
<li>
|
||||
<time datetime="{{ .Date.Format " 2006-01-02" }}">{{ .Date.Format "2006-01-02" }}</time>
|
||||
<div class="post-entry">
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
{{ with .Params.tags }}
|
||||
<div class="tags">
|
||||
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="home-section-head">
|
||||
<h2>reading</h2>
|
||||
<a class="see-all" href="/reviews/">all reviews</a>
|
||||
</div>
|
||||
<ul class="review-list">
|
||||
{{ range first 3 (where .Site.RegularPages "Section" "reviews").ByDate.Reverse }}
|
||||
{{ $rating := .Params.rating | default 0 }}
|
||||
<li>
|
||||
<span class="review-date">{{ .Params.date_read | default (.Date.Format "2006-01") }}</span>
|
||||
<div class="review-entry">
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
{{ with .Params.book_author }}<span class="review-author">{{ . }}</span>{{ end }}
|
||||
<span class="review-rating">
|
||||
{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{{ end }}
|
||||
6
site/layouts/page/single.html
Normal file
6
site/layouts/page/single.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{{ define "main" }}
|
||||
<article>
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ .Content }}
|
||||
</article>
|
||||
{{ end }}
|
||||
24
site/layouts/reviews/list.html
Normal file
24
site/layouts/reviews/list.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{ define "main" }}
|
||||
<p class="section-label">reading</p>
|
||||
<ul class="review-list">
|
||||
{{ range .Pages.ByDate.Reverse }}
|
||||
{{ $rating := .Params.rating | default 0 }}
|
||||
<li>
|
||||
<span class="review-date">{{ .Params.date_read | default (.Date.Format "2006-01") }}</span>
|
||||
<div class="review-entry">
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
{{ with .Params.book_author }}<span class="review-author">{{ . }}</span>{{ end }}
|
||||
<span class="review-rating">
|
||||
{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}
|
||||
<span style="color: var(--muted); font-size: 0.8em; margin-left: 0.4rem;">{{ $rating }}/5</span>
|
||||
</span>
|
||||
{{ with .Params.tags }}
|
||||
<div class="tags">
|
||||
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
32
site/layouts/reviews/single.html
Normal file
32
site/layouts/reviews/single.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{ define "main" }}
|
||||
{{ $rating := .Params.rating | default 0 }}
|
||||
<article>
|
||||
<h1>{{ .Title }}</h1>
|
||||
|
||||
<div class="book-meta">
|
||||
{{ with .Params.book_author }}
|
||||
<span class="meta-key">author</span>
|
||||
<span class="meta-val author">{{ . }}</span>
|
||||
{{ end }}
|
||||
|
||||
<span class="meta-key">read</span>
|
||||
<span class="meta-val">{{ .Params.date_read | default (.Date.Format "January 2006") }}</span>
|
||||
|
||||
<span class="meta-key">rating</span>
|
||||
<span class="meta-val">
|
||||
<span class="rating-blocks">{{- strings.Repeat $rating "▓" -}}{{- strings.Repeat (sub 5 $rating) "░" -}}</span>
|
||||
<span class="rating-num">{{ $rating }}/5</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{ .Content }}
|
||||
|
||||
{{ with .Params.tags }}
|
||||
<div class="post-footer">
|
||||
<div class="tags">
|
||||
{{ range . }}<a class="tag" href="/tags/{{ . | urlize }}/">{{ . }}</a>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</article>
|
||||
{{ end }}
|
||||
@@ -1,33 +1,32 @@
|
||||
/* ── Reset & base ─────────────────────────────── */
|
||||
/* ── Fonts ─────────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
/* ── Reset ─────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Colour tokens ────────────────────────────── */
|
||||
/* ── Tokens ────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #6b7280;
|
||||
--accent: #2563eb;
|
||||
--border: #e5e7eb;
|
||||
--code-bg: #f3f4f6;
|
||||
--max-width: 680px;
|
||||
--bg: #0b0d0a;
|
||||
--bg-raised: #111710;
|
||||
--fg: #cde0ba;
|
||||
--fg-dim: #84a070;
|
||||
--muted: #4d6340;
|
||||
--green: #5bff8f;
|
||||
--green-dim: #2a7848;
|
||||
--green-glow: rgba(91, 255, 143, 0.12);
|
||||
--amber: #f0aa45;
|
||||
--amber-dim: rgba(240, 170, 69, 0.15);
|
||||
--border: #192417;
|
||||
--max-w: 680px;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
--font-serif: 'Spectral', Georgia, serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111111;
|
||||
--fg: #e5e5e5;
|
||||
--muted: #9ca3af;
|
||||
--accent: #60a5fa;
|
||||
--border: #2d2d2d;
|
||||
--code-bg: #1e1e1e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Typography ───────────────────────────────── */
|
||||
/* ── Base ──────────────────────────────────────── */
|
||||
html {
|
||||
font-size: 18px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
@@ -36,160 +35,505 @@ html {
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
line-height: 1.7;
|
||||
padding: 2rem 1.25rem;
|
||||
font-family: var(--font-serif);
|
||||
line-height: 1.75;
|
||||
padding: 3rem 1.5rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Layout ───────────────────────────────────── */
|
||||
/* Scanline overlay */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
rgba(0, 0, 0, 0.045) 3px,
|
||||
rgba(0, 0, 0, 0.045) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* ── Layout ────────────────────────────────────── */
|
||||
.site-wrap {
|
||||
max-width: var(--max-width);
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────── */
|
||||
/* ── Header ────────────────────────────────────── */
|
||||
.site-header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 4rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.02em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
color: var(--green);
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
letter-spacing: 0.02em;
|
||||
transition: text-shadow 0.2s;
|
||||
}
|
||||
|
||||
.site-title::before {
|
||||
content: '~/';
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-title:hover {
|
||||
text-shadow: 0 0 16px var(--green-glow), 0 0 32px var(--green-glow);
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--fg-dim);
|
||||
text-decoration: none;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.site-nav a:hover,
|
||||
.site-nav a.active {
|
||||
color: var(--green);
|
||||
border-color: var(--green-dim);
|
||||
}
|
||||
|
||||
/* ── Footer ────────────────────────────────────── */
|
||||
.site-footer {
|
||||
margin-top: 5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.site-footer 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);
|
||||
.site-footer a:hover {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────── */
|
||||
.site-footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
/* ── Section label ─────────────────────────────── */
|
||||
.section-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
margin-bottom: 2rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Post list ────────────────────────────────── */
|
||||
.section-label::before {
|
||||
content: '// ';
|
||||
color: var(--green-dim);
|
||||
}
|
||||
|
||||
/* ── Tags ──────────────────────────────────────── */
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--fg-dim);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.05rem 0.45rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag::before {
|
||||
content: '#';
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
background: var(--amber-dim);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Post list ─────────────────────────────────── */
|
||||
.post-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post-list li {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem 1fr;
|
||||
gap: 0 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.post-list time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.25rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.post-list a {
|
||||
.post-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.post-entry a {
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.post-list a:hover {
|
||||
color: var(--accent);
|
||||
.post-entry a:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ── Article ──────────────────────────────────── */
|
||||
/* ── Single article ────────────────────────────── */
|
||||
article h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.4rem;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 2.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
article h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
||||
article h3 { font-size: 1.1rem; margin: 1.75rem 0 0.5rem; }
|
||||
article h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
article p { margin-bottom: 1.25rem; }
|
||||
article h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 2rem 0 0.5rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
article a {
|
||||
color: var(--accent);
|
||||
color: var(--green);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: var(--green-dim);
|
||||
transition: text-decoration-color 0.15s;
|
||||
}
|
||||
|
||||
article a:hover {
|
||||
text-decoration-color: var(--green);
|
||||
}
|
||||
|
||||
article ul,
|
||||
article ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
article li { margin-bottom: 0.25rem; }
|
||||
article li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
article blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 1rem;
|
||||
color: var(--muted);
|
||||
margin: 1.5rem 0;
|
||||
border-left: 2px solid var(--green-dim);
|
||||
padding-left: 1.25rem;
|
||||
color: var(--fg-dim);
|
||||
margin: 2rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
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;
|
||||
background: var(--bg-raised);
|
||||
color: var(--green);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
font-family: var(--font-mono);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
article pre {
|
||||
background: var(--code-bg);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 2px solid var(--green-dim);
|
||||
padding: 1.25rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1.4rem;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
article pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
article hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 2rem 0;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* ── Page heading ─────────────────────────────── */
|
||||
.page-heading {
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-weight: normal;
|
||||
margin-bottom: 2rem;
|
||||
.post-footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Book review ───────────────────────────────── */
|
||||
.book-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
border-left: 2px solid var(--green-dim);
|
||||
padding: 0.75rem 0 0.75rem 1rem;
|
||||
margin-bottom: 2.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-meta .meta-key {
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.book-meta .meta-val {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.book-meta .meta-val.author {
|
||||
color: var(--fg);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rating-blocks {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--amber);
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.rating-num {
|
||||
color: var(--muted);
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ── Review list ───────────────────────────────── */
|
||||
.review-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.review-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem 1fr;
|
||||
gap: 0 1rem;
|
||||
margin-bottom: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.review-list .review-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
padding-top: 0.25rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.review-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.review-entry a {
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.review-entry a:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.review-entry .review-author {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--fg-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.review-entry .review-rating {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--amber);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* ── Homepage ──────────────────────────────────── */
|
||||
.home-prompt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.8;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.prompt-line::before {
|
||||
content: '> ';
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.prompt-response {
|
||||
display: block;
|
||||
color: var(--fg-dim);
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 0.5em;
|
||||
height: 1.1em;
|
||||
background: var(--green);
|
||||
vertical-align: text-bottom;
|
||||
margin-left: 1px;
|
||||
animation: blink 1.1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.home-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
|
||||
.home-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.home-section-head h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.home-section-head h2::before {
|
||||
content: '// ';
|
||||
color: var(--green-dim);
|
||||
}
|
||||
|
||||
.home-section-head .see-all {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.03em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.home-section-head .see-all::after {
|
||||
content: ' →';
|
||||
}
|
||||
|
||||
.home-section-head .see-all:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────── */
|
||||
@media (max-width: 540px) {
|
||||
html { font-size: 16px; }
|
||||
body { padding: 2rem 1.1rem; }
|
||||
|
||||
.post-list li,
|
||||
.review-list li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.post-list time,
|
||||
.review-list .review-date {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.book-meta {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.book-meta .meta-key {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user