WP test container created

This commit is contained in:
jasa
2026-09-24 18:06:11 +02:00
parent 3ee471bf39
commit e2442d1d15
44 changed files with 2244 additions and 47 deletions
+19 -2
View File
@@ -1,4 +1,21 @@
node_modules/ node_modules/
dist/
*.log
demo-dist/ 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/
+30 -3
View File
@@ -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, links, buttons, form fields and the footer. Page-specific styling (messages,
bookstore, menus, the wood and scroll backgrounds) is not here yet. 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 ## Commands
npm install npm install
npm run dev # demo with live reload, at localhost:5173 npm run dev # static style guide, live reload, localhost:5173
npm run preview # demo as a static page, at localhost:4173 npm run preview # static style guide as a built page, localhost:4173
npm run build # the deliverable: dist/tlig.css + dist/fonts/ 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 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 imports `.scss`, so it needs one of the two servers above. `file://` gives a
+87
View File
@@ -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.
+22
View File
@@ -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
<Directory /var/www/html>
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# 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.
<IfModule mod_headers.c>
<FilesMatch "\.(woff2?|ttf|otf|eot)$">
Header always set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>
+4
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
# Replaces the image's 000-default.conf, which is bound to port 80.
<VirtualHost *:8080>
DocumentRoot /var/www/html
ErrorLog /dev/stderr
CustomLog /dev/stdout combined
<Directory /var/www/html>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
+88
View File
@@ -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:
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* Local config for the clean TLIG rig.
*
* Mounted over /var/www/html/wp-config.php, so site/ stays free of config and
* nothing environment-specific can be committed by accident.
*/
define( 'DB_NAME', 'tlig' );
define( 'DB_USER', 'tlig' );
define( 'DB_PASSWORD', 'tlig' );
define( 'DB_HOST', 'db' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
// Local only — these are not secrets and are not used anywhere else.
define( 'AUTH_KEY', 'tlig-local-auth' );
define( 'SECURE_AUTH_KEY', 'tlig-local-secure-auth' );
define( 'LOGGED_IN_KEY', 'tlig-local-logged-in' );
define( 'NONCE_KEY', 'tlig-local-nonce' );
define( 'AUTH_SALT', 'tlig-local-auth-salt' );
define( 'SECURE_AUTH_SALT', 'tlig-local-secure-auth-salt' );
define( 'LOGGED_IN_SALT', 'tlig-local-logged-in-salt' );
define( 'NONCE_SALT', 'tlig-local-nonce-salt' );
$table_prefix = 'wp_';
define( 'WP_HOME', 'http://localhost:8090' );
define( 'WP_SITEURL', 'http://localhost:8090' );
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', true );
define( 'SCRIPT_DEBUG', true );
define( 'DISALLOW_FILE_EDIT', true );
// Outbound HTTP is allowed. It was blocked here once, to stop the rig
// reporting itself to WordPress.org on every admin page — but blocking the
// domain also broke Add Plugins, which has to reach the directory to list
// anything. The automatic calls are stopped where they start instead, in
// site/wp-content/mu-plugins/tlig-offline.php; deliberate ones go out.
define( 'AUTOMATIC_UPDATER_DISABLED', true );
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';
+8
View File
@@ -0,0 +1,8 @@
<?php
/**
* @package TLIG
*/
?>
<?php wp_footer(); ?>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* TLIG theme setup.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const TLIG_VERSION = '0.1.0';
require_once __DIR__ . '/inc/nav.php';
require_once __DIR__ . '/inc/nav-fields.php';
require_once __DIR__ . '/inc/demo-languages.php';
/**
* Theme supports and menu locations.
*
* One location, not two. The site it replaces maintains a separate desktop tree
* (UberMenu) and mobile tree (ShiftNav) per language — 27 menus, which is why
* German, Polish and Arabic have no mobile menu at all. Here one tree renders
* both; the panel simply stacks below the tablet breakpoint.
*/
function tlig_setup(): void {
add_theme_support( 'title-tag' );
add_theme_support( 'post-thumbnails' );
add_theme_support( 'html5', [ 'search-form', 'gallery', 'caption', 'style', 'script' ] );
register_nav_menus( [
'primary' => __( 'Primary navigation', 'tlig' ),
] );
load_theme_textdomain( 'tlig', get_template_directory() . '/languages' );
}
add_action( 'after_setup_theme', 'tlig_setup' );
/**
* The stylesheet is built by Vite into assets/tlig.css.
*
* Versioned by filemtime so each build busts the browser cache — the same
* approach the old child theme uses for tlig_en.css.
*/
function tlig_enqueue_assets(): void {
$rel = '/assets/tlig.css';
$path = get_template_directory() . $rel;
if ( ! file_exists( $path ) ) {
return; // Not built yet; say nothing rather than enqueue a 404.
}
wp_enqueue_style( 'tlig', get_template_directory_uri() . $rel, [], (string) filemtime( $path ) );
$js = get_template_directory() . '/assets/tlig.js';
if ( file_exists( $js ) ) {
wp_enqueue_script(
'tlig',
get_template_directory_uri() . '/assets/tlig.js',
[],
(string) filemtime( $js ),
[ 'strategy' => 'defer' ]
);
}
}
add_action( 'wp_enqueue_scripts', 'tlig_enqueue_assets' );
+25
View File
@@ -0,0 +1,25 @@
<?php
/**
* @package TLIG
*/
?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<a class="skip-link visually-hidden" href="#content"><?php esc_html_e( 'Skip to content', 'tlig' ); ?></a>
<?php tlig_demo_language_bar(); ?>
<header class="site-header">
<div class="site-header__inner">
<p class="site-header__title"><a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php bloginfo( 'name' ); ?></a></p>
</div>
<?php tlig_nav(); ?>
</header>
@@ -0,0 +1,158 @@
<?php
/**
* Demo scaffolding: a language switcher, standing in for WPML.
*
* The real site resolves the menu through WPML, which is not installed here and
* is not what the demo is arguing about. But the argument *is* about languages
* — the claim is that one tree per language renders correctly without anyone
* cutting a bitmap — so the demo has to be able to show fifteen of them.
*
* So the fifteen imported trees are all real menus, and `?lang=xx` picks which
* one the `primary` location resolves to. Under a port this file goes away and
* WPML's own resolution takes over; nothing else in the theme knows about it.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* The languages the demo has a menu for, in the order they are offered.
*
* @return array<string,string> Code => endonym.
*/
function tlig_demo_languages(): array {
return [
'en' => 'English',
'fr' => 'Français',
'de' => 'Deutsch',
'es' => 'Español',
'it' => 'Italiano',
'pt' => 'Português',
'nl' => 'Nederlands',
'da' => 'Dansk',
'sv' => 'Svenska',
'pl' => 'Polski',
'cs' => 'Čeština',
'bg' => 'Български',
'ru' => 'Русский',
'el' => 'Ελληνικά',
'ar' => 'العربية',
];
}
/**
* The language currently being shown.
*/
function tlig_demo_lang(): string {
$want = isset( $_GET['lang'] ) ? sanitize_key( wp_unslash( $_GET['lang'] ) ) : 'en';
return isset( tlig_demo_languages()[ $want ] ) ? $want : 'en';
}
/**
* Arabic is the reason the whole layout is written in logical properties.
*/
function tlig_demo_is_rtl(): bool {
return 'ar' === tlig_demo_lang();
}
/**
* Points the primary location at the menu for the chosen language.
*
* @param array<string,mixed> $args wp_nav_menu arguments.
* @return array<string,mixed>
*/
function tlig_demo_pick_menu( $args ) {
if ( ( $args['theme_location'] ?? '' ) !== 'primary' ) {
return $args;
}
$menu = wp_get_nav_menu_object( 'TLIG ' . strtoupper( tlig_demo_lang() ) );
if ( $menu ) {
$args['menu'] = $menu;
}
return $args;
}
add_filter( 'wp_nav_menu_args', 'tlig_demo_pick_menu' );
/**
* Flips the document for Arabic.
*
* @param string $output The <html> attributes.
* @return string
*/
function tlig_demo_html_dir( $output ) {
if ( ! tlig_demo_is_rtl() ) {
return $output;
}
return preg_replace( '/\bdir="[^"]*"/', '', $output ) . ' dir="rtl" lang="ar"';
}
add_filter( 'language_attributes', 'tlig_demo_html_dir' );
/**
* The switcher itself.
*/
function tlig_demo_language_bar(): void {
$current = tlig_demo_lang();
echo '<div class="lang-bar"><div class="lang-bar__inner">';
echo '<span class="filter-bar__label">' . esc_html__( 'Language', 'tlig' ) . '</span>';
echo '<ul class="filter-bar">';
foreach ( tlig_demo_languages() as $code => $name ) {
$is_current = ( $code === $current );
printf(
'<li><a class="%s" href="%s" hreflang="%s"%s>%s</a></li>',
esc_attr( $is_current ? 'filter filter--sm is-active' : 'filter filter--sm' ),
esc_url( add_query_arg( 'lang', $code, home_url( '/' ) ) ),
esc_attr( $code ),
$is_current ? ' aria-current="page"' : '',
esc_html( $name )
);
}
echo '</ul></div></div>';
}
/**
* Demo scaffolding: force panels open, for screenshots and for the pitch.
*
* `?open=3` holds the third panel open; `?open=all` drops every panel into the
* flow, one under another. The second is the argument in one picture — seven
* panels of seven different heights, all drawn from the same 328px parchment
* file, where the old site needs a separately cut bitmap for each.
*
* It is a stylesheet, not a change to the markup, so nothing about the menu
* itself depends on it.
*/
function tlig_demo_open_panels(): void {
$open = isset( $_GET['open'] ) ? sanitize_key( wp_unslash( $_GET['open'] ) ) : '';
if ( '' === $open ) {
return;
}
if ( 'all' === $open ) {
$css = '.site-nav__bar{display:block}'
. '.site-nav__item{display:block}'
. '.site-nav__panel{display:block;position:static;margin-block:0 1.5rem}';
} else {
$n = max( 1, (int) $open );
$css = sprintf(
// The bar too: below the tablet breakpoint it is hidden until the
// toggle opens it, and a screenshot cannot press the toggle.
'.site-nav__bar{display:flex}.site-nav__item:nth-child(%d) > .site-nav__panel{display:block}',
$n
);
}
printf( "<style id=\"tlig-demo-open\">%s</style>\n", $css );
}
add_action( 'wp_head', 'tlig_demo_open_panels', 99 );
@@ -0,0 +1,234 @@
<?php
/**
* Eight fields on the menu item screen.
*
* WordPress core has offered `wp_nav_menu_item_custom_fields` since 5.4, so
* this needs no plugin and no custom admin page — the fields appear inside the
* item WordPress already draws, and save with it.
*
* Eight against UberMenu's 149. That is a real reduction in control and it is
* deliberate: what went away is per-item colour, per-item background image and
* per-item height, which is exactly the set that made the old menu a picture
* instead of a design. Colour comes from tokens, the ground comes from the
* parchment border-image, and height comes from the content.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* The field definitions, in the order they are drawn.
*
* @return array<string,array<string,mixed>>
*/
function tlig_nav_fields(): array {
return [
'panel' => [
'type' => 'checkbox',
'label' => __( 'Opens a mega panel', 'tlig' ),
'help' => __( 'Top-level items only. The panel is as tall as its content.', 'tlig' ),
],
'heading' => [
'type' => 'checkbox',
'label' => __( 'Heading, not a link', 'tlig' ),
],
'span' => [
'type' => 'select',
'label' => __( 'Column width', 'tlig' ),
'options' => [
'' => __( 'Automatic', 'tlig' ),
'3' => __( 'Quarter', 'tlig' ),
'4' => __( 'Third', 'tlig' ),
'6' => __( 'Half', 'tlig' ),
'8' => __( 'Two thirds', 'tlig' ),
'12' => __( 'Full width', 'tlig' ),
],
],
'layout' => [
'type' => 'select',
'label' => __( 'Image position', 'tlig' ),
'options' => [
'' => __( 'No image', 'tlig' ),
'image_left' => __( 'Beside the label', 'tlig' ),
'image_below' => __( 'Above the label', 'tlig' ),
],
],
'image' => [
'type' => 'number',
'label' => __( 'Image (attachment ID)', 'tlig' ),
],
'icon' => [
'type' => 'select',
'label' => __( 'Icon', 'tlig' ),
'options' => [
'' => __( 'None', 'tlig' ),
'search' => __( 'Search', 'tlig' ),
'book' => __( 'Book', 'tlig' ),
'headphones' => __( 'Headphones', 'tlig' ),
],
],
'feed' => [
'type' => 'number',
'label' => __( 'List children of page ID', 'tlig' ),
'help' => __( 'Generated at render time, so new pages appear by themselves.', 'tlig' ),
],
'block' => [
'type' => 'textarea',
'label' => __( 'HTML block', 'tlig' ),
'help' => __( 'An escape hatch. Filtered through wp_kses_post, so it cannot carry a stylesheet.', 'tlig' ),
],
];
}
/**
* Draws the fields inside the menu item.
*
* @param int $item_id Menu item post ID.
*/
function tlig_nav_item_fields( $item_id ): void {
$nav = tlig_nav_meta( (int) $item_id );
wp_nonce_field( 'tlig_nav_' . $item_id, 'tlig_nav_nonce_' . $item_id );
echo '<fieldset class="field-tlig-nav description-wide" style="margin:10px 0;padding:8px 10px;border:1px solid #dcdcde;">';
echo '<legend style="font-weight:600;padding:0 4px;">' . esc_html__( 'TLIG navigation', 'tlig' ) . '</legend>';
foreach ( tlig_nav_fields() as $key => $field ) {
$name = sprintf( 'tlig_nav[%d][%s]', $item_id, $key );
$id = sprintf( 'tlig-nav-%d-%s', $item_id, $key );
$value = $nav[ $key ] ?? '';
// `feed` is stored as an array so the walker can grow it later; the
// screen only ever asks for the parent, which is the part an editor
// would change.
if ( 'feed' === $key && is_array( $value ) ) {
$value = $value['parent'] ?? '';
}
echo '<p class="description description-wide" style="margin:6px 0;">';
switch ( $field['type'] ) {
case 'checkbox':
printf(
'<label for="%1$s"><input type="checkbox" id="%1$s" name="%2$s" value="1"%3$s> %4$s</label>',
esc_attr( $id ),
esc_attr( $name ),
checked( ! empty( $value ), true, false ),
esc_html( $field['label'] )
);
break;
case 'select':
printf( '<label for="%s">%s<br>', esc_attr( $id ), esc_html( $field['label'] ) );
printf( '<select id="%s" name="%s" class="widefat">', esc_attr( $id ), esc_attr( $name ) );
foreach ( $field['options'] as $opt => $text ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $opt ),
selected( (string) $value, (string) $opt, false ),
esc_html( $text )
);
}
echo '</select></label>';
break;
case 'textarea':
printf(
'<label for="%1$s">%2$s<br><textarea id="%1$s" name="%3$s" rows="3" class="widefat">%4$s</textarea></label>',
esc_attr( $id ),
esc_html( $field['label'] ),
esc_attr( $name ),
esc_textarea( (string) $value )
);
break;
default:
printf(
'<label for="%1$s">%2$s<br><input type="number" id="%1$s" name="%3$s" value="%4$s" class="widefat"></label>',
esc_attr( $id ),
esc_html( $field['label'] ),
esc_attr( $name ),
esc_attr( (string) $value )
);
}
if ( ! empty( $field['help'] ) ) {
printf( '<span class="description" style="display:block;color:#646970;">%s</span>', esc_html( $field['help'] ) );
}
echo '</p>';
}
echo '</fieldset>';
}
add_action( 'wp_nav_menu_item_custom_fields', 'tlig_nav_item_fields', 10, 1 );
/**
* Saves them.
*
* Stored as one array under `_tlig_nav` rather than eight meta rows: the whole
* point is that an item's configuration is small enough to read at a glance.
* An empty configuration deletes the row instead of writing an empty array, so
* the 214 items that need no settings carry no meta at all.
*
* @param int $menu_id Menu term ID.
* @param int $item_id Menu item post ID.
*/
function tlig_nav_item_save( $menu_id, $item_id ): void {
$nonce = $_POST[ 'tlig_nav_nonce_' . $item_id ] ?? '';
if ( ! wp_verify_nonce( sanitize_key( $nonce ), 'tlig_nav_' . $item_id ) ) {
return;
}
$raw = $_POST['tlig_nav'][ $item_id ] ?? [];
if ( ! is_array( $raw ) ) {
$raw = [];
}
$nav = [];
foreach ( tlig_nav_fields() as $key => $field ) {
$value = $raw[ $key ] ?? '';
switch ( $field['type'] ) {
case 'checkbox':
if ( ! empty( $value ) ) {
$nav[ $key ] = 1;
}
break;
case 'select':
$value = (string) $value;
if ( '' !== $value && isset( $field['options'][ $value ] ) ) {
$nav[ $key ] = 'span' === $key ? (int) $value : $value;
}
break;
case 'textarea':
$value = wp_kses_post( (string) $value );
if ( '' !== trim( $value ) ) {
$nav[ $key ] = $value;
}
break;
default:
$value = (int) $value;
if ( $value > 0 ) {
$nav[ $key ] = 'feed' === $key
? [ 'parent' => $value, 'count' => -1, 'orderby' => 'title' ]
: $value;
}
}
}
if ( $nav ) {
update_post_meta( $item_id, '_tlig_nav', $nav );
} else {
delete_post_meta( $item_id, '_tlig_nav' );
}
}
add_action( 'wp_update_nav_menu_item', 'tlig_nav_item_save', 10, 2 );
+528
View File
@@ -0,0 +1,528 @@
<?php
/**
* The navigation: one tree, rendered from the menu itself.
*
* What this replaces is worth stating, because it is the whole argument.
*
* The site it comes from runs UberMenu, whose per-item settings blob carries
* 149 keys. Across all 15 desktop menus — 748 items — exactly eight of them are
* ever set to something that matters:
*
* span 167 the column's width, stored as a fraction ("1-4", "2-3")
* layout 165 image_left | image_below
* image 159 a thumbnail beside the label
* panel 96 this top-level item opens a mega panel
* heading 94 the item is a heading, not a link
* block 56 raw HTML dropped into the panel
* icon 52 one of exactly three icons, site-wide
* feed 14 list child pages of a given parent
*
* Everything else in the blob is the plugin's own defaults written back
* verbatim — 667 items carry `dt_orderby: name` and `tab_layout: left` without
* a tab or a taxonomy anywhere in the menu.
*
* So the eight live in one `_tlig_nav` array, and the structure comes from the
* tree with no configuration at all: depth 0 is the bar, depth 1 a column,
* depth 2 a link. No per-item heights, no per-item colours, no background image
* per panel — the panel's ground is the parchment border-image, which is one
* file at any height.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Read an item's TLIG settings.
*
* @param int $item_id Menu item post ID.
* @return array<string,mixed>
*/
function tlig_nav_meta( int $item_id ): array {
$meta = get_post_meta( $item_id, '_tlig_nav', true );
return is_array( $meta ) ? $meta : [];
}
/**
* The three icons the entire site uses, inline.
*
* A whole icon font was loaded for these. Counted across all 15 menus: 24
* `fa-search`, 14 `fa-book-open`, 14 `fa-headphones`, and nothing else. Inline
* SVG costs no request and inherits `currentColor`, so an icon on the dark
* ground needs no second rule.
*
* @param string $name Icon key.
* @return string SVG markup, or '' for an unknown name.
*/
function tlig_nav_icon( string $name ): string {
static $icons = [
'search' => '<path d="M11 4a7 7 0 1 0 4.19 12.6l4.1 4.1 1.41-1.42-4.1-4.1A7 7 0 0 0 11 4Zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z"/>',
'book' => '<path d="M3 5c2.5-1 5-1 8 .5v14c-3-1.5-5.5-1.5-8-.5V5Zm18 0c-2.5-1-5-1-8 .5v14c3-1.5 5.5-1.5 8-.5V5Z"/>',
'headphones' => '<path d="M12 3a9 9 0 0 0-9 9v5a3 3 0 0 0 3 3h2v-8H5v0a7 7 0 0 1 14 0v0h-3v8h2a3 3 0 0 0 3-3v-5a9 9 0 0 0-9-9Z"/>',
];
if ( ! isset( $icons[ $name ] ) ) {
return '';
}
return sprintf(
'<svg class="site-nav__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">%s</svg>',
$icons[ $name ]
);
}
/**
* Renders the primary menu.
*
* @param string $location Registered menu location.
*/
function tlig_nav( string $location = 'primary' ): void {
if ( ! has_nav_menu( $location ) ) {
return;
}
// The toggle is part of the menu's own markup rather than the header's, so
// `aria-controls` can name the list it opens without the template having to
// know the id. Below the tablet breakpoint it is the entire mobile menu.
$toggle = sprintf(
'<button type="button" class="site-nav__toggle" aria-expanded="false" aria-controls="%s">%s</button>',
'tlig-menu',
esc_html__( 'Menu', 'tlig' )
);
wp_nav_menu( [
'theme_location' => $location,
'container' => 'nav',
'container_class' => 'site-nav',
'container_aria_label' => __( 'Primary navigation', 'tlig' ),
'menu_id' => 'tlig-menu',
'menu_class' => 'site-nav__bar',
'items_wrap' => $toggle . '<ul id="%1$s" class="%2$s">%3$s</ul>',
'depth' => 3,
'walker' => new TLIG_Nav_Walker(),
'fallback_cb' => false,
] );
}
/**
* Turns a three-level menu into a bar of mega panels.
*
* `Walker_Nav_Menu` hands `start_lvl()` a depth but not the item that owns the
* level, so the current item is stashed in `start_el()` — the panel wrapper has
* to know whose panel it is in order to give it an id for `aria-controls`.
*/
class TLIG_Nav_Walker extends Walker_Nav_Menu {
/** @var WP_Post|null The item most recently opened, for start_lvl(). */
private $current;
/** @var array<int,array<string,mixed>> Settings of the open ancestors, by depth. */
private $stack = [];
/** @var array<int,int> How many children each item has, by item ID. */
private $siblings = [];
/**
* Counts each item's children before walking.
*
* A column with no width of its own should share the row with its
* neighbours, which means knowing how many there are. UberMenu stored that
* as `columns: auto` and worked it out at render time; the walker is handed
* the whole tree here, so it can do the same.
*
* @param array<int,WP_Post> $elements All menu items.
* @param int $max_depth Depth limit.
* @param mixed ...$args Passed through.
* @return string
*/
public function walk( $elements, $max_depth, ...$args ) {
$this->siblings = [];
foreach ( (array) $elements as $element ) {
$parent = (int) ( $element->menu_item_parent ?? 0 );
$this->siblings[ $parent ] = ( $this->siblings[ $parent ] ?? 0 ) + 1;
}
return parent::walk( $elements, $max_depth, ...$args );
}
/**
* Opens a submenu level.
*
* Depth 0 → the panel and its grid. Depth 1 → the list of links in a column.
*
* @param string $output Accumulated markup, by reference.
* @param int $depth Depth of the *parent* item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function start_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
if ( 0 === $depth ) {
$id = $this->current ? 'tlig-panel-' . (int) $this->current->ID : '';
$output .= sprintf(
"\n%s<div class=\"site-nav__panel\" id=\"%s\">\n",
$indent,
esc_attr( $id )
);
// The panel repeats its own item as a link, and the stylesheet shows
// it only in the stacked layout. There, a tap on the bar item opens
// the panel instead of following the link — which is what a phone
// user expects, and what the old site got wrong in the opposite
// direction: with hover the only way in, its top-level pages were
// unreachable on a phone. This is the way back to that page.
if ( $this->current && '' !== trim( (string) $this->current->url ) && '#' !== $this->current->url ) {
$output .= sprintf(
"%s\t<a class=\"site-nav__self\" href=\"%s\">%s</a>\n",
$indent,
esc_url( (string) $this->current->url ),
esc_html( trim( wp_strip_all_tags( $this->current->title ) ) )
);
}
$output .= sprintf( "%s\t<ul class=\"site-nav__grid\">\n", $indent );
return;
}
$output .= sprintf( "\n%s<ul class=\"site-nav__links\">\n", $indent );
}
/**
* Closes a submenu level.
*
* @param string $output Accumulated markup, by reference.
* @param int $depth Depth of the parent item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function end_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
$output .= 0 === $depth
? sprintf( "%s\t</ul>\n%s</div>\n", $indent, $indent )
: sprintf( "%s</ul>\n", $indent );
}
/**
* Opens an item.
*
* @param string $output Accumulated markup, by reference.
* @param WP_Post $item Menu item.
* @param int $depth Depth of the item.
* @param stdClass $args wp_nav_menu arguments.
* @param int $current_object_id Unused.
*/
public function start_el( &$output, $item, $depth = 0, $args = null, $current_object_id = 0 ) {
$this->current = $item;
$nav = tlig_nav_meta( (int) $item->ID );
$this->stack[ $depth ] = $nav;
$has_children = in_array( 'menu-item-has-children', (array) $item->classes, true );
$indent = str_repeat( "\t", $depth + 1 );
switch ( $depth ) {
case 0:
$output .= $indent . $this->bar_item( $item, $nav, $has_children );
break;
case 1:
$output .= $indent . $this->column( $item, $nav, $has_children );
break;
default:
// A leaf can carry a block or a feed too — the English
// TESTIMONIES panel is two of them, and dropping them here is
// how they went missing the first time.
$output .= $indent . '<li class="site-nav__leaf">'
. $this->link( $item, $nav, 'site-nav__link' )
. $this->block( $nav )
. $this->feed( $nav );
}
}
/**
* Closes an item.
*
* @param string $output Accumulated markup, by reference.
* @param WP_Post $item Menu item.
* @param int $depth Depth of the item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function end_el( &$output, $item, $depth = 0, $args = null ) {
$output .= "</li>\n";
}
/**
* A top-level item: the thing you see in the bar.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param bool $has_children Whether it opens a panel.
* @return string
*/
private function bar_item( WP_Post $item, array $nav, bool $has_children ): string {
$panel = $has_children && ! empty( $nav['panel'] );
$classes = [ 'site-nav__item' ];
if ( $panel ) {
$classes[] = 'site-nav__item--panel';
}
if ( in_array( 'current-menu-item', (array) $item->classes, true ) ) {
$classes[] = 'is-current';
}
$attrs = '';
if ( $panel ) {
// The link still goes where it always went; the button beside it is
// what opens the panel. A single element cannot both navigate and
// disclose, and hover alone leaves touch and keyboard out.
$attrs = sprintf(
' aria-expanded="false" aria-controls="%s"',
esc_attr( 'tlig-panel-' . (int) $item->ID )
);
}
$out = sprintf( '<li class="%s">', esc_attr( implode( ' ', $classes ) ) );
$out .= $this->link( $item, $nav, 'site-nav__top' );
if ( $panel ) {
$out .= sprintf(
'<button type="button" class="site-nav__disclose"%s><span class="visually-hidden">%s</span></button>',
$attrs,
/* translators: %s: menu item label. */
esc_html( sprintf( __( 'Open the %s menu', 'tlig' ), wp_strip_all_tags( $item->title ) ) )
);
}
return $out;
}
/**
* A column inside a panel.
*
* The width is a grid span, not a pixel height: `--tlig-span` feeds
* `grid-column: span var(--tlig-span)`. UberMenu stored it as a fraction of
* the row, which is the same thing said less directly.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param bool $has_children Whether it holds links.
* @return string
*/
private function column( WP_Post $item, array $nav, bool $has_children ): string {
$span = isset( $nav['span'] ) ? max( 1, min( 12, (int) $nav['span'] ) ) : 0;
// No width of its own: divide the row by however many columns this
// panel has. Without this every unsized column fell back to a quarter,
// which is right for a panel of four and wrong for the 28 panels —
// UNITY in DIVERSITY, MISSION, SEARCH, across the languages — that
// hold a single column. Those were rendering a 1200px banner into a
// quarter of the width.
if ( ! $span ) {
$count = max( 1, (int) ( $this->siblings[ (int) $item->menu_item_parent ] ?? 1 ) );
$span = max( 1, min( 12, (int) round( 12 / $count ) ) );
}
$style = sprintf( ' style="--tlig-span:%d"', $span );
$out = sprintf( '<li class="site-nav__col"%s>', $style );
// A heading is an item with its link switched off. It was a link with
// `disable_link: on` before; here it is simply not an anchor, which is
// also what a screen reader needs to hear.
$title = trim( wp_strip_all_tags( $item->title ) );
$linked = false;
if ( ! empty( $nav['heading'] ) && '' !== $title ) {
$out .= sprintf( '<h3 class="site-nav__heading">%s</h3>', esc_html( $title ) );
} elseif ( ! $has_children && '' !== $title ) {
$out .= $this->link( $item, $nav, 'site-nav__link' );
$linked = true;
}
// `link()` already emits the thumbnail, so only a column that did not
// render one needs it here — otherwise a childless column with an image
// shows it twice.
if ( ! $linked ) {
$out .= $this->figure( $nav );
}
$out .= $this->block( $nav );
$out .= $this->feed( $nav );
return $out;
}
/**
* An anchor, with its optional thumbnail and icon.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param string $class Class for the anchor.
* @return string
*/
private function link( WP_Post $item, array $nav, string $class ): string {
$title = trim( wp_strip_all_tags( $item->title ) );
$inner = $this->figure( $nav );
if ( ! empty( $nav['icon'] ) ) {
$inner .= tlig_nav_icon( (string) $nav['icon'] );
}
if ( '' !== $title ) {
$inner .= sprintf( '<span class="site-nav__label">%s</span>', esc_html( $title ) );
}
if ( '' === $inner ) {
return '';
}
// An item with its link switched off still has something to show — the
// English menu's fourth column is a single picture with `disable_link`
// set, and wrapping it in an anchor to nowhere would be a lie to anyone
// tabbing through. It renders as the content without the anchor.
// No destination either: an item can lose its link because the editor
// switched it off, or because it never had one — 137 of the 748 items
// point at an `#ubermenu-column` placeholder. Both are content without
// a link, and neither should be an anchor to nowhere.
if ( ! empty( $nav['heading'] ) || '' === trim( (string) $item->url ) || '#' === $item->url ) {
$static = $class . ' is-static';
if ( '' === $title && ! empty( $nav['image'] ) ) {
$static .= ' ' . $class . '--figure';
}
return sprintf( '<span class="%s">%s</span>', esc_attr( $static ), $inner );
}
$classes = [ $class ];
if ( ! empty( $nav['layout'] ) ) {
$classes[] = $class . '--' . str_replace( '_', '-', (string) $nav['layout'] );
}
// A picture with nothing to label is the column's content, not a
// thumbnail beside a word, and the stylesheet sizes the two differently.
if ( '' === $title && ! empty( $nav['image'] ) ) {
$classes[] = $class . '--figure';
}
return sprintf(
'<a class="%s" href="%s"%s>%s</a>',
esc_attr( implode( ' ', $classes ) ),
esc_url( (string) $item->url ),
$item->target ? sprintf( ' target="%s" rel="noopener"', esc_attr( $item->target ) ) : '',
$inner
);
}
/**
* The item's thumbnail, if it has one.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function figure( array $nav ): string {
if ( empty( $nav['image'] ) ) {
return '';
}
$id = (int) $nav['image'];
if ( ! $id ) {
return '';
}
// `large`, not `medium`. Four of these are a column's whole content, and
// two of them are 1200 and 1380 pixels wide in the library — asking for
// `medium` handed the browser a 300px copy to stretch across a 730px
// column. `large` carries a srcset, so a 56px thumbnail still costs a
// small file.
return wp_get_attachment_image(
$id,
'large',
false,
[
'class' => 'site-nav__thumb',
'loading' => 'lazy',
'alt' => '',
]
);
}
/**
* Raw HTML attached to the item.
*
* 56 items carry one. They are not arbitrary — across every language only
* five tags appear in them, and 14 of the 56 are the search form. They are
* kept as an escape hatch, not as a design tool, and the content is filtered
* so a menu cannot inject a stylesheet the way the Danish one does today.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function block( array $nav ): string {
if ( empty( $nav['block'] ) ) {
return '';
}
$html = (string) $nav['block'];
if ( false !== strpos( $html, '[search-form]' ) ) {
return '<div class="site-nav__block">' . get_search_form( [ 'echo' => false ] ) . '</div>';
}
return '<div class="site-nav__block">' . wp_kses_post( do_shortcode( $html ) ) . '</div>';
}
/**
* A list of child pages, generated rather than hand-maintained.
*
* This is the one setting that makes the menu content-driven in the literal
* sense: publish a page under the named parent and it appears, in every
* language, without anyone editing a menu.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function feed( array $nav ): string {
if ( empty( $nav['feed'] ) || ! is_array( $nav['feed'] ) ) {
return '';
}
$feed = $nav['feed'];
$parent = isset( $feed['parent'] ) ? (int) $feed['parent'] : 0;
if ( ! $parent ) {
return '';
}
$children = get_posts( [
'post_type' => 'page',
'post_parent' => $parent,
'posts_per_page' => isset( $feed['count'] ) ? (int) $feed['count'] : -1,
'orderby' => isset( $feed['orderby'] ) ? (string) $feed['orderby'] : 'title',
'order' => 'ASC',
'suppress_filters' => false,
] );
if ( ! $children ) {
return '';
}
$out = '<ul class="site-nav__links site-nav__links--feed">';
foreach ( $children as $child ) {
$out .= sprintf(
'<li class="site-nav__leaf"><a class="site-nav__link" href="%s"><span class="site-nav__label">%s</span></a></li>',
esc_url( (string) get_permalink( $child ) ),
esc_html( get_the_title( $child ) )
);
}
return $out . '</ul>';
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/**
* Fallback template.
*
* @package TLIG
*/
get_header();
?>
<div class="scroll">
<div class="scroll__head"></div>
<div class="scroll__body">
<?php
while ( have_posts() ) :
the_post();
?>
<article <?php post_class(); ?>>
<h1><?php the_title(); ?></h1>
<div class="prose"><?php the_content(); ?></div>
</article>
<?php
endwhile;
?>
</div>
<div class="scroll__foot"></div>
</div>
<?php
get_footer();
+12
View File
@@ -0,0 +1,12 @@
/*
Theme Name: TLIG
Theme URI: https://ww3.tlig.org
Description: True Life in God — a standalone theme. No Avada, no menu plugins.
The stylesheet is built from SASS in this project's src/ and emitted
to assets/tlig.css; this file exists only to declare the theme to
WordPress and is deliberately empty of rules.
Version: 0.1.0
Requires at least: 6.0
Requires PHP: 8.1
Text Domain: tlig
*/
+29
View File
@@ -107,9 +107,38 @@ function trackViewport () {
update() update()
} }
// -- Parchment ladder --------------------------------------------------------
//
// Heights the live site ships a bitmap for, then two it does not.
const IMG = '/wp-content/includes/images/menu'
const LADDER = [
{ h: 309, src: `${IMG}/papyrus_submenu_309.png` },
{ h: 351, src: `${IMG}/papyrus_submenu_351_cnd.png` },
{ h: 366, src: `${IMG}/papyrus_submenu_366_cnd.png` },
{ h: 402, src: `${IMG}/papyrus_submenu_402_cnd.png` },
{ h: 450, src: `${IMG}/papyrus_submenu_450_cnd.png` },
{ h: 180, src: null },
{ h: 620, src: null },
]
function renderLadder () {
document.querySelector('#ladder').innerHTML = LADDER.map(({ h, src }) => `
<div class="parchment-row">
<div class="parchment-row__key">${h}px</div>
<p class="parchment-row__caption">original bitmap</p>
${src
? `<img src="${src}" alt="the original ${h}px bitmap" style="height:${h}px">`
: `<div class="parchment-row__none" style="height:${h}px">no bitmap exists for this height</div>`}
<p class="parchment-row__caption">.parchment, from the one 9-slice tile</p>
<div class="parchment" style="height:${h}px; box-sizing:border-box"></div>
</div>`).join('')
}
// -- Wire up ----------------------------------------------------------------- // -- Wire up -----------------------------------------------------------------
renderSwatches() renderSwatches()
renderLadder()
trackViewport() trackViewport()
// `?script=bg` opens straight onto a given script, so a particular cut can be // `?script=bg` opens straight onto a given script, so a particular cut can be
+49 -10
View File
@@ -12,18 +12,11 @@
@use '../sass/abstracts' as *; @use '../sass/abstracts' as *;
// The scroll frame is a real component now (`layout/_page.scss`), and its image
// tokens already point at this project's own art directory — so the demo no
// longer overrides anything. It only adds its own chrome below.
$img: '/wp-content/includes/images'; $img: '/wp-content/includes/images';
// The scroll frame is a real component now (`layout/_page.scss`). All the demo
// does is point its four image tokens at the local mirror in `site/`, which is
// exactly the re-pointing the component was built to allow.
:root {
--tlig-ground: url('#{$img}/background_planche_1920.jpg');
--tlig-scroll-head: url('#{$img}/page/background-message-entete_cnd.png.webp');
--tlig-scroll-body: url('#{$img}/page/background-message-milieux1200x190-4_cnd.png.webp');
--tlig-scroll-foot: url('#{$img}/page/background-message-footer-1200x114_cnd.png.webp');
}
body { body {
min-height: 100vh; min-height: 100vh;
} }
@@ -90,6 +83,52 @@ body {
align-items: center; align-items: center;
} }
// -- Parchment ladder --------------------------------------------------------
.parchment-ladder {
display: flex;
flex-direction: column;
gap: space('lg');
}
.parchment-row {
&__key {
margin-block-end: space('2xs');
font-family: font('body');
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $brown-600;
}
&__caption {
margin-block: space('2xs');
font-family: font('body');
font-size: 11px;
font-style: italic;
color: $brown-600;
}
// Both panels occupy an identical box, so the only difference on screen is
// rendering fidelity. `fill` reproduces what the live site does to the
// bitmap today — `background-size: cover` stretches it just like this.
img {
width: 100%;
object-fit: fill;
}
&__none {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(145, 116, 85, 0.5);
font-family: font('body');
font-size: 12px;
font-style: italic;
color: $brown-600;
}
}
// -- Swatches ---------------------------------------------------------------- // -- Swatches ----------------------------------------------------------------
.swatch-grid { .swatch-grid {
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+22
View File
@@ -137,6 +137,28 @@
class with modifiers replaces them.</p> class with modifiers replaces them.</p>
</section> </section>
<!-- ------------------------------------------------------------------ -->
<section class="demo-section">
<p class="demo-label">Parchment — one file, any height</p>
<p class="demo-note" style="margin-bottom:1.5rem">Left: the live site's
fixed-height bitmap, one file per exact pixel height. Right: the same
height rendered by <code>.parchment</code> from a single 31&nbsp;kB
9-slice tile. The originals total 13&nbsp;MB before WordPress's ~470
thumbnail derivatives.</p>
<div id="ladder" class="parchment-ladder"></div>
<p class="demo-note">The last two rows are heights the site
<em>cannot currently produce</em> — there is no bitmap for them. That is
the whole point: a panel is as tall as its content, so a menu can grow
with a translation instead of being cropped to fit.</p>
<p class="demo-note">The edge also stays translucent. The live CSS sets
<code>background-color:#000000</code> under a tile whose outer ring has
alpha, painting a black fringe into the antialiasing — visible against
the wood if you look for it.</p>
</section>
<!-- ------------------------------------------------------------------ --> <!-- ------------------------------------------------------------------ -->
<section class="demo-section"> <section class="demo-section">
<p class="demo-label">Filter pills</p> <p class="demo-label">Filter pills</p>
+106
View File
@@ -0,0 +1,106 @@
/**
* The navigation's one piece of JavaScript.
*
* CSS opens a panel on `:hover`, which covers a mouse on a wide screen and
* nothing else. Everything deliberate — a tap, a click, the keyboard — goes
* through `aria-expanded` on the disclosure button, which the stylesheet reads
* with `:has()`. This file only ever sets that attribute: no class toggling on
* the panel, no inline styles.
*
* The button exists because a single element cannot both navigate and
* disclose. On the old site that ambiguity is why the top-level pages are
* unreachable on a phone: the only way to reach the panel was to hover.
*
* In the stacked layout the bar item opens its panel too, because on a phone
* that is what a tap on a row means. The page it links to is then reached from
* `.site-nav__self` at the top of the panel, so nothing becomes unreachable —
* the mistake this replaces, made the other way round.
*/
const NAV = '.site-nav'
const DISCLOSE = '.site-nav__disclose'
const TOGGLE = '.site-nav__toggle'
const TOP = '.site-nav__top'
/** Closes every open panel except, optionally, one. */
function closeAll(nav, except) {
nav.querySelectorAll(`${DISCLOSE}[aria-expanded="true"]`).forEach((button) => {
if (button !== except) button.setAttribute('aria-expanded', 'false')
})
}
/**
* Is the menu in its stacked layout?
*
* Asked of the stylesheet rather than duplicated from it: the Menu toggle is
* `display: none` above the tablet breakpoint and shown below it, so its
* computed display *is* the answer. A `matchMedia` here would be a second copy
* of a number that lives in SASS, and the two would drift.
*/
function isStacked(nav) {
const toggle = nav.querySelector(TOGGLE)
return !!toggle && getComputedStyle(toggle).display !== 'none'
}
/** Opens one panel, closing any other. */
function open(nav, disclose, want) {
closeAll(nav, disclose)
disclose.setAttribute('aria-expanded', want ? 'true' : 'false')
}
function init(nav) {
nav.addEventListener('click', (event) => {
const disclose = event.target.closest(DISCLOSE)
if (disclose) {
open(nav, disclose, disclose.getAttribute('aria-expanded') !== 'true')
return
}
// Stacked, a tap on the bar item opens its panel rather than following the
// link. Without this the page navigates away and the menu the reader had
// just opened is gone — which reads as the menu collapsing under them.
// The link is not lost: the panel repeats it as `.site-nav__self`, which
// is visible only in this layout.
const top = event.target.closest(TOP)
if (top && isStacked(nav)) {
const item = top.closest('.site-nav__item')
const button = item && item.querySelector(`:scope > ${DISCLOSE}`)
if (button) {
event.preventDefault()
open(nav, button, button.getAttribute('aria-expanded') !== 'true')
}
return
}
const toggle = event.target.closest(TOGGLE)
if (toggle) {
const opened = nav.classList.toggle('is-open')
toggle.setAttribute('aria-expanded', String(opened))
if (!opened) closeAll(nav)
}
})
// Escape closes the panel and returns focus to the item that opened it —
// otherwise a keyboard user is left inside a panel with no way out but Tab.
nav.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return
const open = nav.querySelector(`${DISCLOSE}[aria-expanded="true"]`)
if (!open) return
open.setAttribute('aria-expanded', 'false')
open.focus()
})
document.addEventListener('click', (event) => {
if (!nav.contains(event.target)) closeAll(nav)
})
}
export function initNav(root = document) {
root.querySelectorAll(NAV).forEach(init)
}
+3
View File
@@ -1,3 +1,6 @@
// Entry point. Its only job today is to pull the stylesheet into the build; // Entry point. Its only job today is to pull the stylesheet into the build;
// behaviour belongs in src/js/ and gets imported here as it is written. // behaviour belongs in src/js/ and gets imported here as it is written.
import './sass/main.scss' import './sass/main.scss'
import { initNav } from './js/nav.js'
initNav()
+32
View File
@@ -49,6 +49,38 @@
border-radius: radius($round); border-radius: radius($round);
} }
// The parchment panel.
//
// One 9-slice tile at any size. This replaces ~470 fixed-height bitmaps — the
// `papyrus_submenu_{265…480}` family, one file per exact pixel height — which
// existed only because nobody sliced the image. Measured: the submenu texture's
// interior varies by 2/255 over its whole height, and all the decoration is a
// soft shadow in the outer 36px.
//
// Three details are load-bearing:
//
// `fill` without it the middle is not painted and the panel is a
// hollow frame. The commonest way to get a 9-slice wrong.
// `round` not `stretch`. Stretching smears the grain, which is exactly
// what the live `background-size: cover` does today — so this
// is strictly more faithful than what ships.
// transparent the tile's outer ring has alpha. The live CSS sets
// `background-color: #000000` under it, which paints a black
// fringe into the antialiased edge.
//
// `border-image` does not affect layout, so a panel is exactly as tall as its
// content. That is what makes the menu content-driven: no `min-height`, and no
// `white-space: nowrap` to stop labels wrapping out of a fixed box.
@mixin parchment {
border-style: solid;
border-width: var(--#{$prefix}-parchment-edge);
border-image-source: var(--#{$prefix}-img-parchment);
border-image-slice: 36 fill;
border-image-repeat: round;
border-image-width: 1; // == border-width; stated so the two cannot desync
background-color: transparent;
}
// Visible only to screen readers. // Visible only to screen readers.
@mixin visually-hidden { @mixin visually-hidden {
position: absolute; position: absolute;
+8
View File
@@ -24,6 +24,9 @@ $red-780: #7e050c; // .linkUnderline
$red-700: #8f0b06; // primary button fill $red-700: #8f0b06; // primary button fill
$red-650: #770000; // mobile header / menu $red-650: #770000; // mobile header / menu
$red-600: #951111; // blockquote rule $red-600: #951111; // blockquote rule
$red-550: #aa0000; // the menu's only hover colour — UberMenu's
// style_top_level_font_color_hover, and the same for
// headings and submenu links
$red-500: #961a07; // alert $red-500: #961a07; // alert
$red-400: #ca4a1f; // link hover (global `a:hover` in tlig_en.css) $red-400: #ca4a1f; // link hover (global `a:hover` in tlig_en.css)
@@ -36,6 +39,11 @@ $gold-300: #ffe7bf; // mobile menu hover
$gold-200: #fff8c4; // mobile menu text $gold-200: #fff8c4; // mobile menu text
$gold-100: #fbefc9; // footer text $gold-100: #fbefc9; // footer text
// Parchment — the ground the whole site sits on. Two tones, measured: the
// submenu texture is flat and cool, the header ground is warmer and mottled.
$parchment-500: #f0e1bc; // submenu interior, median (240,225,188)
$parchment-400: #e9dba9; // header ground, median (233,219,169)
// Neutrals. // Neutrals.
$white: #ffffff; $white: #ffffff;
$grey-050: #fbfbfb; $grey-050: #fbfbfb;
+45 -1
View File
@@ -49,6 +49,10 @@ $colors: (
'button-donate-bg': $gold-700, 'button-donate-bg': $gold-700,
'button-donate-ink': $white, 'button-donate-ink': $white,
// Navigation. One red for every hover in the menu — the live site uses
// #aa0000 there and #ca4a1f everywhere else, and both are real.
'nav-link-hover': $red-550,
// Filters — the pill toggles used across archives, galleries and the topbar // Filters — the pill toggles used across archives, galleries and the topbar
'filter-fill': $wood-50a, // hover and selected 'filter-fill': $wood-50a, // hover and selected
'filter-ink-active': $white, 'filter-ink-active': $white,
@@ -68,6 +72,40 @@ $colors: (
// Chrome // Chrome
'shadow': $shadow-35a, 'shadow': $shadow-35a,
'shadow-soft': $shadow-15a, 'shadow-soft': $shadow-15a,
// Parchment
'parchment': $parchment-500,
'parchment-header': $parchment-400,
);
// -- Images ------------------------------------------------------------------
//
// Every decorative image in one map, emitted as `--tlig-img-*`. Two sources:
// WordPress uploads, which the site already hosts and which are too large to
// bundle; and project assets under `src/images/`, which Vite fingerprints and
// emits beside the stylesheet so the bundle resolves wherever it is dropped.
//
// Because these are custom properties, a demo, a staging copy or a breakpoint
// can re-point any of them without touching a rule.
// Project assets. The old site scatters these across `uploads/2018/10`,
// `2018/11`, `2019/03` and `2019/09` — wherever they happened to be uploaded.
// Here they are one directory the theme owns, and Vite emits them beside the
// stylesheet, so the bundle resolves at whatever path it is dropped. That
// matters: the theme's CSS lives at wp-content/themes/tlig/assets/, so a
// site-absolute upload path and a stylesheet-relative one are not the same.
$art: '/images' !default;
$images: (
// The page scroll's three illustrations, plus the wood ground
'ground': "url('#{$art}/background_planche_1920-3_cnd.jpg')",
'scroll-head': "url('#{$art}/page/background-message-entete_cnd.png.webp')",
'scroll-body': "url('#{$art}/page/background-message-milieux1200x190-4_cnd.png.webp')",
'scroll-foot': "url('#{$art}/page/background-message-footer-1200x114_cnd.png.webp')",
// Project assets — one 9-slice tile replaces the whole
// papyrus_submenu_{265…480} family
'parchment': "url('/images/parchment.webp')",
); );
// -- Type -------------------------------------------------------------------- // -- Type --------------------------------------------------------------------
@@ -79,7 +117,13 @@ $fonts: (
'display': (papyrus-bold, 'Papyrus', fantasy), 'display': (papyrus-bold, 'Papyrus', fantasy),
'body': (Lato, 'Trebuchet MS', Helvetica, sans-serif), 'body': (Lato, 'Trebuchet MS', Helvetica, sans-serif),
'message': ('Trebuchet MS', Verdana, sans-serif), 'message': ('Trebuchet MS', Verdana, sans-serif),
'nav': (Oswald, 'Lato', sans-serif), // The menu is Papyrus on the live site, and nothing in any stylesheet says
// so: UberMenu writes it from its Customizer into an inline <style>, as
// `font-family: papyrus-bold` on `.ubermenu-main .ubermenu-target` — which
// matches every item at every level. This token named Oswald for a while,
// which was Avada's `--nav_typography-font-family`, i.e. the font of a main
// menu the site does not use.
'nav': (papyrus-bold, 'Papyrus', fantasy),
); );
$font-sizes: ( $font-sizes: (
+7
View File
@@ -18,6 +18,13 @@
@each $key, $value in $font-weights{ --#{$prefix}-weight-#{$key}: #{$value}; } @each $key, $value in $font-weights{ --#{$prefix}-weight-#{$key}: #{$value}; }
@each $key, $value in $space { --#{$prefix}-space-#{$key}: #{$value}; } @each $key, $value in $space { --#{$prefix}-space-#{$key}: #{$value}; }
@each $key, $value in $radii { --#{$prefix}-radius-#{$key}: #{$value}; } @each $key, $value in $radii { --#{$prefix}-radius-#{$key}: #{$value}; }
@each $key, $value in $images { --#{$prefix}-img-#{$key}: #{$value}; }
--#{$prefix}-site-width: #{$site-width}; --#{$prefix}-site-width: #{$site-width};
// How wide the parchment's shaded edge renders. `border-image-slice` is
// unitless source pixels, so this scales the same 36px source band to
// whatever a panel needs — a small flyout re-points it and gets a
// proportionally finer edge from the same file.
--#{$prefix}-parchment-edge: 36px;
} }
+2
View File
@@ -2,3 +2,5 @@
@forward 'field'; @forward 'field';
@forward 'filter'; @forward 'filter';
@forward 'footer'; @forward 'footer';
@forward 'nav';
@forward 'parchment';
+417
View File
@@ -0,0 +1,417 @@
// -----------------------------------------------------------------------------
// The navigation.
//
// One tree renders the bar, the mega panels and the mobile menu. The site this
// replaces keeps three separate things: UberMenu for the desktop bar, ShiftNav
// for the mobile drawer — 27 hand-maintained menus between them — and a
// per-item background bitmap for every panel, cut to the panel's pixel height.
//
// Three properties carry the whole replacement:
//
// * `grid-column: span var(--tlig-span)`, which is UberMenu's `columns`
// fraction said directly. 1-4, 1-3, 1-2 and 2-3 are the only widths in the
// data, and they are spans 3, 4, 6 and 8 of twelve.
// * the `parchment` mixin, a 9-slice of one 328px file. It does not
// participate in layout, so a panel is exactly as tall as its content —
// which is what removes the `min-height`, the `white-space: nowrap` and the
// whole papyrus_submenu_{265…480} family.
// * logical properties throughout, so Arabic is `dir="rtl"` and nothing else.
// -----------------------------------------------------------------------------
@use '../abstracts' as *;
.site-nav {
// The panels are positioned against the bar, not against the item: 653 of
// 667 items were set to `submenu_position: full_width` on the old site, and
// a full-width panel is the one the parchment edge reads correctly on.
position: relative;
max-width: var(--#{$prefix}-site-width);
margin-inline: auto;
font-family: font('nav');
}
.site-nav__bar {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.site-nav__item {
// Deliberately *not* positioned. The panel is `position: absolute` with
// `inset-inline: 0`, and it resolves those against its nearest positioned
// ancestor — so a `position: relative` here silently makes every panel as
// wide as its own bar item, about 150px, with the columns spilling out of
// the parchment. The bar is the containing block; the item is not.
display: flex;
align-items: center;
}
// -- The bar -----------------------------------------------------------------
.site-nav__top {
display: inline-flex;
align-items: center;
gap: space('2xs');
// 15px each side, as UberMenu's `style_top_level_horiz_padding` sets.
padding: space('sm') 15px;
border-block-end: 3px solid transparent;
color: color('page-ink-strong');
font-size: size('base');
font-weight: weight('bold');
line-height: leading('tight');
text-decoration: none;
&:hover,
&:focus-visible {
border-block-end-color: color('nav-link-hover');
color: color('nav-link-hover');
}
}
.is-current > .site-nav__top {
border-block-end-color: currentColor;
}
// The disclosure button sits beside the link rather than replacing it. One
// element cannot both navigate and disclose, and hover alone excludes touch
// and the keyboard — which is how the old menu's top-level pages became
// unreachable on a phone.
.site-nav__disclose {
align-self: center;
width: 2rem;
height: 2rem;
margin-inline-start: calc(space-raw('xs') * -1);
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
&::before {
content: '';
display: block;
width: 0.5rem;
height: 0.5rem;
margin-inline: auto;
border-inline-end: 2px solid currentColor;
border-block-end: 2px solid currentColor;
transform: rotate(45deg) translate(-0.1rem, -0.1rem);
transition: transform 120ms ease-in-out;
}
&[aria-expanded='true']::before {
transform: rotate(-135deg) translate(-0.2rem, -0.2rem);
}
&:focus-visible {
@include focus-ring;
}
}
// -- The panel ---------------------------------------------------------------
.site-nav__panel {
position: absolute;
z-index: 20;
inset-inline: 0;
inset-block-start: 100%;
display: none;
padding: space('lg');
@include parchment;
}
// `aria-expanded` is the only thing that opens a panel by intent, at every
// width, and the JS only ever sets that attribute.
//
// There used to be a `:focus-within` branch here for keyboard users, and it
// was the cause of two bugs at once. Clicking the disclosure button focuses
// it, so the panel could never be closed again by clicking it: the attribute
// went back to `false` and `:focus-within` held the panel open anyway. And
// tapping a top-level link focused it just long enough to flash its panel open
// before the navigation took the focus away. A keyboard user reaches the panel
// the same way everyone else does — Tab to the button, press it — which is the
// ordinary disclosure pattern and needs no second opener fighting the first.
.site-nav__item:has(> .site-nav__disclose[aria-expanded='true']) > .site-nav__panel {
display: block;
}
// Hover is the third way in, and it is the one that has to be asked for.
// Below the tablet breakpoint the panels sit in the flow rather than over it,
// so an unguarded `:hover` makes every panel spring open in turn as a mouse
// travels down the list. And a touch screen that reports hover leaves a tapped
// panel stuck open, because nothing ever un-hovers it.
@media (hover: hover) and (pointer: fine) {
@include up('tablet') {
.site-nav__item:hover > .site-nav__panel {
display: block;
}
}
}
// The panel's own page. Hidden above the tablet breakpoint, where the bar item
// is a link you can simply click; shown when stacked, where a tap on that item
// opens the panel instead.
.site-nav__self {
display: none;
margin-block-end: space('sm');
padding-block-end: space('xs');
border-block-end: 1px solid color-mix(in srgb, currentColor 25%, transparent);
color: color('page-ink-strong');
font-size: size('base');
font-weight: weight('bold');
text-decoration: none;
&:hover,
&:focus-visible {
color: color('nav-link-hover');
}
}
.site-nav__grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: space('lg');
margin: 0;
padding: 0;
list-style: none;
}
.site-nav__col {
// The default is a quarter, which is what 71 of the 152 sized columns asked
// for; anything else sets --tlig-span from its own settings.
grid-column: span var(--#{$prefix}-span, 3);
min-width: 0;
}
// Smaller than the links it heads, and uppercase — which looks inverted until
// you see it working: it is an eyebrow label, not a title. Those are the live
// site's own values (`style_header_font_size: 14`, `text_transform: uppercase`).
.site-nav__heading {
margin: 0 0 space('xs');
color: color('page-ink-strong');
font-family: font('nav');
font-size: size('sm');
font-weight: weight('bold');
line-height: leading('tight');
text-transform: uppercase;
&:hover {
color: color('nav-link-hover');
}
}
.site-nav__links {
margin: 0;
padding: 0;
list-style: none;
}
.site-nav__leaf + .site-nav__leaf {
margin-block-start: space('2xs');
}
.site-nav__link {
display: flex;
align-items: center;
gap: space('xs');
padding-block: space('3xs');
// Solid ink at 18px, not 14px at 86%. The translucent version measured
// 7.44:1 against the heading's 10.78:1 — both pass AA, but the gap plus four
// fewer pixels is what read as washed out. 18px is the live site's size.
color: color('page-ink-strong');
font-size: size('base');
line-height: leading('snug');
text-decoration: none;
&:hover,
&:focus-visible {
color: color('nav-link-hover');
text-decoration: underline;
text-underline-offset: 3px;
}
// `image_below` in the old settings: the thumbnail stacks over the label.
&--image-below {
flex-direction: column;
align-items: start;
gap: space('2xs');
text-align: start;
}
}
.site-nav__icon {
flex: 0 0 auto;
width: 1em;
height: 1em;
}
.site-nav__thumb {
display: block;
max-width: 100%;
height: auto;
}
// A thumbnail inside a link is decoration beside a label; a picture that is the
// column's only child is content, and fills it. The old menu said the same
// thing with a per-item `image_width_custom` of 150, 200, 235 or 730 px.
.site-nav__link .site-nav__thumb {
// `height` has to be restated: as a flex item the `height: auto` above
// resolves against the line rather than the intrinsic ratio, and a wide
// image collapses to a strip.
flex: 0 0 auto;
width: 56px;
height: auto;
}
// A picture with no label is not a thumbnail, it is the column's content, and
// it fills it. Four items are like this — the angel drawing, the books, the
// unity banner, the About portrait — and the 56px cap above was shrinking all
// of them. The live site said the same thing with a per-item pixel width of
// 150, 200, 235 or 730, each chosen to fit its column; a span-3 column is
// about 260px at the site width and a span-8 about 730px, so filling the
// column reproduces those numbers without a ninth setting to maintain.
.site-nav__link--figure .site-nav__thumb,
.site-nav__col > .site-nav__thumb {
// Bounded on both axes, and `width: auto` rather than `100%`.
//
// Filling the column is right for the wide pictures — the unity banner is
// 1200x363, the books 1380x234 — and wrong for the two upright ones. The
// angel drawing is 334x407, so on a phone, where a column is the full width,
// "fill the column" meant 436px tall: a menu item as tall as the screen.
// Constraining both axes lets each picture take whichever limit it reaches
// first, and `auto` stops a small file being upscaled into blur.
width: auto;
max-width: 100%;
max-height: var(--#{$prefix}-nav-figure-max, 360px);
}
.site-nav__link--image-below .site-nav__thumb {
width: 100%;
}
.site-nav__block {
font-size: size('sm');
img {
max-width: 100%;
height: auto;
}
}
// -- The nav toggle, and the stacked layout ----------------------------------
.site-nav__toggle {
display: none;
}
@include down('tablet') {
// This is the whole mobile menu. No second tree, no second plugin: the panel
// stops being a dropdown and becomes part of the flow, and the twelve-track
// grid collapses to one column. The 12 ShiftNav menus have nothing to do.
.site-nav__toggle {
display: inline-flex;
align-items: center;
gap: space('xs');
margin: space('xs') space('md');
padding: space('xs') space('sm');
border: 2px solid color-mix(in srgb, currentColor 55%, transparent);
border-radius: radius('sm');
background: transparent;
color: color('page-ink-strong');
font-family: font('nav');
font-size: size('base');
cursor: pointer;
}
.site-nav__bar {
display: none;
flex-direction: column;
align-items: stretch;
}
.site-nav.is-open .site-nav__bar {
display: flex;
}
.site-nav__item {
flex-wrap: wrap;
justify-content: space-between;
}
.site-nav__panel {
position: static;
flex-basis: 100%;
padding: space('sm');
}
.site-nav__self {
display: block;
}
// A full-width column makes an upright picture tall. Half the desktop cap.
.site-nav__grid {
--#{$prefix}-nav-figure-max: 180px;
}
.site-nav__grid {
grid-template-columns: minmax(0, 1fr);
gap: space('md');
}
.site-nav__col {
grid-column: 1 / -1;
}
}
// -- The demo's language bar -------------------------------------------------
.lang-bar {
background-color: color('footer-bg');
@include dark-ground;
}
.lang-bar__inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: space('xs');
max-width: var(--#{$prefix}-site-width);
margin-inline: auto;
padding: space('xs') space('md');
}
// -- A ground for the bar ----------------------------------------------------
//
// Provisional. The real header — the gold band, the mottled parchment, the
// title as live text in two translatable halves around the Holy Face — is its
// own piece of work. What is here is the minimum the navigation needs to be
// legible: brown text on wood is not.
.site-header {
background-color: color('parchment-header');
}
.site-header__inner {
max-width: var(--#{$prefix}-site-width);
margin-inline: auto;
padding: space('sm') space('md') 0;
}
.site-header__title {
margin: 0;
font-family: font('display');
font-size: size('2xl');
line-height: leading('tight');
a {
color: color('page-ink-strong');
text-decoration: none;
}
}
+29
View File
@@ -0,0 +1,29 @@
// -----------------------------------------------------------------------------
// Parchment panel.
//
// A thin class over the `parchment()` mixin, for content panels and for the
// demo. Blocks that already have a class of their own — the nav panel, the
// header — include the mixin directly rather than carrying two classes in
// markup a PHP walker generates. Same split as `dark-ground` / `.on-dark`.
// -----------------------------------------------------------------------------
@use '../abstracts' as *;
.parchment {
@include parchment;
padding: space('lg');
color: color('page-ink');
// Modifiers re-point the token rather than restating `border-width`, so the
// edge scales proportionally from the same source file.
&--tight {
--#{$prefix}-parchment-edge: 18px;
padding: space('sm');
}
&--hair {
--#{$prefix}-parchment-edge: 10px;
padding: space('xs');
}
}
+22 -22
View File
@@ -17,40 +17,33 @@
@use '../abstracts' as *; @use '../abstracts' as *;
// Where WordPress serves these from. A project that mirrors the assets // The four image tokens this file uses are declared with every other image in
// elsewhere overrides the custom properties below rather than this variable. // `abstracts/_tokens.scss` and emitted by `base/_root.scss`. What stays here is
$uploads: '/wp-content/uploads' !default; // the breakpoint re-pointing below — the same four names, different files.
:root {
--#{$prefix}-ground: url('#{$uploads}/2019/03/background_planche_1920-3_cnd.jpg');
--#{$prefix}-scroll-head: url('#{$uploads}/2018/10/background-message-entete_cnd.png.webp');
--#{$prefix}-scroll-body: url('#{$uploads}/2018/10/background-message-milieux1200x190-4_cnd.png.webp');
--#{$prefix}-scroll-foot: url('#{$uploads}/2018/11/background-message-footer-1200x114_cnd.png.webp');
}
// The narrower cuts. Same four names, different files. // The narrower cuts. Same four names, different files.
@include down('tablet') { @include down('tablet') {
:root { :root {
--#{$prefix}-ground: url('#{$uploads}/2019/09/background_planche_800x1300.jpg'); --#{$prefix}-img-ground: url('#{$art}/background_planche_800x1300.jpg');
--#{$prefix}-scroll-head: url('#{$uploads}/2018/11/background-message-entete750x140_cnd.png.webp'); --#{$prefix}-img-scroll-head: url('#{$art}/page/background-message-entete750x140_cnd.png.webp');
--#{$prefix}-scroll-body: url('#{$uploads}/2018/11/background-message-milieux750x190_cnd.png.webp'); --#{$prefix}-img-scroll-body: url('#{$art}/page/background-message-milieux750x190_cnd.png.webp');
--#{$prefix}-scroll-foot: url('#{$uploads}/2018/11/background-message-footer-6_cnd.png.webp'); --#{$prefix}-img-scroll-foot: url('#{$art}/page/background-message-footer-6_cnd.png.webp');
} }
} }
@include down('phone') { @include down('phone') {
:root { :root {
--#{$prefix}-ground: url('#{$uploads}/2019/09/background_planche_600x1000.jpg'); --#{$prefix}-img-ground: url('#{$art}/background_planche_600x1000.jpg');
--#{$prefix}-scroll-head: url('#{$uploads}/2018/11/background-message-entete360x140-2_cnd.png.webp'); --#{$prefix}-img-scroll-head: url('#{$art}/page/background-message-entete360x140-2_cnd.png.webp');
--#{$prefix}-scroll-body: url('#{$uploads}/2018/11/background-message-milieux360x190_cnd.png.webp'); --#{$prefix}-img-scroll-body: url('#{$art}/page/background-message-milieux360x190_cnd.png.webp');
--#{$prefix}-scroll-foot: url('#{$uploads}/2018/11/background-message-footer-360x114-3_cnd.png.webp'); --#{$prefix}-img-scroll-foot: url('#{$art}/page/background-message-footer-360x114-3_cnd.png.webp');
} }
} }
// -- The wood ground --------------------------------------------------------- // -- The wood ground ---------------------------------------------------------
html { html {
background: var(--#{$prefix}-ground) top center / contain fixed; background: var(--#{$prefix}-img-ground) top center / contain fixed;
} }
// -- The scroll -------------------------------------------------------------- // -- The scroll --------------------------------------------------------------
@@ -68,15 +61,22 @@ html {
.scroll__head { .scroll__head {
height: 140px; height: 140px;
background-image: var(--#{$prefix}-scroll-head); background-image: var(--#{$prefix}-img-scroll-head);
background-position: bottom; background-position: bottom;
} }
.scroll__body { .scroll__body {
// Establish a block formatting context. Without it the first child's top
// margin — an h1's, say — collapses straight through this box and pushes it
// down, leaving a band of bare wood where the papyrus should start. The box
// has no padding-top and no border by design, so there is nothing else to
// stop it.
display: flow-root;
// Closes the hairline where the head's bottom row meets the body. // Closes the hairline where the head's bottom row meets the body.
margin-block-start: -1px; margin-block-start: -1px;
padding: 0 clamp(#{space-raw('md')}, 5vw, 5rem) space('xl'); padding: 0 clamp(#{space-raw('md')}, 5vw, 5rem) space('xl');
background: var(--#{$prefix}-scroll-body) repeat-y top / 100% auto; background: var(--#{$prefix}-img-scroll-body) repeat-y top / 100% auto;
} }
.scroll__foot { .scroll__foot {
@@ -93,6 +93,6 @@ html {
z-index: 1; z-index: 1;
margin-block-start: -20px; margin-block-start: -20px;
height: 114px; height: 114px;
background-image: var(--#{$prefix}-scroll-foot); background-image: var(--#{$prefix}-img-scroll-foot);
background-position: top; background-position: top;
} }
+90
View File
@@ -0,0 +1,90 @@
"""Convert the submenu master into the theme's 9-slice parchment.
The tile is the master, whole: 1200x450, converted to WebP and otherwise
untouched. That sounds like doing nothing, and the reason it is right is worth
recording, because two earlier versions of this file did a great deal.
A 9-slice needs only a 36px border and a small middle, so the obvious cut is
328x328 — one tiny file for any panel at any height. But cutting it that narrow
means manufacturing seven junctions that do not exist in the artwork, and the
master's drop shadow is *directional*: at the same distance from the border the
four edges differ by 20-35 luminance levels (left 192 against top 157 at k=12),
converging only as they reach the interior. Reused edges and pasted corners
therefore cannot agree, and the corners showed it:
top-left | left slab 41.9 <- worst
bottom-left | bottom slab 27.7
bottom-left | left slab 24.3
top-right | top slab 15.1
bottom-right| bottom slab 1.9
Twenty times worse at one corner than another. Levelling does not rescue it:
correcting every rim piece onto one shared cross-edge profile still left the
worst junction at 15, and flattened the shadow's direction to get there. Worse,
each correction fights the others — wrapping a slab so it tiles moves its ends
away from the corners above and below it, which is the same defect again.
Keeping the master whole removes the problem instead of correcting it. Every
piece is contiguous with its neighbours because they were never cut apart, so
there is nothing to level, nothing to wrap, and nothing to flatten.
What this costs, and why it is still the right trade:
* `border-image-repeat: round` scales a slab so a whole number fits. The
middle is 1128x378, and a panel up to about 460px tall needs exactly one
repeat — no join anywhere. The demo's panels are 280-460px.
* Above roughly 640px the vertical count rounds to 2 and a join appears in
the left and right borders. That happens on a phone, where the panels stack
and run long. The join is untreated; the alternative was a treated join at
every corner of every panel, which is the defect this replaces.
* Horizontally, a 1200px panel needs 0.99 repeats: no join at the site width.
On a phone the 1128px edge art is squeezed to about 358px, so the tear
reads finer. That is a scale change, not a seam.
WebP at quality 90 rather than lossless: the artwork is a soft shadow over a
grain of std 2, which the encoder carries at a third of the size.
"""
import numpy as np
from PIL import Image
SRC = '/home/jasa/PycharmProjects/wp-migrate/site/wp-content/uploads/2019/10/papyrus_submenu_450_cnd.png'
OUT = '/home/jasa/PycharmProjects/tlig_css/src/images/parchment.webp'
# Must match `border-image-slice` in abstracts/_mixins.scss.
SLICE = 36
QUALITY = 90
im = Image.open(SRC).convert('RGBA')
W, H = im.size
im.save(OUT, quality=QUALITY, method=6)
import os
print('wrote %s %dx%d %.1f KB' % (OUT, W, H, os.path.getsize(OUT) / 1024))
# Diagnostics. Nothing here is corrected — these are the numbers that would
# tell us the master had changed, or that a panel size had moved outside the
# range where the middle needs a single repeat.
t = np.asarray(Image.open(OUT).convert('RGBA')).astype(np.float32)
def level(band):
"""Alpha-weighted mean luminance, so the transparent rim does not count."""
w = band[..., 3] / 255.0
return float((band[..., :3].mean(axis=2) * w).sum() / (w.sum() + 1e-6))
print(' every piece is contiguous; junction steps are the master\'s own:')
for name, a, b in (
('top-left | left slab', t[SLICE-4:SLICE, 4:SLICE-4], t[SLICE:SLICE+4, 4:SLICE-4]),
('bottom-left | left slab', t[H-SLICE-4:H-SLICE, 4:SLICE-4], t[H-SLICE:H-SLICE+4, 4:SLICE-4]),
('top-right | right slab', t[SLICE-4:SLICE, W-SLICE+4:W-4], t[SLICE:SLICE+4, W-SLICE+4:W-4]),
('bottom-right | right slab', t[H-SLICE-4:H-SLICE, W-SLICE+4:W-4], t[H-SLICE:H-SLICE+4, W-SLICE+4:W-4]),
):
print(' %-30s %5.1f' % (name, abs(level(a) - level(b))))
mid_h, mid_w = H - 2 * SLICE, W - 2 * SLICE
print(' middle slab %dx%d — repeats needed by `round`:' % (mid_w, mid_h))
for h in (280, 380, 460, 640, 900, 1600):
n = max(1, round((h - 2 * SLICE) / mid_h))
print(' panel %4dpx tall -> %d repeat%s%s'
% (h, n, '' if n == 1 else 's', '' if n == 1 else ' (a join appears)'))
+14 -9
View File
@@ -27,22 +27,24 @@ export default defineConfig(({ mode }) => {
// without rebuilding. // without rebuilding.
base: './', base: './',
// `site/` mirrors the live asset tree, so the demo can reference // No public directory. It used to point at `site/`, which mirrored the
// /wp-content/includes/images/… exactly as a WordPress template does. // live asset tree — but `site/` is now a WordPress install, and a demo
publicDir: here('site'), // build would copy the whole of it into demo-dist/. Every image the
// stylesheet needs is a Vite asset under src/images/ instead, which is
// also what makes the bundle resolve at whatever path it is dropped.
publicDir: false,
css: { css: {
devSourcemap: true, devSourcemap: true,
}, },
build: { build: {
outDir: isDemo ? here('demo-dist') : here('dist'), // The build emits straight into the theme, so the theme always runs the
// current stylesheet with no copy step and there is no `dist/` to keep in
// sync. The theme's `assets/` is a build artifact and is not versioned.
outDir: isDemo ? here('demo-dist') : here('site/wp-content/themes/tlig/assets'),
emptyOutDir: true, emptyOutDir: true,
// The demo needs the wood and scroll images; the deliverable does not —
// WordPress already hosts them.
copyPublicDir: isDemo,
sourcemap: true, sourcemap: true,
cssCodeSplit: false, cssCodeSplit: false,
@@ -59,7 +61,10 @@ export default defineConfig(({ mode }) => {
assetFileNames: (info) => { assetFileNames: (info) => {
const name = info.names?.[0] ?? info.name ?? '' const name = info.names?.[0] ?? info.name ?? ''
if (name.endsWith('.css')) return 'tlig.css' if (name.endsWith('.css')) return 'tlig.css'
if (/\.(woff2?|ttf|eot|svg)$/.test(name)) return 'fonts/[name][extname]' if (/\.(woff2?|ttf|eot)$/.test(name)) return 'fonts/[name][extname]'
// Stable, unhashed image paths: the theme preloads the parchment,
// so its URL must not change on every build.
if (/\.(webp|png|jpe?g|svg|avif)$/.test(name)) return 'images/[name][extname]'
return 'assets/[name]-[hash][extname]' return 'assets/[name]-[hash][extname]'
}, },
}, },