diff --git a/.claude/context/deployment.md b/.claude/context/deployment.md
new file mode 100644
index 0000000..2925406
--- /dev/null
+++ b/.claude/context/deployment.md
@@ -0,0 +1,32 @@
+# Deployment
+
+## Pipeline
+
+GitHub Actions workflow: `.github/workflows/cicd.yml`
+
+**Trigger**: Push to `src/**` or `.github/workflows/**`
+
+**Steps**:
+1. Checkout repo (with Git LFS)
+2. Detect branch name
+3. Configure AWS credentials
+4. Create/select S3 bucket (main → production bucket, branches → `{branch}.{bucket}`)
+5. Sync `src/` directory to S3 with public-read ACL
+6. Configure S3 static website hosting (index: `index.html`, error: `404.html`)
+7. Apply bucket policy from `.github/workflows/policy.json`
+8. For non-main branches: create Cloudflare CNAME record for subdomain
+9. Purge Cloudflare cache
+
+## Branch Strategy
+
+| Branch | URL | Auto-created |
+|--------|-----|-------------|
+| `main` | https://lef.fyi | No (production) |
+| `feature-x` | https://feature-x.lef.fyi | Yes (DNS + S3 bucket) |
+
+## Secrets (referenced in CI, never stored in repo)
+
+- `AWS_ACCESS_KEY`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` — S3 access
+- `AWS_S3_BUCKET` — production bucket name
+- `CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_DNS_SECRET_API_TOKEN` — DNS + cache management
+- `EMAIL` — Cloudflare auth email
diff --git a/.claude/context/project-structure.md b/.claude/context/project-structure.md
new file mode 100644
index 0000000..92be059
--- /dev/null
+++ b/.claude/context/project-structure.md
@@ -0,0 +1,55 @@
+# Project Structure
+
+```
+lef-web/
+├── src/ # Deployable website root
+│ ├── index.html # Homepage
+│ ├── 404.html # Error page
+│ ├── pages/
+│ │ └── posts/ # Blog posts
+│ │ ├── 20220305-my-first-post.html
+│ │ └── 20220319-a-very-poetic-python.html
+│ ├── media/ # All media assets
+│ │ ├── 00-common/ # Shared assets (banner image)
+│ │ └── YYYYMMDD-slug/ # Post-specific media directories
+│ ├── tufte.css # Source stylesheet
+│ ├── tufte.min.css # Minified stylesheet (generated, commit both)
+│ ├── et-book/ # ET Book font files (Roman, Bold, Italic, Display variants)
+│ ├── favicon.ico
+│ ├── robots.txt
+│ └── sitemap.txt # Generated by tools/src/sitemap_generator.py
+├── tools/ # Python utilities
+│ ├── pyproject.toml # uv config (Python 3.9+, ruff linter)
+│ ├── uv.lock
+│ └── src/
+│ └── sitemap_generator.py # Generates sitemap.txt from src/ directory
+├── .github/
+│ └── workflows/
+│ ├── cicd.yml # Deploy pipeline (S3 + Cloudflare)
+│ └── policy.json # S3 bucket policy template
+├── .claude/ # Claude AI collaboration config
+│ ├── settings.json # Permissions
+│ ├── context/ # Domain knowledge files
+│ └── skills/ # Step-by-step procedures
+├── .vscode/ # Editor config
+│ ├── settings.json # Spell checker words
+│ ├── launch.json # Debug configs
+│ └── html.code-snippets # HTML authoring snippets
+├── .gitattributes # Git LFS: *.png, *.jpg, *.jpeg
+├── .gitignore # .DS_Store, node_modules
+├── package.json # npm: clean-css-cli, http-server
+└── CLAUDE.md # Primary Claude developer guide
+```
+
+## Naming Conventions
+
+- Post files: `YYYYMMDD-slug.html` (date prefix for chronological sorting)
+- Media directories: match the post name (`YYYYMMDD-slug/`)
+- Common/shared media: `00-common/` prefix ensures it sorts first
+- CSS: edit `tufte.css`, always regenerate `tufte.min.css`
+
+## Git LFS
+
+Tracked via `.gitattributes`:
+- `*.png`
+- `*.jpg` / `*.jpeg`
diff --git a/.claude/context/tufte-patterns.md b/.claude/context/tufte-patterns.md
new file mode 100644
index 0000000..b697d81
--- /dev/null
+++ b/.claude/context/tufte-patterns.md
@@ -0,0 +1,110 @@
+# Tufte CSS HTML Patterns
+
+Reference for all HTML patterns used in this project. Copy and adapt as needed.
+
+## Page Template
+
+```html
+
+
+
+
+
+PAGE TITLE | Lef adores you ❤️
+
+
+
+
+
+
+
+
+
+
+
PAGE TITLE
+
First paragraph.
+
+
+
+
+
+```
+
+Note: The invisible duplicate banner image is a spacing hack — do not remove it.
+
+## Sidenote (Numbered Marginal Note)
+
+Each sidenote needs a **unique id** within the page. Place inline within a `
`:
+
+```html
+
+
+
+
+ Note content here.
+
+```
+
+- Desktop: appears in the right margin with a superscript number
+- Mobile: collapses behind a ⊕ toggle button (CSS-only, no JS)
+
+## Sidenote with Image
+
+```html
+
+
+
+
+
+
+```
+
+## Full-Width Image
+
+```html
+
+```
+
+## Blockquote
+
+```html
+
+
Title
+ Content here.
+
+```
+
+## Inline Code
+
+```html
+some_command
+```
+
+## Links
+
+Same tab:
+```html
+Link text
+```
+
+New tab (external links):
+```html
+Link text
+```
+
+## Section Structure
+
+Posts use `` with multiple `` children. Each section typically has a heading:
+
+```html
+
+
+
Post Title
+
Intro content...
+
+
+
Subsection
+
More content...
+
+
+```
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..c30a15f
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm run start)",
+ "Bash(npm run cssmin)",
+ "Bash(npx http-server*)",
+ "Bash(cd tools && uv run python*)"
+ ]
+ }
+}
diff --git a/.claude/skills/creating-post/SKILL.md b/.claude/skills/creating-post/SKILL.md
new file mode 100644
index 0000000..4ac54d2
--- /dev/null
+++ b/.claude/skills/creating-post/SKILL.md
@@ -0,0 +1,46 @@
+---
+name: creating-post
+description: Create a new blog post with the standard Tufte CSS template and update all references.
+---
+
+# Creating a Blog Post
+
+## When to Use
+When creating a new page or blog post for lef.fyi.
+
+## Steps
+
+1. **Create the HTML file**
+ - Path: `src/pages/posts/YYYYMMDD-slug.html`
+ - Use today's date (YYYYMMDD format) and a URL-friendly slug
+ - Start from the page template in `.claude/context/tufte-patterns.md`
+
+2. **Create media directory** (if the post has images)
+ - Path: `src/media/YYYYMMDD-slug/`
+ - Name must match the post filename (without `.html`)
+
+3. **Write the content**
+ - Use `` > `` structure
+ - Add sidenotes for supplementary information (see tufte-patterns.md)
+ - Each sidenote `id` must be unique within the page
+ - Use external link pattern (`target="_blank" rel="noopener noreferrer"`) for off-site links
+
+4. **Add to homepage**
+ - Edit `src/index.html`
+ - Add a link to the new post in the appropriate section
+
+5. **Update sitemap**
+ - Run: `cd tools && uv run python src/sitemap_generator.py https://lef.fyi ../src --except "et-book"`
+ - Or manually add the URL to `src/sitemap.txt`
+
+6. **Preview locally**
+ - Run: `npm run start`
+ - Check at http://127.0.0.1:8080/pages/posts/YYYYMMDD-slug.html
+ - Verify sidenotes toggle correctly on narrow viewport
+
+## Verification
+- Page loads without errors
+- Banner image displays and links to homepage
+- Sidenotes display in margin on desktop, toggle on mobile
+- Footer "Return home" link works
+- Post appears on homepage
diff --git a/.claude/skills/updating-styles/SKILL.md b/.claude/skills/updating-styles/SKILL.md
new file mode 100644
index 0000000..03915f9
--- /dev/null
+++ b/.claude/skills/updating-styles/SKILL.md
@@ -0,0 +1,35 @@
+---
+name: updating-styles
+description: Modify the Tufte CSS stylesheet and regenerate the minified version.
+---
+
+# Updating Styles
+
+## When to Use
+When making any CSS changes to the website's appearance.
+
+## Steps
+
+1. **Edit the source stylesheet**
+ - Path: `src/tufte.css`
+ - Never edit `tufte.min.css` directly — it's generated
+
+2. **Regenerate minified CSS**
+ - Run: `npm run cssmin`
+ - This overwrites `src/tufte.min.css` from `src/tufte.css`
+
+3. **Preview changes**
+ - Run: `npm run start`
+ - Check multiple pages (homepage + a post) to verify nothing broke
+ - Test responsive behavior at narrow widths (sidenote toggles, full-width images)
+
+4. **Commit both files**
+ - Always commit `tufte.css` and `tufte.min.css` together
+
+## Key CSS Architecture
+- Base width: 55% for text, right margin reserved for sidenotes
+- Responsive: orientation-based media queries (portrait mode triggers mobile layout)
+- Sidenotes use CSS counters for automatic numbering
+- Full-width images use `max-width: none` to escape the text column
+- Colors: background `#f9fefc`, text `#443b36`, accent `#dc5945`
+- Font: ET Book (custom, loaded from `et-book/` directory)
diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml
index 7bf040d..6ef2ea7 100644
--- a/.github/workflows/cicd.yml
+++ b/.github/workflows/cicd.yml
@@ -10,58 +10,77 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@master
+ - uses: actions/checkout@v4
with:
lfs: true
+
- name: Get branch name
id: branch-name
- uses: tj-actions/branch-names@v5.1
+ run: echo "BRANCH=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
+
- name: Configure AWS Credentials
- uses: aws-actions/configure-aws-credentials@v1
+ uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- - name: Create S3 Bucket
- id: create-s3-bucket
+
+ - name: Determine S3 bucket name
+ id: bucket
run: |
- if [ "${{ steps.branch-name.outputs.current_branch }}" = "main" ]; then
+ if [ "${{ steps.branch-name.outputs.BRANCH }}" = "main" ]; then
BUCKET=${{ secrets.AWS_S3_BUCKET }}
else
- BUCKET=${{ steps.branch-name.outputs.current_branch }}.${{ secrets.AWS_S3_BUCKET }}
+ BUCKET=${{ steps.branch-name.outputs.BRANCH }}.${{ secrets.AWS_S3_BUCKET }}
fi
- if ! aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
- aws s3 mb s3://${BUCKET}
+ echo "NAME=${BUCKET}" >> $GITHUB_OUTPUT
+
+ - name: Create S3 bucket if needed
+ run: |
+ if ! aws s3api head-bucket --bucket "${{ steps.bucket.outputs.NAME }}" 2>/dev/null; then
+ aws s3 mb s3://${{ steps.bucket.outputs.NAME }}
fi
- echo "::set-output name=BUCKET::${BUCKET}"
- - name: Sync repo to S3
- uses: jakejarvis/s3-sync-action@v0.5.1
- with:
- args: --acl public-read --follow-symlinks --delete
- env:
- AWS_S3_BUCKET: ${{ steps.create-s3-bucket.outputs.BUCKET }}
- SOURCE_DIR: "src"
- - name: Rename example.com to our domain in policy.json
+
+ - name: Configure bucket for static website hosting
run: |
- cat ${{ github.workspace }}/.github/workflows/policy.json |sed s/www.example.com/${{ steps.create-s3-bucket.outputs.BUCKET }}/g > policy.json
- - name: Make S3 bucket into a statically hosted website and apply policies
+ aws s3api delete-public-access-block \
+ --bucket ${{ steps.bucket.outputs.NAME }}
+ sed "s/www.example.com/${{ steps.bucket.outputs.NAME }}/g" \
+ ${{ github.workspace }}/.github/workflows/policy.json > /tmp/policy.json
+ aws s3api put-bucket-policy \
+ --bucket ${{ steps.bucket.outputs.NAME }} \
+ --policy file:///tmp/policy.json
+ aws s3 website s3://${{ steps.bucket.outputs.NAME }}/ \
+ --index-document index.html \
+ --error-document 404.html
+
+ - name: Sync src/ to S3
run: |
- aws s3 website s3://${{ steps.create-s3-bucket.outputs.BUCKET }}/ --index-document index.html --error-document 404.html
- aws s3api put-bucket-policy --bucket ${{ steps.create-s3-bucket.outputs.BUCKET }} --policy file://policy.json
- aws s3api delete-public-access-block --bucket ${{ steps.create-s3-bucket.outputs.BUCKET }}
- - name: Update DNS Records
+ aws s3 sync src/ s3://${{ steps.bucket.outputs.NAME }}/ \
+ --follow-symlinks \
+ --delete
+
+ - name: Create DNS record for branch subdomain
+ if: steps.branch-name.outputs.BRANCH != 'main'
run: |
- if ! [ "${{ steps.branch-name.outputs.current_branch }}" = "main" ]; then
- curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \
- -H "X-Auth-Email: ${{ secrets.EMAIL }}" \
- -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \
- -H "Content-Type: application/json" \
- --data '{"type":"CNAME","name":"${{ steps.branch-name.outputs.current_branch }}","content":"${{ steps.branch-name.outputs.current_branch }}.${{ secrets.AWS_S3_BUCKET }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com","ttl":1,"priority":10,"proxied":true}'
- fi
+ curl -sS -X POST \
+ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/dns_records" \
+ -H "X-Auth-Email: ${{ secrets.EMAIL }}" \
+ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \
+ -H "Content-Type: application/json" \
+ --data '{
+ "type": "CNAME",
+ "name": "${{ steps.branch-name.outputs.BRANCH }}",
+ "content": "${{ steps.bucket.outputs.NAME }}.s3-website.${{ secrets.AWS_REGION }}.amazonaws.com",
+ "ttl": 1,
+ "proxied": true
+ }'
+
- name: Purge Cloudflare cache
run: |
- curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \
+ curl -sS -X POST \
+ "https://api.cloudflare.com/client/v4/zones/${{ secrets.CLOUDFLARE_ZONE_ID }}/purge_cache" \
-H "X-Auth-Email: ${{ secrets.EMAIL }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_DNS_SECRET_API_TOKEN }}" \
-H "Content-Type: application/json" \
- --data '{"purge_everything":true}'
+ --data '{"purge_everything": true}'
diff --git a/.gitignore b/.gitignore
index 9daa824..b58f48d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.DS_Store
node_modules
+.venv
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..fda88cc
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,72 @@
+# lef-web — lef.fyi
+
+Static personal website built with hand-coded HTML and Tufte CSS. Hosted on AWS S3 with Cloudflare CDN.
+
+**Repo**: `git@github.com:Lef-F/lef-web.git`
+**Live**: https://lef.fyi
+
+## Architecture
+
+```
+src/ → Deployable website (HTML, CSS, fonts, media)
+tools/ → Python utilities (sitemap generator)
+.github/ → CI/CD (GitHub Actions → S3 + Cloudflare)
+.claude/ → Claude context, skills, settings
+```
+
+All pages are hand-written HTML — no static site generator, no templating engine, no JavaScript.
+
+## Development Commands
+
+```shell
+npm run start # Local server at http://127.0.0.1:8080
+npm run cssmin # Minify tufte.css → tufte.min.css (MUST run after CSS changes)
+```
+
+Sitemap generation (requires uv in `tools/`):
+```shell
+cd tools && uv run python src/sitemap_generator.py https://lef.fyi ../src --except "et-book"
+```
+
+## Conventions
+
+### File Naming
+- Blog posts: `src/pages/posts/YYYYMMDD-slug.html`
+- Post media: `src/media/YYYYMMDD-slug/` (matching the post date+slug)
+- Shared media: `src/media/00-common/`
+
+### HTML Patterns
+Every page follows this structure:
+- `` with viewport meta, charset, title (`Page Title | Lef adores you ❤️`), favicon, tufte.min.css
+- Banner: clickable full-width image linking to `/` + invisible duplicate for spacing
+- Blog posts: content in `` > `` elements
+- Homepage: uses `` elements directly (no `` wrapper)
+- `