WP test container created
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
@@ -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';
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* @package TLIG
|
||||
*/
|
||||
?>
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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' );
|
||||
@@ -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 );
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
*/
|
||||
@@ -107,9 +107,38 @@ function trackViewport () {
|
||||
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 -----------------------------------------------------------------
|
||||
|
||||
renderSwatches()
|
||||
renderLadder()
|
||||
trackViewport()
|
||||
|
||||
// `?script=bg` opens straight onto a given script, so a particular cut can be
|
||||
|
||||
@@ -12,18 +12,11 @@
|
||||
|
||||
@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';
|
||||
|
||||
// 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 {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -90,6 +83,52 @@ body {
|
||||
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 ----------------------------------------------------------------
|
||||
|
||||
.swatch-grid {
|
||||
|
||||
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
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 |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -137,6 +137,28 @@
|
||||
class with modifiers replaces them.</p>
|
||||
</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 kB
|
||||
9-slice tile. The originals total 13 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">
|
||||
<p class="demo-label">Filter pills</p>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// 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.
|
||||
import './sass/main.scss'
|
||||
import { initNav } from './js/nav.js'
|
||||
|
||||
initNav()
|
||||
|
||||
@@ -49,6 +49,38 @@
|
||||
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.
|
||||
@mixin visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -24,6 +24,9 @@ $red-780: #7e050c; // .linkUnderline
|
||||
$red-700: #8f0b06; // primary button fill
|
||||
$red-650: #770000; // mobile header / menu
|
||||
$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-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-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.
|
||||
$white: #ffffff;
|
||||
$grey-050: #fbfbfb;
|
||||
|
||||
@@ -49,6 +49,10 @@ $colors: (
|
||||
'button-donate-bg': $gold-700,
|
||||
'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
|
||||
'filter-fill': $wood-50a, // hover and selected
|
||||
'filter-ink-active': $white,
|
||||
@@ -68,6 +72,40 @@ $colors: (
|
||||
// Chrome
|
||||
'shadow': $shadow-35a,
|
||||
'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 --------------------------------------------------------------------
|
||||
@@ -79,7 +117,13 @@ $fonts: (
|
||||
'display': (papyrus-bold, 'Papyrus', fantasy),
|
||||
'body': (Lato, 'Trebuchet MS', Helvetica, 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: (
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
@each $key, $value in $font-weights{ --#{$prefix}-weight-#{$key}: #{$value}; }
|
||||
@each $key, $value in $space { --#{$prefix}-space-#{$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};
|
||||
|
||||
// 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,3 +2,5 @@
|
||||
@forward 'field';
|
||||
@forward 'filter';
|
||||
@forward 'footer';
|
||||
@forward 'nav';
|
||||
@forward 'parchment';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -17,40 +17,33 @@
|
||||
|
||||
@use '../abstracts' as *;
|
||||
|
||||
// Where WordPress serves these from. A project that mirrors the assets
|
||||
// elsewhere overrides the custom properties below rather than this variable.
|
||||
$uploads: '/wp-content/uploads' !default;
|
||||
|
||||
: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 four image tokens this file uses are declared with every other image in
|
||||
// `abstracts/_tokens.scss` and emitted by `base/_root.scss`. What stays here is
|
||||
// the breakpoint re-pointing below — the same four names, different files.
|
||||
|
||||
// The narrower cuts. Same four names, different files.
|
||||
@include down('tablet') {
|
||||
:root {
|
||||
--#{$prefix}-ground: url('#{$uploads}/2019/09/background_planche_800x1300.jpg');
|
||||
--#{$prefix}-scroll-head: url('#{$uploads}/2018/11/background-message-entete750x140_cnd.png.webp');
|
||||
--#{$prefix}-scroll-body: url('#{$uploads}/2018/11/background-message-milieux750x190_cnd.png.webp');
|
||||
--#{$prefix}-scroll-foot: url('#{$uploads}/2018/11/background-message-footer-6_cnd.png.webp');
|
||||
--#{$prefix}-img-ground: url('#{$art}/background_planche_800x1300.jpg');
|
||||
--#{$prefix}-img-scroll-head: url('#{$art}/page/background-message-entete750x140_cnd.png.webp');
|
||||
--#{$prefix}-img-scroll-body: url('#{$art}/page/background-message-milieux750x190_cnd.png.webp');
|
||||
--#{$prefix}-img-scroll-foot: url('#{$art}/page/background-message-footer-6_cnd.png.webp');
|
||||
}
|
||||
}
|
||||
|
||||
@include down('phone') {
|
||||
:root {
|
||||
--#{$prefix}-ground: url('#{$uploads}/2019/09/background_planche_600x1000.jpg');
|
||||
--#{$prefix}-scroll-head: url('#{$uploads}/2018/11/background-message-entete360x140-2_cnd.png.webp');
|
||||
--#{$prefix}-scroll-body: url('#{$uploads}/2018/11/background-message-milieux360x190_cnd.png.webp');
|
||||
--#{$prefix}-scroll-foot: url('#{$uploads}/2018/11/background-message-footer-360x114-3_cnd.png.webp');
|
||||
--#{$prefix}-img-ground: url('#{$art}/background_planche_600x1000.jpg');
|
||||
--#{$prefix}-img-scroll-head: url('#{$art}/page/background-message-entete360x140-2_cnd.png.webp');
|
||||
--#{$prefix}-img-scroll-body: url('#{$art}/page/background-message-milieux360x190_cnd.png.webp');
|
||||
--#{$prefix}-img-scroll-foot: url('#{$art}/page/background-message-footer-360x114-3_cnd.png.webp');
|
||||
}
|
||||
}
|
||||
|
||||
// -- The wood ground ---------------------------------------------------------
|
||||
|
||||
html {
|
||||
background: var(--#{$prefix}-ground) top center / contain fixed;
|
||||
background: var(--#{$prefix}-img-ground) top center / contain fixed;
|
||||
}
|
||||
|
||||
// -- The scroll --------------------------------------------------------------
|
||||
@@ -68,15 +61,22 @@ html {
|
||||
|
||||
.scroll__head {
|
||||
height: 140px;
|
||||
background-image: var(--#{$prefix}-scroll-head);
|
||||
background-image: var(--#{$prefix}-img-scroll-head);
|
||||
background-position: bottom;
|
||||
}
|
||||
|
||||
.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.
|
||||
margin-block-start: -1px;
|
||||
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 {
|
||||
@@ -93,6 +93,6 @@ html {
|
||||
z-index: 1;
|
||||
margin-block-start: -20px;
|
||||
height: 114px;
|
||||
background-image: var(--#{$prefix}-scroll-foot);
|
||||
background-image: var(--#{$prefix}-img-scroll-foot);
|
||||
background-position: top;
|
||||
}
|
||||
|
||||
@@ -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)'))
|
||||
@@ -27,22 +27,24 @@ export default defineConfig(({ mode }) => {
|
||||
// without rebuilding.
|
||||
base: './',
|
||||
|
||||
// `site/` mirrors the live asset tree, so the demo can reference
|
||||
// /wp-content/includes/images/… exactly as a WordPress template does.
|
||||
publicDir: here('site'),
|
||||
// No public directory. It used to point at `site/`, which mirrored the
|
||||
// live asset tree — but `site/` is now a WordPress install, and a demo
|
||||
// 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: {
|
||||
devSourcemap: true,
|
||||
},
|
||||
|
||||
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,
|
||||
|
||||
// The demo needs the wood and scroll images; the deliverable does not —
|
||||
// WordPress already hosts them.
|
||||
copyPublicDir: isDemo,
|
||||
|
||||
sourcemap: true,
|
||||
cssCodeSplit: false,
|
||||
|
||||
@@ -59,7 +61,10 @@ export default defineConfig(({ mode }) => {
|
||||
assetFileNames: (info) => {
|
||||
const name = info.names?.[0] ?? info.name ?? ''
|
||||
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]'
|
||||
},
|
||||
},
|
||||
|
||||