Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ public/
result
result-*
.direnv/

# Zola class-based syntax highlighting — regenerated into static/ on every build
static/giallo-*.css
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ Project-level guidance for Claude Code working in this repo.

## Stack

Zola static site generator. Content lives in `content/`, templates in `templates/`, styles in `sass/main.scss`, structured data in `data/`. No npm. Build with `zola build`; serve locally via the launch config in `.claude/launch.json`.
Zola static site generator. Content lives in `content/`, templates in `templates/`, styles in `sass/main.scss`, structured data in `data/`. No npm.

The toolchain is pinned by the Nix flake (ADR 0007), and config lives in `zola.toml` (a non-default name), so prefer the flake entrypoints over bare `zola`:

- `nix build` — renders the site into `./result` (the rendered `public/` contents).
- `nix run` (alias `nix run .#serve`) — starts the live-reloading dev server (`zola serve`) at `http://127.0.0.1:1111`.
- `nix develop` — a shell with the pinned Zola on `PATH` for ad-hoc commands (pass `--config zola.toml`).

## Design rules

Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# shanemurphy.space
# semurphy.com

Personal portfolio and writing site for Shane Murphy. Built with [Zola](https://www.getzola.org/) and deployed to GitHub Pages.

Expand All @@ -17,19 +17,27 @@ No Node.js, no npm, no JavaScript build tooling.

## Local development

Install Zola via Homebrew:
The toolchain is pinned by the Nix flake (see [ADR 0007](docs/adr/0007-nix-build-tooling.md)) — no separate Zola install needed.

Serve locally with live reload:

```bash
brew install zola
nix run
```

Serve locally with live reload:
This runs `zola serve` with the correct config; the site is available at `http://127.0.0.1:1111`.

Build the site into `./result` (the rendered `public/` contents):

```bash
zola serve
nix build
```

The site is available at `http://127.0.0.1:1111`.
Or drop into a shell with the pinned Zola on `PATH` for ad-hoc commands:

```bash
nix develop
```

## Deployment

Expand All @@ -40,12 +48,12 @@ Pushes to `main` automatically trigger the GitHub Actions workflow at `.github/w
In the repository's **Settings → Pages**:

- **Source:** GitHub Actions
- **Custom domain:** `shanemurphy.space`
- **Custom domain:** `semurphy.com`
- **Enforce HTTPS:** enabled (once DNS has propagated)

## Custom domain DNS (Squarespace)

The domain `shanemurphy.space` is registered through Squarespace Domains. The following DNS records must be set in the Squarespace DNS panel:
The domain `semurphy.com` is registered through Squarespace Domains. The following DNS records must be set in the Squarespace DNS panel:

### A records (root domain)

Expand Down
5 changes: 5 additions & 0 deletions content/work/fitness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
+++
title = "Why Sitting is Hard"
date = 2026-04-06
draft = true
+++
81 changes: 81 additions & 0 deletions content/work/virtues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
+++
title = "Virtue-Driven Development Considered Harmful"
date = 2026-06-18
draft = true
+++

Software teams love virtues. But architectural values are not ideals; they are
bundles of trade-offs sold under a single flattering name.

- We chose Go because it's simple
- We reached for microservices because they scale
- We picked Rust because it's safe

These sound like arguments, but they are slogans until they say what they cost.
And some don't even survive the asking. "Simplicity" is rarely created or
destroyed — just moved somewhere you stop looking at it. "Scalability" is a bill
you may have no reason to pay.

Safety is the interesting one, because it survives. The borrow checker is a
genuine cost, paid in real units, and it still clears — which is exactly why it
makes a good ruler for the others.

<!-- TODO: spine of the piece — a virtue has to pass TWO tests:
1. Does it name its trade-offs? (honesty)
2. Is the thing even worth wanting? (merit)
- Simple (Go): fails both. Hides costs; "simplicity" may just be
redistributed, not created/destroyed.
- Scalable (microservices): fails both. Hides costs; may not be needed.
- Safe (Rust): the LOAD-BEARING example. Clears merit outright (safety is
genuinely valuable), so only test 1 is left — and the costs (borrow
checker, prototyping speed, hiring bar) are real but worth it.
Rust isn't the exception to "trade-offs with marketing names" — it's the
virtue that earns the name by being honest about the bill. It's what keeps
the essay from collapsing into "never trust any virtue" / never build
anything. Use it as the pivot into the body, not a closing punchline.

Names for smuggling a trade-off in as axiomatically good — overlapping
lenses on the same dodge, NOT a clean taxonomy (resist the urge to build a
tree; the tidiness would itself be a "simple" smuggled in):
- Glittering generality — vague virtue-word that wins assent before you
can ask "simple for whom?" (warm framing — the one to lead with)
- Begging the question / question-begging epithet — the loaded adjective
presents the contested claim as a settled premise (cold/logical framing)
- Persuasive definition — bake the goodness into the word's meaning so
disagreeing sounds anti-virtue (most euphemistic-feeling)
- Thought-terminating cliché — "keep it simple" ends analysis vs advances
- Presupposing a frame — not really the parent of the others, but a
related, less specific sibling: the word ships an unstated context in
which it's obviously good; accept the word, accept the frame. Close-
feeling to all of them without strictly containing any.
Point: a virtue is MOST dangerous when it feels axiomatic — feeling
axiomatic is how it dodges the trade-off question. Safety never gets to be a
glittering generality (nobody finds the borrow checker warm), so it does the
honest work the others skip.

FORMAT / VOICE: practical, not flowery. Keep the philosophy above as
scaffolding in my head, not on the page. The meat is a run of examples, each
one shaped:
quote you've DEFINITELY heard -> the trade-off it hides -> who pays it
Make each cost land by naming the UNIT it's measured in (network calls, lines
of err handling, config branches, onboarding hours). Vague costs read as
opinion; a unit reads as proof. "it's slower" is weak; "you traded a 50ns
function call for a 5ms RPC that can fail" is the lightbulb.
Aim for 5-10 examples: with that many, the reader will feel at least ONE
deep in their bones — that's the hit that sells the whole frame.

Example shortlist (only keep ones the reader has actually heard):
- "microservices so teams move independently" -> fn call became a network
call: retries, partial failure, tracing, staging never fully up.
Independence between teams bought with coupling between services.
- "Go is simple" -> no enums/sum types, if err != nil x50. Language stayed
simple by making YOUR code carry the complexity.
- "no framework, just vanilla, less magic" -> you wrote a worse,
undocumented framework inline; onboarding = read all of it.
- "configurable so it's flexible" -> every option is a branch to test and a
decision pushed onto the user. Flexibility = complexity with a sales team.
- "monorepo, everything in one place" -> real honest trade-off to name.
- "Postgres for everything, keep it boring" -> real honest trade-off.
HONEST COUNTERWEIGHT: keep the Rust/safety section as the one whose bill is
real AND worth paying. Earns reader trust; stops the piece reading as
"every virtue is a lie." -->
40 changes: 40 additions & 0 deletions docs/adr/0008-theming-and-dark-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 0008. Theming and dark mode

- **Status:** Accepted
- **Date:** 2026-06-20

## Context

The site shipped with a single light palette ("Birch & Cream") defined once in `sass/_palette.scss` and exposed to every component as `--color-*` custom properties. No dark mode existed.

Adding a dark mode raises three questions:

1. **How is a palette represented?** The CSS-custom-property indirection already in place means a theme is nothing more than a re-binding of the six `--color-*` tokens. No component CSS needs to change. This is the single-source-of-truth principle from CLAUDE.md applied to color.
2. **How does a visitor select a theme, and does an OS preference participate?** Options: manual toggle only; OS preference only (`prefers-color-scheme`); or both, with manual choice overriding the OS default.
3. **How is a flash of the wrong palette on load avoided?** A purely CSS/`prefers-color-scheme` solution flashes nothing, but a stored manual choice that contradicts the OS will flash unless applied before first paint.

A dark palette was chosen by comparing three candidates in-browser ("Espresso" warm, "Slate" cool-neutral, "Ink" near-black). Slate was selected.

## Decision

**Palette = token re-binding.** Light stays the `:root` default. The dark palette ("Slate") is defined once as a Sass mixin (`slate-tokens`) and applied in two places: under `[data-theme="dark"]` (explicit choice) and under `:root:not([data-theme="light"])` inside a `@media (prefers-color-scheme: dark)` block (OS default). `color-scheme` is set alongside so native form controls and scrollbars match.

**Resolution order:** explicit `[data-theme]` (set by the toggle) → OS `prefers-color-scheme` → light. A stored `"light"` choice opts back out of the OS default via the `:not([data-theme="light"])` guard.

**Persistence and no-flash:** the chosen theme is stored in `localStorage` under `theme`. A tiny inline script in `<head>` applies an explicit stored choice to `<html>` before first paint. With no stored choice, nothing is applied and CSS `prefers-color-scheme` governs — so first-time visitors get their OS preference with zero flash. A second script wires the nav toggle button and keeps its label in sync when the OS preference changes and no explicit choice is set.

Rejected: OS-preference-only (no way to override per-device); manual-only (ignores a preference the visitor already expressed at the OS level); a build-time or server-rendered theme (static host, no per-request state).

## Consequences

**Good:**
- Adding or swapping a palette is editing six values in one mixin; no component CSS changes.
- First-time visitors get their OS preference automatically; returning visitors get their explicit choice; neither flashes.
- The only JavaScript on the site is two small inline scripts — no framework, no build step, consistent with the no-npm constraint.

**Bad / costs:**
- Two inline scripts in `base.html` are now load-bearing for correct first-paint behavior. They must stay inline (an external file would defeat the no-flash goal).
- Syntax highlighting is baked in at build time via `zola.toml`'s `catppuccin-latte` (a light theme). In dark mode, highlighted code blocks look out of place. Fixing this requires switching Zola to CSS-class highlighting and shipping per-theme highlight CSS — deferred until a writing post with code actually needs it.

**Foreclosed:**
- A third user-selectable theme would reintroduce the cycle-style toggle the evaluation phase used; the current toggle is deliberately binary.
34 changes: 34 additions & 0 deletions docs/adr/0009-apex-domain-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 0009. Apex domain migration to semurphy.com

- **Status:** Accepted
- **Date:** 2026-06-20

## Context

The site originally launched on `shanemurphy.space` (ADR 0001 hosting, ADR 0002 domain strategy). In practice the `.space` TLD caused friction: several services (form providers, some email validators, link unfurlers, and account-signup allowlists) treat newer/non-`.com` TLDs as suspicious or reject them outright. A shorter, conventional `.com` avoids this class of papercut.

`semurphy.com` was acquired and the apex was migrated (CNAME + `base_url` already point at it; HTTPS live). This ADR records the decision so the architectural changelog stays coherent — `docs/roadmap.md` and `README.md` now say `semurphy.com`, which would otherwise silently contradict ADRs 0001–0002.

## Decision

The canonical apex domain is **`semurphy.com`**, registered through Squarespace Domains, served by GitHub Pages with the same A records and `www` CNAME (`shaneeverittm.github.io`) documented in `README.md`. `static/CNAME` and `zola.toml`'s `base_url` reflect this.

This **amends the naming** used in ADRs 0001 and 0002; it does **not reverse their decisions**:

- ADR 0001 (GitHub Pages hosting) stands unchanged.
- ADR 0002 (tracker on a subdomain, not a subpath) stands — the subdomain is now `tracker.semurphy.com`.

The old `shanemurphy.space` continues to resolve and redirects to the canonical apex, so existing links don't break.

## Consequences

**Good:**
- Conventional `.com` removes TLD-based rejection from third-party services.
- No structural change: same host, same DNS shape, same deploy pipeline.

**Bad / costs:**
- Two registrations to keep renewed for as long as the `.space` redirect is maintained.
- Any hard-coded `shanemurphy.space` strings (e.g. the contact email `mail@shanemurphy.space` in `zola.toml`) must be migrated deliberately — email in particular depends on where the mailbox actually lives and is intentionally left for a separate decision.

**Foreclosed:**
- Nothing. The redirect preserves the old domain's inbound links indefinitely if desired.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ A starter template is at `0000-template.md`. Copy it, renumber, fill it in.
| [0005](0005-progression-rule-encoding.md) | Encoding progression rules in the program TOML | Accepted |
| [0006](0006-session-json-schema.md) | Session JSON schema | Accepted |
| [0007](0007-nix-build-tooling.md) | Nix flake as the build toolchain | Accepted |
| [0008](0008-theming-and-dark-mode.md) | Theming and dark mode | Accepted |
| [0009](0009-apex-domain-migration.md) | Apex domain migration to semurphy.com | Accepted |
8 changes: 4 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Living document. Updated as phases complete or assumptions change. The original

## Context

The site is a Zola SSG at shanemurphy.space (GitHub Pages, GitHub Actions deploy). It includes a workout program in
The site is a Zola SSG at semurphy.com (GitHub Pages, GitHub Actions deploy). It includes a workout program in
`data/workout_program.toml` with auto-derived volume targets per muscle group.

The goal is a workout tracker that:
Expand Down Expand Up @@ -47,7 +47,7 @@ These touch every later phase, so resolving them first is load-bearing. Each wil

1. **Hosting target.** Currently on GitHub Pages. The tracker write path needs a serverless function that can commit to
the repo via the GitHub API.
2. **Domain strategy.** Subdomain (`tracker.shanemurphy.space`) vs subpath (`/tracker`).
2. **Domain strategy.** Subdomain (`tracker.semurphy.com`) vs subpath (`/tracker`).
3. **Auth between phone and Worker.** Single-user shared secret vs OAuth-with-GitHub vs nothing-but-obscurity.
4. **CI rebuild filtering.** Whether session-only commits should skip the tracker SPA rebuild.
5. **Structured progression rules in TOML.** Whether to encode `progression = { kind, increment, cadence, deload }` now
Expand Down Expand Up @@ -102,9 +102,9 @@ without bundling them into a SPA build.
adds one).
- Verify program.json is fetchable from a deployed page.

**Deliverable:** A live URL like `shanemurphy.space/program.json` that returns the compiled program.
**Deliverable:** A live URL like `semurphy.com/program.json` that returns the compiled program.

**Ready when:** `curl https://shanemurphy.space/program.json` returns valid JSON matching the schema agreed in Phase 0.
**Ready when:** `curl https://semurphy.com/program.json` returns valid JSON matching the schema agreed in Phase 0.

---

Expand Down
36 changes: 13 additions & 23 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
{
description = "shanemurphy.space — Zola static site";
description = "semurphy.com — Zola static site";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};

outputs = { self, nixpkgs }:
outputs =
{ self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
Expand All @@ -31,29 +37,12 @@
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.zola ];
# `nix develop` drops into a bash subshell. When launched from Warp,
# emit Warp's bootstrap escape sequence so the subshell gets blocks,
# completions, etc.
#
# Two guards:
# - $- contains `i` only for an interactive shell. Under direnv the
# hook runs non-interactively inside the already-warpified host
# shell, so there's no subshell to bootstrap — skip it there.
# - $TERM_PROGRAM makes it a no-op outside Warp.
shellHook = ''
case $- in
*i*)
if [ "$TERM_PROGRAM" = "WarpTerminal" ]; then
printf '\eP$f{"hook": "SourcedRcFileForWarp", "value": { "shell": "bash" }}\x9c'
fi
;;
esac
'';
};
});

# `nix run` / `nix run .#serve` → live-reloading dev server.
apps = forAllSystems (pkgs:
apps = forAllSystems (
pkgs:
let
serve = pkgs.writeShellScriptBin "serve" ''
exec ${pkgs.zola}/bin/zola --config zola.toml serve "$@"
Expand All @@ -65,6 +54,7 @@
type = "app";
program = "${serve}/bin/serve";
};
});
}
);
};
}
Loading
Loading