[loop-cycle-152] feat: scaffold the Wizard's Tower — two rooms, entry hall (#215) (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-03-18 21:33:17 -04:00
parent aada6098e2
commit 156e8df22a
12 changed files with 483 additions and 2 deletions

31
Makefile Normal file
View File

@@ -0,0 +1,31 @@
.PHONY: dev build deploy clean
# Local development server
dev:
@echo "Starting dev server on :8080..."
@cd "$(CURDIR)" && python3 -m http.server 8080
# Build the static site (generate blog index + RSS from posts)
build:
@echo "Building the Tower..."
@python3 scripts/build.py
@echo "Done."
# Deploy — configure DEPLOY_TARGET in environment or .env
deploy: build
@echo "Deploying..."
@if [ -z "$${DEPLOY_TARGET:-}" ]; then \
echo "Error: Set DEPLOY_TARGET (e.g., user@host:/var/www/tower)"; \
exit 1; \
fi
rsync -avz --delete \
--exclude '.git' \
--exclude 'scripts/' \
--exclude 'Makefile' \
--exclude '*.md' \
./ "$${DEPLOY_TARGET}/"
@echo "Deployed to $${DEPLOY_TARGET}"
# Clean generated files
clean:
@echo "Nothing to clean yet."

View File

@@ -1,3 +1,39 @@
# alexanderwhitestone.com
# The Wizard's Tower
Public-facing interface for Timmy — AlexanderWhitestone.com
**AlexanderWhitestone.com** — two rooms, nothing else.
## Rooms
- **The Workshop** (`/world/`) — A 3D space where Timmy lives. Visitors enter and interact.
- **The Scrolls** (`/blog/`) — Alexander's words. Plain text, RSS, sovereign publishing.
## Structure
```
index.html Entry hall — two doors
world/ The Workshop (3D scene, Timmy presence)
blog/ The Scrolls (posts, RSS feed)
scripts/ CLI tools (aw-post for quick publishing)
static/ Shared assets (fonts, favicon)
Makefile Build, dev, deploy
```
## Development
```bash
make dev # Local dev server on :8080
make build # Build static site
make deploy # Deploy (configure target in Makefile)
```
## Tech Decisions (Open)
- [ ] 3D engine: Three.js vs Babylon.js
- [ ] Blog: Hugo vs hand-rolled static generator
- [ ] Hosting: self-hosted Nginx/Caddy vs static CDN
- [ ] Timmy's 3D character design
## Philosophy
Two doors. No navbar. No sidebar. No footer links. You walk in, you choose a room.
The Workshop is alive. The Scrolls are permanent. That's the Tower.

13
blog/feed.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>The Scrolls — Alexander Whitestone</title>
<link href="https://alexanderwhitestone.com/blog/"/>
<link rel="self" href="https://alexanderwhitestone.com/blog/feed.xml"/>
<id>https://alexanderwhitestone.com/blog/</id>
<updated>2026-03-19T01:32:41Z</updated>
<author>
<name>Alexander Whitestone</name>
</author>
<subtitle>Words from the Wizard's Tower</subtitle>
</feed>

40
blog/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Scrolls — The Wizard's Tower</title>
<link rel="alternate" type="application/rss+xml" title="The Scrolls" href="/blog/feed.xml">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
max-width: 640px; margin: 0 auto; padding: 2rem 1rem;
background: #0a0a0f; color: #e0d8c8;
font-family: Georgia, serif; line-height: 1.6;
}
header { margin-bottom: 3rem; border-bottom: 1px solid #2a2520; padding-bottom: 1rem; }
header h1 { font-size: 1.2rem; font-weight: normal; letter-spacing: 0.1em; }
header nav { margin-top: 0.5rem; font-size: 0.8rem; }
header a { color: #8a7f6a; text-decoration: none; }
header a:hover { color: #e0d8c8; }
.posts { list-style: none; }
.posts li { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #1a1510; }
.posts .date { font-size: 0.8rem; color: #6a6050; display: block; }
.posts .title { font-size: 1rem; }
</style>
</head>
<body>
<header>
<h1>The Scrolls</h1>
<nav><a href="/">← The Tower</a> · <a href="/blog/feed.xml">RSS</a></nav>
</header>
<main>
<ul class="posts">
<li>
<span class="date">2026-03-18</span>
<span class="title">Hello World</span>
</li>
</ul>
</main>
</body>
</html>

0
blog/posts/.gitkeep Normal file
View File

83
index.html Normal file
View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Wizard's Tower</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0a0a0f;
color: #e0d8c8;
font-family: Georgia, 'Times New Roman', serif;
}
h1 {
font-size: 1.4rem;
font-weight: normal;
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 3rem;
color: #8a7f6a;
}
.doors {
display: flex;
gap: 4rem;
}
.door {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: #e0d8c8;
padding: 2rem 3rem;
border: 1px solid #2a2520;
transition: border-color 0.3s, color 0.3s;
}
.door:hover {
border-color: #8a7f6a;
color: #fff;
}
.door-name {
font-size: 1.2rem;
margin-bottom: 0.5rem;
}
.door-desc {
font-size: 0.8rem;
color: #6a6050;
}
.door:hover .door-desc {
color: #8a7f6a;
}
@media (max-width: 600px) {
.doors { flex-direction: column; gap: 2rem; }
}
</style>
</head>
<body>
<h1>The Wizard's Tower</h1>
<nav class="doors">
<a href="/world/" class="door">
<span class="door-name">The Workshop</span>
<span class="door-desc">Enter the world</span>
</a>
<a href="/blog/" class="door">
<span class="door-name">The Scrolls</span>
<span class="door-desc">Read the words</span>
</a>
</nav>
</body>
</html>

49
scripts/aw-post Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# aw-post — Quick-post a scroll from the command line
#
# Usage:
# aw-post "Title of the Post"
# aw-post "Title" < body.md
# echo "Post body here" | aw-post "Title"
#
# Creates a new markdown file in blog/posts/ with frontmatter.
# Rebuilds the blog index and RSS feed.
set -euo pipefail
BLOG_DIR="$(cd "$(dirname "$0")/.." && pwd)/blog/posts"
if [ $# -lt 1 ]; then
echo "Usage: aw-post \"Title of the Post\""
echo " Pipe or redirect body content via stdin."
exit 1
fi
TITLE="$1"
DATE=$(date +%Y-%m-%d)
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
FILENAME="${DATE}-${SLUG}.md"
FILEPATH="${BLOG_DIR}/${FILENAME}"
if [ -f "$FILEPATH" ]; then
echo "Error: $FILEPATH already exists"
exit 1
fi
# Read body from stdin if available
BODY=""
if [ ! -t 0 ]; then
BODY=$(cat)
fi
cat > "$FILEPATH" << EOF
---
title: "${TITLE}"
date: ${DATE}
---
${BODY}
EOF
echo "Created: ${FILEPATH}"
echo "Next: rebuild with 'make build'"

172
scripts/build.py Normal file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Build script for The Scrolls — generates blog index and RSS feed from
markdown posts in blog/posts/.
Each post is a markdown file with YAML frontmatter:
---
title: "Post Title"
date: 2026-03-18
---
Body content here.
Generates:
- blog/index.html with post listing
- blog/feed.xml Atom feed
"""
import os
import re
from datetime import datetime, timezone
from pathlib import Path
SITE_URL = "https://alexanderwhitestone.com"
BLOG_DIR = Path(__file__).parent.parent / "blog"
POSTS_DIR = BLOG_DIR / "posts"
def parse_frontmatter(text):
"""Extract YAML frontmatter from markdown text."""
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
if not match:
return {}, text
meta = {}
for line in match.group(1).strip().split("\n"):
if ":" in line:
key, val = line.split(":", 1)
meta[key.strip()] = val.strip().strip('"').strip("'")
return meta, match.group(2).strip()
def load_posts():
"""Load and sort all posts by date (newest first)."""
posts = []
if not POSTS_DIR.exists():
return posts
for path in sorted(POSTS_DIR.glob("*.md"), reverse=True):
text = path.read_text()
meta, body = parse_frontmatter(text)
if meta.get("title") and meta.get("date"):
posts.append(
{
"title": meta["title"],
"date": meta["date"],
"slug": path.stem,
"body": body,
"path": path,
}
)
return posts
def generate_index(posts):
"""Generate blog/index.html with post listing."""
if not posts:
print(" No posts found. Keeping placeholder index.")
return
items = []
for p in posts:
items.append(
f' <li>\n'
f' <span class="date">{p["date"]}</span>\n'
f' <span class="title">{p["title"]}</span>\n'
f" </li>"
)
post_list = "\n".join(items)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Scrolls — The Wizard's Tower</title>
<link rel="alternate" type="application/rss+xml" title="The Scrolls" href="/blog/feed.xml">
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
max-width: 640px; margin: 0 auto; padding: 2rem 1rem;
background: #0a0a0f; color: #e0d8c8;
font-family: Georgia, serif; line-height: 1.6;
}}
header {{ margin-bottom: 3rem; border-bottom: 1px solid #2a2520; padding-bottom: 1rem; }}
header h1 {{ font-size: 1.2rem; font-weight: normal; letter-spacing: 0.1em; }}
header nav {{ margin-top: 0.5rem; font-size: 0.8rem; }}
header a {{ color: #8a7f6a; text-decoration: none; }}
header a:hover {{ color: #e0d8c8; }}
.posts {{ list-style: none; }}
.posts li {{ margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #1a1510; }}
.posts .date {{ font-size: 0.8rem; color: #6a6050; display: block; }}
.posts .title {{ font-size: 1rem; }}
</style>
</head>
<body>
<header>
<h1>The Scrolls</h1>
<nav><a href="/">← The Tower</a> · <a href="/blog/feed.xml">RSS</a></nav>
</header>
<main>
<ul class="posts">
{post_list}
</ul>
</main>
</body>
</html>"""
(BLOG_DIR / "index.html").write_text(html)
print(f" Generated index with {len(posts)} post(s).")
def generate_feed(posts):
"""Generate blog/feed.xml Atom feed."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
entries = []
for p in posts[:20]: # Cap at 20 entries
# Simple HTML conversion: wrap paragraphs
html_body = "\n".join(
f"<p>{para}</p>" for para in p["body"].split("\n\n") if para.strip()
)
entries.append(
f""" <entry>
<title>{p["title"]}</title>
<link href="{SITE_URL}/blog/posts/{p["slug"]}.html"/>
<id>{SITE_URL}/blog/posts/{p["slug"]}</id>
<updated>{p["date"]}T00:00:00Z</updated>
<content type="html"><![CDATA[{html_body}]]></content>
</entry>"""
)
entry_block = "\n".join(entries)
feed = f"""<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>The Scrolls — Alexander Whitestone</title>
<link href="{SITE_URL}/blog/"/>
<link rel="self" href="{SITE_URL}/blog/feed.xml"/>
<id>{SITE_URL}/blog/</id>
<updated>{now}</updated>
<author>
<name>Alexander Whitestone</name>
</author>
<subtitle>Words from the Wizard's Tower</subtitle>
{entry_block}
</feed>"""
(BLOG_DIR / "feed.xml").write_text(feed)
print(f" Generated feed with {len(entries)} entry/entries.")
def main():
print("Building The Scrolls...")
posts = load_posts()
generate_index(posts)
generate_feed(posts)
print("Build complete.")
if __name__ == "__main__":
main()

0
static/.gitkeep Normal file
View File

0
world/assets/.gitkeep Normal file
View File

37
world/index.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Workshop — The Wizard's Tower</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0f; color: #e0d8c8; font-family: Georgia, serif; }
#scene {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
text-align: center;
color: #6a6050;
}
.placeholder h1 { font-size: 1.2rem; font-weight: normal; margin-bottom: 1rem; }
.placeholder p { font-size: 0.85rem; }
.placeholder a { color: #8a7f6a; }
</style>
</head>
<body>
<div id="scene">
<div class="placeholder">
<h1>The Workshop</h1>
<p>Timmy's world is being built.</p>
<p><a href="/">← Back to the Tower</a></p>
</div>
</div>
<!-- Three.js scene will mount to #scene -->
<!-- <script type="module" src="main.js"></script> -->
</body>
</html>

20
world/main.js Normal file
View File

@@ -0,0 +1,20 @@
/**
* The Workshop — Three.js scene bootstrap
*
* This file will initialize the 3D world where Timmy lives.
* Currently a placeholder until tech decisions are made:
* - 3D engine confirmed (Three.js vs Babylon.js)
* - Character design direction chosen
* - WebSocket bridge to Timmy's soul designed (#243)
*
* See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema)
*/
// Future: import * as THREE from 'three';
export function initWorkshop(container) {
// TODO: Initialize 3D scene
// TODO: Load wizard character model
// TODO: Connect to Timmy presence WebSocket
console.log('[Workshop] Scene container ready:', container.id);
}