7 Commits

16 changed files with 997 additions and 2 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Build artifacts — regenerated by `make build` / `python3 scripts/build.py`
# Source of truth is blog/posts/*.md
blog/posts/*.html

55
404.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lost in the 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: 1.5rem;
color: #8a7f6a;
}
p {
font-size: 0.9rem;
color: #6a6050;
margin-bottom: 2rem;
}
a {
color: #8a7f6a;
text-decoration: none;
padding: 0.8rem 2rem;
border: 1px solid #2a2520;
transition: border-color 0.3s, color 0.3s;
}
a:hover {
border-color: #8a7f6a;
color: #fff;
}
</style>
</head>
<body>
<h1>Lost in the Tower</h1>
<p>This room doesn't exist.</p>
<a href="/">Return to the Entry Hall</a>
</body>
</html>

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.

10
api/health/index.json Normal file
View File

@@ -0,0 +1,10 @@
{
"status": "ok",
"services": {
"api": true,
"agent_loop": false,
"websocket": false
},
"uptime": null,
"version": "20260322.230710"
}

25
blog/feed.xml Normal file
View File

@@ -0,0 +1,25 @@
<?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:45:33Z</updated>
<author>
<name>Alexander Whitestone</name>
</author>
<subtitle>Words from the Wizard's Tower</subtitle>
<entry>
<title>Hello World</title>
<link href="https://alexanderwhitestone.com/blog/posts/2026-03-18-hello-world.html"/>
<id>https://alexanderwhitestone.com/blog/posts/2026-03-18-hello-world</id>
<updated>2026-03-18T00:00:00Z</updated>
<content type="html"><![CDATA[<p>The Tower stands. Two doors. You choose.</p>
<p><strong>The Workshop</strong> is where the wizard lives — a 3D world you walk into. Not a chatbot. A presence.</p>
<p><strong>The Scrolls</strong> is where the words live — plain text, RSS, no platform tax. You're reading one now.</p>
<blockquote>
<p>Sovereignty isn't a feature. It's the architecture.</p>
</blockquote>
<p>This is the first scroll. More will follow.</p>]]></content>
</entry>
</feed>

52
blog/index.html Normal file
View File

@@ -0,0 +1,52 @@
<!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, a { color: #8a7f6a; text-decoration: none; }
header a:hover, 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 a { color: #e0d8c8; text-decoration: none; }
.posts a:hover { color: #fff; }
.post-date { font-size: 0.85rem; color: #6a6050; margin-bottom: 2rem; display: block; }
.post-body p { margin-bottom: 1.2rem; }
.post-body h2 { font-size: 1.1rem; margin: 2rem 0 1rem; color: #c0b8a8; }
.post-body h3 { font-size: 1rem; margin: 1.5rem 0 0.8rem; color: #a09888; }
.post-body blockquote { border-left: 2px solid #2a2520; padding-left: 1rem; color: #a09888; margin-bottom: 1.2rem; }
.post-body code { background: #1a1510; padding: 0.15em 0.4em; font-size: 0.9em; }
.post-body pre { background: #1a1510; padding: 1rem; overflow-x: auto; margin-bottom: 1.2rem; }
.post-body pre code { background: none; padding: 0; }
.post-body em { font-style: italic; }
.post-body strong { color: #fff; }
</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>
<a href="/blog/posts/2026-03-18-hello-world.html">Hello World</a>
</li>
</ul>
</main>
</body>
</html>

0
blog/posts/.gitkeep Normal file
View File

View File

@@ -0,0 +1,14 @@
---
title: "Hello World"
date: 2026-03-18
---
The Tower stands. Two doors. You choose.
**The Workshop** is where the wizard lives — a 3D world you walk into. Not a chatbot. A presence.
**The Scrolls** is where the words live — plain text, RSS, no platform tax. You're reading one now.
> Sovereignty isn't a feature. It's the architecture.
This is the first scroll. More will follow.

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>

105
scripts/aw-post Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# aw-post — Quick-post a scroll from the command line
#
# Usage:
# aw-post "Title" # Create empty post, open for editing
# aw-post "Title" < body.md # Pipe body from stdin
# echo "Post body" | aw-post "Title" # Pipe body from stdin
# aw-post --file thoughts.md # Post from a file (title from first line)
# aw-post --from-x "Tweet text here" # Port an X/Twitter post
#
# Creates a markdown file in blog/posts/, rebuilds the blog.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BLOG_DIR="${REPO_DIR}/blog/posts"
DATE=$(date +%Y-%m-%d)
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-' | head -c 60
}
create_post() {
local title="$1"
local body="$2"
local slug
slug=$(slugify "$title")
local filename="${DATE}-${slug}.md"
local filepath="${BLOG_DIR}/${filename}"
if [ -f "$filepath" ]; then
echo "Error: $filepath already exists"
exit 1
fi
cat > "$filepath" << EOF
---
title: "${title}"
date: ${DATE}
---
${body}
EOF
echo "Created: ${filepath}"
python3 "${REPO_DIR}/scripts/build.py"
}
# Parse arguments
case "${1:-}" in
--file)
# Post from a file — title from first non-empty line
if [ -z "${2:-}" ] || [ ! -f "${2:-}" ]; then
echo "Usage: aw-post --file <path-to-markdown>"
exit 1
fi
BODY=$(cat "$2")
# Extract title: first line starting with # or first non-empty line
TITLE=$(echo "$BODY" | grep -m1 '^#' | sed 's/^#* *//' || echo "$BODY" | grep -m1 '.' || echo "Untitled")
if [ -z "$TITLE" ]; then
TITLE="Untitled"
fi
# Strip the title line from body if it was a markdown header
BODY=$(echo "$BODY" | sed '1{/^#/d;}')
create_post "$TITLE" "$BODY"
;;
--from-x)
# Port an X/Twitter post — the text becomes both title and body
if [ -z "${2:-}" ]; then
echo "Usage: aw-post --from-x \"Tweet text here\""
exit 1
fi
TEXT="$2"
# Title: first 60 chars, cleaned up
TITLE=$(echo "$TEXT" | head -1 | cut -c1-60 | sed 's/[[:space:]]*$//')
create_post "$TITLE" "$TEXT"
;;
--help|-h)
echo "aw-post — Quick-post a scroll to The Scrolls"
echo ""
echo "Usage:"
echo " aw-post \"Title\" Create a post (body from stdin)"
echo " aw-post --file thoughts.md Post from a markdown file"
echo " aw-post --from-x \"Tweet text\" Port an X/Twitter post"
echo " aw-post --help Show this help"
;;
"")
echo "Usage: aw-post \"Title of the Post\""
echo " See: aw-post --help"
exit 1
;;
*)
# Standard mode: title as arg, body from stdin
TITLE="$1"
BODY=""
if [ ! -t 0 ]; then
BODY=$(cat)
fi
create_post "$TITLE" "$BODY"
;;
esac

327
scripts/build.py Normal file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
Build script for The Scrolls — generates blog pages 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 linked post listing
- blog/posts/<slug>.html for each post
- blog/feed.xml Atom feed
"""
import html
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
SITE_URL = "https://alexanderwhitestone.com"
ROOT_DIR = Path(__file__).parent.parent
BLOG_DIR = ROOT_DIR / "blog"
POSTS_DIR = BLOG_DIR / "posts"
HEALTH_DIR = ROOT_DIR / "api" / "health"
PAGE_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, a { color: #8a7f6a; text-decoration: none; }
header a:hover, 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 a { color: #e0d8c8; text-decoration: none; }
.posts a:hover { color: #fff; }
.post-date { font-size: 0.85rem; color: #6a6050; margin-bottom: 2rem; display: block; }
.post-body p { margin-bottom: 1.2rem; }
.post-body h2 { font-size: 1.1rem; margin: 2rem 0 1rem; color: #c0b8a8; }
.post-body h3 { font-size: 1rem; margin: 1.5rem 0 0.8rem; color: #a09888; }
.post-body blockquote { border-left: 2px solid #2a2520; padding-left: 1rem; color: #a09888; margin-bottom: 1.2rem; }
.post-body code { background: #1a1510; padding: 0.15em 0.4em; font-size: 0.9em; }
.post-body pre { background: #1a1510; padding: 1rem; overflow-x: auto; margin-bottom: 1.2rem; }
.post-body pre code { background: none; padding: 0; }
.post-body em { font-style: italic; }
.post-body strong { color: #fff; }
"""
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 md_to_html(text):
"""Minimal markdown to HTML — paragraphs, headers, bold, italic, code, links, blockquotes."""
lines = text.split("\n")
out = []
in_code_block = False
in_blockquote = False
paragraph = []
def flush_paragraph():
if paragraph:
content = "\n".join(paragraph)
content = inline_format(content)
out.append(f"<p>{content}</p>")
paragraph.clear()
def inline_format(s):
# Code spans first (protect from other formatting)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
# Bold
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
# Italic
s = re.sub(r"\*(.+?)\*", r"<em>\1</em>", s)
# Links
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', s)
return s
for line in lines:
# Fenced code blocks
if line.strip().startswith("```"):
if in_code_block:
out.append("</code></pre>")
in_code_block = False
else:
flush_paragraph()
out.append("<pre><code>")
in_code_block = True
continue
if in_code_block:
out.append(html.escape(line))
continue
stripped = line.strip()
# Empty line — flush paragraph
if not stripped:
flush_paragraph()
if in_blockquote:
out.append("</blockquote>")
in_blockquote = False
continue
# Headers
header_match = re.match(r"^(#{1,3})\s+(.+)$", stripped)
if header_match:
flush_paragraph()
level = len(header_match.group(1)) + 1 # h2, h3, h4 (h1 is title)
content = inline_format(header_match.group(2))
out.append(f"<h{level}>{content}</h{level}>")
continue
# Blockquotes
if stripped.startswith("> "):
flush_paragraph()
content = inline_format(stripped[2:])
if not in_blockquote:
out.append("<blockquote>")
in_blockquote = True
out.append(f"<p>{content}</p>")
continue
# Regular text — accumulate paragraph
paragraph.append(stripped)
flush_paragraph()
if in_blockquote:
out.append("</blockquote>")
if in_code_block:
out.append("</code></pre>")
return "\n".join(out)
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_post_page(post):
"""Generate an individual post HTML page."""
body_html = md_to_html(post["body"])
page = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(post["title"])} — The Scrolls</title>
<link rel="alternate" type="application/rss+xml" title="The Scrolls" href="/blog/feed.xml">
<style>
{PAGE_STYLE}
</style>
</head>
<body>
<header>
<h1>{html.escape(post["title"])}</h1>
<nav><a href="/blog/">← The Scrolls</a> · <a href="/">The Tower</a> · <a href="/blog/feed.xml">RSS</a></nav>
</header>
<main>
<span class="post-date">{post["date"]}</span>
<article class="post-body">
{body_html}
</article>
</main>
</body>
</html>"""
out_path = POSTS_DIR / f"{post['slug']}.html"
out_path.write_text(page)
return out_path
def generate_index(posts):
"""Generate blog/index.html with linked post listing."""
if not posts:
items_html = ' <li><span class="date">—</span><span>No scrolls yet.</span></li>'
else:
items = []
for p in posts:
escaped_title = html.escape(p["title"])
items.append(
f' <li>\n'
f' <span class="date">{p["date"]}</span>\n'
f' <a href="/blog/posts/{p["slug"]}.html">{escaped_title}</a>\n'
f' </li>'
)
items_html = "\n".join(items)
page = 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>
{PAGE_STYLE}
</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">
{items_html}
</ul>
</main>
</body>
</html>"""
(BLOG_DIR / "index.html").write_text(page)
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
body_html = md_to_html(p["body"])
escaped_title = html.escape(p["title"])
entries.append(
f""" <entry>
<title>{escaped_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[{body_html}]]></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 generate_health():
"""Generate api/health/index.json with build-time metadata."""
HEALTH_DIR.mkdir(parents=True, exist_ok=True)
health = {
"status": "ok",
"services": {
"api": True,
"agent_loop": False,
"websocket": False,
},
"uptime": None,
"version": datetime.now(timezone.utc).strftime("%Y%m%d.%H%M%S"),
}
(HEALTH_DIR / "index.json").write_text(json.dumps(health, indent=2) + "\n")
print(" Generated api/health endpoint.")
def main():
print("Building The Scrolls...")
posts = load_posts()
for p in posts:
out = generate_post_page(p)
print(f" Built: {out.relative_to(BLOG_DIR.parent)}")
generate_index(posts)
generate_feed(posts)
generate_health()
print("Build complete.")
if __name__ == "__main__":
main()

0
static/.gitkeep Normal file
View File

0
world/assets/.gitkeep Normal file
View File

124
world/index.html Normal file
View File

@@ -0,0 +1,124 @@
<!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; margin-bottom: 0.5rem; }
.placeholder a { color: #8a7f6a; }
/* Connection status HUD */
#status-hud {
position: fixed;
top: 12px;
right: 12px;
background: rgba(10, 10, 15, 0.85);
border: 1px solid #2a2520;
border-radius: 6px;
padding: 8px 14px;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: #6a6050;
z-index: 100;
min-width: 180px;
}
#status-hud .status-line {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
#status-hud .status-line:last-child { margin-bottom: 0; }
#status-hud .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
#status-hud .dot.connecting { background: #b8860b; animation: pulse 1.2s ease-in-out infinite; }
#status-hud .dot.online { background: #4a9; }
#status-hud .dot.offline { background: #a44; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
#retry-btn {
display: none;
margin-top: 6px;
padding: 3px 10px;
background: #2a2520;
border: 1px solid #4a4030;
border-radius: 3px;
color: #8a7f6a;
font-family: 'Courier New', monospace;
font-size: 0.7rem;
cursor: pointer;
}
#retry-btn:hover { background: #3a3530; color: #c0b8a8; }
</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>
<div id="status-hud">
<div class="status-line">
<span class="dot connecting" id="status-dot"></span>
<span id="status-text">INITIALIZING</span>
</div>
<div class="status-line">AGENTS: <span id="agent-count">0</span></div>
<button id="retry-btn">Retry connection</button>
<a id="fallback-link" href="/api/ui" style="display:none; margin-top:6px; color:#8a7f6a; font-size:0.7rem;">Open API dashboard →</a>
</div>
<!-- Reject unknown sub-paths: only /world/ is valid -->
<script>
(function() {
var path = window.location.pathname.replace(/\/+$/, '') || '/';
if (path !== '/world') {
window.location.replace('/404.html');
}
})();
</script>
<!-- Fallback: if main.js fails to load, don't leave user on INITIALIZING forever -->
<script>
(function() {
var INIT_TIMEOUT_MS = 8000;
window.__workshopBooted = false;
setTimeout(function() {
if (window.__workshopBooted) return;
var dot = document.getElementById('status-dot');
var text = document.getElementById('status-text');
var btn = document.getElementById('retry-btn');
var fallback = document.getElementById('fallback-link');
if (dot) { dot.className = 'dot offline'; }
if (text) { text.textContent = 'WORKSHOP UNREACHABLE'; }
if (btn) { btn.style.display = 'block'; }
if (fallback) { fallback.style.display = 'block'; }
}, INIT_TIMEOUT_MS);
})();
</script>
<!-- Three.js scene will mount to #scene -->
<script type="module" src="main.js"></script>
</body>
</html>

130
world/main.js Normal file
View File

@@ -0,0 +1,130 @@
/**
* The Workshop — Three.js scene bootstrap
*
* Initializes the 3D world where Timmy lives.
* Handles WebSocket connection to tower-hermes backend with
* timeout, retry, and clear status display.
*
* See: #242 (3D world), #243 (WebSocket bridge), #265 (presence schema)
*/
// Future: import * as THREE from 'three';
const HERMES_WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') +
location.host + '/ws/tower';
const CONNECT_TIMEOUT_MS = 5000;
const RETRY_DELAY_MS = 3000;
const MAX_AUTO_RETRIES = 3;
const Status = { CONNECTING: 'connecting', ONLINE: 'online', OFFLINE: 'offline' };
const dom = {
dot: document.getElementById('status-dot'),
text: document.getElementById('status-text'),
agents: document.getElementById('agent-count'),
retryBtn: document.getElementById('retry-btn'),
fallback: document.getElementById('fallback-link'),
};
// Signal to inline fallback script that main.js loaded successfully
window.__workshopBooted = true;
let ws = null;
let autoRetries = 0;
let connectTimer = null;
function setStatus(state, message) {
dom.dot.className = 'dot ' + state;
dom.text.textContent = message;
var isOffline = state === Status.OFFLINE;
dom.retryBtn.style.display = isOffline ? 'block' : 'none';
if (dom.fallback) dom.fallback.style.display = isOffline ? 'block' : 'none';
}
function setAgentCount(n) {
dom.agents.textContent = n;
}
function cleanup() {
clearTimeout(connectTimer);
if (ws) {
ws.onopen = null;
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
if (ws.readyState <= WebSocket.OPEN) ws.close();
ws = null;
}
}
function connect() {
cleanup();
setStatus(Status.CONNECTING, 'CONNECTING\u2026');
setAgentCount(0);
try {
ws = new WebSocket(HERMES_WS_URL);
} catch (err) {
console.error('[Workshop] WebSocket creation failed:', err);
onFail();
return;
}
connectTimer = setTimeout(function () {
console.warn('[Workshop] Connection timeout after ' + CONNECT_TIMEOUT_MS + 'ms');
cleanup();
onFail();
}, CONNECT_TIMEOUT_MS);
ws.onopen = function () {
clearTimeout(connectTimer);
autoRetries = 0;
setStatus(Status.ONLINE, 'ONLINE');
console.log('[Workshop] Connected to tower-hermes');
};
ws.onmessage = function (evt) {
try {
var msg = JSON.parse(evt.data);
if (typeof msg.agents === 'number') setAgentCount(msg.agents);
} catch (_) {
// non-JSON messages are ignored
}
};
ws.onclose = function () {
clearTimeout(connectTimer);
console.log('[Workshop] Connection closed');
onFail();
};
ws.onerror = function () {
clearTimeout(connectTimer);
console.error('[Workshop] WebSocket error');
// onclose will fire after this, which calls onFail
};
}
function onFail() {
if (autoRetries < MAX_AUTO_RETRIES) {
autoRetries++;
setStatus(Status.CONNECTING, 'RETRYING (' + autoRetries + '/' + MAX_AUTO_RETRIES + ')\u2026');
setTimeout(connect, RETRY_DELAY_MS);
} else {
setStatus(Status.OFFLINE, 'WORKSHOP OFFLINE');
}
}
// Manual retry resets the counter
dom.retryBtn.addEventListener('click', function () {
autoRetries = 0;
connect();
});
// Boot
export function initWorkshop(container) {
console.log('[Workshop] Scene container ready:', container.id);
connect();
}
initWorkshop(document.getElementById('scene'));