Author SHA1 Message Date
dilonne f5a83507a6 ci(deploy): rewrite for build-on-droplet + local runner 2026-09-02 19:55:56 +02:00
dilonne 8ef06cb3e7 feat(deploy): aibook.fun deploy layer (terraform + ansible + docker on DO) 2026-09-02 10:57:09 +02:00
SinachPat 89a3d76122 feat(marketing-site): American Dream cover band + narrative weave
- Cover: publisher-style belly band across the lower book cover using a
  compressed banner (2.2MB screenshot -> 60KB WebP, resized to display
  needs), cropped via a width-locked <img> + percentage offset so the
  wordmark stays legible rather than getting zoomed/cropped sideways.
  Compass + wordmark compacted into a row to make room.
- Copy: the theme woven through Field Notes, Contents, the Founding
  Sponsor tier, and one direct FAQ entry -- mostly oblique, since the
  cover already states it explicitly. Hero/Recipe/Capture/Footer left
  untouched on purpose.
2026-08-28 10:49:57 +01:00
pat 9340a0a972 Merge pull request 'feat: rebuild marketing site with full component suite and preview' (#3) from feature/marketing-site into main
Reviewed-on: WeOwnAI/aibook-fun#3
2026-08-27 15:56:49 +00:00
pat 169e3494e3 Merge pull request 'docs: write README with project overview and docs index' (#2) from docs/plain-language-rewrite into main
Reviewed-on: WeOwnAI/aibook-fun#2
2026-08-26 14:10:13 +00:00
pat 99ca572ab4 Merge pull request 'docs: rewrite PRD, Spec, and Implementation Plan for clarity' (#1) from docs/plain-language-rewrite into main
Reviewed-on: WeOwnAI/aibook-fun#1
2026-08-26 13:56:32 +00:00
29 changed files with 1082 additions and 18 deletions
+38
View File
@@ -0,0 +1,38 @@
# Rebuild + roll aibook.fun forward on any push to main.
# Runner: repo-registered act_runner on the aibook.fun droplet itself
# (co-located → local docker commands, no cross-host SSH needed).
#
# Repository secrets required (Settings → Actions → Secrets):
# (none — runner is on the droplet, has local root)
name: deploy
on:
push:
branches: [main]
paths:
- 'marketing-site/**'
- 'deploy/**'
- '.gitea/workflows/**'
workflow_dispatch:
concurrency:
group: aibook-fun-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: aibook-fun-droplet
steps:
- name: Pull latest + rebuild + roll
run: |
set -euo pipefail
cd /opt/aibook_fun/repo
git fetch --prune origin
git reset --hard origin/main
docker build -t aibook-fun-marketing:v1 \
-f /opt/aibook_fun/repo/deploy/docker/Dockerfile \
/opt/aibook_fun/repo/marketing-site/
cd /opt/aibook_fun
docker compose up -d --remove-orphans
docker image prune -f --filter "dangling=true"
+73
View File
@@ -0,0 +1,73 @@
# aibook-fun deploy — gitignore
# ⚠️ CRITICAL: Never commit credentials or state files
# =============================================================================
# Terraform / OpenTofu State (CRITICAL - contains infrastructure details)
# =============================================================================
*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
# NOTE: .terraform.lock.hcl is NOT ignored — commit it so provider versions
# are reproducible across machines/CI runs.
# Terraform variable files with real values
terraform.tfvars
terraform.tfvars.json
*.auto.tfvars
*.auto.tfvars.json
# Terraform crash logs
crash.log
crash.*.log
# =============================================================================
# Environment Files (CRITICAL - contains passwords)
# =============================================================================
.env
.env.prod
.env.local
.env.*.local
# =============================================================================
# Backups (may contain sensitive data)
# =============================================================================
backups/
*.tar.gz
*.sql
*.sql.gz
# =============================================================================
# Logs
# =============================================================================
logs/
*.log
# =============================================================================
# OS & Editor
# =============================================================================
.DS_Store
.DS_Store?
._*
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/
*.code-workspace
# =============================================================================
# Docker
# =============================================================================
docker-compose.override.yml
docker-compose.override.yaml
# =============================================================================
# Ansible
# =============================================================================
*.retry
.vault-pass
# OpenTofu saved plans (sensitive) — never commit
*.tfplan
+25
View File
@@ -0,0 +1,25 @@
# Deploy
Provisions a DO droplet + reserved IP + firewall + monitoring, builds the
Next.js image locally on the droplet, runs it behind Caddy.
## Deploy from scratch
```bash
cd terraform
infisical login
export INFISICAL_PROJECT_ID=<aibook-fun-project-id>
./itofu.sh init && ./itofu.sh plan && ./itofu.sh apply
./itofu.sh output -raw droplet_ip
# add Cloudflare A record: aibook.fun → <that-ip>
cd ..
ansible-playbook -i 'root@<that-ip>,' ansible/deploy.yml
```
## Structure
- `terraform/` — droplet + IP + firewall + monitoring
- `docker/` — Dockerfile + compose + Caddy
- `ansible/` — droplet convergence
+33
View File
@@ -0,0 +1,33 @@
# Ansible settings for deploying aibook-fun.
#
# The whole point of this file is SSH connection reuse. On a workstation whose
# SSH key lives in a hardware-backed agent (Secretive, a YubiKey, the Secure
# Enclave), every *new* TCP connection needs a fresh signature — which means a
# Touch ID prompt. Ansible opens many connections per run and does so
# non-interactively, so those prompts cannot be answered and the run dies with:
#
# sign_and_send_pubkey: signing failed ... agent refused operation
#
# Ansible's own defaults make this worse in two ways, both fixed below:
#
# 1. It keeps control sockets in ~/.ansible/cp, separate from the ones the
# operator's own `ssh` has already authenticated. Pointing it at the same
# directory and the same %C naming as OpenSSH means ansible *reuses* a
# master connection the operator authorised earlier — no new signature.
# 2. ControlPersist defaults to 60s, so a master closes between steps of a
# deploy and the next one needs another signature.
#
# Together: authenticate once at the start of a work session, then deploy
# repeatedly without touching the key again.
[defaults]
host_key_checking = True
interpreter_python = auto_silent
[ssh_connection]
# Match OpenSSH's own ControlPath so ansible shares the operator's masters.
# %%C is escaped for configparser — ssh receives %C (a hash of local host,
# remote host, port and user), exactly what ~/.ssh/config uses.
control_path_dir = ~/.ssh/sockets
control_path = %(directory)s/%%C
ssh_args = -C -o ControlMaster=auto -o ControlPersist=4h
pipelining = True
+150
View File
@@ -0,0 +1,150 @@
---
# Usage: ansible-playbook -i 'root@<droplet-ip>,' deploy/ansible/deploy.yml
# Pre: droplet cloud-init complete (.bootstrap-complete marker present)
- name: Deploy aibook-fun app layer
hosts: all
gather_facts: false
become: true
vars:
app_dir: /opt/aibook_fun
repo_dir: /opt/aibook_fun/repo
repo_url: "https://git.weown.tools/WeOwnAI/aibook-fun.git"
repo_branch: main
image_tag: "aibook-fun-marketing:v1"
pre_tasks:
- name: Verify droplet has completed cloud-init bootstrap
ansible.builtin.stat:
path: "{{ app_dir }}/.bootstrap-complete"
register: bootstrap_marker
- name: Fail clearly if bootstrap is not complete
ansible.builtin.fail:
msg: >-
{{ app_dir }}/.bootstrap-complete is missing — droplet has not
finished cloud-init. Wait a few minutes after `tofu apply` and retry.
Check /var/log/cloud-init-output.log on the droplet.
when: not bootstrap_marker.stat.exists
- name: Ensure app dir exists
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
owner: root
group: root
mode: "0755"
tasks:
# ─── Clone / update repo on droplet ────────────────────────────────────
- name: Clone (or update) aibook-fun repo on droplet
ansible.builtin.git:
repo: "{{ repo_url }}"
dest: "{{ repo_dir }}"
version: "{{ repo_branch }}"
force: true
register: git_clone
notify: Rebuild image
# ─── Upload Dockerfile (deploy/ scaffold not yet pushed to git.weown.tools) ─
- name: Upload Dockerfile to droplet
ansible.builtin.copy:
src: ../docker/Dockerfile
dest: "{{ app_dir }}/Dockerfile"
owner: root
group: root
mode: "0644"
# ─── Build docker image locally on droplet (unconditional — docker layer cache handles no-ops) ─
- name: Build Next.js docker image locally
ansible.builtin.shell: |
set -euo pipefail
docker build -t {{ image_tag }} -f {{ app_dir }}/Dockerfile {{ repo_dir }}/marketing-site/
args:
executable: /bin/bash
register: image_build
changed_when: "'writing image' in image_build.stderr"
notify: Reconcile docker compose stack
# ─── Upload compose + Caddyfile ────────────────────────────────────────
- name: Upload compose.yaml
ansible.builtin.copy:
src: ../docker/compose.prod.yaml
dest: "{{ app_dir }}/compose.yaml"
owner: root
group: root
mode: "0644"
notify: Reconcile docker compose stack
- name: Upload Caddyfile
ansible.builtin.copy:
src: ../docker/Caddyfile
dest: "{{ app_dir }}/Caddyfile"
owner: root
group: root
mode: "0644"
notify: Reconcile docker compose stack
- name: Ensure caddy log dir is writable
ansible.builtin.file:
path: /var/log/caddy
state: directory
owner: root
group: root
mode: "0777"
- name: Ensure Caddy Docker volumes are writable (caddy runs as UID 1000)
ansible.builtin.shell: |
set -euo pipefail
for suffix in caddy_data caddy_config; do
path="/var/lib/docker/volumes/aibook_fun_${suffix}/_data"
if [ -d "$path" ]; then
chown -R 1000:1000 "$path"
fi
done
args:
executable: /bin/bash
changed_when: false
# ─── Ensure compose stack is running (idempotent) ──────────────────────
- name: Ensure compose stack is running (idempotent — `up -d` no-op if unchanged)
ansible.builtin.shell: |
set -euo pipefail
cd {{ app_dir }}
docker compose up -d --remove-orphans
args:
executable: /bin/bash
register: compose_up
changed_when: "'Started' in compose_up.stderr or 'Created' in compose_up.stderr or 'Recreated' in compose_up.stderr"
- name: Wait for web app to respond
ansible.builtin.uri:
url: http://127.0.0.1:3000/
status_code: [200]
register: health_probe
retries: 20
delay: 5
until: health_probe.status == 200
- name: Report success
ansible.builtin.debug:
msg: "aibook-fun app layer deployed. URL: https://aibook.fun"
handlers:
- name: Rebuild image
ansible.builtin.shell: |
set -euo pipefail
cd {{ repo_dir }}
docker build -t {{ image_tag }} -f {{ repo_dir }}/deploy/docker/Dockerfile {{ repo_dir }}/marketing-site/
args:
executable: /bin/bash
changed_when: false
- name: Reconcile docker compose stack
ansible.builtin.shell: |
set -euo pipefail
cd {{ app_dir }}
docker compose up -d --force-recreate --remove-orphans
args:
executable: /bin/bash
changed_when: false
+76
View File
@@ -0,0 +1,76 @@
# aibook-fun — host hardening (CIS-aligned, Phase A)
#
# Applies the DevSec hardening baseline (≈ CIS Ubuntu L1) to the droplet:
# kernel/sysctl, auth policy, filesystem perms, SSH crypto + policy — then
# measures the result with Lynis. Idempotent; run any time after bootstrap:
#
# ansible-galaxy collection install -r ansible/requirements.yml # once
# ansible-playbook ansible/harden.yml -i 'root@<droplet-ip>,'
#
# PHASE A vs PHASE B (deliberate scope — do not "fix" this into Phase B):
# Phase A (this play): CIS L1 OS + SSH hardening with root login KEPT
# (key-only). Every fleet tool (deploy.sh, backup.sh, manage-droplets.sh)
# SSHes as root@; removing root here would break the fleet mid-flight.
# Phase B (tracked, pre-first-customer): ops-user model (root SSH off,
# password-sudo 2FA), mesh/CIDR-locked port 22 — per SOP-INFRA-012. Needs
# the fleet tooling migrated off root@ first.
#
# Docker-safe exceptions (the CIS-L2-vs-container-runtime conflicts):
# - net.ipv4.ip_forward stays 1 — Docker bridge networking requires it. A
# defaults-level override silently LOSES to the inner role's defaults;
# it must be a task-var on the include (hit live 2026-07-03: containers
# got DNS but no TCP egress when this landed at 0).
---
- name: Harden aibook-fun host (CIS L1 via devsec — Phase A)
hosts: all
become: true
vars:
run_lynis: true
tasks:
- name: "OS hardening (devsec.hardening.os_hardening ≈ CIS Ubuntu L1)"
ansible.builtin.include_role:
name: devsec.hardening.os_hardening
vars:
# MUST be task-vars here (see header) — Docker bridge needs ip_forward.
sysctl_overwrite:
net.ipv4.ip_forward: 1
- name: "SSH hardening (devsec.hardening.ssh_hardening — key-only, strong KEX/ciphers)"
ansible.builtin.include_role:
name: devsec.hardening.ssh_hardening
vars:
# Phase A: root stays (fleet tooling), but key-only and CIS crypto.
ssh_permit_root_login: "without-password"
ssh_password_authentication: false
ssh_challenge_response_authentication: false
- name: "Measure: install + run Lynis (hardening index; non-blocking)"
when: run_lynis | bool
block:
- name: "Install Lynis"
ansible.builtin.apt:
name: lynis
state: present
update_cache: true
- name: "Run Lynis audit (quick)"
ansible.builtin.command: lynis audit system --quick --no-colors
register: lynis_out
changed_when: false
failed_when: false
- name: "Lynis summary (record this index in the site CHANGELOG)"
ansible.builtin.debug:
msg: "{{ lynis_out.stdout_lines | select('search', 'Hardening index|Warnings|Suggestions|Tests performed') | list }}"
- name: "Verify Docker survived hardening (containers must still run)"
# brace-free format string: Go-template braces here would be re-templated
# by ansible's own Jinja pass and error out.
ansible.builtin.command: docker info --format json
register: docker_ok
changed_when: false
- name: "Docker status"
ansible.builtin.debug:
msg: "Docker OK (server {{ (docker_ok.stdout | from_json).ServerVersion | default('unknown') }}) — hardening did not break the runtime"
+8
View File
@@ -0,0 +1,8 @@
# Ansible Galaxy requirements for the optional hardening play (harden.yml).
# Install once per operator workstation:
# ansible-galaxy collection install -r ansible/requirements.yml
collections:
# DevSec Hardening Framework — CIS/STIG-aligned os_hardening + ssh_hardening.
# https://github.com/dev-sec/ansible-collection-hardening
- name: devsec.hardening
version: ">=8.8.0"
+23
View File
@@ -0,0 +1,23 @@
aibook.fun {
# AIBook.fun is a static marketing site — pure reverse proxy to the Next.js
# standalone server. No auth/headers added here beyond baseline security.
reverse_proxy web:3000
log {
# Host-bind-mounted /var/log/caddy/ so logs survive container recreation.
output file /var/log/caddy/aibook-fun.log
}
header {
# Force HTTPS for a year (HSTS — a lapsed cert is a service outage, not a
# warning; weown rail per W24 phase4 findings).
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
-Server
}
}
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
# aibook-fun — Next.js 16 marketing site (marketing-site/), standalone output.
#
# Build context = marketing-site/ (see .gitea/workflows/deploy.yml):
# docker build -f deploy/docker/Dockerfile -t <image> marketing-site/
#
# Multi-stage: deps → build (next build → .next/standalone) → slim non-root runner.
# Requires `output: 'standalone'` in next.config.mjs (added on this branch).
# Node 20.9+ / 22+ required by Next 16; pin 22 LTS.
FROM node:22-alpine AS deps
WORKDIR /app
# Lockfiles first for layer caching.
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Next standalone ships as a single node server.js; run as non-root (uid 1001).
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# Static assets not bundled into the standalone output.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+59
View File
@@ -0,0 +1,59 @@
services:
web:
image: aibook-fun-marketing:v1
pull_policy: never
restart: unless-stopped
ports:
# Loopback-only: lets caddy proxy and health probes reach the app.
# Not internet-exposed; caddy handles :80/:443 → web:3000.
- "127.0.0.1:3000:3000"
networks:
- aibooknet
healthcheck:
test: ["CMD-SHELL", "curl -f -o /dev/null -w '%{http_code}' http://localhost:3000/ | grep -q '^200$' || exit 1"]
start_period: 30s
interval: 30s
timeout: 10s
retries: 5
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- web
environment:
DOMAIN: aibook.fun
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- /var/log/caddy:/var/log/caddy
networks:
- aibooknet
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 128M
volumes:
caddy_data:
name: aibook_fun_caddy_data
caddy_config:
name: aibook_fun_caddy_config
networks:
aibooknet:
name: aibook_fun_net
driver: bridge
+41
View File
@@ -0,0 +1,41 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/digitalocean/digitalocean" {
version = "2.100.0"
constraints = "~> 2.36"
hashes = [
"h1:+1p5n5KeGjz/ulw/ZLXoaBkF+2UTgKqWqERw1nA2sJQ=",
"h1:79G5uDt1VHL6gWtSnK8lCOHZpo/jp/kSGA7FXtqQvX4=",
"h1:7r9AZrpc8pB6PRsJfmxSGLzIRmohmfpX6RrnA1FLbh4=",
"h1:9VsRnY7rmGiMFVG4ix3ry2fx8B8d7l9PyEpmGjjiW3I=",
"h1:LchVh6xhpXw1PEnl2mRnsLlR4dzLePw8M2vw4DuPk9U=",
"h1:OmnMLcKMFnZLI1CvTgQBod0awOpuVxsfB8dqBejfwE8=",
"h1:Q9BxzK1Tp8YeuCUXVogVfqTsVCrWUUJJdvpL7UA0W/s=",
"h1:R13HFu3vyxrd4aHyh/+p2YY0vWpPAFjafBRYVuIL5HE=",
"h1:RJH5t57bLx1b2+l+gS6RRHyakhEQjSKL/QYu6npCtbI=",
"h1:WQ2ClvMwBILHDzWWhAm3LBRra4HkSe9taUlUGSLBNWI=",
"h1:WbQfCVfC+GU0qPSoZKuoEllr3cMqwTC1Fx5DfbUjy/o=",
"h1:iwjK8zLBTHWHICCP/O+QFcyxH16bV4gKPFUbmu2foqc=",
"h1:pTFXceDc5intkLpVFVW3PKfwkF1CvmnWi3vYcF6SAQs=",
"h1:pc1iMmHPQA37PCkVWTRzuO/WgaoSGYtSBVP5paO6nK4=",
"h1:uDVCzJGtpV2DqyPWoYWjQpbEV48PxS6/U7/uiGfDDsU=",
"h1:yyBAhHZmejmdvjuvsF2gAwIZebqOmisQIT5dOQqQx9A=",
"zh:021dbdacf7dd636cb66990964737bc4a6402160b5301e06b6fdab03a814f7e96",
"zh:083ff822b22738eedc9eae29aa5d4c369b2de921e49fefa9858a4e7c6274b0c1",
"zh:10695b5f7dbbe8ba1a20ac6bea1ab8613b43d2b1f6f9b53369bed296b2dff4d2",
"zh:110124b9d07cd1f0f2831ad27dfeb5a010a070952ea9a9e675ca3cfe158a0ffa",
"zh:409a8b22ffb9a53caf57d0a16f5f8ac0a2fcd61b6b0bcd512a685ea26cd06047",
"zh:488e4b494c198c0696c85d2c73c66c40567ca2ad5230bbd95d8ffdead86e646d",
"zh:4dd5b6e8c1be5fb619812fe738d0b49e0d24731fb290c0ab5190b7bc60c70e66",
"zh:6717fa7f623ad4c9b4d99acceffd1372d272ff02d7c98259dd17ec7ef11b836c",
"zh:7a25445caebd2e7f6ab2381578ecaf31744159a7e4a16476f19112d2e50d8519",
"zh:86b3d7050ba04ec7e0a14dbee8c0540fca8f4377fb072f87c88a6fcd26388714",
"zh:c23f30a38930c283055b6f04acbcad06cb0dd3c71c172eb23bb239f50dd12823",
"zh:d1f9c5be7f7d5c71830357bdd9c0fb529185e9380092e1b792f969495c943790",
"zh:d222573e7de8f9ad1c5b87a6c44c2dc59947c3968b3e70b7d4c76263d6c8fe5c",
"zh:e1f2d606661435416c4d2b48db80244d0acef88a5ce81b5dcbd65ff2642b1b4d",
"zh:ea13fad19414586864557601c59e85188601e150c815ec1e71f8ed83f1942a50",
"zh:f795be71dc933aaec7145f93068da17f227783964d33a79de591a042aac7c3ca",
]
}
+25
View File
@@ -0,0 +1,25 @@
# aibook-fun - Terraform Backend Configuration
# State stored in DigitalOcean Spaces (S3-compatible) for team sharing, SSE-C
# encrypted at rest.
#
# Pass credentials via itofu.sh (preferred) or -backend-config flags:
# access_key / secret_key / sse_customer_key
# Backend config cannot reference Terraform variables — itofu.sh forwards them.
terraform {
backend "s3" {
endpoint = "https://atl1.digitaloceanspaces.com"
bucket = "weown-tools-state"
key = "aibook-fun/aibook-fun.tfstate"
region = "us-east-1"
encrypt = true
acl = "private"
# Required for DO Spaces (S3-compatible but not AWS)
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
skip_s3_checksum = true
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Run OpenTofu with TF_VAR_* creds injected from Infisical /tofu/.
# Usage: infisical login && INFISICAL_PROJECT_ID=<id> ./itofu.sh <init|plan|apply|...>
set -euo pipefail
INFISICAL_PROJECT_ID="${INFISICAL_PROJECT_ID:-}"
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
TOFU_PATH="/tofu"
PLAN_FILE="${WEOWN_TOFU_PLAN:-plan.tfplan}"
: "${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID not set. Export it (e.g. from site.conf) before running itofu.sh.}"
command -v infisical >/dev/null 2>&1 || { echo "ERROR: infisical CLI not found." >&2; exit 1; }
command -v tofu >/dev/null 2>&1 || { echo "ERROR: tofu not found." >&2; exit 1; }
infisical export --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" >/dev/null 2>&1 || {
echo "ERROR: cannot read $INFISICAL_PROJECT_ID $INFISICAL_ENV $TOFU_PATH — run 'infisical login' and check your access." >&2; exit 1; }
[ "$#" -ge 1 ] || { echo "usage: ./itofu.sh <init|plan|apply|output|destroy|...> [args]" >&2; exit 1; }
irun() { infisical run --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" -- "$@"; }
# S3 backend reads AWS_* env on every state op — so ALL tofu calls go through trun.
# shellcheck disable=SC2016
trun() { irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu "$@"' itofu "$@"; }
case "$1" in
init)
shift
# shellcheck disable=SC2016
irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu init -reconfigure \
-backend-config="sse_customer_key=$TF_VAR_spaces_encryption_key" "$@"' itofu "$@"
;;
plan)
shift
trun plan -out="$PLAN_FILE" "$@"
;;
apply)
shift
if [ "$#" -eq 0 ] && [ -f "$PLAN_FILE" ]; then
set +e; trun apply "$PLAN_FILE"; rc=$?; set -e
rm -f "$PLAN_FILE"
exit "$rc"
fi
trun apply "$@"
;;
*)
trun "$@"
;;
esac
+113
View File
@@ -0,0 +1,113 @@
# aibook-fun - Main Infrastructure
# Managed by OpenTofu
resource "digitalocean_droplet" "web" {
name = "aibook-fun"
image = var.droplet_image
size = var.droplet_size
region = var.region
monitoring = true
backups = true
# CPU/RAM-only resize (provider default is true = also grow the disk).
resize_disk = false
ssh_keys = var.ssh_key_fingerprints
user_data = templatefile("${path.module}/templates/cloud-init.yaml", {
project_name = "aibook_fun"
})
tags = ["aibook-fun", "web", "weown-ai"]
lifecycle {
# A droplet destroy means losing every Docker volume + cert state on it.
# make any such plan a hard error instead of an accident.
prevent_destroy = true
# user_data/tags/ssh_keys are create-time-only on DO; ignore drift so
# unrelated changes don't arm `# forces replacement` on next apply.
ignore_changes = [user_data, tags, ssh_keys]
}
}
# Optional encrypted-at-rest data volume (default 0 = stateless: droplet local
# disk only). Enable by setting data_volume_size_gb > 0 if the site ever needs
# durable state; DO block storage is encrypted at rest, the droplet disk is not.
resource "digitalocean_volume" "data" {
count = var.data_volume_size_gb > 0 ? 1 : 0
region = var.region
name = "aibook-fun-data"
size = var.data_volume_size_gb
initial_filesystem_type = "ext4"
description = "aibook-fun encrypted-at-rest data (docker data-root)"
lifecycle {
prevent_destroy = true
}
}
resource "digitalocean_volume_attachment" "data" {
count = var.data_volume_size_gb > 0 ? 1 : 0
droplet_id = digitalocean_droplet.web.id
volume_id = digitalocean_volume.data[0].id
}
resource "digitalocean_reserved_ip" "web" {
region = var.region
}
resource "digitalocean_reserved_ip_assignment" "web" {
ip_address = digitalocean_reserved_ip.web.ip_address
droplet_id = digitalocean_droplet.web.id
}
#trivy:ignore:AVD-DIG-0001 # Public web server: HTTP/HTTPS inbound from internet is required by design
#trivy:ignore:AVD-DIG-0003 # Public web server: unrestricted outbound required for package installs, ACME, image pulls
resource "digitalocean_firewall" "web" {
name = "aibook-fun-fw"
droplet_ids = [digitalocean_droplet.web.id]
# SSH — restrict via var.ssh_source_cidrs. ⚠️ PRODUCTION: pin to admin IP/32 or VPN
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = var.ssh_source_cidrs
}
# HTTP (ACME challenge + redirect)
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# HTTPS
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# HTTPS/QUIC (HTTP/3)
inbound_rule {
protocol = "udp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# All outbound TCP (apt, ACME, container image pulls)
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
# All outbound UDP (DNS, NTP, QUIC)
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
tags = ["aibook-fun"]
}
+44
View File
@@ -0,0 +1,44 @@
# aibook-fun - Monitoring Configuration
# Managed by OpenTofu
resource "digitalocean_monitor_alert" "cpu" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/cpu"
compare = "GreaterThan"
value = var.cpu_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: CPU usage above ${var.cpu_alert_threshold}% for 5 min"
}
resource "digitalocean_monitor_alert" "memory" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/memory_utilization_percent"
compare = "GreaterThan"
value = var.memory_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: Memory usage above ${var.memory_alert_threshold}% for 5 min"
}
resource "digitalocean_monitor_alert" "disk" {
count = var.enable_monitoring ? 1 : 0
alerts {
email = [var.alert_email]
}
window = "5m"
type = "v1/insights/droplet/disk_utilization_percent"
compare = "GreaterThan"
value = var.disk_alert_threshold
enabled = true
entities = [digitalocean_droplet.web.id]
description = "aibook-fun: Disk usage above ${var.disk_alert_threshold}% for 5 min"
}
+28
View File
@@ -0,0 +1,28 @@
# aibook-fun - Terraform Outputs
# Managed by OpenTofu
output "droplet_ip" {
description = "Droplet IPv4 address (reserved IP)"
value = digitalocean_reserved_ip.web.ip_address
}
output "reserved_ip" {
description = "Reserved IP address (alias for droplet_ip; stable across rebuilds)"
value = digitalocean_reserved_ip.web.ip_address
}
output "droplet_id" {
description = "Droplet ID"
value = digitalocean_droplet.web.id
}
output "domain" {
description = "Primary domain"
value = var.domain
}
output "site_url" {
description = "Site URL"
value = "https://${var.domain}"
}
@@ -0,0 +1,85 @@
#cloud-config
# First-boot bootstrap: base packages + Docker + git. App layer via ansible.
package_update: true
packages:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
- jq
- unzip
- git
write_files:
# Docker installation
- path: /tmp/install-docker.sh
permissions: "0755"
content: |
#!/bin/bash
set -euo pipefail
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sh /tmp/get-docker.sh
systemctl enable docker
systemctl start docker
usermod -aG docker ubuntu
# Docker daemon config (log rotation)
- path: /etc/docker/daemon.json
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
# Encrypted-at-rest data volume setup (runs BEFORE Docker is installed).
# Graceful no-op when no volume is attached (data_volume_size_gb = 0 default
# for aibook-fun stateless marketing site).
- path: /tmp/setup-data-volume.sh
permissions: "0755"
content: |
#!/bin/bash
set -euo pipefail
DEV="/dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data"
MNT="/mnt/${project_name}_data"
for i in $(seq 1 24); do
[ -e "$DEV" ] && break
sleep 5
done
if [ ! -e "$DEV" ]; then
echo "no data volume attached after 120s - using droplet local disk (stateless mode)"
exit 0
fi
blkid "$DEV" >/dev/null 2>&1 || mkfs.ext4 -q "$DEV"
mkdir -p "$MNT"
grep -q "scsi-0DO_Volume_aibook-fun-data" /etc/fstab || \
echo "$DEV $MNT ext4 defaults,noatime,nofail,discard 0 2" >> /etc/fstab
mountpoint -q "$MNT" || mount "$MNT"
mkdir -p "$MNT/docker"
jq --arg dr "$MNT/docker" '. + {"data-root": $dr}' /etc/docker/daemon.json > /tmp/daemon.json.new
mv /tmp/daemon.json.new /etc/docker/daemon.json
mkdir -p /opt/${project_name}
echo "data volume mounted at $MNT (docker data-root relocated)"
runcmd:
# Data volume setup only when a block volume is attached
- 'bash -c "[ -e /dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data ] && bash /tmp/setup-data-volume.sh || echo no-data-volume"'
- [bash, /tmp/install-docker.sh]
- [systemctl, restart, docker]
# Ensure the app dir exists for ansible to land the compose + Caddyfile
- [mkdir, -p, /opt/${project_name}]
# Marker so ansible/deploy.yml can confirm the host is ready
- [touch, /opt/${project_name}/.bootstrap-complete]
# Enable automatic security updates
- dpkg-reconfigure -plow unattended-upgrades
final_message: |
aibook-fun bootstrap complete. Next step from the operator workstation:
ansible-playbook aibook-fun/ansible/deploy.yml -i 'root@<this-droplet-ip>,'
Ansible will clone the repo, build the Docker image locally on this droplet,
and bring up the compose stack. Site served at https://aibook.fun.
+27
View File
@@ -0,0 +1,27 @@
# ⚠️ NEVER commit terraform.tfvars with real values. Use ./itofu.sh instead —
# it reads TF_VAR_* straight from Infisical /tofu/.
do_token = "<YOUR_DIGITALOCEAN_TOKEN>"
ssh_key_fingerprints = ["<YOUR_SSH_KEY_FINGERPRINT>"]
# pin to admin IP/32 or VPN range for prod
ssh_source_cidrs = ["0.0.0.0/0", "::/0"]
spaces_access_key = "<YOUR_SPACES_ACCESS_KEY>"
spaces_secret_key = "<YOUR_SPACES_SECRET_KEY>"
spaces_encryption_key = "<YOUR_SSE_C_KEY_BASE64>"
domain = "aibook.fun"
region = "atl1"
droplet_size = "s-2vcpu-4gb-amd"
droplet_image = "ubuntu-24-04-x64"
data_volume_size_gb = 0
enable_monitoring = true
# MUST be verified on the DO account
alert_email = "PLT@weown.net"
cpu_alert_threshold = 80
memory_alert_threshold = 90
disk_alert_threshold = 85
+82
View File
@@ -0,0 +1,82 @@
variable "domain" {
type = string
default = "aibook.fun"
}
variable "region" {
type = string
default = "atl1"
}
variable "droplet_size" {
type = string
default = "s-2vcpu-4gb-amd"
}
variable "droplet_image" {
type = string
default = "ubuntu-24-04-x64"
}
variable "ssh_key_fingerprints" {
type = list(string)
}
variable "ssh_source_cidrs" {
description = "CIDR list allowed to reach port 22 — pin to admin IP/32 or VPN range for prod"
type = list(string)
default = ["0.0.0.0/0", "::/0"]
}
variable "do_token" {
type = string
sensitive = true
}
variable "spaces_access_key" {
type = string
sensitive = true
}
variable "spaces_secret_key" {
type = string
sensitive = true
}
variable "spaces_encryption_key" {
description = "DO Spaces SSE-C encryption key (32-byte AES-256, base64)"
type = string
sensitive = true
}
variable "data_volume_size_gb" {
description = "0 = stateless (droplet local disk); >0 attaches encrypted DO block volume for Docker data-root"
type = number
default = 0
}
variable "enable_monitoring" {
type = bool
default = true
}
variable "alert_email" {
description = "Email for monitoring alerts — MUST be verified on the DO account"
type = string
default = "PLT@weown.net"
}
variable "cpu_alert_threshold" {
type = number
default = 80
}
variable "memory_alert_threshold" {
type = number
default = 90
}
variable "disk_alert_threshold" {
type = number
default = 85
}
+17
View File
@@ -0,0 +1,17 @@
# aibook-fun - OpenTofu Version Constraints
# Managed by OpenTofu
terraform {
required_version = ">= 1.7.0"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.36"
}
}
}
provider "digitalocean" {
token = var.do_token
}
+13 -6
View File
@@ -22,12 +22,19 @@ export default function Book3D() {
</h3> </h3>
<span className="book3d__line" /> <span className="book3d__line" />
<span className="book3d__sub">Tested workflows you can actually run</span> <span className="book3d__sub">Tested workflows you can actually run</span>
<svg className="book3d__mark" viewBox="0 0 40 40" fill="none"> <div className="book3d__foot">
<circle cx="20" cy="20" r="17.5" stroke="#efe9db" strokeWidth="1" opacity="0.7" /> <svg className="book3d__mark" viewBox="0 0 40 40" fill="none">
<path d="M20 5 L22.6 20 L20 35 L17.4 20 Z" fill="#efe9db" /> <circle cx="20" cy="20" r="17.5" stroke="#efe9db" strokeWidth="1" opacity="0.7" />
<path d="M5 20 L20 17.4 L35 20 L20 22.6 Z" fill="#efe9db" opacity="0.55" /> <path d="M20 5 L22.6 20 L20 35 L17.4 20 Z" fill="#efe9db" />
</svg> <path d="M5 20 L20 17.4 L35 20 L20 22.6 Z" fill="#efe9db" opacity="0.55" />
<span className="book3d__brand">AIBook.fun</span> </svg>
<span className="book3d__brand">AIBook.fun</span>
</div>
{/* Belly band — the classic publisher's wraparound strip for a themed
tie-in, rather than redesigning the whole cover around it. */}
<div className="book3d__band">
<img src="/american-dream-banner.webp" alt="" />
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -12,7 +12,7 @@ export default function Contents() {
<section className="section contents" id="contents"> <section className="section contents" id="contents">
<div className="wrap"> <div className="wrap">
<span className="spec reveal">The founding library</span> <span className="spec reveal">The founding library</span>
<h2 className="reveal">Six practices. Written by people who do the work.</h2> <h2 className="reveal">Six practices, written by people who built their careers on them.</h2>
<div className="toc reveal"> <div className="toc reveal">
{ITEMS.map((x) => ( {ITEMS.map((x) => (
<a href="#get" key={x.num}> <a href="#get" key={x.num}>
+4
View File
@@ -20,6 +20,10 @@ const QA = [
q: "Why subscribe instead of buy once?", q: "Why subscribe instead of buy once?",
a: "Because a static AI book is out of date in months. The subscription is what keeps every workflow current — and it’s what funds the practitioners keeping them that way.", a: "Because a static AI book is out of date in months. The subscription is what keeps every workflow current — and it’s what funds the practitioners keeping them that way.",
}, },
{
q: "Why frame this around the American Dream?",
a: "Because for most of the people writing and reading this guide, real fluency with these tools isn’t a slogan — it’s a practical ladder. Nobody in this book inherited their edge; they built it, one real skill at a time. We think this is the next one worth building.",
},
]; ];
export default function FAQ() { export default function FAQ() {
+2 -2
View File
@@ -2,12 +2,12 @@ const NOTES = [
{ {
n: "01 / Stays current", n: "01 / Stays current",
h: "A subscription, not a static book", h: "A subscription, not a static book",
p: "AI moves too fast for a book you buy once. Each annual edition is refreshed so the workflows still work when you open them.", p: "AI moves too fast for a book you buy once. Each annual edition is refreshed so the ground you gain here doesn’t erode the moment you look away.",
}, },
{ {
n: "02 / Authored, then open", n: "02 / Authored, then open",
h: "Written by practitioners first", h: "Written by practitioners first",
p: "The first edition is seeded by co-authors — a real founding library, not an empty shelf. Open, paid contribution opens once it’s worth browsing.", p: "The first edition is seeded by co-authors — a real founding library, not an empty shelf. Open, paid contribution opens once it’s worth browsing, so the people who build it get to own a piece of it.",
}, },
{ {
n: "03 / Responsible by design", n: "03 / Responsible by design",
+3 -2
View File
@@ -7,8 +7,9 @@ export default function Letter() {
Most AI advice is either hype or theory — and neither survives contact with real Most AI advice is either hype or theory — and neither survives contact with real
work. We’re writing the guide we wish we’d had: workflows that <em>actually run</em>, work. We’re writing the guide we wish we’d had: workflows that <em>actually run</em>,
with the <span className="mark">guardrails built in</span>, kept honest by the people with the <span className="mark">guardrails built in</span>, kept honest by the people
who use them every day. Fund the first edition, and you don’t just get the book — you who use them every day. For a lot of us, mastering these tools isn’t optional
help decide what goes in it. anymore — it’s how you build the life you’re working toward. Fund the first edition,
and you don’t just get the book — you help decide what goes in it.
</p> </p>
<div className="sign reveal"> <div className="sign reveal">
<span className="scrawl">The co-authors</span> <span className="scrawl">The co-authors</span>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Tiers() {
<div className="t-unit">limited · final terms at launch</div> <div className="t-unit">limited · final terms at launch</div>
<div className="t-desc"> <div className="t-desc">
A named acknowledgment in the guide, a private cohort seat, and a hand in shaping A named acknowledgment in the guide, a private cohort seat, and a hand in shaping
the first edition. the first edition — the ground floor of something you helped build.
</div> </div>
</div> </div>
</div> </div>
+3
View File
@@ -1,6 +1,9 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
// Deploy layer (deploy/docker/Dockerfile) builds the standalone
// output. No runtime behavior change for preview/dev.
output: 'standalone',
// A stray package-lock.json sits at the repo root; pin the workspace root here // A stray package-lock.json sits at the repo root; pin the workspace root here
// so Turbopack doesn't infer the wrong one. // so Turbopack doesn't infer the wrong one.
turbopack: { turbopack: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+19 -6
View File
@@ -191,7 +191,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
.hero-visual { display: flex; justify-content: center; order: 2; } /* book on the right */ .hero-visual { display: flex; justify-content: center; order: 2; } /* book on the right */
.hero-copy { min-width: 0; order: 1; } /* text on the left */ .hero-copy { min-width: 0; order: 1; } /* text on the left */
.book3d { --bw: 300px; --bh: 420px; --bt: 54px; position: relative; perspective: 2000px; flex: none; padding: 6px 0 40px; } .book3d { --bw: 300px; --bh: 420px; --bt: 54px; --band-h: 58px; position: relative; perspective: 2000px; flex: none; padding: 6px 0 40px; }
.book3d__inner { .book3d__inner {
position: relative; width: var(--bw); height: var(--bh); margin: 0 auto; position: relative; width: var(--bw); height: var(--bh); margin: 0 auto;
transform-style: preserve-3d; transform: rotateX(7deg) rotateY(-34deg); transform-style: preserve-3d; transform: rotateX(7deg) rotateY(-34deg);
@@ -208,7 +208,7 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
transform: translateZ(calc(var(--bt) / 2)); border-radius: 3px 8px 8px 3px; overflow: hidden; transform: translateZ(calc(var(--bt) / 2)); border-radius: 3px 8px 8px 3px; overflow: hidden;
background: linear-gradient(125deg, #23745f, #1a5748 48%, #123f34); background: linear-gradient(125deg, #23745f, #1a5748 48%, #123f34);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07), inset 8px 0 16px -8px rgba(0, 0, 0, 0.5), 0 32px 46px -22px rgba(18, 63, 52, 0.55); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07), inset 8px 0 16px -8px rgba(0, 0, 0, 0.5), 0 32px 46px -22px rgba(18, 63, 52, 0.55);
padding: 32px 27px 27px; display: flex; flex-direction: column; padding: 32px 27px calc(var(--band-h) * 1.48); display: flex; flex-direction: column;
} }
.book3d__back { transform: rotateY(180deg) translateZ(calc(var(--bt) / 2)); border-radius: 8px 3px 3px 8px; background: #0f3b30; } .book3d__back { transform: rotateY(180deg) translateZ(calc(var(--bt) / 2)); border-radius: 8px 3px 3px 8px; background: #0f3b30; }
.book3d__pages { .book3d__pages {
@@ -235,8 +235,21 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
.book3d__title { font-family: var(--font-display); font-weight: 600; font-size: 2.25rem; line-height: 1.02; letter-spacing: -0.02em; color: #f3eee2; margin-top: 16px; } .book3d__title { font-family: var(--font-display); font-weight: 600; font-size: 2.25rem; line-height: 1.02; letter-spacing: -0.02em; color: #f3eee2; margin-top: 16px; }
.book3d__line { width: 34px; height: 2px; background: rgba(239, 233, 219, 0.5); margin: 15px 0; } .book3d__line { width: 34px; height: 2px; background: rgba(239, 233, 219, 0.5); margin: 15px 0; }
.book3d__sub { font-family: var(--font-body); font-size: 13px; color: rgba(239, 233, 219, 0.68); line-height: 1.45; max-width: 16ch; } .book3d__sub { font-family: var(--font-body); font-size: 13px; color: rgba(239, 233, 219, 0.68); line-height: 1.45; max-width: 16ch; }
.book3d__mark { width: 44px; height: 44px; margin-top: auto; } .book3d__foot { display: flex; align-items: center; gap: 10px; margin-top: auto; }
.book3d__brand { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; color: rgba(239, 233, 219, 0.85); margin-top: 12px; } .book3d__mark { width: 30px; height: 30px; flex: none; }
/* Belly band: a promotional strip wrapped across the lower cover, publisher-
style, rather than redesigning the whole face around the source graphic.
The <img> is width-locked to the band and shifted up so the crop stays
full-width (legible "AMERICAN" wordmark) and only trims top/bottom. */
.book3d__band {
position: absolute; left: 0; right: 0; bottom: calc(var(--band-h) * 0.31);
height: var(--band-h); overflow: hidden;
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.1) inset, 0 3px 10px rgba(0, 0, 0, 0.3);
}
.book3d__band img {
position: absolute; left: 0; top: -78%; width: 100%; height: auto; display: block;
}
.book3d__brand { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; color: rgba(239, 233, 219, 0.85); }
.book3d::after { .book3d::after {
content: ""; position: absolute; left: 50%; bottom: 6px; width: 60%; height: 24px; transform: translateX(-50%); content: ""; position: absolute; left: 50%; bottom: 6px; width: 60%; height: 24px; transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(18, 60, 50, 0.3), transparent 70%); filter: blur(7px); z-index: -1; background: radial-gradient(ellipse, rgba(18, 60, 50, 0.3), transparent 70%); filter: blur(7px); z-index: -1;
@@ -245,9 +258,9 @@ h1, h2, h3 { text-wrap: balance; margin: 0; }
.hero-grid { grid-template-columns: 1fr; gap: 44px; } .hero-grid { grid-template-columns: 1fr; gap: 44px; }
.hero-visual { order: 2; } .hero-visual { order: 2; }
.hero-copy { order: 1; } .hero-copy { order: 1; }
.book3d { --bw: 256px; --bh: 358px; --bt: 48px; } .book3d { --bw: 256px; --bh: 358px; --bt: 48px; --band-h: 50px; }
} }
@media (max-width: 420px) { .book3d { --bw: 210px; --bh: 294px; --bt: 40px; } } @media (max-width: 420px) { .book3d { --bw: 210px; --bh: 294px; --bt: 40px; --band-h: 40px; } }
@media (prefers-reduced-motion: reduce) { .book3d__inner { animation: none; } } @media (prefers-reduced-motion: reduce) { .book3d__inner { animation: none; } }
/* ============ FIELD NOTES (even gutters, aligned to container edges) ============ */ /* ============ FIELD NOTES (even gutters, aligned to container edges) ============ */