diff --git a/.gitignore b/.gitignore
index 7ff4c88..9eaee33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,21 @@
node_modules/
-dist/
-*.log
demo-dist/
+*.log
+
+# Build output. `npm run build` emits into the theme.
+site/wp-content/themes/tlig/assets/
+
+# WordPress core lives in site/ but is not ours — it is extracted from the
+# Docker image so the two cannot drift, and can be recreated at any time. Only
+# our theme and the asset mirror are the deliverable.
+site/*
+!site/wp-content/
+site/wp-content/*
+!site/wp-content/themes/
+!site/wp-content/includes/
+site/wp-content/uploads/
+site/wp-content/plugins/*
+!site/wp-content/plugins/index.php
+site/wp-content/themes/*
+!site/wp-content/themes/tlig/
+site/wp-content/upgrade/
diff --git a/README.md b/README.md
index f122c18..ab83d9a 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,39 @@ This first pass covers the common foundation only: fonts, colour, type scale,
links, buttons, form fields and the footer. Page-specific styling (messages,
bookstore, menus, the wood and scroll backgrounds) is not here yet.
+## Layout
+
+ src/sass/ the stylesheet
+ src/images/ art: the parchment 9-slice, the scroll, the wood ground
+ src/demo/ the static style guide's own chrome
+ site/ WordPress docroot (core is gitignored; see below)
+ wp-content/themes/tlig/ THE DELIVERABLE
+ local/ docker-compose for the clean rig
+
+WordPress core sits in `site/` but is not ours: it is extracted from the Docker
+image so the two cannot drift, and is gitignored. Only the theme and the asset
+mirror are versioned. To recreate core:
+
+ docker run --rm wordpress:php8.3-apache tar -cC /usr/src/wordpress . | tar -xC site/
+
## Commands
npm install
- npm run dev # demo with live reload, at localhost:5173
- npm run preview # demo as a static page, at localhost:4173
- npm run build # the deliverable: dist/tlig.css + dist/fonts/
+ npm run dev # static style guide, live reload, localhost:5173
+ npm run preview # static style guide as a built page, localhost:4173
+ npm run build # the theme's stylesheet -> site/wp-content/themes/tlig/assets/
+
+ cd local && RUN_UID=$(id -u) RUN_GID=$(id -g) docker compose up -d
+ # the theme on real WordPress at localhost:8090 (admin / admin)
+
+The rig is deliberately **not** the `wp-migrate` one. That mounts the production
+tree — Avada, 55 plugins, a 1.5 GB database — and exists to mirror what ships.
+This one boots with nothing but core, so the theme has to stand on its own. Keep
+both: the other is the reference to compare against. It runs on 8080, this on
+8090, so they can run side by side.
+
+A `npm run build` lands directly in the running theme — the docroot is this
+project's own `site/`, so there is no copy step and no container restart.
There is no way to open `src/index.html` from disk — it is an ES module that
imports `.scss`, so it needs one of the two servers above. `file://` gives a
diff --git a/local/README.md b/local/README.md
new file mode 100644
index 0000000..f804608
--- /dev/null
+++ b/local/README.md
@@ -0,0 +1,87 @@
+# The demo WordPress
+
+A clean install with **no Avada and no plugins**, so the theme has to stand on
+its own. The `wp-migrate` rig is the opposite — the production tree, kept as the
+reference to compare against — and is never touched from here.
+
+ cd local
+ docker compose up -d # http://localhost:8090/
+
+Docroot is the project's own `../site`, so a `npm run build` lands in the running
+theme with no copy step and no restart.
+
+## Logging in
+
+ http://localhost:8090/wp-admin/ admin / tlig
+
+Throwaway credentials for a throwaway local install. Nothing here is ever
+deployed, and the database lives in a named volume, not in the repo.
+
+## wp-cli
+
+The `cli` service is behind a Compose profile so it never starts with the site.
+Run it one command at a time:
+
+ docker compose --profile cli run --rm cli wp user list
+ docker compose --profile cli run --rm cli wp plugin list
+ docker compose --profile cli run --rm cli wp post list --post_type=page
+
+Adding a user, for instance:
+
+ docker compose --profile cli run --rm cli \
+ wp user create editor editor@example.org --role=editor --user_pass=tlig
+
+It talks to MySQL directly — it does not go through Apache — so it works even
+when the site itself is down. It shares the same `../site` mount and the same
+`wp-config.php` as the web container, so the two can never disagree about which
+database they are looking at.
+
+## Resetting
+
+ docker compose down -v # -v also drops the database volume
+
+`site/` survives, because it is the repo's own tree. Re-running `wp core install`
+gives a fresh site against the same files.
+
+## Offline by design
+
+`wp-config.php` sets `WP_HTTP_BLOCK_EXTERNAL`: the rig boots with no network and
+never reports this install to WordPress.org. Blocking the request does not stop
+WordPress making it, though — the update checks still ran, each got a `WP_Error`
+back, and under `WP_DEBUG` each printed a warning *before* the headers, so every
+redirect and cookie after it failed too. The message it printed ("could not
+establish a secure connection") was misleading: nothing was wrong with TLS, the
+request was refused locally.
+
+`site/wp-content/mu-plugins/tlig-offline.php` stops the checks at the source
+instead of letting them fail. Keep it; without it the admin fills with warnings.
+
+## The menus
+
+`site/wp-content/import/` holds the fifteen desktop menu trees lifted from the
+live site — 748 items, structure and labels only, with UberMenu's 149-key
+settings blob reduced to the eight fields the audit shows are ever used. Re-run
+it after changing `menus.json`; it replaces each tree rather than doubling it:
+
+ docker compose --profile cli run --rm cli \
+ wp eval-file wp-content/import/import-menus.php
+
+It takes a couple of minutes — `wp_update_nav_menu_item` is not fast — and
+finishes with a count of menus, items and images.
+
+`?lang=el` picks the language (WPML is not installed; `inc/demo-languages.php`
+stands in for it). `?open=3` holds the third panel open and `?open=all` drops
+every panel into the flow — the second is the argument in one picture: seven
+panels of seven heights, all from one 328px file.
+
+## Regenerating the parchment
+
+ python3 tools/cut-parchment.py && npm run build
+
+It reads `papyrus_submenu_450_cnd.png` from the production tree, so that path
+has to exist. The tile is the master whole — 1200x450, WebP at quality 90 —
+because every narrower cut has to manufacture junctions the artwork does not
+have, and the corners showed it badly. The script's header records what was
+tried; its output prints the four corner junctions (the master's own, so a
+regression check rather than a correction) and how many vertical repeats each
+panel height needs.
diff --git a/local/apache-local.conf b/local/apache-local.conf
new file mode 100644
index 0000000..80eca3e
--- /dev/null
+++ b/local/apache-local.conf
@@ -0,0 +1,22 @@
+# Logs and the PID file must go to writable paths: /var/log/apache2 and
+# /var/run/apache2 are root-owned in the image, and this container runs as an
+# unprivileged uid so that it matches the owner of the bind-mounted tree.
+ErrorLog /dev/stderr
+PidFile /tmp/apache2.pid
+ServerName localhost
+
+
+ Options FollowSymLinks
+ AllowOverride All
+ Require all granted
+
+
+# Fonts are the only subresource the browser always fetches in CORS mode, and
+# WordPress emits asset URLs from WP_HOME — so opening the site at any other
+# host turns every font into a blocked cross-origin request. Fine on a local
+# rig; never in production.
+
+
+ Header always set Access-Control-Allow-Origin "*"
+
+
diff --git a/local/apache-ports.conf b/local/apache-ports.conf
new file mode 100644
index 0000000..521b61f
--- /dev/null
+++ b/local/apache-ports.conf
@@ -0,0 +1,4 @@
+# Apache runs as an unprivileged uid here, so it cannot bind port 80. It listens
+# on 8080 inside the container; compose publishes that as 8090 on the host, one
+# port along from the wp-migrate rig so both can run side by side.
+Listen 8080
diff --git a/local/apache-vhost.conf b/local/apache-vhost.conf
new file mode 100644
index 0000000..496e28b
--- /dev/null
+++ b/local/apache-vhost.conf
@@ -0,0 +1,12 @@
+# Replaces the image's 000-default.conf, which is bound to port 80.
+
+ DocumentRoot /var/www/html
+
+ ErrorLog /dev/stderr
+ CustomLog /dev/stdout combined
+
+
+ AllowOverride All
+ Require all granted
+
+
diff --git a/local/docker-compose.yml b/local/docker-compose.yml
new file mode 100644
index 0000000..173ef22
--- /dev/null
+++ b/local/docker-compose.yml
@@ -0,0 +1,88 @@
+# A clean WordPress for the TLIG theme.
+#
+# Deliberately NOT the wp-migrate rig. That one bind-mounts the production tree
+# — Avada, 55 plugins, a 1.5 GB database — and exists to mirror what ships. This
+# one boots in seconds with nothing but core, so the theme has to stand on its
+# own. Keep both: the other is the reference to compare against.
+name: tlig-theme
+
+services:
+ db:
+ image: mysql:8.4
+ command: ["--mysql-native-password=ON"]
+ environment:
+ MYSQL_DATABASE: tlig
+ MYSQL_USER: tlig
+ MYSQL_PASSWORD: tlig
+ MYSQL_ROOT_PASSWORD: tlig
+ volumes:
+ - db:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-ptlig"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ wp:
+ image: wordpress:php8.3-apache
+ # Skip the image's core-unpacking entrypoint: core is already on disk in
+ # site/, extracted from this same image so the versions cannot drift. The
+ # entrypoint would otherwise try to chmod a bind mount it does not own.
+ entrypoint: ["apache2-foreground"]
+ # Fedora runs SELinux enforcing, which blocks container access to bind
+ # mounts — including, silently, the config files below, which is why Apache
+ # kept binding port 80 despite a mounted ports.conf.
+ security_opt:
+ - label=disable
+ # Rootless podman maps the host user to container root by default, but
+ # Apache refuses to run workers as root. keep-id maps host uid 1000 to
+ # container uid 1000, so the container process is literally the owner of the
+ # bind-mounted tree. Under rootful docker, drop this and set RUN_UID=1000.
+ userns_mode: "keep-id"
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "8090:8080"
+ environment:
+ WORDPRESS_DB_HOST: db
+ WORDPRESS_DB_NAME: tlig
+ WORDPRESS_DB_USER: tlig
+ WORDPRESS_DB_PASSWORD: tlig
+ WORDPRESS_DEBUG: "1"
+ WORDPRESS_CONFIG_EXTRA: |
+ define( 'WP_DEBUG_DISPLAY', true );
+ define( 'SCRIPT_DEBUG', true );
+ define( 'DISALLOW_FILE_EDIT', true );
+ # The project's own site/ is the docroot, so a Vite build lands in the
+ # running theme immediately — no copy step, no container restart. Config is
+ # mounted over the top, so site/ never holds environment-specific settings.
+ volumes:
+ - ../site:/var/www/html
+ - ./wp-config.php:/var/www/html/wp-config.php:ro
+ - ./apache-ports.conf:/etc/apache2/ports.conf:ro
+ - ./apache-vhost.conf:/etc/apache2/sites-enabled/000-default.conf:ro
+ - ./apache-local.conf:/etc/apache2/conf-enabled/zz-local.conf:ro
+ user: "${RUN_UID:-1000}:${RUN_GID:-1000}"
+
+ cli:
+ image: wordpress:cli-php8.3
+ security_opt:
+ - label=disable
+ userns_mode: "keep-id"
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ WORDPRESS_DB_HOST: db
+ WORDPRESS_DB_NAME: tlig
+ WORDPRESS_DB_USER: tlig
+ WORDPRESS_DB_PASSWORD: tlig
+ volumes:
+ - ../site:/var/www/html
+ - ./wp-config.php:/var/www/html/wp-config.php:ro
+ user: "${RUN_UID:-1000}:${RUN_GID:-1000}"
+ profiles: ["cli"]
+
+volumes:
+ db:
diff --git a/local/wp-config.php b/local/wp-config.php
new file mode 100644
index 0000000..a7fdbd1
--- /dev/null
+++ b/local/wp-config.php
@@ -0,0 +1,47 @@
+
+
+