Tripvance — Changelog
=====================

The recent releases in full, older ones in one line each. The current
release is also listed in readme.txt, which is what the plugin directory
shows.

= 2.29.0 =
MED's request (11 Nov 2026): a polish, technical SEO and performance release that also prepares the plugin for Tripvance Pro, with every proposed change checked against the code before any of it was written. Most of the list turned out to be in place already; what follows is what was actually missing.

Audited and left as they are (already implemented, or resting on something the code does not do)
* One Offer at the public "From" price, never an AggregateOffer; no TouristTrip; FAQPage on the homepage only, and not when the SEO plugin's graph has one; BreadcrumbList only where no SEO plugin prints one.
* Canonical and noindex,follow on narrowed archive views for core, Yoast, Rank Math and SEOPress; translated filter values in Polylang's alternates; x-default. The landing page's filter is radio buttons, with no query string.
* A design's stylesheet and script only on its own tours (the layout registry); assets/css/*.rules.php are build-time indexes read only when `wptm_inline_used_css` is on, not CSS generated per request; the route is an inline SVG with no tiles and no script, so there is nothing to lazy-load.
* Nothing is locked, moved or removed for Tripvance Pro, so includes/migrate.php is untouched and no site needs a legacy flag.

Structured data (includes/structured-data.php)
* known_template(): templates/noir-design/single-tour.php added. Noir (2.21.0) prints Tour::price() in its hero and booking band and the Reviews\cached() figures in its trust bar, but was not in the list, so offers and aggregateRating were both null on a Noir tour and product() published nothing.

hreflang (includes/seo.php)
* The pll_rel_hreflang_attributes filter returns an empty list when SEO\own_noindex() is true. Polylang (src/frontend/frontend-filters-links.php) leaves the alternates out on paged views only, so /fr/tours/?destination=x printed alternates to the same filtered, noindex view in each language.

The total while it is asked for (assets/css/booking-panel.css, assets/css/experience.css)
* .wptm-group-summary[aria-busy="true"] [data-group-total] and .wptm-xtotal[aria-busy="true"] .wptm-xtotal__value: a linear-gradient band moved by a keyframe (wptm-price-wait, wptm-xtotal-wait), 1.2s; background and animation none under prefers-reduced-motion. No script change: both scripts already set aria-busy.
* booking-panel.min.css, experience.min.css and admin.min.css rebuilt with esbuild 0.25.9 (which reproduces the three 2.28.4 files byte for byte). booking-panel.rules.php and experience.rules.php carry the three new rules each and the new size; both indexes rebuild their stylesheet exactly.

For add-ons, and Tripvance Pro
* includes/bootstrap.php: do_action( 'wptm_loaded', WPTM_VERSION ) after the core group and, in wp-admin, the admin and importer groups. WordPress reads `tripvance-pro/` before `tripvance/`, and this plugin loads its modules while its file is included, so an add-on had no moment at which Tripvance was known to exist short of plugins_loaded. An add-on read later checks did_action( 'wptm_loaded' ).
* includes/compat.php: tripvance_is_pro_active(), false unless the `wptm_pro_active` filter says otherwise. It hides the link to Tripvance Pro and decides nothing else: no feature asks it before working (WordPress.org guideline 5).
* includes/about.php: PRO_URL (https://tripvance.net/pricing/); a "Tripvance Pro" link last in the plugin's row; a Tripvance Pro card on the About screen (what it adds, that it is optional and separate, one button), replaced by a thank-you line when Pro is active. "No paid edition: nothing to unlock" and "no edition above this one" are reworded, since they stop being true. No notice, badge or lock on any other screen (guideline 11).
* readme.txt: "Is there a paid version?" in the FAQ.
* templates/sections/booking-success.php: do_action( 'wptm_booking_success_actions', $booking_id, $booking ) at the head of the confirmation's actions, for an add-on's own button (Tripvance Pro's optional "pay the deposit now"). Tripvance prints nothing there.
* includes/payments/payments.php: method_label() falls back to the methods() label (filter `wptm_payment_methods`), so a method an add-on registers (Tripvance Pro's `stripe`, `woocommerce`) reads as its name on the pass, the receipt and the documents, not as its key.

Checked
* php -l on every file (PHP 8.3). tests-2.29.0/run.php at E_ALL: the Noir template is known, the hreflang list is emptied only on a noindex view and keeps x-default elsewhere, wptm_loaded fires once with the version, the Pro helper follows its filter, the plugin row and the About screen in both states. rules_check.php: the two edited indexes rebuild their .min.css.

= 2.28.4 =
MED's request (7 Nov 2026): the homepage's tailor-made banner, cleaner and more elegant, of a piece with the page, with a picture used automatically — redrawn from the image he supplied, in moroccantraveltrips.com's identity.

The band (includes/home/tailor-banner.php, assets/css/home-tailor-banner.css — rewritten)
* Gone: the rounded card inside the page's column, the two gold glows, the dotted field, the pill eyebrow with its compass disc, the circled ticks, the arrow in a circle, the turning seal, and the picture as a framed card inset in the band.
* Edge to edge (MED, second look: "ممتد من اليمين واليسار ومتناسق"): the section is the band, 100vw wide like the closing call to action (body clips the scrollbar's few pixels, as for every full-width homepage band), no ground or padding of its own. Its grid is [full-start] minmax(pad, 1fr) [content-start] (wrap/2 − pad) [middle] (wrap/2 − pad) [content-end] minmax(pad, 1fr) [full-end]: the middle columns are exactly the page's wrap, so the words start on the same line as every other section's (measured: 0px off at 1366, 1905 and 2560, in English and Arabic, inside a 1200px theme container), and the picture runs from the middle to the screen's edge. Arabic mirrors it: words on the right, picture to the left edge.
* A flat ground (ink #171a20 for Dark, the page's cream with a hairline above and below for Light); the homepage's own .wptm-home__eyebrow (short gold rule, small capitals; a lighter gold on the dark ground) and .wptm-home__btn--primary; the title at most 18ch; the promises as plain gold ticks on one line.
* A phone: the picture first across the screen (16:9 for a photograph, 4:3 for the drawing, so none of it is cut), then the words with the page's side padding.
* Photograph sizes: '(max-width: 760px) 100vw, 50vw'.

The drawing (assets/img/tailor-journey.svg, 16 KB, new)
* Used when the Photograph field is empty. An original drawing on the theme of MED's image, not a copy of it: a glass trip planner (place, pace, a culture/rest switch, a budget slider) whose answers flow in gold lines to a journey card (a route drawn day by day, where to sleep, what to see), above an open book with Morocco rising from it — a minaret with its walls and palms, the snow of the Atlas, dunes with a caravan, the blue town — and a brass compass. Navy ground, gold light, the site's accent.
* Deliberately left out of the supplied image: every word (its labels were garbled, and a picture with English in it would sit on French, Spanish and Arabic pages); the passport and the national emblem; a country outline (a route stands in for the map, so no outline can be drawn wrong).
* Its own ground is transparent: the dark ground and the warm light in the outer corner are the band's CSS, so the drawing stands on a ground as wide as the screen makes that half, with no edge to see. Contained (never cropped), standing on the floor of the band. On the Light tone it keeps a dark panel of its own (#161c27).
* An <img> with width/height, alt="" (decoration: the band's words say it all), lazy, async decode.
* The Photograph field's help says so: leave it empty to use the drawing.

Third look (MED, 7 Nov 2026, screenshots of the live page: "الخلفية بشكل تلقائي مثل الصورة", no white strip around the band or before the closing call, no excerpts in the blog section or the activities archive)
* The drawing stood on a navy 4:3 panel: Cloudflare was still serving the first copy of tailor-journey.svg (with its own ground) under a ten-year max-age, the URL carrying no version (checked live: cf-cache-status HIT, the old file). The URL now ends in ?ver=<version>.<file date> (\Tripvance\asset_version(), as for the sheets), so every new copy is a new URL.
* The ground is the drawing's: on the Dark tone with the drawing, the whole band takes its navy (#111827 to #1b2433) and the warm light in the outer corner (top right; top left in Arabic), and the picture's half has no ground of its own: no panel, no edge, at any width. The Light tone and photographs are unchanged.
* No white strip between two grounds (assets/css/home.css): the video band and the closing band kept their top margin (64px; half a gap) even after a band, and it showed as white between two colours: under the tailor-made banner, and between the blog section on paper and the closing call. After a paper, dark, video, tailor-made or closing band they now start flush; after a white section they keep it, since it is that section's own white. The split and card closing designs are unchanged. The white above the banner on the live page is the activities section's own bottom space (as every white section ends), not a gap.
* Homepage blog section: no excerpt in any layout (Cards, Editorial, Magazine rail, Panorama rows, Compact grid, Editorial list, Timeline, Article Carousel): the picture, the category and date, the title. guide_slot() no longer carries a word count. The .wptm-home__guideexcerpt rules are left in the layout sheets, unused (css-prune drops them).
* Activities archive and its type pages: cards show the title, the facts and the price; the excerpt stays on the activity's page. The [tripvance_activities] shortcode keeps its own excerpt option.

Verification
* php -l; PHPCS (EscapeOutput, ValidatedSanitizedInput, I18n) on tailor-banner.php: clean.
* The real section_tailor_banner() rendered under E_ALL (notices fatal) with the real icons and the homepage sheets, inside a 1200px theme container under a normal section, Dark and Light, English and Arabic, with and without a photograph; Chrome at 390, 1366, 1905 and 2560: the band spans the window, no sideways scroll, words on the section line above (0px), no script errors, Arabic mirrored with the arrow turned.
* esbuild 0.25.9 reproduces the shipped home-tailor-banner.min.css; the sheet has no rule index (not pruned).
* On moroccantraveltrips.com the field currently holds an uploaded icon (icono-Paso-de-montana); empty it to show the drawing.
* Third look: a homepage flow rendered from the real banner and sheets (white section, banner, video band, white, paper, closing band, alternating rhythm) in Chrome at 1440 and 390, English and Arabic: 0px between every pair of sections, the band's ground the drawing's with no panel, no sideways scroll. The real section_guides() rendered under E_ALL for all eight layouts: six articles, no excerpt. home.min.css rebuilt with esbuild 0.25.9 and its rule index regenerated (the new rule's classes sit inside :is(), so it is never pruned). PHPCS clean on the three PHP files touched.

= 2.28.3 =
MED's follow-up to 2.28.2 (7 Nov 2026, screenshots of moroccantraveltrips.com): the activity banner edge to edge and against the header; homepage activity cards with the title, the price and the rating only; no scrollbar under the blog section's Article Carousel; a Ministry of Tourism emblem for the licence line, with the blue mark beside the number.

Activity banner (measured on the live page, 2.28.2 installed)
* Not full width: .wptm-act clipped its own horizontal overflow (the guard against the banner's 100vw), and on Jannah .wptm-act sits in #content.container, 1200px — so the clip cut the banner to 1200px. On the activity page the guard is the body's now (body.single-wptm_activity { overflow-x: clip }, .wptm-act visible). Not a scroller, so the panel and the side summary still stick (checked live).
* Not against the header: Jannah gives #content a 30px top margin (15px on a phone). On activity pages it is 0, next to the 2.28.2 #tie-container rule. CSS, not a script: nothing moves after the page has painted, so no layout shift.
* Injected live at 1905 and 390: banner from edge to edge of the window, 0px under the header, no sideways scroll (scrollWidth = clientWidth), side summary held at 24px.

Homepage activity cards
* Photograph, title, then one line: the price ("From" small, the amount large in the ink colour, the basis small) at the start and, when the activity has approved reviews, the rating at the end — Card\meta()'s rule and Card\rating_html(), as on every card. No excerpt, no duration. "Price on request" where there is no figure; nothing where the activity says nothing.
* activity_meta_line() → activity_price(); .wptm-home__actnote and .wptm-home__actmeta are gone.

Blog — Article Carousel
* scrollbar-width: none and ::-webkit-scrollbar { display: none } on the row; the unused --gc-thumb/--gc-track colours removed; bottom padding 20 → 14px (room for the cards' shadow only). Touch and trackpad scroll it as before; the round arrows page it.

Licence line (hero and trust section)
* New hero setting, under Tourism licence: "Issuer emblem (optional)", a media-library field (licence_icon, a URL like the other home images). When set, the emblem leads the line (28px, contained, never cropped) and the line gets .wptm-home__licence--emblem for a tighter start padding. The blue verified mark now follows the number in every case.
* Guide hero: the licence is a white tag with a hairline and the number in the title's navy (it was the dark glass pill of the photo heroes, a grey smudge on paper); dark scheme follows the guide card.
* The setting's help says to use an emblem only where the issuer allows it.

Plugin Check (MED's report of 7 Nov 2026, 19:05), same version
* includes/tailor/settings-screen.php: the proposal reminder's day count, printed through %d, is passed through absint() as well, so EscapeOutput sees it escaped.
* includes/tailor/proposal.php: the accepted add-ons are array_map( 'sanitize_key', wp_unslash( $_POST['addons'] ) ), then array_filter(). The is_scalar filter used to sit between the two and hid the sanitizer from the sniff; WordPress's sanitize_key() already returns '' for a nested array, which array_filter() drops (checked against core's own function: ["Camel-Ride", ["x"], "dinner_1", "", "BAD key!"] → ["camel-ride", "dinner_1", "badkey"]).
* readme.txt: the 2.26.0, 2.25.0 and 2.23.4 upgrade notices shortened to 300 characters or fewer; all 78 notices are now within the limit.
* PHPCS 3.13.2 + WPCS 3.1.0 (EscapeOutput, ValidatedSanitizedInput, NonceVerification, PreparedSQL, PreparedSQLPlaceholders, I18n, NoSilencedErrors, GlobalVariablesOverride) over the whole plugin: no findings outside vendor/tcpdf, which Plugin Check does not scan.

Build
* esbuild 0.25.9 reproduces the shipped .min of every sheet touched (activity, home, home-guides-carousel, home-hero-guide, dark). Indexes rebuilt for activity, home and home-hero-guide (the builder reproduces the shipped home-hero-guide index rule for rule); each matches its .min to the byte. activity.min.css 52,530 bytes; every rebuilt .min is stamped newer than its source.

Verification
* php -l on every changed PHP file. The real section_activities() and licence_line() rendered under E_ALL (notices fatal) with a rated activity, an unrated one, a price on request, and the licence with and without an emblem and a link.
* Live page previews (DOM and CSS injected into moroccantraveltrips.com/fr/): cards show title + "À partir de 8 € par personne"; licence tag white with the emblem first and the blue mark after GVCT 2898; carousel offsetHeight = clientHeight (no scrollbar). Activity banner as above.
* Fixture suites re-run (2.28.1 layout, 2.28.2 sticky/video): clean.
* Not run: Plugin Check / PHPCS, PHP 7.4, the build itself on a live WordPress.

= 2.28.2 =
MED's follow-up to 2.28.1 (7 Nov 2026): keep the price and booking panel in the side on activity pages; accept a video file from the site's own server (e.g. an .mp4 in uploads); show the homepage's and the archive's photographs at the activity page's square size. No new strings except the video field's help, no options, nothing stored.

The booking panel in the side
* What was wrong, measured on moroccantraveltrips.com: Jannah draws the page inside #tie-container with overflow: hidden. A sticky element inside a scroll container sticks to that container, not to the window, so the panel never stuck at all (scrolled to 3000px it was 1992px off the top of the screen). The script saw this and correctly wrote nothing; there was nothing it could do.
* Fix: on activity pages only, `.single-wptm_activity #tie-container { display: flow-root; overflow: clip; }`. clip hides the same overflow without being a scroller; flow-root keeps the block formatting context hidden gave. Injected on the live page first: page height, width, footer and content positions identical, and the panel then holds at 24px. A browser without clip keeps hidden — the page as it was.
* The live panel is 1486px tall in an 850px window (price, badges, facts, the full request form). Even sticking, it would have been pinned by its foot (2.15 design), with the price off the top of the screen for the whole page. Now a panel taller than the window scrolls with the page, and once it has gone by, a summary — the price and the Book button, frosted like the phone bar — holds the top of the side to the end of the column. The phone bar's observer shows it at the same moment (head button and panel both off screen); the Book button scrolls back to the form. A panel that fits the window is unchanged: it pins itself and the summary stays hidden. Phones keep the bar. Without the script the panel behaves as in 2.28.1.
* The theme rule is for Jannah and activity pages only. The tour page's sidebar sits in the same wrapper; not changed here.

Video files
* templates/sections/video.php: a URL ending in a video extension WordPress knows (wp_get_video_extensions(): mp4, m4v, webm, ogv, flv) becomes a <video controls autoplay playsinline> with the hero as its poster, waiting in the same <template> as an embed — same poster, same press, nothing downloaded until then. Everything else still goes to oEmbed (YouTube, Vimeo). The size is set on the element, because the tour sidebar and the activity page both show it and only the activity page has a stylesheet for the frame.
* activity.js focuses the player either way and only adds autoplay=1 to an iframe. tour.js needed nothing: it clones the template and the video starts on its own.
* The field's help, in both editors: "YouTube, Vimeo, or a video file (MP4, WebM) from the Media Library."

Square photographs on the homepage and the archives (measured on the live site before and after)
* Activity cards everywhere (archive, under a tour, shortcode and block lists): --wptm-card-ratio 1 / 1, phones included. Activity archive at 1366: 348x232 → 348x348.
* Homepage activities (.wptm-home__actimg): 1 / 1 with height: auto. 359x239 → 359x359.
* Tour archive grid (.wptm-card2__media): 4 / 3 → 1 / 1, the shape the homepage's featured cards already show the same tours in. 382x287 → 382x382; 357x268 → 357x357 on a phone. The list view keeps its own frame. A photograph of another shape is still covered (the archive keeps its top-anchored crop).

Build
* esbuild 0.25.9 reproduces the shipped tour, home, card and activity .min files exactly; all four rebuilt. activity.min.css 52,376 bytes, card 19,636, home 128,376, tour 152,116; activity.min.js 7,522.
* build_rules.py (harness ZIP) now handles nested conditional blocks and @keyframes; it reproduces the shipped indexes of activity, card and booking-panel byte for byte and those of tour, home, home-hero-split and home-search-variants rule for rule (same rules, blocks and required classes; class numbering differs). activity, card, home and tour indexes rebuilt and match their .min files to the byte.

Verification
* php -l on every changed PHP file. single-activity.php rendered under E_ALL (notices fatal) with an MP4, a tall and a short panel, inside a Jannah-style #tie-container { overflow: hidden }, English and Arabic.
* Chrome at 1366 and 390: under the wrapper the short panel sticks at 24px; the tall panel scrolls and the summary holds the top of the side from the moment the panel leaves to the end of the column; on phones the summary is never drawn; the summary's Book returns to the form. The MP4 plays on press (playing, 16:9, poster from the hero, focused); YouTube still gets autoplay=1. The 2.28.1 checks re-run: no overflow, no script errors, hero square at every width.
* Live site (browser, not yet updated): the theme rule and the three ratio rules injected into the real pages and measured as above.
* Not run: Plugin Check / PHPCS, PHP 7.4, Polylang, the plugin itself on a live WordPress.

= 2.28.1 =
Activity page release (MED's brief "Upgrade Tripvance to v2.28.1 & Overhaul the Single Activity Page (Zero-Crop 1:1 Hero Images & Mobile Polish)", 7 Nov 2026), plus MED's request to show the activity's video in the right place. Nothing to tick and nothing stored: an activity saved by 2.28.0 renders with the changes below and no others. No new strings.

The video (MED's addition)
* What was wrong: an activity with a video URL printed the tour's poster button, after the gallery, and pressing it did nothing. activity.js had no handler for it (the tour's lives in tour.js, which this page does not load), and the poster's rules are in tour.css under .wptm-tour, which this page is not — so it was also unstyled.
* Where it is now: straight after the description (Overview), before the gallery, as a section of the page's own with a visible "Video" heading and the accent rule. The Section Manager lists it in the same place; a page whose layout an editor saved keeps its saved order.
* The player: the tour's own templates/sections/video.php (one oEmbed cache, one theme override), 16:9, the embed fetched only when pressed, then started at once (autoplay=1 after the press). Styled in activity.css under .wptm-act, so it is right with or without tour.css. The play button is centred by inset and auto margins; the tour sheet's 50% + translate centring is off-centre in Arabic.
* A "Video" control on the main photograph beside "View photos": scrolls to the section and starts the film. Printed only when the section will be (a video URL, a player from the host, not hidden).
* video.php takes two optional arguments, 'heading' => false (the activity heads the section itself, so the hidden h2 is not printed twice) and 'sizes'. Tour pages pass neither and print what they did.
* Found in testing: the video is now drawn before the banner (the banner has to know whether to offer it), and its poster is the hero photograph. Experience\Media\picture() wraps only the first copy of that photograph on a page in the mobile-hero <picture>, so the poster took the phone picture and the hero lost it. The hero image is drawn first, before the video, as it always was.

Where the build differs from the brief, and why
* Desktop hero: the 480px cap is on the frame as well as the image, and the frame stands at the end of its column. With the cap on the image alone, the frame stayed the column's width and the photo controls sat beside the photograph instead of on it.
* Mobile hero: a mobile hero chosen in the editor keeps its own shape (aspect-ratio: auto 1 / 1). The field's help says "Nothing is cropped"; the square is only the space held while it loads. A featured image is the brief's square exactly.
* Related cards on phones: two to a row (about 170px squares at 390px). One column of full-width squares made four cards four screens long, which is what the old 16:9 crop was preventing.
* Phone bar button: 46 → 48px, the brief's touch target. The bar's height is derived from it, so the room the page leaves under the reading follows. The safe-area padding the brief asks for was already there (--act-safe-b, env(safe-area-inset-bottom, 0px)).
* The bar's frosted values sit on the card itself rather than in an @supports block: a browser without backdrop-filter ignores that one line and still gets 0.92 white.
* Photo control: it was already a pill with a camera and logical insets. Polished: hairline edge, shadow, -webkit-backdrop-filter (Safari before 18 only knows the prefixed name and was getting the flat fallback), 44px on touch (it was 40, under the page's own target).
* Arabic: the brief asks for flawless RTL; the kicker, "From", the glance labels and the CTA were tracked in Arabic too. One rule at the end of activity.css sets letter-spacing: normal under :lang(ar) and [dir="rtl"], !important because the presentation variants in experience.css use heavier selectors (house rule: never track Arabic).

On every activity page
* Hero, 1000px and up: square, at most 480 × 480, in an even 1fr / 1fr banner, centred on the words. Was 4:3, up to 520px tall: an eighth cut from the top and the bottom of a square photograph.
* Hero, under 1000px: square, at most 360px, centred. The image's sizes attribute follows (360px / 480px instead of 720–860px), so phones and desktops download a smaller file for the LCP image.
* Related activity cards: 1:1 (were 4:3, and 16:9 on phones).
* Facts strip: duration, place and difficulty as pills (text-colour fill and edge, so the dark scheme needs no rule of its own). "From" on its own line above the amount. The offer fills its row, price at the start and button at the end; on phones a hairline separates it from the pills. CTA: a shadow on hover, a 1px press, touch-action: manipulation.
* Phone bar: frosted card (0.92 white, 14px blur, navy hairline and shadow, 16px corners).
* Print: the video section and the photo controls are left off the sheet.
* Assets: activity.min.css 47,117 → 51,215 bytes (8.9 → 9.6 KB gzipped), activity.min.js 6,478 → 7,174. activity.rules.php rebuilt (197 classes, 447 rules) and matches activity.min.css to the byte; the builder used reproduces the 2.28.0 index of this sheet, card and booking-panel byte for byte. Minified with esbuild 0.25.9, which reproduces the 2.28.0 activity.min.css and .min.js exactly. The .min files are newer than their sources.

Verification
* php -l on every changed PHP file (PHP 8.3).
* templates/single-activity.php rendered under E_ALL with every notice fatal, through a stub WordPress around the real template, the real video section, icons, labels (English and Arabic) and the real Section Manager: with and without video, gallery, price, related activities, a mobile hero, tour.css, and the video hidden in the Section Manager. Section order checked: lead, facts, glance, highlights, overview, video, gallery, included; no video section and no video control when hidden or absent; no duplicate "Video" heading.
* Chrome, at 320, 390, 768 and 1366 wide, English and Arabic, 2.28.0 beside 2.28.1, with test photographs carrying a coloured band on every edge: all four bands visible on the hero at every width (2.28.0 lost the top and bottom bands on the desktop hero); cards square; no horizontal overflow; no script errors; photo controls 44px on touch and on the photograph; the play button centred in both directions; "Video" scrolls to the section and loads the player with autoplay=1 at 16:9; pressing the 2.28.0 poster loads nothing (the fault, reproduced); the bar's computed corners, fill, blur, shadow and 48px button; letter-spacing normal in Arabic.
* Not run: a full WordPress install (not available in this session), Plugin Check / PHPCS, a PHP 7.4 binary, a real YouTube or Vimeo embed (a stand-in iframe), Polylang, the live site. Run Plugin Check before SVN.

= 2.28.0 =
Homepage visual polish, conversion and search-engine release (MED's brief "v2.28.0 — Homepage Visual Polish, UX Conversion & Google SEO/CWV Upgrade", 6 Nov 2026). Every new option is off until ticked. A homepage saved by 2.27.1 renders the same page, apart from the changes the brief makes for everyone, listed under "On every homepage" (the HTML check and the computed-style audit, under Verification, hold the two builds to exactly that list).

Where the build differs from the brief, and why
* Search engines (home_schema): off until ticked, with or without an SEO plugin. The brief also turned it on by itself on sites without one; house rule 1 (nothing new on a live page without a tick) and the 2.5.0 decision to leave site-wide structured data to the SEO plugin come first. The tick's description recommends it for a site with no SEO plugin.
* With Rank Math or Yoast the two nodes go into their graph (as the tour Product does), not into a second @graph beside it: two graphs on one page is the duplicate the structured-data file exists to prevent.
* The search keeps its field names (?destination=marrakech, not ?wptm_destination=): they are what the archive reads.
* An empty search opens the archive's own address (/tours/), without a bare "?".
* Staggered rise: an animation-delay, not a transition-delay. A transition on the cards would have replaced their own hover transitions for good; the animation runs once and leaves them as they were.
* Counts in the search menus are tours only. With Activities on (the default) destinations are shared with activities and WordPress's count took them in ("Marrakech (4)" for two tours and two activities); for such a taxonomy the aggregate counts the tours themselves, in two queries. A term without tours of its own (a parent destination) prints its name alone. The destination tiles' "N tours" line had the same fault and now reads the same count (it is left out at 0).
* Destination price: the destination and its sub-destinations, as its page lists them; a destination whose priced tours use two currencies prints none (there is no honest lowest of €95 and $155).
* Quick links under the search: the four destinations with the most tours, tour types filling the row when there are fewer. Never "popular": the plugin does not know what is popular.
* Logos: a library logo prints its own width and height (wp_get_attachment_image) and a definite CSS height from them; only an address from elsewhere gets width="140" height="44" and a box of that shape. Measured in Chrome: a logo with no definite height is 0 × 0 until it arrives, whatever its attributes say.
* The tiles' scrim: the brief's three-stop gradient is on every tile, and the words get a ground of their own (a 52 px fade on .wptm-home__tilecopy, growing upwards only) because the gradient alone leaves a 16:10 tour-type tile, or a name with its line, under 4.5:1 over a white photograph. The circle tiles' own shade is a little deeper where the name sits.
* Hero points (the reassurance points under the hero buttons), not in the brief's list of changes but in its contrast target: on a photograph hero (Classic Cover, Cinematic Journey, Panorama Slider) each label sits on a small painted dark ground (background and spread shadow, no padding, so nothing moves). 2.27.1 measured 1.7:1 over a bright slide.
* h3s: the classic Article Cards titles, the tile names and the activity names became h3s; the six other article layouts already printed h3s and keep their rules untouched. Their copy wrappers are divs now (a heading cannot sit in a span). The h3 reset is scoped to the three that changed, so in every layout the computed styles are 2.27.1's, apart from Arabic, where those three names lose the -0.01em tracking (house rule).
* The phone bar steps aside at the closing call to action, or at the last section when there is none, so it never covers the footer. It needs the plugin's template: a theme that ships its own tripvance-home.php prints it by calling Tripvance\Home\mobile_bar() before </main>.
* CSS split: per selector, not per line range. A selector moved only when no rule left behind in home.css (a phone size, the Arabic reset) overrides it, since the new files print after home.css: 234 of 307 candidate selectors moved, the other 73 stayed where they win as before. Search variants print before the hero file, the order their rules had.

On every homepage
* Search: straight to the tours archive (/tours/?destination=…) when it is known to answer there: pretty permalinks, a rewrite table with the archive's rules, and no page of its own at that address (the archive's own page, seo.php's archive_pages(), is not in the way). Otherwise the 2.27 form (home address + post_type=wptm_tour). Empty menus are not sent. Each menu item carries its number of tours.
* Headings: H1 (hero) → H2 (sections) → H3 (tiles, activities, articles).
* Destination and tour-type tiles: the three-stop scrim and the ground under the words; tour counts without activities.
* Logos in Trusted platforms and Trust keep their box while they load.
* Hero points on photograph heroes: a painted ground.
* Cards in sections below the first screen rise one after another (55 ms apart, the first six of each row); none under reduced motion or in print.
* Without an SEO plugin the homepage gets a meta description: the page's excerpt or text, else the hero's line (Luxury hero's while Luxury Editorial draws the page), in the language being read; none when there is nothing (nothing invented).

New options (Tours → Homepage), each off
* Hero → Phone photograph (hero_mobile): Classic Cover, and Cinematic Journey without a film. The hero background becomes <picture><source media="(max-width: 720px)" srcset="(library 'large' sizes)" width height><img …> with the desktop <img> exactly as before (front_size(), fetchpriority="high", eager, async). An address from elsewhere is its own srcset, with no invented size. Resolved in the aggregate.
* Hero → Tour search → Quick links (search_chips): glass pills under the form (light ones on Journey Spotlight, Oasis Mesh and Certified Local Guide), each its term's page.
* Explore destinations → Starting price (dest_show_price): "From €120" beside the count, the lowest PricingModel::startingPrice() of the published tours, formatted as the cards format it (format_decimal), computed once in the aggregate (all tours' terms in two queries, posts and meta primed in one pass), never per tile.
* Testimonials → Tour taken (tour_id per review): a link under the reviewer to the tour, translated (translated_post()), only while published; copied into the review viewer.
* Hero → Extras → Phone bar (home_mobile_bar): the hero's main button (else the Trip Builder) and WhatsApp, fixed to the foot of screens up to 760 px between the hero and the last section; padding for the safe area; away by visibility, so its links leave the keyboard order; no slide under reduced motion.
* Questions and answers → Search engines (home_schema): ItemList of the featured tours (position, name, url, in the printed order) and, while the section is on, FAQPage with the questions and answers as printed. Rank Math (rank_math/json_ld, 101) and Yoast (wpseo_schema_graph, 21): into their graph, the FAQPage left out when theirs has one; otherwise one graph of its own.

Performance (Core Web Vitals)
* home.css 202,984 → 184,610 bytes; home.min.css 144,531 → 128,326 bytes, 2.28.0's own rules included. Loaded only when in use (Tripvance\Home\optional_styles()): home-hero-split.min.css 4,032, home-hero-floating.min.css 8,104, home-hero-mesh.min.css 2,255, home-search-variants.min.css 10,056. Classic Cover with the Unified bar or Quick Search loads home.min.css alone.
* Rule indexes: home.rules.php recorded 144,060 bytes against a 144,531-byte home.min.css, so css-prune.php kept the link whenever wptm_inline_used_css was on; tour.rules.php and home-hero-slider.rules.php were out of step the same way. All three are rebuilt, the four new sheets have their own, and all 26 match their .min.css to the byte. The developer kit's build-css-rules.py was not in this build's sources; it was rewritten from css-prune.php and the 2.15.6 changelog and checked against the 19 indexes that were in step: the same rules, wrappers, blocks and required classes for every one.
* Nine .min files (cars, experience, sahara, admin-bring, booking-screen, home-custom-html, and the Editorial, Magazine Rail and Panorama Rows article layouts) were a second or two older than their sources on disk, so assets.php served the readable sources instead. Their contents were current; they are restamped, byte for byte the same.
* Rebuilt with esbuild 0.25.9 (--target=es5 for scripts): home.css, home.js and the four new sheets. Every other .min is 2.27.1's, byte for byte.

Strings
* home_search_chips ("Quick links"): EN, FR, ES, AR and the gettext switch. Every other label is an existing one (from, home_cta_whatsapp, home_tour_count…).

Verification
* Harness (outside the plugin) on WordPress 6.5.5 with SQLite, PHP 8.3, a classic test theme: t228.php, 173 PHP checks, with every notice, warning or deprecation from the plugin fatal (E_ALL) in the CLI and in every page the server rendered: defaults and the sanitiser, optional_styles() for eleven hero/search pairs, H1/H2/H3 in all eight article layouts, the search address (pretty and plain permalinks, a rewrite table without the archive, the archive's own page and the posts page at /tours/), menu counts with activities, the phone photograph (library, external, Cinematic Journey), quick links, prices (single-currency, mixed, on request, draft, parent destination), the review link (published, unknown, draft), the phone bar (button, WhatsApp only, neither), logo sizes, both schema nodes and their Rank Math and Yoast merges, the meta description, the strings, every rule index's size, no .min older than its source.
* htmlcheck.py: eleven configurations in English and Arabic rendered by 2.27.1 and 2.28.0 with the new options off; after the documented changes are applied to the 2.27.1 page, the two are identical (22 of 22).
* styleaudit.py: computed styles of every element (61 properties, its box, ::before and ::after) in 176 page states (eleven configurations, EN/AR, 1440/1024/782/390, with and without reduced motion), 2.27.1 against 2.28.0 at defaults: every difference is one listed above, none unexplained. The CSS split alone: 0 differences in 80 states.
* b228.py, 1,842 browser checks with the new options on, EN/AR at 1440/1024/782/390: no horizontal overflow; WCAG AA under the glyphs (lowest: tile names 6.98, quick links 4.82, hero points 5.93); the phone photograph chosen under 720 px; the phone bar (away over the hero, up after it, away at the closing call and the footer, focus only while shown, no slide under reduced motion, never on a wide screen); the search sending only what was chosen; Tab to the quick links with a visible outline, Enter to the term page; the review viewer's tour link and Escape; the staggered rise (0..5, 55 ms, hover transitions kept, nothing under reduced motion); used-CSS inlining drawing the same page with home.css inlined.
* Not run: a live WordPress site, Polylang, Rank Math and Yoast themselves (their filters were called with graphs of their shape), PHP 7.4 (the code avoids 8.x syntax), Plugin Check / PHPCS. Run Plugin Check before SVN.

= 2.27.1 =
Homepage and Trip Builder release (MED, 6 Nov 2026): a tailor-made banner for the homepage, the trust proof in the hero or as a strip of its own, arrows for the Travel guides, and the planner's newer features in Luxury Editorial and Experium. Everything new on the homepage is off until ticked; a homepage saved before this release renders as before.

Where the build differs from the request, and why
* Travel guides: the arrows are a new layout, Article Carousel, rather than arrows added to the seven layouts there are. Each of those lays its articles out for the page (a lead story and a rail, rows, a timeline); a row that scrolls sideways is a layout of its own. The button to all articles stays under the row. How many articles: up to 12 now (was 9), since a carousel is what more articles are for.
* Trust without repeating the licence: the number is printed once wherever it can come from. The hero's own licence line (Hero → Tourism licence) wins in the hero, so the chips leave the licence out when that line is shown. The Trust strip leaves it out whenever the page already shows a licence: the hero's line or chips, the foot of the Trust section (which repeats the hero's line), or a named Guide profile. The Guide profile itself is unchanged.
* Luxury and Experium: they take the features, not Studio's layout. Your trip (the column beside the form) and the icons on questions and answers are how Studio looks, and both dresses already have their own way of showing progress (the numbered rail) and the answers (the summary on the last step), so those stay Studio's. Everything else is in both: the dates' span, the months for flexible dates, a one-answer card moving on when picked with a finger or a mouse, answers in two lines, who handles the request on the last step, the line naming the tour the planner was opened from, and the receipt's copy button and WhatsApp link.
* The tour page's own call to action keeps its Luxury and Experium drawing; the homepage banner is the new one.

Homepage: tailor-made banner (includes/home/tailor-banner.php, assets/css/home-tailor-banner.css)
* Section tailor_banner, off, order 35 (under the featured tours), through the homepage's hooks like the Guide profile. Prints nothing without a published Trip Builder page (Tailor\page_url(), in the language on screen); the panel says so with a link to the Trip Builder screen.
* Words: small heading, title, line, button; each empty one falls back to the Trip Builder's own sentence (Tailor\word(): tmr_eyebrow, tmr_cta_title, tmr_cta_text, tmr_cta_button), so a rewritten sentence on the Trip Builder screen is the banner's too, in every language. All four are on the language tabs.
* The three promises (tmr_assure_1..3, an empty one left out), on by default with a tick to hide them. Optional photograph (gp_picture(): the library's own markup and srcset) filling the other half; without one, a round seal with a slowly turning dashed ring (no motion under reduced motion). Tone: dark (the page's ink with an accent glow and a faint dotted field) or light (the paper colour).
* One column under 760px, the photograph first; the button full width under 480px. Logical properties, the glow and the dotted field mirrored in Arabic; no tracking or capitals in Arabic.

Homepage: trust in the hero and as a strip (includes/home/proof-strip.php, assets/css/home-proof.css)
* Hero → Trust in the hero (hero_proof, off): small chips after the licence line in every hero design (hero_copy, hero_slide_copy, hero_guide_copy): years of experience (Guide profile), the scores (Settings → Trust, with their logos), the social following and the licence, each a link when it has an address (nofollow, new tab). Drawn like each design's licence line: dark glass on photographs and colour, light on Split, Mesh and Certified Local Guide.
* Section proof_strip (Trust strip, off, order 12: under the hero), placeable anywhere in the page order, needs nothing from the Guide profile: one card divided by hairlines, the figures counting up once on screen (the Guide profile's counters in home.js, ending on the server's own text; none under reduced motion), two to a row on a phone with the licence on a row of its own. Optional small heading (language tabs); its aria-label otherwise "Why travel with us".
* Home\proof_entries(): one list for both, built from Proof\items( true ); nothing is printed without proof.

Homepage: Article Carousel (Travel guides)
* guides_styles() 'carousel' → assets/css/home-guides-carousel.css, loaded only with that layout. Picker card (preview carousel4) and its miniature. The shared media frame in home.css now names the carousel too.
* Three cards on a wide screen, 2.2 on a tablet, 1.15 on a phone (the next one shows at the edge), scroll snapping, a 6px scrollbar in the site colour. Two round arrows at the row's edges, level with the middle of the pictures (container units), printed hidden and shown by home.js only when the row is wider than its box; each moves the row by a page, is disabled at its end and reads the position as a distance from the start, so Arabic moves the right way. Keyboard: they are buttons, with aria-controls and labels.
* Strings: home_guides_prev, home_guides_next (EN/FR/ES/AR and the gettext switch).

Trip Builder, Luxury Editorial and Experium (templates/tailor/builder.php, assets/js/trip-builder.js, assets/css/trip-builder.css, includes/tailor/frontend.php)
* render() passes the guide (when "Who handles requests" is ticked) and the WhatsApp number to every dress; the template prints the copy button, the tour line and the split labels in every dress; the script's span, months and auto-advance no longer check for Studio (this.studio is gone). The WhatsApp message takes each line's label from its step (data-label), which is what Your trip printed, so it is the same in Studio and needs no Your trip elsewhere.
* Styles under .wptm-tmr:is(.wptm-tmr--lux, .wptm-tmr--experium), from each dress's tokens: Luxury square with small wide-set capitals, the guide's photograph framed like a print with an accent rule, the tour line in the italic serif; Experium with forest green, sand frames and a round photograph with a sand ring. The tour line steps back with the promises after the first question. Arabic: no tracking, no capitals, no Latin serif.
* Studio is unchanged: its markup differs from 2.27.0 only in indentation, and its pages are pixel-identical (four states, EN and AR, 1440 and 390).
* Settings → Trip Builder → Who handles requests: the description no longer says Luxury and Experium are left out.

Verification
* Harness (outside the plugin): t2271.php, 173 PHP checks under E_ALL with every notice fatal (defaults off, sanitiser, banner words in English and Arabic and the planner's rewritten promise, the licence once in thirteen combinations of hero line, chips, Trust section (with and without items) and Guide profile, the strip's counters, the carousel's markup, the panels, the strings, Luxury and Experium planner pages with and without guide and number); the 2.27.0 suite (185) unchanged.
* b2271.py, 772 browser checks on the source and on the minified files: four homepage variants in English and Arabic at 1440, 1024, 782 and 390 (overflow, banner layout and contrast, chips, licence once, strip counting and reduced motion, carousel arrows, ends, direction, keyboard, reduced motion), Luxury and Experium planner (tour line, span, months, pointer and keyboard picks, two-line answers, guide card, receipt), Studio pixel comparison with 2.27.0. The 2.27.0 browser suite: 239/239 (its Luxury check now expects the tour line). Settings layout check: all passed.
* Not run: a live WordPress site and Plugin Check / PHPCS (not available in this sandbox). Run Plugin Check before SVN.

= 2.27.0 =
Tailor-made workflow release (MED's brief "v2.26.0 update and settings fix", 6 Nov 2026) and the Ratings & licence settings fix. Numbered 2.27.0: 2.26.0 was already built that morning (Trip Builder Studio, second pass) and a number that may be on SVN is never reused. Everything new is off or empty until used: a request with no tour and no session, a proposal with no tiers or add-ons and a site with "Formatted e-mails" unticked read, store and send what 2.26.0 did (compared below).

Where the build differs from the brief, and why
* Settings → Ratings & licence: the row now has its heading above it and the table spans the card. Beside the 220px label column, at 1440px, five columns left the page-address field about 60px wide. The table's wrapper is a size container and the cards start under 47rem of its own room (not @container 860px / @media 1100px on the screen): what decides the layout is the cell, not the window, whether the admin menu is open or folded. The fields drop WordPress's .regular-text/.small-text for their own classes instead of overriding them.
* "Customise this tour": the tour id rides in the fragment 2.26.0 introduced (#tmr-tour=42&tmr-days=8&tmr-dest=…), not ?from_tour, so the planner keeps one address for caches and search engines. The line on the opening reads "Planning from the tour you were viewing" rather than the tour's title: a title read from a fragment would let any link print any words on the agency's page, and the server never sees the fragment. The days and places were pre-filled by 2.26.0 already.
* Draft in sessionStorage: shipped in 2.26.0; it now keeps the tour id too, so a refresh keeps the link to the tour.
* Comfort tiers are alternatives to the offer: the offer is the first choice (it can be named), each tier has its own price and deposit in the proposal's basis. The PDF and the proposal e-mail still state the offer itself; the page shows the choices.
* Insights "single batched query": one query for the requests and one priming the meta cache for all of them.
* HTML e-mails: the traveller's three messages only (copy of the request, proposal, reminder); the agency's stay plain text.

Settings → Trust → Ratings & licence (includes/admin/settings-screen.php, assets/css/settings-admin.css)
* Root cause: .wptm-set-card .form-table th/td were descendant rules, so the table nested in the row took 220px headings and the row padding; WordPress's own .form-table rules (200px th, block cells under 782px, 4.375em .small-text) did the same. The plugin's rules now reach the settings table's own rows only (> tbody > tr >); inside .wptm-proof-fields WordPress's are undone.
* Columns: platform 6.5rem, score 7.5rem (4.75rem field and its "/ 5"), count 7.5rem (7–8rem field: "Followers" whole at 13px and 16px), address fluid, logo 1% nowrap. Every field width 100%, min 0. Licence number and title in .wptm-proof-licence-pair (grid, auto-fit, minmax(min(100%, 10rem), 1fr)).
* Under 47rem: no column headings; each row a two-column grid card — name, score and count side by side (bottom-aligned), address, logo (flex) — every field named by data-label (existing strings). Gridinsoft's "—" count cell is hidden there.
* New: .wptm-set-wide rows (heading on its own row, control across both columns).

The tour a request started from (includes/tailor/frontend.php, submit.php, requests.php, admin.php, proposal-admin.php, templates/tailor/builder.php, assets/js/trip-builder.js)
* prefill_fragment() starts with tmr-tour={id}. The script's prefill() reads it, setTour() fills the hidden wptm_tmr_from_tour and shows .wptm-tmr__fromtour (Studio only; the other dresses get the field, nothing visible). The draft stores it as "tour".
* posted_tour(): absint, kept only for a published tour (is_tour_post); stored as _wptm_tmr_from_tour (META_FROM_TOUR), only when there is one. get() returns from_tour.
* source_tour(): the tour while it is not trashed. brief(): "Started from the tour {title}" linked to the tour. suggested(): a request with no length takes the tour's wptm_duration_days (≤ 90, a day card each), no places → the tour's title.

Information sessions (includes/tailor/submit.php, requests.php, admin.php, includes/operations/board.php)
* Resolves the ponytail note in validate(): session_fields() returns day, time (H:i) and mode; create() stores _wptm_tmr_session_day/_time/_mode (META_SESSION_*) beside the message's first line, which stays as it was for both e-mails. get() returns session_day/time/mode; session_start() reads them as a DateTimeImmutable in the site's time zone and refuses a day that does not exist (2026-02-30 is not rolled over).
* brief(): an Information session band (date · time, mode · time zone, "This session has passed" once past) with "Download .ics calendar invite": admin-post.php?action=wptm_tmr_session_ics, nonce per request, can_manage(). session_ics(): one VEVENT in UTC, 30 minutes (filter wptm_tmr_session_minutes, 5–480), name, e-mail, telephone and the request's screen in DESCRIPTION, RFC 5545 escaping, lines folded at 75 octets without splitting a UTF-8 character, CRLF.
* Operations: sessions_between() (one ranged DATE meta query, closed requests left out, time order); an "Information sessions" card in the rail and a check "Information sessions nobody has confirmed" (New or In Review), both only on a site in session mode or with sessions in the week. session_mode_label() lives in requests.php so the board never depends on an admin file.

Proposal tiers and add-ons (includes/tailor/proposal.php, proposal-admin.php, proposal-i18n.php, templates/tailor/proposal.php, assets/js/tailor-proposal.js, assets/css/tailor-proposal.css, assets/css/tailor-proposal-admin.css)
* blank(): offer_label, tiers (MAX_TIERS 3: label, note, price, deposit), addons (MAX_ADDONS 6: id, title, price, basis person|group). clean_tiers()/clean_addons() type and bound them on save and on read (no name or no price: dropped; deposit ≤ price; add-on ids unique keys, a new or copied row gets one). The revision hash ignores the three keys while they are empty, so a proposal sent before reads as the same version.
* Builder: "Choices for the traveller (optional)" under Included / Not included, a <details> with fixed rows (three tiers, six add-ons): no script needed, an empty row is not saved.
* offers(), has_choices(), choice(): the offer and its tiers; the total is the chosen offer for the party (× adults (≥1) + children when per person, as agreed_total() books it) plus each add-on ticked (× the party when per person). Unknown tier → the offer; unknown add-ons dropped.
* The traveller's page: tier cards (radios) and add-on boxes in #tvp-offer, tied to the accept form by form="tvp-accept", so they post without the script; :has(input:checked) marks them, .is-on for older browsers. "Your total" in the price box, the dock and the confirmation dialog. The script keeps the headline, the bar, the deposit, the regular price (the offer's own: hidden for a tier) and the total in step; the total is written from three amounts the server formatted (the separators, cents, and whether four-figure amounts are grouped), so it reads as the server's prices in every language. After a yes the choices are shown as chosen, disabled.
* accept( $id, $lang, $tier, $addons ): with choices, META_DECIDED adds tier, label, addons (with each line's total), party and total, and price/deposit/regular are the chosen offer's. The agency's e-mail lists the option, the add-ons and the agreed total. prepare_booking(): the booking's agreed price is that total; the note lists option, add-ons and total; the tour's inclusions gain the tier and the add-ons. The banner on the request shows the choice.

Insights (includes/admin/analytics.php, assets/css/dashboard.css)
* tailor_panels() under the booking funnel, for the period chosen: Requests received → Proposals sent (sent, changes or accepted) → Proposals accepted → Booked (a live booking), each rate of the step before; "What travellers ask for": the eight most requested places and the budgets chosen, as bars with their share of the period's requests. Left out on a site with no Trip Builder page and no request.

Formatted e-mails (includes/tailor/mail.php, options.php, settings-screen.php, proposal.php, proposal-reminders.php)
* Setting html_mail, '' by default, in Tailor-Made Requests → Settings → Emails. html_wanted() (filter wptm_tmr_html_mail): customer, proposal and proposal_reminder only.
* html_body(): from the same lines — table layout, inline styles, 600px card, the accent colour, the logo (Guest Pass logo, else the site logo; not an SVG, which mail programs do not draw; on a dark band when it is a light logo), else the site name; "Reference: TMR-…" becomes a badge, "- Label: value" lines a table, an address alone on its line a button named by the line before it (the address under it). dir and text-align from the message's language (each builder now passes lang), else from Arabic or Hebrew letters. Everything escaped.
* deliver(): after wptm_tmr_pre_send (a template system still gets the plain data), the Content-Type is replaced, and the plain text goes out as AltBody through a phpmailer_init hook removed after the send.

Strings
* Trip Builder: tmr_from_tour. Proposal: prop_tier_main, prop_tiers_title, prop_addons_title, prop_addon_person, prop_addon_group (translators comments), prop_your_total, prop_total_for (translators comment). English, French, Spanish, Arabic, and the gettext switch. Admin strings through __().

Build
* .min files rebuilt with esbuild 0.25.9; the two scripts with --target=es5 (2.26.0's trip-builder.min.js carried one template literal that esbuild had written for "\n").

Verification
* Harness (kept outside the plugin, tripvance-2.27.0-test-harness.zip): an in-memory WordPress with every notice fatal and the real tailor, proposal, operations, Insights, i18n, brand, token and pricing code. t227.php: 185 checks (the strings in four languages, the source tour, sessions and the .ics file, the Operations board, tiers and add-ons from sanitising to the booking, Insights counts and its two queries, HTML e-mails on and off, the setting).
* Against 2.26.0, the same requests drawn by both builds: the proposal page without choices (sent, accepted, Arabic) identical but for its random key, and the same revision hash; the brief without a tour or a session, the Operations screen without sessions and both e-mails with the setting off identical; the Trip Builder in Studio, Luxury, Experium, Arabic and session mode differs only by the hidden field (and in Studio the hidden line).
* Chromium: Ratings & licence at 1440/1024/782/390, LTR and RTL, filled and empty, source and minified (16 layouts each): no overflow of the page or the card, every field inside it, no value or "Followers" clipped. browser227.py, source and minified (239 checks each): every total equals what the server prints for the same choice in EN/FR/ES/AR, cents included; the choices post with the accept form; the deposit and regular price follow the tier; the dialog and the dock; an accepted proposal locked; a page without choices unchanged and error-free; the Trip Builder from a tour (field, line, fragment removed, kept through a refresh) in Studio EN/AR and Luxury; no overflow at 1440 and 390; screenshots of the brief, the builder, Insights, Operations and the e-mails.
* Not run: a live WordPress site and Plugin Check / PHPCS (WordPress core cannot run in this sandbox). Run Plugin Check before SVN.

= 2.26.0 =
Trip Builder, second Studio pass (MED's eight-point brief, 6 Nov 2026). Luxury Editorial and Experium are untouched: their planner pages are pixel-compared with 2.25.0, and their tour call to action prints what 2.25.0 printed but for the link's fragment.

Where the build differs from the brief, and why
* Pre-fill from a tour: the brief asked for ?from_tour={id} read on the server. The link carries a fragment instead (#tmr-days=8&tmr-dest=d12,d15), built on the tour page by Tailor\prefill_fragment() and read by the planner's script. A fragment never reaches the server, so the planner keeps one address for page caches and search engines; a query string would have made one crawlable copy of the planner per tour. The script takes the fragment off the address bar once read.
* Flexible dates: the brief allowed the arrival to be left empty when "flexible" is on. Kept required where the owner made the step required: a month chip always gives a date (its first day, or today for this month), so a traveller who only knows "October" can go on, and the owner's "required" still means something. The flexible switch stays on, so the request reads as a month.
* Auto-advance, the dates' span, the months, the split labels and the receipt buttons are Studio only, like the rest of Studio. The draft works in every dress (nothing to see until a refresh).

Studio (templates/tailor/builder.php, assets/css/trip-builder.css)
* Opening: a 56px star lattice behind it (inline SVG, ink at 7.5%), faded by a radial mask. Under way, the eyebrow, lead and promises fold with max-block-size/opacity/margin over .45s and become hidden for reading at the end (visibility, delayed); the title shrinks with it. Reduced motion: no transition (the base rule).
* label_parts() (frontend.php): title, separator as written, rest; the template prints the separator in .wptm-tmr__sr between the title and the line under it, so the card's text — read by the summary, Your trip and WhatsApp — is the label letter for letter. ASCII and full-width colons; no split when nothing follows the colon.
* A place without a picture: the pin icon on a light accent-to-cool postcard; its name in ink without the dark foot.
* Sticky bar at 640px and below: sticky to the foot, 92% white with a 10px blur, safe-area inset.
* Route: the destinations' order of picking is kept in the script (this.route) and drawn as chips joined by →/← in Your trip; the form still sends them as checkboxes.
* Receipt: a copy button in the ticket (shown only when navigator.clipboard.writeText exists: https), "Copied" for 2.2s, aria-live; a WhatsApp link to wa.me/{settings number}, drawn by the server with the reference for the page returned to without scripts, rewritten by the script with the reference and the dates, length, places and travellers lines.
* Fixed (from 2.25.0): the Studio chip background overrode the pressed state, so a chosen duration chip read white on white; the background now applies to chips not pressed.

Script (assets/js/trip-builder.js, plain ES5)
* initAuto(): steps with data-multi="0". A change counts as a pick when a mousedown/touchstart on the step came within 1.5s and the event is trusted; arrow keys (which also change a radio) and restored drafts never move on. 260ms pulse (.is-picked), then advance(), which still validates.
* initSpan(): days = span + 1, nights = days - 1, from the existing day strings and two new night strings; hidden when a date is missing or the order is wrong.
* initMonths(): six chips from this month, names from toLocaleDateString in the page's language, first letter capitalised; a chip earlier than today's min is today; a departure before the new arrival is cleared.
* Draft: sessionStorage key wptm_tmr_state_{path}_{lang}; the step fields only (never hidden fields, never the consent), the step, the furthest step reached, the route and whether the length was typed. Restored before the first step is drawn, then the tour fragment applied on top; thrown away when the list of steps changed; erased in showDone(). Storage errors are ignored.

Tour call to action (includes/tailor/frontend.php, assets/css/tailor-cta.css)
* cta_markup(): Luxury (modifier) and Experium keep 2.25.0's markup; Studio adds .wptm-tmr-cta--studio, the emblem (guide's photograph through guide(), else the compass), the three promises from word( 'tmr_assure_1..3' ) as seals, a raised card; the button lifts on hover (no transition with reduced motion).
* prefill_fragment(): wptm_duration_days 1–90; the tour's wptm_destination terms and their ancestors, in the default language (pll_get_term), matched to the planner's active destination answers by their term.

Strings
* tmr_night_one, tmr_nights_n (translators comment), tmr_months, tmr_copy, tmr_copied, tmr_wa_button, tmr_wa_text (translators comment): EN/FR/ES/AR (French narrow no-break spaces) and the gettext switch; script_config() passes nightOne, nightsN, months, copy, copied, waText. New icon: copy.

Verification
* Harness: 116 renders under E_ALL (4 languages × 3 dresses × request/session × form/sent, guide cases, receipt and CTA per dress), 237 PHP checks (strings, label split, fragment, setting), the 2.25.0 browser suite (46/50 checks) and a new 2.26.0 suite (43 checks: span, months, auto-advance by pointer only, route order, draft restore/erase, pre-fill, copy, WhatsApp text, sticky bar, reduced motion, Luxury scope) on the source and on the minified files; Luxury/Experium pixel comparison with 2.25.0.
* Not run: a live WordPress site and Plugin Check / PHPCS.

= 2.25.0 =
Trip Builder, the visitor's side (MED: "elegant, encouraging and professional", after four dashboard references: soft canvases, white cards, a progress ring, icon tiles, a boarding pass). A new dress, Studio, for every site without a dress of its own; Luxury Editorial and Experium are untouched.

Studio (templates/tailor/builder.php, assets/css/trip-builder.css)
* $wptm_tmr_studio = neither lux nor exp, read in the template; the root takes .wptm-tmr--studio. Everything Studio adds is printed only then (the workspace, Your trip, the question and answer icons, the time chip), and every rule is under .wptm-tmr.wptm-tmr--studio, one class above the base rules, so the other two dresses render exactly as before. Checked by comparing screenshots of both, before and after, in French and Arabic at 1440 and 390, eight states each: identical except the arrow below.
* Removed after review (MED): the "About 3 minutes" chip in the opening.
* Layout: the root is a canvas (32px corners, accent and cool blooms on #f3f5fa); the opening is centred on it; .wptm-tmr__work holds Your trip and the form card. At 1024px and wider they sit side by side (Your trip sticky, 5.5rem from the top), and the row of circles and the step name above the bar are hidden, since Your trip lists the steps. Below 1024px Your trip folds above the form. Without the script there is no Your trip and the form keeps its 880px reading width.
* Sentence case in Studio: no tracked capitals on the eyebrow, the step count, badges, group titles, the summary, the reference or "What happens next" — every language sets the same way, Arabic first.
* Answers: text cards take an icon tile (option_icon()); places with a picture are square postcards with the name on a dark foot and the mark beside it; two to a row on phones. Counts sit in tiles. The keyboard ring wins over a chosen card's frame.
* Received: the tick pops once (wptm-tmr-pop), the reference is a stub with a notch in each side, "What happens next" is numbered and joined by a line.
* Session mode keeps "How it works" between the opening and the form, with room below it.
* The last step (after review, from MED's live page, where name, e-mail and phone sat three to a row and "(Optional)" fell under "Country of residence"): two fields to a row (one on phones), the group headings as a word and a rule, "(optional)" kept on its label's line. The four contact fields carry their icon (user, mail, phone, globe) as a background drawn from icons.php's own paths, so no markup changed for the other dresses; at the start of the field in either direction.

Who handles requests (includes/tailor/frontend.php, options.php, settings-screen.php, templates/tailor/builder.php)
* MED: make the traveller feel the request is handled by a licensed guide (on his site, Mouhssine ELIOUJ, licence GVCT Réf. 2898). Nothing of his is in the plugin: Tailor\guide() reads the name, line and photograph of Homepage → Guide profile (Home\get, the photograph through Home\gp_picture at thumbnail size, alt "" beside the printed name) and the licence of Settings → Trust (Proof\items(): title, number, verify link). No guide name: the site's name beside the licence; neither: nothing.
* Setting show_guide, off by default (a name on the form is a promise only the owner can make), in Wizard steps beside "Page content", inside the form; a save from code keeps it. render() passes guide only in Studio and only when ticked.
* Drawn twice from one closure: in full at the head of the last step (photograph with a tick when there is a licence, "Your request is handled personally by", name, line, the licence as a seal with Verify, opening apart, rel nofollow), and smaller at the foot of Your trip (no line, no link). On the last step the small one is hidden, the full one is beside it; once sent it returns. Name and line dir="auto"; the number in <bdi>, unbroken.

Your trip (templates/tailor/builder.php, assets/js/trip-builder.js)
* An <aside> drawn hidden around a <details>: open beside the form, folded above it (the native disclosure; the script sets open from a (min-width: 1024px) query, reopens it if folded on a wide screen, and takes its heading out of the tab order there). Opening a step from it folds it again on a phone.
* Its rows carry data-wptm-tmr-railitem and data-wptm-tmr-goto like the row of circles, so go() marks both lists. go() now reads each row's step from its goto index instead of its position, and revealRail() looks only inside the row of circles.
* updateTrip() on every input, change and step: a step counts once reached and answered; the ring is answered / steps (an @property number, so it eases where supported); the mood line reads "Let’s begin", "Taking shape" (under half), "Almost there", and "Ready to send" on the last step. A reached step with no answer says "Not specified", quietly; the current step says nothing until answered. Lists show a chip per answer and up to three of the chosen pictures (their currentSrc, textContent only). The contact row shows the name.
* showDone(): every row done and closed, the mood line becomes "Your reference · TMR-…", the "nothing is sent before the last step" note leaves.
* The in-step summary of the last step is hidden beside Your trip (it is the same list) and kept below 1024px, where Your trip is folded.

Icons (includes/tailor/frontend.php)
* step_icon( $key, $session ): calendar, hourglass, spark, pin, compass, bed, users, wallet, mail (chat in session mode).
* option_icon( $list, $id ): the shipped answers each map to an existing icon; budgets b1–b4 print four bars with as many lit; any other answer takes its list's icon. All from includes/icons.php; nothing new is loaded.

Strings (includes/tailor/i18n.php, includes/tailor/frontend.php)
* tmr_guide_by, tmr_trip_title, tmr_trip_answered (translators comment), tmr_trip_private, tmr_mood_0 … tmr_mood_3: English, French (narrow no-break space before the colon), Spanish, Arabic, and the gettext switch. script_config() passes answered, mood0–3 and refLabel.

Fix
* The summary's range of dates is joined with "←" when the planner reads right to left; "→" pointed back at the first date in Arabic. All dresses.

Verification
* Harness (kept outside the plugin): an in-memory WordPress layer with every notice fatal, the real tailor, i18n, icons and proof code. 72 renders (4 languages × 3 dresses × request/session × form/sent, and the guide cases: full, licence only, name only, unticked, other dresses, receipt), 140 PHP checks of the strings, icons and the setting, 46 browser checks (50 with the guide) on the source and on the minified files (desktop, phone, Arabic), screenshots at 1440 and 390 in four languages, the no-script form and receipt.
* Not run: a live WordPress site and Plugin Check / PHPCS (WordPress core cannot be executed in this sandbox). Run Plugin Check before SVN.

= 2.24.0 =
Tailor-Made Proposals, conversion and workflow (MED, from a four-part brief): the proposal as a PDF, the versions sent kept and restorable, a library of written days, a reminder before an offer expires, and the deposit or Guest Pass offered at the moment of the yes. The agency-controlled, no-gateway model is unchanged.

Proposal PDF (includes/tailor/proposal-pdf.php, new; includes/tailor/proposal.php; includes/documents/pdf.php; templates/tailor/proposal.php)
* The route answers ?pdf=1 on the proposal's own address after current() has passed: the traveller's key (verify()) or staff who can manage the request (preview, no key). A wrong key is the same 404 as before. A proposal with nothing in it shows its page. The flag is read from $_GET (wants_pdf()); a public query variable named "pdf" would collide with other plugins.
* pdf_bytes(): view() inside Tailor\in_locale( language() ), so the PDF says exactly what the page says, in the traveller's language. pdf_html() lays it out with the documents' helpers (html_letterhead, heading_line, keep, eyebrow, html_lists, xt, xp, xl, local_image, image_tag) and palette: letterhead with the company and logo, Reference and date of issue, the title, "Designed exclusively for …", the cover, the facts (route, dates, duration, travellers, transport, accommodation) three to a row, the note, the highlights, the days (label and date in the margin; title; from → to · transport · duration; text; overnight · meals), the offer (label, regular price struck with the saving, price, basis, saving, deposit, valid until), included / not included, licence, ratings, years, travellers and promises, contact, and the private line. Empty sections are left out.
* render_pdf() gained a "proposal" type that takes the prepared page and its title, so the page setup (fonts, right to left, margins, running header, page numbers, footer, temporary images) is the documents' own, not a copy.
* Right to left: a route or a date range is one left-to-right island with →. The page isolates each place with <bdi>; the engine has no isolates, and two Latin places around ← joined into one run that read backwards ("Fès to Marrakech"). A Latin name in an Arabic sentence is embedded so its words keep their order.
* download_pdf() streams it through Documents\send_pdf() as Proposal-TMR-….pdf after Quotes\private_headers(); a server that cannot draw it (no cURL, library missing) logs the reason and shows the page, which has Print.
* pdf_url( $id, $token = '' ) and view()['pdf_url']; "Download PDF" (prop_download_pdf, four languages) under the offer and beside Print, and in the side panel next to "Preview the traveller's page". A download icon joins icons.php.

Sent versions (includes/tailor/proposal.php, proposal-admin.php, privacy.php, admin.php)
* META_REVISIONS (_wptm_tmr_proposal_revisions), MAX_REVISIONS 15. record_revision( $id, $event ) on mark_sent() (email, whatsapp), accept() (accepted) and reopen (reopened): at, user, event, events, hash, title, price, regular, deposit, currency, basis, valid, days, data. A version identical to the last one is not stored again: its events grow instead ("E-mail · WhatsApp · Accepted" on one line), so fifteen lines are fifteen different proposals.
* "Sent versions (N)" in the side panel, newest first: date, title, events, days, price; "As in the builder" on the version matching the builder; otherwise Restore (wptm_tp_do = restore_rev_{index}), while not accepted. Restoring writes that version to _wptm_tmr_proposal instead of the posted form, logs proposal_restored and says that the traveller sees the last version sent until it is saved or sent again.
* Tailor\anonymize() deletes the versions. History labels for proposal_restored and proposal_reminded.

Day library (includes/tailor/proposal-admin.php, assets/js/tailor-proposal-admin.js, assets/css/tailor-proposal-admin.css)
* Option wptm_tmr_day_snippets (not autoloaded), MAX_SNIPPETS 60, newest first; the same day saved again moves to the top. Each day is cleaned by clean_day() — the builder's own rules, now one function that sanitize() uses too — plus an id and its language.
* AJAX wp_ajax_wptm_tp_snippet_save and wp_ajax_wptm_tp_snippet_delete, nonce wptm_tp_snippet and Tailor\can_manage() of the request; 403 otherwise, 400 for an empty day.
* "Save to library" in each day's tools, and "Insert saved day (N)" beside "Add a day": a panel listing title, route and thumbnail, inserting a filled day (title, from, to, mode, duration, text, overnight, meals, photograph) renumbered like any other, each with Delete. Drawn by the script from localised data (textContent only); hidden without it and on an accepted proposal.

Expiry reminder (includes/tailor/proposal-reminders.php, new; options.php; settings-screen.php; bootstrap.php)
* Settings → Emails: proposal_reminders (off by default) and proposal_reminder_days (1–7, default 2).
* EVENT wptm_send_proposal_reminders, daily at Booking\Reminders\next_morning() (9:00 site time) while on, cleared while off and on deactivation (bootstrap's list).
* due( $today ): proposals in sent or changes (the 200 most recent), whose valid date is today + N, not already reminded for that date (REMINDED_KEY _wptm_tmr_proposal_reminded holds the date reminded), with a valid e-mail. run() writes the date before sending, so a second run or a refused mail never sends twice; an extended offer may be reminded once for its new date. Sent through Tailor\deliver() in the traveller's language (prop_remind_sub, prop_remind_lead, the title, route, dates, price and private link), filter wptm_tmr_proposal_reminder_message; logged as proposal_reminded (failed => 1 when refused). A server that cannot recover the key has no link to send and sends nothing.

Deposit hand-off (includes/tailor/proposal.php, proposal-admin.php, templates/tailor/proposal.php, assets/css/tailor-proposal.css)
* blank(): payment_url ('') and auto_book (0). sanitize(): esc_url_raw() limited to https, at most 500 characters; a checkbox. The offer card shows both.
* accept(): with auto_book, prepare_booking( $id, true ) — the new $system flag skips the check that the current user may create tours (the traveller is not logged in; the proposal was authorised by staff and the key and nonce were checked). A failure is logged and the acceptance stands; Operations lists accepted proposals without a booking.
* booking_of() moved to proposal.php: the traveller's page reads it and the admin files are not loaded there.
* view() for an accepted proposal: deposit_due (the deposit agreed), payment_url (the proposal's, else Payments wetravel_url when https), pass_url (GuestPass\url() of the booking). The thank-you shows "Open your Guest Pass" when the pass exists; otherwise, with a deposit or a link, "Deposit & next step" with the amount, "Pay deposit online" (new tab, noopener noreferrer) and WhatsApp (shown once). With neither, the thank-you is as before.
* New traveller strings in English, French, Spanish and Arabic: prop_download_pdf, prop_open_pass, prop_pass_text, prop_pay_deposit, prop_deposit_title, prop_deposit_text, prop_remind_sub, prop_remind_lead.

Verified
* Harness tailor24.php (new, E_ALL, notices fatal): 87 checks. PDF: the route in separate processes — traveller key (fr) and Arabic stream %PDF-, a wrong key 404s, staff without a key get the PDF, an empty proposal shows its page; the French text read back with pdftotext (title, days, highlights, lists, licence, rating, route, prices, French labels) and the cover embedded; pdf_url with and without key; two buttons on the page and one in the side panel. Versions: kept on send, merged when identical, a new one when changed, accepted and reopened recorded, Restore ignoring the posted form, refused while accepted, the list and its buttons, 15 at most, erased by anonymize(). Library: cleaning, refusing an empty day, dropping a non-image, dedupe to the top, script data, 60 at most, delete, the AJAX doors (nonce, permission, save, 400, delete), the builder hooks, sanitize() through clean_day(). Reminder: off by default, scheduling on and off, due() picking exactly sent/changes dated today + 2 with an e-mail, two sent in French and Arabic with the link, stamped, never twice, once more after an extension, a refused mail stamped and flagged, the filter, days out of range, the deactivation list, settings clamped. Hand-off: https only, http/javascript/long refused, auto_book booking by the system and refused to the same user without it, pass_url and deposit_due, the Guest Pass button, the deposit box with the link, WhatsApp once, the WeTravel fallback (https only), nothing for an open proposal, the plain thank-you, the builder fields.
* ops.php 54 and proposal.php 33 (2.23.4) pass unchanged on this code.
* PDF pages rasterised and read, French and Arabic: layout, right-to-left order of routes, dates and names.
* Chromium: the proposal page (offer; deposit fr and ar; Guest Pass fr and ar) at 1280 and 390 px — no horizontal overflow, no clipped button, no script error; the builder's library driven with a stubbed admin-ajax: shown, two items listed, insert fills and renumbers a day, Save posts the day with nonce and request and marks the button, Delete refreshes the list.
* Minified copies rebuilt with esbuild 0.25.9 (tailor-proposal, tailor-proposal-admin CSS and JS, operations). php -l on every PHP file.
* Not run: PHPCS / Plugin Check (the tools cannot be fetched or run here) and a live WordPress; escaping, nonces, prepared SQL and translators comments checked by reading.

= 2.23.4 =
Seven gaps closed in Tailor-Made Proposals and Operations (MED, from a review of 2.23.3): a per-person price booked as the group's total, a draft tour missing what the proposal said, unlinked lines on the board, a crew rule that ignored drivers, multi-day trips vanishing on their second day, accepted proposals the board never mentioned, and a daily manifest no menu could reach.

Proposal → booking (includes/tailor/proposal-admin.php)
* agreed_total( $unit, $basis, $request ): a per-person price times the party the booking is written for — max( 1, adults ) + max( 0, children ), the figures prepare_booking() books — and a group price as it is. prepare_booking() passes that total to Booking\create_manual(); until 2.23.3 the per-person figure was the booking's agreed total and a note asked the operator to multiply it, so a forgotten note meant the Guest Pass, the balance and every document showed one traveller's price as the group's.
* The booking note gives the sum: "Proposal priced per person (EUR 500.00 × 4 travellers = EUR 2,000.00); adjust in Price & services if child rates differ." (singular for one traveller). The basis read is the one the traveller accepted ($decided['basis']), else the proposal's, as before.
* banner(): the accepted banner shows the basis beside the price — "Agreed price: EUR 500.00 per person × 4 travellers = EUR 2,000.00", or "… for the whole group". A decision recorded without a basis reads the proposal's.

Draft tour (includes/tailor/proposal-admin.php, includes/tailor/proposal.php)
* fill_tour() also writes wptm_highlights (each highlight's text, one per line, as the tour's Highlights field reads them; blank lines dropped), wptm_start_point and wptm_end_point, and wptm_price_currency. Nothing is written for what the proposal leaves empty.
* The ends come from Proposal\ends(): "From" and "To" as typed, else the first and the last place of the days — the same ends facts() always drew, now one function both call. facts()'s far point for a round trip (the traveller's asked-for destination) is not used as an end point: it is a guess, and the tour's fields stay empty rather than plausible.

Operations board (includes/operations/board.php, includes/mail/outbox.php, assets/css/operations.css)
* Messages: Mail\attention_bookings( $limit = 20 ) reads the newest 200 messages that gave up (failed, MAX_ATTEMPTS used) or were cut off (uncertain) and returns one entry per booking, newest first, with that message's status and type. The check lists each booking — reference, tour · date, "Failed · Reminder before departure" — linking to its edit screen at #wptm-booking-mail. The waiting line stays; the count is still every message (failed + uncertain + waiting). The two summary sentences are gone.
* Booked twice: each line links to the first trip's booking, else its departure (wptm-departures&departure=), else the guide, driver or vehicle booked twice.
* Crew: is_unassigned( $crew ) — no guide and no driver. A vehicle alone is nobody. checks() and trip_row() both call it, so the table's "Nobody assigned" and the check's list cannot disagree. Until 2.23.3 both asked for a guide or a vehicle: a trip with only its driver was flagged, one with only a vehicle passed. The badge reads "no guide or driver".
* In progress: ongoing( $today ) finds confirmed bookings that left in the LOOKBACK (30) days before today and whose last day (start + Assignments\duration_days_for_booking() − 1) is today or later; transfers never are. They head today's group in the trips table with "In progress · Day 2 of 5" (.wptm-ops__leg.is-progress, the board's green), count in "Travelling today", the week and the hero, and are not passed to checks() — their pick-up has happened, and crew and balance were on the board every day before they left — so their row carries no "Nobody assigned" or "Pick-up not set" either: the table warns about exactly what the checks list. wptm_operations_checks still receives the window's journeys only. The month's bookings' meta is read in one query (update_meta_cache) before their lengths are.
* Accepted proposals not yet booked: a check of its own after the follow-ups (key proposals, icon tickets-alt), present when the tailor-made module is. It lists what the tailor-made overview calls "to book" (Tailor\attention(), kind book: accepted, and no live booking — a booking in the trash counts as none), each linking to the request at #wptm-tp-answer, with the traveller and the start date.

Daily manifest (includes/admin/navigation.php, includes/operations/manifest.php)
* untabbed() lists only wptm-receipt. The manifest is a tab of Operations and a line in All screens, as the group's description says; opened without a date it is today's.
* manifest.php no longer takes the page out of the menu at priority 100. Taking it out of untabbed() alone was not enough: the navigation reads the menu at priority 1000, after the page was gone, so tabs() and directory() never saw it. In the compact menu the sidebar is still one entry per group; in the classic menu "Daily manifest" is listed under Tours.

Verified
* Harness ops.php (E_ALL, notices fatal; outbox SQL run on SQLite): 54 checks — is_unassigned(); attention_bookings() (one per booking, newest wins, uncertain, limit); trips on the road (order, Day 2 of 5, last day, a trip already back, a transfer, a pending booking and one past LOOKBACK left out, counted in the week, not re-checked, the filter's journeys unchanged); the crew check; message, clash and proposal items and their links; the rendered screen (badge, "Travelling today" = 3, the table's flags equal to the check's list, no warning on a trip under way); a quiet day; the manifest registered, a tab, in All screens in both menu layouts, the compact sidebar unchanged.
* Harness proposal.php (E_ALL): 33 checks — 500.00 × 4 = 2,000.00 booked with its note, the party booked equal to the multiplier, a second click returning the same booking; fill_tour()'s highlights, typed and derived ends, currency, and nothing written for an empty proposal; a group price unchanged; the accepted basis winning over a later edit; one traveller (singular); no price; the banner for both bases and for a decision without one; facts()'s route unchanged.
* The same suites against 2.23.3: ops.php fails 36 checks, proposal.php 15 — every one a gap above. With only untabbed() changed, the five manifest checks still fail.
* Chromium (the plugin's admin.css, dashboard.min.css, operations.min.css): the board at 1440 and 390 px, left to right and right to left — no horizontal overflow, no clipped badge or link, no script error.
* php -l on every PHP file (420). Not run: PHPCS / Plugin Check (the tools could not be fetched or run here); translators comments, escaping and prepared SQL on the new code checked by reading against the WPCS sniffs.

= 2.23.3 =
Operations and Everything in Tripvance (MED, after three references: an HRM dashboard with a greeting, counters and action lists; a recruitment dashboard with a banner, a week calendar and cards; a classes grid of uniform cards): a new Operations interface, and the list of every screen as an independent, ordered section.

Operations board (includes/operations/board.php, assets/css/operations.css)
* board( $today ): the window (today and HORIZON days after) read once — trips_between() → row() — and grouped by day; checks() turns every section into data (key, title, lead, Dashicon, what "clear" means, count, items of url/label/leg/meta/badge) with the filter wptm_operations_checks. Until 2.23.2 each of five sections called trips_between()/row() again: on the harness week 19 booking reads became 6.
* Parity: the harness renders the 2.23.2 board and this one on the same data and compares every section's list (references, legs, reasons): identical for the week's trips and all nine checks. The strings are the 2.23.2 strings; new ones: "To check", "%s thing(s) to check", "%1$s of %2$s lists clear", "Checklist".
* Screen: the dashboard's parts (dashboard.css is now enqueued under operations.css) — hero with the date, the status (alert or calm) and the week's trips · guests, Today's manifest / Tomorrow's manifest; four counters through Admin\focus_cards() (today, tomorrow, the next seven days, to check) linking into the page; "Upcoming trips": one table, a tbody per day with journeys (#wptm-ops-day-Y-m-d) headed by Today / Tomorrow / weekday, the date, trips · guests and that day's manifest; travellers with their initials (Admin\avatar()); the rail: an eight-day strip (days with journeys link to their group) and the Checklist (every check: open → coral count, linked to its card; clear → a tick and the clear sentence); then "To check": a card per open check, or one calm line.
* The wrap carries .wptm-hq, so admin.js's "On this page" bar no longer builds for the board (the cards and the checklist are its navigation). Under 783 px each journey is a card of labelled lines; the pick-up cell is one element so the time and the place stay on one line.
* Departures and licences moved into departure_items() and licence_items(); the mail lines are items without a link.

Everything in Tripvance (includes/admin/navigation.php, assets/css/dashboard.css, assets/js/admin.js)
* A page of its own, slug wptm-screens ("All screens"; capability: the Tours menu's edit_posts), and a group of its own in the compact menu, just above About. The nav bar's "All screens" links to it on every screen (also the dashboard), except on the page itself. In the classic menu it reads the menu itself (collect()).
* directory(): the groups with the screens this user may open (tabs()), menu order, the page's own group left out. Each group may carry a description (new key; filter wptm_admin_menu_groups); the built-in groups and the tailor-made group have one.
* Cards: icon in the group's tone, name (linking to its first screen), description, count; the list in a soft panel; six screens or more → a wide card (two columns) with its list in two columns. Grid: 4 / 3 (< 1400 px) / 2 (< 1100 px, no wide cards) / 1 (≤ 782 px) columns, dense flow.
* A "Find a screen" box (hidden without JavaScript): narrows to screens whose name, or whose group's name, contains the text, ignoring case, Latin accents and Arabic short vowels; Enter opens the only screen left; "No screen matches." when none.
* wptm_dashboard_after still fires; the navigation module no longer prints the directory there. The old .wptm-directory styles are replaced by .wptm-screens.

Verified
* Harness ops.php (new, E_ALL): 60 checks — the window, journeys and legs, grouping, each check's items, the filter, the hero/counters/day groups/strip/checklist/cards markup, a clear list as a ticked row, a quiet week, modules switched off, the permission refusal, assets; the All screens page (groups, order, what is hidden, wide cards, counts, the search box), the bar's link, the dashboard foot, the classic menu. Parity run against 2.23.2: identical. run.php: 198 checks as before.
* Playwright (WordPress admin CSS, minified files): board busy and quiet, All screens, at 1440 / 1100 / 390, left to right and right to left: no horizontal overflow, no clipped element, no script error; the find box narrows, clears, and says when nothing matches.

= 2.23.2 =
Proposal page and Plugin Check (MED, from the live page and a Plugin Check report): the logo missing from the traveller's page, icons sitting under their words, facts that disagreed (route "Marrakech" alone, "21 November 2026 → 21 November 2026", "Adults: 4 · Children: 0", "ALFONSO CAJIGA"), five Plugin Check findings, and a Tailor-Made Requests tile wanted on the Bookings dashboard.

Logo (includes/tailor/proposal.php, templates/tailor/proposal.php, assets/css/tailor-proposal.css)
* Cause: the logo was printed on a white plate, and a white logo made for a dark header disappeared on it (the empty plate on the live page).
* logo_tone( $id ): 'light' | 'dark' | 'solid' | '' from read_tone(), a 24 × 24 GD sample of the file (PNG, JPEG, WebP, GIF; 16 MP at most) weighted by opacity: under 1 opaque sample → '', over 97 % opaque → 'solid', mean luminance over 0.6 → 'light'. Kept as _wptm_logo_tone "tone@filemtime", read again when the file changes. An SVG, or a server without GD, gives ''.
* The logo now stands on the photograph like the Guest Pass's (max 180 × 46 px, the Guest Pass's double drop shadow); .wptm-prop__logoplate--dark keeps a white plate (ink colour, so an alt text is legible), --solid shows the picture as it is with rounded corners.
* A logo WordPress cannot size (no tag from wp_get_attachment_image()) is printed from wp_get_attachment_url(), as the Guest Pass does since 2.13.0. Loaded eagerly: it is above the fold.

Facts that agree (includes/tailor/proposal.php, requests.php, mail.php, proposal-admin.php)
* facts( $p, $request, $lang ): one source for view() and view_summary() (so for the page, the e-mail and WhatsApp). Length: max( typed days, written days ), else the request's; nights typed only count for the typed length, else days − 1. The last date is the first + (days − 1): a one-day trip shows one date; the request's own end is used only when there is no length at all.
* Route: "From" and "To" as typed, else the ends of the days (stops()). When there is no end, or the trip ends where it began, the far point is added: the middle of the places between, else the first destination the traveller asked for. "Marrakech" alone became "Marrakech → Essaouira"; a loop reads "Marrakech → Merzouga → Marrakech".
* party(): "4 adults", "2 adults · 1 child" (FR adultes/adulte, enfants/enfant; ES adultos/adulto, niños/niño; AR "البالغون: %d", "بالغ واحد", "الأطفال: %d", "طفل واحد"); no children line when there are none. Used by the bar, the e-mail and the builder's suggested group; a group line still equal to the old "Adults: n · Children: n" is shown in the new words.
* Tailor\person_name(): a name typed all in capitals (or all small) is title-cased for display — the eyebrow, the greetings (proposal e-mail, WhatsApp, request acknowledgement) and the booking prepared from the proposal. A name typed with care, or in a script without capitals, is kept. The request keeps what was typed.
* The proposal e-mail lists the route and the travellers beside the length and the dates.
* New label keys prop_adults, prop_adult_one, prop_children, prop_child_one (devkit/gen_proposal_i18n.py, 88 keys).

Icons (includes/icons.php, includes/tailor/proposal-admin.php, includes/tailor/admin.php, CSS)
* New drawn icons: eye, link, plus, image, unlock. The builder's buttons (save, preview, e-mail, reopen, add a day, add photographs, prepare booking, e-mail the Guest Pass, the locked note) and the request header's WhatsApp and e-mail buttons use them instead of dashicons, whose font metrics put them under the words. Measured in Chromium with WordPress's own button CSS: icon centre within 1 px of the text centre.
* "Copy link" no longer shrinks into two lines: flex: none, nowrap.

Bookings dashboard (includes/admin/dashboard.php, assets/css/dashboard.css)
* focus_data() adds 'tailor' when Tailor\status_counts() has any request: "Tailor-made requests · New, changes asked or to book", counted by Tailor\attention( 99 ), linked to the requests list (a card may now carry its own address at index 6). Deep gold tone (#8a5c1c, white text readable). Five tiles: one row on a wide screen, 3 + 2 under 1180 px, 2 + 2 + 1 under 960 px.

Plugin Check
* proposal-admin.php: (int) ++$n in the step bar and (int) $id in the WhatsApp button (EscapeOutput, two errors).
* structured-data.php: defined( 'ABSPATH' ) || exit; right after the namespace, before the long comment, where the check looks for it.
* seo.php: the ignore names WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in.
* readme.txt: Description rewritten short (the directory merges the other sections into it), Changelog keeps the last three releases; this file has the full history.

Verified
* Harness E_ALL (tripvance-2.23.2-test-harness.zip): 198 checks, 0 notices — the 169 of 2.23.1 plus: tones of a white, a dark, an opaque and an SVG logo; the tone kept and re-read when the file changes; plate classes on the page; the address fallback; the reported day trip (name, route, one date, "4 adults", "1 day") on the page, the e-mail and WhatsApp; written days beating a typed length; a loop's far point; party words in EN/FR/AR; person_name cases; drawn icons only in the send box; the dashboard's fifth tile and its link.
* Playwright (Chromium, WordPress 6.x admin CSS): logo loaded and visible on the photograph at 1440 and 390 (light: no plate; dark: plate), bar icons 0 px off their labels, send box icons ≤ 1 px, Copy link on one line, tiles in 5 / 3 + 2 / 2 + 2 + 1 without overflow; minified files identical in behaviour.

= 2.23.1 =
Tailor-Made Requests, stability and polish (MED, after two dashboard references: a travel dashboard with stat cards and an HRM dashboard with counters, a donut and an action table): the request should open on what the traveller asked, the builder should be easy to fill, and the requests list should become a dashboard.

Request screen (includes/tailor/admin.php, assets/css/tailor-admin.css)
* The "Request" meta box (details_box(), rows_card()) is replaced by brief(), printed from the header's edit_form_after_title callback, so it is always first and cannot be dragged under the builder: four tiles (dates with the departure and "flexible", length with nights, travellers as a total with the split, budget), "What they would like" as chip rows (destinations, interests, style, accommodation; an unanswered row is left out), "In their own words" as a quote, and Contact (mailto, tel + WhatsApp, country, language, consent). The 2.23.0 builder box had been registered at add_meta_boxes priority 5 and so sat above the request: fixed by the move.
* The header keeps identity and actions only: the facts strip (dates, duration, travellers, submitted, language, country, budget) became one quiet line (submitted, country, language) since the brief carries the rest. Avatar and actions span three grid rows. The navigation's "Request" points at #wptm-tmr-brief. WhatsApp numbers through wa_number() → WhatsApp\normalise_number() (a national number with a trunk zero no longer opened the wrong chat).
* Tailor\status_counts(): the grouped status count, memoised per request, shared by the status tabs and the overview.

Builder (includes/tailor/proposal-admin.php, assets/css/tailor-proposal-admin.css, assets/js/tailor-proposal-admin.js)
* steps(): a sticky section bar (under the admin bar; static under 782 px) — progress bar, "n of 6 sections ready" / "Ready to send", one link per card, ticked when filled (title; price > 0; a highlight; a titled day, shown against the days asked for; a photograph; a trust switch), recomputed with the live preview.
* hint(): what the traveller asked, beside the field it informs — destinations and interests over the title, budget and party over the price, dates and length over the days.
* The side panel is no longer sticky: it slid over Status, Draft Tour and History while scrolling. Cards keep a scroll margin under the step bar.
* One notice: redirect_post_location drops WordPress's "message" when a proposal notice is set.
* Unsaved changes: leaving the screen after typing in the builder asks first (beforeunload); saving, mailto:, tel: and wa.me links do not.
* Banners carry id="wptm-tp-answer" for the overview's buttons.

Requests list (includes/tailor/dashboard.php, new, admin group after tailor/admin.php)
* overview(), echoed from views_edit-wptm_tmr at priority 5 (under the title, above the tabs; not in the bin): a time-of-day greeting with the user's name; four counters linking to their tab (new; in review + contacted; proposals sent with "n asked for changes"; confirmed with "n to book"); a ring of all live requests by status (conic-gradient in the tab colours, total in the middle, legend with counts); "Needs your attention": accepted proposals without a live booking, then change requests (newest answer first), then new requests, at most six, a request listed once for its most urgent reason, each row with initials, name, reference · date · days · travellers, what happened, how long ago, and Prepare booking / Update proposal (→ #wptm-tp-answer) or Open & reply (→ #wptm-tmr-brief). "Nothing is waiting" otherwise.
* proposal_counts() (grouped on _wptm_tmr_proposal_state) and attention() (two small id queries on indexed keys, list screen only).
* The list's initials badge is absolutely placed for a table cell; inside the overview it is a grid item (position: static).

Verified
* Harness (tripvance-2.23.1-test-harness.zip; get_posts with one meta clause and grouped counts now answered by the stub): 169 checks, 0 notices — the 142 of 2.23.0 plus: no Request meta box; the brief right after the header, four tiles with total/split, nights and budget, wishes as chips with the unanswered one left out, the quote, mailto and wa.me; no facts strip in the header, the quiet line, the navigation to the brief; six steps, the trip and price hints, the days asked for passed to the script; the redirect without "message"; the overview (greeting, four counters, ring total, new requests listed and a booked proposal not, counters filtering, the order book → changes → new, a request listed once, "1 to book", nothing in the bin).
* Playwright: the request screen and the overview at 1440/900/390 px — no overflow, no script errors; the builder's form names still clean after adding, moving and picking.
* Not run: a live WordPress install, Plugin Check / PHPCS. Run Plugin Check before SVN.

= 2.23.0 =
Tailor-made proposals (MED): answer a Tailor-Made Request with a private, conversion-minded landing page the traveller can confirm or ask to change, then turn the confirmation into a booking with its Guest Pass.

Data (includes/tailor/proposal.php, new, loaded by tailor.php in the core group)
* Stored on the request, not in a table or a post type: _wptm_tmr_proposal (the content, money in minor units), _wptm_tmr_proposal_state ('' | ready | sent | changes | accepted; "expired" is computed from the valid-until date, never stored, and an accepted proposal never expires), _wptm_tmr_proposal_hash / _sealed (the key), _wptm_tmr_proposal_feedback (the traveller's change requests, last 20), _wptm_tmr_proposal_decided (when, price, regular, deposit, currency, basis, title, language), _wptm_tmr_proposal_unread, _wptm_tmr_booking. Proposal\get() returns every key typed; blank() is the shape.
* The key: Tokens\mint/digest/seal/unseal/matches under CONTEXT 'tmr_proposal', minted on the first save with content and kept for the life of the proposal, so a corrected proposal sent again keeps the traveller's link. A server that cannot seal mints a new key when asked to show it again.
* Route: /proposal/{reference}/?k={key} on Lang\home_url() of the request's language (?wptm_proposal=…&k=… without permalinks). Rewrite rule at init 20, flushed once through wptm_rules_stale (option wptm_tmr_proposal_route), like the quote route. The reference is looked up with one meta query. Staff who can manage the request open it without a key (preview: ribbon, buttons disabled, no dialog); everyone else needs the key and a saved proposal; a wrong key is the same 404 as an unknown reference. Quotes\private_headers() (no-store, X-Robots-Tag noindex, nofollow, noarchive, DONOTCACHEPAGE, LiteSpeed no-cache) is reused.
* Proposal\view() gathers and formats everything inside Tailor\in_locale( request language ); trust() reads Proof\items( false ) (licence and platform ratings only, never the site score), Home\get( 'guide_profile_years' ) unless the proposal sets its own figure, and Tripvance\trust_items() (four at most). stops() makes the drawn route: where day one starts, then where each day ends, no repeats in a row, each stop remembering the first day that reaches it (data-stops on the figure).
* Answers (admin-post wptm_tmr_proposal_answer, nopriv and logged-in): nonce per request, key re-checked, rate limit through Security\window_count/window_hit (10 a minute per hashed client_ip(), the quote meter), state checked. accept(): state accepted, decided snapshot, unread flag, Tailor\set_status( confirmed ), history 'proposal_accepted', the agency e-mail, action wptm_tmr_proposal_accepted. request_changes(): topics whitelisted, text sanitize_textarea_field (2,000), state changes, history 'proposal_changes' (200 characters), the agency e-mail, action wptm_tmr_proposal_changes. An expired offer can still be asked to change, not accepted.
* Messages through Tailor\deliver() (and its wptm_tmr_pre_send short-circuit): the agency's in the site's default language with Reply-To the traveller (filter wptm_tmr_proposal_admin_message), the traveller's in theirs with the title, length, dates, price and discount, validity and the link (filter wptm_tmr_proposal_message). Plain text, like every Tripvance message. whatsapp_text() builds the click-to-chat text.
* Privacy: Tailor\anonymize() also deletes the feedback and the key.

Builder (includes/tailor/proposal-admin.php, new, admin group after draft-tour.php)
* Two meta boxes on the request (registered at add_meta_boxes priority 5 so the dispatch panel heads the side column): the builder in the main column (cards: trip, price & offer, highlights, day-by-day, gallery, trust) and "Proposal: preview & send" in the side column (state pill, live itinerary preview, private link with Copy, Preview, Send by e-mail, Send on WhatsApp, Reopen, the traveller's requests). Every control is inside WordPress's post form; the buttons are submit buttons named wptm_tp_do (save | email | reopen | booking).
* A new request opens with suggested(): the destinations as the title, days and nights, the site currency, the accommodation chosen and the party in the traveller's language, and one empty card per day asked for. Nothing that was not in the request.
* sanitize(): text fields sanitize_text_field (200), the note and each day's text sanitize_textarea_field (3,000 / 2,000), money through Payments\to_minor() ("1 200,50" and "2.400,50" read right), a regular price not above the offer dropped, the deposit capped at the price, icons and transport modes whitelisted, photographs kept only when wp_attachment_is_image(), the cover only when in the gallery (else the first), empty lines, highlights and day cards dropped, everything bounded (40 days, 30 lines, 12 highlights, 24 photographs).
* save_post_wptm_tmr at priority 20, after the request's own save, behind the same nonce (Tailor\SAVE_NONCE) and Tailor\can_manage(). An accepted proposal is never overwritten. The result reaches the screen as wptm_tp_notice through redirect_post_location.
* The WhatsApp button opens wa.me in a new tab and sends a beacon (admin-post wptm_tmr_proposal_wa, nonce, capability) so the history says it was shared and the request moves to Proposal Sent.
* edit_form_after_title at 20: the proposal notices and the traveller's answer as a banner (changes: their latest words; accepted: when, the agreed price, then Prepare booking & issue Guest Pass, or once done the booking, the pass link, Customise the Guest Pass, E-mail the Guest Pass through GuestPass\action_url( 'wptm_pass_send' ), WhatsApp through WhatsApp\resolve( 'pass_ready' )). Opening the request clears the unread flag.
* prepare_booking(): only from accepted; idempotent (a live linked booking is returned); a 60-second option lock. The request's tour (Tailor\existing_tour() or Tailor\create_draft_tour(), always a draft) is filled by fill_tour(): title, wptm_duration_days/nights, the days as structured itinerary cards (ItineraryDetails\normalize, structured true: route, travel_time, overnight, transport facts and a Meals line in the traveller's language), wptm_included / wptm_not_included, the cover as thumbnail and the rest as wptm_gallery. Then Booking\create_manual() with kind product, status confirmed, notify false, the agreed price and currency, the request's dates, party, contact and language, source 'other' with the reference, and a team note (deposit asked, per-person basis). Confirmation issues the pass through the Guest Pass lifecycle (mail held back by the manual path); GuestPass\ensure() is called again for certainty. Links both ways (_wptm_tmr_booking, _wptm_tmr_request), history 'booking', action wptm_tmr_proposal_booked. A booking saved but not finished (the price) is linked and its reason shown.

Request screen (includes/tailor/admin.php)
* The header shows the proposal pill beside the status, and the country and budget among the facts; the navigation gains "Proposal". The list's status column shows the proposal pill, ringed and pulsing until somebody opens a new answer. The history names the new events: Proposal link generated, sent by e-mail / shared on WhatsApp, Traveller asked for changes, Proposal accepted, reopened, Booking and Guest Pass prepared.

Page (templates/tailor/proposal.php, assets/css/tailor-proposal.css, assets/js/tailor-proposal.js; theme override tripvance/proposal.php)
* Its own document like the quote and the Guest Pass; the brand accent printed inline (Brand\primary()), the theme's font families through tripvance_document_typography(). Sections left out when empty. Owner text dir="auto", places and dates isolated in <bdi>, scores and figures LTR, arrows in the reading direction.
* The route: an SVG stretched to its box (preserveAspectRatio none, non-scaling strokes), the places as buttons positioned in percent, the coloured line revealed by a clip rect that grows in user units (exact at any width, where a dash offset would not be), the vehicle placed on the path by halving getPointAtLength() over x (the drawn path only moves forward). An IntersectionObserver on the day being read sets the active day, the timeline fill and the target stop; the vehicle tweens there. Mirrored in Arabic. On a phone only the ends and the current place are named.
* Accept asks once in a <dialog> (without script the form submits directly); Request changes is a <details> form; the lightbox is a <dialog> with arrows, keys (reversed in RTL), swipe and a counter; the phone dock hides while the summary bar or the answer is on screen. Reveal motion only once the script runs; none under reduced motion; print hides the actions and keeps the itinerary.

Strings (includes/tailor/proposal-i18n.php, new, generated from one table by the developer kit's gen_proposal_i18n.py): 84 traveller-facing keys prop_* in English, French, Spanish and Arabic, joined to labels() (so Polylang's string table lists them) with the gettext switch for language packs; translators comments on every placeholder. Admin wording is ordinary __().

Icons (includes/icons.php): plane, train, boat, sun.

Verified
* Harness (tripvance-2.23.0-test-harness.zip: an in-memory WordPress, E_ALL with every notice an exception, the real tailor, tokens, i18n, proof, brand and icons modules; Booking, Guest Pass, Quotes, Security and Pricing replaced by recorders) — 142 checks, 0 notices: route and query var; the suggested builder; saving (XSS stripped, "3 000" and "2.400,50", deposit cap, whitelisted icons and modes, non-images and the bad cover dropped, empty rows dropped); one key minted, kept on a second save, isolated from other contexts; the e-mail in French with link, price and discount; the WhatsApp beacon; the page keyed (FR LTR, AR RTL, ES minimal with every empty section left out), wrong key / unknown / malformed reference 404, staff preview with disabled buttons; bad nonce and bad key sent home; an empty change request refused; changes recorded with topics whitelisted, the agency mailed with Reply-To, the unread pill and banner; the rate limit; acceptance (state, status, decided price, agency mail, history), a second acceptance refused, the thank-you page; the builder locked and not overwritten; Prepare booking (draft tour titled, itinerary cards, facts, meals in French, inclusions, cover and gallery, length, the manual booking's kind, status, silence, price, currency, names, date, party, language, deposit note, the pass issued, history) and its idempotence; the booked banner; reopen; expiry (no accept, change form open, acceptance refused); an empty builder opening no link; the history labels; the list pill; anonymising; every key in four languages and the gettext switch; no unknown key used.
* Playwright Chromium 1440×900 and 390×844, FR and AR, source and esbuild 0.25.9 minified: no horizontal overflow, no script errors, the active day and the vehicle at the right stop after scrolling (day 3 → Merzouga), the lightbox (next is previous in Arabic), the confirmation dialog, the dock, print, reduced motion and no JavaScript with every section visible. The builder in a mock of the request screen: the discount ("20% off · the traveller saves €600"), adding a day and a highlight, picking an icon, moving a day — the form posts one clean set of indexed names.
* Not run: a live WordPress install (the sandbox would not run WordPress core), Plugin Check / PHPCS. Run Plugin Check before SVN.

= 2.22.3 =
Guide profile, round 4 (MED, from the live Spanish page): the logo still missing, the years icon in the wrong place, a social page wanted.

* Logo: decided as markup before anything is printed — Home\gp_picture() now returns its markup ('' for an empty address); the theme's custom_logo fallback goes through wp_get_attachment_image(), which answers '' for a deleted attachment. Empty → no <figure>, the row takes .wptm-gp__row--single. The live page had a picture column with nothing in it, which is what an empty figure (or a zero-width image) gives. CSS: inline-size 100% of the 360 px box, height from the picture, max-block-size 320 px contained — an SVG with no intrinsic width, or a file whose metadata has no size, can no longer collapse. Homepage panel: a small preview of the logo in use (the field's or the site's), or a note that there is none; a white header logo on a light page is called out.
* guide_profile_years_badge now means the years' icon in the proof grid (.wptm-gp__mark--logo, like a platform logo); the floating picture moved to a new key, guide_profile_overlay ("Image over the guide", .wptm-gp__overlay), so a site that had set the badge sees it where MED asked for it. Both in the aggregate and the builder's reference labels.
* gp_picture(): an address with a -WxH suffix is retried without it, so a resized copy is printed from the library (width/height, srcset) rather than as a bare img.
* Social page (includes/proof.php): proof_social_{name,count,url,logo}, keep-if-absent; Proof\items( true ) adds kind 'followers' (number_format_i18n, the proof_followers label, FR/ES/AR) after the platforms. Homepage only, like Gridinsoft. Settings row before the licence; a fifth logo picker. The Guide profile prints it with a counter; the panel summary lists it.
* home.js / noir.js: each counter is set inline-block at its final width before it is zeroed — the count-up measured 0 layout shift in Chromium (PerformanceObserver layout-shift) at 1440/900/390.
* Verified: harness E_ALL — library logo with width/height, a -300x300 years icon traced to its original, a deleted site logo leaving no column, the overlay on the photograph, the social item with its link (EN/FR "12 400"/AR) and absent from the tour strip, the Settings social row and five pickers, the panel's new fields and its no-logo note — 0 notices. Playwright with brand-square: logo 360×320 / 317 / 255 px, years icon 32 px loaded, overlay 140/99/92 px, no overflow, no JS errors, CLS 0.

= 2.22.2 =
Guide profile, round 3 (MED): a years badge in the first row; the logo without a frame.

* New key guide_profile_years_badge (Builder\media_field, esc_url_raw, in the aggregate and the builder's reference labels). Printed by a new Home\gp_picture() — the media library's markup when the address is an attachment (srcset, the WebP as uploaded), else a plain image — on the photograph's start edge (absolute, drop-shadow filter so a transparent seal casts its own shadow, 96–140 px, 92 px on phones), or under the introduction when there is no photograph. Alt: the years and "Years of experience", translated.
* The logo: no circle, no dashed ring, no radial ground, no shadow; shown whole up to 360 px wide and tall, never upscaled past its own size. gp_picture() also prints it.
* Verified: harness E_ALL (seal inside the portrait figure with its alt text in EN/FR/AR, logo, empty-proof and no-name cases) — 0 notices; Playwright 1440/900/390 × LTR/RTL with brand-square loaded, transparent WebP test files — no overflow, no JS errors.

= 2.22.1 =
The Guide profile in two rows, after the live page (MED, a French site in Square corners): the card read small and boxy, the portrait came out square.

Guide profile (includes/home/guide-profile.php, assets/css/home-guide-profile.css)
* Two open rows after the model MED sent, no card: (1) eyebrow, name with the last word in the accent, line, introduction | the photograph, large (440 px), 4:5 under an arch — border-radius 50% 50% 36px 36px / 40% 40% 36px 36px, a true half-circle with no radius scaling (999px tops had shrunk the bottom corners to 8px) — an accent disc behind, the verified mark when a licence is set; (2) the logo whole in a soft circle with a dashed outer ring | the philosophy (h3 with an accent rule) and the proof grid. Phones: picture first, then the words.
* New keys: guide_profile_logo (falls back to the theme's custom_logo), guide_profile_about_title (default proof_philosophy, "Our philosophy", FR/ES/AR), guide_profile_about (500). Both words are on the language tabs; the logo is in the aggregate and in the builder's reference labels. The second row needs words or proof; a logo alone prints nothing.
* Proof grid: credentials first (years, licence), then Tripadvisor, Trustpilot and Gridinsoft as a 60 px dial with the score inside. Items are links when an address is set. Counters, dial and stars run on the list (data-wptm-gp moved from the card to the list, so a tall section on a phone still reaches the observer's threshold).
* Rounded in Square: brand-square.css sets every corner inside .wptm-home to 0 !important at two classes; the section's shapes are restated !important at three.

Platform logos (includes/proof.php, includes/admin/settings-screen.php, assets/css/settings-admin.css)
* proof_{tripadvisor,trustpilot,gridinsoft,licence}_logo: an attachment id, kept only when it is an attachment (Proof\clean_logo), keep-if-absent like the other keys. Picked with the plugin's own term-image picker (admin-term-image.js, wp_enqueue_media on the Settings screen only), a Logo column in the Ratings & licence table.
* Proof\mark( $item, $size ): the uploaded logo ('medium', not the cropped thumbnail, alt '' — the platform is named beside it) else the neutral icon. Used by the trust strip, Noir's trust bar and the Guide profile.

Verified: harness E_ALL — logo sanitising, the strip printing an uploaded logo for one platform only, the Guide profile (logo, philosophy, accent word, credentials order; empty-proof and no-name cases), Settings (four pickers inside the form) and the Homepage panel — 0 notices. Playwright 1440/900/390 × LTR/RTL with brand-square loaded, source and minified: arch/emblem/marks/tick computed as rounded, counters end on the printed text, no overflow, no JS errors.

= 2.22.0 =
Proof from outside the site: review platforms and the licence in the trust strip, and a Guide profile section for the homepage.

Data (includes/proof.php, new, core group after settings.php)
* Settings → Trust gains structured fields instead of more "Title | Detail | icon" syntax: proof_{tripadvisor,trustpilot}_{score,count,url}, proof_gridinsoft_{score,url}, proof_licence, proof_licence_label, proof_licence_url. Proof\sanitize() is merged into sanitize_settings(); a key the request does not carry keeps its stored value, so the wizard or a restore cannot erase them. A score is one decimal on /5 or a whole number on /100 ("4,9" reads 4.9); zero, text or off-scale is stored empty.
* Proof\items( $site ) is the one reading every place uses: platforms in a fixed order, then the licence; a platform only with a score, the licence only with a number. Gridinsoft is a score about the website, so it is returned only with $site (the homepage), never under a tour. The licence title falls back to the existing home_licence label ("Ministry of Tourism licence", already translated in FR/ES/AR); proof_licence_label and proof_licence are registered with Polylang's string table.
* Nothing fetched, nothing invented; no structured data (Rank Math owns it). The platforms are named in words beside neutral icons (two new ones in icons.php: review, pin-star); their logos are trademarks and are not drawn or bundled.

Trust strip (templates/sections/trust.php, assets/css/tour.css)
* Proof badges before the promises: score, "/5", five stars filled to the score (CSS gradient, the 2.21 Noir technique), the platform and review count as the link (stretched over the badge), the licence with a "Verify" link. Tour::has('trust') is true with proof alone, so every layout that asks will_render() shows the strip.

Noir (templates/noir-design/single-tour.php, assets/css/noir.css, assets/js/noir.js)
* Proof leads the trust bar (still four at most), inside the same will_render( 'trust' ) gate, so the Section Manager switch hides it too. The score is its own LTR run (dir="ltr"): in Arabic "4.9 / 5" had reordered to "5 / 4.9".
* Motion, once, on the existing reveal: noir.js counts the score up from zero and puts the server's exact text back at the end (a decimal comma survives); the stars wipe in with clip-path; the icon ring pings twice; a faint light crosses the badge every seven seconds. All off under reduced motion; without JS the figures are simply printed.
* The booking side lists the promises only (from the list before the four-item cap), not the scores.

Homepage — Guide profile (includes/home/guide-profile.php, new; assets/css/home-guide-profile.css, new; assets/js/home.js)
* Joined through the homepage hooks the way Transfers is: wptm_home_sections, wptm_home_defaults (off, order 85), wptm_home_translatable_fields (heading, line, introduction; not the name), wptm_home_panels / wptm_home_admin_panels, wptm_home_sanitize. Registered in section_help(), section_icon(), the builder's category map, miniature and reference labels. The photograph's attachment id is gathered in the aggregate.
* One card: round portrait with a verified mark (only when a licence is set), eyebrow "Meet your guide", name, line, introduction, chips for years / licence / each rating, and the Gridinsoft score as a dial (SVG ring, pathLength 100, green ≥ 70, amber ≥ 40, red below), the arrangement of the Gridinsoft page — the number large, "points" and "/100" beside it. Prints nothing without a name; every part is left out when empty. dir="auto" on the owner's words; "12+" and "4.9/5" isolated LTR.
* home.js: when the card is first on screen the figures count up, the ring fills and the stars draw in (classes .wptm-gp--js / .is-in; transitions only once .is-in is set, so adding the JS class never animates backwards). Off under reduced motion.
* Stylesheet loaded only while the section is on; night mode handled in the same file from the scheme tokens.

Fixed
* includes/home/admin.php sanitize(): hero_slider_interval read the raw key after a ?? fallback, so a save without it stored 0 and raised "Undefined array key". Found by the E_ALL harness.

Verified
* Stub-WordPress harness, E_ALL, full core and admin load: sanitiser cases, items() order and gating, the strip (with/without proof, FR decimal comma, Arabic), the Guide profile (full, no proof, no name), the Noir trust bar and booking ticks, the Settings screen and the Homepage screen (the new fields inside their <form>), translatable fields — 0 notices.
* Playwright Chromium 1440/900/390 × LTR/RTL, source and minified: no horizontal overflow, no JS errors, counters end on the printed text, the dial at 28 (72/100), a click anywhere on a Noir proof badge lands on its link, reduced motion shows final values with no JS classes. esbuild 0.25.9 rebuilt tour, noir, home-guide-profile .min.css and home, noir .min.js.
* Not run: a live WordPress install, Plugin Check / PHPCS (the sandbox could not run them). Run Plugin Check before SVN. tour.rules.php (CSS prune index) was already out of step with tour.min.css in 2.21.1; pruning is off by default and falls back to the linked file.

= 2.21.1 =
Guest reviews: a star rating instead of a dropdown, and the Noir review form fixed.

Rating control (includes/reviews.php, shared by every layout)
* The comment_form_field_comment field is now five radio buttons under one label, not a <select>: "n out of 5" as each one's accessible name (reused review_out_of, no new string), a visually-hidden input (.wptm-vh) and a star drawn from icon('star-full'). No rating pre-selected — an unanswered field, same as the old blank option, so a review stays optional.
* assets/css/tour.css: .wptm-starpick draws the five from a plain sibling selector — highest value first in the markup, the group laid out row-reverse so star 1 sits on the left, :hover/:checked on one star lighting every star before it in the markup (after it on screen). Colour comes from --wptm-border (unlit) and --wptm-accent (lit/hover/:focus-visible), both already set per layout and per site accent, so the widget needs no layout-specific CSS of its own. Forced direction: ltr on the group — a rating reads by its shape, not by the page's script, so it stays "more stars, further right" in Arabic too. The old select-specific rules (width, the phone full-width override) are removed with it.

Noir (assets/css/noir.css)
* The review form's own fields were unreadable on an Experium build: light text where the background also turned out light, the field's hairline invisible against it (reported with a screenshot — name and email asked for, but nothing of the labels or the boxes around them could actually be read). The design's own tokens (--wptm-page, --wptm-border, retuned on .wptm-tour.wptm-noir since 2.21.0) are correct on their own, verified against a direct stub-and-browser render with no theme present; on the live site something in the theme's own comment-form styling was still reaching the native fields. input/textarea background, text and border inside .wptm-reviews__form are now pinned with !important, scoped to that one form and skipping the submit button and the checkbox so neither loses its own colour. The five stars take the design's hairline and gold the same way the rest of the page does.
* Verified with a stub comment_form() built to WordPress core's own shape (#respond, #commentform, the real field classes) so the plugin's filters run against real markup: the Noir render showed correct dark fields and gold stars with no theme in the page at all, which is what pointed at the theme rather than the plugin's own CSS as the live-site cause; the defensive rule stands regardless, since it costs nothing when it turns out not to be needed. Clicking a star checks the right radio and lights every star up to it; Salma (a light layout, untouched by the Noir rule) renders the same widget in its own brown. Arabic/RTL: stars stay left-to-right, no horizontal overflow. Zero PHP notices under E_ALL. Minified copies rebuilt with esbuild 0.25.9.
* Not run: a live WordPress install, Plugin Check / PHPCS. Run Plugin Check before SVN.

= 2.21.0 =
The Noir tour layout: a dark luxury editorial page for high-ticket private journeys, built from the existing fields and the shared booking components.

Layout
* includes/layouts.php: `NoirDesign` registered as a curated family (template templates/noir-design/single-tour.php, style assets/css/noir.css, script assets/js/noir.js). The registry gains a `script` key, normalised beside `style`.
* includes/assets.php: a design's `script` is enqueued deferred on its tours only, under the same handle as its stylesheet (wptm-design-noirdesign). No other design declares one.
* assets/css/admin.css: the picker miniature for the new card (bg--dark).

Template (templates/noir-design/single-tour.php)
* Hero: hero image at hero_size()/hero_sizes_attr() (the existing preload matches), the primary term or the title as a very large decorative word behind, breadcrumbs, eyebrow (primary term · duration), H1, the lead, a glass "Book now" pill (data-wptm-open-form when the tour books through the plugin) and the starting price. Up to five Tour::quick_facts() as cards, each paired with the next gallery photograph; a scroll-snap row under 768px. The header carries .wptm-hero so tour.js hides the phone bar while it is on screen.
* About: the description (post content, else the lead) in one 72ch measure, the highlights in two columns under it. The itinerary is its own section: a line down the middle of the page, the days alternating sides (odd: card at start, photograph at end; even: mirrored), a numbered disc on the line per day (DAYS: "Day 01" + day_title(); TIMELINE: the time), the day's figures from ItineraryDetails as chips (ItineraryView\bar() order, fact_icon/fact_label, the highlight last), the step's own photograph or, when "photographs beside the itinerary" is on, the gallery's. Headings h2 → h3. Phone: line at the left, text then photograph.
* Inclusions: each Included line a card; a line split on its first colon or a spaced dash into title and sentence; icon from the wording (bed, car, users, meal, ticket, shield, water, wallet, compass) else check. Not included under a <details>, open when nothing is included.
* Trust: the review average (Reviews\cached) when reviews are on and there is one, then trust_items() from Settings, four at most; a note matching "n/5" draws five stars filled to the score.
* Booking: section('book-now', inline false) on a glass card at the start side, the photograph beside it — the tour's new `noir_booking_image` field (includes/fields/schema.php, type image, shown when layout is NoirDesign), else the last gallery picture — with the duration, the starting price and the first three trust titles under it; the hero, softened and blurred, behind the band; booking-success at the top of the page after a request; mobile-bar and booking-modal as on every design. Sections this page does not place (pricing, bring, good to know, route, video…) print before the booking band in reading order, faq/reviews/related after it.
* Every section prints nothing when its data is empty; a tour with only a title renders a hero and the booking card.

Third pass (MED, from the live experiumorocco.com page)
* Hero cards: Tour::quick_facts() as icon cards (26px icon in a sand ring on a green radial ground, the mark faint in the corner); the gallery photographs they carried moved to the new gallery band.
* Bullet: `--wptm-noir-bullet`, an inline SVG of the Experium mark (sand peak, green peak, snow cap), on section headings, highlights, the booking-side promises and lists inside the description. Override with one custom property.
* Days: no box around the text (52ch measure, end-aligned on the start side), figures as icon + words over a hairline instead of pills; each photograph a `data-wptm-lightbox` link with a hover "enlarge" mark.
* Gallery band (`#wptm-gallery`, between the days and the inclusions): Tour::slides() as a 4-column mosaic (lead 2×2, four tiles, "+n · View all n photographs" on the fifth), the rest as hidden links so the viewer holds every photograph. tour.js's shared lightbox, no new viewer. noir.js (runs before tour.js) hands a day photograph that is also in the gallery to the gallery link, so the viewer counts each picture once.
* Booking: the field-tint rule gave the stepper's number its own 3ch box; the stepper is now one bar (52px buttons with hairline separators, sand glyphs). Headings at `.wptm-tour.wptm-noir.wptm-noir :is(h1…h6)` — Manassa painted the booking title its dark green. The lead no longer inherits tour.css's auto margins (the indent).

Stylesheet (assets/css/noir.css)
* Palette from the brand tokens where brand.php prints them: --wptm-brand-secondary-dark (Experium forest green) mixed into the ground, --wptm-brand-sand / -sand-light as the gold, --wptm-brand-neutral as the paper the hero fades up from; neutral obsidian fallbacks on a site without them. The hero's words are set at .wptm-tour.wptm-noir specificity so a theme's h1/a/p colours (experiumorocco.com's green headings over the photograph) cannot reach them.
* Tokens on .wptm-tour.wptm-noir; the shared --wptm-page, --wptm-accent, --wptm-on-accent, --wptm-hairline, --wptm-border retuned so the booking form, dialog, bar, FAQ and reviews turn dark without their own rules. The .wptm-noir-stage wrapper is painted dark so scheme.js finds a dark backdrop and links dark.css for the remaining fixed-colour surfaces. --wptm-accent stays overridable by the site accent from Settings.
* .wptm-hero's immersive rules from tour.css (absolute, inset 0, z-index -1) are undone on the Noir hero; tour.css's `img { height: auto }` is out-specified on the booking photograph.
* Reveal (opacity + translateY) only once the script adds .wptm-noir--js; none under prefers-reduced-motion. The timeline fill is a block-size from --wptm-noir-progress.
* Logical properties throughout; letter-spacing: normal and the theme's font under [dir=rtl] and :lang(ar); full bleed via the stage wrapper with body:has() overflow-x: clip.

Script (assets/js/noir.js, ES5, 1.1 KB minified)
* One IntersectionObserver marks [data-wptm-noir-reveal] elements is-in once; a second watches the timeline and attaches a passive, rAF-batched scroll handler only while the list is near the screen, writing --wptm-noir-progress.

Verification
* The real template rendered through a WordPress stub layer under E_ALL (full tour, bare tour, Arabic/RTL): zero notices; the design stylesheet and script enqueued on the Noir tour only. Chromium (Playwright) at 1440/1024/390, LTR and RTL: no horizontal scroll, no element past the viewport, no JS errors, dark scheme detected, every reveal fired, the two-step booking form working inside the glass card. Minified copies built with esbuild 0.25.9.
* Not run: a live WordPress install, Plugin Check / PHPCS. Run Plugin Check before SVN.

= 2.20.0 =
SEO, Google compliance and front-end speed pass, from an audit of experiumorocco.com (2.19.2, Manassa, Rank Math, Polylang free, ES at the root, /en/, /fr/). An independent review of the diff found a Yoast title loop, forms failing where REST is closed, a stale-cookie nonce mismatch, a front-page edge case and the H1 language fallback: all fixed before release. Verified on WordPress 7.2 beta + SQLite, Polylang (GitHub) ES/EN/FR, Rank Math (GitHub) and with no SEO plugin; checklist script, Playwright and Lighthouse in the separate tests zip.

Homepage hero
* includes/home/render.php: new `hero_h1` value (hero_h1_text(), hero_h1_tag(), hero_h1()). Set, it prints <h1 class="wptm-home__herotext wptm-home__heroh1"> in the hero copy of every design (classic, split, floating, mesh, guide, slider) and the design's own heading becomes <p class="wptm-home__herotitle">. Empty, the first heading stays the H1 and slides 2 to n are <p> (were h2). A language whose tab leaves it empty does not borrow the default language's H1 (hero_h1_text() compares with raw_config()). hero_float_items() takes the attachment's alt text when a row has none. Slides and the classic background use front_size() (2048x2048, filter wptm_home_hero_size) instead of 'full'.
* includes/home/home.php, admin.php, translations.php: the field (default '', 120 characters), its row under "Main heading", its box in every language tab.
* includes/assets.php: the homepage hero preload is gone (the slider preloaded the full-size original while the <img> loaded the 768 px candidate: two downloads). Term-archive preloads are unchanged.
* assets/css/home-hero-slider.css: no rise animation on the hero copy; the first photograph's drift and the caption fade wait for `has-moved`, which assets/js/home-hero-slider.js sets on the first change of photograph. assets/css/home.css: style of the H1 line. Slides 2 to n were already deferred to data-* and fetched after `load` (verified: no request before the load event).

Links, dates, prices
* includes/indexes.php: url() returns '' while an index is switched off; all_url() falls back to the tours archive. Used by the homepage destinations and types sections (render-sections.php) and car hire (cars/render.php). Luxury's chrome already left the link out on ''.
* includes/Pricing/Service.php format(): NumberFormatter (locale@numbers=latn, plugin symbol, decimals only when needed) when ext-intl is loaded, else number_format_i18n() with the symbol after a no-break space (before it in English). Filter wptm_format_price. activities/class-activity.php (was-price) and guest-pass/data.php money() now go through it. Structured data keeps Money::toDecimal().
* Dates: already wp_date()/get_the_date() with date_format; Polylang's translated date_format gives "1 de noviembre de 2026" (verified). Nothing changed.

Structured data (includes/structured-data.php)
* Product @id {canonical}#product, mainEntityOfPage = the WebPage @id (Rank Math's, Yoast's, or {canonical}#webpage); description from the SEO plugin's meta description, then excerpt, then trimmed content, then lead; image = featured + up to 4 gallery photographs; brand = site name; sku = wptm_sku or post id; availability always set (InStock without a booking button).
* rank_math/schema/add_global_entities: true on an eligible tour. Rank Math adds no Organization/WebSite/WebPage/breadcrumbs to a singular whose post type schema is None, which is why the tour graph held the Product alone.
* No SEO plugin: one graph, Organization, WebSite, WebPage, BreadcrumbList (Breadcrumbs\own_trail(), the trail the page prints), Product. Beside SEOPress/AIOSEO/TSF: the Product alone, as before. No TouristTrip, Event or FAQPage anywhere.

Archive, feeds, legacy addresses (includes/seo.php, archive.php, legacy-urls.php, frontend/print.php, templates/archive-tours.php)
* archive_pages(): the published page at the tours archive's address in any language, followed through its Polylang translations. Its title is the H1, its content (shortcodes removed) the introduction on page 1, its SEO title and description the archive's (Rank Math, Yoast, or document_title_parts + the plugin's meta tag). SEO words on page 1 of the unfiltered list only (later pages keep the SEO plugin's numbered titles); a re-entry guard stops Yoast's for_post()->title from calling back into wpseo_title. A front page or posts page is never taken for it. Those pages leave every sitemap; one published at another address 301s to the archive; each other language's archive is added to the tour sitemap; a missing language gets a draft once (admin, daily check).
* Feeds of tour post types, archives and taxonomies: 404 on 'wp' priority 0 (before Rank Math's attachment redirect, which caught /tours/destination/x/feed/ through the tour attachment-feed rule), feed links hidden (feed_links_extra_show_*).
* own_canonical(): never on a search. robots_txt: Disallow /*wptm_print= inside the User-agent: * group. Print view: noindex, nofollow.
* legacy-urls.php: /tour/{slug}/ and /tour/{slug}/{anything}/ to the tour (current slug or _wp_old_slug), /ba_idiomas/, /ages/, /ba_duracion/, /ba-wishlist/ (and /tour/) to the tours archive of the address's language; one 301, before the address history and WordPress's guess. Filters wptm_legacy_tour_bases, wptm_legacy_archive_bases.
* pll_rel_hreflang_attributes: x-default = the default language's URL (filter wptm_hreflang_x_default).
* templates/cookies/parts/intro.php: the banner title is a <p> (the dialog's aria-labelledby already named it).
* includes/card.php: one SIZES for card photographs, 400px at desktop instead of 31vw.

Speed
* includes/assets.php: plugin_page(); no stylesheet given a 'path' for core inlining and the 60 KB inline budget filter removed; css-prune.php off by default (filter wptm_inline_used_css); served_version() replaces the source stamp with the served file's (was 2.19.2.<mtime>.<mtime>); scheme.js not for the consent sheet alone.
* includes/analytics.php, booking/source.php: attribution script on plugin pages, and wherever form_fields() prints a booking form; assets/js/attribution.js keeps the visit in memory and writes sessionStorage only after consent (and init no longer runs twice). Decision: a site with no consent authority at all stores nothing, so a booking on a later page of the visit records no referrer.
* includes/fresh-nonce.php (new): no nonce in an anonymous GET page; GET /wp-json/tripvance/v1/nonce?tour=ID (no-store), admin-ajax `wptm_fresh_nonce` when REST is closed to visitors, and an inline helper (wptmFetch + form filler; cookies sent; a failed fetch is forgotten; a submit goes on after 8 s). booking/handler.php nonce_field(), templates/sections/booking-activity.php, templates/transfers/booking-form.php, luxury/enquiry.php, assets.php (price and date-lookup config), Pricing/Service.php and experience/pricing.php (price boxes) use it; booking.js, salma.js, experience-total.js, group-pricing.js send through wptmFetch and no longer give up on an empty nonce. Same field names, same checks on submission.
* home/cache.php: flushed on language add/edit/delete and on Polylang language/translation term changes. luxury.js: the hero film starts after `load`. Intro video preload="none". Full-size originals replaced by 2048x2048 in sections/render.php, cars/render.php, home/moroccan-editorial.php, and 'large' for type tiles.

Known: the .rules.php indexes of home.css and home-hero-slider.css no longer match their rebuilt .min (only read when wptm_inline_used_css is turned back on, which then keeps those two as links). Luxury Journey hero images and their preloads still use 'full'.

= 2.19.2 =
Stability release after a Plugin Check run on 2.19.1 (3 errors in one sniff, 5 readme warnings). No behaviour, option, string, style or script changed.
* includes/admin/dashboard.php (rank_list, the most-requested-tours bars) and includes/admin/analytics.php (the funnel bars and the money bars): the width class printed with printf() now passes share_step() through absint(). share_step() already returns an int from 0 to 100, so the output is the same; WordPress.Security.EscapeOutput.OutputNotEscaped cannot see a function's return type and wants an escaping function at the point of output. The two share_step() calls in request_chart() build a string with sprintf() and are not output there, so they are untouched.
* readme.txt: the upgrade notices for 2.15.0, 2.15.1, 2.15.2, 2.15.4 and 2.15.6 (304 to 351 characters) are rewritten under 300 (upgrade_notice_limit); every notice in the file is now under 300.
* Lesson: a value printed straight into markup goes through an escaping function even when it is an int by contract (absint for numbers); a cast on the argument is not enough once a function sits between the cast and the output. Upgrade notices are counted by the directory in characters, 300 at most.

= 2.19.1 =
Tours → Settings (wptm-tour-settings), redesigned after three references MED supplied (a green finance dashboard with soft cards and icon tiles, the Kiska settings screen with a grouped icon navigation, switch rows and one Save button, an education dashboard with a greeting header). Presentation only: no option key, sanitiser, nonce, capability, hook or filter changed, and the form posts the same 137 fields as 2.19.0 (serialised with FormData after the scripts ran, compared name and value).
* includes/admin/settings-screen.php: each group heading carries data-wptm-group-icon (a dashicon: admin-appearance, calendar-alt, media-document, translation, admin-plugins, admin-tools). The long introduction under the title is gone; its two lasting points are a note at the foot of the navigation card (new string). The search box has its icon. The toolbar title is two spans: the group (data-wptm-setbar-group) and the section (data-wptm-setbar-title). "What a tour card shows" is fieldset.wptm-set-choices, its labels without the inline style.
* assets/js/settings-admin.js: the navigation lists the groups as links (.wptm-setnav__head: icon, name; aria-expanded) to their first section; only the group on screen opens its list (a search opens every group with a match). Choosing a group keeps the page at the group's top instead of jumping past its header. The group header (.wptm-set-intro: icon, name, line) replaces the one-line note. The narrow panel measures the toolbar when it opens (and on scroll while open) and drops below it — until now, at the top of the page, it opened over the toolbar and its own toggle. .is-dirty on the settings root while something is unsaved. Group matching is by data-wptm-nav-group instead of the label's text.
* assets/css/settings-admin.css (+ .min, esbuild 0.25.9): the navigation as a card (search field, group rows with icon tiles, the open group tinted with a solid icon, sections on a guide line with a mark for the current one); the toolbar as a card with the crumb, the amber unsaved pill and the green Save button; the group header; cards at 14 px radius with an edge-to-edge header; label column 220 px; one green focus ring for fields; quiet secondary buttons; table headers wrap between words only. Check boxes inside the cards drawn as switches (appearance: none, the knob in ::before replacing WordPress's tick; RTL knob mirrored; 42 × 24 px under 783 px; reduced-motion and forced-colours blocks). --wp-admin-theme-color set to the plugin green inside .wptm-settings so the design pickers and preview width buttons match. WordPress's !important margin on fieldset labels is overridden once, for the choice tiles. Logical properties only, no letter-spacing, no forced capitals (the uppercase group labels are gone).
* Verified by rendering the real settings_page() with E_ALL through a WordPress stub layer (luxury, language switcher and overlap sections included) in Chromium with WordPress's own admin stylesheets: 1440, 1024 and 390 px, right to left; every group from its head and from a section link; search (2/18) and Escape; the unsaved pill and ring; a switch toggled by its label; the phone panel opening below the bar and closing on a choice; no horizontal scroll, no JS errors, no PHP notices. The minified files render pixel-identical to the sources. tests/test-settings-2191.php (13 checks).
* About, the logo and the directory (same release, second request from MED, with a tutoring dashboard as the reference: a coloured profile sidebar, a greeting card with an illustration, stat tiles):
  - includes/admin/navigation.php: a new group, `about` (dashicons-info-outline, primary and only item wptm-about), last in groups(); wptm-about left the Settings group's items. Every module that adds a group through `wptm_admin_menu_groups` anchors it to a neighbour (services, tailor, transfers), so About stays the last sidebar entry, under Settings. The header comment no longer counts the entries. The directory title prints the group name in .wptm-directory__name and the number of screens in .wptm-directory__count.
  - includes/admin/status.php: comment only (System status now ends the Settings tabs, as its fallback already did).
  - includes/admin/settings-screen.php: the Admin menu option reads "one entry per area" and lists About among the sidebar entries (two strings).
  - includes/about.php: rewritten. WEBSITE constant (https://tripvance.net/). A side card (.wptm-about__side: the mark from assets/img/tripvance-icon.webp at 88 px, name, version pill, Author / Licence / Website as a <dl>, "Everything included" with five true statements), a hero (h2 "Hello, {display name}" escaped, "Hello" when there is none; the two lead sentences; Visit tripvance.net and Support forum; the logo from assets/img/tripvance-logo.webp at 240 × 60), three help tiles (forum, e-mail, website: icon, title, detail, link; new-tab links rel noopener, mailto in place), and the SEO note as a card. New strings for the facts, the checklist, the tiles, the greeting and the note title.
  - assets/img (new): tripvance-logo.webp (480 × 120, 15 KB, alpha) and tripvance-icon.webp (256 × 256, 3.7 KB), scaled from the files on tripvance.net and checked byte for byte (SHA-256 of the transfer) before being written. Shipped, never hot-linked: no request to tripvance.net from the admin.
  - assets/css/admin.css (+ .min): the About styles replaced (teal #015662 from the logo for the card, icons, links and primary button; orange #f39200 for the ticks only, never for text; container queries at 880 px for the two columns and 720 px for the hero; phone rules; forced colours). .wptm-nav__mark draws the shipped icon instead of the masked T on a green gradient; its forced-colours ::before rule removed. The sidebar menu icon is unchanged (a mask in currentColor, so it follows the admin colour scheme).
  - assets/css/dashboard.css (+ .min): the directory in CSS columns (230 px, break-inside: avoid) instead of a grid whose rows left holes under the short groups; a count pill per group; each link a row with a hover tint and a chevron (mirrored in RTL; reduced motion respected).
  - readme.txt: "Official website: tripvance.net" under the description (the Plugin URI already points there).
  - Verified the same way (real render() and directory callback through the stub layer, WordPress admin CSS, Chromium): About at 1440, 1024 and 390 px and right to left, the header bar with the new mark, the directory at 1440 px; no JS errors, no notices, no horizontal scroll. tests/test-about-2191.php (14 checks), tests/test-settings-2191.php still 13/13.
* Text width and contact (same release, third request from MED: no empty strip beside the text; the site's own address instead of a personal one):
  - The width caps on text are gone where they left a blank strip inside a card: 34 inline max-width styles in includes/admin/settings-screen.php (descriptions in ch, the reconcile, section-pages, generated-pages and landing-pages tables in px — their margins kept — and the PHP notice), includes/language-switcher.php (5) and includes/overlap.php (2 descriptions, 1 table); in settings-admin.css the 72ch on card descriptions and switch labels, the 78ch on the group header line, the 760px on the card-field tiles and the .wptm-settings-note rule; in admin.css the 60ch on the About lead and the 75ch on the About note. The footer-columns editor keeps its 760px: it is a form of inputs, not text.
  - includes/about.php: EMAIL is contact@tripvance.net, the address tripvance.net publishes on its home, About and Contact pages (MED wrote contact@tripvance.com; the site's own .net address was used — one constant if .com is wanted). No other file carried the personal address.
  - includes/admin/settings-screen.php: "your theme&#8217;s" and "Tours &#8594; Destinations" were inside esc_html_e(), so the entities printed as text; written as ’ and →.
  - Fields posted unchanged (137, compared again). tests/test-settings-2191.php now 16 checks (no inline max-width, no ch cap, no escaped entity), tests/test-about-2191.php 16 (site address, uncapped About text).

= 2.19.0 =
The bookings list (edit.php?post_type=wptm_booking) and the Tailor-Made Requests list and request screen, redesigned after three references MED supplied (hotel cards, a people table with avatars and status pills, a schedule). Presentation, plus three small additions; no meta key, nonce, capability, query argument, status rule, column key, hook or filter changed.
* assets/css/admin-list.css (new, + .min): one look for both lists (body.wptm-list, added by includes/assets.php on edit-wptm_booking and edit-wptm_tmr, enqueued there at admin_enqueue_scripts 30 after every other stylesheet of those screens). The title with its actions; the waiting notice as one soft pill; the views as tabs (counts in pills, a colour dot per status, for the tailor-made statuses too); #posts-filter as one card laid out by named grid areas (search, chips, filters, bulk, pages, table, foot; the tailor-made list puts its month filter beside the search) with the bulk-actions band tinted; the search as a rounded field with its icon; pill buttons; fields 38 px with one focus ring; thead quiet, rows on white with a hairline, hover and ticked tints (:has), a mark at the start of a new request's row. Below 1100 px each row is a card (the reference cell is display: contents so its reference, row actions and toggle are placed one by one; customer, trip, date and total always shown, the other columns behind WordPress's toggle with their names; what WordPress does for this under 783 px is repeated from 783 to 1099 px). Below 783 px no card frame, the quick filters scroll on one line, the filters fold behind the new Filters button, the bulk actions stay under the table as WordPress has them. Logical properties only, no letter-spacing, no forced capitals.
* WordPress 7.1 prints the check box as a td and the reference as a th: every rule matches .check-column / .column-primary whatever the element.
* includes/booking/admin.php: the quick filters show their count (matching_query() with the other filters kept, `m` passed as a query argument; left out while a search, a booking type or the bin is shown, so a number never disagrees with the rows it opens); the Filters toggle for a phone (aria-expanded true when one of the filters is in use); bare_view_count() takes the brackets out of the view counts (also used by the tailor-made views); list_avatar() draws the initials badge with Admin\initials() (one letter for Arabic) in one of six tones by post id, for both lists.
* includes/booking/manual.php + includes/admin/export-bookings.php: the title line's actions are one .wptm-list-actions group: Export CSV (BookingExport\list_link(): manage_options, the same admin-post action and nonce as the dashboard's form with both dates empty, so every booking) and Add booking.
* assets/js/booking-admin.js: advancedFilters() also drives the Filters toggle (.has-filters-toggle / .is-filters-open on #posts-filter; without script the filters stay open). assets/js/admin.js: the number of ticked rows beside Bulk actions, written into a .wptm-selected span it adds (aria-live polite; wptmAdmin.selected, "%s selected").
* Phone: WordPress sets every span of the row actions to font-size 0 and sizes only their links, which hid Quick view's label; it is sized again.
* includes/tailor/admin.php: views carry wptm-view--tmr-{status} and their counts without brackets, and WordPress's "Mine" view is left out as on the bookings list (saving a request from its screen makes the saver its author, so "Mine" counted the requests someone happened to save); the customer column has the initials badge and one line for e-mail and country; the search placeholder (tailor-admin.js, wptmTailorAdmin.search). The request head shows the initials (the icon when there is no name) and a WhatsApp button beside Email the traveller when the number has eight digits or more. details_box() prints three cards with rows_card() — Trip (arrival, departure, flexible, length, adults, children, total), Traveler (name, country, language, e-mail, phone / WhatsApp, consent), Preferences (interests and destinations as chips, travel style, accommodation, budget) — then Special requests; card() and chips_card(), now unused, are removed. Saving is unchanged (same nonce, notes and status fields; the Draft Tour form and its confirmation).
* assets/css/tailor-admin.css: the request screen in the booking screen's language (head card, initials, fact tiles, pill buttons; postboxes as cards; soft cards with an icon tile; status, Draft Tour and history boxes), phone rules; its old list rules and the uppercase, letter-spaced card headings removed.
* assets/css/booking-admin.css and admin.css: the list rules of 1.8.5, 2.0.1, 2.8.9 and 2.10.1 removed (booking-admin.css 82 KB -> 50 KB, 59 KB -> 36 KB minified; admin.css 153 KB -> 149 KB); the Quick view phone rules kept. The dashboard, the booking screen (desktop and phone), Add booking and the tours list render pixel-identical before and after.
* Verified on WordPress 7.1 (SQLite, PHP 8.3, E_ALL) with 37 bookings and 6 tailor-made requests: 1440, 1180, 1024 and 390 px, right to left; every status tab; a quick filter on and off with its applied chip and Clear filters; the travel-date conflict warning; a search (counts hidden, the subtitle on its own line); the bin; Advanced filters open and closed; ticking rows, select all and none; Quick view; the Move to menu; Quick Edit; the phone toggle and the Filters button (open by itself with a filter in use); the request screen's status move with a note, notes and the Draft Tour confirmation; no horizontal scroll, no JS errors, no PHP notices. tests/test-lists-219.php (14 checks).
* Not run in this session: PHPCS / Plugin Check (the sniffer could not be installed here), a PHP 7.4 binary, Polylang. Run Plugin Check before SVN.

= 2.18.0 =
The booking request screen (post.php for wptm_booking), redesigned after three references MED supplied (a travel dashboard with a departure/arrival bookings list and an activity timeline, a hotel booking confirmation with a stepper and a reservation and price summary, a delivery dashboard with order info and a status timeline). Presentation only: the form posts the same fields (147/151/132/139 on four bookings, compared name by name before and after), and no meta key, nonce, capability, status rule, hook or filter changed.
* includes/booking/admin.php: the edit_form_after_title header builds and filters the facts exactly as before (`wptm_booking_head_facts`), then prints each in its card. The keys the money and quote sections add are recorded ($wptm_base_keys / $wptm_money_keys) and go to the price summary; the date, the trip and the party (for a transfer: the trip, Pickup, Passengers, Requested Vehicle Type) are the ticket and are not printed twice; every other fact (crew, a site's own) stays with the trip. The section is .wptm-booking-head.wptm-bkx: the sticky .wptm-booking-head__row (initials badge, reference, saved and pending status with their data attributes, name, trip and date line, unsaved flag, Save changes), then .wptm-bkx__layout with the aside (cards) and .wptm-bkx__main (render_flow(), unchanged). The documents moved from the header row to the Documents card; the contacts and facts from under it to their cards.
* includes/booking/summary.php (new): person_badge() (Admin\initials(), one letter for Arabic), card_open(), fact_rows() (values in <bdi>), trip_card() with trip_ticket() / transfer_ticket() (Transfers\booking_data(): from, to, vehicle, return leg when ROUND_TRIP, passengers and bags), customer_card() (WhatsApp, phone, email as working links, the preferred one marked), money_card() (the money facts; the payment state as payment_tag() in the heading instead of a row; a native <progress> of the share received when a total and a balance exist; jump links to Payments and Price & services), status_times() and status_track() (received, contacted, confirmed, completed from the history: a skipped step is left out, an unrecorded time is not shown, a closed booking ends on its closing step), status_card() (the track, then status_box() inside #wptm-booking-status as before), documents_card() (Guest Pass and the three PDFs: open, download or a jump to the panel that issues them). The stylesheet is enqueued here on post.php / post-new.php for bookings only, after wptm-booking-admin.
* assets/css/booking-screen.css (new, + .min): scoped to .wptm-bkx (the tailor-made request screen reuses the older header classes and is unchanged). Container queries on the section: one column (cards, then the steps after the trip and the customer), two cards a row from 640 px, the work beside a 340–370 px column from 1000 px. The flow as a stepper (connectors drawn with :has(), done green with a tick, current in indigo, the open tab a soft pill), the lead as a banner with an icon square, the step's panels two to a row only when the step itself is 900 px wide (container query instead of the 1200 px window rule). Names of steps hidden on a narrow rail stay for screen readers (they were display:none). Logical properties, no letter-spacing, forced-colours and reduced-motion blocks.
* assets/css/booking-admin.css: the rules of the header's contact chips and document buttons (24 selectors) removed; the tailor-made request screen, the bookings list, the dashboard and the booking screen render pixel-identical before and after the removal.
* Verified on WordPress 7.1 (SQLite, PHP 8.3, E_ALL): a tour (confirmed, part-paid, pass active), a contacted request, a new request, a cancelled one and a round-trip transfer; 1440, 1280, 1024 and 390 px; right to left; every step tab opens its panels; a summary jump link lands on its panel; a status move with follow-up and reason saved from the Status card (track updated with the time); Confirm with the deposit from step 4 (status, received, balance, 20 % bar, track); sticky header pinned at 32 px; no horizontal scroll, no JS errors, no PHP notices. tests/test-booking-screen-218.php (status_track, 6 checks).

= 2.17.0 =
The admin dashboard, redesigned (Tours → Dashboard and its Insights tab), after two reference dashboards MED supplied: coloured figure tiles with an icon square, a week strip, faces of today's travellers, a ring of statuses, a two-bar chart, a timeline of the week. Presentation only: no option, meta key, status rule, capability, nonce, query argument, list filter or hook changed.
* includes/admin/dashboard.php: dashboard_page() is one board in the order the day is worked: hero() (date, H1, three facts, search, Add booking, More actions) then <hr class="wp-header-end"> so notices print under the head; focus_cards() from focus_data() (same four Booking\count_matching() filters, tones indigo/coral/green/night, full colour only with a count); a two-column board (.wptm-hq-board) with the queue and the week's trips in the main column and week_card(), agreed_card(), status_card() in the rail (sticky at >= 1280 x 840); weekly_figures() always open; calendar(); the export form in a card (printed only when BookingExport\form() prints something); wptm_dashboard_after unchanged.
* New helpers: initials() (first letter of the first and last word; one letter for Arabic, a joined script), avatar(), week_days() (seven days from calendar_rows(), which now remembers each month for the request, so the strip, the agenda and the calendar share one query per month), trip_row(), trips_and_guests(), week_counts() (the old weekly counting, returned instead of printed), status_url(), share_step() (0-100 in steps, for bar classes: no style attribute anywhere), request_chart() (HTML columns, right to left on an RTL site, scale as text, hidden from assistive technology because the figures are printed beside it). daily_requests() also counts the agreed requests of each day. sparkbars() is removed (no other reader).
* Upcoming trips reads calendar_rows() for the seven days (cancelled and declined left out, as the list's Next 7 days leaves them out), grouped in <details> per day with trips and guests on the fold; the first two days with trips are open. Each trip: pickup time (site format) or a "not set" mark, type icon, title, traveller, reference, guests, status, Quick view and the row menu. Capped at UPCOMING_SHOWN (60) with "N more in the list".
* status_card(): Booking\status_counts() as SVG arcs (pathLength 100, small gaps), each status a link to its filtered list with count and share. agreed_card(): the week's agreed share as a ring, links to Confirmed.
* An empty attention panel shows a green tick instead of the warning icon.
* assets/css/dashboard.css (new, + .min): loaded by dashboard.php on the dashboard and Insights only (admin_enqueue_scripts 20, after wptm-admin). Tokens on .wptm-hq (--hq-*), logical properties only, no letter-spacing, forced-colors and reduced-motion blocks; 2 columns >= 1280 px, rail under the queue below, two tiles a row <= 960 px, phone layout <= 782 px. Times use unicode-bidi: plaintext so "7:30 am" stays in order on an RTL screen and an Arabic time stays right to left.
* includes/admin/analytics.php: Insights uses the same board: the period as a row of links (same `days` argument, aria-current), four tiles, the funnel as bars, money per currency (agreed, received, outstanding) with a received bar; the attribution note kept. Translator comments sit on the line of each placeholder string.
* includes/admin/navigation.php + admin.css: the navigation row has the Tripvance mark (CSS mask, white on the brand green), the group icon after the name, pill tabs; a three-column grid (brand, tabs, All screens) so a group with many tabs (Settings) wraps its tabs inside the middle column. Phone unchanged (one scrolling row). The directory is a card with icon squares.
* assets/js/admin.js: the "On this page" strip skips .wptm-hq screens.
* assets/css/admin.css: 216 selectors of the old dashboard (cards, stats, spark, meter, pipe, rank, att, dash__panel/head/search/...) removed, with their comments: 175 KB -> 153 KB source, 137 KB -> 122 KB minified, on every plugin screen. The bookings list, booking screen, settings and operations render pixel-identical before and after (compared at 1440 and 390 px).
* Verified on WordPress 7.1 (SQLite, PHP 8.3, WP_DEBUG E_ALL) with 36 seeded bookings and with none: 1920/1440/1280/1180/1024/900/390 px, RTL (Arabic names), a user with edit_wptm_bookings only; Quick view from a queue row and from a face, Escape closes it, a folded day opens, the one-click move (Mark contacted, Confirm) with its notice under the head, month navigation; no horizontal scroll, no JS errors, empty debug.log. tests/test-dashboard-217.php (22 checks) for initials, share_step, daily_requests and request_chart. php -l on the changed files; a static check of text domains, translator comments and escaped output.
* Not run in this session: PHPCS / Plugin Check (the sniffer could not be installed here), a PHP 7.4 binary, Polylang. Run Plugin Check before SVN.

= 2.16.3 =
Private services, from the commissioning site.
* The cover badge ("Brochure", top corner) is gone: on a real cover it sat over the brochure's own logo. has_brochure() and the svc_badge string (four languages) had no other reader and are removed; Services\prime() no longer primes the PDF attachments.
* .wptm-svc-cover__link and its inset frame: border-radius 0 (archive and the "More private services" covers, one component). The service page's hero cover keeps its corners.
* assets/js/service.js: the footer seam runs on every services page (it was limited to a page ending on .wptm-svc__close / .wptm-svc__more), so the list page, which ends on its covers and its own bottom padding, meets the footer as well. Same limits: first in-flow visible element after the page, 1–240 px.
* Verified with the QA column at 1440 (15 px scrollbar) and 375 px, LTR/RTL: list and service page 0 px to the footer, no badge in the HTML, no horizontal scroll; DearFlip stage unchanged; empty debug.log.

= 2.16.2 =
Private services on the commissioning site sat in the theme's 1200 px column: the hero and the closing band stopped at its edges, and a 70 px strip of the theme's background separated the band from the Luxury footer.
* assets/css/service.css: main.wptm-svc bleeds with margin-inline calc(50% - 50vw) / inline-size 100vw (zero where the column already is the window); .wptm-svc--bled replaces that with measured margins (--svc-bleed-left / --svc-bleed-right); .wptm-svc--measuring puts it back in the column for one read; body:has(.wptm-svc) overflow-x clip. Wraps keep --wptm-container.
* assets/js/service.js (new, ES5, deferred, + .min): measures the column, sets exact fractional offsets (right to left included), and when main's last child is .wptm-svc__close or .wptm-svc__more, measures the space down to the first in-flow, visible element after the page (fixed, absolute, hidden and zero-height ones skipped) and closes it with a negative bottom margin, 1–240 px only. Re-run on resize (rAF), load and pageshow.
* Verified with a QA column (1200 px, 70 px under it, night footer printed at wp_footer) at 1440 px with a 15 px scrollbar and at 390 px, LTR and RTL: page and hero 0 → clientWidth, no horizontal scroll, band to footer 0 px; list page (ends on the covers) keeps the theme's gap; unboxed block theme unchanged; DearFlip stage 1136 × 876 unchanged; no JS errors, empty debug.log.

= 2.16.1 =
Private services, design pass for the commissioning site's identity. Verified on the same WordPress test site with a real DearFlip (JS edition, dearhive/dearflip-js-flipbook 1.7.36) rendering an 8-page PDF, at 1366 and 390 px (iPhone user agent).
* assets/css/service.css: palette as custom properties on .wptm-svc — --svc-accent #aa4a29, --svc-accent-hover #8a3a1f, --svc-ink #2a211b, --svc-muted #66594d, --svc-cream #f6f2ec, --svc-beige #ece3d7, --svc-night #2b2723, --svc-soft #a6a099, --svc-line-dark rgba(255,255,255,.16), --svc-on-night #f6f2ec (not turned in night mode). --wptm-gold follows --svc-accent so the shared focus ring matches. Navy and gold are no longer used on these pages.
* Brochure: no band colour, no white stage, no padding; .wptm-svc__stage > *, .df-container and ._df_book get background-color transparent !important (DearFlip writes its backgroundColor option inline, #fff by default; its WebGL renderer is created with alpha, so the pages show on the page). The wrap keeps the content width (1136 px usable at 1200) and drops to 6 px side padding under 600 px. DearFlip's auto height fits the book to the width, capped at the window height: 876 px container on a 1366×900 window, a single page 336 px wide on a phone.
* templates/single-service.php: the ask block moved to the end as aside.wptm-svc__close, a full-width night band with a hairline under it (the divider before the footer). "More private services" sits on the beige band before it.
* includes/services/services.php: the site accent is printed as --svc-accent with --svc-accent-hover at 81% of each channel (#AA4A29 → #8a3c21).
* Night mode remaps ink, muted, cream, beige and line; the terracotta and the night band stay.

= 2.16.0 =
Private services: a commissioned feature, shipped to every site and off until switched on. Reviewed on a real WordPress (7.2 beta, SQLite drop-in, PHP 8.3, E_ALL; Playwright).
* includes/services/services.php (new): post type wptm_service (public, REST for the block editor, has_archive, page-attributes for the Order field, rewrite slug \Tripvance\rewrite_slug( 'service', 'private-services' ), with_front false), registered at init 6 only while Services\enabled() (setting `services`, filter wptm_services_enabled). Polylang: translated content. Rewrite rules follow the switch: option wptm_services_rules holds the state they were built for ('0' / '1'), compared on admin_init 99, one flush on a change, then SEO\seed_rank_math_sitemap() when turned on. Templates routed at template_include 98 (theme first: single-wptm_service.php or tripvance/single-service.php; archive-wptm_service.php or tripvance/archive-services.php). Archive main query: menu_order ASC, date DESC, 12 per page. assets/css/service.css on both pages, after tokens; accent inline. Breadcrumb trail Home › list › service; post_type_archive_title → archive_title().
* Meta: _wptm_service_flipbook (the shortcode as typed, one line, tags stripped) and _wptm_service_pdf (attachment id, kept only when its mime type is application/pdf). flipbook() renders the field with do_shortcode() only when shortcode_exists() for its tag; otherwise '' and no section. has_brochure() (cover badge) reads without rendering. prime() warms posts, meta, cover images and PDFs of a list in two queries; others() takes limit+1 ids in the list order and drops the current one in PHP (no post__not_in).
* includes/services/admin.php (new): Brochure box in the sidebar (so the block editor shows it without the meta box pane): shortcode field with a warning when the shortcode is not registered, PDF select of the 100 newest PDFs (the chosen one kept however old), Upload a PDF link. Saved on save_post_wptm_service with nonce, autosave/revision and edit_post checks. Menu group "Private services" after Transfers (wptm_admin_menu_groups, priority 20).
* includes/services/i18n.php (new): 15 keyed strings (svc_*), English, French, Spanish and Arabic, literal gettext switch, Polylang string table override, on the shared registry.
* templates/single-service.php, templates/archive-services.php (new). Every block is drawn only with content. Covers: cover image full bleed, scrim, inset frame, title, two-line excerpt, Discover; a navy cover without an image; a Brochure badge when there is a flipbook or a PDF.
* Permalinks: 'service' joins segment_keys(), editable_keys(), english(), labels(), suggestion() (en private-services, fr services-prives, es servicios-privados) and rebase(); rewrite_slugs() source; Slugs\owners() and the archive-rule copy list; the translated-base canonical redirect lists; Addresses & SEO examples and counts. live() keeps the field off the screen while the module is off. A stored configuration from 2.15 has no 'service' key and reads the English default.
* settings.php / settings-screen.php: `services` (checkbox), `services_title`, `services_intro` (kept from storage when a writer does not send them); row after Transfers with the list address and a link to Addresses & SEO. pll-strings.php: the two texts are translatable settings. db.php: wptm_services_rules in PRIMED_OPTIONS (no extra query when absent). uninstall.php deletes it with the other flags (services themselves are kept, like every post). navigation.php: wptm_service in the add-new list. seo.php: wptm_service in the description fallback and the Rank Math sitemap seed. tokens.css: .wptm-svc gets the shared tokens, focus ring and reduced motion. service.css carries its own night-mode colours (html[data-wptm-scheme=dark]).
* Verified: module off by default (no post type, no Addresses field, rows kept when switched off, 404 off / 200 on); settings form saves through the grouped screen; /private-services/ and /private-services/bird-watching/ 200 at 1366 and 390 px with no horizontal scroll and no JS errors; flipbook shown with a registered shortcode, hidden (no raw shortcode in the HTML) with an unregistered one, editor warning shown; PDF download; WhatsApp then e-mail fallback; no-image cover; 14 services → 2 pages; custom title in H1 and <title>; Addresses & SEO: clash with /transfers/ refused by name, private-services → experiences → private-services with 301s both ways (list, single, page/N) and no loop; block editor save of the Brochure box; Arabic RTL (mirrored, arrows flipped, no tracking on the eyebrow); night mode readable; E_ALL crawl of 16 Tripvance admin screens and the public URLs with an empty debug.log; php -l on every changed file.
* Not run in this session: Polylang (its build needs Composer, refused here), Rank Math, a classic theme, PHPCS / Plugin Check and a PHP 7.4 binary. Run Plugin Check before SVN.

= 2.15.6 =
Stability and speed review on a real WordPress (7.2 beta, SQLite drop-in, PHP 8.3, E_ALL; Playwright).
* includes/css-prune.php (new, core): on the homepage, single tours/activities/transfers, their archives and the plugin taxonomies, template_redirect (PHP_INT_MAX) starts an output buffer. Its callback holds every chunk (an early flush is delayed, ob_clean() honoured) and, on the final pass, replaces each <link rel=stylesheet media=all> whose href is WPTM_URL assets/css/<name>.min.css and which has assets/css/<name>.rules.php with <style id="(same id)"> holding the rules the page can use. A rule is kept when, for any of its selectors, every "required" class is a token of the page (the HTML with <style> blocks removed: class attributes, data attributes, templates and JSON all count). Rules are emitted in file order; at-rule blocks are reopened per original block, so the cascade is the file's. Unconditional at-rules (@keyframes, @font-face) always stay. Any mismatch keeps the <link>: SCRIPT_DEBUG, a stale .min (not served, so not matched), a rules file whose recorded size differs from the .min.css, a media other than all, an exception. Filter wptm_inline_used_css (bool). Previews (wptm_home_preview, wptm_builder) keep every rule.
* assets/css/*.rules.php (generated, 22 sheets: home, tour, card, booking, booking-steps, booking-panel, salma, experience, activity, transfer, home-hero-slider, home-hero-guide, home-journey-ticket, home-moroccan-editorial, home-editorial-mosaic, sahara, qamar, sara, atlas, medina, index, sections). Built from the .min.css by build-css-rules.py (developer kit, not shipped). "Required" = a class that occurs in the plugin's PHP or templates (literally or under a dynamic prefix such as 'wptm-home__hero--style-' . $style) and in none of its JavaScript files (literally or under a string prefix concatenated in JS) and is not inside :not()/:is()/:where()/:has(). A class a script can add, a theme or WordPress class, a selector without such a class: the rule is unconditional. The builder refuses to write an index whose rules, flattened with their condition chains, differ from the stylesheet's.
* includes/assets.php: the 2.15.4 inliner leaves a sheet with a rule index as a <link> while css-prune is active (CssPrune\active()), so it is pruned instead of inlined whole.
* Verified: computed styles (every property of every element and ::before/::after) identical with and without pruning on 36 public URLs at 390 and 1366 px, and on the homepage in 7 configurations covering every hero, search, featured, destinations, trust, why, gallery, CTA, signature and travel-guides design (all sections on, with content); screenshots identical after opening the review dialog, the photo viewer and the tour gallery. The only differences seen were run-to-run (content-visibility estimates, image decode timing) and flipped between runs. Sizes: homepage 144 KB → 36 KB (seeded site) / 67–97 KB (every section on); tour list 150 → 43 KB; tour page 150 → 50–59 KB; HTML +6–9 KB gzipped against 23–25 KB gzipped and one blocking request removed. rewrite() costs ~1.6 ms per page.
* includes/assets.php enqueue_scheme(): dark.css is no longer enqueued (it was media="not all": not blocking, but 66 KB downloaded on every light page). wp_add_inline_script( 'wptm-scheme', window.wptmDarkCss = <minified URL>, 'before' ); assets/js/scheme.js check() creates <link id="wptm-dark-css"> on the first dark result, appended to <head>, and still switches an existing media="not all" link from a cached page. Guard is now wp_script_is( 'wptm-scheme' ).
* includes/home/home.php: the hero branch returned after printing home-flush inline (2.15.4), which skipped the wptm-home script on every homepage with a hero — review dialog, photo viewer, testimonials slider, scroll-down link and hero video controls were dead. Now Tripvance\enqueue_head_flush() (assets.php), shared with includes/archive.php, where home-flush.js was still a blocking external <script> in the head of every tour list and term page.
* includes/db.php: PRIMED_OPTIONS + AUTOLOADED_FLAGS go through wp_prime_option_caches() right after the core modules load (bootstrap.php): one SELECT at most, none when all are autoloaded. Measured before: 8–9 single-option SELECTs on every front-end request, ~25 on wp-admin and admin-ajax (heartbeat). AUTOLOADED_FLAGS gains 16 once-written flags (wptm_layout_default_60, wptm_seo_141, wptm_sitemap_seeded, wptm_slug_signature, wptm_pass_route, wptm_activity_flush, wptm_transfer_rules, wptm_transfer_flush, wptm_tmr_no_wellness, wptm_home_priority_backfilled, wptm_me_three_up, wptm_landing_repaired, wptm_landing_slugs_fixed, wptm_product_redirect, wptm_home_cache_ver, wptm_cars_cache_ver); their writers now pass autoload=true. FLAGS_REVISION in tables_stamp() makes confirm_tables() run autoload_flags() once on upgraded sites.
* includes/departures.php: next_open() memo moved to next_open_memo(); prime_next_open( $ids ) answers "no open departure" for a whole list with one SELECT DISTINCT tour_id … IN (…) (left to the per-tour path when the table errors). includes/card.php prime() calls it for tours and activities.
* includes/home/render-sections.php: featured, activities and travel guides use Card\prime() (posts, meta, terms, featured images, gallery strip, departures) instead of _prime_post_caches() without meta.
* includes/assets.php (single tour): hero, featured image and up to 40 gallery attachments primed in one query before the head; includes/class-tour.php related(): the picks primed before get_post_status() per pick.
* Queries on the seeded test site (WP_DEBUG, no object cache): homepage 70 → 59, tour list 48 → 36, tour page 58 → 42, term page 49 → 43, blog post 32 → 27, 404 32 → 25.
* Checked: php -l on every changed file; no cross-file duplicate declarations (namespace + name) and no PHP 8-only tokens in the plugin; activation and E_ALL crawl of 36 public URLs and every Tripvance admin screen with an empty debug.log.

= 2.15.5 =
PageSpeed Insights (experiumorocco.com, mobile: TBT 0, CLS 0, LCP 4.3 s, Speed Index 9.2 s).
* assets/js/home-hero-slider.js (+ .min.js, edited in step): sync() adds 'complete' !== document.readyState to the is-paused condition — the drift on the active photograph and the progress fill (the slideshow's clock) are paused by the existing CSS until window load. load( 1 ) runs at load instead of at start-up. Before, the clock and the drift ran from the first frame and the second photograph (272 KB full size on that site) was fetched with the page.
* includes/assets.php: the PHP_INT_MAX wp_enqueue_scripts hook also walks the queue's dependencies and enqueues by name every wptm-* sheet that has a 'path' — wp_maybe_inline_styles() reads $wp_styles->queue only, so tokens and card (dependencies of wptm-home / wptm-tour) were never inlined. styles_inline_size_limit → max( limit, 60000 ) on the front end (tokens 5.5 + brand 5 + brand-square 7 + slider 14 + card 19.6 KB).
* includes/home/render-sections.php section_featured(): $lead = the section is first in active_sections(); cards get 'eager' => $lead && 0 === $i, Moroccan Editorial 'eager' => $lead. Spotlight and ticket layouts unchanged.
* Not verified on a live WordPress in this round (the sandbox refused to run WordPress core); php -l, node --check, a stub self-check of the dependency walk.
* Not the plugin's: the theme's animated GIF logo (Manassa 2.25.3 now loads it after the page), Contact Form 7 on every page (Manassa 2.25.3 loads it only where a form is printed).

= 2.15.4 =
PageSpeed Insights (experiumorocco.com, mobile 75): what belonged to the plugin.
* includes/assets.php: minified_path( $relative ) carries the rules minified_src() applied (SCRIPT_DEBUG, wptm_use_minified_assets, readable, newer than the source, more than a tenth of it) and returns a path; minified_src() builds its address from it. inline_source( $relative ): the file's code for printing inline, '' above 8 KB. A wp_enqueue_scripts hook at PHP_INT_MAX gives every registered wptm-* stylesheet served from WPTM_URL with media "all" (not wptm-cookie-consent, not anything already carrying a path) a 'path' = its minified file, so core's wp_maybe_inline_styles() (wp_head/wp_footer at 1) inlines them smallest-first within its budget (20 KB, 40 KB from 6.9) and rewrites relative url()s. dark.css is media "not all" and so never inlined — the inline printer drops the media attribute. The slider hero (hero_style 'slider') is preloaded: Home\hero_float_items( $image, '', 1 ) → first slide, 'full', '100vw', exactly as hero_slides() draws it.
* includes/home/home.php: wptm-home-flush is registered with src=false and printed through wp_add_inline_script( inline_source( 'assets/js/home-flush.js' ) ) in the head; the external enqueue remains as the fallback when the file cannot be read.
* assets/js/home-flush.js start(): the first flush() goes through later() (requestAnimationFrame) — before the first paint, after the parser's own layout — instead of running synchronously in the MutationObserver callback, where getBoundingClientRect() forced a layout of the half-built page (88 ms on the trace's mobile CPU).
* includes/cookies/frontend.php: style_loader_tag for the consent sheet → media='print' onload="this.media='all'" data-wptm-cc-css, plus a <noscript> copy of the original tag. assets/js/cookie-consent.js whenStyled( cb ): show( false ) and the #cookie-settings open wait for that link's load event (or 1200 ms), so the banner never paints before its rules.
* assets/css/cookie-consent.css: contain: layout style was on .wptm-cc .wptm-cc__surface (the 2.15.2 edit matched the wrong block); now on .wptm-cc, the fixed root, where the isolation matters.
* assets/css/home-hero-slider.css: .wptm-home__slidesdot min-inline-size 24px, padding-inline 8px; .wptm-home__slidesnav gap 4px.
* Verified on a real WordPress (7.2 beta, SQLite drop-in) with Playwright at 412×823 @1.75: no JS errors; consent sheet non-render-blocking then media=all; banner shown fixed with contain; dots 50×44; flush inline, no request; preload and <img> resolved to one download (hero-768x494.jpg); no Set-Cookie. Lighthouse 12 (mobile, simulated) on that page: no plugin forced reflow, LCP discoverable with preload; the remaining plugin items are home.min.css (144 KB raw, 96 % unused there, 83 % on the live site — 47 marker-delimited sections, a split per homepage section is the next step) and card.min.css (unused only on a page without cards).
* Not the plugin's: the theme's style.css and theme.js (unminified, no cache TTL), Google Fonts in the critical chain, the two animated-GIF logos (1.5 MB), JPEG photographs without WebP, uploads' 7-day cache TTL, image compression.

= 2.15.3 =
Hotfix.
* includes/documents/store.php: file_name( int $id ) → stored_file_name( int $id ). pdf.php declares file_name( array $doc ) (the download name) in the same namespace; both files load on every request, so 2.15.1 and 2.15.2 died with "Cannot redeclare" before WordPress printed anything. Found by activating the plugin on a real WordPress (SQLite drop-in) rather than by php -l, which cannot see a redeclaration across files. The on-disk names are unchanged: same HMAC, same shape.
* includes/db.php confirm_tables(): the stamp is written only when all_tables_exist() AND VERSION_OPTION === SCHEMA_VERSION. On a real install where a migration step returned false, 2.15.2 wrote the stamp from the tables alone and maybe_install() then trusted it, so the step would never have been retried. The init hook now calls confirm_tables() then maybe_install(), nothing else.

= 2.15.2 =
Performance: Core Web Vitals, full-page-cache cleanliness, per-request query budget, wp-admin.
* assets/js/cookie-consent.js: save() writes the state and hides the banner, then runs signalGoogle(), emit() and releaseAccepted() through afterPaint() (requestAnimationFrame → setTimeout 0): the DOMParser work and the third-party tags leave the click task. start() releases a returning visitor's tags through whenIdle() (requestIdleCallback, timeout 1500; setTimeout 120 fallback). Withdrawal still reloads first.
* includes/cookies/frontend.php: wp_script_add_data( HANDLE, 'strategy', 'defer' ); the script_loader_tag filter drops data-no-defer and keeps data-cfasync/data-no-optimize/data-no-minify/nowprocket. held_scripts() already omits empty categories, so script_config() needed no change.
* assets/css/cookie-consent.css: contain: layout style on .wptm-cc and .wptm-cc-reopen. prefers-reduced-motion already stops wptm-cc-in.
* includes/db.php: TABLES_OPTION ('wptm_db_tables', autoloaded) holds tables_stamp() = SCHEMA_VERSION . ':' . $wpdb->prefix. confirm_tables(): a matching stamp fills table_memory() for tables() and returns true with no query; otherwise all_tables_exist() and, on success, the stamp is written and autoload_flags() runs. Both request hooks (init 5, admin_init maybe_install) call it first. installed() delegates to table_exists( table() ) — one memory, one query at most. install() calls forget_stamp() beside forget_tables(). maybe_install() writes VERSION_OPTION with autoload=true and the stamp after the migrations. AUTOLOADED_FLAGS lists the twelve once-written options; autoload_flags() hands them to wp_set_option_autoload_values() when WordPress has it (6.4+).
* includes/admin/status.php report(): DB\forget_tables() first, DB\forget_stamp() when a table is missing, so the diagnostic always reads the database and a found fault routes the next request through the repair path. uninstall.php deletes wptm_db_tables.
* Pricing/integrity.php, brand-upgrade.php, quotes/render.php, booking/post-type.php, partners/partners.php, resources/resources.php, luxury/luxury.php, admin/settings-screen.php, archive.php, duration.php, migrate.php: the stamp options are written autoload=true; maybe_migrate(), Duration\upgrade(), the three grant_capabilities() hooks, the quote-route and switch-repair hooks and the rewrite check return early on wp_doing_ajax().
* includes/analytics.php: capture() keeps only the legacy clean-up — delete the httponly cookie an older release wrote when consent is absent; it writes nothing else, so no public GET carries Set-Cookie. write_cookie() removed. script_config() (name, path, domain, secure, days, codes = code_params(), consent = api | tripvance | always | never) is printed before 'wptm-attribution', which analytics.php now enqueues (defer) on every public page unless consent is 'never'; Booking\Source keeps its own enqueue of the same handle. The wptm_booking_created hook falls back to from_request() when the cookie is empty.
* assets/js/attribution.js: the remembered first page now keeps its query string and tour id (body classes single-wptm_tour + postid-N). sync(): consent from wp_has_consent('statistics'), window.tripvanceConsent.has('analytics'), or the fixed answer; writes {source, medium, campaign, term, content, partner, referrer, landing, at} from the first page, first touch wins, a later code (codeIn(location.search) || codeIn(visit.search)) overrides partner, no consent clears the cookie; cookie not httponly, SameSite=Lax, Secure on https, 900-byte cap. Re-runs on tripvance:consent and wp_listen_for_consent_change.
* assets/js/tour.js measureHeader(): atTop/rootTop read before the probe; --wptm-offset and padding written last. readBasePadding() reads the stylesheet's padding-top once at start and on resize (re-applying base + clearance); the scroll loop no longer calls removeProperty()/getComputedStyle().
* includes/assets.php: defer on wptm-card, wptm-scheme, wptm-salma; indexes.php, cars/cars.php, home/home.php (wptm-home, wptm-home-hero-slider), luxury/composer/composer.php: defer. wptm-home-flush stays blocking in the head (anti-flash by design); wptm-request-id is inline.
* includes/booking/attention.php waiting_counts(): static per-request memo (menu, toolbar, toolbar follow-up).
* includes/admin/dashboard.php: booking_rows() keeps static $row_cache (id → row) and static $assignments; only unseen ids are primed and built. attention_rows(): one prepared SELECT with LEFT JOIN wp_postmeta ON meta_key='_wptm_status', WHERE meta_id IS NULL OR meta_value IN (pending, contacted, confirmed), post_status NOT IN (trash, auto-draft), ORDER BY post_date DESC LIMIT 1000 — the same set the OR/NOT EXISTS meta_query returned without its join over every meta row. The ledger was not used as the source: DB\backfill() is never scheduled, so pre-ledger bookings would be missed.
* Rebuilt: cookie-consent.min.js/.min.css, attribution.min.js, tour.min.js (terser / csso).

= 2.15.1 =
Technical fixes from the 2.15.0 review.
* documents/store.php: store_pdf() writes the bytes through WP_Filesystem to uploads/wptm-documents (files_dir(): wp_mkdir_p + .htaccess "Require all denied", index.php, web.config, re-written whenever missing), file name doc-{id}-{24 hex of HMAC-SHA256(id, wp_salt('auth'))}.pdf (file_name()), reads it back and compares SHA-256 before the row is written; the `pdf` column then holds "file:<name>" (FILE_POINTER). Any failure falls back to the Base64 row as before. stored_pdf() follows a pointer (file_path() accepts only the exact file-name shape, so a tampered row cannot name another path) or decodes Base64; hash_equals() against pdf_hash in both cases. store_pdf() returns early when an intact copy exists, because rewriting the deterministic file with a fresh render would orphan the row's digest. delete_file() (SELECT … WHERE pdf LIKE 'file:%', so a Base64 row is never pulled) runs before the row deletes in delete_files_for() and in finish_document()'s pending clean-up. uninstall.php states that the folder is kept with the tables.
* bootstrap.php: class_file() + autoload() on spl_autoload_register — a lookup for Tripvance\Tour and its four traits, and Tripvance\Pricing\<Word> → includes/Pricing/<Word>.php when that file exists. Those sixteen files leave modules('core'); integrity.php, Service.php and clarity.php stay (procedural, they register hooks). class_exists() guards elsewhere keep working: they autoload.
* reservations.php: lock_key( tour, date, departure = 0 ) — "wptm_{tour}_{departure}_{date}" when a departure is named, the old "wptm_{tour}_{date}" otherwise; acquire_lock()/release_lock() take the optional third argument. reserve() and revise() take the lock only when $capacity > 0 and release it with the same departure.
* payments/public.php: claim_throttled() uses Security\origin_key() and returns false on '' (no shared "unknown" bucket). handle(): CLAIM_AMOUNT_MAX (ten million major units); an amount <= 0 or above it is stored as no amount.
* seed.php term_name(): a case for every place in structure(). i18n/starters.php: Arabic entries for the twelve places, 'Meknes' => 'Mequinez' in Spanish, the Arabic Ouarzazate highlight ends on ورزازات so the card finds its term. ensure() already receives the English source as $also, so a site seeded in 2.15.0 adopts its Latin-named Arabic terms rather than minting Arabic duplicates.

= 2.15.0 =
Technical SEO, Core Web Vitals and generated-page languages.
* structured-data.php: offers() adds availability() — InStock when the page offers a booking (has_cta()), OutOfStock for a scheduled tour with no open departure (Departures\next_open(), memoised, the ticket's own answer), left out when the page has no way to book; filter wptm_product_schema_availability. The Rank Math enrichment is enrich_graph(), shared with Yoast: merge_offer() lays Tripvance's Offer over a single Offer already there (priceValidUntil, seller… kept; priceSpecification dropped, it states a price of its own), and replaces a list or an AggregateOffer; on request still unsets offers. price_is_obtainable(): seasons_cover_year() merges the seasons' mm-dd ranges (wrapping ones split at the new year, real_day() skipping 30 February and the like) instead of 366 PriceRequest objects per view; SeasonRule::from()/to() added. Checked against the old walk on 3,000 random season sets.
* structured-data.php: yoast_graph() on wpseo_schema_graph (20) — enriches a Product Yoast already has for the page, else appends one with mainEntityOfPage → Yoast's WebPage @id (context main_schema_id, canonical as fallback); yoast_handled() keeps the standalone block (wp_head 30) silent once Yoast took it. Yoast with its schema switched off still gets the standalone block; wptm_product_schema_print returning false leaves Yoast's graph untouched, as it left the page before.
* seo.php: sitemap_images() on rank_math/sitemap/urlimages and wpseo_sitemap_urlimages (20): tour hero + gallery (Tour::gallery()), activity hero (Experience\hero_id) + gallery, landing hero (Landing\value), homepage hero per language (overrides_for, else the default; not for the mesh and split heroes). src/title/alt entries, addresses already listed skipped; filter wptm_sitemap_images.
* indexes.php: sitemap_links() (the language loop, shared with core's sitemap) and sitemap_xml() appended through rank_math/sitemap/{taxonomy}_content and wpseo_sitemap_{taxonomy}_content — first page only (sitemap_n), switched on and with terms. tour_photos(): fields ids + no_found_rows, update_meta_cache + one wp_get_object_terms( all_with_object_id ), transient wptm_idx_photos_{md5(taxonomy|lang)} for a day, dropped on save_post_wptm_tour for every taxonomy × language.
* archive.php: price, duration and rating sorts no longer set a meta_query (EXISTS OR NOT EXISTS: a join on post_id alone, every meta row of every tour, GROUP BY, filesort). numeric_sort() + posts_orderby: a correlated subquery on (post_id, meta_key) — numbered first, in their order, then the rest, newest first; an empty value is no number; a duplicated meta row cannot repeat a tour. Same order as 2.14.2 for numbered tours on MariaDB 10.11 (the unnumbered ones were ordered by whichever row GROUP BY kept), no temporary table.
* assets.php: preload_hero() (attachment, size, sizes the template uses; an address-only hero as itself) and own_template(). wp_head 5: term archives drawn by templates/archive-tours.php with a term image (hero_size(), hero_sizes_attr()); the homepage when the hero is its first section — image (full, 100vw), guide (large, its sizes), split (the spotlight tour's hero at 800×800, else the hero image). Not in the Luxury view. activities/template.php: preloads Experience\hero_id() (it preloaded the featured image while the page drew the chosen hero) with the template's booking-column test, Section Manager included. landing.php: srcset and sizes 100vw with the 1536 preload (the img picked another candidate).
* sections/render.php: image() lazy by default; eager + fetchpriority high only for render_hero(). render_faq(): the question is h3.wptm-sec__faq-q-title in the summary (sections.css resets it to the summary's type). Heading levels checked on the single, archive, home and landing templates.
* home-flush.js: reads first — the hero's own top is its position minus the pull already applied — and writes only when the gap changed; the old reset-and-measure only when the arithmetic disagrees (a collapsing margin). Load and resize runs in requestAnimationFrame; the first stays synchronous, before the first paint. min rebuilt.
* lang.php: in_language() fills gettext_tripvance / gettext_with_context_tripvance from starter_words() (includes/i18n/starters.php: 155 sources × fr/es/ar, filter wptm_starter_words) only where gettext returned the source, so a language pack still wins. Covers the landing definitions and generic seed, the page starters, seeded term names and the home/car-hire titles; highlight lines end on the same term name the table gives the term (Ouarzazate stays Ouarzazate). ensure_translated_pages() takes $slug_for; translated_slug() uses it when wp_unique_post_slug() (asked as publish) leaves it free, else {base}-{lang}. home/admin.php and cars/admin.php pass accueil/inicio/alraisiya… and location-de-voiture/alquiler-de-coches/kira-al-sayarat… through wptm_page_slug.
* seed.php: ensure() takes the English source as $also — a term in that language still carrying it (by name, or by its slug "{source}-{lang}" or "{source}") is adopted, so re-seeding a site seeded before 2.15.0 without a language pack links the terms that hold the tours instead of minting "Privé" beside "Private".
* i18n: 180 built-in values revised (fr 37, es 48, ar 95) in translations.php, i18n-ops.php, luxury, tailor, transfers, activities and cookies — calques ("jours de préavis", "Pedir un precio", "جيد أن تعرف"), grammar, one form of address per page (es), one word per concept (pick-up: الاصطحاب, travel pass, travel designer). Keys and placeholders unchanged.
* Checks (outside the plugin): schema 3,024 (season coverage vs the old walk, merge, availability, graphs), archive sort 25 (MariaDB), sitemap 16, seed 5, lang 1,898 (every starter string: placeholders, emphasis, fields, term references; fallback order), render 8, home-flush in Chromium 8 (source and min). A separate review of the whole diff found the two issues fixed above (Yoast opt-out, re-seeding) and nothing else.

= 2.14.2 =
Stability release after a full review of 2.11.9–2.14.1.
* booking/admin.php: add_screen_option( layout_columns max 1 ) moved from the generic add_meta_boxes hook to add_meta_boxes_wptm_booking (it reached every edit screen). Deposit handler (save_post 30): records the payment even when the status row confirmed the booking earlier in the same save, then skips set_status; a non-empty amount that to_minor() cannot read or is ≤ 0 sets the deposit error and confirms nothing.
* booking/flow.php: flow_checks() — per-step checklist from the booking (step 2 written to / marked contacted; 3 price, WeTravel link, pass issued, pass sent by guest_pass mail or pass_payment/pass_ready WhatsApp; 4 claim, deposit, confirmed; 5 pick-up, guide, complete pass = pass_ready WhatsApp or a guest_pass mail after confirmed_at()). WeTravel field and the deposit block above the step's buttons (the deposit block sat inside the button row). A claim moves flow_step to 4. pass_payment WhatsApp offered only with an active pass. Claim time read through get_gmt_from_date(). WeTravel input type text + inputmode url (a hidden invalid url field blocked form#post).
* booking/workspace.php: fact 'claimed'; next step "The guest says the deposit has been sent…" pointing at Payments.
* guest-pass/render.php: full() = confirmed or completed (filter kept; the 2.11.9 paid-or-near rule is gone, the light pass is the deposit stage's); payment_actions() only for state pending; payment_settled() shows "Paid in full" only with a known balance; pass_soon_text reworded ("once your booking is confirmed") in en/fr/es/ar.
* guest-pass/actions.php: guest actions (pick-up confirmation, arrival) require a confirmed booking (a contacted booking's pass is servable).
* seo.php: marker stores the excerpt as WordPress saved it; an excerpt equal to the plain lead (or its kses-encoded form) is the copy — no rewrite every save, no loss after restore; duplicate get_the_excerpt filter removed (post-types.php has it). class-tour.php summary(): same recognition.
* duration.php: upgrade groups by language, days and the word (case-insensitive), so only case duplicates merge; wptm_duration_merged written only when non-empty, autoloaded.
* whatsapp/templates.php: pass_payment default puts {pass_url} on its own line.
* experience-editor.js: one invalid event handled per submit (400 ms), the clearing listener added once. booking-admin.css: half panels in the grid line up (row-gap, same top rule for every panel), checklist styles.
* Checks: review of the full diff by a separate pass; duration 25, excerpt 9 (incl. kses), claim form; pass renders (deposit, confirmed unpaid, part paid, paid in full, Arabic, transfer); booking flow rendered through stubs for contacted / claimed / confirmed.

= 2.14.1 =
Stability release.
* booking-admin.css: .is-stuck adds a shadow only (2.14.0 shrank padding, thumbnail, name and reflowed the actions — the height change moved the sentinel back into view and the bar toggled every frame near the top). body.js.post-php.post-type-wptm_booking:not(.wptm-ws-on) hides #postbox-container-1/2 until show() has moved the step panels (visibility, revealed after 2.5 s by animation whatever happens); scroll-margin-block-start 150px on postboxes, notices, the step fields and the status row so land() stops below the sticky row.
* guest-pass/render.php: payment_settled() for confirmed/completed passes (paid in full; paid X + rest at the meeting; all at the meeting) — no methods, no deposit line, no claim form. section_itinerary() prints the day label and title only.
* Booking form: setting form_pickup (default off, also for existing sites) gates the pick-up field and its summary row (templates/sections/booking-form.php) and the experience form's pick-up (experience/booking.php). Transfers unchanged.
* i18n: pass_paid_full_title, pass_paid_full_text, pass_paid_part, pass_rest_on_meet, pass_all_on_meet (en/fr/es/ar + gettext).
* Checks: pass renders for deposit / confirmed unpaid / part paid / paid in full / Arabic / transfer at E_ALL.

= 2.14.0 =
Guest Pass at the deposit stage; confirm with the deposit; edits in History; sticky header.
* guest-pass/lifecycle.php: servable_statuses() adds 'contacted' (pass state 'pending', label pass_state_pending now "Awaiting deposit" in en/fr/es/ar). render.php full(): only a confirmed (paid or within the reveal days) or completed pass is full; payment_actions() also for state 'pending'; deposit_minor() shared by pay_later() and the claim; the claim form gets the pass's methods and the amount due (deposit while nothing is paid, else the balance).
* payments: methods() + western_union, moneygram, wetravel (brand names in method_label()); claim( $id, $ref, $extra ) stores method and amount_minor, history note says what was claimed; claim_of() returns them; public.php handle() reads payment_method / payment_amount (to_minor); claim_form() prints the method select and amount when given.
* booking/flow.php: step done-ness — confirmed + active pass counts for step 5 only with a pick-up time/place or guide on it. Step 3: flow_pass() (issue / open / email, now for contacted), WhatsApp pass_payment first. Step 4: flow_deposit() — claim notice, amount (claim, else deposit share of the total), method (claim, else WeTravel), reference, button wptm_confirm_deposit (submits form#post, confirm dialog via admin.js); "Confirm without recording a payment" as the secondary. Step 5: "Pick-up, guide & vehicle" jump. Step texts rewritten.
* booking/admin.php: save_post 30 handler — nonce wptm_confirm_deposit_{id}, can_manage, Payments\add() (refusal → wptm_deposit_error_ transient, no confirmation), then set_status( confirmed ); save report lines for the deposit and its refusal. History: notes edited/cleared, follow-up set/removed. history_box(): count, day headings, time per entry (full date in the title). booking.php record_history() cap 40 → 150.
* guest-pass/admin.php: details save logs which groups changed (Pick-up, Guide, Vehicle, Payment, Emergency); WeTravel link added/changed/removed logged; guide_license field. data.php/render.php: guide license printed with a shield.
* whatsapp/templates.php: kind pass_payment and its default; {deposit} falls back to the deposit share of the total when no quote sets one.
* booking-admin.js: stickyHead() — sentinel + IntersectionObserver toggles .is-stuck. booking-admin.css: sticky row (32/46/0px offsets), compact stuck state, unsaved pill, facts tiles, lead callout, panel heading accent, focus colour, deposit block, history day pills. min files rebuilt.
* i18n: pass_guide_license, pay_sent_method, pay_sent_amount (en/fr/es/ar + gettext).
* Checks: pass renders (deposit stage light even 3 days out; full pass with licence) at E_ALL; claim form markup test; mock of the Confirm step and History with WordPress admin CSS, top and scrolled.

= 2.13.1 =
Booking screen as one panel; WeTravel link in Agree & deposit.
* booking/admin.php: the Status meta box is no longer registered; status_box() prints in the header (div#wptm-booking-status.wptm-booking-statusbar, before the flow) as one row — Move to, Follow up on, Reason (an input, same name wptm_status_note, same nonce) — without the duplicate saved status and Save button (the header has both). add_screen_option layout_columns max 1 on add_meta_boxes; get_user_option_screen_layout_wptm_booking → 1. workspace.php box_exists() answers true for wptm-booking-status, so "Open Status" links and the dashboard's #wptm-booking-status anchors still land.
* booking/flow.php: flow_wetravel() under step 3's actions — url field wptm_wetravel_url, own nonce wptm_wetravel_{id}, Save (submits form#post), Open. guest-pass/admin.php: saved on save_post_wptm_booking 20 into META_OPS pay_wetravel_url; the Guest Pass details save (10) keeps the stored value, and the field left that box.
* booking-admin.js: pair() after a step's panels move under its tab — known small panels get .wptm-half, an odd last one stays full width. booking-admin.css: status row grid (3 → 2 → 1 columns), the WeTravel field, ≥1200px two-column grid for the step's panels (dense). min files rebuilt.
* Check: a mock of the screen with WordPress 6.6 admin CSS and the plugin's admin.css, screenshot at 1440.

= 2.13.0 =
Duration terms follow the days; lead → excerpt; editor and list improvements; Guest Pass layout, logo and per-booking WeTravel.
* duration.php: term_days() (term meta _wptm_duration_days, else an exact "N day(s)/jour(s)/día(s)/أيام…" name; 0 for ranges), tour_days() (wptm_duration_days, else an exact label or preset), auto_terms() (every term exact, or none; filter wptm_duration_auto_terms), canonical_name() ("3 Days" in English, the language's word elsewhere), term_for() (find by days in the language, prefer the canonical spelling; create with slug sanitize_title(name), "-{lang}" on a clash; pll_set_term_language + link_translations()), sync() on wp_after_insert_post 20 (after every save_post handler; reads saved meta only; wp_set_object_terms saves no post, so no loop). order() by term meta then first number — Archive\order_durations() delegates to it; archive filter_terms() orders durations; home search_form() reads active_terms() (hide_empty, ordered, transient wptm_active_duration_terms[_lang], 12 h) flushed on save_post_wptm_tour, transition_post_status, deleted_post, set_object_terms and created/edited/delete wptm_duration. upgrade() once on admin_init 30 (manage_options; option wptm_duration_terms_v): groups exact terms by language and days, keeps the clean slug else most tours, moves objects, deletes the rest, records slug → slug in wptm_duration_merged, fixes case-only spelling, stores days, then syncs every published tour when auto_terms(). template_redirect 1: a merged slug as /…/duration/{slug}/ (404) or ?duration={slug} → 301 to the kept term.
* duration.php (groups): new tour (auto-draft) gets wptm_group_min 1; wp_after_insert_post 15 sets min to 1 when min > max > 0 and leaves a one-time notice.
* seo.php: wp_after_insert_post 30 copies the plain lead into an empty excerpt (or one it wrote before, remembered in _wptm_excerpt_auto) through wp_update_post with a static guard; a written excerpt is never overwritten and ends the copying; an emptied lead takes the copy back. get_the_excerpt 5: an empty tour excerpt answers with the lead, before core trims the content. Not excerpt → lead: the excerpt is printed after the gallery, a lead copied from it would print it twice. class-tour.php summary() returns '' for the auto copy, so the page is unchanged.
* tour-editor-admin.js: nightsFromDays(); publishGuard jumps to the first problem (jump()) instead of only the alert. experience-editor.js: an HTML5 `invalid` field marks its tab (error) until valid and scrolls into view.
* admin/tours.php: Featured star column (wp_ajax_wptm_toggle_featured: nonce, edit_post, toggles wptm_home_featured, ensures wptm_home_priority, Home\cache_flush; inline script on edit.php for tours only); Duration shows the term under the label, is sortable by wptm_duration_days, no longer hidden by default. admin.css: star styles.
* Guest Pass: sections one column at every width (eyebrow with a hairline above the content), tip / P.S. / "soon" note full width, money as tiles from 560px; logo without the white plate (drop shadow), with a wp_get_attachment_url() fallback for a WebP or SVG WordPress could not size. Booking detail pay_wetravel_url (Guest Pass details → Payment) passed to Payments\instructions( $lang, $wetravel ), winning over the site's general link (setting relabelled). pay_wetravel_hint under WeTravel's name. Deposit line hidden while a payment claim awaits verification.
* i18n: pay_wetravel_hint (en/fr/es/ar + gettext).
* Checks: test_duration.php (26 asserts: recognition, merge, clean slug kept, redirect map, backfill, order, cache, range site untouched), test_excerpt.php (7), stub render of the pass (light, full, Arabic, transfer) at E_ALL; screenshots at 1440 and 390.

= 2.12.0 =
Guest Pass: book now, pay later; blog in "Need help?"; no excerpt on the ticket.
* payments/settings.php: defaults deposit_percent (20), recommended (wetravel), wu_name/wu_country/wu_instructions, mg_name/mg_country/mg_instructions, wetravel_url/wetravel_instructions (the *_instructions translatable). method_names() (western_union, moneygram, wetravel, bank_transfer, paypal, cash, other); deposit_percent() clamps 0–100. instructions() emits Western Union, MoneyGram and WeTravel blocks before bank transfer; every block carries `recommended`; PayPal and WeTravel blocks carry `link` (button wording). templates/quote.php reads `link`, falling back to pay_paypal_link. Existing installs read the new defaults through settings()'s merge; nothing to migrate.
* payments/settings-screen.php: "Book now, pay later" section (deposit %, recommended select), Western Union, MoneyGram, WeTravel sections; saved through the same handler (manage_options + nonce), recommended only from method_names().
* guest-pass/render.php: payment_actions() always lists the configured methods while a balance is owed on a confirmed pass (before 2.12.0 only with "bank details on the pass" ticked). pay_later(): the chosen option, and while paid_something() is false the deposit line — total_minor × share, through Payments\format(). pay_method(): <details name="wptm-pay-method"> (exclusive where supported), recommended open with a badge; the bank block drops its account rows unless show_on_pass, and offers WhatsApp instead. paid_something() split out of full().
* data.php / payments/public.php: payment.total_minor; pay_deposit. blog_url(): blog_urls[lang] first (new Guest Pass setting, one field per Polylang language), then blog_url, then the posts page.
* section_help(): pass_help_blog (or pass_help_wa without a blog) and WhatsApp + "Read our blog" buttons; the blog link follows the guest's language and is not shown on a cancelled pass.
* templates/guest-pass.php: the lead prints only for a transfer (its route line), never the tour's excerpt.
* guest-pass.css: `.wptm-pass a` → `:where(.wptm-pass) a` (it outranked .wptm-pass__btn, so the white text of the WhatsApp/navy buttons inherited the ink). Pay-later card, method folds (physical-sided chevron so it points down in RTL), help text. min rebuilt.
* i18n (en/fr/es/ar + gettext): pass_pay_methods, pay_later_title, pay_later_deposit, pay_later_deposit_share, pay_recommended, pay_receiver, pay_country, pay_wetravel_link, pass_pay_bank_ask, pass_help_blog, pass_help_wa.
* Checks: stub render (light, full, Arabic, transfer) at E_ALL with the real payments settings module and both label registries; screenshots of the payment and help sections at 1366 and 390 (RTL).

= 2.11.9 =
Guest Pass: a clean ticket, an animated welcome, a light pass before the deposit.
* templates/guest-pass.php: the hero prints only the brand, the state pill and the ribbon; eyebrow, title and lead moved into the stub (.wptm-pass__trip, the h1 there). pass_hello is no longer printed; .wptm-pass__welcome heads the main column (pass_welcome, the first name in .wptm-pass__guestname, pass_welcome_sub), not on a cancelled pass. The "changed" alert, the Directions and Voucher quick actions follow full().
* render.php: full( $pass ) — completed, or payment status deposit/partial/paid/free/partially_refunded, or a paid figure with a non-zero digit, or the travel date within wptm_guest_pass_reveal_days (7) of today; filter wptm_guest_pass_full. When false, sections() drops pickup, guide, vehicle, itinerary, voucher and bring and puts section_soon (pass_soon_title / pass_soon_text) first. Worked out per view, so passes sent before this version follow it.
* guest-pass.css: 2.11.9 block — lighter veil (dark only under the ribbon), photo hero 230–320px on a phone; welcome: clip-path wipe (mirrored in RTL), gold stroke, a 7s background-clip sheen behind @supports; never split into letters (Arabic joins), no italic or tracking in RTL. ≥1040px the side column is sticky at any height above 560px, the photo hero shrinking to clamp(210px, 100vh − 420px, 380px). guest-pass.min.css rebuilt (esbuild 0.25.9).
* i18n: pass_welcome, pass_welcome_sub, pass_soon_title, pass_soon_text in en/fr/es/ar and gettext-labels.php.
* Checks: stub render of four scenarios (light, full, Arabic, transfer) at E_ALL, no notices; screenshots at 1366×700 and 390.

= 2.11.8 =
Language-correct terms everywhere on the front end, a data integrity repair, Rank Math sitemap seeding, SEO corrections.
* lang.php: the page-language rule (display_language(), front_end(), term_fits(), terms_in_language(), post_fits()). Polylang scopes plain get_terms() and post queries to the current language with two deliberate exceptions — get_terms() with object_ids (every wp_get_object_terms / wp_get_post_terms / cold get_the_terms) and a WP_Query narrowed by a translated taxonomy (the term is taken to imply the language). Both broke on mis-filed data: Archive\filter_terms() (object_ids) offered ?destination=merzouga-es on an English page, filter_state() found no English term, 404. filter_object_terms() on `get_terms` (object_ids only; objects always, ids / id=>x only when every queried taxonomy is ours; names/slugs/tt_ids/count untouched — wp_set_object_terms reads tt_ids) and the same rule on `get_the_terms` (warm-cache path). restrict_query_language() on pre_get_posts 20 sets Polylang's `lang` var on any query for our post types narrowed by one of our translated taxonomies (query var or tax_query clause, NOT IN excluded) and not yet given a language; only when every post type it can return is ours and translated in Polylang; on a main term archive with "language set from content" (no current language yet at pre_get_posts) the queried term's language is used. Never in wp-admin, AJAX, cron, WP-CLI or REST: the editor reads terms to save them. Verified on WP 7.1.3 + Polylang (directory and content modes): the facet dataset query lost the language because the copied query vars carry post_type '' and Polylang unsets `lang` for an untranslated post type.
* archive.php: filter_terms() needs no change of its own; it is covered by both rules. pll_translation_url (priority 40) translates each filter value of a narrowed view into the target language and drops one with no translation — Polylang kept the query string, so the switcher and hreflang of /fr/tours/?destination=merzouga-fr pointed at two 404s. own_canonical() shared by Yoast and Rank Math; SEOPress gets its own callback returning a <link> tag (seopress_titles_canonical filters the whole tag; it was handed a bare URL). Rewrite version 6.
* class-tour.php: related() and guides() drop a hand-picked post with no translation in the page language (post_fits). bootstrap.php: get_tour_data() term names built from objects so the rule applies.
* admin/integrity.php (new, Settings group, manage_options): mismatches() — every tour/activity/transfer with a language, every translated-taxonomy term in another language, its translation via term_translations() (one wp_get_object_terms per 200 posts). The check is a dry run behind a nonce; the repair (admin_post_wptm_integrity_repair, nonce + capability) re-checks and refuses unless the plan fingerprint matches what was shown, swaps per post and taxonomy in one wp_set_object_terms, leaves untranslated terms in place, logs to wptm_integrity_log (last 20) and the plugin log. Polylang 3.5+'s set_object_terms pass (which translates every term of the post and CREATES missing translations) is suspended during the write and restored after.
* seo.php: seed_rank_math_sitemap() adds pt_wptm_{tour,activity,transfer}_sitemap = 'on' to rank-math-options-sitemap only when the key is absent and the option exists (never created: Rank Math's installer uses add_option); once per version on admin_init 20, re-armed by activation. Rank Math draws the toggle from its field default (on) but builds the sitemap from the stored option (absent = off). Yoast: enrich_defaults() already includes new post types. SEOPress: unticked until ticked, screen and sitemap agree. own_noindex() + X-Robots-Tag: noindex, follow (appended) on template_redirect 20 for this plugin's own noindex decisions (archive, empty index, empty directory, tailor result) — SEOPress and others print their own robots tag.
* post-types.php: destination, tour type and category register no rewrite rules when their section is off, as departure and duration already did. Before: /tours/category/<anything>/ matched, the non-public query var was dropped, 200 with the home page.
* slugs.php: is_scalar() before sanitize_title() on carried filters (?destination[]=x was a TypeError, HTTP 500, on PHP 8); only a sort current_sort() accepts is carried (no 301 chain).
* SEO-AUDIT.md (outside the plugin): the full review against Google Search Central, ranked, with proposals for the medium and low findings.
* Checks: test bundle tripvance-2.11.8-tests (run.sh, setup-data.php; language 18, integrity 14, seed 6 asserts) against WordPress with Polylang. Against 2.11.7 the language check fails 7.

= 2.11.7 =
Dashboard calendar redrawn, two security fixes, SEO and speed corrections.
* admin/dashboard.php: calendar() takes no rows; calendar_rows( $month ) queries the month by travel date through Booking\matching_query( wptm_month ) (1,000 cap), drops cancelled/declined, sorts each day by pickup time (untimed last) then title; calendar_time() formats a pickup with the site's time format; calendar_day_url() opens the list on one day (wptm_after = wptm_before). Items carry data-wptm-qv (Quick view), a <time>, a type icon (transfer/activity/custom), a guest count; day numbers link to the list; "+N more" links; a month summary (trips, guests, not confirmed) and an "Open month in the list" link; table caption, aria-current="date". The whole thing sits in .wptm-dash__panel--calendar with the shared panel head; the export form follows it, outside the scroll box. Check: calendar harness in the test bundle (9 asserts).
* admin.css: calendar block rewritten; @media (max-width: 782px) turns the table into an agenda (thead/pads/empty days hidden, weekday shown from .wptm-cal__wd); weekend tint; past days at 72%; dark/no-color-mix/forced-colours fallbacks kept. admin.min.css rebuilt (it was older than its source, so the readable file was being served).
* mail/messages.php, tailor/mail.php: header_safe() also strips commas — wp_mail() splits Reply-To on commas, so a name "x@evil.com, Alice" added a second Reply-To.
* luxury/enquiry.php: state token lower-cased at mint; the reader passes it through sanitize_key().
* shortcodes.php: empty_directory() decides before wp_head (has_shortcode + get_terms number=1 per [wptm_terms]); wp_robots and rank_math/frontend/robots hooked at file scope; nothing_yet() no longer adds filters from inside the_content (too late — the robots tag is printed at wp_head priority 1). cards(): the first card is fetchpriority=high only when the opening prints no photograph.
* indexes.php: rank_math/frontend/robots mirror of the empty-index noindex (Rank Math ignores wp_robots).
* seo.php: share_image_id() (tour hero → landing hero_image → homepage hero_image) answered through the post_thumbnail_id filter, added at wp_head priority 0 and removed at PHP_INT_MAX, so every SEO plugin's og:image sees the hero when the featured image is empty and the theme's own featured-image block does not.
* legacy-urls.php: REQUEST_URI through esc_url_raw() + rawurldecode() (sanitize_text_field stripped percent-encoded octets); destination() compares decoded paths; moved_taxonomy() decodes the slug before sanitize_title().
* assets.php: dark.css enqueued with media="not all"; scheme.js sets #wptm-dark-css media="all" on a dark page. The tour hero preload skips a Luxury Journey tour (luxury/chrome.php preloads it at the size it draws).
* templates/archive-tours.php: two inline scripts (wp_print_inline_script_tag) fold the lead, close the drawer on ≤782px when nothing is applied, and fold long chip lists while parsing; archive.js keeps doing the same on resize and tap. No script: everything shown whole, as before.
* templates/sections/booking-form.php: stepper buttons named "− Adults" / "+ Adults" / "− Children" / "+ Children". home/render-sections.php + home.js: role="tablist" set by the script that fills the dots.

= 2.11.6 =
PDF redesign, concise pick-up time, licence wording.
* compat.php: tripvance_concise_time() — ≤24 chars kept as written; longer with exactly one clock time (08:00, 8.15, 9h00) → that time as HH:MM; otherwise ''. Used by the pass pickup (render.php) and the programme's meeting time (pdf.php). Check: test_concise_time.php in the test bundle (8 cases).
* render.php note(): a pickup note over 140 characters prints its first sentence as the summary of a <details>, the rest inside (.wptm-pass__more, a +/− mark).
* pdf.php: palette constants INK/SOFT/MUTED/HAIR/PAPER/GOLD; helpers eyebrow() (caps + tracking in LTR only), heading_line(), rule() (accent stroke + hairline), gap(). Letterhead meta as a two-column table, a rule() under it; keep() and the lists use heading_line(); parties on PAPER, no borders; price table header as small muted labels over an ink rule, hairline rows, no zebra, total 11.5pt in the accent; programme facts on PAPER with gold labels; days as rows with an optional 16% label column (only when a label remains after dropping one the title already opens with), hairline between days. Margins 20mm, line ratio 1.4, body ink #27313c. pdf-engine.php: softer running header, footer and draft mark.
* company.php: the logo fallback reads GuestPass\logo_id().
* pass_ribbon_licensed reworded in en/fr/es/ar (Ministry of Tourism); .wptm-pass__seal sentence case. ≥900px quick-action buttons are outlined on white, WhatsApp stays green.

= 2.11.5 =
Guest Pass: document layout, still promise, tip wording, blog P.S.
* .wptm-pass__main is now one white sheet; its .wptm-pass__section children lose background, border, radius and shadow and are divided by a top hairline; the footer closes the sheet. ≥1040px each section is a grid (150px / 1fr): the eyebrow spans the first column, everything else the second; the tip and the P.S. are indented to the content column. The voucher QR is 200px there and the reference never wraps.
* The ribbon markup, keyframes and reduced-motion rules are removed. .wptm-pass__promise (still): .wptm-pass__seal (shield + pass_ribbon_licensed) and .wptm-pass__vow (pass_ribbon_experience; italic in LTR only). One fade-in with the hero. Setting key show_ribbon kept, relabelled "Licence and promise line".
* pass_safety_text reworded in en/fr/es/ar: no card details ever; only part of the price by transfer, as a sign the booking is serious, depending on the trip; the rest paid on meeting.
* New section 'blog' after 'help' (section_blog): .wptm-pass__ps, a paper note with a tape strip, labels pass_blog_ps / pass_blog_text / pass_blog_link (en/fr/es/ar + gettext). data['blog'] = blog_url( $lang ): the blog_url setting, else page_for_posts (pll_get_post for the pass language), only when published; '' hides the note. Settings show_blog (default on) and blog_url (http(s) only).

= 2.11.4 =
Guest Pass: desktop layout, ticket, motion, ribbon. Checked at 1440/390 px, LTR and RTL.
* templates/guest-pass.php: .wptm-pass__doc holds .wptm-pass__side (ticket + quick-action bar) and .wptm-pass__main (notices, sections, footer). ≥1040px the doc is a 2-column grid (minmax(360px,430px) / 1fr, max 1180px); the side is sticky when the window is at least 760px tall. ≥900px the bar is a static row under the ticket instead of a fixed bar. The bar moved into the side column; it is outside the tilted wrapper, so position:fixed still works on phones.
* Ticket, after the shape of a clip-path ticket component: .wptm-pass__ticketwrap carries the layered drop-shadow and the tilt; .wptm-pass__hero and .wptm-pass__stub are each masked with two half-width radial gradients, a hole at the shared edge, so the notches are cut (the page shows through) and the shadow follows them. The dashed tear is the stub's ::before between the notches. The photo moved inside the hero.
* Ribbon (.wptm-pass__ribbon): labels pass_ribbon_licensed and pass_ribbon_experience (en/fr/es/ar, gettext mirror) plus the company name; two identical halves, each repeating the items twice, animated to -50% in 48s (+50% for RTL); faded ends by mask-image; paused on hover; for reduced motion still and wrapped. A visually hidden copy is read once; the moving track is aria-hidden. Setting show_ribbon (default on); off on a cancelled pass.
* assets/js/guest-pass.js (new, ~70 lines, no dependency): IntersectionObserver marks .wptm-pass__main children, days, key/value rows and the stub facts with .wptm-pass__reveal and a sibling delay (70ms steps, max 6), then adds html.wptm-pass-motion — the hidden state needs both, so nothing is hidden if the script fails. Tilt only for (hover:hover) and (pointer:fine), max 6°, perspective 1100px, rAF-throttled. Both skipped for prefers-reduced-motion.
* Print: no photo, veil, ribbon, masks, shadow or motion.

= 2.11.3 =
Payments, Guest Pass hero, card-safety tip, Trip Builder interests. Checked on WordPress 7.2 beta (SQLite).
* payments/admin.php form(): the refund checkbox is gone. With net held > 0, a radio pair name="refund" value 0 (checked) / 1, the refund one naming the ceiling; with nothing held, no refund input at all. autocomplete="off" so a browser restore cannot pre-pick it. The handler reads refund only as the literal '1'. payments.php add() keeps its own refund ceiling. Checked: €50 received on a fresh booking, then a €20 refund, both recorded.
* guest-pass/data.php: 'logo' => logo_id() (pass option → Documents\company_option('logo_id') → theme custom_logo → site_icon, each checked with wp_attachment_is_image); 'photo' => the tour's featured image id (0 for a transfer).
* templates/guest-pass.php: header = optional .wptm-pass__heroimg (object-fit cover, z -2) and a ::before gradient veil (z -1); .wptm-pass__hero (top row: brand on .wptm-pass__logoplate + status pill; eyebrow, h1, lead, hello); .wptm-pass__stub (white, dashed top edge, two paper-coloured notches; reference, then the summary dl). The old .wptm-pass__strip is gone. Print: no photo, veil or notches.
* render.php: section 'safety' after 'payment' (.wptm-pass__tip, role=note, inline shield SVG); labels pass_safety_title / pass_safety_text in labels.php, translations.php (fr/es/ar) and gettext-labels.php. Not printed on a cancelled pass. .wptm-pass__time--text when the time field is longer than 12 characters. A purely numeric day label goes through pass_day ("Day %d").
* tailor/options.php: 'wellness' out of shipped_options(); a one-time admin_init pass (option wptm_tmr_no_wellness) removes the row from a saved interests list. The i18n keys stay, for requests that chose it.

= 2.11.2 =
Booking screen, second pass, checked on a real WordPress (7.2 beta, SQLite) at 1700/1440/1100/390 px and right-to-left.
* Menus cut off: .wptm-flow had overflow:hidden, which clipped the absolute WhatsApp/Email menu whenever the panel under it was short (a step whose panels are hidden in Screen Options). overflow visible; the rail inherits the top corners; the menu is z-index 50, a soft shadow card with no border.
* Icons: core's `.wp-core-ui .button .dashicons { line-height: 1.9 }` dropped the glyph below the label. Every icon in the flow and the contact chips is a fixed 16/18px square with line-height 1. The caret uses physical bottom/right borders (a down chevron, logical sides would turn it sideways in RTL) and is inline-block so it keeps its size in a block summary on phones.
* Panels in a step: no border, radius, background or shadow; a hairline between two; the header is a plain title; .handle-actions and the closed-box summary hidden and .closed .inside shown, since the step opens its panels anyway. The "acts at once" leading bar is dropped inside the flow (the step says it).
* Header: the contacts list and the facts grid are buffered and printed after the header row, at full width. Facts grid floor 150px.
* Rail: steps flex 1 0 auto; .wptm-flow is an inline-size container and under 860px only .is-now and the open tab keep their names (the 1100px screen with the side column overlapped them). sync() centres the open tab when the rail scrolls.
* Checked: saving from the header with the trip details moved into step 3 posts the field and returns to step 3.

= 2.11.1 =
Booking screen polish: panels inside their step, contact chips, aligned facts.
* flow.php: every step panel ends in .wptm-flow__boxes[data-wptm-flow-boxes], and History & notes gets a panel of its own (data-wptm-flow-panel="log"). booking-admin.js show() appends the open step's postboxes into it, in the step's order. The header is printed in edit_form_after_title, i.e. inside #post-body-content, so the moved boxes stay inside #poststuff and form#post: every #poststuff rule applies and every field still posts. Boxes not listed in any step stay where WordPress put them. The order arrows are hidden in the host (no sortable column there).
* sectionOf() keeps a box in the open step when it is listed in two (trip details, payments, messages); the trip details are now also in step 3, whose job is "adjust the request".
* Step 1's "Open the request" / "Trip details" jumps print in .wptm-flow__nojs: hidden with the script, since the panels are already under the tab.
* Header contacts (booking/admin.php): one chip per channel, a dashicon, the label and the value; WhatsApp links to wa.me through WhatsApp\link( normalise_number() ), opened in a new tab; a number WhatsApp cannot use stays plain text. "(preferred)" became a "Preferred" tag.
* Header facts: a grid (auto-fill, 140px floor); a value longer than 26 characters spans two columns (.is-wide), so a tour name no longer stacks on three lines.
* Rail scroll bug: .wptm-flow__tab is width:100% with 12px padding and was content-box, so each tab overflowed its step by 24px and the rail scrolled. box-sizing: border-box. Same fix on the WhatsApp/Email summaries on phones.
* The nav under the header (All bookings / Bookings dashboard) is gone from the booking screen; the tailor-request screen keeps its own.

= 2.11.0 =
Booking flow strip, requests badge on the menu bar, Trip Builder session mode, Panorama Slider messages, signature-tour ticket.
* Booking screen (includes/booking/flow.php): five steps — Request, Contact, Agree & deposit, Confirm, Travel pass — as a rail of tabs with each step's actions under it: WhatsApp (a menu of the template messages, each a wa.me link with the text filled in server-side through WhatsApp\resolve()), Email (the manual message types), "Mark as contacted" / "Confirm the booking" (the quick-status admin-post action, with a confirm), Edit / Send a quote / Record a payment (panel jumps), Issue / open / email the Guest Pass (the pass's own actions), Invoice & documents. flow_step() is a pure function of status, contact log, money received and pass state. The rail is also the panel navigation: workspace_sections() are now the five steps plus "History & notes" (a panel may belong to two steps — trip details, payments — so show() collects the open step's panels first and opens collapsed ones); the old section row, the "Next action" block, the reference field, the Publish box and the slug box are no longer shown (hidden by CSS, their fields still posted). booking-admin.css (.wptm-flow*), booking-admin.js (section event → action panel, one open menu).
* Requests waiting (includes/booking/attention.php): the total of new bookings (Booking\pending_count) and new tailor-made requests (Tailor\new_count) as a bubble on the top-level Tripvance menu entry (admin_menu 110, native awaiting-mod markup), a chip on the admin bar inside wp-admin, and a dashboard widget listing the latest five. Both counts were already cached transients; nothing new is queried per page.
* Trip Builder → "The last step asks for": `mode` = request (default) | session. Session: "How it works" in three steps before the form (.wptm-tmr__how--session), the last step asks for the day, time and format (online / at our offices; fields wptm_tmr_session_day/time/mode, checked in submit.php session_fields() and trip-builder.js), the session as the first line of the request's message, "Request my session" as the button, the reassurance line dropped. Shipped wording for every sentence in en/fr/es/ar (tmr_session_* keys, i18n.php) and the session sentences on the wording screen (session_word_keys(); shipped_word() maps the shared keys to their session versions). SEO title/description printed in session mode as on Luxury.
* Trip Builder → "Show the planner alone on its page": `page_only` hides the page's own content under the planner (kept in the database).
* Panorama Slider: each photograph row gains a heading, a text and a button (hero_float_items title/text/button; the language tabs translate them and the row link). With any heading set, hero_slide_copy() draws every message in the page, stacked in one grid cell (no height change as they turn), the first heading the page's h1 and the others h2; home-hero-slider.js switches [data-wptm-slidemsg] with the photographs. No new file or request.
* Homepage → Signature tour layout: `signature_style` = card (default) | ticket. Ticket (.wptm-home__sig--ticket): the photograph as a 4:3 face, a perforated stub (CSS mask notches, drop-shadow follows) with name, two-line lead, duration, destination, fare and button; highlights and group size left to the tour page; slower dissolve with a settle; marks fill over the five seconds.
* Marketplace card style: the related tours under a tour drop "View tour" and the rule over the foot; the fare sits under the title at its start, the duration after it.
* Planner dress in the Experium palette: .wptm-tmr--experium (colours only; the layout is `mode`). Luxury's own "how" block is not printed when session mode printed one.
* One-time pass (includes/brand-upgrade.php, wptm_experium_2110): a site already in the Experium palette gets mode=session, page_only (when a page is assigned), signature_style=ticket, and the four wording keys stored by 2.10.2 renamed (tmr_exp_* → tmr_session_*), so the update changes nothing it shows. The 2.10.2/2.10.3 interim builds' site-specific seed tables are gone from the plugin.
* French built-in Trip Builder strings: a narrow no-break space before ? ! : ;.

= 2.10.1 =
Cards per row (3 or 4); an editor for the Luxury footer's link columns, with Contact / Support / Legal / Booking Information by default; redesigned directory pages; Luxury page starters removed.
* card.php: per_row() reads the new cards_per_row setting ('3' default, '4'); settings.php keeps it when a writer does not send it. Settings screen row under Tour card style.
* Grids that were fixed at three now ask per_row(): home featured ($cols = min(per_row, count)) and spotlight overflow, cars and transfer homepage grids, archive-activities, archive-tours (.wptm-arc__grid--4), hubs and landing pages (wptm-cards--4 only when 4, so the 3 default renders exactly as before), [wptm_tours]/[wptm_activities] default columns. Explicit shortcode/section column counts are untouched.
* card.css: .wptm-cards--4 from 601px is container-relative — repeat(auto-fill, minmax(max(min(100%, 200px), quarter of the row), 1fr)) — so a narrow content column never squeezes four cards; smaller body padding (boxed only), title, facts, price and CTA. tour.css: the same for .wptm-arc__grid--4 (230px floor) and card2.
* Luxury footer: setting lux_footer_columns = list of {title, band top|middle, links[{label,url}]}; absent = default_footer_columns() (luxury.php), an empty list = no columns. sanitize_footer_columns(): sanitize_text_field, esc_url_raw (site paths, mailto:, tel: kept; javascript: dropped), half rows and empty columns dropped, 8 columns × 10 links. The main sanitiser hands the list through like card_fields; the editor posts a [set] marker so "all removed" is saved, and a writer without the editor keeps what was stored.
* chrome.php: own_columns($band) resolves "/path" with home_url(); footer_word() prints a stored default in the page language through t() (11 new lux_* keys, fr/es/ar + gettext mirror). Top-band own columns sit after Destinations and replace the MENU_FOOTER column, which returns when there is none. Middle band: one Contact column (email, phone, WhatsApp, each under a small label) then the middle-band columns; ≤699px Contact takes the row and the link columns pair up.
* Editor (luxury/admin.php + settings-admin.js/css): one card per column, link rows, add/remove/move/restore defaults from <template>s; plain inputs save without the script.
* Not translatable through Polylang yet: wording the owner typed in the footer columns (defaults are, through the label registry).
* Directory pages ([wptm_terms], shortcodes.php) rewritten. Cards (destinations, departures): .wptm-dir__card = one link; media 4:5 (3:2 departures, 4:3 places on phones), eyebrow tour count (idx_tour_count), name, description clamp 3, "Explore". Grid columns from Card\per_row() with a 250/220px floor. Rows (tour types, durations): .wptm-dir__row header (icon, count, h2 name, description, "View all …") + Card\tour() in .wptm-cards--per_row; ≤600px a scroll-snap strip. Wrapper carries "alignwide"; the title/lead header prints outside it at text width. wptm-dir--lux when Luxury is on (radius 0, lighter names, no icon discs). wptm-card is enqueued with wptm-tour (head, and the footer fallback).
* cards(): the "more" card only when Indexes\enabled() — before, limit="5" on the generated Destinations page cut the list and linked the index, a 404 while it is off. Pages\definitions() no longer passes limit to destinations/departures.
* tour.css: TERM INDEX block replaced (old .wptm-types/.wptm-type/.wptm-places/.wptm-place rules gone, plus their dark.css rules and the .wptm-type__list exception); .wptm-dir dropped from the ≤782px gutter rule so the grid lines up with the theme's text. brand-square: .wptm-dir__rowicon.
* Luxury directory pass (pages.php): [wptm_terms] gains opening="1" (a .wptm-dir-open band: site name, the page title as the h1, the lead, over the most-toured term's photograph, full-bleed; no photograph → the same words over a hairline) and close="1" (.wptm-dir-close: idx_cta_* wording; Luxury planning_url() first, else WhatsApp, plus "Browse every tour"). definitions() add both and the SitePage template when Luxury is on; ensure() writes the template. luxury_pass() on admin_init, once per LUXURY_PASS in wptm_lux_directories (publish_pages, not ajax): ensure(destinations, tour-types, departures) then dress_shortcodes() on every known directory page (+ durations) — attributes only, limit dropped, a shortcode already saying opening/close untouched — and SitePage template only where it was default. Rows in Luxury draw the shared tour cards without box or corners. card.css: the 4-per-row boxed padding rule wrapped in :where() so directory rules can win.
* Trip Builder in Luxury: render() passes 'lux' (Luxury\enabled(), read per render, so existing Luxury sites change on update; no page or template is touched — the page button already makes full-width pages and an adopted page's template stays the owner's). builder.php root gets .wptm-tmr--lux. trip-builder.css: a token override block (accent #aa4b29 via --tmr-site-accent fallback, ink #2e2a27, muted #6e6862, faint #948f89, surface #f6f1ea, radii 0, no shadows, --tmr-serif = "Cormorant Garamond", Georgia… named not loaded) + ink hero band, tagline promises with dots (stacked on phones), accent-topped square panel, serif question/title, tracked small-capital labels and buttons; letter-spacing normal and inherited face for Arabic. accent_style() also sets --tmr-site-accent so the site's accent still wins.
* Morocco Trip Planner (Luxury): Tailor\lux_key() maps tmr_eyebrow/title/intro and tmr_page_title/slug to tmr_lux_* when Luxury is on; word() still returns the owner's wording first. 13 new tmr_lux_* keys (en + fr/es/ar + gettext). create_builder_page() uses them (slug morocco-trip-planner; language suffix rule covers it). SEO: seo_page() (Luxury + a page_ids() page) → document_title_parts and a meta description with no SEO plugin; rank_math/frontend/title|description and wpseo_title|metadesc otherwise — each only when SitePage\seo_read() finds no own value. Title "Morocco Trip Planner: Design Your Private Tailor-Made Tour", description 149 chars with the phrase once. lux_rename() once per wptm_tmr_lux_named: generated titles (any language) → tmr_lux_title in the page's language; unpublished pages also get the slug. builder.php: style="--tmr-band;--tmr-band-on" from Luxury\tone_table() (the footer's contrast colours) and a "How the trip planner works" section (h2 + three h3). CSS: band = footer ground + accent glow + 45° lattice; full-bleed on the full-width page, top margin 0; phone eyebrow without rules.
* Planner polish (trip-builder.css, Luxury only): .is-enhanced:not(.is-first) and .is-sent shorten the hero (padding transition, lead + promises hidden, smaller title); step entrance 0.5s/5px; chosen card = accent frame (inset 1px) on surface, square marks; picture cards get a soft bottom shade on hover/checked; empty media = accent lattice; summary on sand with tracked labels; ≤640px the nav bar is sticky at the foot (safe-area inset).
* Planner band, final: flat var(--tmr-band) (glow, lattice and the top accent rule removed); full-bleed on every template — 100vw approximation, then luxury.js bleed() (selector now includes `.wptm-tmr--lux > .wptm-tmr__hero`) measures exact offsets and closes the theme's gap under the menu (.wptm-lux--measuring / .wptm-lux--bled rules in trip-builder.css; render() calls Luxury\enqueue() so the script is there even with the Luxury footer off); root margin-top 0. .wptm-tmr__how hidden on steps after the first and once sent. Tailor\lux_layout() once per wptm_tmr_lux_layout: planner pages on ''/default → SitePage\TEMPLATE (any other template kept; the tailor "full-width layout" control stays the way back).
* Footer social: footer_columns() no longer adds the Social column; Luxury\footer_social() prints .wptm-lux__footersocial under .wptm-lux__footernote — inline 24px-grid line SVGs per network (no remote assets), 40px round links with aria-label = network name. Luxury\original_image() (id or URL → original file, width, height, alt) now shared by footer_badge() and the planner. New 'media' footer field lux_mark → render() 'mark' → builder.php .wptm-tmr__howicon (26px high, max 48px wide) in place of the diamond; .wptm-tmr__how border-top removed.
* Luxury footer: band hairlines are ::after lines spanning calc(50% - 50vw) both ways, the footer clips (overflow-x: clip). Privacy link removed from the legal line; Luxury\footer_badge() prints setting lux_footer_badge (media id or URL; 'media' footer field) from the original file (no resized copy, so GIFs animate) with its own width/height, CSS height 32px/26px, max 180px wide; .wptm-lux__footerfoot centred instead of baseline.
* luxury/starters.php deleted (wptm_page_starters / wptm_page_starter_content filters gone with it). The core starters (About us, Destination guide, Tour collection, Activities, Contact and booking, Blank) remain.
* Cookie banner, compact (cookie-consent.css): surface 16px/18px, title 1rem (0.9375rem ≤600px), buttons 40px with 8px×16px padding. .wptm-cc__actions--main is a grid in every layout: Reject | Accept (equal tracks, stretched to one height) | Manage as a link; ≤600px Reject | Accept, Manage on the line under them. "Continue without accepting" moved into the head flex line (margin-inline-start: auto, wraps under the title only when both do not fit) — the 44px top strip and the panel-handle offset are gone. Bottom bar 12px block padding; bottom panel 18px×22px on desktop, 46px choice buttons; floating card 400px.
* cc_desc default shortened in en/fr/es/ar + gettext ("Like most of the web, this site uses cookies…"). Only the default changes; a description typed in settings stays.
* Bookings list (booking-admin.css "2.10.1: QUIET POLISH", admin.php): table with 12px corners and a soft shadow, muted header row, stripes replaced by hairlines, accent references; initials avatar (.wptm-col-avatar, desktop) in the customer column; date_countdown() adds Today / Tomorrow / In N days under the date for open bookings within 14 days (.wptm-col-soon, .is-now for ≤1 day). Status-view counts in pills; ≤782px the views are one swipeable line; ≤600px the filters pair up two to a row (travel dates full width).
* Luxury Our purpose, framed portrait (luxury.css): the stepped hairline outline (.wptm-lux__purposemedia::before), the 4px white mat and the drop shadow removed; the photograph keeps the media radius only. Layout, text and phone order unchanged.
* Taxonomy index (/tours/destination/, /tours/type/, templates/index-terms.php): the intro read get_post_field('post_content', get_queried_object_id()) — 0 on this virtual address, so the global $post, i.e. whatever post the main query held — and printed it through wp_kses_post(), leaving <style> contents as text. Replaced by Indexes\lead() (idx_lead_destination/type/category, en/fr/es/ar + gettext; filter wptm_index_lead, '' removes it). Render-time fix: every site is corrected by the update, nothing stored.
* Index count: Indexes\count_label() — "8 destinations", "5 tour types", "4 categories" (idx_dest_count, idx_type_count, idx_cat_count + _many) instead of "8 sections". The duplicate count pill on the photo removed; phones show 16:9 photos.
* Index images: Indexes\image() now reads hub image → the term's own image (TermImage\get_id, the field on the term edit screen, translations included) → wptm_image → the featured image of the newest published tour in the term (tour_photos(): one bounded query per page, terms from the primed cache).
* Index SEO: Indexes\description() (lead + first five names, ≤160 chars) as meta description and a self canonical with no SEO plugin; rank_math/frontend/description, wpseo_metadesc (only when empty), rank_math/frontend/canonical and wpseo_canonical otherwise. The index is listed first in its taxonomy's core sitemap (wp_sitemaps_taxonomies_pre_url_list, page 1, enabled and non-empty only).
* SEO\fallback_description() with no SEO plugin: tours, activities and transfers get a meta description from the excerpt, else the opening of the content; term archives from the term description (SEO\plain(): shortcodes, tags and style/script contents out, cut at a word near 155). Not on noindexed views. Tour archive and term archives get a canonical (narrowed views → Archive\canonical_target() or the clean address; later pages → themselves) — WordPress prints one for single pages only.
* Index in every language: Indexes\paths() gives each Polylang language its own rule (/fr/tours/destination/ → wptm_index + lang=fr, translated tour bases included); one rule when the languages share the address. url( $taxonomy, $lang ). Before, every language but the default answered 404. pll_translation_url points the switcher and Polylang's hreflang at the same index in the other language; an index with no terms in its language is noindex, follow; the sitemap lists each language's index that has terms. Headings from idx_title_destination / idx_title_type / idx_title_category (en/fr/es/ar) instead of the untranslated taxonomy label.
* Index main query neutralised (parse_query, priority 99): post__in [0], no_found_rows, not tax/archive/singular, no queried object; pre_handle_404 keeps it 200; reset again on 'wp'. With Polylang the language made the request a taxonomy view whose queried object was the language term; its id, read as a post id by Home\is_home_page(), rendered the Spanish index as the homepage.
* tripvance_queried_post_id() (compat.php): the queried object's id only when it is a WP_Post, else 0. Replaces every get_queried_object_id() in includes/ and templates/ (46 calls): on a term archive or a virtual address those returned a term id that other code read as a post id (homepage/landing/site-page/SEO meta lookups on an unrelated post).
* Lang\in_language() also pins t() (filter wptm_translation_language for calls without a language) to the language it switches to: keyed labels used to follow Polylang's current language, not the switched locale.
* Pages\definitions(): titles and leads of the directory pages through t() (pg_dest/pg_dep/pg_dur/pg_type _title/_lead, en/fr/es/ar + gettext) instead of __(), so a site without a language pack makes French, Spanish and Arabic pages in their language. Pages\reword(): once per wptm_pages_wording (admin_init, publish_pages) gives existing generated pages their language's title and lead — only where the words are still generated ones in any language; addresses untouched. uninstall.php deletes the option.
* Cookie banner, French: non-breaking spaces before ":" and "!". Index cards: the arrow is its own span and mirrors in RTL (the row-reverse rule removed).
* permalinks.php routes_to_us(): the @ on preg_match removed (as WP::parse_request()).
* Booking screen: the long "Saves the status…" note under the head's Save button removed (the Status box says the same); section tabs as one segmented bar (swipeable on phones); Guest Pass buttons at one height.

= 2.10.0 =
Import and export carry activities, transfers and vehicle profiles (format 2).
* admin/export.php: FORMAT 2; types() registry (wptm_tour, wptm_activity, wptm_transfer) with own_key() meta prefixes (_wptm_transfer_ and wptm_gallery for transfers, _wptm_vprofile_ for profiles); items carry post_type; a top-level "vehicles" list; each _wptm_transfer_vehicles row gains "vehicle" (the profile's _wptm_vprofile_internal_ref, else its slug); wptm_activity_type added to the exported taxonomies; terms filtered by get_object_taxonomies().
* admin/restore.php: item_type() (format 1 items are tours), find_vehicle(), vehicle_rows(), restore_vehicles() before items; existing() spans the three types; terms only where is_object_in_taxonomy(); wptm_gallery never copied from meta (rebuilt from matched images); wptm_activity_tour skipped across sites; meta written with wp_slash(); the restore form posts its own wptm_mode; analyze() counts each type and new vehicle profiles.
* restore.php upload: a one-shot wp_check_filetype_and_ext filter lets a .json reported as text/plain, text/html or text/json through; read_file() still validates the document.
* bootstrap.php: the importer group loads admin/export.php (REST import fataled on FORMAT).
* import/screen.php: WooCommerce section only when woo_active(); cards; upload before the report; restore mode select; per-type counts. Page title "Import and export".
* rest.php: restores vehicles before items.

= 2.9.9 =
Booking forms without a tour; Luxury footer hidden page by page; luxury demo makes no pages.
* booking/forms.php (new, core group after handler.php): non-public post type wptm_bform (title, thumbnail; under Bookings in the grouped menu). Its facts are stored under the tour field keys (wptm_pricing_model, wptm_price_from, wptm_max_guests, wptm_min_notice_days) so the engine reads a form through the Tour class unchanged; _wptm_bform_layout ('' two-step | full) and _wptm_bform_button are its own.
* Joins the engine through the existing filters: wptm_booking_post_types, wptm_tour_booking_mode (always wptm_booking), wptm_simple_booking_form. Validation, availability, the ledger, mails, Guest Pass and documents are untouched.
* handler.php: return_url() + filter wptm_booking_return_url replaces get_permalink() in redirect_with_state() and the non-form-mode redirect; tours resolve exactly as before. A form returns to wp_get_referer() (the _wp_http_referer field nonce_field() already prints; same-site only) without the old state token. The state now carries 'product' so a page with several forms answers under the right one.
* booking.php: tour_url is empty for a post that is not publicly viewable, so mails do not link to a page that does not exist. admin.php: booked_type_label() names "Booking form".
* [tripvance_booking_form id display=popup|inline button]: popup reuses the plugin's .wptm-modal markup and booking.css (light/dark already styled) inside a .wptm-tour wrapper; assets/js/booking-popup.js (new) opens/closes it, traps Tab, Escape closes, focus returns, a request with errors reopens it. data-wptm-bform-* attributes so tour.js never binds it. Styles enqueued in the head when the post content holds the shortcode.
* salma.js: availability lookups use the form's own wptm_tour_id (config.tour as fallback), so two two-step forms on one page each ask about their own product. booking-steps.php: the step-one legend reads "Book your experience" on a booking form.
* Button design (forms.php): look_options() whitelists icon (17 of the plugin's own icons or none), icon_position, style (solid|outline|soft), shape (rounded|pill|square), size (md|sm|lg), width (auto|full), align (start|center|end); colour via sanitize_hex_color(), '' = site accent. Stored in one meta, _wptm_bform_look; look() merges stored values with shortcode attributes (null = not given) and drops anything unknown to the default. ink() picks #fff or #1a1a1a by WCAG luminance so a light custom colour keeps readable words. button() prints .wptm-bbtn + modifiers; the arrow icon mirrors on RTL.
* Edit screen: the design fields with a live preview (assets/js/booking-forms-admin.js, same classes and the same luminance rule), icons from <template> copies of icon().
* Booking slip (includes/booking/receipt.php, new): Receipt\render() reads the stored booking only — reference, form name, date, time, party, pick-up, name, contact method, status, the estimate from the price snapshot, created date — plus the site logo (grayscale, as a thermal print) or name, a barcode from bars(), and WhatsApp / download card / print actions. Replaces booking-success for booking forms: in the window the form opened (opened again on arrival) or in place for display="inline". claim_success_panel() keeps it single.
* Stages (booking-popup.js): data-stage processing → printing (0.9 s) → complete (2.75 s); the paper feeds in 19 steps (CSS keyframes from the reference component's timing), LEDs and a spinner/tick on the screen, the status line in an aria-live region, actions shown once printed. The markup is the finished slip: no script or prefers-reduced-motion = complete at once. Print opens the slip alone in a window with the plugin's stylesheet.
* assets/css/booking-forms.css (new): button + slip, logical properties, letter-spacing normal on Arabic/RTL, night mode, ≤420px layout. Selectors doubled (.wptm-bbtn.wptm-bbtn) so theme button rules do not win; modifiers come after the base at the same weight.
* Labels rcpt_processing, rcpt_printing, rcpt_complete, rcpt_booking, rcpt_time, rcpt_name, rcpt_contact, rcpt_print, rcpt_label in labels.php, gettext-labels.php and fr/es/ar in translations.php.
* Noticed, not changed: templates/sections/booking-form.php prints the ticket barcode through wp_kses_post(), which strips SVG, so the full form's ticket shows no barcode. The slip prints bars() directly.
* Booking reference (booking.php): REFERENCE_DEFAULT 'WPT-{YYYY}-{NUM}'; sanitize_reference_pattern() keeps A-Z, 0-9, '-' and the tokens {YYYY} {YY} {MM} {DD} {NUM} (anything else, stray braces included, removed; {NUM} appended when missing; '' stored for the default); reference_format() reads settings reference_pattern / reference_digits (1–8, default 3); format_reference(); next_reference() counts per year (option wptm_booking_counter_{Y}, the existing row, so numbering continues) only when the pattern holds the year, otherwise one counter wptm_booking_counter_all, so a year-less pattern can never repeat. A result over 32 characters (the Guest Pass lookup limit, /^[A-Z0-9-]{3,32}$/) falls back to the default shape. The old wptm_booking_reference_prefix filter still applies to the default pattern. reference_prefix() (letters before the first token, WPT otherwise) is the default of wptm_tour_code_prefix, so the ticket's tour code matches.
* Settings → Booking reference (settings-screen.php, after Default booking mode): pattern + digits, token help, an example that follows the typing (admin.js); settings.php sanitises both and keeps the stored values when a writer does not send them.
* Slip window without a title bar: the panel holding the slip (forms.php) prints no .wptm-modal__head, carries aria-label "Booking slip" and a corner close button; 540px wide from 600px up (button beside the machine), a 52px strip for the button on phones.
* Edit screen shortcode boxes: inline onfocus replaced by data-wptm-select handled in booking-forms-admin.js (no executable attributes in admin markup).
* Slip actions: WhatsApp on its own row (green), Download (short label rcpt_download, aria-label keeps "Download booking confirmation") and Print side by side, all the paper's width (calc(80% + 3rem - 36px)), 46px high, inline SVG download/print icons. Close buttons of both windows (.wptm-bform__close): a plain SVG cross, no disc, theme button colours kept off.
* Weight: booking-forms.css no longer depends on the tour stylesheet and is the only file added to the head of a page holding the shortcode (13 KB minified); booking.css / booking-steps.css go to the head only when the content holds display="inline" — otherwise a classic theme prints them in the footer (a block theme renders content before the head, so there they stay in the head). booking-popup.js, salma.js and booking.js load with defer from the shortcode. Pages without the shortcode load nothing new. wptm_luxury_pages_retired is autoloaded (it is read on every admin load).
* Specificity: tour.css's .wptm-tour * { max-width: 100% } now prints after booking-forms.css, so the slip's own widths are set at .wptm-tour .wptm-rcpt weight, and the slip panel's width at .wptm-modal__panel.wptm-bform__slippanel (booking.css sets the panel at equal weight).
* uninstall.php: removes the one-time flag wptm_luxury_pages_retired; keeps wptm_booking_counter_all like the yearly counters.
* luxury/chrome.php: HIDE_FOOTER ('_wptm_hide_footer') and footer_hidden_here(); the automatic wp_footer print skips a singular page carrying it. A footer placed as a section still prints. luxury/admin.php: side meta box on every viewable post type while the footer is on (Luxury + lux_footer), save_post with its own nonce; save_hide_footer() shared with the page builder (admin/page-builder.php: row in "Page title, address and layout", written only when hide_footer_field was on the form).
* luxury/demo.php: the 'pages' set, journeys_page() and the page loop removed. One-time admin_init (option wptm_luxury_pages_retired): pages in the demo record still marked demo and still drafts go to the Trash; published ones stay; the record keeps only what is left.
* Verified on WP 6.8.3 + SQLite, E_ALL, through the real screens (tests/test_299.py, 88 checks; tests/browser_299.py, 20 checks and tests/browser_slip.py, 32 checks; plus a classic theme (Twenty Twenty-One) pass at 390 and 1366 px, RTL, night mode, reduced motion): footer box save/clear in the editor and the page builder, demo retirement, form create/save, popup open/Escape/close/focus, two-step to step two inside the dialog, error → same page with dialog reopened, success → confirmation in place, booking stored under the form name, tour bookings still return to the tour.

= 2.9.8 =
Travel Guides pictures uncropped; new optional Marketplace tour card style. No data changes; existing card styles unchanged.
* card_style setting: third value "marketplace" (sanitiser whitelist in settings.php; Card\style() and the body class wptm-card-style--{style} in card.php; option in settings-screen.php). Unknown or unsaved value → boxed, so existing sites are unchanged.
* CSS only, same markup for every style: appended to card.css (.wptm-card) and tour.css (.wptm-card2), every selector under body.wptm-card-style--marketplace. No box; square photo (archive list view keeps its row); place as a grey tag; large title; rating; price as one line; excerpt and the "View tour" word hidden (the card is the link; the list view shows the excerpt). Phones: .wptm-cards becomes a scroll-snap row.
* The place/rating row is unwrapped with display: contents and reordered with flex order; the place keeps flex: 0 0 auto so it does not grow into the column's free height.
* Cards take their natural height in this style, so the WhatsApp button pinned to the card foot stays on the price line.
* archive.php: phpcs annotation on the filter-link $_GET read (already sanitised by sanitize_title() after an is_scalar() check); no behaviour change.
* Verified on WP 6.8.3 (E_ALL): boxed/open/marketplace at 390/820/1366 px, list view, night mode on a dark theme, RTL, setting saved through Settings; zero horizontal overflow. .min.css rebuilt with esbuild 0.25.9.
* Featured images are typically 1200×675 (16:9); the frames were 3:2 (cards, shared frame, editorial list, timeline, rail lead/more), 4:3 (compact grid, rail thumbs, editorial items, mobile overrides), 16:10 (editorial lead) and 2:1 (panorama rows), so object-fit: cover cut every picture.
* home.css: .wptm-home__guides--cards .wptm-home__guideimg and the shared .wptm-home__guidemedia frame → aspect-ratio 16 / 9.
* home-guides-*.css: every guidemedia ratio → 16 / 9; per-breakpoint ratio overrides deleted (all identical now).
* editorial / panorama_rows: guidemedia height: 100% (stretched to the copy's row → cover cropped the sides) replaced by align-self: center, so the frame keeps 16:9 beside taller copy.
* Verified with a 1200×675 bordered test image, all 7 layouts × 1366/1024/800/390 px: rendered image ratio 1.778 in 28/28 slots (before: 28/28 cropped). .min.css rebuilt with esbuild 0.25.9.

= 2.9.7 =
Sticky section bar under any theme header. No data or setting changes.
* tour.js: measureHeader() was called at start, load and resize only. Three theme behaviours broke it, reproduced with a fixture header (mu-plugin, ?xh=1/2/3): (1) relative at the top → fixed and shorter after 200px: offset 0, bar pinned at top:0 under the header ("disappears"); (2) sticky from the start, logo row collapses after 60px: offset 165 kept, bar 90px under a 75px header ("gap"); (3) fixed, hides on scroll down: 165px gap while hidden.
* Now: headerMayMove() on scroll/resize runs measureHeader() once per animation frame while scrolling and for 600 ms after (a theme's transition outlives the last scroll event); --wptm-offset written only when the rounded value changes; a 'wptm:offset' event on the root re-places the sticky booking column.
* clearStickyAncestors() hoisted out of the column IIFE and also run for the section bar when it is sticky (every width), once per page.
* archive.js: same follow loop around configure(); --wptm-offset written only on change.
* Verified, wheel-scrolling at 1600×900, 1366×768 and 390×844, sampled 80 ms (mid-transition) and 700 ms after each step, scrolling down and back up: bar stuck exactly at the header's bottom and hit-testable in all three fixtures; booking column at header + 24; admin bar + plain theme unchanged; listing and activity pages without errors; tour.min.js / archive.min.js rebuilt and re-run.

= 2.9.6 =
Rank Math sitemap tab names. No data, setting or address changes.
* Cause: RankMath\Helper::choices_post_types() memoises post type labels in a static the first time it is called (during init 10 via its settings bootstrap), while get_accessible_post_types() keeps recomputing until wp_loaded. wptm_activity (init 12) and wptm_transfer (init 12) were in the second list but not the first → tab title $object->label looked up in the stale map → "".
* activities/post-type.php: post type + wptm_activity_type at init 6; the borrowed taxonomies (wptm_destination, wptm_place) attached in a separate init 12 hook, because wptm_place registers at 11.
* transfers/post-types.php: register_post_types() at init 6.
* Verified with Rank Math (trunk) + Polylang on WP 6.8.3: Sitemap tabs read Tours / Activities / Transfers; activity taxonomies still destination, activity_type, place (+ Polylang's); wptm_activity sitemap lists the activities; tour, activity and home pages 200 with no PHP notices.

= 2.9.5 =
Cookie consent: languages, shape, and the rules of more countries. No data migration.
* includes/cookies/i18n.php (new): cc_* labels in the registry (wptm_tour_labels / wptm_tour_translations / wptm_tour_gettext_label) with fr/es/ar. text_defaults( $lang ) now = t() of text_keys(); categories(), link labels, Always active, close, Cookie Settings all through t().
* cookies.php text(): on a non-main Polylang language → option( lang_key( $lang, $key ) ) (flat key i18n_<lang>_<field>), else pll_translate_string of the main wording when it differs, else the plugin's wording in that language. Main language unchanged (custom → pll__ / WPML).
* Settings: shape rounded|square (square = --wptm-cc-radius:0 inline + .wptm-cc--square for switches/close), label_continue, continue_link, reopen, gpc, gcm ('1' by default, so existing sites get them; the form posts a hidden 0 before each checkbox).
* parts/intro.php: .wptm-cc__continue (data-wptm-cc-action="reject", data-wptm-cc-continue), absolute top inline-end; surface padding-block-start 44px while .wptm-cc--has-continue:not(.is-answered); arrow mirrored in RTL. parts/preferences.php: [data-wptm-cc-gpc] note.
* frontend.php render(): .wptm-cc-reopen button (data-wptm-cc-open, hidden until answered; side = card position; light/dark; square). wp_head priority 1: Consent Mode v2 default denied + ads_data_redaction + url_passthrough, and an immediate update from the stored cookie (version-checked). script_config() carries gpc / gcm.
* cookie-consent.js: GPC = config.gpc && navigator.globalPrivacyControl === true → accept-all leaves marketing off; signalGoogle() after every save; is-answered class; continue/close/reopen visibility.
* admin.php: Legal protections section, Corners select (radius row only for rounded), text_rows( $lang ) per Polylang language, other_languages(), save loop over main + other languages (tripvance_substr, 160/2000 chars).
* Verified with Polylang 3.9 (en/fr/es/ar) on WP 6.8.3: banner language, translated privacy link, RTL, continue → all denied + gtag update + reopen shown, returning visit → head update, GPC accept-all → marketing 0 and explicit opt-in → 1, three layouts × two shapes × 1280/390 (no overlap, no sideways scroll), admin save of every new field.

= 2.9.4 =
Security, stability, SEO and the settings screen. No data migration.
* tailor/submit.php: the rid dedupe key is md5( rid | nonce | wp_json_encode( data ) ) — the page-printed rid alone made two cached-page visitors one retry; honeypot treats a non-scalar as filled; meter( true ) capped by Security\REFUNDS_PER_HOUR.
* security.php: REFUNDS_PER_HOUR = 10; refund() counts refusals in wptm_rl_refund_<origin> and stops refunding past the cap (handler.php, transfers/booking.php, luxury/enquiry.php all route through it).
* booking/handler.php, luxury/enquiry.php: honeypot array bypass closed; enquiry language via Booking\posted_language().
* guest-pass/lifecycle.php: servable( $booking_id ) = enabled() && status in servable_statuses(); used by render.php current(), actions.php, payments/public.php claim_subject(), documents/public.php customer_may_open(). booking/voucher.php current() returns 0 when enabled() is off.
* compat.php: tripvance_query_string( $var ) — a registered public query var can arrive as an array; used by guest-pass/render.php, guest-pass/data.php, quotes/render.php, documents/public.php, booking/voucher.php, archive.php.
* partners/commissions.php: formula-safe cells. builder/canvas.php: template delete limited to its author or manage_options.
* admin/forms.php: flash_hold()/flash_take() — a per-user one-minute transient behind the fixed flag "held"; payments/admin.php, quotes/admin.php, resources/assign-admin.php back_url() and admin/restore.php use it; admin/import/screen.php maps none/type/storage to sentences and reads a held one.
* settings.php: sanitize_settings() keeps only scalars (card_fields aside). archive.php filter_url(): scalar guard. templates/sections/video.php: wptm_oembed_<md5> transient (week / hour).
* site-page.php: seo_plugin() returns 'other' for AIOSEO, The SEO Framework, Slim SEO, SmartCrawl, Squirrly; the description tag is printed only from META_DESCRIPTION (no excerpt fallback); page-builder.php explains the 'other' case.
* templates/*-design/single-tour.php: <main>/<div> by wptm_tour_render_main like the default template. tour/itinerary-view.php: image_alt_or() fallbacks. tailor/frontend.php: wp_robots + rank_math/frontend/robots noindex on ?tmr_sent / ?tmr_error. builder/preview.php: X-Robots-Tag + Rank Math filter.
* shortcodes.php: item heading h2 when the shortcode has no title (h3 under an h2 otherwise); nothing_yet() prints "This list is being prepared · See all tours" and noindex when a directory has no rows. archive-sidebar.php: side titles are h2. pages.php: section_viewable( $key ) — ensure() skips a directory whose taxonomy is not viewable. cars/render.php: subject() falls back to the page title before the site name.
* luxury/demo.php: pages use starters 'journeys' (journeys_page()) and 'about', carry META_KIND and the full-width template.
* landing/meta.php: fields cta_eyebrow, cta_title, cta_lead (group 'closing'); landing/render.php cta() reads them, else the labels. i18n: lp_cta_lead / idx_cta_lead lose "Nothing is paid until it is confirmed" in en/fr/es/ar.
* tailor/options.php: word_keys(), word( $key, $lang ), 'words' default; tailor/settings-screen.php: "Wording on the page" section and sanitiser (per language slot, 400 chars); templates/tailor/builder.php reads word() for the hero, reassurances and done panel.
* admin/settings-screen.php: data-wptm-group-intro on group-start headings and a where-to-start paragraph; settings-admin.js shows one group at a time (is-grouped / is-off-group), the nav switches groups, search lifts the restriction, sessionStorage wptmSettingsGroup and the URL hash pick the group; settings-admin.css styles .wptm-set-intro and the active group.

= 2.9.3 =
Listing pages, finer. No data or setting changes.
* tour.css: .wptm-archive no longer clips sideways (body.wptm-archive-page does, a class archive.php adds on every tour listing); .wptm-arc__hero--image is 100vw with margin-inline calc(50% - 50vw), padding-inline max(gutter, 50vw − 50%) so the words align with the column, square corners; title and links forced white at (0,3,0). archive.js corrects the position in an off-centre theme column and uses clientWidth. archive.php enqueues home-flush.js on listings; home-flush.js now also takes .wptm-arc__hero--image as its hero, so the gap under the theme header closes.
* List view filters (≥783px): no border, background or padding box; a hairline under them.
* Cards: .wptm-card__inner--ask / .wptm-card2--ask (set when the tour has a WhatsApp link and the field is on); the button sits at the bottom end of the card body (--ask-size/-end/-bottom per context: boxed, open, list, coarse pointer), the foot reserves its width and is printed even when price and "View tour" are off. Tinted rgba(37,211,102,.12) / #128c4a, solid on hover.


Polish: the Experium palette and the Panorama Slider hero. No data migration; nothing changes on a site until one of the two is chosen, apart from the four display fixes listed at the end.

Revision (same release, after the client's colour guide)
* Experium retuned to the guide: accent #82583f (Atlas brown, 6.6:1 with white), brand_secondary #24463a (forest green), brand_neutral #f5efe4, buttons and radius soft. Brand\experium_tokens() overrides the computed tokens with the guide's own values (primary-dark #6a4632, line and card border #e3dccd, neutral-deep #efe6d6) and adds --wptm-brand-sand #c3a06f / -sand-light #d2b488 / -secondary-dark #1a332a / -cream / -text / -muted. brand.css (.wptm-brand--experium): on dark heroes, the CTA band and dark grounds the accent becomes sand with dark-green words (the guide's "button over the hero"); the slider veil is #1a332a.
* Panorama Slider: up to ten slides (hero_float_items( ..., $max ); Floating Stories keeps five); hero_slider_motion drift | glide | fade (slider_motions()); non-first slides printed with data-src/srcset/sizes (slide_deferred()) and fetched by the script one ahead; an out-of-turn step waits up to 1 s for its picture. Glide = transform slides with an is-back direction class, laid out before the class swap.
* Bug found in QA: the dissolve timer read --wptm-slide-fade with parseFloat, and the minifier writes 1400ms as 1.4s, so the outgoing slide was released after 50 ms (a dip to the ground in Drift, a missing half in Glide). Units are now parsed.
* The pause button's icon follows the visitor's choice (is-stopped), not a hold from the pointer on the controls.
* builder-admin.js: a repeater with data-wptm-bulk="<cell>" gets "Add several photos" (wp.media, multiple: add); each picture fills the next empty row or goes through the same Add as a click, up to the ceiling, with its alt text.
* tour.css/tour.js: the section bar's ground is --wptm-nav-ground = the first opaque background above the page (measured once by tour.js into --wptm-page-ground), else the old fallback; stuck, it is painted 24px past both ends; on phones width: auto so the negative margins widen it to the glass (it was 100% and only shifted). dark.css keeps the scheme colour at night.
* salma.css: facts strip on var(--wptm-page, Canvas) (white by day, scheme card at night); up to five facts it is a flex row with content-sized cells.
* home-flush.js: when no known header name matches, the nearest earlier sibling (walking out from the hero) at least half the window wide is the header; a header fixed at the very top counts when not scrolled.
* home.css: headings on the dark heroes, the CTA band and dark grounds inherit the band's colour (a theme's h1/h2 colour had painted the hero title green).

Revision 3 (cards, signature slideshow, tags, night arrows, empty drawer)
* card.php: Card\field_choices() / fields() / style(); data() blanks the fields switched off (place, badge, rating, next, duration, price, whatsapp) and carries show_price / show_lead / show_cta, so the shared, archive and related cards all follow one setting. settings.php: card_style boxed|open; card_fields list (a hidden empty entry makes "none" a choice; a writer without the key keeps the stored list). Body class wptm-card-style--open; card.css / tour.css "Open cards" blocks at (0,3,1)–(0,4,1) to clear brand.css and dark.css.
* Signature: up to six photographs (hero + gallery) stacked in .wptm-home__sigpic; non-first via slide_deferred(); home.js turns them every 5 s while ≥25 % on screen and the tab is visible, holds on mouseenter, never under reduced motion, loads one ahead; outgoing keeps the drift (is-leaving) while it fades; decorative dots.
* tour.css end: .wptm-arc__option, .wptm-arc__chips a without border (kept under prefers-contrast: more), tinted; .wptm-arc__view without frame.
* dark.css: the gallery arrows were given a hover ground by the generated night sheet, which filled their full-height hit strip; transparent now, as by day.
* archive-tours.php: the filter list is reduced to axes with two or more terms before the drawer is printed (an all-skipped list left an empty framed details box in the sidebar column).

Revision 2 (corner shape, signature tour, slider text)
* brand_shape: rounded | square (Brand\choices(), sanitised by the same loop; not an Experium palette value, so the owner chooses it in Experium too). Brand\square() = unified() && square. tokens(): radius scale and button 0. Body class wptm-brand--square. assets/css/brand-square.css (generated by qa/gen_square.py from the module stylesheets, ~7.5 KB) zeroes every corner inside the module roots with !important and restores the 110 selectors drawn as true circles (border-radius 50%, or 999px with equal width and height) plus radios; enqueued after brand.css only when square.
* Signature tour (render-sections.php section_signature(), home.css block replaced): two-half card, image absolute-filled; eyebrow + tour type; lead clamped to 3 lines; check icons; facts from fact_duration / fact_destination (first two terms + "+N", full list in title) / fact_group; foot = price (once) + button. The card reads --sig-paper / --sig-line / --sig-radius from the section (it used to declare its own, which silently ignored brand.css's section values): white on a paper band, the page cream on a white one; dark.css gives it the scheme card at night. ≤520px facts are label/value rows.
* Slider text sets hero_slider_text light | warm | panel (slider_text_sets(); defaults, sanitizer, canvas, pll list; class wptm-home__hero--text-*). Tokens --slide-ink / -text / -eyebrow / -mark / -shadow; eyebrow 12.5px/600/.18em, title clamp(2.1rem, 1.2rem + 3.4vw, 4.1rem)/700/1.08, text clamp(1.03rem, .95rem + .35vw, 1.2rem)/400/1.65 at 52ch, all at (0,3,0) so a theme's weight 300 or heading colour cannot reach them. Panel: deep colour at 78% with blur and a mark rule; the veil lightens under it.

Experium (includes/brand.php, settings-screen.php, admin.css)
* `brand_mode` gains `experium`. Brand\experium() holds the palette as setting values (see the revision above for the values). They are served on the way out through the existing `wptm_setting` filter (priority 5), not written into the option: the owner's Unified values survive a switch, and a later release that retunes the palette reaches every Experium site without a re-save. Every reader of `accent` (home, cars, landing, hub, activities, transfers, luxury, documents) follows automatically.
* Brand\unified() is true for unified and experium, so the same tokens, brand.css mapping and night-mode remapping apply. Body class `wptm-brand--experium` in addition to `wptm-brand--unified`.
* The settings screen calls Brand\raw_settings( true ) first, so it prints and re-saves the stored values, never the palette (verified: an untouched save keeps accent/secondary as stored).

Panorama Slider (home/home.php, render.php, render-sections.php, admin.php; assets/css/home-hero-slider.css, assets/js/home-hero-slider.js)
* hero_styles() gains `slider`; new home key `hero_slider_interval` (slider_intervals(): 4, 6, 8, 10; default 6). Slides reuse `hero_float_items` (max 10, see the revision), falling back to the hero image alone. Stylesheet and script are enqueued only when the hero is on and the design is chosen.
* Markup: every slide is printed (first eager/high priority, others lazy/low), so no-JS visitors and crawlers get the first photograph as a cover; the controls are printed hidden and revealed by the script. Slides are a role=group carousel with per-slide group labels and aria-hidden on inactive slides; dots carry aria-current; the toggle swaps its label.
* Timing: the progress fill on the current mark is a CSS animation; the script advances on its animationend. Pausing = animation-play-state, so the photograph's drift, the progress line and the clock stop together and nothing drifts out of step. Holds while the keyboard is in the hero or a mouse is on the controls (not on hover over the whole hero, which would freeze a full-screen slideshow).
* Dissolve: the outgoing slide is kept opaque under the incoming one (is-leaving, per-slide timer) so the change never dips to the ground colour.
* Veil drawn with color-mix() from --home-dark (rgba fallback); Experium makes it deep cedar. Light / medium / dark overlay setting honoured; mirrored in RTL; a vertical veil on phones.
* Width: home.css's centred 100vw bleed, then the script measures the real position and corrects it (position: relative left) and uses the page's clientWidth, not 100vw, so a theme with an off-centre column or a scrollbar still gets edge-to-edge with no sideways scroll.
* The "see the tours" arrow is not printed with this design (the controls take the foot of the hero), as with Certified Local Guide.
* i18n: home_slides_label/pause/play/prev/next/item in en, fr, es, ar.

Verified (WordPress 6.8.3 + SQLite, PHP 8.3, Chromium): homepage at 1440 and 390, LTR and RTL, off-centre content column (hero 0 → clientWidth, scrollWidth = clientWidth), reduced motion (starts paused), no JavaScript (first photograph, no controls), slide advance after one interval, no console errors; settings save round-trip with Experium; tour page in Experium; Plugin Check sniffs: no new findings.

= 2.9.2 =
Polish: the Experium palette and the Panorama Slider hero.

* New: Experium, a third Brand identity mode, from the Experium Morocco colour guide: forest green for headings, dark bands and the hero veil, Atlas brown for buttons and prices, desert sand for buttons and marks over photographs and dark bands, warm sand and cream papers, warm hairlines. It runs through the Unified identity pipeline; your own Unified colours and shapes are kept untouched and come back if you switch.
* New: Panorama Slider, a seventh homepage hero design. Up to ten full-width photographs under a steady heading, text and buttons; an optional caption per photograph (a link when given), marks with a progress line, previous/next and pause; swipe on phones; starts paused for visitors who prefer reduced motion. Three movements: Drift (a slow push in and a dissolve), Glide (the photographs slide across) and Fade. Only the first photograph loads with the page; each next one is fetched shortly before it is due. The hero reaches both edges of the window even in a theme whose content column is not centred.
* New: "Time on each photograph" (4, 6, 8 or 10 seconds) and "Movement".
* New: Corner shape (Settings > Brand identity, in Unified and Experium): Rounded or Square. Square makes every corner straight across the tour pages, cards, homepage, booking panel, buttons and fields; only true circles (icon discs, dots, round buttons) stay round. Loaded only when chosen.
* New: "Text over the photographs" for Panorama Slider: Light, Warm or Panel (the words on a translucent dark panel). Each set fixes the colour, size, weight and shadow of the words so a theme cannot thin or tint them.
* Changed: the Signature tour section redesigned: the photograph fills one half of the card edge to edge; eyebrow with the tour type, title, a three-line lead, highlights with a check, one row of facts (duration, destination, group size) and a foot with the price and the button. The price is printed once; a long destination list shows two names and the count of the rest. On phones the facts become rows.
* New: Tour card style (Settings): Boxed, as before, or Open — the photograph framed on its own and the title and details below it on the page's colour. Applies to every tour card, including the listings.
* New: What a tour card shows (Settings): destination line, rating, duration, next departure, short description, price, badge, "View tour" link, WhatsApp button — each can be switched off for every card variant at once.
* Changed: the Signature tour photograph is now a slideshow of the tour's own photographs (the card image, then up to five from its gallery), turning every five seconds while the card is on screen; it holds under the pointer and stays still for visitors who prefer reduced motion. Only the first photograph is fetched before the card is reached.
* Changed: the filter options, the related-destination tags and the grid/list switch have no frames; a soft tint marks them and the chosen one is filled.
* Fixed: night mode painted the tour gallery's next/previous arrow strip as a dark slab over the photograph.
* Fixed: on a listing where no filter has two choices (a destination page whose other filters have one term each), an empty framed box stood at the top of the column and did nothing when pressed; it is no longer printed.
* New: "Add several photos" on the hero image list: pick many pictures from the media library at once.
* Changed: the Floating Stories image list is shared with Panorama Slider and renamed "Slider and Floating Stories images" (Floating Stories uses the first five).
* Fixed: the tour page section bar was drawn white on any page colour; it now takes the theme's own page colour, extends past its ends when stuck, and reaches both edges on phones.
* Fixed: the Salma facts strip sat on a grey wash and split long values into equal narrow cells; it is now a card on the page's card colour whose cells size to their content.
* Fixed: a theme header with an unknown name left a gap between the menu and the homepage hero; the hero now finds the element above it and sits right under it.
* Fixed: a theme that colours h1/h2 directly painted the homepage hero title (and headings on dark bands) in its colour over the photograph; they keep the band's white.
* Code: an annotation on the transfer editor's meta box ordering for Plugin Check.

= 2.9.1 =
Multilingual permalinks and SEO-safe URL management. Built on the existing address engine (rewrite_slugs(), includes/slugs.php's per-language rule copies, the Setup wizard's evidence of a new site); no competing permalink system, no new dependency, PHP 7.4.

Audit (2.9.0)
* Post types wptm_tour (base `tours`, archive), wptm_activity (`activities`, archive opt-in), wptm_transfer (`transfers`, archive opt-in); taxonomies under the tour base: wptm_destination `tours/destination`, wptm_tour_type `tours/type`, wptm_theme `tours/category`, wptm_departure `tours/departure`, wptm_duration `tours/duration` (the last two off on sites first activated on 1.4.1+); wptm_activity_type at `activity-type`. Index pages (/tours/destination/) and explicit top rules for the section pages (archive.php). Bases came from the English literals, the wizard's tour_base / activity_base (new sites, 2.8.10), or, with "Addresses per language" on, the installed catalogue per language.
* Booking, voucher, Guest Pass, quote, PDF and WhatsApp links use admin-post, admin-ajax, their own routes or get_permalink(); none stores a tour base.

Configuration (includes/permalinks.php, new)
* `wptm_permalinks`: mode (shared | translated), the default language when saved, and a segment map per language plus '*' (any other language, and the whole site when Polylang is off). Written only by Addresses & SEO or by the wizard's Apply on a new site. Absent on every updated site, which therefore runs the 2.9.0 code paths unchanged: rewrite_slugs(), Slugs\enabled(), Slugs\segments() and the slug signature return exactly what they did (verified: identical rewrite_rules and signature after the update on an English site and a Polylang site).
* With it, rewrite_slugs() registers the default language's segments and Slugs copies the rules for every other language, as the catalogue mode did; shared mode needs no copy. The tour sections follow the tour base (`circuits/destination`); a segment a catalogue had translated on its own is kept as it was when a legacy configuration is saved unchanged.
* paths(): every route's path per language, from Slugs\language_prefix() (Polylang's own force_lang, hide_default and rewrite options) and WordPress's root. The preview, the impact, the history, the redirects and the result check all read it. A prediction check compares it with the real permalinks of one item and one term per language before any change; a mismatch blocks the change.
* validate(): empty, slash, characters (2–40, a–z, 0–9, single hyphens; accents and capitals normalised first), reserved WordPress words, language codes, two Tripvance routes at one effective address (the same word in different language folders is fine). Nothing else blocks a save: a page or another plugin at the same word, or an earlier base, is the administrator's call, and going back to an earlier base makes it live again (its redirect leaves the history). Errors name the language and field; nothing is renamed or suffixed.
* apply(): one at a time (a lock shared by Addresses & SEO and the wizard); snapshot (`wptm_permalinks_previous`), history (`wptm_permalink_history`), configuration, each read back from the database past the option cache; a mismatch restores all three. Rules are rebuilt on the next admin request (wptm_rules_stale), after every route is registered with the new configuration. No post, term, meta, booking or SEO field is written.

Redirects
* History: one entry per previous path {path, language, route}, for every route including those switched off at the time; a path that becomes active again is removed, so an old entry can never answer for a live address. Capped at 3000 entries (about a hundred changes on a three-language site).
* On a 404 only (GET/HEAD, not admin, AJAX, REST or preview), template_redirect priority 5: the longest previous path that prefixes the request; the item looked up by its own slug in the old address's language (published, exactly one, language verified), the term by its slug in that language, the list page for a bare base; page/N carried. Target from get_permalink() / get_term_link() / the current path, so it is always one hop. Query arguments that do not change routing are carried. X-Redirect-By: Tripvance. Cache-Control: max-age=86400, so a browser does not keep for ever a redirect that Restore later reverses. A previous Tripvance address with no exact target stays 404: WordPress's own slug guess is switched off for it, as it could land on another language's page.
* Translated mode: the default base under another language's folder (/fr/tours/a-tour-fr/) now redirects to /fr/circuits/a-tour-fr/ (it used to answer 200 beside the new address); transfer list pages too. These redirects now follow GET and HEAD only, carry tracking arguments, and accept addresses without a trailing slash.

Polylang
* Free: no Pro API used. pll_check_canonical_url: an item asked for in another language's folder had its folder swapped and its base kept (/fr/circuits/a-tour/ → /circuits/a-tour/, a 404); the base is swapped too (→ /tours/a-tour/). pll_translation_url: list pages' hreflang and switcher links had the same problem (/es/circuits/); fixed with the same Slugs\swap_base().
* The section pages' explicit top rules (archive.php) are now copied for translated languages; without them /fr/circuits/destination/x/ fell into the copied tour attachment rule and answered 404 (also on 2.9.0 catalogue sites).
* Pro: detected by POLYLANG_PRO. Translated mode is not offered (one owner per address) unless Tripvance already translates, or `wptm_permalinks_translated_allowed` says so.
* Unsupported for changes, with the reason on screen: plain permalinks, languages by domain, subdomain or content, bases filtered by code.

Admin
* Settings → Addresses & SEO (includes/admin/addresses.php, assets/css|js/addresses-admin.*): current configuration, Shared / Translated, bases table (real Polylang names, any number of languages), live preview, one Save changes (validated on the server; a refusal keeps what was typed, with the error beside each field), result checked against the rebuilt rules, Restore Previous in one click. Choosing Translated slugs fills the per-language fields with the suggested words (French circuits / activites / transferts, Spanish tours / actividades / traslados, English as is); a site already on translated bases sees its own.
* Setup wizard: the Addresses step uses the same editor; on a new multilingual site Translated slugs is preselected with the suggestions; a site that may already have addresses, or whose routing Tripvance does not change (plain permalinks, Polylang by domain, subdomain or content, filtered bases), sees "Existing URLs are protected" and a Manage Addresses & SEO button. Apply stores the addresses through Permalinks\apply() first, only when an address actually moves on a site that may choose, and puts them back if the settings write fails. Wizard drafts started before 2.9.1 are discarded rather than half read. The old tour_base / activity_base fields, base_error(), is_moving_rule(), address_rows(), address_preview() and rules_have() are gone.
* General settings: the "Addresses per language" checkbox moved out; its stored value is carried over on every save so a legacy translated site cannot lose its addresses by saving another setting.

Tested on WordPress 6.8.3, Polylang 3.8.10 (free), MariaDB 10.11, PHP 8.3 (code checked for 7.4): upgrade of an English-only site and of a Polylang site (identical URLs, rules and signature; no redirect, no 404), a 2.9.0 catalogue-translated site, a new install through the wizard, shared ↔ translated, three successive changes, restore, invalid input, Polylang off and on again, default-language folder shown and hidden, WordPress in a subdirectory, no trailing slash, Pro detection, canonical, hreflang and core sitemaps, booking submission, 71 admin screens without a PHP notice.

= 2.9.0 =
Tourist transport and transfers. Development of the existing transfer module only: no new post type, no second booking system, no second gallery, no new framework or dependency. Every existing meta key, option, address, ID and booking is read exactly as in 2.8.12; nothing is migrated.

Data (additive)
* `_wptm_transfer_stops`: array of {name, description, note}, in the agency's order; a row without a name is dropped; at most 20. `_wptm_transfer_faq`: array of {q, a}; a question without an answer is kept in the editor and left off the page; at most 40. Both are read back through the same sanitiser the save uses (Transfers\stops(), Transfers\faq()), so a hand-written or imported value can never reach a template in another shape or raise a notice. Saved only when their box was on the screen (a `*_present` flag, as the vehicle list already did), behind the transfer nonce and edit_post.
* Polylang: excluded from custom-field synchronisation (pll_copy_post_metas with $sync), copied when a translation is created — each language keeps its own stops and questions. Vehicle Types stay shared.

Transfer screen
* Final pass: fields named the way an agency says them (From, To, Duration, "Price for this route"), with an example in the box and one line of help only where it adds something; the pickup and meeting instructions joined the hours, the notice, round trips and flight details in Booking & operations; the currency sits above the prices it applies to and the code beside each price follows it; each vehicle row shows the Vehicle Type's picture and capacity; shorter text areas; the vehicle table turns into cards whenever its box is narrow (measured on the box, so an open editor sidebar counts); remove buttons name what they remove; "Transfer gallery" and transfer wording in the gallery box. The shared text field accepts an optional placeholder.
* Editor shell: the transfer screen now uses the shared Tripvance editor (classic edit screen, as tours and activities; a transfer whose description already holds blocks keeps the block editor) — the bar over the title, Transfer sections navigation, a Transfer status card and advisory readiness (essentials: title, From, To, a priced Vehicle Type; stops and FAQ are never counted). The shell's words come from one helper with a `wptm_editor_words` filter, so a transfer never reads "New tour". Transfer basics sits under the title; Booking & operations is split into Availability, Pickup and Journey; the stops show as a route line from pickup to drop-off; a duplicate vehicle is named in its row; a professional empty state when no Vehicle Type exists. All Transfers: Route (with duration · distance), Price ("From …" or "Price not set") and Vehicles columns, Vehicle Types read in one query. Shared stylesheet: no letter-spacing on the small headings in right-to-left admins. No meta key, box ID, save rule or front-end template changed.
* Box order: the transfer's own boxes (one list, Transfers\editor_boxes()) are drawn first and together in the main column, then every other box — SEO plugin, theme, WordPress — under an "Additional settings" line; applied after all boxes are registered and over a saved drag arrangement, on the transfer screen only; no other box is removed, moved to another column or changed. With JavaScript the untouched blank rows the no-JS form needs are dropped and each empty list says so; without it they still add a stop, a question or a vehicle. Live readiness counts "Vehicle and price" only for one row with a published, active Vehicle Type, a valid price and Offered on, as the server does.
* Floating Stories hero (home.css only): the photographs lose their thick white frames for a soft shadow and a 16px radius, lean one or two degrees in alternating directions instead of six, and have sizes of their own; the lower pair sits in a gutter the search bar leaves beside it at every desktop width, so the bar never crosses a photograph; the fifth photograph shows in the phone strip rather than half under the bar; three still, faint stars replace seven drifting glowing ones; the gradient is quieter, the heading shadow gone, and the copy follows one scale (eyebrow, title, line, membership badge, bar). Between 1100px and 820px the lower pair gives way and the upper pair shrinks to the corners; on phones the strip shows two photographs and scrolls. No other hero style, setting, key or template changed.
* Fewer curves (home.css only): on Floating Stories the photographs take a 6px corner, the search bar 6px, its button 4px, the membership label 5px and the scroll-down label 3px, at every width (the shared phone rules had put 18px back on the bar); on Classic Article Cards the photograph takes a 4px corner and, now that its own height no longer outranks the 3:2 frame, every card's picture is the same size whatever was uploaded; the category is a 3px label on the date's baseline with a lighter date beside it; the hover is a 1.5% zoom with no shadow. No container, border or shadow around an article; no other hero style, article layout or section changed.
* Boxes: Transfer basics (route ids kept), Route & stops (new), Vehicle Types & pricing, Booking & operations (now in the main column; operating hours and notice moved here), Content & practical information, FAQ (new), then the gallery, now in the main column like tours and activities. Field names unchanged.
* The repeaters reuse transfer-admin.js, the vehicle table's script: one delegated listener per box, bound once; new rows take an index no row has had on the screen, so remove-then-add can never collide; the posted order is the document order. Without JavaScript the blank last row adds an item.

Transfer page (templates/single-transfer.php, parts route.php, faq.php, gallery.php — each overridable under tripvance/transfers/)
* Order by the traveller's questions: banner (the route on one line, origin → destination, the arrow turned on right-to-left pages), At a glance, Your route (every route: two points with the leg's duration, or every stop), vehicles and form, About, Pickup and meeting, Included, Important information, FAQ, Photos, Cancellation, closing prompt. The vehicles are the form's first step and are not listed twice. One H1, H2 per section, H3 per question.
* The script (transfer.js) now loads on every transfer page, as each part does nothing without its markup: FAQ accordion (answers printed open, folded by the script) and the photo viewer, a port of the activity viewer.
* Fix: `.wptm-trf img { height: auto }` outranked `.wptm-trf-veh__img { height: 100% }`, leaving a strip under vehicle pictures.

Lists
* Transfers\card(): one transport card for the archive and the homepage's Route Focus and Premium Transport layouts; assets/css/transfer-cards.css, loaded only there. The Classic Cards layout keeps Card\transfer() unchanged.
* Archive: Transfers\prime_cards() before the loop (posts, meta, thumbnails and every Vehicle Type in one query) — measured 40 queries for a page of 10 transfers, 42 for 3. Pagination through paginate_links() with the archive strings, in a labelled nav.
* Homepage: `transfers_style` (cards | route | premium, default cards) and `transfers_more` (default on), both cleaned in the module's wptm_home_sanitize filter and carried over while the module is off. The link goes to the archive only while it is published.

Homepage builder
* Transfers is a section of the builder's library (Tours & activities): added, hidden, shown, moved and removed like any other, its settings on the Content tab (title, intro, automatic or chosen by hand, how many, View all transfers) and its Layout on the Design tab, with miniatures and the layout's name on the section's row (canvas home_styles(), Home\picker_items( 'transfers' )).
* A section added to a page that never had it starts on Route Focus (marked Recommended). A homepage that showed transfers before 2.9.0 keeps Classic Cards, and nothing changes on the site until the owner saves another choice.
* The panel says when no transfer is published yet, with a link to add one; the site then prints no section at all. The page's inventory counts published transfers.
* Fix: the builder's summary under a section read only the page builder's selectionMode switch, so Transfers left in Automatic showed "2 chosen" from an old hand-picked list.
* Cards: the route leads (origin and destination, top to bottom so it reads the same way round in Arabic), then the transfer's name, smaller, on two lines at most; the kind of transfer, duration, capacity, price and button. The homepage gives the grid an even number of columns for the cards it has (one on a phone, two on a tablet, up to four), so one or two cards are never stretched across the page. Fix: a stray rule had moved the route line under every card's first marker.
* The section's link reads "View all transfers".

Not in this release, on purpose: maps, geocoding, distance or kilometre pricing, payments, dispatch, WhatsApp API, schema, canonical/meta/OG/robots output.

Stability pass (still 2.9.0)
* Operations, round trips (includes/resources/assignments.php, includes/operations/board.php, manifest.php, assign-admin.php): Assignments\transfer_legs() reads the two journeys of a transfer booking from the booking alone (`_wptm_booking_date`/`_wptm_booking_time`, `_wptm_trf_return_date`/`_wptm_trf_return_time`). On a transfer booking the assignment's existing day_index names the journey: 0 outbound (every transfer assignment made before, unchanged in meaning), 1 return. Window: pickup time + the transfer's duration when minutes_from_text() can read it (a range counts as its upper end; over 24 h or unreadable is not used), otherwise from the pickup time (or 00:00) to 23:59:59 of that day; on a same-day round trip that fallback outbound window ends where the return begins, so one driver doing both is not a clash with itself. Board and manifest list journeys (Operations\trips_between(): bookings_between() plus returns_between() on `_wptm_trf_return_date`), labelled Outbound/Return with their own date, time, pickup (the return starts at the drop-off address, else the booked destination) and crew (Assignments\crew() takes a journey; for a transfer the default is the outbound one, so every screen that showed "the driver" shows the same one). Balances stay per booking; the manifest counts bookings and travellers once. describe() and the booking history name the journey ("Driver (Return)"). Tours and activities: windows, day picker and crew unchanged.
* Assignments\resync(): each row of a booking takes the window of the day or journey it names. Called by the booking screen whenever the date, the pickup time or the return changes, whether or not the change notice is sent (before, the crew moved only through wptm_booking_date_changed, fired only with the notice), and by assign() for the booking being crewed, so a row saved before this pass cannot clash with its own return. Nothing else is rewritten.
* Add booking (includes/booking/manual.php, booking-admin.js): Return date and Return time for a round trip, shown only for one; required with the travel date and pickup time; read in the site timezone (wp_timezone()); the return must be after the pickup (same day later is fine); stored in the existing `_wptm_trf_return_*` keys through the same create(); one way stores none whatever a hidden field holds; a refused form keeps the values and marks the fields. The travel date is now checked as a real calendar date. Booking screen (includes/booking/edit.php): a round trip's return is editable under Trip details with the same rules; a round trip saved without a return before this pass can still be edited and is flagged under its title until one is set.
* Transfer form (assets/js/transfer.js, templates/transfers/booking-form.php): the server's rules in the browser — passengers whole 1–60, bags whole 0–99, a real date and 24-hour time, the minimum notice (from data-wptm-earliest, printed in the site timezone; on a cached page it is only more lenient), a return after the pickup — with the existing translated messages beside each field. The send button is released on a page restored by Back and 20 seconds after a send the browser stopped; a second click while sending still counts once. Server (includes/transfers/booking.php): passengers must be whole digits ("2.5", "1e1" were read as 2 and 10).
* Prices (includes/transfers/pricing.php, admin.php): Transfers\parse_price() is the one policy for typed prices, worked out on the digits; to_decimal() no longer goes through a float. A refused price is reported on the transfer screen and the row keeps its previous price (or stays without one). to_minor() reads a stored value the policy would refuse the way it was read before, so no saved price changes. Upper limit 10 000 000.00 per journey.
* Input hardening: non-scalar $_POST values are skipped (transfer and Vehicle Type fields), read as empty (vehicle rows) or refused (transfer form, attribution fields) instead of raising "Array to string conversion"; the honeypot counts as filled when sent as a list (it read as empty); a form returned with errors carries a start time that satisfies the minimum fill time, so a person correcting one field is not silently dropped.
* readme.txt: the 2.9.0 Upgrade Notice shortened to 248 characters (Plugin Check upgrade_notice_limit).
* Minified copies rebuilt with esbuild 0.25.9 for the four files changed (admin.css, booking-admin.css, booking-admin.js, transfer.js).

Night mode pass (still 2.9.0)
* Detection (assets/js/scheme.js, new, ~2 KB, no dependency): reads the background colour actually painted behind the first Tripvance element; below WCAG relative luminance 0.18 it sets data-wptm-scheme="dark" and --wptm-scheme-bg on <html>, otherwise removes both. Re-checked when the theme changes a class, attribute or style on <html> or <body>, when prefers-color-scheme changes, on load and on pageshow. No theme class names, nothing stored, nothing sent.
* Styles (assets/css/dark.css, new): every rule is under html[data-wptm-scheme="dark"], so none of it applies on a light page. A hand-written head sets the night palettes (tour designs, cards, homepage, car hire, luxury, itinerary, brand tokens; navy kept as a fill) and the form fields; the rest is generated by the QA tool darkify.py from the front-end stylesheets (light fills sunk into the measured page colour, dark text lifted), inside @supports (color: color-mix()), so an older browser keeps the light look rather than a half-dark one. Stylesheets are not edited.
* Unified brand identity: brand.php prints opaque light papers (--wptm-brand-neutral, --wptm-brand-neutral-deep) and a navy line (--wptm-brand-line) on the module roots, which brand.css maps onto the price card (--tk-paper), cream bands, ticket stubs and lines. At night these three tokens take the night card, a card tinted with the brand primary, and the night line, on the same roots; text in --wptm-brand-secondary takes the night ink. Primary, secondary and shapes stay the brand's.
* Loading (includes/assets.php, enqueue_scheme()): only on a page that already enqueued a Tripvance stylesheet; checked at wp_enqueue_scripts (priority 100) and again in wp_footer for shortcodes and blocks. A site accent (accent_inline_style()) is restated for night so the Classic preset's lighter night green never replaces it.
* WhatsApp mark: Home\whatsapp_glyph() and Cars\whatsapp_glyph() use icon( 'whatsapp' ) (filters wptm_home_whatsapp_icon / wptm_cars_whatsapp_icon kept); builder section buttons to https://wa.me/ and both card ask links show it too (sections.css spacing).
* Itinerary (itinerary.css): 4px inline-start and top padding so the day badge's ring is not clipped by the list's edge.
* Minified copies built with esbuild 0.25.9: dark.min.css, scheme.min.js, itinerary.min.css, sections.min.css.

= 2.8.12 =
Builder saves: no endless spinner, and a failure that says what it was. Shared by the Homepage builder, the page builder and the car hire builder (one save path, builder-admin.js). Nothing stored is changed or migrated: no setting key, option, page, homepage section, field name, SEO, Polylang or Rank Math output; the sections are not reordered and the builder is not rebuilt.

Cause of the endless spinner
* A JSON save that succeeded called location.assign( result.redirect ) and left the button busy, counting on the next page to clear it. The homepage's redirect is admin_url_for( 'saved' ) plus the #section last edited. After one save the browser is already on that address, so the second save asked for the same address with a fragment — a same-document fragment navigation by the HTML spec: no request, no load, nothing ever cleared the spinner. The status beside the title said "Saved" while the button span. Reproduced in jsdom on 2.8.11 and fixed in 2.8.12.
* Fix (builder-admin.js, saved()): on success the form is marked clean, the conflict token field takes result.revision, the spinner stops and the button is enabled — before any navigation. Then: the same address apart from the fragment → history.replaceState + location.reload(); another same-origin address → location.assign(); another origin → stay. The leave-page guard is suspended for 10 seconds only, so a navigation the browser refused leaves a normal, usable screen. builder-visual.js also clears its busy state on "saved", not only on "idle" and "failed".

One way out of every save
* fail( kind, status ) in the submit handler is the single exit for failures and runs once (a settled flag): clear the timer, setSaving( false ) first, then the notice. A late answer after a timeout changes nothing on screen.
* Timeout: SAVE_TIMEOUT = 30000 ms. The timer is set before fetch and cleared only after response.text() has been read, so a server that sends headers and then stalls is caught too (2.8.11 cleared it on the headers). With AbortController the request is aborted as well; without it the attempt is still given up on screen.
* The answer is read as text and classified by readSaveAnswer(), never trusted:
  - JSON with a boolean "success" (Builder\save_result()) → the server's own result; success:false keeps its own message and code (conflict, truncated, payload_*, save_failed), as before.
  - JSON after a PHP notice/warning printed ahead of it (display_errors on) → the JSON from '{"success":' on is used, so a stored save is not reported as a failure.
  - Redirected to wp-login.php, or a login form → nonce-expired.
  - 400/401/403 with WordPress's own refusal (wp_die JSON code "wp_die", .wp-die-message HTML, empty body, -1/0) → nonce-expired.
  - 5xx → server-error.
  - Anything else that is not the result (ModSecurity or another firewall page, a host page, HTML with 200) → invalid-response.
  - fetch rejected → network; aborted → timeout.
* The request sends Accept: application/json, so check_admin_referer()'s refusal and admin-post's signed-out refusal come back from wp_die() as JSON (wp_is_json_request()). The body stays multipart FormData: $_POST and the nonce work as before.

The notice (inside the builder, above the form)
* One sentence per kind, the edits-are-still-here promise kept, and an "Error type: <kind> · HTTP <status>" line — a fixed code and the status only, never the payload, a field or the response body.
* Try again resubmits through requestSubmit() with the same button that was pressed (so the return-to section is the same); the conflict token keeps a retry safe when a slow save did land after all (it is refused as a conflict instead of overwriting).
* nonce-expired also offers "Copy my changes" and "Reload the latest version", as a conflict does, since a fresh key needs a reload.
* New strings: saveServer, saveInvalid, saveRetry, saveErrorCode; saveTimeout and saveExpired reworded.
* The status cards' buttons (Publish, Set as front page…) drop their busy state when the browser brings the screen back from its cache (pageshow), as the bar's Save already did.

Server (includes/admin/builder.php)
* deliver_result() sends nocache_headers() with the JSON answer, and on a refusal calls log_refusal(): with WP_DEBUG_LOG only, one error_log line with the target, the code, the transport (json/form), the payload's byte length, the number of POST vars, max_input_vars and post_max_size. No field, payload, customer or user data.
* Unchanged: the JSON transport (one wptm_builder_payload field plus action, nonce, referer, token and the pressed button — a handful of POST vars however long the page), the 1 MB payload_limit(), the plain-form fallback without JavaScript and its end-of-form sentinel, the redirect flags, the handlers in home/admin.php, admin/page-builder.php and cars/admin.php.

Checks
* jsdom, the real builder-admin.js + builder-visual.js, 18 cases: success with the same address + fragment (fails on 2.8.11), another address, another origin; 500 HTML; 403 firewall page; 403 nonce as JSON and as HTML; redirect to wp-login.php; 200 HTML; network failure; timeout with no answer (request aborted); timeout with a stalled body; notice + JSON; 409 conflict; Try again after a failure; the page builder and the car hire builder. Each ends with the spinner off, Save enabled, the typed value intact; a homepage of 300 rows plus Custom HTML with <script> travels whole in the JSON payload as a handful of POST fields.
* PHP harness, E_ALL: deliver_result() status codes, nocache, redirect for the plain form, and log_refusal() content (no field value in the log).
* php -l on every changed PHP file; node --check on the changed scripts; .min.js rebuilt with esbuild.

= 2.8.11 =
Tour page width and theme headers, and the Site pages / page builder button polish. Nothing stored is changed or migrated: no setting key, tour, booking, page, address, canonical, schema or Rank Math output; pages are still created as drafts and nothing is published, put in a menu or made the front page.

Tour page and the theme's content area
* Cause: tour.css gave .wptm-tour width: 100% and framed the Classic article only as .wp-site-blocks > .wptm-tour > .wptm-tour__article (Salma only as .wp-site-blocks > .wptm-salma-design), i.e. only on block themes. On a classic theme, get_header() opens whatever the theme opens — on many, no content container — so the page took the whole window; the article's gutter existed only below 783px. --wptm-container was declared but nothing applied it.
* Fix: one frame rule at the end of tour.css for .wptm-tour > .wptm-tour__article and .wptm-tour.wptm-salma-design, in CSS only. It compares the width the page was given (100%) with the window (100vw): more than 64px narrower means the theme has a content area, and the page uses all of it with no padding of its own; otherwise the page is held to --wptm-frame and centred, never closer to the glass than --wptm-gutter, and any padding the theme does give counts towards that gutter instead of adding to it. Padding rather than a narrower box, so the section bar's phone bleed and the article's horizontal clip are unchanged.
* --wptm-frame = var(--wp--style--global--wide-size, var(--wptm-container, 1200px)): the theme's wide size from theme.json when it has one (the width it gives wide, two-column content; its narrower content size is for running text, which the page already holds to --wptm-measure), else the plugin's container. Redeclare --wptm-frame on .wptm-tour to choose another. No theme is named, no width is measured by script.
* The block-theme-only rules are reduced to what is still theirs: .wp-site-blocks > .wptm-archive keeps its measure; Salma keeps its top padding there. The mobile padding-inline on .wptm-tour__article and on Salma's head, layout, booking result, blocks, related tours and trust strip is now the root's frame padding.
* Unchanged: the other designs (Sara, Atlas, Sahara, Medina, Qamar, Zellige, Luxury) and their full-width heroes and bands, archives, destinations, activities.

Theme header and the sticky booking column (assets/js/tour.js)
* measureHeader() probed the top line of the window, which is the WordPress toolbar when it shows, so a header fixed or stuck under it was never found. It now probes at the toolbar's height (also under 600px, where the toolbar scrolls away but themes still pin the header there), skips the toolbar itself, and tells fixed from sticky: a sticky element whose parent is no taller than itself cannot stick and is ignored.
* A fixed header is checked against where the page starts, at the top of the page only: if it covers it, .wptm-tour gets exactly the missing height added to its own top padding; a theme that already leaves room gets nothing. The page's top edge does not move with that padding, so measuring again never adds it twice. Measured at start, on load, on resize (debounced) and before an in-page jump (for headers some themes fix only after scrolling) — never on scroll.
* The ancestor repair (overflow hidden → clip, so position: sticky works) now runs only once the booking column is actually sticky, so phones and one-column views leave the theme's wrappers untouched; a plain block wrapper also gets display: flow-root so floats stay contained and margins do not collapse through it as they did under overflow: hidden.

Site pages and the page builder (builder.php, builder-visual.css/js, home/admin.php, cars/admin.php, builder/canvas.php)
* Cause of the uneven bar: every button was display: block with a 28px line in a 36px box (44px on touch screens); a <button> centres its label, a link (Preview, Preview page, Preview homepage) does not. Bar and status-card buttons are now inline-flex, centred, with one font size, weight, padding and icon gap; heights stay the admin's own and widths follow the label.
* Undo and Redo are disabled until there is history (unchanged) and are now readable when disabled: no half opacity on top of WordPress's grey. Secondary actions are ink on white with one border; Save and the status cards' primary actions keep the Tripvance green.
* The bar's actions are one group (.wptm-visual__actions) that moves under the title as a whole on a narrow bar; the empty spacer is gone.
* Saving keeps the button's label (and width) and shows a spinner over it, with aria-busy; "Saving…", "Saved", "Unsaved changes" and the error stay in the status beside the title. One busy() helper replaces four copies of the label swap.
* Status cards: one quiet border and an 8px radius instead of a gold bar on every card (the pre-publishing checklist keeps its amber edge), more room under the labels and badges, and actions that fill their rows (each from its own label width) instead of leaving "Set as front page" alone under a row of three. A lone action keeps its own width.
* "Create the homepage (button on the left)" / "Create the page (button on the left)" said where the button was only for a wide, left-to-right screen; they now name the Status card. "Choose a section on the left" now says "in the list".

= 2.8.10 =
Guided setup: an optional four-step wizard for a new site — default currency, tour and activity addresses, default designs, review. Updating an existing site changes nothing: no address, price, currency, design or setting is written, the wizard does not open, and no invitation is shown. Nothing is migrated.

* Where: Tripvance → Settings → Setup wizard (a tab of the Settings group; a submenu entry under Tours with the classic menu). No new top-level menu, no redirect, nothing blocked before or without it. Capability: manage_options, the same as the settings screen; every form carries a nonce.
* Invitation: "Welcome to Tripvance" with Start setup and Later, on the Plugins screen and Tripvance screens, only when every piece of evidence says the site is new: the install profile recorded at first activation is "new", there is no tour, activity, transfer or booking in any status, the settings screen has never been saved, the homepage has no configuration and the setup guide's welcome was never answered. The wizard's own marker is not evidence; its absence on an updated site means nothing. It replaces the setup guide's welcome notice, so a new site is greeted once. Reactivation writes nothing.
* Steps: progress indicator, Back, Continue and "Exit without saving". Choices live in a per-user draft (transient, 12 hours) and are validated on the server at each Continue and again, all together, at Apply settings. Opening the wizard from a link or the menu starts from the values the site uses now.
* Storage: no new settings system. default_currency, layout and activity_presentation are the settings screen's own keys; tour_base and activity_base are two new keys on the same wptm_settings option, read by rewrite_slugs() through chosen_bases(), and carried over by sanitize_settings() so a later settings save keeps them. Absent on every existing site, so rewrite_slugs() returns what it always did.
* Apply: one merge into wptm_settings through the new merge_settings() (a single update_option, so all or nothing), re-read and compared before "Tripvance setup is complete" is shown; on failure the stored array is put back and the review says so with the choices kept. A lock option and a one-time token make repeated presses safe: a second press while the first runs waits, one after it lands on the result.
* Currency: EUR, USD, GBP, MAD (default_currencies(), now also what the settings screen lists), with a price example from Pricing\format_decimal(). No price is converted. Read-only while a tour or activity without wptm_price_currency, a transfer without its own currency, or a booking without _wptm_price_currency exists, since each is read in the default.
* Addresses: tour (single, list of all tours, and the destination/type/category/departure/duration bases under it) and activity (single, and the list when that archive is on). Editable only with pretty permalinks, addresses per language off, no code changing the segments through wptm_rewrite_slugs, a "new" install profile, no content in any status, no terms in the tour or activity taxonomies and no menu link to them; otherwise shown read-only with the reason. Refused with a message beside the field: wrong format (2–40 lowercase letters, digits, single hyphens — nothing is rewritten silently), the same word for both, WordPress reserved terms and query vars, rewrite bases, Tripvance's other routes and legacy bases, a language code, an existing page or post, another post type's or taxonomy's base, any existing rewrite rule that is not one of the moving tour and activity rules. Post type and taxonomy names, labels, existing slugs, WordPress permalink settings, Polylang settings and SEO plugin data are not touched. After a change, wptm_rules_stale is set and the rules are rebuilt once on the next admin request, after init has registered the new bases; the result screen checks the stored rules and offers Rebuild permalinks if they are missing. No redirect system is added.
* Design: tour layouts from Layouts\picker_items() (legacy ones only when in use) and the three activity layouts, as radio cards with the existing CSS miniatures. The review counts tours without wptm_layout and activities without wptm_presentation that will follow the new default.
* Assets: assets/css/setup-wizard.css and assets/js/setup-wizard.js (ES5, no dependencies), loaded on the wizard screen only. Logical properties, RTL, keyboard (native radios and buttons), phones.
* Fix: the setup guide's "Use this design" saved the settings array through the settings sanitiser, which on a site whose settings were never saved wrote every other switch as off — activities among them. It now merges.

Setup wizard and menu icon corrections (same version)
* Eligibility: SEO\new_install() is a historical flag and no longer decides anything in the wizard (its meaning is unchanged for the rest of the plugin). A new option, wptm_setup_eligible, is written once by the activation hook, before anything else in it: 'yes' when the database holds no option, post or term of Tripvance or of the build it was renamed from (wptm_*, mtt_*), otherwise 'no'; an existing record is never rewritten. Updates write nothing, so an updated site — including one whose content was deleted — never qualifies. Completing the wizard sets it to 'used', so reopening it shows the addresses read-only. tour_base / activity_base already stored are read as before. The content, term, menu and conflict checks stay, and every step is validated again at Apply settings.
* Step indicator: steps already reached are submit buttons of the wizard form (form attribute), so the open step's fields reach the draft first, as with Back and Continue; going back keeps what was typed without checking it, going forward checks the open step, and nothing beyond the furthest step reached is offered. Works without JavaScript; a hidden first submit makes Enter in a field mean Continue (it used to mean Back).
* Drafts across tabs: every form carries the draft id and its revision; handle() refuses a form from another draft or an earlier revision without touching the draft and shows the latest state with an explanation. Expiry and the repeated Apply press keep their handling. Opening the wizard from the menu keeps a draft with unapplied choices (with a notice and "Start again from the current settings") instead of discarding it silently.
* Concurrent changes: the draft records the wizard values the site used when it began. At Apply, a value changed elsewhere since then that the draft would overwrite stops the apply; the review lists the value now and the choice made, with "Keep the current values". Settings the wizard does not write are never a conflict and are kept by the merge.
* Currency: PricingEngine::code() is the engine's test (trimmed, upper-cased, three letters), now used by PricingEngine::currency() itself and by the wizard. Tours, activities and transfers are judged by their distinct stored values (one query each) and counted with a meta query; bookings are those Payments\expected() accounts in the default (no snapshot, accepted quote or revision), read a page of ids at a time. No price or currency is changed.
* Failure reporting: "nothing was changed" is said only when the stored settings are unchanged or the undo was read back.
* Menu icon: it was an SVG data URI with a black fill that WordPress's svg-painter script recolours after the page has loaded. menu_icon() now returns 'none' and assets/css/admin-menu.css (every admin screen, in the head) draws the same "T" as a CSS mask painted with currentColor, so normal, hover, focus and current follow the admin colour scheme from the first paint, expanded or folded.

Draft concurrency, booking currency check and the Operations board (same version)
* Draft locking: handle() takes a per-user lock (SetupWizard\with_draft_lock(): one INSERT IGNORE row, 15 s time to live, waited for up to 5 s, released in finally) around reading the draft, checking its id and revision, changing it and saving it; Back, Continue, the step indicator, Keep the current values and Apply settings all go through it. A request that cannot get the lock changes nothing and says so. The Apply lock uses the same atomic claim instead of add_option(), which is an INSERT … ON DUPLICATE KEY UPDATE and let two callers both "win". The draft is read past this request's option cache. screen() no longer writes the draft when a step is only viewed; it writes only to start a draft, to restart one, or to replace one with nothing unsaved whose starting values no longer match the site — under the same lock.
* Booking currency: SetupWizard\booking_currency_source() follows Payments\expected()'s order — a revision newer than the accepted quote, the accepted quote, a valid snapshot, the default — without calling it (expected() reads a damaged snapshot's currency directly and would raise a notice). A snapshot is valid only as an array with a total key and a currency PricingEngine::code() accepts; missing, empty, a:0:{}, damaged serialisation, no currency, invalid currency all count as the default. A revision or quote with a valid currency wins over an old snapshot; one whose currency cannot be read locks the step as "cannot confirm". Every booking is scanned two hundred ids at a time with meta primed, with one query per page for which have an accepted quote; an exception locks the step.
* Operations board (includes/operations/board.php, assets/css/operations.css on that screen only): title, description and manifest buttons in one header; "On this page" after it (flex order), wrapping on a wide screen and scrolling inside itself below 783px; headings land below the toolbar and the sticky bar. admin.js marks the current section with the bar's real height when it is stuck and wraps (unchanged on screens where it is one row), marks the section jumped to at the foot of the page, and sets aria-current. Trips show trip and tour, date, customer and party, pick-up, crew (guide, driver, vehicle) and payment state, with amber badges for "Pick-up not set", "Nobody assigned" and "Awaiting verification", as labelled cards on phones (the table no longer carries .wp-list-table, whose phone layout hides cells). Empty states are a calm line per section ("No trips today.", "No trips tomorrow.", …). Queries, statuses and links are unchanged.


= 2.8.9 =
Site pages and the page builder, reworked around what an owner does with them: find a page, make one in the right language, edit it without losing anything, and publish it on purpose. No page is deleted, recreated, published, added to a menu or made the front page by this update; content, IDs, addresses, templates, translations and SEO fields of existing pages are not touched. No option, table or meta key is migrated.

Tour editor: title and Inclusions (same version)
No meta key, field name, stored format, save rule or front-end output changes; nothing is migrated.
* The title is above every section instead of inside Basics: WordPress's own title block (the same #title input, permalink box and nonce) is moved, as markup, before the page is sent (output buffer between edit_form_top and edit_form_after_title in Experience\Editor), under a "Tour title" / "Activity title" label. Nothing is copied or moved by script, so switching sections never hides, re-creates or resets it; it scrolls with the page (no sticky). If WordPress's markup is not where expected, it is printed unchanged.
* Inclusions in three groups: Highlights with its design under it; "What’s included" (✓) and "What’s not included" (−) in two equal columns with the same help line; What to bring on its own row. Groups come from a `group` key the native panel renderer reads (Experience\Schema\inclusions_layout()); only widths, rows, the two labels and help change.
* What to bring: eight everyday suggestions, the rest behind "Show all suggestions" / "Show fewer suggestions" (aria-expanded; without JavaScript all are shown). A suggestion already in the list — its key after a "|" or its exact name, ignoring spacing and case — is marked added (aria-disabled, a tick, screen-reader text) and pressing it again changes nothing; marks follow typing, pasting and deleting. A new line is only ever appended; focus stays on the button pressed.
* Fix: switching sections left keyboard focus inside the hidden description editor (WordPress resizes it on the resize the section switch announces, and TinyMCE focuses an editor for every command); focus taken that way is handed back.
* Fix: a section without side boxes kept WordPress's 300px side margin (the rule was weaker than WordPress's own), so the boxes were narrower than the title above them.

Bookings list page (same version)
No data, meta key, hook, capability, price, payment or booking status changes; nothing is changed by searching, filtering or viewing.
* Header: Bookings and Add booking (the one primary button) on one line, out of the status views; one short waiting notice with "Show new requests"; status views in their own wrapping row with clear counts and the current one marked. WordPress's post-status views (Mine, Published, Pending, Draft) and post-status labels ("Pending" beside a completed booking: a leftover of the save bug fixed in this version) are no longer shown on this list; the posts themselves are untouched and every booking stays under All.
* One filter panel: search (reference, name, e-mail or phone), the daily shortcuts, then Status, Trip, Travel dates (one from–to control) and Payment with visible labels; Booking type, Travel month, Source and Request received (WordPress's own "All dates", which is the date the request arrived, now named) under Advanced filters, open whenever one of them is in use. The quick filter is carried by the form, so applying a filter or a search keeps it; sorting and quick filters keep the search and filters; Clear filters keeps the sort. Travel-date filters narrow each other, and a combination that leaves no day is said so. Bulk actions and the count sit against the table.
* Table: shorter rows, the e-mail and country on one line, wider date and money columns, badges of one size (status filled with a dot, payment outlined, source neutral), words wrapping inside their cell on tablets, filters shown on phones (WordPress hides them). Money: the total and its source ("Estimate", "Agreed", "Revised") or "No price yet", then the ledger's state with "Paid …", "Due …" or "Overpaid by …" from Payments\summary().
* Filter panel tightened: on a wide screen the search and the quick filters share one row, the four main filters and Filter the next, then one line with Advanced filters, what is applied and Clear filters. Advanced filters is a button (type="button", aria-expanded, aria-controls; it never submits) with an arrow that turns, showing "(n)" only for advanced filters actually applied and valid; it opens on load when one is applied and closes without clearing. Without JavaScript the button is hidden and the panel stays open. Clear filters appears with any search, filter, quick filter, status view or the Bin, and links to the plain list (sort and view mode kept, first page, All).
* Fix: Screen Options on the bookings list answered only clicks on its top edge — the list's positioned page wrapper was painted over WordPress's floated tab — so it seemed to open once and lag the next time. The tab is lifted above the wrapper; every visible control on the booking screens is now checked to receive its own click.
* Bookings filter panel: the Advanced filters arrow is now drawn (SVG) and centred beside its label instead of a font icon that could sit lower on some systems; every button, field and chip in the panel shares one height, radius and focus ring (36px on desktop, 44px touch size from 782px down), the panel is white, field labels line up, and Clear filters carries a small × and turns red on hover.
* Laid out with CSS grid placement and display: contents over WordPress's own markup — nothing moved by script — and scoped to body.edit-php.post-type-wptm_booking.

Admin bar labels (same version)
* The admin bar said "View Posts" on the Tours and Activities lists and "View Post" on a tour, because those post types had no view labels. Tours, Activities and Transfers now carry their own (View Tours / View Tour, View Activities / View Activity, View Transfers / View Transfer) as registration labels: WordPress's admin bar reads them and links to get_post_type_archive_link() or the item's permalink, as before. No slug, rewrite, archive setting or ID changes; Posts, Pages and other post types are untouched; with an archive switched off WordPress still shows no archive link.

Bookings dashboard, list and booking screen (same version)
No table, option, meta key, price, payment, seat, status rule, message or document behaviour changes. Viewing, filtering and Quick view read only; nothing is sent or changed by them.
* Dashboard in working order: search (reference, name, e-mail or phone), Add booking, a More actions menu for adding tours, activities, guides, drivers, vehicles and departures; four figures — Requests to answer (status New), Follow-ups due (date today or earlier, open bookings), Trips today and Trips tomorrow (not cancelled or declined) — each counted by the same query the bookings list runs for its link (Booking\list_query_vars()), so a figure and its rows cannot disagree; Needs attention; Upcoming trips (the list's Next 7 days); the calendar; and the week's figures, request sources and most requested trips in a folded section. The old Today box, which repeated the figures, is gone; the pipeline counts now come from status_counts(), the same as the list's status links.
* Needs attention: rules keyed and ranked (attention_reasons(), no translated text compared): unconfirmed_soon (new or contacted, travelling within 7 days; today or tomorrow ranks highest), crew_missing (a confirmed transfer within 7 days without a driver or fleet vehicle, asked only when the agency keeps them — tours and activities are never asked for a guide or a driver), payment_claim, followup_due, no_reply (new for 2 days or more), balance_open (a confirmed trip within 7 days with a balance on an agreed total — open, never "overdue": there is no due date). Sorted by rank, then travel date, then follow-up date; one action per row opening the right panel; further reasons in a disclosure; View all opens the list filtered to exactly these bookings. The previous "Trip team not assigned" for every confirmed trip is removed.
* Quick view on the list (row action) and the dashboard: admin-ajax GET with a nonce and edit_post on that booking, rendered on the server from get_booking(), context(), Payments\summary(), the Guest Pass meeting point, the attention rules and next_steps(). The panel is emptied before each request and late answers are dropped; failure shows Try again; Escape, a close button and the backdrop close it; focus is kept inside and returned to the button; the page behind does not scroll. Without JavaScript the button is a link to the booking.
* Bookings list: quick filters (Today, Tomorrow, Next 7 days, Follow-up due, Needs attention) that combine with every other filter, sort and page; booking type now also Tours, Activities and Custom trips (Transfers and Tours and activities kept); payment state from the ledger's index (_wptm_payment_state, written by Payments\settle(), which now also runs when a customer reports a payment or accepts a quote); the applied filters, each removable, and Reset filters; search also matches WhatsApp numbers and phone numbers typed with other spacing; a payment tag drawn unlike the status pill; on phones each booking is a short card with name, trip, date, status, Open booking and Quick view. "All bookings" on a booking returns to the list as it was filtered (kept per tab; only the list's own arguments, checked again by the server).
* Booking screen: "Save changes" everywhere it saves the page, with descriptions that say exactly what it saves. After saving, one notice reports each part — status, trip details, follow-up, internal notes — and names a part that was refused, with a link to its panel, instead of WordPress's generic line and separate notices. Changed fields are marked until saved (in the existing change listener; the duplicate one is removed). Action buttons say what they do: Record payment, Issue PDF / Issue revised PDF, Preview draft PDF, Save assignments, Mark as sent, Retry sending, Email the guest link, Create quote draft.
* Styles scoped to the plugin's classes; the dashboard's share bars use classes instead of style attributes; logical properties throughout, no letter-spacing on right-to-left headings.

Site pages: Move to Trash (same version)
* Each page in Tripvance → Pages has a ⋮ menu with "Move to Trash", so a page no longer has to be opened to be removed. A confirmation names the page and says it can be restored; Cancel or Escape changes nothing. On success the row leaves the list, the count follows (and an emptied page of the list says so), and a notice offers Undo, which puts the page back with the status it had (published, draft…), not WordPress's default of Draft.
* WordPress's own wp_trash_post() and wp_untrash_post() only: no permanent deletion, no SQL; content, builder settings, SEO meta, images and Polylang translation links stay. Translations are never trashed with a page.
* Checked on the server at every request: the nonce, delete_post for this page, that it is a page, listed here, not already in the Trash. The front page and the posts page of Settings → Reading (and their Polylang translations, when Polylang is active) are refused with a note to choose another one first; with the Trash turned off (EMPTY_TRASH_DAYS = 0) the action is refused rather than becoming a permanent delete.

Builder start (same version)
* Fix: opening the Homepage or a site page in the builder first showed the plain form — the Order boxes, the tick boxes and "Adding and removing sections needs JavaScript" — then swapped it for the builder. Cause, measured: the builder's scripts are footer scripts behind the media library's, so the browser painted the parsed form first (about 0.1 s on a fast line, over 2 s on a slow one); it never waited on images, the preview frame or the page's load event. Now a small inline script marks the builder as starting the moment its layout opens, and the stylesheet keeps the form laid out but out of sight behind an outline of the builder (aria-busy, "Loading the builder…"). builder-canvas.js ends it the moment it has drawn itself. No fixed delay.
* If a builder script cannot be loaded or throws while starting, or the page finishes loading without the builder, the plain form is shown under a notice with Try again and Use the simple form. Without JavaScript none of this runs and the plain form is there as before. Fields, saving and page data are not touched.

Builder refinement (same version)
Four small tools in both builders (Homepage and Site Pages); nothing changes on the site until a page is saved, and a page saved before looks the same after.
* A name for each section, for the owner only ("Desert trips", "Marrakech activities"), typed in the section's header. Empty means the section is called by its type, and the type stays visible under a name. Stored where each screen already keeps a section's state: a homepage row's `label` (custom HTML keeps its existing Name field), a page block's `metadata.name` — WordPress's own block name, which the block editor's Rename writes and shows, and which nothing prints. Never on the page, in SEO or in schema; capped at 80 characters, cleaned on save. A form that does not send the field (an older open tab) keeps the stored name. Carried in exports, saved templates and imports; a file made before keeps the homepage's names as they are.
* A one-line summary on every section card, read from its own fields only — no request, no query: the type (when the section has a name), its design, "shows up to N" (what is asked for) or "N chosen" / "N items" (what is actually filled in, never a guess at results), and whether its photograph and focal point are set. Hidden sections keep their badge.
* Focal point picked on the photograph: click it, move it with the arrow keys (Shift for steps of 10), or type Across and Down in percent; "Back to the default" changes nothing else. Stored in the existing Focal point field as "x% y%" (whole numbers, 0–100, physical so never mirrored in RTL) and printed as object-position; the file is never changed and the crop and shape are not touched. Named choices (top, bottom, start, end) and every saved value are kept. Where: Luxury hero, Our purpose and Full-bleed story on the homepage; the Luxury page sections that already had a focal point; and, new, the standard homepage hero's cover photograph (`hero_focus`) and the Site Pages hero section (`focus`), both empty by default. Destination and tour images are not affected.
* Copy style / Paste style: copies only the choices on a section's Design and Spacing tabs (and on the homepage its spacing and background, plus its own layout choices such as columns and heading position). Never words, photographs, links, chosen tours or terms, code, the name, the place, the hidden state or the focal point. Pasted on a section of another kind, only the shared settings whose value fits are applied, and its design or layout family is never carried over; a homepage setting shared by two sections is never changed. Nothing fits: a message and no change. One undo step; kept in the editor's memory only, never the system clipboard.
* Fix: the Undo, Redo and Preview buttons of the light top bar were white on white once enabled.
Assets keep version 2.8.9; their cache stamps follow the files, so the new scripts load at once.

Builder stability and Our purpose (same version)
Causes proven with a long browser session using human clicks (the mouse moves a few pixels while pressed), each fixed at its source:
* Clicks lost to dragging. Every section row and library card was draggable as a whole, so a click with about 5 px of movement became a drag and the click never arrived — select, move, hide, duplicate, remove and the library's Add all "did nothing" now and then. Only the handle (rows) and the picture-and-words area (library cards) drag now; buttons only click. Before the fix the soak test failed 11 of 17 steps on the homepage and 8 of 10 on a site page; after it, none.
* Buttons replaced under the pointer. The list was redrawn 60–250 ms after every edit and redraw removed and re-created every "Add a section here" gap — and, on site pages, every row. A press that started just after typing lost its click, and a keyboard user lost focus after Enter on a row. The list now only moves the nodes that are out of place; gaps are created once and kept.
* Two settings panels at once. A removed section's panel kept its "current" mark; brought back by Undo it showed beside the selected one. Which panel shows is now derived from the selection every time the inspector is drawn.
Hardening (not observed failing, closed anyway): dialog answers that arrive after the dialog was closed or moved on are dropped; the import field is emptied after each choice so choosing the same file again works; error lines no longer pile up; a hidden section's duplicate is one undo step; the save request gives the button back after 90 seconds without an answer, with a message that says the save may or may not have been stored (the conflict token keeps a retry safe); the library drawer (narrower screens) closes on a press outside and on Escape; the notice has a close button; fields reached with Tab stop below the sticky toolbar; an empty repeating table no longer shows its column headings on phones, and a filled one scrolls inside its box with readable headings.
max_input_vars: verified, not changed — a plain save of a long homepage to a server with max_input_vars=120 is refused whole (nothing stored) and the JSON save to the same server stores everything.
Our purpose (Luxury homepage and page block)
* Commitment columns get equal room: the space between columns is a gap with the rule drawn in its middle. Before, padding on all but the first column gave the first one about 23 px more for its words.
* Titles start on one line and sentences on another in every row (subgrid, unchanged), checked with titles of one to four lines, two, three and five commitments, at 1024 and 1440 px and in RTL; stacked on tablets and phones with no spare space.
* "Larger" photograph: about half the width (506 px against 364 px at 1440) at the same 4:5 shape and crop; the commitments become rows beside it — title, then its sentence, aligned in two columns — instead of three columns squeezed to two or three words a line. "Current size" stays the default; no saved page changes size.
* The photograph's `sizes` now match the width it is shown at in each arrangement, so a sharp file is chosen without an oversized one.
* Not done, waiting for the client: the Royal Mansour photograph (not supplied — choose it in the Photograph field) and which icon to change (the section prints no icon on the site).

Section builder (homepage and Site Pages)
The homepage and the page builder are now composed from a section library instead of a fixed list of sections to show or hide. Nothing changes on the front end until a page is saved from the new builder; an unsaved homepage renders exactly as before.
* Library on the left (Introduction, Tours & activities, Destinations, Content, Trust, Contact), each card with a short description and a small drawing of the section, a search, and Add or drag into place. Settings of sections that are not on the page are not shown.
* The canvas lists only the sections on the page, in their real order: drag to reorder (with an insertion line), "Add a section here" between sections, and for the keyboard the move buttons and Alt+Arrow keys. Selecting a section opens its settings in Content, Design and Spacing tabs, beside a preview that follows the same order.
* Section actions: move up/down, duplicate (custom HTML on the homepage; on site pages every section the page may hold more than once), hide for now, remove. A removal can be undone from the notice; Undo/Redo cover add, remove, reorder, hide and settings, 30 steps, for the current visit to the screen.
* Homepage storage: a `layout` list in wptm_home, one entry per section {id, type, on, space, ground} plus `layout_version`. The old `sections` table is still written, derived from the layout, so code that reads it keeps working; `layout_sig` (a fingerprint of that table) lets the builder notice when something else changed the table — a preset, the Luxury switch — and take those changes in instead of overwriting them. Until the first save from the builder the layout is derived from the old table on read, without writing anything.
* Custom HTML is no longer limited to five slots. Each block has its own id, optional admin label and code under wptm_home[blocks]. The five old slots become blocks that keep their keys (custom_html_N), content, order, visibility and translations; their code is stored again only when it is edited. Code is filtered as before for users without unfiltered_html, and unchanged existing code is never re-filtered.
* Before the first save in the new format the previous wptm_home is copied to wptm_home_legacy_backup (not autoloaded, never deleted, also kept on uninstall). If the copy cannot be written the save is refused and nothing changes.
* Per-section spacing (compact, normal, spacious) and background (keep, white, paper) on the homepage; spacing for every Tripvance block on site pages. Hiding a block on a site page sets `wptmHidden`; the block stays in the content and is skipped on the front end.
* Design: three call to action layouts (band — the existing one —, split with an image, card), alongside the existing hero, featured, destination, team, testimonial, why and gallery styles. A "Page design" panel sets accent and secondary colour, background, content width, spacing, button and image corners for the homepage (wptm_home[design]) or one site page (`_wptm_page_design`); empty values inherit the global settings and the theme's fonts; a section's own choice wins over the page's.
* Starter layouts — private tours, luxury, desert & adventure, local guide & day activities, transfers — set the order and styles of sections. Applying one shows what it will do (the new order, sections taken off, kept custom HTML, styles changed) and can be cancelled; it changes the form only, and nothing is stored until Save. No reviews, numbers, bookings or pages are created.
* Save as template, export and import as JSON (format "tripvance-builder", version 1). Exports leave out bookings, customers, keys and site settings; image, tour and term references are listed so the import can say which do not exist on this site. Imports are checked for format, version, size (512 KB), types and allowed values, never fetch a URL, fill the form only, and keep the page's own texts unless asked to replace them.

Fixes
* Starter pages were stored with damaged block settings. wp_insert_post() unslashes what it is given; the starter's markup — JSON attributes with " escapes, a term description with quotes, a typed backslash — was passed unslashed, so `"` came out as `u0022` and backslashes vanished. Creation now slashes title, content and meta once. The Luxury demo pages had the same fault and get the same fix. The page builder's SEO title, description and keyword are slashed for update_post_meta() too.
* Builder saves with JavaScript failed every time. builder-admin.js sent the JSON save to `form.action`, and the form holds an input named "action" (admin-post's), which shadows that property: the request went to "[object HTMLInputElement]" and the screen said the server could not be reached. It now reads the attribute. This affected the page builder, the homepage builder and the car hire builder.
* Templates are kept. The layout radios used to write `default` whenever "Full width" was not ticked, so saving a page that had a theme template or the landing template moved it to the default one. The template now changes only on an explicit choice; a custom one is shown by name with "Keep" selected. The save and status calls also pass an empty page_template, so wp_update_post() no longer re-validates the stored template after writing — which answered "Invalid page template" (reported as a failed save that had in fact happened) for any template the active theme does not list, and without the error flag reset it to the default.
* Status changes are checked. Only draft/pending → publish, scheduled → publish now (date set to now, or WordPress would schedule it again), private → publish and publish → draft are accepted, each with the post's own capability (`publish_post`), a check that the status shown is still the status stored, and the conflict token; the final status is read back, and a draft with a future date that WordPress scheduled is reported as scheduled, not published.

Creating a page
* The language comes first, and the starter's title, headings and notes are generated inside `Lang\in_language()` for that language, then the language is assigned. A typed title is never translated, and pages are never linked as translations on their own.
* Destinations and tour types are searched as you type (`wp_ajax_wptm_pages_terms`, twenty at a time, the whole taxonomy reachable, Polylang's `lang` argument), replacing the first-200 select. Without JavaScript the field takes an exact name or slug, resolved in the page's language. A term in another language is refused on the server (`term_lang`).
* Changing the language clears a choice made in the other one, and says so.
* A page already made from the same starter, for the same term and language, is pointed out with a link, before creating (`wp_ajax_wptm_pages_existing`) and on the builder's "created" screen. Only plugin metadata counts: `_wptm_starter`, and for new pages `_wptm_starter_term` and `_wptm_starter_taxonomy`. Titles are never compared, and older pages get no inferred metadata.
* A refused attempt keeps the typed values for the same user for ten minutes (transient `wptm_pages_retry_{user}`) and reopens the form filled in. The button locks after the first press, and a per-form token makes a repeated POST open the page already made instead of making a second.
* Starter cards show the page's sections in order, read from the starter's own content. The title is suggested from the destination or type and never overwrites a typed title.
* The starter FAQ answers and the call to action's sentence are now bracketed notes to replace; nothing promises flexible itineraries or a payment policy. Pages already made keep their words.

The list
* The list is the screen, at full width, with "Create a page" opening the form on request. Pages come from the sources that own them: the homepage and the car hire page with every Polylang translation, the index pages, the landing pages and the starter pages, each ID once.
* Search by title, kind, language and status are applied by the database before the page of results is cut; 20 per page with the count; last modified; the language and links to the real Polylang translations (missing ones marked); Edit opens the editor that owns the page (homepage and car hire builders, the WordPress editor for index and landing pages, the page builder otherwise), plus View/Preview and WordPress settings; actions the user may not take are not shown.
* "Other pages with Tripvance sections" finds ordinary pages holding Tripvance blocks (filtered in PHP, no NOT IN); "Add to Site pages" writes only the marker.
* Empty states, and set-up buttons for the homepage, car hire and index pages when they do not exist yet. On a phone each page is a card.
* The screen's script and styles moved out of admin.js/admin.css into site-pages.js/site-pages.css, loaded on this screen only.

The page builder
* Content and Page settings & SEO are two tabs over the same form (every field is still saved); a panel opened from the navigation, the preview, an invalid field or a returning save brings its tab forward. Arrow keys move between tabs, mirrored in right-to-left.
* Closed sections show the first words in them. Each block with an Add template gets Duplicate in its header (a new identity; uid, src and sig of the original are never copied). The move arrows keep the keyboard on the moved section and announce its position. Panels are numbered within their tab.
* The status card shows the real status — Draft, Pending review, Private, Scheduled for {date}, Published — and one action that fits it. Save buttons say "Save draft", "Update the published page", "Update the private page" or "Update the scheduled page", with a sentence on what that means for visitors and that every save stores the whole page.
* Publishing with unsaved changes saves first and publishes on the server only after the save was stored whole; a conflict, a truncated request, a failed write or an invalid field publishes nothing.
* "Before publishing" lists, from the saved page: bracketed notes left in Tripvance sections, headings and text; addresses a visitor cannot follow; buttons with text and no address; WhatsApp or contact buttons with no number, telephone or e-mail in Settings; more than one H1 among the plugin's sections and headings, or one H1 on the theme layout. It says what it does not read (theme header, footer, menus, other plugins' blocks) and never blocks publishing.
* Full-width pages: an optional visible page title (`_wptm_show_title`) when no section is the main heading; off by default, so existing pages look as before. Ordinary blocks between sections share the sections' width, gutters and vertical rhythm.
* Rank Math keeps sole ownership of its title, description and focus keyword; nothing is written to them or duplicated.
* Phones: the builder's bars are not sticky and never cover content; the section strip and the section search no longer widen the screen; the preview mode is chosen by a media query, not innerWidth.

Builder polish (same 2.8.9 build number)
* Fix: the homepage builder never used the protected JSON save. Its search-field boxes are named `search_fields[]`, which the save's form-to-JSON fold refused, so every homepage save fell back to a plain POST: a failed request left the screen, and no recovery snapshot was ever kept. The fold now appends `name[]` values as PHP does; the homepage now saves, refuses and recovers like the page builder.
* Fix: Enter in the new "Find a section or a setting" box searches; it never submits (and saves) the form.
* Fix: moving a section with the arrows in the layout table kept no keyboard focus; focus stays on the arrow and the new position is announced. A move now updates the bar's "Unsaved changes" at once (`wptm:dirty`).
* Navigation: grouped into General settings, Page sections (in the order the page prints them, re-sorted as sections move), Appearance (the presets) and Translations (the language tabs); hidden and unsaved sections say so in words. On a phone it folds into one button.
* Find a section or a setting: titles, field labels and sub-headings, including inside closed panels and Advanced folds; fields of a layout not currently chosen are not offered. Going there opens the panel and the fold and focuses the field below the sticky bars (`scroll-margin`). Nothing typed is changed.
* Closed panels show the first words in them; adding a hidden section opens it with a visible note; section offers carry a local dashicon.
* Preview: says whether it shows your unsaved changes (only you) or the saved version; desktop, tablet and phone are laid out at 1280, 768 and 390 px and scaled to fit, so the page meets the same breakpoints as on the device.
* Translation tabs: each box is marked "Translated" or "Not translated — the page shows the source text".
* Phones: panel Save rows are not sticky, the layout table drops its description column, the navigator's rows no longer break words letter by letter.
* Our purpose: the commitments use a CSS subgrid, so titles share a line and sentences share a line in every row, whatever the title length, font, language or width; browsers without subgrid keep the previous layout. New "Photograph size": Current size (default for every saved homepage and page section) or Larger, same crop and shape (`lux_purpose_media`, section arg `media`).
* Moroccan Editorial Carousel: the corner geometry motif and the drawn flourish are removed, with their CSS. The palm and kasbah drawing stays. The "Which" choice is gone; a stored value is carried unchanged in a hidden field, and a band set to one of the removed drawings now draws nothing (said on the screen) rather than being switched to the palm.

Code: new assets/css/site-pages.css and assets/js/site-pages.js; functions `SitePages\outline()`, `existing_pages()`, `managed()`, `query()`, `PageBuilder\readiness()`, `change_status()`, `notices()`, `save_label()`; constant `SitePage\SHOW_TITLE`. Minified copies rebuilt.

= 2.8.8 =
Booking workspace: the edit-booking screen says where a booking stands and what to do next, groups its panels into five sections, and protects unsaved changes. No table, option, meta key, price, status rule, message or document behaviour is changed; nothing is migrated or repriced.

* Summary: reference and saved status, customer with the email, phone and WhatsApp the booking holds (the preferred one marked), the tour, activity, transfer or trip, date and start time, travellers. The price is named after its source — "Agreed total (accepted quote)", "Agreed total" for a revision, "Price at request" for the snapshot, "On request" — and payment state, received and balance appear only when the payment ledger exists. The latest quote and its state are shown; accepting a quote is not shown as paid or confirmed. Everything is read from saved data, so the summary changes only after saving. An old booking with no party shows "Not set", not "0 adults".
* Next action: one step worked out from the saved status and data by a short list of rules (`Booking\next_steps()`, a plain function of `Booking\workspace_facts()`): review a new request; follow up a sent, expired, declined or draft quote; review an accepted quote before confirming; set the travel date; mark a past trip completed; add the meeting time or place the guest will see (read through the Guest Pass, including the tour's meeting point and a transfer's pickup); assign a driver or fleet vehicle — on a transfer only, and only when the agency keeps drivers or vehicles. Up to three short notes follow: a requested date that has passed, a guide, driver or vehicle assigned elsewhere at the same time, a balance due, an email not delivered. Cancelled, declined and completed bookings are never asked to prepare anything; with no rule matching, a neutral sentence. Its button only opens the panel. Filters: `wptm_booking_workspace_facts`, `wptm_booking_workspace_sections`.
* Sections: Customer & request, Price & quote, Trip preparation, Messages, documents & Guest Pass, History & internal notes, and All panels. One section is shown at a time, starting with the one the next action points to; a section that needs attention is marked with "!" and a screen-reader label. Status, Publish and any panel from another plugin stay visible in every section. The section is kept across Save booking; an action that returns to a panel, or prints its result inside one, opens that panel's section. A required field in a hidden section or closed panel is opened and focused instead of blocking the save silently. Without JavaScript every panel is on the page and the section links jump to them.
* Unsaved changes: "Unsaved changes" beside Save booking once a field is edited; leaving the page, following a link or running an action asks first. Save booking warns when a panel with its own button (Payments, Quote, crew…) holds typed values it will not save. Buttons that send, issue or record carry a bar on their leading edge and are locked with "Working…" after the first click, so a double click sends one request; previews in a new tab and document downloads are not locked. Server-side duplicate checks are unchanged.
* History: one timeline, newest first, in the main column — the booking's log (status, trip edits, quotes, documents, payments, crew, Guest Pass), the emails in the outbox with their delivery state, and WhatsApp messages marked as sent, each labelled with its source. Times in the log are converted from site time correctly. Nothing is reconstructed for older bookings; internal notes stay in their own panel.
* Phones: the section links scroll in one row; the header's document buttons are two to a row again (a 2.8.6 rule was overridden).
* Code: the booking panel navigation moved from admin.js to booking-admin.js with the workspace; new file `includes/booking/workspace.php`. Minified copies rebuilt.

= 2.8.7 =
Settings screen reorganised, and stability fixes found by a full pass over the admin screens, the front end in every tour layout, both experiences and right-to-left. No setting, option, table, price or booking behaviour is changed.

* Settings screen reorganised: one section navigation instead of two — a column beside the settings on a wide screen, grouped under Core, Booking, Content & pages, Public & localization, Setup & integration and Advanced, with "Find a setting" at its top; on a tablet or phone the same list opens from the section button in the toolbar. The sections follow that order on the page, and the content uses the width the navigation used to leave empty. One Save button, in a slim toolbar that stays in reach without covering the section headings it scrolls to. Nothing about the settings themselves changed: the same fields, names, values, defaults and save.
* Fix: Settings printed a PHP warning ("Undefined array key 0") in Section pages when a hierarchical section had terms without tours. The example link now uses the first term returned, whatever its key.
* Fix: System status was laid out as one row, because its wrapper shared the class of the status badge; the diagnostic report was squeezed beside the cards and the page scrolled sideways. The report now sits below the cards at full width.
* Phones: the Site pages list and the Section pages table in Settings scroll inside their own region instead of widening the screen, and the Site pages actions wrap.
* Code: the WPML hooks used for translation are marked as WPML's own for Plugin Check, a file-scope loop variable carries the plugin prefix, and the rate-table cache key is built with wp_json_encode() instead of serialize(). The booking screen's stylesheet and script now ship with minified copies like every other asset.
* readme: the 2.8.3 upgrade notice is shortened to fit the 300-character limit.

= 2.8.6 =
Visual polish of the booking screens, the Guest Pass and the PDF documents. No change to how bookings, prices, seats, notifications, documents or the Guest Pass work; no data, option or table is changed.

* Add booking: the agreed total and its currency sit together in one highlighted panel, the amount larger and the currency a narrow code; status and "Send the usual emails" are each a tile, the ticked one marked; more space between rows and their descriptions; the button fills the width on a phone. The error list is indented on the correct side in right-to-left admin.
* Booking screen on a phone: the page no longer scrolls sideways. The Guest Pass map-pin pair and the Trip content image and itinerary fieldsets now shrink to the screen.
* Money fields: "Total to offer" and "Deposit asked for" (Quote) and "Amount" (Payments) are wide enough to show the full figure, with the currency beside them; the balance in Payments is larger; the invoice card shows "Not issued yet" / "Issued as …" as a small label.
* Right to left: no letter-spacing on Arabic small-capital headings in the booking screens, or on the Guest Pass reference label, status pill and payment terms.
* Trip programme PDF: the "Itinerary" heading is kept on the same page as the first day instead of being left alone at the foot of a page.
* "On this page" bar (Bookings dashboard, Operations, Settings): it now sticks directly under the WordPress toolbar at the toolbar's real height (32px, 46px on narrow screens) instead of a fixed 46px that left a strip of content showing above it, and it stays one row that scrolls sideways instead of wrapping into a two- or three-row bar (up to 99px) over the content. Headings it links to land below it. On phones it is not sticky.
* Edit booking, shorter: for anyone who has not arranged the screen themselves, the panels follow the work — request, trip, reception, crew, price, payments, quote, documents, trip content, messages, notes — and the less used ones (Guest Pass details, crew, quote, trip content, messages, WhatsApp, referral, voucher, history) start closed. A closed panel shows a one-line summary of what it holds in its heading, marked when something was typed in it before closing. Fields in a closed panel are still saved; the jump links open the panel they point to. Opening or closing any panel keeps that person's own choice, as WordPress always does. The header adds the received amount and the balance beside the agreed total; on a phone its document buttons sit two to a row.
* Booking screen: a screen-reader label inside the documents table no longer widens the page on a phone; the internal notes panel is tinted and edged so it reads apart from what the guest sees.

= 2.8.5 =
Booking documents: a price set for one booking (revisions with a reason, never over the original), the booking's own trip content, and real PDF quotes, invoices and trip programmes — numbered at issue, frozen, shareable on the Guest Pass. Manual bookings for tours, activities, transfers and custom trips. Schema 6 adds two tables; nothing existing is migrated or repriced.

* Price & services (booking screen): a line editor — service, quantity, unit price, line total — with a discount (amount or percentage), an optional tax line and the currency. Totals are worked out on the server in integer minor units; the browser's running total is a preview. A price of zero and a price on request are kept apart. Each save is a revision stored with the booking (`_wptm_price_revision`, one meta row each, append-only) carrying who, when, the reason (required), the previous figure, its source and the difference; the snapshot taken when the request arrived is never touched, and the product, its rates and its group prices are never written. Payments already recorded fix the currency: a revision in another currency is refused. A revision records no payment, moves no status and sends nothing.
* One money authority: `Payments\expected()` now reads the latest agreement — a revision, or a quote the customer accepted after it — before the snapshot, so the list, the header, the payments panel, the Guest Pass, quotes and invoices print the same figure. A total below what was paid shows "paid in excess" instead of hiding it; nothing is refunded automatically. A quote drafted after a revision starts from the revised lines (`wptm_quote_pricing_context`, priority 5).
* Imported prices never double: an engine line is read as quantity × unit only where that reproduces its own total. A group price ("Private tour, 4 travellers, 660") imports as 1 × 660. The basis (per person, per group, per vehicle) is shown.
* Exact parsing: `Payments\to_minor()` converts the decimal on its digits (`Payments\decimal_to_minor()`), not through a float. What it accepts is unchanged.
* Trip content for this booking: an explicit "Make an editable copy" stores title, description, chosen images, itinerary (keeping days, timeline or route), included / not included, meeting point, time and instructions on the booking (`_wptm_trip_copy`). Opening a booking never copies anything; editing the copy never writes to the product; editing the product never reaches the copy. The Guest Pass prints the copy's title and itinerary when there is one.
* Documents: quotes (from Quotes versions), invoices and trip programmes, previewed as DRAFT without a number and issued with one. New table `wptm_documents` (schema 6): the issued content is frozen as JSON — company, customer, lines, totals and the words in the booking's language — with its SHA-256; an altered row is never served. Numbers are allocated at issue only, from the rows (MAX + 1 per type and series), under a UNIQUE (type, series, number, revision) key that refuses a duplicate under concurrent requests and retries. A correction is a revision with the same number; the previous one is kept, marked superseded. Issuing confirms nothing and records no payment. The PDF is rendered once before a number is taken: a layout that cannot be produced issues nothing and uses no number.
* PDF: real PDF files from the bundled TCPDF 6.11.4 (LGPL-3.0-or-later, `vendor/tcpdf`, trimmed to the engine and DejaVu Sans; loaded only when a PDF is produced; no Composer, no external service). Arabic is shaped and laid out right to left; references, amounts, telephone numbers and Latin-only lines keep their direction. Images come only from the media library, checked to be inside the uploads folder; WebP and other formats are converted to a temporary JPEG through WordPress's image editor. Page numbers, a running header, the company footer, and headings kept with what follows them. Every text is escaped before layout.
* Access: admin downloads need the booking capability and a nonce. A customer can download a document only when it is issued, current, intact and shared, with their Guest Pass key (revoked or regenerated keys stop working at once) or, for a quote, that quote's own key. The Guest Pass lists shared documents; the quote page offers its shared PDF. No file is written to the uploads folder and no address works without a key.
* Company details (Settings): trading and legal name, logo, address, contacts, free identifier rows (ICE, RC, IF, VAT…), footer and short terms per language, numbering (prefixes, year, digits, starting numbers), defaults for payment instructions and programme prices, and an optional tax line (off by default; rate in basis points). Empty fields fall back to the values the site already has (Guest Pass name and logo, support telephone and e-mail, WhatsApp, site address). Payment details stay on the Payments screen and are printed only when chosen.
* Add booking: a tour or activity, a transfer (vehicle and trip type, priced per vehicle) or a custom trip with no product (holds no seats). The product's price for the party is stored as the booking's snapshot, as a request from the site stores it; an optional agreed total is recorded as a revision. Source "Other" needs a description. Values are kept when a field is refused.
* Fix: a booking entered by hand never held its seats. Its ledger key was 43 characters for a 40-character column, so `$wpdb` refused the row; it is now 39. Capacity is respected again for manual bookings with a date.
* Fix: the Guest Pass showed only the old typed payment fields to guests: the ledger summary was registered on wp-admin requests only. It now loads with the pass, and "paid" is what is held (received less refunded).
* Bookings list: Trip, Source and "Total · paid · due" columns (Guests and Submitted are under Screen Options), filters by source and travel-date range, a custom trip's own title, and an empty state that says what to do.
* Booking screen: the header carries the Quote, Invoice and Programme PDF buttons (the current issued file, or the Documents panel), the jump links follow the order a booking is handled, and the default panel order matches it until the user rearranges. The payments form is one readable column.
* Front-end request form: a summary line at the top when a field below was refused.
* Debug notice: the Luxury section registry is attached on `init` instead of at load, so WordPress 6.7+ no longer reports a translation read too early.
* Erasure keeps issued invoices (accounting records) and removes quote and programme documents of the erased booking.
* Issued PDFs are kept. After the number is allocated the document is rendered with it and the file stored in `wptm_document_files` (database only, base64 with a SHA-256 checked on every read; nothing in uploads or the media library). The row stays "pending" — not current, not shared, not shown to the customer — until the file is read back intact; only then is it issued and the revision it replaces marked superseded. Every download, admin or customer (same capability, nonce and key checks), is that file, so later changes to images, logo, company details or the product do not reach it. A render or storage failure leaves the numbered row "Not issued — PDF not stored" with "Finish issuing", which reuses the row and the number; issuing again meanwhile is refused. Deleting a booking or erasing it removes the files with the documents. A document issued by an earlier 2.8.5 build has no stored file: it is drawn again from its frozen text with the pictures as they are now (said so in the list) and its row is not changed.
* Fix (Add booking): an agreed total that could not be saved no longer lets the booking be confirmed or announced. The booking is kept, stays pending, queues nothing, and quotes and invoices are refused until the price is saved; the booking screen shows the amount entered and "Save the agreed price and finish". The retry runs on the same booking under a lock and reads the table first, so a revision whose write reported a failure but held is not written twice. 0 is a price. A booking finished later by recovery follows the same rule. The confirmation is checked in the table, not taken from `set_status()`'s answer: if it did not hold, nothing is queued, what is left stays on the booking and the retry resumes there; a booking already confirmed is not transitioned again, and one a person moved elsewhere (cancelled, declined…) keeps that decision. In recovery, a booking that is saved but not finished is not marked done: its entry is kept and the next run resumes after the effects, without binning it or releasing its seats.
* Fix (Add booking): "Notify" now covers the status change too. Unchecked, confirming queues nothing (`Mail\held_back()`: one booking, this request only, released in `finally`; every other status listener still runs). Checked, the messages are queued once, after the booking and its price are saved. Later changes made on the booking behave as before.
* Fix (Add booking): with seat limits enforced, any failure to hold the seats refuses the booking, not only "full", as the website form does. Nothing is written and what was typed is kept. Without enforcement, transfers and custom trips are unchanged.
* Fix (recovery): `settle()` compares the saved values with those submitted; a missing row is incomplete as before, a different value is unknown (kept for the administrator notice, not binned). A record that stopped mid-save with nothing to compare against is unknown instead of complete. Seats are checked on the ledger row. The settling lock (`wptm_booking_settling_{id}`) lapses after five minutes and only its owner releases it; the 2.8.3 claim that could stall a booking for ever is ignored. Progress after the save is recorded (`_wptm_settle_stage`), so a stop after "complete" still queues the messages; a stop inside the `wptm_booking_created` listeners does not run them again (logged). Waiting bookings are one option row each (`wptm_booking_unverified_{id}`), so concurrent writers lose none; the 2.8.3 list is moved on first read.
* Fix (prices): `Pricing\snapshot_state()` no longer accepts an existing snapshot on presence. It must be one readable row with its total and currency agreeing with the expected snapshot; otherwise the new answer `invalid` (unknown, never complete or binned). Missing or wrong report copies (`_wptm_price_total_minor`, `_wptm_price_currency`) are written again from the stored snapshot, never from current rates; the snapshot is never replaced.

= 2.8.3 =
Stabilisation: bookings, prices and notifications are confirmed as stored before success is announced; Guest Pass map priority. No schema, URL, token, QR, permission or pricing-formula change; nothing is migrated or repriced.

* Bookings: `Booking\create()` reads the required values back from the table (uncached) and compares them with what was written — reference, customer name, e-mail and phone, product, date and start time, departure, party, status, the price snapshot when one was worked out, and the reservation link — instead of trusting `wp_insert_post()` or `update_post_meta()`'s return. Three outcomes are kept apart. Complete: the booking carries `_wptm_save_state = complete` (itself verified), the ledger is joined and `wptm_booking_created` fires. Confirmed incomplete: the post is moved to the bin and the move is confirmed in the table before `create()` returns `wptm_booking_incomplete`; only then do tour, activity, transfer and manual callers release the seats; if the move cannot be confirmed the outcome is unknown. Unknown (the verification read failed): `create()` returns `wptm_booking_unverified`; nothing is binned, deleted or released, the ledger row and the reservation link stay with the request so a resend waits for this booking instead of creating another, the visitor sees the translated "still being recorded" message with their answers kept (the manual form says to check the Bookings list first), and the booking is recorded in `wptm_booking_unverified`. The queue runner (loop back, cron, hourly sweep, admin fallback) settles it with `Booking\recover_unverified()`: complete → the save state, ledger join, history, `wptm_booking_created` and the notifications, once (an atomic claim row guards them); confirmed incomplete → binned, the move confirmed, then the seats released; still unreadable → kept, and after ten minutes administrators see a count with a "Check now" button. A request that stopped mid-save (state still "pending" after ten minutes) is settled the same way. Age alone never decides an outcome. Luxury's transaction is unchanged: the same checks run inside it, and any failure rolls it back.
* Repeated requests: a repeated copy of a request (`await_booking()`, `resolve_duplicate()`, and the fingerprint shortcut for tours, activities and transfers) is answered with a booking only once `Booking\completion_state()` says it is complete. A ledger row naming a booking, or a booking naming its reservation, is no longer enough; a failed read is never complete. Until then the copy waits up to the existing limit and gets the "still being recorded" message. Bookings made before 2.8.3 carry no save state and count as complete when they have a reference and a status. Reconcile no longer joins a booking whose save is still pending. Uninstall removes the two new operational options.
* Prices: `Pricing\store_snapshot()` keeps its signature and returns true only when the snapshot (and, for a priced booking, its total and currency) read back intact; `Pricing\snapshot_state()` gives the full answer (stored / exists / failed / unknown). An existing snapshot is never replaced, checked in the table. Tour, activity and transfer requests pass the server-side snapshot to `create()`, which stores it before the booking is announced. A zero total is stored as a price; a price on request stores the snapshot without a total; no quote stores nothing, as before. The calculation is unchanged and uses the same inputs, just before the post rather than after it; listeners of `wptm_booking_created` (the payment state) now see the snapshot. Recovery stores the snapshot worked out at request time if it is missing; nothing is repriced.
* Notifications: `Mail\queue_state()` tells queued / exists / skipped / failed apart (`queue()` still returns the row id). Customer mail switched off, a missing address or a refused duplicate is not a failure. A required request or confirmation message that could not be added to the outbox flags the booking with the existing `_wptm_mail_queued` key; the queue runner adds what is still owed — dedupe keys make repeated or concurrent runs write one row — and removes the flag only when nothing owed is missing. What is owed follows the booking's current status (`Mail\still_owed()`): "request received" only while the request is open (pending, contacted), the confirmation only while confirmed, the agency's new-request message not once cancelled, declined or completed. The same test runs again when a row is composed for sending; a message that no longer fits is cancelled as "No longer applicable", not failed. Retries are spaced by a one-minute gate; a retry that still fails shows administrators a count and "Try again now" (capability and nonce checked). No personal data in notices or the log. A failed notification never cancels or recreates a booking; "sending"/"uncertain" rows are untouched and never resent automatically.
* Guest Pass map: the booking's own map link, then the booking's own coordinates (a complete, in-range pair; 0 is valid), then the tour's meeting map link, then a search for the place and address. Before, the tour's link outranked a pin set for the booking.
* Luxury: the private-enquiry storage check is shown under Settings → "How journeys are sold" when the luxury experience is on, so a non-InnoDB or unreadable-engine database is visible before the first enquiry is refused. Nothing is converted and nothing is saved unsafely.
* Changelog: 2.8.2 said sites with non-InnoDB tables "keep the previous behaviour and log a warning"; in fact the enquiry is refused, the reason logged and an administrator notice shown. Corrected below.

= 2.8.2 =
Luxury enquiry concurrency fix and Guest Pass visual polish. No schema, URL, token, QR or permission change.

* Luxury enquiries: "sent" is answered only when the enquiry is stored. The booking carries its submission key (`_wptm_enquiry_once`, written with the record), so a retry after a crash between saving and finishing resolves to it instead of filing a duplicate. A copy that arrives while the first is still writing waits up to 6 s (`wptm_luxury_once_wait`) and then gets a translated, recoverable message with its answers kept. A failed save releases the claim at once. Claims are taken with INSERT IGNORE, expired claims (120 s) are taken over with a compare-and-swap UPDATE, and releases delete only the releasing request's own token, so a stale worker can neither overwrite nor release a newer claim. The hourly sweep deletes only the exact expired values it read.
* Luxury enquiries, storage: the booking, every meta row (the submission key included), the enquiry fields, the outbox rows and the end of the claim are one InnoDB transaction (`store_enquiry()`). It opens by locking the claim row (SELECT … FOR UPDATE) and checking the owner token, so a worker whose lease was taken over writes nothing and queues no mail, and no takeover can happen while the owner is inside it. A request that dies before COMMIT leaves nothing (the server rolls back on disconnect); a silent reconnect mid-transaction is detected by connection id and its fragments removed; a failed COMMIT is resolved by reading. Notifications are queued inside the transaction and only started after COMMIT (`queue_mail()`, `notify()` unchanged for callers); `wptm_luxury_enquiry_created` fires after COMMIT. Lock waits are bounded to 3 s for this connection. Sites whose posts, postmeta, options or outbox tables are not InnoDB (or whose engines cannot be read) store no enquiry: the visitor gets the translated retry message, the reason is logged and an administrator notice explains it (see Safeguards).
* Luxury enquiries, safeguards: no enquiry is stored without a transaction. If posts, postmeta, options or the outbox table is not InnoDB ('unsupported') or the engines cannot be read ('check_failed'), the visitor gets the translated retry message with their answers kept, only this request's claim is released, the reason is logged and an administrator notice explains it (clearing itself once the check passes). Before COMMIT every required value (customer, tour, party, notes, status, enquiry fields, reference, request key) is read back and compared with the validated submission, and the owed outbox rows are checked against the booking; `queue_mail()` now reports queued / exists / skipped / failed, so switched-off customer mail or a missing address never blocks an enquiry while a failed insert rolls it back. A failed COMMIT, or one answered on a replacement connection, is never taken at its word: the transaction is ended with ROLLBACK and the durable state is read afresh (`committed_state()`): complete is success, absent is a retryable failure, anything else is "confirmation pending" with nothing recreated or deleted. The lock-wait setting is restored at shutdown.
* Guest Pass: header reads agency, pass title with reference and status, trip, then the facts; the facts grid has no empty cells; a reset rule that outranked every spacing rule now carries no weight (`:where()`), restoring the intended spacing; long names and addresses wrap inside their cards; pickup details and guest actions are grouped under hairlines; plain buttons get the primary style instead of the browser's; the QR sits on plain white with quiet space; the phone bar colours WhatsApp by destination; print shows black text (the header's white text vanished on paper), hides controls and WhatsApp links, keeps the QR block and key groups whole.

= 2.8.1 =
Stabilisation pass before 3.0. No schema change, no new dependency, no change to pricing formulas, URLs, hooks or stored data. Existing bookings, price snapshots, settings and layouts are read exactly as before.

* Booking idempotency (includes/booking/handler.php, transfers/booking.php, luxury/enquiry.php). The ledger key was sha256( product | wptm_request_id ), and the identifier is printed into the page, so behind a page cache every visitor shared it: the second visitor to book matched the first visitor's reservation and was shown the first visitor's reference, date and pick-up. The key is now `Booking\idempotency_key()`: scope, product, request identifier, the nonce the form carried, and `Booking\request_payload()` (normalised identity plus every booking distinction). A genuine retry repeats all of it; two visitors cannot. `Booking\request_id_field()` prints the field and queues a tiny inline script that replaces the printed value per page view; without JavaScript the payload alone keeps visitors apart.
* Concurrent copies. A copy that found the ledger row with no booking on it yet carried on and created a second booking on the same reservation (six simultaneous posts made six bookings). `Booking\resolve_duplicate()` now waits up to PENDING_WAIT (8 s, filter `wptm_booking_pending_wait`) for the first copy via `Reservations\owner()` and the booking's `_wptm_reservation_id`, then answers with that booking, or with a recoverable message if it is still being written or was released.
* Duplicate detection. `duplicate_key()` hashes every identifier given plus date, start time, departure, party, extras, pick-up, guide language, notes and, for transfers, vehicle, trip, drop-off, return, luggage and flight. `normalise_number()` follows the WhatsApp module's rules (international digits kept whole, a trunk zero gets the agency's country code) instead of keeping the last nine digits.
* Expired nonce. Booking and transfer handlers used to redirect silently to the home page; the luxury form ended on wp_die(). `Booking\expired_nonce()` sends the visitor back to the product with `err_expired` and the sanitised answers in the usual one-use state transient (metered, honeypot-checked, nothing booked). New strings: err_expired, err_in_progress (en, fr, es, ar).
* Luxury enquiries: the once-key includes the payload and nonce and is claimed with add_option() before anything is stored (released on validation or storage failure, swept hourly). The wptm_enq answer page now sends DONOTCACHEPAGE, no-store headers and Rank Math noindex.
* Outbox (mail/outbox.php, mail/admin.php). `recover_stale()` moves rows left "sending" for STALE_AFTER (900 s, filter `wptm_mail_stale_after`) to the new status "uncertain" in one conditional UPDATE; run() never picks them up. Messages panel: "Mark as delivered" (`mark_delivered()`) and "Send again anyway"; `resend()` refuses a row in flight and resets only the exact row the operator saw (`row_version()`), so a double click sends once. compose() exceptions are recorded as failures. fail() counted attempts one high, so the first retry waited 10 minutes instead of 2. counts() gains `uncertain`, shown on Settings and the Operations board.
* Pricing: extras keys are sanitize_key( sanitize_title() ), the form the booking filter and the preview already used; an extra with an Arabic name was dropped from both. Latin keys are unchanged, so stored snapshots are unaffected.
* Editor (fields/native.php, acf-integration.php, tour/itinerary-details.php). `Fields\inferred_value()` shows the value a tour actually uses for pricing_model, departure_mode and price_currency when none is stored, so Update on an untouched legacy tour no longer rewrites a per-group price as per traveller, a scheduled tour as flexible, or pins EUR on a MAD site. Number boxes show "45,5" / "1 250,50" / "€99.90" as the same plain amount (the browser emptied them before, and the save wiped the price); unreadable text is kept in a text box. The itinerary save no longer resets the 2.5.0 re-check stamp from 2 to 1.
* Front end: `.wptm-route__heading` no longer forces the body face; the route end label is never squeezed (it overflowed the viewport by up to 21px at 360px on Sara and Medina); Atlas's route row spans the band. `.wptm-cta__form` (620px) and `.wptm-cta__lead` (50ch) are restated after the `.wptm-tour *` guard, which had lifted them (Qamar's closing form ran 1020px wide). tour.js reserves the bar's height at the end of the document, so a theme footer is no longer under the phone bar at the bottom of the page.
* Admin help: the price and Group Rates fields say whether the amount is per traveller or per group; the seasons help no longer claims amounts are accepted (they are percentages).

= 2.8.0 =
Tripvance 2.8 is a curated tour layout release. The tour page is offered as seven layouts with different purposes instead of a long list of overlapping designs, and the architecture tells a layout (the page structure) apart from a style variant (its visual language) and a gallery variant (how it presents the photographs). Nothing is migrated and no tour needs to be reopened or saved: tours, activities, bookings, pricing, Group Rates, itineraries, FAQ, reviews, galleries, highlights, trust items, translations, settings and saved layout values are read exactly as before.

* Layout registry (includes/layouts.php): one list with each layout's family, purpose, recommended use, style variants, gallery variants and legacy aliases. New helpers: `Layouts\families()`, `Layouts\offered()`, `Layouts\is_legacy()`, `Layouts\resolve( Tour )` (layout, family, variant, classes, variant stylesheet), `Layouts\root_classes()`, `Layouts\picker_items()`, `Layouts\variant_choices()`. Filters: `wptm_layout_legacy_aliases`, `wptm_layout_resolved`; `wptm_layouts` is unchanged. Slugs are unchanged (SalmaDesign, SaraDesign, AtlasDesign, SaharaDesign, MedinaDesign, QamarDesign, classic).
* Variants are stored per tour in two new optional fields, `wptm_layout_style` and `wptm_layout_gallery` (shown only for Atlas and Sahara), with site defaults in Settings (`layout_style`, `layout_gallery`). Empty means the site default, then the family default (Clean, Cinematic). A variant saved for another family is ignored, not erased. Only the resolved variant's stylesheet is loaded (atlas-zellige.css, sahara-immersive.css, sahara-mosaic.css).
* Legacy layouts: Immersive, Editorial, Mosaic, Adventure and ZelligeDesign stay registered under the same slugs with the same templates and stylesheets, so a tour that saved one renders exactly as before (checked by comparing the 2.7.0 and 2.8.0 HTML of every fixture tour). They are described in the picker by their successor (Editorial → Atlas, ZelligeDesign → Atlas with the Zellige style, Immersive and Mosaic → Sahara with that gallery, Adventure → Sahara) and are shown only to the tour or site that has one saved. The older aliases (split, sidebar, wide, stacked) map as before.
* Salma — unchanged: gallery beside the two-step booking panel. Gains the family class only.
* Sara — marketplace product page: title, rating and place across the top; a product gallery (one large photograph, three beside it, the count on the last); the facts in one ruled row (a two-column grid on phones); trust; then the reading beside the sticky price card. Salma opens with booking; Sara opens with the product.
* Atlas — itinerary first: after the introduction beside the booking card, the route and the days run across the page on a tinted band with a day index beside them (sticky on wide screens, a row of chips on phones); the practical part follows in the order Good to know, Included, What to bring, Rates, FAQ, Reviews (Included and What to bring used to sit beside the card, before the days). The navigation follows the new order. Zellige style: star motif on headings, mounted opening photograph, two-tone rules, drawn in CSS only.
* Sahara — photo-first with no sidebar: the opening per gallery variant (Cinematic: title over a full-width photograph; Immersive: taller edge-to-edge photograph, title beneath; Mosaic: title, then one large photograph and four beside it); facts and an offer line (price and button) on one band; highlights across the page on a sand band; one open column with photo breaks from the tour's own gallery (decorative repeats, empty alt; only when there are enough photographs); the meeting point and help cards; the booking band with the form; then questions and reviews.
* Medina — compact: the meeting point (address and time) joins the facts above the fold; the short practical sections (Included, What to bring, Good to know, Rates) pair up two to a row on wide screens; on phones the order is photograph, title, facts, meeting point, booking, then the notes.
* Qamar — premium storytelling (rebuilt; its 2.7 arrangement overlapped Medina's): full-width opening photograph, centred title, the lead set large with the facts as one quiet line, highlights on a paper band, the description beside a photograph, the journey (route and days) in one measure, the notes beside a photograph on the other side, inclusions and rates, one invitation band (price, booking form, meeting point and help), questions and reviews side by side, related tours. No sidebar.
* Classic — unchanged.
* New shared parts in templates/parts/: itinerary-index.php (the day index, from Tour::itinerary(), Itinerary\mode() and ItineraryView\day_title() — no parsing of its own) and offer-line.php (Tour::price() and Tour::cta(), the same link the phone bar uses). `Tripvance\part()` renders a part; theme override tripvance/parts/<name>.php.
* The booking band draws the form itself on Sahara and Qamar (`book-now` with `inline` off), since those layouts have no ticket to carry it; the band keeps id="wptm-book-now", so every booking button has its target.
* Gallery: a horizontal swipe on the main photograph now turns it (it already did in the viewer), direction-aware for right-to-left pages. Same script, bound once.
* Layout picker: Settings, the tour's Layout field and the setup wizard show the seven layouts with their purpose, a description, when to use them and a miniature of the real arrangement.
* One list for every layout control (`Layouts\admin_choices()`): the picker cards and the select under them offer the curated layouts plus the tour's (or site's) own legacy value only; saving without a change keeps a legacy value, and validation still accepts every registered slug. The tour's Sidebar field is shown only under layouts that read it (the shared template: Classic and the legacy presets; `Layouts\supports_sidebar()`), hidden rather than cleared, so a stored value is kept; the Settings description says so.
* Fixed: the setup wizard could not set any of the *Design layouts as the default (the slug was lowercased before it was checked against the registry).
* No schema, no new dependency, no new post type, no change to pricing, Group Rates, booking calculations, validation, meta keys of existing fields, URLs or slugs.
* Final polish (same version):
  - Sara: the booking column sticks as one stack (sara.css, and tour.js now measures `.wptm-sara__aside`). Only the price card was sticky, so the meeting point and "Need help?" cards that follow it in the same column slid underneath it while scrolling and the WhatsApp button at their foot was painted over or showed as a cut strip. The stack is pinned by its bottom edge when taller than the screen, so its last card is always reached.
  - WhatsApp mark: `icon( 'whatsapp' )` is now the filled WhatsApp glyph (currentColor, even-odd) instead of two thin stroked outlines that blurred into a scribble at button size. Inline SVG, no font, no request. Used on every WhatsApp button, including the contact section, the archive sidebar, landing pages and the term index, which drew a generic chat bubble.
  - "Need help?" and meeting point cards (tour.css): heading centred on its badge, the WhatsApp button on the card's foot when the card is stretched beside a neighbour, a clear step above the button, long labels wrap with the mark kept whole; on phones the badge stays beside the heading (the help card's badge used to fall under the button).
  - Atlas route at a glance: heading at the plan's title scale, the sketch capped to a sketch's height, the chain as a ruled row of stops (labels above the two ends, arrows trailing each stop so a wrapped line never starts with an arrow; route.css, Atlas and Qamar only, RTL mirrored, Arabic untracked).
  - Medina: the questions and reviews no longer sit beside an empty half page. With notes, they close the body across both tracks (side by side when there are two, a heading/content split when there is one); with no notes, they take the notes' track beside the reading; with nothing to read, the notes take the first track. Same section output and source order, so phones read as before. The meeting point and help cards share one height; the closing call to action lost its doubled rule.
  - Qamar: stacked sections in a narrative band are spaced again (a reset outweighed the spacing rule), short narratives sit on the photograph's centre line, the summary reads as a standfirst, the route heading matches the journey's scale, the highlights band and the related tours lost a stray second rule, and the concierge cards share one height.

= 2.7.0 =
Tripvance 2.7 is a tour display stabilization and consistency release. Every tour layout keeps its look; underneath, the layouts now share the same components and the same data, so a configured section no longer goes missing on one design or behaves differently on another. Nothing is migrated and no tour needs to be reopened or saved: tours, activities, bookings, pricing, Group Rates, translations, settings and saved meta keys are read exactly as before.

* Shared layout contract: layouts decide where a shared section sits, never what it does. New helpers `Tripvance\will_render()` (does a section have something to draw, and is it not switched off in the Section Manager) and `Tripvance\layout_order()` (the section keys in the order a design really draws them).
* Trust section on SalmaDesign (after the tour, before the related tours) and QamarDesign (a band of its own before the closing call to action), from the same shared section every other design already used. No trust items in Settings, no band. Trust rows with no title are no longer printed as empty lines.
* One tour card data layer (`Tripvance\Card\data()`) and one renderer (`Tripvance\Card\render()`) with three variants: standard, archive and related. The archive's `.wptm-card2` and the related-tour cards moved out of their templates with their markup and classes unchanged; prices, "From" wording, basis and the Group Rates notice all come from the tour's authoritative pricing methods. The related cards now name places by the same rule as every other card (at most two destinations, the tour type when there is none), and their images and meta are fetched in one query instead of one per card.
* Section navigation follows the page: AtlasDesign (route and itinerary lifted out of the column), ZelligeDesign (route under the facts), Sara, Qamar, Sahara and Medina pass the order they actually draw, so the links read top to bottom in page order and never point at a section the design does not draw. Public anchor ids are unchanged.
* Booking confirmation: exactly one confirmation panel on every design. Sara, Qamar, Atlas, Sahara, Medina and Zellige drew it twice (top of the page and closing band); the classic layouts kept the booking ticket beside it. Both carried `id="wptm-book-now"`; now one element does.
* Booking dialog: decided by the dialog on every design (no dialog without a booking form, none on a confirmation page). Tours with booking switched off no longer carry a hidden dialog holding an empty link, and SalmaDesign's ticket no longer shows an empty link under a perforation.
* The booking form's nonce input no longer repeats `id="_wptm_nonce"` when the form is drawn twice on a page (band and dialog). Same action, name and value: the request handler is unchanged.
* The two-step booking form is a shared component, not part of SalmaDesign. Its rules moved from salma.css into the new booking-steps.css (handle `wptm-booking-steps`), loaded wherever the form can appear; salma.css (handle `wptm-salma`, unchanged) now holds SalmaDesign's layout only, is loaded on SalmaDesign only and depends on the form's stylesheet. The split moved rules without editing one, and was checked for cascade-order changes. The form's markup moved to templates/parts/booking-steps.php; the old path still loads it. The script keeps its file, handle and wptmSalma settings.
* Empty-state safety: the facts strip, the design bands that wrap it, the related-tours bands and the FAQ (entries with no question) draw nothing when there is nothing to show, instead of a styled empty band or a heading over nothing.
* Itinerary: a day title typed as "Day 1: ..." under a card that already reads "Day 01" and "Day 1 of 14" is printed without the repeated number. Display only; the stored title is untouched, and a title whose number differs prints as written.
* Highlights: one shared item style on every layout — a fixed 34px icon centred on the first line of text, the list's gap as the only spacing (18px, 28px between columns), two columns where the section is at least 680px wide and one below, 15–16px text at weight 600 with a 1.5 line height, no tracking on Arabic. SalmaDesign keeps its smaller text size and drops its own item overrides. A highlights line with no title ("| detail") no longer makes an empty Highlights section or a navigation link to one.
* Empty states, second pass: the booking card beside the column (Sara, Atlas), the information cards under the gallery (Qamar, Medina) and the closing band that holds the booking call to action and the related tours (Qamar, Medina) are drawn only when something inside them will be; the sidebar prints nothing, not even its hidden heading, when it has neither a ticket nor a card.
* Tour::has() had two 'facts' cases; the unreachable second one is removed (behaviour unchanged).
* Phone booking bar: with a long translated basis or Group Rates note the price column could take the whole bar and squeeze the button to one letter per line. The price keeps its intended cap; short prices look exactly as before.
* One documented layering scale (`--wptm-z-sticky`, `--wptm-z-float`, `--wptm-z-modal`, `--wptm-z-lightbox`, with reserved dropdown and overlay levels) used by the section navigation, the phone bar, the booking dialog and the photo viewer. The values are the ones already in use, so nothing moves relative to a theme or a chat widget.
* Breakpoints: paired `max-width: Npx` / `min-width: N+1px` queries now meet at N.98px, closing the gap a zoomed or fractional-width screen could fall into. Integer widths behave exactly as before; layout-specific breakpoints are kept.
* JavaScript: the tour, two-step form, booking form, card and itinerary scripts bind once per element, so a script loaded twice by an optimisation plugin no longer doubles every handler.
* No schema, no new dependency, no pricing formula, booking calculation, meta key, URL or text-domain change.

= 2.5.1 =
* Hotfix: Improved stability and responsive layout of the Homepage FAQ section.
* Fixed inconsistent FAQ card heights and layout shifting when displaying answers.

= 2.5.0 =
Tripvance 2.5 focuses on administrator workflow, editor stability, UI consistency, draft/publish actions, validation feedback and overall admin polish. A refinement release: tours, activities, transfers, bookings, pricing, group rates, quotes and the Guest Pass work as before, product pages keep their design with a finishing pass on the gallery, spacing and review form. One conservative migration runs on upgrade: a migrated itinerary still stored as a single untouched collapsed day is rebuilt from its original text; edited cards are never replaced and the original text is kept.

* Editor layout drawn by the server and CSS from the first paint: section navigation in the left column, the opening section chosen before the page is drawn, no rearranging after load. Reopens on the section being edited after a save. Works without JavaScript.
* Status-aware action bar: Save draft / Publish for new and draft posts, Update for published and scheduled ones, Save as pending and Submit for review where permissions require. Preview, Duplicate, View bookings, Move to draft and Move to trash in a More menu, shown only where they apply. The buttons press WordPress's own controls and work without JavaScript.
* Human status labels (New tour, Draft, Pending review, Scheduled, Published, Private); "auto-draft" is never shown.
* Save state: Not saved yet, Unsaved changes, Saving…, Saved just now / N minutes ago. The leave-page warning respects saves and previews. Ctrl/Cmd+S saves a draft as a draft.
* Duplicate for activities as well as tours; always a draft; the Polylang translation group and import/restore history are not copied.
* One completion definition (essentials only), an actionable "items need attention" list, and one set of section marks: complete, needs attention, incomplete, must be fixed.
* Drafts are never blocked. Publish and Update stop only for a missing title or a malformed number or address, explained under the bar and on the section. A first publish without a title is kept as a draft on the server.
* One icon policy, one button shape and hierarchy, one chip style, a shared spacing scale and one accent colour across the editor, bookings, operations and transfers.
* Same-named terms in the editor's term lists are shown with their language, parent or slug; nothing is merged or deleted.
* Keyboard support for the new menus, lists and field finder; visible focus; no state told by colour alone; RTL-safe logical properties; no horizontal scrolling at narrow widths.
* Tour galleries: every photograph is in the thumbnail strip, which scrolls sideways with the next picture showing at its edge and previous/next arrows once there is more to see; the selected thumbnail stays clear and in view. Small sets (two to six photographs) fill the row instead of leaving a gap, with no arrows when everything fits. Activity galleries mark the last frame "+N" when the viewer holds more photographs than the grid shows.
* Product pages: no empty "related tours" block (and its reserved blank space) when there is nothing to suggest; the classic layouts get a content width and gutter on block themes; the "prices checked" date wraps inside a narrow booking card.
* Reviews: the logged-in line reads as one sentence, the rating select is sized to its options, the submit button uses the plugin's primary style, and the summary and form line up with the review list in every layout (full width on tablets and phones).
* Itinerary and FAQ: text pasted from Word, Google Docs, AI tools or other plugins no longer collapses into one day or one question. Numbered days or questions run together in one paragraph ("2- Day two" or "2-Day two") are separated when the numbering is unambiguous, a day recovered that way as "Title / Description" keeps a short title, and an ordered list keeps its sequence; ranges, times, dates and prices never open a day, and bullet lists stay inside their answer. Content that already read correctly is read exactly as before. Migrated tours whose single collapsed day was never edited are rebuilt from their original text once, on upgrade; edited cards are never replaced and the original text is kept.
* Structured data: Tripvance adds at most one Product to a Tour or Activity page (name, URL, image and short description where shown; the bookable price as one Offer at the public "From" price — the fixed price or the lowest valid Group Rate; the aggregate of approved on-site reviews where shown). Prices come from the page's own pricing code; tours on request, incomplete rate tables and seasonal discounts get no price. With Rank Math the data enriches Rank Math's own Product for the page or adds one Product to its graph; without it, one JSON-LD Product is printed. Nothing is added elsewhere.
* Removed: the homepage graph (TravelAgency, WebSite, WebPage, FAQPage, ItemList) and its setting, and the ratings and reviews previously added to Rank Math Service, TouristTrip or Trip entities. Legacy settings are preserved. The old rating_schema value is read only to preserve an existing rating opt-out until the new product_rating preference is saved. "Stars in search results" is now "Search result product data", on by default, with its own rating option; a site that had switched the old rating option off keeps ratings off; search engines decide what is shown.
* Rank Math: the sitemap empty-terms switch is no longer forced for every taxonomy (only empty Tripvance terms are left out), and the page builder no longer writes or clears Rank Math's SEO title, description and focus keyword.

= 2.1.0 =
Transfers with reusable Vehicle Types, Fleet links in Operations, transfer-aware booking documents, and an optional Privacy & Cookies module. Tours, activities, group rates and their pricing are unchanged, and nothing is migrated: existing bookings and settings keep working.

**Transfers**

* New Transfers post type (`wptm_transfer`, /transfers/{slug}/): route, pickup and drop-off types, estimated duration, distance, operating hours, minimum booking notice, pickup and meeting instructions, inclusions, important information, cancellation and gallery. An optional transfer archive (Settings), off by default. No schema is printed; the SEO plugin owns the markup.
* Vehicle Types (`wptm_vehicle_profile`): reusable customer-facing vehicle options with category, class, passenger and luggage capacity, features and a picture. Private data, not public pages.
* Transfer pricing: each transfer sets its own price per Vehicle Type. A round trip is two journeys at the same price; there are no distance, surcharge or discount rules. One pricing service feeds the page, the server-side check and the price stored with the booking.
* Transfer booking form: Vehicle Type cards, one way or round trip, pickup date and time, passengers, luggage, optional flight details and addresses. Passenger capacity is checked in the browser and on the server; minimum notice is enforced on the server.
* A transfer request is an ordinary booking record (same statuses, history, reservation ledger, messages and confirmation), marked `_wptm_booking_type = transfer`, with its facts under `_wptm_trf_*` and its price snapshot stored once.
* The price snapshot stored with a transfer booking is worded in the booking's language (one way, round trip); amounts are unchanged and earlier bookings are not rewritten.
* The WhatsApp follow-up after a transfer request carries the transfer's facts — reference, transfer, route, pickup and drop-off, pickup date and time, passengers, Vehicle Type and return — to the site's WhatsApp number. Tours and activities keep their message.
* Homepage: a new Transfers section, off until ticked in Page layout. Latest transfers or transfers chosen and ordered by hand, a heading and a line, and cards with the route, Vehicle Types, capacity and the "from" price per vehicle from the transfer pricing service, linking to each transfer page. Existing homepages render unchanged.

**Operations and Booking Admin**

* Operations → Vehicles is now labelled Fleet, and Transfers → Vehicle Types holds the customer-facing options. Post types and meta keys are unchanged.
* A fleet vehicle can be linked to a Vehicle Type, whose capacity and class it then reads (an existing seat figure still wins). Optional brand, model and operational status (Available, Assigned, Unavailable, Maintenance).
* Assigning a fleet vehicle to a transfer: a warning when it does not match the requested Vehicle Type or is Unavailable or in Maintenance; refused when it cannot carry the passengers. The requested Vehicle Type is never overwritten.
* Transfer bookings show a Transportation block (route, requested Vehicle Type, passengers, luggage, assigned fleet vehicle, driver), a booking type filter in the list, and Trip details with pickup date, pickup time and one Passengers field kept equal to the reservation ledger.
* Quotes, the Guest Pass, the voucher, check-in, the operations board and the personal-data export read transfer bookings in transfer terms, and quotes start from the price stored with the booking rather than tour rates. The Guest Pass and quotes no longer read a transfer as a tour.

**Privacy & Cookies**

* Optional consent banner, off by default and loading nothing while off: Tripvance → Settings → Privacy & Cookies. Three layouts, four categories (Necessary, Analytics, Marketing, Preferences), editable wording, RTL and keyboard support.
* Scripts placed in the Analytics, Marketing and Preferences boxes, or tagged `type="text/plain" data-wptm-consent="…"`, run only after their category is accepted. Consent is kept in one first-party cookie, `wptm_consent`.
* Campaign attribution (`wptm_attr`) now honours the Analytics choice made in Privacy & Cookies. When a consent manager runs the WordPress Consent API in opt-in mode, its statistics answer is final and a refusal there is never overridden by Privacy & Cookies; the `wptm_attribution_consent` filter still has the last word. Without consent it stays off.
* The module helps collect and honour consent; it does not by itself make a site legally compliant.

**Display**

* Homepage: the hero no longer jumps under the theme header, spacing in rails and lists is restored, and search fields have a 44px touch target.
* Tour and activity pages: the price, its basis and the group-rate note read as one phrase; phone bar and quick-fact layout fixes. Display only.

= 2.0.2 =
Centralized pricing and group-rate integrity validation; scoped archive facets,
normalized duration labels, clean reviews and graceful form fallbacks.

= 2.0.1 =
Visual refinement of the tour page and the booking screens. No change to the pricing engine, the booking workflow, the stored data or the meta keys.

* Tour header: title, place, rating and the starting price read as one opening; the quick facts sit under the gallery as one strip.
* Gallery: the picture takes the reading column on a desktop, the thumbnails are an even strip, and a "View all photos" button on the picture opens the viewer, which now shows where you are in the set ("3 / 8") and follows the reading direction.
* Booking panel: reads in the order of the decision — From price, date and travellers, the rate for that party, extras, the estimated total, then the button — with the site's own reassurances and reply time under the button. Every figure still comes from the server.
* Fix: optional extras in the two-step form were drawn as large empty boxes with upper-case labels; they are real checkboxes with their price.
* Fix: a form reopened at step two after an error did not fill its summary.
* Phone bar: more compact, safe-area aware, and hidden while a dialog or the photo viewer is open.
* Sections: one spacing and type scale for Overview, Itinerary (multi-day thread and timed day timeline), Included / Not included, Important information, questions, reviews and related tours.
* Reviews: a summary card with the average as stars, and each review as a card with the name, date and stars.
* Fix: a single related tour grew to the width of the page; the rate table on SalmaDesign split its header on phones; sections below the booking panel carried a double gutter on phones; the page ran edge to edge on block themes.
* Bookings list: a Total column (accepted quote, else the price worked out with the request), status views with counts, a "Move to" menu for a safe status change from the list (same transitions, history and hooks as the booking screen), and a readable layout on phones.
* Booking screen: the request is set out as Booking, Trip, Customer, Pricing (line by line from the stored price snapshot) and Notes, each reachable from the header.
* Fix: group-rate input uses one decimal parser for dot/comma decimals and grouped thousands; displayed tiers retain cents.
* Admin: non-blocking warnings for group-rate gaps, overlaps, reversed ranges and invalid prices; saved values and engine selection rules are unchanged.
* Reviews: full, true half and empty SVG stars use the existing icon set, with RTL and accessible rating text.

= 2.0.0 — The Experience Builder =

Tours and Activities share one Experience Core and one section-based
editor, and stay two post types with their own URLs and page designs.
No meta key moves and nothing is migrated; see readme.txt for the list.

Pricing clarity
---------------

Presentation only: no stored price, tier, total or formula changes.

* Group-size prices say "Price varies by group size" beside the "From"
  price; the stored "From" price is shown unchanged.
* Booking summary reads "Rate for 2 travellers · €495 / person" instead of
  "Base rate", and updates as the party changes.
* "View group rates" lists the configured tiers from the engine's own bands.
* Children priced below the adult rate are said to be.
* Admin notice when the "From" price differs from the lowest group rate.

Tailor-Made Requests
--------------------

A "plan your own trip" wizard beside the booking form, not inside it.

* Trip Builder: nine steps (dates, length, interests, destinations,
  travel style, accommodation, travellers, budget, contact), one at a
  time, with progress, Previous / Continue and a summary before Send.
  Values stay in the form while moving between steps; nothing reaches
  the server before Send. Without JavaScript every step shows on one page
  and the form still posts. `[tripvance_trip_builder]`, a Trip Builder
  block, or an assigned page, whose Polylang translations show it too.
* Settings: steps on/off, required/optional and reordered; answers for
  interests, destinations, travel styles, accommodation levels and budget
  ranges, each with label, optional Media Library image, active state and
  sort order. Destinations can link to tour destinations. Shipped answers
  carry French, Spanish and Arabic; renamed or added ones go to Polylang's
  string table (group "Tripvance Trip Builder").
* "Create Trip Builder Page" makes Draft pages on the Tripvance full-width
  template: one per language enabled in Polylang, titled in that language
  where Tripvance has the words and linked as translations (setting
  `page_ids`), or one in the site language without Polylang. Pressed again
  it creates only the languages still missing, and never a second page: it
  re-finds the pages it made and keeps assigned pages as they are. An existing page can be moved to
  that template with one button; nothing is changed without it.
* Each step shows one number: the step navigation's list markers and theme
  counters are switched off, and questions carry no second number.
* Requests: private post type `wptm_tmr`, references TMR-1001 onwards,
  statuses New, In Review, Contacted, Proposal Sent, Confirmed, Closed,
  history, internal notes, privacy export and erasure. Reuses the booking
  capabilities.
* Create Draft Tour: title in the request's language with reference,
  days/nights and the destination terms saved with the request; the
  party size stays on the request. No itinerary, price or text
  is invented; the request is shown beside the draft. One per request.
* Emails: to the agency (booking address unless set) and, optionally, to
  the traveller in their language, through wp_mail() with filters
  (`wptm_tmr_admin_message`, `wptm_tmr_customer_message`,
  `wptm_tmr_pre_send`).
* Tour page call to action "Can't find exactly what you want?", off by
  default, on tours only, in every layout through `wptm_tour_sections`.
* New stored values: option `wptm_tmr_settings`, option
  `wptm_tmr_counter`, `_wptm_tmr_*` meta on requests (chosen destinations
  keep their option id, label and tour destination term id), `_wptm_tmr_request`
  on a Draft Tour, `_wptm_tmr_builder_page` on the page it creates.

New stored values, all optional and shared by both types:
wptm_hero_image_mobile, wptm_itinerary_route, wptm_sections,
wptm_presentation (activities), wptm_video_url (activities), and
_wptm_guide_language on a booking. New settings: experience_editor,
activity_presentation. New option: wptm_experience_version.

= 1.42.1 — Why the Activity page could look unstyled, and the phone =

Two faults with one symptom, and the Activity page on a small screen. No
tour page changes, no meta key moves, and every activity, price, gallery
and booking is where it was.

Why the styles were not there
-----------------------------

* The stylesheet was named behind two other handles. wptm-activity was
  enqueued with wptm-tokens and wptm-card as dependencies, which is what
  orders them correctly in the head — and WP_Dependencies::all_deps()
  returns false when a named dependency is not registered, so the style is
  not printed at all. No notice, no warning, nothing in the log: the page
  ships its whole markup and no rules. One handle deregistered by an
  optimisation plugin is enough. Both are now registered at the point of
  use, and a handle that still cannot be found is dropped from the list
  rather than allowed to take the page's own stylesheet with it.
* The page's appearance depended on a second file. Every panel, cell rule,
  hairline and tinted card on it is drawn with custom properties declared
  in tokens.css, and a declaration that reads a property with no value is
  invalid at computed-value time and dropped whole — `border: 1px solid
  var(--wptm-line)` becomes no border, not a default one. So losing that
  one 3 KB file, while activity.css loaded in full, left At a glance with
  no box and no rules between its cells, Highlights with no card behind
  them and the panels with square corners: the page reading as plain text
  and lists. activity.css now carries its own floor for the values its UI
  cannot draw without, declared through :where() so it has no specificity
  at all and tokens.css, the accent setting and the unified brand still
  win wherever they are present.
* Assets are versioned by the file that is served. asset_version() stamped
  the readable source while minified_src() handed the browser the .min
  built from it. Rebuild the .min without touching its source — a build
  step on deploy, a checkout of one file — and the bytes at that address
  change while the address does not, so every browser, CDN edge and page
  cache holding the old copy keeps it. The served file's own modification
  time is now part of the version.
* A .min that is empty or a tenth the size of its source is not served. An
  interrupted build leaves a file, and its modification time says it is
  the newest thing in the folder, so every existing test passed and the
  browser got an empty stylesheet with a 200.

The page on a phone
-------------------

* The hero starts below the theme's header. A fixed header is painted over
  the top of a band that reaches both edges of the screen, and at 360
  pixels the whole breadcrumb row and half the line above the title were
  underneath it. What is measured is the overlap, not a height: whatever
  is fixed or sticky at the top of the window, the admin bar included,
  against where the band actually begins. A header in the normal flow
  measures zero and nothing moves. No theme's height is written anywhere.
* One step tighter on the spacing scale. The page is spaced with three
  custom properties and every one of them was at the lower bound of a
  clamp written for a six-hundred pixel column, so twelve sections came to
  seven hundred pixels of air. The rhythm is the same rhythm, one step in.
* At a glance is compact cells rather than desktop cards, and a short last
  row takes the width instead of leaving an empty ruled half beside it.
* The gallery is two across. Twelve full-width frames at 390 pixels is
  three and a half thousand pixels of scrolling with what is included,
  where to meet and what it costs to cancel on the far side of it. A tap
  still opens the full-size picture in the viewer, which is larger than
  the one column ever was.
* The meeting point stacks, so an address is two lines rather than four of
  two words, and the photograph is large enough to recognise.
* The running order's clock column is the width of its badge at each size.
  The widening this page applies for the badge was unconditional and two
  classes deep, so it also overruled the itinerary component's own
  narrow-screen rules and took a fifth of the screen from the text.
* What to bring, Important information and the questions are headed like
  everything else on the page. They are the tour page's section templates
  and their title sets no colour, so they inherited the body grey and
  stood beside navy headings at a different size over a shorter rule.

The price bar
-------------

* Its height is derived from its own parts and the safe area, and the page
  reserves exactly that. The two were separate numbers and disagreed: at
  360 pixels the price wrapped, the bar became taller than the room left
  for it, and it covered the bottom of the last card.
* The price stays on one line and is never cut — the basis gives way on the
  narrowest screens, the amount never does — and a button label longer
  than the space for it ends in an ellipsis instead of past the card's
  edge. The label had also been given the calendar icon's line-height by a
  first-child selector written when every button carried the icon; on the
  one button that does not, that is the label.

= 1.42.0 — The Activity page, and a way out of the wrong post type =

The Activity page rebuilt around its reading column, a photograph viewer, and
a converter for a tour that should have been an activity. Nothing on a tour
page changes, no meta key moves, and every tour, activity, price, itinerary
and booking is where it was.

The page
--------

* The hero is full width. The band reaches both edges of the screen while
  its words stay in the same column as the reading below them, and it sits
  against the site header instead of under a band of white. The photograph
  keeps its own proportions rather than being stretched to the height of the
  text beside it, and the duration, difficulty, price and button are one
  card under the title rather than a row with a rule over it.
* The booking rail holds the booking card and nothing else. Meeting point,
  cancellation, important information and the questions were drawn in a
  three-hundred-and-seventy pixel column beside the form, where an accordion
  became a ribbon of text; they are sections of the reading column now, at
  its full width, each drawn once. Two dozen stylesheet rules that existed
  only to shrink them to fit that rail are deleted rather than left behind.
* The booking card follows the reader on a wide screen and stacks normally
  on a phone. It carried a max-height and an overflow of its own for a
  while, which put a second scrollbar inside a card of form fields; the tall
  case is answered by the offset instead, so a panel taller than the window
  travels until its last row is on screen and pins from there.
* One spacing scale. Six different margins between sections became three
  custom properties, and a section heading sits closer to what it introduces
  than to the section above it.
* At a glance carries every fact, including when the activity runs, which
  had a card of its own. The strip wraps into counted columns — one, two or
  three by its own width — instead of forcing every fact onto a single row,
  so the labels are read at their proper size rather than shrunk to fit.
* The description and the FAQ answers are justified, hyphenated and set at a
  reading measure, ragged on a narrow column, inheriting the theme's type.

The photographs
---------------

* "View photos" and every thumbnail open a viewer: the full-size picture, a
  counter, previous and next, Escape, arrow keys, a backdrop that closes it
  and a swipe on a touch screen. The page behind does not scroll while it is
  open and focus returns to the frame it was opened from.
* The viewer holds every photograph the activity has, not the twelve the
  grid draws, so the count on the banner and the pictures a reader can reach
  are the same number. The extra ones are links without an image: they cost
  a tag each and fetch nothing until somebody reaches them.
* The hero is the only image fetched eagerly. The gallery is lazy, every
  frame states its dimensions, and nothing below moves as the files arrive.

Tour to Activity
----------------

* An unpublished tour can be copied into a new draft activity, from the
  tours list or from the Publish box. Title, content, excerpt, featured
  image, gallery, destination and place terms, and every commercial and
  experience field the two post types already share — which is most of them,
  because an activity stores them under the tour's own names.
* The tour is never modified: it keeps its post type, its status and every
  meta row, and gains one key naming what it became. No media is copied — a
  gallery is attachment ids, and an id on a second post is the same file
  shown twice. Converting a second time opens the activity that exists.
* The itinerary is converted rather than copied. A tour keeps its stops as
  days and an activity keeps them as moments of one occasion, so a copied
  row arrived as an empty schedule with every word still in it. Stops are
  read through the itinerary layer's own reader — structured, legacy text,
  or the two merged — and written as schedule rows in order with their
  times, titles, descriptions, locations and photographs. A fact or a day
  note with no field of its own is carried into the description rather than
  dropped, and anything that cannot be mapped is named in a notice on the
  new activity.
* Activity type is left for a person to choose: activities have their own
  vocabulary, and guessing which one a "Desert tour" is would be inventing
  data. Booking settings come across with "Accept bookings" off, so nothing
  starts taking requests at a price nobody has checked.

Housekeeping
------------

* Nothing above this page is modified any more. The script used to rewrite a
  theme wrapper's overflow: hidden to clip so the sticky card would stick;
  it now reads that chain and, where a theme clips, writes no offset and
  lets the card fall back to ordinary positioning.
* A table name reaching SHOW INDEX is filtered to word characters, the field
  width is cast at the point of output, and the upgrade notices are inside
  the directory's length limit.

= 1.41.0 — The tour editor answers as you type =

Eight changes to the add and edit screen. Nothing was redesigned, no library
was added, no meta key moved, and the front of the site is untouched.

What was wrong
--------------

* The status card printed "wptm_booking" where the booking mode should be.
  It looked the stored value up in a map of four modes — form, whatsapp,
  external, none — and a tour stores wptm_booking, inquiry, whatsapp,
  external or disabled. Two of the five could never match, and the fallback
  was to print the raw value. It is now the same five the front end switches
  on, asked of the tour so an inherited mode and a mode with no data behind
  it both read correctly.
* Every line of that card was an answer from the last save, beside a
  readiness meter that had already learned to answer from the form.

What is new
-----------

* The status card is live. Destination, duration, price, booking and status
  are recomputed from the fields as they are typed, using the words PHP
  already had: the mode labels, the duration presets and the site's booking
  mode are handed to the script rather than restated in it. A line that ends
  up empty hides; a line a site added through the filter is left alone.
* Completion counts the essentials: Title, Destination, Duration, Short
  description, Price or on request, Itinerary. Destination and Duration are
  new rows. The hero image, the departure point, the inclusions and the
  gallery moved under a Recommended heading — still listed, still ticked,
  no longer holding the percentage down. Advice, never a gate, as before.
* The bar above the title says "Unsaved changes" from the first keystroke
  and returns to "Saved N ago" on submit. The state was already tracked for
  the warning on the way out; it is simply said out loud now.
* Field search covers the itinerary's own fields — the time, the distance,
  the transport, the overnight, the carried-over day info — and opens the
  card and the fold a match is behind.
* A closed day card carries its time, distance, transport and overnight
  rather than a count of facts, so a run of closed cards can be read without
  opening them. Kept current by the script as the fields change.
* Pricing, the Itinerary and the media box each open with one quiet line
  counting what is in them. Nothing is computed that the screen does not
  compute anyway, and a panel with nothing in it prints nothing.
* Eight help lines that only restated their label are gone. Every line
  carrying a format or a rule is kept.

The readiness rows are read three times a page and now build a Tour to
answer the duration, so the list is memoised per post for the request.

= 1.40.2 — A quieter itinerary =

One stylesheet, assets/css/itinerary.css. No markup, no PHP, no stored value,
no colour and no behaviour differs from 1.40.1.

* The hairline between days is gone. A rule above every day drew a stack of
  horizontal lines across a component whose whole structure is one vertical
  one, and the two were arguing: the thread says the days are a single run,
  the rules said they were a list of rows. The space between days went up to
  replace it, on all three breakpoints, along with the space between the
  parts of one day.
* The thread ends properly. It was cut at the last day's badge, on the
  reasoning that the badge is the last thing on it — which is wrong for any
  last day that has moments, and left their marks floating with nothing
  joining them to anything. Cutting it at the foot of the day instead only
  moves the problem: the line then runs past the final mark and stops dead in
  the white space under it. So it is not cut at all now. It runs the length
  of the last day and dissolves over the stretch below the last mark, a
  distance that follows the rail so it stays in proportion at every width.
* The prose is sized to be read. A day's description was 15px — caption size,
  for the one long read on the page. It is now clamped between 15.5px and
  17.5px, around 16px at tablet width and 17.2px on a wide screen, with the
  leading at 1.72 and paragraph spacing at 1.05em. A moment's words, which
  are also a day trip's stop descriptions, went from 14.5px to between 15px
  and 16.5px and stay a step below the day's own body, which is what holds
  the hierarchy together when a day has both.
* Justified, from 992px up. text-align: justify with text-justify: inter-word
  and hyphens: auto — the even right edge of a printed programme, with the
  slack spent on the spaces rather than stretched between the letters, and
  hyphenation to keep it from having to. Below that width the text is ragged,
  and so it is inside the container queries, because a wide window is not a
  wide measure: the layouts that put the itinerary in a column beside a
  sticky panel would otherwise justify at phone width. Both paragraphs also
  now state text-align: start rather than inheriting, so a theme that centres
  its body copy cannot centre a travel programme — and start rather than left,
  so an Arabic page still reads from the right.

= 1.40.1 — The migration actually runs =

One fix. Nothing about the editor, the templates, the storage, Activities,
bookings or pricing differs from 1.40.0 in any way.

The activation hook ran the 4.0 prefix rename and then wrote the schema
counter at its head. That is a claim about every migration up to the current
release, and one of them had not started: the itinerary move belongs to the
admin_init pass, which then saw a site already at the head and returned. The
tours kept their itineraries in the old text field and nothing ever came back
for them. It showed up on three paths — a plugin updated while it was
deactivated and then switched on, a reinstall over an old database, and a
fresh install.

* Activation records only what it performed: the rename, which is schema 2.
* The itinerary move is recorded only by the pass that runs it, and only once
  the last tour is through. A batch cut short resumes; a request that does
  not have the module loaded records nothing rather than assuming.
* The number only ever moves forward. Deactivating and reactivating a
  migrated site leaves it where it is instead of writing it back down and
  running the move again over days somebody has since edited.
* Sites already stranded by 1.40.0 are repaired. The head moves to 4, because
  a 3 written by that build may mean the move finished or may mean it never
  ran and there is no way to tell the two apart from the number alone. Every
  site goes through the move once more: one that genuinely finished has every
  tour flagged, finds nothing to do and pays a single query; one that was
  stranded finishes properly. The move itself is unchanged — idempotent, in
  batches, and it never writes over a card somebody has filled in.

= 1.40.0 — The itinerary lives on its cards =

Until this release a day of a tour lived in two places. Its title and its
prose were a numbered line in the Itinerary text field — "1- Marrakech to
Ait Ben Haddou", a syntax somebody had to remember — and everything else it
said was on the card beside that field. Adding a day wrote a line into the
text; naming the card rewrote the same line. One value, two homes, and the
whole arrangement resting on a text format.

The cards are the itinerary now. Press Add a day, write a title and a
description, and that is a day.

What changed on the editor
--------------------------

* Title and Description are fields of the card, saved with it like every
  other field. The description takes as many paragraphs as it takes; a blank
  line starts a new one, which is what a blank line always meant.
* Add a day, or Add a stop, creates the card and nothing else. No numbered
  line is written into any text field, by that button or by anything else.
* Days can be moved and removed on the screen. The order of the cards is the
  order of the itinerary, and the field names follow after every change, so
  what is on the screen is what is saved. Removing a card that has something
  written on it asks first.
* The Itinerary text field is no longer drawn. It is shown read-only under
  the cards, folded away, so an owner can still read and copy what it holds.
* Everything 1.39.0 added is still there and unchanged: the hour-led stop
  headers, More details, the moments with add, duplicate, move and remove,
  Copy the previous day into the empty fields, Open all and Close all, the
  remembered folds and the unsaved-work dot.

What happens to existing tours
------------------------------

Schema 3. The first time an administrator opens wp-admin, every tour is
walked once and its itinerary is moved into its cards.

* The old text is read through Parser::itinerary(), the same parser the tour
  page, the print sheet and the guest pass have always been drawn with. A day
  therefore means exactly the same thing after the move as before it.
* The title becomes the card's title. The paragraphs, or the rich content,
  become its description. The hour a numbered line stated — "1- 08:30 |
  Departure" — becomes the day's start time. A day info line such as
  "Accommodation: Riad | Meals: Breakfast" is carried over as it was.
* Nothing already entered is written over. A card that states a subtitle, a
  distance, a photograph or a moment keeps it; the text answers only where
  the card is silent.
* A tour written as rich text keeps its markup. Headings, links, emphasis and
  lists survive, cleaned with the allowance a post's own content gets, rather
  than being flattened into plain sentences.
* wptm_itinerary is never written and never deleted. It stays as the backup,
  and as the fallback the front of the site still reads for any tour the move
  has not reached.
* The move is idempotent. Each tour is stamped when it is done, so running it
  again is a no-op, and saving a tour on the new editor counts as moving it.
* A large catalogue finishes over several page loads, sixty tours at a time.
  Nothing disappears meanwhile: a tour still waiting is drawn from its old
  text exactly as it was. Tripvance → System Status says how many are left.

One reader
----------

The tour page, the Luxury Journey, the print sheet, the guest pass, the
Visual Builder's composer, the REST payload, Tour::itinerary(), the itinerary
mode and the editor's own cards all ask one function now: the structured
cards first, the old text only as the fallback. Two layouts of the same tour
cannot disagree about what its days say, and the shape that function returns
is the shape every one of those consumers already read, so none of them had
to change.

Day-by-day, Single day / Timeline and Automatic behave exactly as they did.
No template, class, stylesheet or piece of front-end markup was touched.

Activities
----------

Untouched, deliberately and carefully. An activity's running order shares
this meta row — it is day zero of it — and a row written by an earlier
version is now read at the version it was written at rather than refused by
the newer one, which is the one way a version bump could have made a schedule
disappear. The migration never queries activities at all.

= 1.38.0 — Stability & Performance =

No new features, on purpose. This release is about what happens underneath a
site that is already working: how much of the plugin a page has to load, how
the booking engine behaves when two people press the button at once, and what
somebody can find out when something is wrong without having to ask.

Every tour, activity, booking, price, group rate, itinerary, builder layout,
setting, translation, guest pass and queued message is exactly where it was.
No meta key, option, table or post type was renamed. Nothing needs migrating,
and nothing is reset.

Diagnosis
---------

Tripvance → System Status is a new screen and the only visible addition in
this release. It reads and never writes.

* Tripvance: the plugin version, the database schema version and the one this
  build expects, whether migrations finished, and whether permalinks and the
  rewrite rules are in a state the tour pages, the guest pass and the customer
  quote page can work with.
* WordPress: version, PHP version, memory limit, WP_DEBUG, SCRIPT_DEBUG,
  WP-Cron, whether a persistent object cache is in use, the database server.
* Database: every table the plugin owns, present or missing, and every index
  its queries depend on.
* Bookings: five checks over the reservation ledger, reported as counts and
  nothing else — seats held by a booking that was never written, ledger rows
  pointing at a booking that is gone, bookings whose ledger row never got its
  reference, ledger rows whose status disagrees with the booking's, and
  departures holding more seats than their capacity.
* Email queue: waiting, sending, retrying, given up on, stuck, sent,
  cancelled, and when the queue last ran.
* Scheduled tasks: when each of the four events this plugin owns next runs,
  whether any is overdue, and whether any is scheduled more than once — which
  is how one traveller ends up with two reminders and is the sort of thing
  that survives a migration unnoticed.
* Integrations: Polylang and its languages, Rank Math, ACF, WooCommerce.

Copy diagnostic report produces the same information as plain text. It carries
no customer name, e-mail address, telephone number, booking detail, API key or
secret, and it says so at the bottom, so somebody can paste it into a public
forum and see for themselves that it was safe to.

Loading
-------

A front-end request parses roughly ten thousand fewer lines of PHP than it did
in 1.37.0. Nothing was deleted to achieve it: the code was moved to where it
is asked for.

* includes/settings.php was 1,824 lines, of which about 1,500 were the screen
  that edits the settings. That screen is now includes/admin/settings-screen.php
  and loads in wp-admin only. Reading a setting, cleaning a submitted one and
  the spacing class on the body stayed where they were.
* includes/i18n.php was 4,543 lines, nearly all of it three tables. Each is now
  a data file loaded on first use, and the built-in French, Spanish and Arabic
  tables are not opened at all on a site serving English.
* includes/luxury/composer/schema.php was 3,763 lines of two arrays that are
  only read when somebody is editing or rendering a Luxury Journey. Same
  treatment, same values.
* includes/home/render.php kept the dozen functions other modules call and
  moved its thirty-six section renderers into render-sections.php, which
  Home\section() loads before it dispatches. A tour page, an archive or a
  Luxury journey no longer parses two thousand lines of homepage.

Booking and security
--------------------

* Rate limiting is atomic where it can be. On a site with a persistent object
  cache the counter is incremented in the cache rather than read, incremented
  in PHP and written back, so a burst of parallel submissions is counted once
  per submission instead of once for the burst. On a site without one — the
  default WordPress install — the transient path is unchanged, deliberately:
  wp_cache_incr() on the non-persistent cache forgets everything at the end of
  the request, which would switch metering off on exactly the sites that have
  no other protection.
* A new Advanced setting names where the client address is read from:
  REMOTE_ADDR (the default), Cloudflare's CF-Connecting-IP, or X-Forwarded-For.
  Only REMOTE_ADDR is trusted unless an administrator says otherwise, because a
  forwarded header is a value the sender chooses; a site that is not behind the
  proxy would be handing every script an unlimited allowance. Whichever is
  chosen, the value is validated as an IP address and falls back to the
  connection if it is not one, and the left-most entry of a forwarded list is
  the one used. The booking form, the date lookup, the price calculator, the
  guest pass actions and the quote page now all read the same answer.
* The schema installer is held to one attempt an hour when there is work to
  do. It was running unguarded on admin_init, which on a site whose database
  user may not CREATE TABLE meant dbDelta over seven tables on every single
  wp-admin request. The front-end path already had that guard; both now share
  it. table_exists() is also answered once per table per request instead of
  once per call.
* Every REST route, AJAX handler and admin-post action was reviewed for its
  nonce, its capability, its sanitising, its escaping and its use of prepared
  statements. No missing check was found. Two routes are documented where they
  were not: the Luxury content search states which screens it serves and why it
  asks for the lower of their two capabilities, and the import endpoint states
  that it stays on manage_options.

Assets and build
----------------

* booking.js, group-pricing.js and booking-panel.css were shipping without
  their minified copies, so the booking panel — the one page where size matters
  most — served the readable files. All three are built.
* bin/verify-build.mjs refuses a release with a missing or stale minified file,
  with a minified file that is not smaller than its source, with a version that
  disagrees between the plugin header, WPTM_VERSION, readme.txt and this file,
  or with any file that does not parse. bin/build-zip.sh will not package
  without it, and CI runs it on every push.
* bin/build-assets.mjs rebuilds every minified file from its source, and CI
  checks that a fresh build changes nothing.

Stylesheets
-----------

The design is unchanged. Every removal below was verified to leave the
computed value of every remaining declaration exactly as it was.

* The itinerary's old timeline component was replaced in 1.36.0 and its CSS was
  never removed: 74 rules and 79 selectors in tour.css that nothing could
  match. Gone, with the two grouped selectors that mentioned it trimmed rather
  than dropped, so the FAQ and gallery rules that shared them are untouched.
* 133 declarations across ten stylesheets were overridden by a later rule with
  the same selector in the same context and could never apply. Gone.
* The homepage and the car hire page guarded their full-width bands with a rule
  using :has(), which a browser without :has() support discards entirely —
  leaving a sideways scrollbar on the two pages most likely to be looked at on
  a phone. The guard is now also stated with the template's own body class,
  with a fallback for browsers that lack overflow: clip.
* One right-to-left fix in home.css: the gap between a guide's category and its
  date was a physical margin-left and sat on the wrong side in Arabic.

One defect found while reading
------------------------------

`get_terms_args` is a filter any plugin on a site may attach to. One that
forces `fields` to `ids` or `names` for its own reasons leaves integers or
strings where the homepage and the car hire page both read `$term->term_id`
and hand the object to a function typed `\WP_Term`. The result is a
TypeError on the front page: a white screen, caused by a plugin that has
never heard of this one. Four call sites now go through one guard,
`tripvance_term_objects()`, which keeps only real terms; the worst case is a
missing tile on a page somebody can still read. Nothing changes on a site
where no such filter exists.

Tests
-----

* A unit suite that needs no database, no WordPress and no Composer: run it
  with `php tests/run.php`. 127 cases over the price calculator and its bands,
  the itinerary shape rules, the booking status map and which statuses hold a
  seat, the rate limiter on both storage layers, the client-address rule, the
  settings sanitiser, the mail retry policy and what it is allowed to store,
  and the diagnostic report's redaction. Each file runs in its own process,
  because this plugin memoises on purpose and a shared process makes a suite
  depend on load order.
* An integration suite for what only a database can answer: the last seat
  contested by two requests, the conditional insert holding when the advisory
  lock is taken away, a transaction rolled back after a throw, the lock
  released however the function leaves, an idempotent retry resolving to the
  first booking, the mail queue's claim, retry, backoff and attempt limit, the
  homepage builder recovering from malformed input, and an upgrade from an
  earlier schema with every tour, reservation and setting intact.
* Neither suite ships. tests/, bin/, .github/, composer.json and the PHPUnit
  configuration are all excluded from the release ZIP by .distignore, and CI
  fails if any of them reaches it.

Release candidate review
------------------------

A second pass over the finished release, fixing only what was wrong.

* uninstall.php said one thing and did another. Its comments claimed the
  settings row "has already gone above" when the code deliberately keeps it,
  named two Luxury flags where three are deleted, and promised to name term
  meta rows that it correctly does not touch. The retention policy is
  unchanged — no table is dropped and nothing the owner typed is deleted —
  but the file now says so accurately, names every option it keeps, and
  states the rule for anybody adding a line to it. A test reads the file and
  checks each promise against the code, so the two cannot drift again.
* System Status told a site with no tables that its indexes were "all
  present". There were none to look at; it now says so, and says how many it
  did check when there were. A memory limit of -1 reads as "no limit" rather
  than as a number, and a site whose address cannot be parsed prints
  "unknown" rather than an empty line.
* The customer directory read the requested sort column twice, once to test
  it against the allowlist and once to use it, with a small difference
  between the two expressions. Both were correct; writing it once removes the
  way that stops being true.
* The build check now covers blocks/ as well as assets/. `minified_src()`
  serves anything under the plugin's own URL, so a stale editor.min.js there
  would have been served to the block editor exactly as a stale stylesheet
  would be to a visitor — and it was not being checked.
* blocks/tours/editor.js is empty, and now says why: the Tours block is
  registered from PHP with every other section block, and block.json beside
  it is the readable record rather than the source of the registration. Kept
  rather than deleted, so nobody has to work it out a third time.

Nothing else was changed. The itinerary, the booking engine, the database
schema, every meta key and every option name are exactly as they were.

Verification
------------

* 202 unit cases, 2,078 assertions, all passing, with no PHP diagnostic of
  any kind emitted by any of them.
* The whole plugin — core, admin and importer, 306 files — loads on PHP 8.4
  under E_ALL with zero warnings, notices or deprecations. No parameter in
  the plugin is implicitly nullable, which is 8.4's own deprecation.
* The itinerary is rendered and read back for every shape that matters:
  multi-day, single-day, a one-day activity, an activity told only in hours,
  an overnight, an untimed running order, and each of Automatic, Days and
  Timeline. Automatic never draws day numbers for a single day or for hours.
* The diagnostic report was generated against a database rigged to answer
  every query with a customer's name, address and telephone number. None of
  it reached the report, because the report is built from counts and
  literals; the same check now ships as a test.
* Every one of the ninety-odd request handlers was re-read for its nonce, its
  capability, its sanitising and its SQL. Nothing was missing. The seven
  public endpoints are now listed by name in a test, each with the reason it
  takes no capability, so an eighth is a decision rather than an omission.
* Every fixed width wider than a 320px phone was traced to the scroller that
  contains it. The mobile booking bar is printed hidden and measured by the
  script, so with JavaScript off there is nothing to cover the page.

Continuous integration
----------------------

PHP 7.4, 8.0, 8.1, 8.2, 8.3 and 8.4: every file parses on each, and the unit
suite passes on each. JavaScript is parsed, the build is verified, a fresh
asset build is checked to change nothing, the ZIP is built and inspected, and
the integration suite runs against MySQL on three PHP and WordPress pairings.
Coding standards run as a report rather than a gate.

= 1.37.0 — Evidence, not a guess; and the cards come off =

Two things 1.36.0 left half done.

The first: its automatic rule still fell back to days. A tour that stated no
duration and whose stops carried no times was read as a run of days, which is
exactly the tour a day trip usually is — somebody fills in the title, the
price and the stops, and never touches the Days field. The rule is now one
sentence, and the asymmetry in it is deliberate: a multi-day trip drawn as a
timeline is a plainer page that still reads correctly, while a day trip drawn
as days tells the visitor they are buying a week and somewhere to sleep. Only
the second is the page stating something untrue, so "days" is never the
fallback — it is claimed on evidence, and on nothing else.

* Days requires one of: more than one day, at least one night, a preset naming more than one day, or an overnight or stay written on a day of the itinerary. Everything else is a timeline, and the Itinerary format setting still overrules all of it.
* The heuristics that read the shape of the itinerary text are gone, and the file is shorter for it. Guessing from how many stops there are, or how many wore a clock, was the part that produced the wrong answer.
* The Visual Builder's `day()` renderer asked nobody. It printed "Day %s" for every row it drew, so a single-day journey composed there numbered its stops as days no matter what the rest of the plugin had decided. It now asks `Itinerary\mode()` like everything else, and a number typed into the row by hand is still honoured.

The second: the itinerary was still a stack of cards, and the new single-day
timeline was not, so one component looked like two.

* A day is no longer paper, a border, a radius and a gap. It is a stretch of the page with a hairline above it, its number on the thread, and its words beside it — one unbroken line from the first day to the last, which is what the timeline already was and what a printed programme looks like.
* The `--steps` variant and its stylesheet are gone. No code path printed that class any more; both shapes are now days or a timeline.

= 1.36.0 — A day trip is not seven days =

The itinerary had one shape: a run of days. That is right for a journey of
a week and wrong for everything that happens between breakfast and dinner,
and a full-day excursion written 08:30, 10:00, 11:30 was therefore
announced to the reader as DAY 01 to DAY 06.

* Two shapes now. Multi-day is unchanged — the numbered badge, the day title, the day's figures. Single-day is a timeline: the hour leads the line, the title follows it, a hairline threads the stops together, and nothing on it is numbered.
* One place decides which. `Tripvance\Itinerary\mode()` answers it for the section template, the Luxury Journey page, the printed sheet, the guest pass and the activity schedule. The Luxury page used to hardcode days, so the same tour could read two ways on two layouts of one site.
* A new optional meta, `wptm_itinerary_mode`, holds a deliberate choice: Automatic, Multi-day trip, Single day / Timeline. Automatic is the default and what every existing tour has; it reads the duration first, and when the tour states none, the shape of the itinerary itself.
* Nothing is migrated, converted or deleted. No stored value changes on update, and a tour saved from the new panel posts exactly the fields the old one did — the two modes were checked field for field.
* The itinerary parser reads "1- 08:30 | Departure from Marrakech" as a numbered stop with a time. It never had: the guard that keeps "2 - 3 hours of walking" and "15 - 20 minutes on foot" inside their sentences also refused this, the form the field's own help text has documented for years. The guard is untouched — the new rule fires only on a real clock followed by a separator, and the FAQ's rules are not involved.
* Progressive disclosure in the panel. A stop leads with its time, a day with its one-line note; the road, the night, the photograph and the named moments sit behind More details. Every field still renders and still posts, so nothing can be lost by not opening a fold.
* Moments can be moved, duplicated and removed with real buttons, and their order is renumbered as they move, so the screen and the page agree.
* A day or a stop can carry one photograph of its own, in place of the gallery picture at that position.

= 1.35.2 — The last warning, and a steadier mark =
* The last Plugin Check warning cleared: the panel flags are read and sanitised in one statement, with the nonce the caller already verified named alongside it.
* An area clicked in the section navigation keeps its mark while the page travels to it. The mark is normally moved by what is on screen, and a smooth scroll crosses two or three boxes on the way.

= 1.35.1 — Plugin Check and palette clean-up =
* Two findings from Plugin Check cleared: the panel flags are sanitised where they are read, and the duplicate action states why it reads the tour id before checking its nonce.
* The last of the WordPress blue inside the Tripvance parts of the tour editor is gone: the section navigation, the readiness list, the switches, the pickers and the gallery controls all use the plugin's own green.
* This file rewritten as a plain list of releases rather than development notes.

= 1.35.0 — Stability and Tour Editor refinement =
* Stability release before publication: no new features, and no change to how anything is stored.
* A saved value a dropdown no longer offers — a layout from a theme that has gone, a value written by an import — is kept instead of being erased on the next save.
* A numbered FAQ question whose text begins with a number is read as a question again. Itineraries are unchanged: a figure inside a line still never starts a day.
* Numbers that count something can no longer be saved below zero.
* Labels on the rich text fields point at the field they name.
* Larger tap targets in the editor: remove a row, remove a photograph, switch a table to text, and every row of the readiness list.
* The section navigation, the readiness list and the editor's own controls use the Tripvance palette throughout.
* Duplicated styling from the previous four releases removed; every rule on the tour screen scoped to that screen.

= 1.34.0 — Tour Editor redesign =
* The tour screen reads as an editor rather than a stack of boxes: warm background, white cards, clear spacing, and a header per card with an icon, a title and a line of explanation.
* Overview becomes two cards — Tour identity (destination, type, hero image, duration, short description) and Trip essentials (group size, difficulty, languages, departure and end point). Content becomes Experience.
* The itinerary and its day cards are one section: the text, then a card per day with route, distances, transport, overnight, meals and named experiences, each opening and closing on its own.
* An Add a day button writes the next numbered day into the itinerary text.
* Pricing opens with what the tour costs, above the fields that decide it.
* Tour media moves into the main column, with the main image named and the gallery given room.
* Layouts are chosen from miniatures instead of a dropdown.

= 1.33.0 — Section navigation moves to the sidebar =
* The section navigation is a card in the sidebar, so nothing on the screen can cover a field while scrolling. Each area shows a tick when its essentials are filled in and a mark when one is missing.
* The sidebar follows the page when it fits on screen and scrolls with the page when it does not; below 851px it becomes ordinary navigation above the fields.
* A bar above the title carries the tour's status, how complete it is, when it was last saved, and Preview, Duplicate, Bookings and Update.
* Every box on the tour screen has a card header; Tour status and Tour readiness become one card.

= 1.32.0 — One editor, pricing tables, duplication =
* Tripvance's own editor is the tour screen on every site; ACF, where installed, is a data layer over the same meta keys. One filter restores the previous arrangement.
* Group rates, optional extras and seasons are tables: a row per band, a column per part, add and remove buttons. The stored lines do not change, and Edit as text is one click away.
* Destination and tour type are fields in the overview panel; the raw custom fields table is removed from the screen.
* A sidebar card shows the tour's picture, destination, duration, price, booking mode, status and completeness; the readiness list names what is missing and each row jumps to its field.
* Tours can be duplicated from the Tours list: content, fields, itinerary and terms are copied into a new draft.

= 1.31.0 — Safer saves in the tour editor =
* A save the server cuts short can no longer undo a tour: each panel ends with a flag, and a panel whose flag did not arrive is left exactly as it was. You are told which areas were dropped, and what to ask your host for.
* Leaving the editor with unsaved changes now warns you.
* A field finder at the top of the editor filters the screen to the fields that match, opening whatever fold or box they are in.
* Ctrl+S, or Cmd+S, saves the tour.

= 1.30.1 — Plugin Check fixes =
* Translator comments added to the party-size strings, and one admin attribute escaped at the point it is printed. No behaviour changed.

= 1.30.0 — Activity page visual pass =
* The activity page is laid out around its photographs: the title, the facts and the picture in one band, with a View photos button on the picture.
* At a glance is one ruled strip; every section carries a line under its heading.
* Highlights and good-to-know notes are cards; what the price covers is two tinted panels.
* The meeting point, cancellation policy, important information and questions stand beside the booking panel on a wide screen.
* A meeting point can carry a photograph.

= 1.29.0 — Activities become full products =
* An activity page now answers what a tour page answers: starting time, group size, languages, pick-up, meeting point, what to bring, cancellation and the questions — each shown only when filled in.
* A new Activity schedule box writes the running order hour by hour, drawn as the same timeline the tour itinerary uses.
* An activity with fixed start times offers them on its booking form, and the chosen time is stored with the request.
* The packing list, the good-to-know note and the FAQ accordion are the tour page's own blocks, borrowed rather than rewritten.

= 1.28.0 — Moroccan Editorial Carousel =
* Three tours across by default, with cards that are never squeezed below a readable width and titles that are never cut.
* The band can be laid on a picture of your own, with the colours you chose becoming a wash over it.

= 1.27.0 — Manual bookings and request sources =
* Bookings can be entered by hand: same reference, same ledger, same board, same voucher, for the requests that arrive by telephone or WhatsApp.
* Every booking records where it came from — a search engine, a social network, a campaign, another website, or direct. No cookie, no third party, and it can be switched off.
* The tour editor gained a navigation card, Collapse all, and the itinerary panel beside its day facts.

= 1.26.0 — Premium itinerary layout =
* The day-by-day section is redrawn: a day badge, a step indicator, an information bar and a timeline of the day's moments, each with its own icon.
* Photographs beside the steps, badges on the moments, and an accordion on a phone.
* Everything remains readable to search engines, and nothing about the itinerary text or its storage changed.

= 1.25.0 — Tour editor navigation, editable footer column =
* The tour edit screen reads as one editor: a navigation card, a readiness card, and one stylesheet across every box. Same metaboxes, same save request, same meta keys.
* The footer's Journeys column is written by the owner rather than generated.

= Earlier releases =

1.24.1 — The Journey Composer admin box dressed to match the rest of the screen.
1.24.0 — A wider Included highlight, tighter experiences, and an admin box that follows the editor.
1.23.1 — The day facts strip drops its frame and joins the day's composition.
1.23.0 — The itinerary and its details become one design, and the tour screen reads in order.
1.22.0 — Structured day facts and experiences, and paragraphs that stay paragraphs.
1.21.0 — Stability release: five audit passes, the three-area editor, and a plugin that deletes nothing you wrote.
1.20.0 — Builder saves that cannot be cut short, a lossless Gutenberg round-trip, and search instead of long catalogues.
1.19.1 — Plugin Check clean: translator comments on every placeholder string.
1.19.0 — A shorter Luxury footer, a mosaic that closes its last row, and a settings screen you can search.
1.18.0 — The footer on every page and edge to edge, and a settings screen with Save always in reach.
1.17.0 — The Luxury footer becomes the foot of an agency's page.
1.16.2 — Fixes a fatal error on the homepage.
1.16.1 — Hardened admin and enquiry input boundaries.
1.16.0 — A cinematic opening that is a film first, and a homepage curated rather than filled.
1.15.0 — Custom HTML anywhere, a framed Our purpose, and a hero that meets the header without moving.
1.13.0 — Tripvance Visual System 2.0: shared admin primitives, operational dashboard, saved-price preview.
1.12.1 — Custom Experiences, landing translations, and Featured Tours refinements.
1.12.0 — A fourth featured-tours layout, offered in two places and drawn by one renderer.
1.11.0 — Three tones for the Luxury Editorial experience, and every colour measured against what is behind it.
1.10.0 — The Luxury Editorial experience gets a homepage of its own, with a film in the hero.
1.9.1 — Stabilising 1.9.0: eight corrections to the Luxury framework, no new features.
1.9.0 — The Luxury Editorial experience: a second presentation of the same catalogue.
1.8.13 — The bookings dashboard rebuilt around the question it is opened to answer.
1.8.12 — Booking response reliability and private-document protection.
1.8.11 — A third Why choose us layout, and square featured photographs shown whole.
1.8.9 — Card grids keep their own height; the sidebar information cards respond.
1.8.8 — Good to know in the column, Need help redrawn, Book via WhatsApp.
1.8.7 — Tour cards redrawn, with a strip of gallery photographs.
1.8.6 — Homepage dialogs centred; coding-standards pass.
1.8.5 — Booking management and Guest Pass preparation.
1.8.0 — Visual Builder 2.0: edit the homepage and every starter page with a live preview.
1.7.7 — Full-width pages, and search-engine tools in the page builder.
1.7.6 — A page builder for every page made from a starter.
1.7.5 — The homepage and Site pages screens rebuilt on one shared framework.
1.7.4 — Car hire settings, rebuilt for an owner with no technical background.
1.7.3 — The navigation language switcher sits on the same baseline as the menu.
1.7.2 — The car hire builder gains a setup checklist and quick navigation.
1.7.0 — Activities become products: an activity can carry its own price and take booking requests.
1.6.1 — Hardening release for pricing, scheduled departures and the operations workflow.
1.6.0 — The operations release: quote, acceptance, payment and voucher inside WordPress.
1.5.0 — The Digital Guest Travel Pass.
1.4.7 — Stability and performance release. No visual change and no change to stored data.
1.4.6 — Travel Guides.
1.4.5 — Homepage and language switcher refinements.
1.4.4 — Language switcher refinements.
1.4.3 — Homepage builder refinements.
1.4.2 — Homepage refinements.
1.4.1 — SEO hardening: Schema defaults, filter URL handling, archive duplication protection.
1.4.0 — Brand identity: colours, typography and presets.
1.3.0 — Homepage refinements.
1.2.0 — Homepage refinements.
1.1.2 — Language switcher refinements.
1.1.1 — Language switcher, visual and mobile refinement.
1.1.0 — The optional language switcher.
1.0.0 — First public release.


== Appendix: release notes as previously published in readme.txt ==

Until 2.7.0 readme.txt carried its own, differently worded copy of the
release history and every upgrade notice since 1.35.2. They are kept here
verbatim so nothing that was published is lost; readme.txt now carries only
the current releases.

=== Changelog (readme.txt, 2.5.1 and earlier) ===

= 2.5.1 =
* Hotfix: Improved stability and responsive layout of the Homepage FAQ section.
* Fixed inconsistent FAQ card heights and layout shifting when displaying answers.
* Added a safe manual upgrade option for Legacy Tours to use the New Tour Editor while preserving existing data and legacy metadata.

= 2.5.0 =
Tripvance 2.5 focuses on administrator workflow, editor stability, UI consistency, draft/publish actions, validation feedback and overall admin polish. It is a refinement release: tours, activities, transfers, bookings, pricing, group rates, quotes and the Guest Pass work as before, product pages keep their design with a finishing pass on the gallery, spacing and review form. One conservative migration runs on upgrade: a migrated itinerary still stored as a single untouched collapsed day is rebuilt from its original text; edited cards are never replaced and the original text is kept.

**Editor layout**

* The tour and activity editors are laid out by the server and by CSS from the first paint: the section navigation is printed in the left column, the section on screen is chosen before the page is drawn, and the boxes of the other sections are marked off as WordPress prints them. The page no longer opens as WordPress's two-column screen and then rearranges itself. Without JavaScript every section is shown and the navigation links jump to each.
* The editor reopens on the section you were working in after a save.
* The 1.x "Tour sections" side card is no longer registered where the section navigation replaces it, so the same controls are not drawn twice.
* Conditional fields, picker chips and the field finder are decided on the server, so nothing moves when the script arrives. The setup and welcome panels are no longer shown above the editor.

**Save, publish and status**

* One action bar that follows the post's status: Save draft and Publish for a new or draft post, Update for a published or scheduled one, Save as pending and Submit for review where WordPress's permissions call for them. Preview, Duplicate, View bookings, Move to draft and Move to trash sit in a More menu and appear only where they apply.
* The buttons press WordPress's own Save Draft, Publish and Update, so nonces, capability checks, autosave and revisions are WordPress's; they also work without JavaScript. Enter in a field still saves as it always did.
* Status is shown in words — New tour, Draft, Pending review, Scheduled (with its date), Published, Private — never "auto-draft".
* The save state reads Not saved yet, Unsaved changes, Saving… or Saved just now / N minutes ago, and only says "saved" after the server has confirmed it. The leave-page warning no longer fires after a save or when nothing changed, and a preview no longer clears it.
* Ctrl/Cmd+S saves a draft as a draft instead of publishing it.
* Duplicate is available for activities as well as tours, always creates a draft, and no longer copies the original's Polylang translation group or its import and restore history. Bookings were never copied and still are not.

**Completion and validation**

* One completion definition: the percentage counts the essentials only; recommended items are listed without holding it down, and optional sections such as FAQ never count against it. Activities now distinguish essential from recommended rows too.
* "N items need attention" opens a short list of actions — Choose a destination, Set a price, Add a hero image — each of which opens the right section and puts the cursor in the field.
* Sections show one set of marks: complete, needs attention, incomplete, must be fixed, or nothing for an optional section.
* Saving a draft is never blocked. Publishing and updating stop only for what would break the page — a missing title, a malformed number or address — with the reasons listed under the bar and marked on the section, never in a browser alert. A first publish without a title is also kept as a draft on the server.

**Consistency and accessibility**

* One icon family (Dashicons) with one size and alignment policy, one button shape with primary, secondary, menu and destructive roles, one chip style, and a shared spacing scale. The admin uses one accent colour across the editor, bookings, operations and transfers.
* Same-named terms in the editor's term lists — one per language, or duplicates left by an import — are shown with their language, parent or slug. No term is merged or deleted.
* Keyboard support for the More menu, the attention list, the section navigation and the field finder; visible focus everywhere; states are never told by colour alone; logical CSS properties for right-to-left screens; no horizontal scrolling at narrow widths.

**Product pages**

* Tour galleries: every photograph is in the thumbnail strip, which scrolls sideways with the next picture showing at its edge and previous/next arrows once there is more to see; the selected thumbnail stays clear and in view. Small sets (two to six photographs) fill the row instead of leaving a gap, with no arrows when everything fits. Activity galleries mark the last frame "+N" when the viewer holds more photographs than the grid shows.
* Product pages: no empty "related tours" block (and its reserved blank space) when there is nothing to suggest; the classic layouts get a content width and gutter on block themes; the "prices checked" date wraps inside a narrow booking card.
* Reviews: the logged-in line reads as one sentence, the rating select is sized to its options, the submit button uses the plugin's primary style, and the summary and form line up with the review list in every layout (full width on tablets and phones).
* Itinerary and FAQ: text pasted from Word, Google Docs, AI tools or other plugins no longer collapses into one day or one question. Numbered days or questions run together in one paragraph ("2- Day two" or "2-Day two") are separated when the numbering is unambiguous, a day recovered that way as "Title / Description" keeps a short title, and an ordered list keeps its sequence; ranges, times, dates and prices never open a day, and bullet lists stay inside their answer. Content that already read correctly is read exactly as before. Migrated tours whose single collapsed day was never edited are rebuilt from their original text once, on upgrade; edited cards are never replaced and the original text is kept.

**Structured data and SEO**

* Tour and Activity pages: Tripvance adds at most one Product, describing that tour or activity only — name, URL, image and short description where the page has them, the bookable price as one Offer at the public "From" price (the fixed price, or the lowest valid Group Rate), and the aggregate of approved on-site reviews where the page shows it. Prices come from the same pricing code as the page and the booking engine; tours on request, incomplete rate tables and seasonal discounts get no price. No availability, SKU, brand, shipping or return data is invented.
* With Rank Math active the data goes into Rank Math's graph: a Product Rank Math already builds for the page is enriched in place, otherwise one Product is added to that graph. Nothing else in the graph is changed. Without Rank Math one JSON-LD Product is printed.
* Removed: the homepage graph (TravelAgency, WebSite, WebPage, FAQPage, ItemList) and its setting, and the ratings and individual reviews previously added to Rank Math Service, TouristTrip or Trip entities. Legacy settings are preserved. The old rating_schema value is read only to preserve an existing rating opt-out until the new product_rating preference is saved.
* The "Stars in search results" setting is now "Search result product data", on by default, with a separate "Include the average rating from approved reviews" option. A site that had switched the old rating option off keeps ratings off; the old value is kept. It makes pages eligible for rich results; search engines decide what is shown.
* Rank Math sitemaps: the empty-terms switch is no longer forced on for every taxonomy on the site; only empty Tripvance terms are left out.
* The page builder no longer writes or clears Rank Math's SEO title, description and focus keyword; with Rank Math active they are shown read-only and edited in Rank Math.

= 2.1.0 =
Transfers with reusable Vehicle Types, Fleet links in Operations, transfer-aware booking documents, and an optional Privacy & Cookies module. Tours, activities, group rates and their pricing are unchanged, and nothing is migrated: existing bookings and settings keep working.

**Transfers**

* New Transfers post type (`wptm_transfer`, /transfers/{slug}/): route, pickup and drop-off types, estimated duration, distance, operating hours, minimum booking notice, pickup and meeting instructions, inclusions, important information, cancellation and gallery. An optional transfer archive (Settings), off by default. No schema is printed; the SEO plugin owns the markup.
* Vehicle Types (`wptm_vehicle_profile`): reusable customer-facing vehicle options with category, class, passenger and luggage capacity, features and a picture. Private data, not public pages.
* Transfer pricing: each transfer sets its own price per Vehicle Type. A round trip is two journeys at the same price; there are no distance, surcharge or discount rules. One pricing service feeds the page, the server-side check and the price stored with the booking.
* Transfer booking form: Vehicle Type cards, one way or round trip, pickup date and time, passengers, luggage, optional flight details and addresses. Passenger capacity is checked in the browser and on the server; minimum notice is enforced on the server.
* A transfer request is an ordinary booking record (same statuses, history, reservation ledger, messages and confirmation), marked `_wptm_booking_type = transfer`, with its facts under `_wptm_trf_*` and its price snapshot stored once.
* The price snapshot stored with a transfer booking is worded in the booking's language (one way, round trip); amounts are unchanged and earlier bookings are not rewritten.
* The WhatsApp follow-up after a transfer request carries the transfer's facts — reference, transfer, route, pickup and drop-off, pickup date and time, passengers, Vehicle Type and return — to the site's WhatsApp number. Tours and activities keep their message.
* Homepage: a new Transfers section, off until ticked in Page layout. Latest transfers or transfers chosen and ordered by hand, a heading and a line, and cards with the route, Vehicle Types, capacity and the "from" price per vehicle from the transfer pricing service, linking to each transfer page. Existing homepages render unchanged.

**Operations and Booking Admin**

* Operations → Vehicles is now labelled Fleet, and Transfers → Vehicle Types holds the customer-facing options. Post types and meta keys are unchanged.
* A fleet vehicle can be linked to a Vehicle Type, whose capacity and class it then reads (an existing seat figure still wins). Optional brand, model and operational status (Available, Assigned, Unavailable, Maintenance).
* Assigning a fleet vehicle to a transfer: a warning when it does not match the requested Vehicle Type or is Unavailable or in Maintenance; refused when it cannot carry the passengers. The requested Vehicle Type is never overwritten.
* Transfer bookings show a Transportation block (route, requested Vehicle Type, passengers, luggage, assigned fleet vehicle, driver), a booking type filter in the list, and Trip details with pickup date, pickup time and one Passengers field kept equal to the reservation ledger.
* Quotes, the Guest Pass, the voucher, check-in, the operations board and the personal-data export read transfer bookings in transfer terms, and quotes start from the price stored with the booking rather than tour rates. The Guest Pass and quotes no longer read a transfer as a tour.

**Privacy & Cookies**

* Optional consent banner, off by default and loading nothing while off: Tripvance → Settings → Privacy & Cookies. Three layouts, four categories (Necessary, Analytics, Marketing, Preferences), editable wording, RTL and keyboard support.
* Scripts placed in the Analytics, Marketing and Preferences boxes, or tagged `type="text/plain" data-wptm-consent="…"`, run only after their category is accepted. Consent is kept in one first-party cookie, `wptm_consent`.
* Campaign attribution (`wptm_attr`) now honours the Analytics choice made in Privacy & Cookies. When a consent manager runs the WordPress Consent API in opt-in mode, its statistics answer is final and a refusal there is never overridden by Privacy & Cookies; the `wptm_attribution_consent` filter still has the last word. Without consent it stays off.
* The module helps collect and honour consent; it does not by itself make a site legally compliant.

**Display**

* Homepage: the hero no longer jumps under the theme header, spacing in rails and lists is restored, and search fields have a 44px touch target.
* Tour and activity pages: the price, its basis and the group-rate note read as one phrase; phone bar and quick-fact layout fixes. Display only.

= 2.0.1 =
Visual refinement of the tour page and the booking screens. No change to the pricing engine, the booking workflow, the stored data or the meta keys.

* Tour header: title, place, rating and the starting price read as one opening; the quick facts sit under the gallery as one strip.
* Gallery: the picture takes the reading column on a desktop, the thumbnails are an even strip, and a "View all photos" button on the picture opens the viewer, which now shows where you are in the set ("3 / 8") and follows the reading direction.
* Booking panel: reads in the order of the decision — From price, date and travellers, the rate for that party, extras, the estimated total, then the button — with the site's own reassurances and reply time under the button. Every figure still comes from the server.
* Fix: optional extras in the two-step form were drawn as large empty boxes with upper-case labels; they are real checkboxes with their price.
* Fix: a form reopened at step two after an error did not fill its summary.
* Phone bar: more compact, safe-area aware, and hidden while a dialog or the photo viewer is open.
* Sections: one spacing and type scale for Overview, Itinerary (multi-day thread and timed day timeline), Included / Not included, Important information, questions, reviews and related tours.
* Reviews: a summary card with the average as stars, and each review as a card with the name, date and stars.
* Fix: a single related tour grew to the width of the page; the rate table on SalmaDesign split its header on phones; sections below the booking panel carried a double gutter on phones; the page ran edge to edge on block themes.
* Bookings list: a Total column (accepted quote, else the price worked out with the request), status views with counts, a "Move to" menu for a safe status change from the list (same transitions, history and hooks as the booking screen), and a readable layout on phones.
* Booking screen: the request is set out as Booking, Trip, Customer, Pricing (line by line from the stored price snapshot) and Notes, each reachable from the header.
* Fix: group-rate input uses one decimal parser for dot/comma decimals and grouped thousands; displayed tiers retain cents.
* Admin: non-blocking warnings for group-rate gaps, overlaps, reversed ranges and invalid prices; saved values and engine selection rules are unchanged.
* Reviews: full, true half and empty SVG stars use the existing icon set, with RTL and accessible rating text.

= 2.0.0 =
Tours and Activities become one Experience Builder, while staying two post types with their own URLs and designs. No meta key moves and nothing is migrated.

* Experience Core: one internal API for the fields tours and activities share (media, pricing, itinerary, inclusions, booking, FAQ, important information, sections, presentation), mapped onto each type's existing meta keys.
* Experience Editor: Basics, Media, Pricing, Itinerary, Inclusions, Booking, FAQ, Additional information and Publish, with a left navigation, one section at a time and marks on incomplete sections. Same form, nonces and save as before. Posts written with blocks keep the block editor; a setting turns the editor off.
* Itinerary: multi-day, single-day timeline, or a new simple route (Marrakech → Atlas Mountains → Ourika). Day cards are kept when switching.
* Pricing: booking forms show how the total is reached ("300 MAD × 4 travelers = 1,200 MAD", or group price and total), from the server's quote. The editor previews it.
* Booking requests: children, hotel / pick-up, preferred language and notes on every form; Pending is now labelled New. Statuses, the ledger and Guest Passes are unchanged.
* Section manager: show, hide and reorder a page's sections, per tour or activity.
* Media: optional mobile hero image (served with picture, no cropping), Select / Replace / Remove pickers, video on activities.
* Layouts: Adventure joins Classic and Editorial for tours; activities gain the same three, with a site default. Theme fonts are always inherited.
* Homepage: Travel Agency, Local Guide, Luxury Operator and Adventure Company presets. Presets never change content.
* Admin: a new minimal Tripvance menu icon (an SVG "T" that follows the admin colour scheme).
* Onboarding: the welcome offers Create your first Tour, Create an Activity, Configure Booking and Build Homepage, and is not shown to sites that already have tours.
* Tailor-Made Requests: a multi-step Trip Builder (shortcode, block or assigned page) with configurable steps and answers, private TMR-numbered requests with six statuses, agency and traveller emails, Create Draft Tour, and an optional tour-page call to action (off by default). No existing meta key, address or setting changes, and no page is created automatically.

= 1.42.1 =
Why Activity pages could render unstyled, and the Activity page on a phone. Nothing on a tour page changes, no meta key moves, and every activity, price, gallery and booking is where it was.

* The Activity stylesheet reaches the page. It was enqueued with two other handles named as dependencies, and WordPress prints nothing at all — with no notice and nothing in the log — when a named dependency is not registered. One handle removed by another plugin and the page shipped its full markup with no stylesheet. Both handles are now registered at the point of use and a handle that still cannot be found is dropped rather than allowed to take the page's stylesheet down with it.
* The page no longer depends on a second file for its appearance. Every panel, card, cell rule and tinted block on it is drawn with custom properties declared in tokens.css, and a custom property with no value invalidates the whole declaration that reads it — so losing that one file left At a glance with no box, Highlights with no card and the panels with square corners, which is the page looking unstyled. activity.css now carries its own floor for those values, at zero specificity, so tokens.css, the site accent and the unified brand all still win when they are there.
* Assets are versioned by the file actually served. The version string stamped the readable source while the browser was given the .min built from it, so rebuilding the .min without touching its source changed the bytes at an address that had not changed and every cache kept the old copy. A .min that is empty or a tenth the size of its source — an interrupted build — is no longer served at all.
* The Activity hero starts below the theme's header. A fixed header is painted over the top of the banner, and on a phone the breadcrumbs and part of the line above the title were underneath it. The overlap is measured from whatever is actually fixed or sticky at the top of the window, including the admin bar, so no theme's height is written into the stylesheet and a theme whose header scrolls away is unchanged.
* The Activity page on a phone. One step tighter on the spacing scale the whole page is set with, reading type at phone proportions, At a glance as compact cells with no empty one at the end of a short row, the gallery two across instead of twelve full-width frames, the meeting point stacked so an address is not four lines of two words, and the running order's clock column at the width of its badge rather than a tour's.
* What to bring, Important information and the questions are headed like the rest of the page. They are drawn by the tour page's section templates, whose title sets no colour, so they inherited the body grey and stood beside navy headings at a different size over a shorter accent rule.
* The mobile price bar cannot clip or cover. Its height is derived from its own parts and the safe area, and the page reserves exactly that much; the price never wraps or is cut, and a long button label ends in an ellipsis instead of past the card's edge. The button's label also stopped being given the icon's line-height — an old first-child selector that matched the label on the one button with no icon.

= 1.42.0 =
The Activity page and a way out of a tour that should have been an activity. Nothing on a tour page changes, no meta key moves, and every tour, activity, price, itinerary and booking is where it was.

* The Activity hero is full width. The band reaches both edges of the screen while its words stay in the same column as the reading below them, and it sits against the site header instead of under a band of white. The photograph keeps its own proportions rather than being stretched to the height of the text beside it.
* The booking rail holds the booking card and nothing else. Meeting point, cancellation, important information and the questions were being drawn in a three-hundred-and-seventy pixel column beside the form; they are sections of the reading column now, at its full width, each drawn once. The card follows the reader on a wide screen and stacks normally on a phone.
* One spacing scale for the page. Six different margins between sections became three custom properties, and a section heading now sits closer to what it introduces than to the section above it.
* At a glance carries every fact, including when the activity runs, which had a card of its own. The strip wraps into counted columns instead of forcing every fact onto one row, so labels are read at their proper size rather than shrunk to fit.
* The gallery opens. "View photos" and every thumbnail open a viewer with the full-size picture, a counter, previous and next, Escape, arrow keys and a backdrop that closes it; the page behind does not scroll and focus returns where it was. Without JavaScript each frame is still a link to its own picture.
* Tour to Activity conversion. An unpublished tour can be copied into a new draft activity from the tours list or the Publish box: title, content, excerpt, featured image, gallery, destination and place terms, and every commercial and experience field that both post types share. The tour is never modified and no media is duplicated.
* The itinerary is converted rather than copied. A tour keeps its stops as days and an activity keeps them as moments of one occasion, so a copied row arrived as an empty schedule. Stops are now read through the itinerary layer's own reader — structured, legacy text, or both — and written as schedule rows in order, with their times, titles, descriptions, locations and photographs. Anything with no field of its own is carried into the description rather than dropped, and whatever cannot be mapped is named in a notice on the new activity.
* Description and answer paragraphs are justified and set at a reading measure, ragged on a narrow column, inheriting the theme's own type.

= Earlier releases =

In brief below. The full notes for every release, including these, are in
changelog.txt, which ships with the plugin.

* **1.41.0** — The tour editor answers as you type: a live status card that says the booking mode in words, completion counted on the six things a tour needs, and field search that reaches the itinerary.
* **1.40.2** — A styling pass over the itinerary: the thread runs to the last moment of the last day, and the descriptions are set at a reading size.
* **1.40.1** — A migration fix. Activation recorded the itinerary migration as finished before it had started; stranded sites are picked up automatically.
* **1.40.0** — The itinerary is written on cards. Existing tours move across automatically, and the old wptm_itinerary field is kept as the backup.
* **1.39.0** — An editor release: days and stops appear as editable cards straight away, and Activities use the same editor as Tours.
* **1.38.0** — A stability release: less loaded on a front-end request, a correct rate limiter under load, a System Status screen and a test suite.
* **1.37.0** — A tour is drawn day by day only when it says it lasts more than one day, so day trips are no longer announced as DAY 01, DAY 02.
* **1.36.0** — Day trips stop being announced as a run of days: a one-day itinerary reads as a timeline of times and stops on every layout.
* **1.35.2** — A stability release. Tours written by any earlier version open, save and render exactly as they did.

=== Upgrade notices (readme.txt, 2.5.0 and earlier) ===

= 2.5.0 =
Admin editor polish (stable layout, status-aware Save/Publish bar, clearer validation) and a finishing pass on tour and activity pages. Pasted itineraries and FAQs no longer collapse; a one-time repair rebuilds untouched collapsed itineraries. No pricing or booking changes.

= 2.1.0 =
Adds Transfers with reusable Vehicle Types, Fleet links in Operations, and an optional Privacy & Cookies module. Tours, activities, bookings and their pricing keep working; nothing is migrated.

= 2.0.1 =
A clearer tour page and booking panel, a fix for the optional extras in the two-step form, and a tidier bookings list with totals, status views and a quick status change. No data, pricing or booking changes.

= 2.0.0 =
Experience Editor, route itineraries, live price breakdowns, a section manager, Adventure layout and a Tailor-Made Trip Builder with private requests. Nothing is migrated: tours, activities, URLs, bookings and Guest Passes keep working.

= 1.42.1 =
Take this first if Activity pages look unstyled: the stylesheet could be silently not printed, and its panels lost their appearance when a second file was missing. Both fixed. The hero no longer starts under a fixed header, and the page is retuned for phones. No Tour or data changes.

= 1.42.0 =
The Activity page, rebuilt around its reading column: a full-width hero, one glance strip, and the meeting point, cancellation and questions moved out of the booking rail. The gallery opens in a viewer. Tours can be converted into draft Activities without retyping. No Tour output changes.

= 1.41.0 =
A tour editor pass. The status card printed the raw booking mode and answered from the last save; both are fixed, and it now follows the form as you type. Completion counts the six things a tour genuinely needs. No stored value, meta key or front-end output changes.

= 1.40.2 =
A styling pass over the itinerary. The thread now runs to the last moment of the last day instead of stopping short, and descriptions are set at a reading size: justified on a wide screen, ragged on a phone. No markup, colour, stored value or behaviour changes.

= 1.40.1 =
A migration fix, and the reason to take this first if you are on 1.40.0. Activation recorded the itinerary migration as finished before it had started, so some sites kept their itineraries in the old text field. Those sites are picked up and finished automatically.

= 1.40.0 =
The itinerary is written on cards: add a day, type a title and a description. Existing tours move across automatically on the first admin page load, and the old wptm_itinerary field is kept untouched as the backup. Nothing on the front of the site changes.

= 1.39.0 =
An editor release. A day or a stop is added as an editable card straight away, the completeness meter answers as you type, and Activities now use the same editor as Tours. Nothing on the front of the site changes and no meta key moves.

= 1.38.0 =
A stability release. No feature or stored value changes. Underneath: less loaded on a front-end request, a rate limiter that counts correctly under concurrent submissions, a new System Status screen, and a test suite behind the booking engine.

= 1.37.0 =
Finishes what 1.36.0 started. A tour is drawn day by day only when it says it lasts more than one day, so day trips are no longer announced as DAY 01, DAY 02. The itinerary is now one thread down the page. No stored value changes.

= 1.36.0 =
Day trips stop being announced as a run of days. A one-day itinerary reads as a timeline of times and stops on every layout, including Luxury. Nothing is converted or deleted, and an Itinerary format setting covers the tours the rule gets wrong.

= 1.35.2 =
A stability release. Nothing about how tours are stored has changed, and tours written by any earlier version open, save and render exactly as they did. Recommended before updating anything else.


Release notes as they appeared in readme.txt (2.8.8 and earlier)
==================================================================

Moved here in 2.8.9 so readme.txt stays within the 5,000 characters the plugin directory reads; nothing was removed.

= 2.8.8 =
Booking workspace on the edit-booking screen. No table, option, meta key, price, status rule, message or document behaviour is changed.

* Summary from saved data: customer contacts, product, date and time, travellers, the price named after its source (accepted quote, revision, price at request), payment figures only when the ledger exists, and the latest quote's state.
* Next action: one step from simple rules — review a request, follow up a quote, review an accepted quote before confirming, set the date, add the meeting time or place, assign a driver or vehicle for a transfer, mark a past trip completed — plus short notes (crew clash, balance due, email not delivered). It only opens the panel.
* Five sections (Customer & request, Price & quote, Trip preparation, Messages/documents/Guest Pass, History & notes) plus All panels; invalid fields, action results and links open the right section. Without JavaScript all panels stay on the page.
* Unsaved-changes warning, action buttons marked and locked after the first click, and one timeline of status, quote, document, payment, crew, email and WhatsApp events.

= 2.8.7 =
Settings screen reorganised, and stability fixes found by a full pass over the admin screens, the front end in every tour layout, both experiences and right-to-left. No setting, option, table, price or booking behaviour is changed.

* Settings screen reorganised: one section navigation instead of two — a column beside the settings on a wide screen, grouped under Core, Booking, Content & pages, Public & localization, Setup & integration and Advanced, with "Find a setting" at its top; on a tablet or phone the same list opens from the section button in the toolbar. The sections follow that order on the page, and the content uses the width the navigation used to leave empty. One Save button, in a slim toolbar that stays in reach without covering the section headings it scrolls to. Nothing about the settings themselves changed: the same fields, names, values, defaults and save.
* Fix: Settings printed a PHP warning ("Undefined array key 0") in Section pages when a hierarchical section had terms without tours. The example link now uses the first term returned, whatever its key.
* Fix: System status was laid out as one row, because its wrapper shared the class of the status badge; the diagnostic report was squeezed beside the cards and the page scrolled sideways. The report now sits below the cards at full width.
* Phones: the Site pages list and the Section pages table in Settings scroll inside their own region instead of widening the screen, and the Site pages actions wrap.
* Code: the WPML hooks used for translation are marked as WPML's own for Plugin Check, a file-scope loop variable carries the plugin prefix, and the rate-table cache key is built with wp_json_encode() instead of serialize(). The booking screen's stylesheet and script now ship with minified copies like every other asset.
* readme: the 2.8.3 upgrade notice is shortened to fit the 300-character limit.

= 2.8.6 =
Visual polish of the booking screens, the Guest Pass and the PDF documents. No change to how bookings, prices, seats, notifications, documents or the Guest Pass work; no data, option or table is changed.

* Add booking: the agreed total and its currency sit together in one highlighted panel, the amount larger and the currency a narrow code; status and "Send the usual emails" are each a tile, the ticked one marked; more space between rows and their descriptions; the button fills the width on a phone. The error list is indented on the correct side in right-to-left admin.
* Booking screen on a phone: the page no longer scrolls sideways. The Guest Pass map-pin pair and the Trip content image and itinerary fieldsets now shrink to the screen.
* Money fields: "Total to offer" and "Deposit asked for" (Quote) and "Amount" (Payments) are wide enough to show the full figure, with the currency beside them; the balance in Payments is larger; the invoice card shows "Not issued yet" / "Issued as …" as a small label.
* Right to left: no letter-spacing on Arabic small-capital headings in the booking screens, or on the Guest Pass reference label, status pill and payment terms.
* Trip programme PDF: the "Itinerary" heading is kept on the same page as the first day instead of being left alone at the foot of a page.
* "On this page" bar (Bookings dashboard, Operations, Settings): it now sticks directly under the WordPress toolbar at the toolbar's real height (32px, 46px on narrow screens) instead of a fixed 46px that left a strip of content showing above it, and it stays one row that scrolls sideways instead of wrapping into a two- or three-row bar (up to 99px) over the content. Headings it links to land below it. On phones it is not sticky.
* Edit booking, shorter: for anyone who has not arranged the screen themselves, the panels follow the work — request, trip, reception, crew, price, payments, quote, documents, trip content, messages, notes — and the less used ones (Guest Pass details, crew, quote, trip content, messages, WhatsApp, referral, voucher, history) start closed. A closed panel shows a one-line summary of what it holds in its heading, marked when something was typed in it before closing. Fields in a closed panel are still saved; the jump links open the panel they point to. Opening or closing any panel keeps that person's own choice, as WordPress always does. The header adds the received amount and the balance beside the agreed total; on a phone its document buttons sit two to a row.
* Booking screen: a screen-reader label inside the documents table no longer widens the page on a phone; the internal notes panel is tinted and edged so it reads apart from what the guest sees.

= 2.8.5 =
Booking documents: a price set for one booking (revisions with a reason, never over the original), the booking's own trip content, and real PDF quotes, invoices and trip programmes — numbered at issue, frozen, shareable on the Guest Pass. Manual bookings for tours, activities, transfers and custom trips. Schema 6 adds two tables; nothing existing is migrated or repriced.

* Price & services (booking screen): a line editor — service, quantity, unit price, line total — with a discount (amount or percentage), an optional tax line and the currency. Totals are worked out on the server in integer minor units; the browser's running total is a preview. A price of zero and a price on request are kept apart. Each save is a revision stored with the booking (`_wptm_price_revision`, one meta row each, append-only) carrying who, when, the reason (required), the previous figure, its source and the difference; the snapshot taken when the request arrived is never touched, and the product, its rates and its group prices are never written. Payments already recorded fix the currency: a revision in another currency is refused. A revision records no payment, moves no status and sends nothing.
* One money authority: `Payments\expected()` now reads the latest agreement — a revision, or a quote the customer accepted after it — before the snapshot, so the list, the header, the payments panel, the Guest Pass, quotes and invoices print the same figure. A total below what was paid shows "paid in excess" instead of hiding it; nothing is refunded automatically. A quote drafted after a revision starts from the revised lines (`wptm_quote_pricing_context`, priority 5).
* Imported prices never double: an engine line is read as quantity × unit only where that reproduces its own total. A group price ("Private tour, 4 travellers, 660") imports as 1 × 660. The basis (per person, per group, per vehicle) is shown.
* Exact parsing: `Payments\to_minor()` converts the decimal on its digits (`Payments\decimal_to_minor()`), not through a float. What it accepts is unchanged.
* Trip content for this booking: an explicit "Make an editable copy" stores title, description, chosen images, itinerary (keeping days, timeline or route), included / not included, meeting point, time and instructions on the booking (`_wptm_trip_copy`). Opening a booking never copies anything; editing the copy never writes to the product; editing the product never reaches the copy. The Guest Pass prints the copy's title and itinerary when there is one.
* Documents: quotes (from Quotes versions), invoices and trip programmes, previewed as DRAFT without a number and issued with one. New table `wptm_documents` (schema 6): the issued content is frozen as JSON — company, customer, lines, totals and the words in the booking's language — with its SHA-256; an altered row is never served. Numbers are allocated at issue only, from the rows (MAX + 1 per type and series), under a UNIQUE (type, series, number, revision) key that refuses a duplicate under concurrent requests and retries. A correction is a revision with the same number; the previous one is kept, marked superseded. Issuing confirms nothing and records no payment. The PDF is rendered once before a number is taken: a layout that cannot be produced issues nothing and uses no number.
* PDF: real PDF files from the bundled TCPDF 6.11.4 (LGPL-3.0-or-later, `vendor/tcpdf`, trimmed to the engine and DejaVu Sans; loaded only when a PDF is produced; no Composer, no external service). Arabic is shaped and laid out right to left; references, amounts, telephone numbers and Latin-only lines keep their direction. Images come only from the media library, checked to be inside the uploads folder; WebP and other formats are converted to a temporary JPEG through WordPress's image editor. Page numbers, a running header, the company footer, and headings kept with what follows them. Every text is escaped before layout.
* Access: admin downloads need the booking capability and a nonce. A customer can download a document only when it is issued, current, intact and shared, with their Guest Pass key (revoked or regenerated keys stop working at once) or, for a quote, that quote's own key. The Guest Pass lists shared documents; the quote page offers its shared PDF. No file is written to the uploads folder and no address works without a key.
* Company details (Settings): trading and legal name, logo, address, contacts, free identifier rows (ICE, RC, IF, VAT…), footer and short terms per language, numbering (prefixes, year, digits, starting numbers), defaults for payment instructions and programme prices, and an optional tax line (off by default; rate in basis points). Empty fields fall back to the values the site already has (Guest Pass name and logo, support telephone and e-mail, WhatsApp, site address). Payment details stay on the Payments screen and are printed only when chosen.
* Add booking: a tour or activity, a transfer (vehicle and trip type, priced per vehicle) or a custom trip with no product (holds no seats). The product's price for the party is stored as the booking's snapshot, as a request from the site stores it; an optional agreed total is recorded as a revision. Source "Other" needs a description. Values are kept when a field is refused.
* Fix: a booking entered by hand never held its seats. Its ledger key was 43 characters for a 40-character column, so `$wpdb` refused the row; it is now 39. Capacity is respected again for manual bookings with a date.
* Fix: the Guest Pass showed only the old typed payment fields to guests: the ledger summary was registered on wp-admin requests only. It now loads with the pass, and "paid" is what is held (received less refunded).
* Bookings list: Trip, Source and "Total · paid · due" columns (Guests and Submitted are under Screen Options), filters by source and travel-date range, a custom trip's own title, and an empty state that says what to do.
* Booking screen: the header carries the Quote, Invoice and Programme PDF buttons (the current issued file, or the Documents panel), the jump links follow the order a booking is handled, and the default panel order matches it until the user rearranges. The payments form is one readable column.
* Front-end request form: a summary line at the top when a field below was refused.
* Debug notice: the Luxury section registry is attached on `init` instead of at load, so WordPress 6.7+ no longer reports a translation read too early.
* Erasure keeps issued invoices (accounting records) and removes quote and programme documents of the erased booking.
* Issued PDFs are kept. After the number is allocated the document is rendered with it and the file stored in `wptm_document_files` (database only, base64 with a SHA-256 checked on every read; nothing in uploads or the media library). The row stays "pending" — not current, not shared, not shown to the customer — until the file is read back intact; only then is it issued and the revision it replaces marked superseded. Every download, admin or customer (same capability, nonce and key checks), is that file, so later changes to images, logo, company details or the product do not reach it. A render or storage failure leaves the numbered row "Not issued — PDF not stored" with "Finish issuing", which reuses the row and the number; issuing again meanwhile is refused. Deleting a booking or erasing it removes the files with the documents. A document issued by an earlier 2.8.5 build has no stored file: it is drawn again from its frozen text with the pictures as they are now (said so in the list) and its row is not changed.
* Fix (Add booking): an agreed total that could not be saved no longer lets the booking be confirmed or announced. The booking is kept, stays pending, queues nothing, and quotes and invoices are refused until the price is saved; the booking screen shows the amount entered and "Save the agreed price and finish". The retry runs on the same booking under a lock and reads the table first, so a revision whose write reported a failure but held is not written twice. 0 is a price. A booking finished later by recovery follows the same rule. The confirmation is checked in the table, not taken from `set_status()`'s answer: if it did not hold, nothing is queued, what is left stays on the booking and the retry resumes there; a booking already confirmed is not transitioned again, and one a person moved elsewhere (cancelled, declined…) keeps that decision. In recovery, a booking that is saved but not finished is not marked done: its entry is kept and the next run resumes after the effects, without binning it or releasing its seats.
* Fix (Add booking): "Notify" now covers the status change too. Unchecked, confirming queues nothing (`Mail\held_back()`: one booking, this request only, released in `finally`; every other status listener still runs). Checked, the messages are queued once, after the booking and its price are saved. Later changes made on the booking behave as before.
* Fix (Add booking): with seat limits enforced, any failure to hold the seats refuses the booking, not only "full", as the website form does. Nothing is written and what was typed is kept. Without enforcement, transfers and custom trips are unchanged.
* Fix (recovery): `settle()` compares the saved values with those submitted; a missing row is incomplete as before, a different value is unknown (kept for the administrator notice, not binned). A record that stopped mid-save with nothing to compare against is unknown instead of complete. Seats are checked on the ledger row. The settling lock (`wptm_booking_settling_{id}`) lapses after five minutes and only its owner releases it; the 2.8.3 claim that could stall a booking for ever is ignored. Progress after the save is recorded (`_wptm_settle_stage`), so a stop after "complete" still queues the messages; a stop inside the `wptm_booking_created` listeners does not run them again (logged). Waiting bookings are one option row each (`wptm_booking_unverified_{id}`), so concurrent writers lose none; the 2.8.3 list is moved on first read.
* Fix (prices): `Pricing\snapshot_state()` no longer accepts an existing snapshot on presence. It must be one readable row with its total and currency agreeing with the expected snapshot; otherwise the new answer `invalid` (unknown, never complete or binned). Missing or wrong report copies (`_wptm_price_total_minor`, `_wptm_price_currency`) are written again from the stored snapshot, never from current rates; the snapshot is never replaced.

= 2.8.3 =
Stabilisation: bookings, prices and notifications are confirmed as stored before success is announced; Guest Pass map priority. No schema, URL, token, QR, permission or pricing-formula change; nothing is migrated or repriced.

* Bookings: `Booking\create()` reads the required values back from the table (uncached) and compares them with what was written — reference, customer name, e-mail and phone, product, date and start time, departure, party, status, the price snapshot when one was worked out, and the reservation link — instead of trusting `wp_insert_post()` or `update_post_meta()`'s return. Three outcomes are kept apart. Complete: the booking carries `_wptm_save_state = complete` (itself verified), the ledger is joined and `wptm_booking_created` fires. Confirmed incomplete: the post is moved to the bin and the move is confirmed in the table before `create()` returns `wptm_booking_incomplete`; only then do tour, activity, transfer and manual callers release the seats; if the move cannot be confirmed the outcome is unknown. Unknown (the verification read failed): `create()` returns `wptm_booking_unverified`; nothing is binned, deleted or released, the ledger row and the reservation link stay with the request so a resend waits for this booking instead of creating another, the visitor sees the translated "still being recorded" message with their answers kept (the manual form says to check the Bookings list first), and the booking is recorded in `wptm_booking_unverified`. The queue runner (loop back, cron, hourly sweep, admin fallback) settles it with `Booking\recover_unverified()`: complete → the save state, ledger join, history, `wptm_booking_created` and the notifications, once (an atomic claim row guards them); confirmed incomplete → binned, the move confirmed, then the seats released; still unreadable → kept, and after ten minutes administrators see a count with a "Check now" button. A request that stopped mid-save (state still "pending" after ten minutes) is settled the same way. Age alone never decides an outcome. Luxury's transaction is unchanged: the same checks run inside it, and any failure rolls it back.
* Repeated requests: a repeated copy of a request (`await_booking()`, `resolve_duplicate()`, and the fingerprint shortcut for tours, activities and transfers) is answered with a booking only once `Booking\completion_state()` says it is complete. A ledger row naming a booking, or a booking naming its reservation, is no longer enough; a failed read is never complete. Until then the copy waits up to the existing limit and gets the "still being recorded" message. Bookings made before 2.8.3 carry no save state and count as complete when they have a reference and a status. Reconcile no longer joins a booking whose save is still pending. Uninstall removes the two new operational options.
* Prices: `Pricing\store_snapshot()` keeps its signature and returns true only when the snapshot (and, for a priced booking, its total and currency) read back intact; `Pricing\snapshot_state()` gives the full answer (stored / exists / failed / unknown). An existing snapshot is never replaced, checked in the table. Tour, activity and transfer requests pass the server-side snapshot to `create()`, which stores it before the booking is announced. A zero total is stored as a price; a price on request stores the snapshot without a total; no quote stores nothing, as before. The calculation is unchanged and uses the same inputs, just before the post rather than after it; listeners of `wptm_booking_created` (the payment state) now see the snapshot. Recovery stores the snapshot worked out at request time if it is missing; nothing is repriced.
* Notifications: `Mail\queue_state()` tells queued / exists / skipped / failed apart (`queue()` still returns the row id). Customer mail switched off, a missing address or a refused duplicate is not a failure. A required request or confirmation message that could not be added to the outbox flags the booking with the existing `_wptm_mail_queued` key; the queue runner adds what is still owed — dedupe keys make repeated or concurrent runs write one row — and removes the flag only when nothing owed is missing. What is owed follows the booking's current status (`Mail\still_owed()`): "request received" only while the request is open (pending, contacted), the confirmation only while confirmed, the agency's new-request message not once cancelled, declined or completed. The same test runs again when a row is composed for sending; a message that no longer fits is cancelled as "No longer applicable", not failed. Retries are spaced by a one-minute gate; a retry that still fails shows administrators a count and "Try again now" (capability and nonce checked). No personal data in notices or the log. A failed notification never cancels or recreates a booking; "sending"/"uncertain" rows are untouched and never resent automatically.
* Guest Pass map: the booking's own map link, then the booking's own coordinates (a complete, in-range pair; 0 is valid), then the tour's meeting map link, then a search for the place and address. Before, the tour's link outranked a pin set for the booking.
* Luxury: the private-enquiry storage check is shown under Settings → "How journeys are sold" when the luxury experience is on, so a non-InnoDB or unreadable-engine database is visible before the first enquiry is refused. Nothing is converted and nothing is saved unsafely.
* Changelog: 2.8.2 said sites with non-InnoDB tables "keep the previous behaviour and log a warning"; in fact the enquiry is refused, the reason logged and an administrator notice shown. Corrected below.

= 2.8.2 =
Luxury enquiry concurrency fix and Guest Pass visual polish. No schema, URL, token, QR or permission change.

* Luxury enquiries: "sent" is answered only when the enquiry is stored. The booking carries its submission key (`_wptm_enquiry_once`, written with the record), so a retry after a crash between saving and finishing resolves to it instead of filing a duplicate. A copy that arrives while the first is still writing waits up to 6 s (`wptm_luxury_once_wait`) and then gets a translated, recoverable message with its answers kept. A failed save releases the claim at once. Claims are taken with INSERT IGNORE, expired claims (120 s) are taken over with a compare-and-swap UPDATE, and releases delete only the releasing request's own token, so a stale worker can neither overwrite nor release a newer claim. The hourly sweep deletes only the exact expired values it read.
* Luxury enquiries, storage: the booking, every meta row (the submission key included), the enquiry fields, the outbox rows and the end of the claim are one InnoDB transaction (`store_enquiry()`). It opens by locking the claim row (SELECT … FOR UPDATE) and checking the owner token, so a worker whose lease was taken over writes nothing and queues no mail, and no takeover can happen while the owner is inside it. A request that dies before COMMIT leaves nothing (the server rolls back on disconnect); a silent reconnect mid-transaction is detected by connection id and its fragments removed; a failed COMMIT is resolved by reading. Notifications are queued inside the transaction and only started after COMMIT (`queue_mail()`, `notify()` unchanged for callers); `wptm_luxury_enquiry_created` fires after COMMIT. Lock waits are bounded to 3 s for this connection. Sites whose posts, postmeta, options or outbox tables are not InnoDB (or whose engines cannot be read) store no enquiry: the visitor gets the translated retry message, the reason is logged and an administrator notice explains it (see Safeguards).
* Luxury enquiries, safeguards: no enquiry is stored without a transaction. If posts, postmeta, options or the outbox table is not InnoDB ('unsupported') or the engines cannot be read ('check_failed'), the visitor gets the translated retry message with their answers kept, only this request's claim is released, the reason is logged and an administrator notice explains it (clearing itself once the check passes). Before COMMIT every required value (customer, tour, party, notes, status, enquiry fields, reference, request key) is read back and compared with the validated submission, and the owed outbox rows are checked against the booking; `queue_mail()` now reports queued / exists / skipped / failed, so switched-off customer mail or a missing address never blocks an enquiry while a failed insert rolls it back. A failed COMMIT, or one answered on a replacement connection, is never taken at its word: the transaction is ended with ROLLBACK and the durable state is read afresh (`committed_state()`): complete is success, absent is a retryable failure, anything else is "confirmation pending" with nothing recreated or deleted. The lock-wait setting is restored at shutdown.
* Guest Pass: header reads agency, pass title with reference and status, trip, then the facts; the facts grid has no empty cells; a reset rule that outranked every spacing rule now carries no weight (`:where()`), restoring the intended spacing; long names and addresses wrap inside their cards; pickup details and guest actions are grouped under hairlines; plain buttons get the primary style instead of the browser's; the QR sits on plain white with quiet space; the phone bar colours WhatsApp by destination; print shows black text (the header's white text vanished on paper), hides controls and WhatsApp links, keeps the QR block and key groups whole.

= 2.8.1 =
Stabilisation pass before 3.0. No schema change, no new dependency, no change to pricing formulas, URLs, hooks or stored data.

* Bookings: the idempotency key is now the form's request identifier plus the normalised request (who, and every fact that distinguishes the booking) and the nonce. Two visitors served the same cached page no longer share a booking or see each other's confirmation; a retried or concurrent copy of one submission still resolves to one booking. A copy that arrives while the first is still being written waits for it instead of creating a second booking on the same reservation. The request identifier is also regenerated per page view in the browser.
* Bookings: duplicate detection includes start time, departure, extras, pick-up and, for transfers, vehicle, trip type, pick-up time, drop-off and return. Phone numbers are no longer cut to their last nine digits.
* Bookings: an expired form returns to the tour, activity, transfer or journey with a translated message and the answers put back (server-side, never in the address, page uncacheable). Nothing is booked from it; the nonce check is unchanged.
* Luxury enquiries: the same cache-safe dedupe, claimed atomically; the answer page is kept out of page caches.
* Outbox: a send interrupted mid-way is marked "Delivery unconfirmed" after 15 minutes and never retried automatically. The booking's Messages panel offers "Mark as delivered" or "Send again anyway"; both are capability- and nonce-checked and version-guarded, so a double click acts once. Composition errors no longer leave a row stuck; the first retry waits the intended two minutes.
* Pricing: an extra with a non-Latin name (e.g. Arabic) could be ticked but was silently dropped from the price and the booking; it is now kept.
* Editor: saving an untouched tour no longer changes it. A pre-1.6 per-group tour kept "Per group", a tour with departures kept "Scheduled", a tour without a currency kept the site currency, and a price written "45,5" or "1 250,50" is no longer emptied by the browser.
* Layouts: "Route at a glance" uses the design's heading face; its end label no longer widens the page on phones; Atlas's ruled route row spans the section. The closing booking form (Qamar) is held to its intended width. The phone booking bar no longer covers the end of the page.

= 2.8.0 =
A curated set of tour layouts. Seven layouts with distinct purposes replace the long list of overlapping ones; the older choices stay supported. Nothing is migrated and no tour needs re-saving.

* Layout families: Salma (booking focused), Sara (marketplace), Atlas (multi-day), Sahara (photo-first), Medina (short experiences), Qamar (premium storytelling), Classic (simple).
* Variants: Atlas styles Clean and Zellige; Sahara galleries Cinematic, Immersive and Mosaic. A variant changes presentation only.
* Legacy layouts (Immersive, Editorial, Mosaic, Adventure, ZelligeDesign) keep their templates and stylesheets and render as before; they are no longer offered for new choices.
* Layout picker in Settings, the tour editor and the setup wizard: each layout with its purpose and when to use it.
* Structural changes to Sara, Atlas, Sahara, Medina and Qamar (see changelog.txt). Prices, Group Rates and booking are the shared components, unchanged.
* Final polish: Sara's booking column no longer hides the help card's WhatsApp button while scrolling; a proper WhatsApp mark on every WhatsApp button; a cleaner Atlas route summary; Medina places its questions and reviews without empty half pages; a finishing pass on Qamar and on the "Need help?" card.

= 2.7.0 =
Tour display stabilization and consistency. Every layout keeps its look; the layouts now share the same components and data. Nothing is migrated and no tour needs re-saving.

* One tour card data layer and renderer for standard, archive and related cards; prices always from the tour's own pricing (Group Rates included).
* Trust section on SalmaDesign and QamarDesign; section navigation follows each layout's real order.
* One booking confirmation per page; no booking dialog or empty link when booking is off; no duplicate ids when the form is drawn twice.
* The two-step booking form is a shared component: its styles moved to booking-steps.css, and salma.css (same handle) holds SalmaDesign's layout only.
* Highlights: one shared item style on every layout — fixed 34px icon aligned to the first line, two columns where the section is wide enough, one on phones, lighter type.
* Empty states: no empty bands, cards, asides or headings when optional content (facts, highlights, related tours, booking, trust, meeting point, help) is missing.
* Phone booking bar no longer collapses with long translated labels; itinerary titles no longer repeat the day number; bind-once scripts; documented z-index scale.

= 2.5.1 =
* Homepage FAQ section: equal card heights and no layout shifting when answers open.
* Safe manual upgrade option for legacy tours to use the new tour editor, keeping existing data and legacy metadata.

= 2.5.0 =
Admin editor workflow and polish: stable layout, status-aware Save/Publish bar, clearer validation, and a finishing pass on tour and activity pages. Pasted itineraries and FAQs no longer collapse.

The full release history is in changelog.txt, which ships with the plugin.
