/*
Theme Name: Saeed ur Riaz Coaching
Theme URI: https://saeedriaz.com
Author: Saeed ur Riaz
Author URI: https://saeedriaz.com
Description: Premium editorial personal coaching theme for happiness, relationships, financial mentoring, healing, and corporate wellbeing.
Version: 4.155.0
Text Domain: saeed-riaz
Domain Path: /languages
Tags: one-column, two-columns, right-sidebar, custom-menu, featured-images, theme-options, translation-ready
Requires at least: 6.0
Tested up to: 6.6
Requires PHP: 7.4
License: GNU General Public License v2 or later
= 4.155.0 =
     * Contact form phone numbers are worldwide. The phone field now reads the visitor's country from the browser's language list - a non-English region such as ur-PK wins over an en-US interface, because this site's visitors commonly read Urdu and run the browser in English - and from the browser's timezone when the language names no country, so an en-US interface in London resolves to Britain and one in Karachi to Pakistan. British English and other regional English tags outrank the timezone, because a visitor who chose en-GB is in Britain even while travelling. Digits are grouped as they are typed with that country's numbering plan rather than a generic rule, a leading trunk 0 on a Pakistani mobile (03...) becomes the site's own "+92 " with one space as soon as the second digit is typed, even in a browser that reports only en-US, and the number is validated against its country's real length instead of a bare 7 to 15 digit count. British, Indian, German and American numbers keep their own zeros and their own grouping; a national number is never trimmed to the detected country's maximum when that country does not even dial a leading 0, which had cut a British 07911 123456 to ten digits on a US-configured browser. A number that no candidate country accepts is still checked against every country the library knows before it is rejected, so a traveller whose browser names only en-US can submit a correct national number. The libphonenumber-js bundle loads from jsDelivr only on the contact template, where a phone field exists; if it cannot be fetched, phone.js does nothing and the previous digit-count rule keeps the form working. The same pass tightened the email check: the local part is held to 64 characters and the whole address to 190, and the field label describes the shape expected. Each country's own maximum length is enforced keystroke by keystroke rather than a single fifteen-digit cap checked at the end, so a +1 number now stops at ten digits instead of accepting unformatted surplus; Pakistani mobiles stop at ten significant digits. A number written with + or 00 keeps the country it names: a British +44 or Emirati +971 number is no longer rewritten as a Pakistani number when the field loses focus, and is validated against its own country rather than the browser's. Applied to functions.php, page-templates/template-contact.php, inc/ajax-handlers.php, assets/js/phone.js (new), assets/js/validation.js, assets/js/theme.js, assets/js/main.js, and the static contact.html, book-a-call.html and book-schedule.html.
     * Spelling, wording and names corrected. "Saeed Ur Riaz" and "Saeed Ul Riaz" are now "Saeed ur Riaz" everywhere, including certificate alt text; "short to achieve" became "difficult to achieve"; "time line" became "timeline"; "on-line" became "online"; sentences that had lost a comma, an article or plural agreement were repaired ("As a Wellbeing and Happiness Coach", "with a small amount of data", "differ from those in your jurisdiction", "24 hours' notice", "audiovisual updates", "integral part of money management"); and the two cards the client asked to remove, "Clarity and Direction" and "Business or Career Growth", are gone from "What We Can Work On", leaving five. Valid British spellings (fulfilment, wellbeing, programme, organisation, enrolment) are kept deliberately. Applied to the About, Services, Emotional Wellbeing, Financial Wellbeing, Legal and Resources templates, front-page.php, inc/helpers.php and the matching static pages.

= 4.154.0 =
     * Footer brand fixed on phones. The footer now always renders the written lockup, "Saeed ur Riaz" with "Coach · Mentor · Healer" directly below it, and keeps an assigned Customizer logo above the lockup as a small mark instead of replacing the name with the image. At 480px and below the brand is left-aligned to the container edge rather than inheriting the centred footer grid. The tagline's disappearance had a root cause: the header rules that hide `.logo-tagline` at 576px and 480px were unscoped and also hid the footer's copy, so both are now scoped to `.header-inner`. A responsive sweep of every static page from 320px to 1440px found one real overflow left: on packages.html at 320px the single-column `.pk-cards` track used a bare `1fr`, whose automatic min-content floor was set by the no-wrap plan title, so the card hung 5px past the viewport. The track is now `minmax(0, 1fr)` and the plan title is allowed to wrap below 480px, while staying on one line wherever there is room. The same pass found that the compact phone button padding at 375px and below made buttons 35px tall, below a finger's 44px target, so `.btn` now carries a `min-height: 44px` floor at those widths. Applied to footer.php, inc/helpers.php, layout.css, breakpoints.css, theme-wp.css and the static packages.html.

= 4.152.0 =
     * Contact page Website card removed. The "Website" contact info card showing saeedriaz.com (its globe icon, label and site link) is deleted from the contact details column at the client's request. The cards that remain there are Email, Phone, Sessions and Follow. Applied to template-contact.php and the static contact.html.

= 4.151.0 =
     * Contact page Hours card removed. The whole "Hours" contact info card (its icon, the label, the hours value from the dashboard option, and the "Sunday Closed" sub line) is deleted from the contact details column at the client's request. The Email, Phone, Website, Sessions and Follow cards are untouched. Applied to template-contact.php and the static contact.html.

= 4.150.0 =
     * Contact page heading strip removed. The "Get in Touch" heading and its lead line ("Ready to take the first step on your journey? Reach out, and we can start discussing how to change your life.") above the Send a message form are deleted at the client's request, so the contact grid follows the hero banner directly. The form's own caption line and the contact detail cards are untouched. Applied to template-contact.php and the static contact.html.

= 4.149.0 =
     * FAQ page closing band trimmed. In the green "Step one takes half an hour" band at the foot of the FAQ page, the client removed the lead paragraph ("A free call, no pressure and no sales script. If it isn't a fit, you'll still come away with something useful.") and the gold "Book an Appointment" button, so no booking link appears in the page text. The heading and the "Read the FAQ" button remain. Applied to template-faq.php and the static faq.html.

= 4.148.0 =
     * Resources page notes links removed for now. The client keeps the "On life, happiness and healing" notes listing page and the note article pages out of the site's public pages for now, so every link from the Resources page into those pages goes: the "Read the full note" text link on the featured note panel, both "Read Note" links on the two smaller note cards, and the "View All Notes" button under them. The notes section stays as a display panel with its images, category tags, titles and summaries; the links return when the notes pages become part of the site. Applied to template-resources.php and the static resources.html.

= 4.147.0 =
     * FAQ page hero lead removed. The line under "Questions people ask" reading "If yours isn't here, ask it on the free call or send it through the contact form." is deleted at the client's request, so the accordion follows the hero directly. The FAQ answers that mention the contact form in their own text (the corporate programmes note) are untouched. Applied to template-faq.php and the static faq.html.

= 4.146.0 =
     * Resources page social media strip retitled and stripped to icons. The "Follow the conversation" section is retitled "Our Social Media links" and its lead sentence is removed at the client's request. All six social cards (Instagram, TikTok, LinkedIn, X / Twitter, YouTube and WhatsApp) now carry nothing but their platform icon on the gradient tile: each card's quoted hook sentence and its small platform label (Instagram Reel, TikTok Hook, LinkedIn Post, X / Twitter Thread, YouTube Short, WhatsApp Status) are deleted, as the client asked for all text removed from the face of these links. The links, gradients and hover behaviour are unchanged. Applied to template-resources.php and the static resources.html.

= 4.145.0 =
     * Resources page audio strip renamed and cards trimmed. The "Listen to the conversation" podcast section is retitled "Related Audio links" and its lead sentence is removed at the client's request. Each audio card now keeps only its headline above a single "Listen audio" button: the duration badges on the cover images the client flagged as incorrect (32 min, 28 min, 45 min, 38 min) and each card's blurb sentence are gone, in the template's card loop and in all four static cards, so a Customizer-configured episode can no longer print them either. The audio files behind the cards and the play behaviour are unchanged.

= 4.144.0 =
     * Resources page blogs strip renamed and articles trimmed. The "Tools for your growth" section is retitled "Related Blogs and Articles" and its lead sentence is removed at the client's request. The four cards keep only their headline above the Download button, renamed to the client's own article titles in their order: Build a solitude practice, How to use solitude, Energy Flow and Success by Choice, with each card's description sentence removed. The download links behind the buttons are unchanged, so the four article PDFs the client supplied on WhatsApp can be dropped into the existing paths. Applied to template-resources.php and the static resources.html.

= 4.143.0 =
     * Resources page classic-books lead rewritten. The line under "Classic books to inspire your journey" is replaced at the client's request with "Here you can download two foundational reads on inner work." (the old "Download these timeless books for free, foundational reads on mindset, purpose and inner growth." goes), applied to template-resources.php and the static resources.html.

= 4.142.0 =
     * Resources page guide card text trimmed and lead rewritten. The card's sentence under Download "Keys to Happiness Within" (the "short crash course in the building blocks of happiness" description) is removed at the client's request, leaving the card reading Download "Keys to Happiness Within" above the Download Free Guide button. The section lead above the card is replaced with "Related articles, blogs, podcast stories and audio visual updates." Both applied to template-resources.php and the static resources.html. The Blog page's own copy of the old guide sentence is untouched.

= 4.141.0 =
     * Packages page 3-Stage Process removed. The "The 3-Stage Transformation Process" section with its three numbered steps (Immediate Stabilization, Systematic Recovery, Resilient Mastery) is deleted in full at the client's request, both in template-packages.php and the static packages.html, so the page now goes from the "Invest in Your Balance, Health, and Happiness" intro straight into the package cards. The same 3-Stage Process on the services and corporate wellbeing pages is untouched, and the steps-grid rules in the shared CSS stay as the fallback those pages and an older template copy use.

= 4.140.0 =
     * Remaining "Take the First Step" bands removed. The home page's Section 08 closing band ("Take the First Step toward Your Growth" with its Book an Appointment button) is deleted from front-page.php and the static index.html, and the About page's "Take the First Step Toward Your Growth" section is deleted from template-about.php and the static about.html, both in full at the client's request. The home page now flows straight into Credentials and the About page into Qualifications and Certifications, and the booking action stays reachable from the header, the floating button and the footer.

= 4.139.0 =
     * Floating booking button added site-wide. A circular gold booking icon now sits above the WhatsApp bubble in the bottom-right corner of every page: one anchor in footer.php for all WordPress templates and the same anchor pasted on every static page, linking to the booking calendar. The circle shares the wa-float's fixed layer, hover lift, focus ring and responsive sizes (52px and 48px at the same breakpoints) and is hidden in print. Base rules live in the shared layout.css and the responsive and print rules in the shared breakpoints.css, both mirrored byte-identically in the theme and the static site; the stray unstyled book-float pill left on the static services page was replaced by this button.

= 4.138.0 =
     * Packages page closing band removed. The "Take the First Step toward Your Growth" section with its two paragraphs (the goals lead and the discovery-call calendar note) and its Book an Appointment button is deleted in full at the client's request, both in template-packages.php and the static packages.html, so the page flows from the Free Bonus strip straight into the practice gallery. The same booking action stays reachable from the header, the floating book button and the footer.

= 4.137.0 =
     * Packages card interiors polished after the three-equal-cards change. The card's space-between spread, written for a two-group card, centred the Plan details block and left a hole above and below it in every card: the card now reads top to bottom naturally and the fee group is pinned to the card's foot with margin-top auto so the three cards' fee blocks land on one line. The detail rows abandon the two-column spec table inside the narrow cards: each label stacks above its value as a small kicker over a reading-size sentence. The price word steps down from a 3.4rem ceiling to 2.6rem so it no longer runs edge to edge in a third-width column. Rules added to theme-wp.css and mirrored in packages.html's inline style.

= 4.136.0 =
     * Packages card fee block enlarged and organized. The payment note dropped out of the caption at 0.8rem grey, so the fee the client prices in read as a footnote under the huge price word. It now renders as a fee block inside the card: the parenthesised arrangement line stays small and soft, the USD amount and the PKR amount step up to display-weight lines, and the free discovery-call line sits last with a check mark in the gold tone. Each note line is classified in the template by its opening characters so a note edited in the dashboard keeps the same structure. Fee block rules added to theme-wp.css and mirrored in packages.html's inline style with the three cards' markup updated to the classified spans.

= 4.135.0 =
     * Packages card groups reordered at the client's request. The price line, its fee caption and the Book an Appointment button now sit at the end of each card, after the Plan details block, so every card reads title and Best For, then what the plan contains, then what it costs and where to book. The block moved as one group with its own styling unchanged, in the template's card loop and in all three cards of the static packages.html.

= 4.134.0 =
     * Coaching With Me page closing banner removed. The "Are You Ready to Lead with High Clarity and Energy" section with its Book an Appointment button is deleted in full at the client's request, both in template-services.php and the static services.html, so the page flows from the 3-Stage Process straight into the practice gallery. The same booking action stays reachable from the header, the floating book button and the footer.

= 4.133.0 =
     * Packages page cards unified. The client asked for the other plans to carry the featured card's own style, so the dark-card-plus-rows split is retired: all three packages render the same dark .pk-hero card, side by side in a new .pk-cards grid that keeps equal heights and collapses to one column below 1100px. Each card shows its Option badge, the compass plan icon, the Best For line under the title, the session basis with the fee amounts and the free discovery-call note, the Book an Appointment button, and the five-row Plan details block. Template loop simplified to one card loop over the package set; the pk-duo and pk-rows rules stay as fallback. Static packages.html carries the same three cards and the matching inline grid rules.

= 4.132.0 =
     * Packages page plans rewritten from the client's updated session table. The three packages now carry their own labels, Option 1, Option 2 and Option 3 in the badge slot, with the titles 1-on-1 Personal Sessions, Group Holistic Sessions and Executive Premium Sessions. Every detail row follows her wording: the new Real-Life Practice and Core Focus sentences, the approx. durations, the Hybrid and 100% Online and Onsite format lines, the four deep-dive sessions and direct WhatsApp access rows, and the fee cells rendered as the session basis on the price line with the USD and PKR amounts and the free discovery-call note as the caption below. The section head above the cards is now "Detailed information Related to Coaching Sessions Options:", the all-inclusion heading becomes "Included with Every Enrolment Sessions Plan" with its lead saying all packages rather than both, and the dashboard guide text follows the section's new name. Defaults updated in inc/packages.php, mirroring the static row-for-row in packages.html.

= 4.131.0 =
     * About page credentials head trimmed. The "Recognition & Certifications" heading is removed at the client's request, leaving the sentence "Professional training and certifications from internationally recognized institutions." as the only line above the certificate gallery, spread on a single line: the narrow-600 cap is dropped from the section head and the lead's 52ch measure is lifted with one scoped rule in theme-wp.css, mirrored in a new inline style block in the static about.html. Applied to template-about.php and about.html.

= 4.130.0 =
     * About page heading line removed. The "My Journey" heading between the greeting lead and the "Born and raised in Pakistan" paragraph is deleted at the client's request, so the story column reads straight from the italic greeting into the first paragraph. Applied to template-about.php and the static about.html.

= 4.129.0 =
     * Broken characters fixed in the header and footer wording. The logo tagline "Coach - Mentor - Healer" and the footer copyright " {year} Saeed ur Riaz" were stored as double-encoded bytes, which the live site printed as the mojibake "Coach A. Mentor A. Healer" and "Ac {year}". The separator, the copyright sign and the tagline in sr_logo_markup are now clean single code points at the byte level, so the header logo, the mobile menu logo, the footer logo and the copyright line render as Coach - Mentor - Healer and (c) Saeed ur Riaz. Static pages were already clean; only the theme defaults were affected.

= 4.128.0 =
     * Home testimonials reworked. The section heading "What People Say" becomes "Glimpse of Service Reviews", all six reviewer cards take the new names the client supplied (Joseph Leo, Patricia Mia, Rashid Wazir, Michael Oliver, Nasir B. Sheikh, Bint e Samia), and the designation line under each name is removed so the card shows the review and the name only. Review texts are unchanged. Applied to front-page.php and the static index.html.

= 4.127.0 =
     * Home page final CTA shortened. On "Take the First Step toward Your Growth" both paragraphs of the intro are removed at the client's request: the lead ("Let's discuss your goals and see how emotional intelligence coaching...") and the calendar note ("Please look through the available time slots..."). The section is now the heading plus the Book an Appointment button. Applied to front-page.php and the static index.html.

= 4.126.0 =
     * About page final CTA shortened. On "Take the First Step Toward Your Growth" the lead paragraph ("Let's discuss your goals and see how emotional intelligence coaching...") is removed at the client's request, leaving the centred heading and the Book an Appointment button. Applied to template-about.php and the static about.html.

= 4.125.0 =
     * Process section intro cleaned up. The sentence "Timings below are placeholders you can adjust." is removed from the lead under "What we actually do together", and the remaining lead is re-centred under the heading: the frozen page-overrides.css holds the 620px lead box on the container's left edge with margin-inline: 0, so the override restores auto margins in theme-wp.css and the static index.html inline style.

= 4.124.0 =
     * Packages featured card footer removed. The "Monthly Fee (On Call - flexible plans)" line and its "Book an Appointment" button are taken back out of the end of the featured package card, undoing the 4.117.0 addition. The card now ends with the Plan details block again, matching the static packages page.

= 4.123.0 =
     * Hero button renamed. The home hero call to action now reads "Book Free Discovery Call" instead of "Book an Appointment", in front-page.php and the static index.html. Header, footer and mid-page buttons keep their existing wording.

= 4.122.0 =
     * Hero green gradient nudged back up. The mask opacities rise from 0.45/0.25/0.05 to 0.52/0.30/0.05, a small lift toward the teal so the left side keeps a clearer brand presence while the right edge still fades to the bare photograph. Declared identically in theme-wp.css and the static index.html.

= 4.121.0 =
     * Hero green gradient lightened. The mask opacities drop from 0.78/0.45/0.05 to 0.45/0.25/0.05, so the teal now reads as a light wash over the whole hero photograph instead of a heavy tint, while the left-to-right fall stays. Declared identically in theme-wp.css and the static index.html.

= 4.120.0 =
     * Home hero mask turned into a green gradient. The flat rgba(0,0,0,0.4) veil over the hero photograph is replaced with a brand-teal gradient that runs left to right, deep 22544C at the left edge fading to the bare photograph at the right. Declared identically in theme-wp.css and in the static index.html inline style.
     * Theme width falls back in step with the static site below 1025px. The .section > .container widening rule in theme-wp.css is removed, so every band on the page takes the same measure as its static twin again; the Approach photograph now renders 798x500 at a 1000px viewport instead of 958x500, matching index.html at every width.

= 4.119.0 =
     * Services removed from the dashboard. The Services tab, its content type, the service meta box block, and the "Add a service" button are gone from the admin dashboard. The dashboard now manages Testimonials, Certifications, Gallery, Leads and Bookings. The service post type and front-end pages remain untouched.

= 4.118.0 =
     * Home page image replaced. grow-together.jpeg is replaced with the new grow-together.png (1398x1125) in both the theme and the static site, rendered at 500px height to match the adjacent section image.

= 4.117.0 =
     * Packages page featured card gains a footer. "Monthly Fee (On Call - flexible plans)" with a "Book an Appointment" button is added before the closing of the featured package card, so the pricing line and the booking CTA sit inside the card rather than below it.

= 4.116.0 =
     * Calendar page gets a .booking-card. The scheduler is now wrapped in a centred card (max-width 1400px, 1600px on large screens, rounded corners, butter ground, teal border) with the embed at a bounded 850px height (900px on large screens) instead of stretching edge to edge. The auto-height listener still sizes the frame to Calendly's reported content height, so there is no blank band and no internal scrollbar.

= 4.115.0 =
     * Calendly widget moved to the centre of the page. The inline iframe embed runs full width of the viewport again (matching the static site), with the 1900px height that fits Calendly's full booking flow without an internal scrollbar. The container is full-bleed on .cal-page.

= 4.114.0 =
     * Booking page removed. The separate /booking/ form page is gone: template-booking.php deleted, the demo-content entry dropped, static booking.html deleted, sr_booking_url() and single-service.php now resolve to the calendar page, and the Calendly success_url lands there too. Every Book button points at the calendar page.
     * Calendly widget embedded directly in the static calendar page. The custom calendar and booking form are removed, so the widget is the page: full width, 100vh height, no container wrapper, background set to the site butter (FFFFF5) to match, and the iframe-height override added so the global height:auto reset no longer clips it to 150px.
     * Theme calendar page widened to match. template-calendar.php uses the wide container instead of the narrow one, and the iframe-height fix already in theme-wp.css keeps it from being clipped.

= 4.113.0 =
     * Recognition & Certifications re-ordered on the About page. The client
        asked for the coaching-related certificates on top and for no slider,
        so the eleven coaching-related certificates now lead the gallery
        (Certified Professional Trainer, Seven Habits, Problem Solving and
        Decision Making at LUMS, Competent Communicator, Hypnosis
        Practitioner, Fourth Dimension, Positive Attitude, Negotiation Skills,
        Leading the Millennials, USAID Training) and the other sixteen follow
        them, still two per row, still with the first two visible and the
        rest behind View All Credentials. Matching is by image filename, so
        certificates installed as posts follow the same order.
     * Stray hyphens:auto paragraphs block removed again. It was left in 24
        static pages and theme-wp.css by the justified-copy attempt, its
        comment claimed justification the rule no longer performed, and it
        overrode the frozen hyphens:manual the design deliberately declares.
        Body copy is back to what the four base stylesheets say.

= 4.112.0 =
     * Hero gets a dark overlay. A full-cover rgba(0,0,0,0.4) veil on
        .hero-bg::after darkens the home hero photograph so the copy and
        CTA read against a moodier band. It sits above the image but
        below the copy and is clipped by the hero rounded corners. The
        pill size, transparent border and earlier mask removal remain.
        Frozen stylesheets untouched; mirrored inline on the static home
        page.

= 4.111.0 =
     * Hero gradient mask removed. The accent-colour overlay on
        .hero-bg::after that tinted the right side of the home hero is
        taken off, so the photograph shows cleanly again with no
        gradient. The pill size and transparent border changes from
        4.109.0 remain. The frozen stylesheets are untouched; the same
        removal is mirrored inline on the static home page.

= 4.110.0 =
     * Hero background gets an accent-colour gradient mask. A full-cover
        overlay on .hero-bg::after tints the whole photograph, stronger on
        the right side (rgba(34,84,76,0) to rgba(34,84,76,0.55)) and
        fading to transparent on the left, so the copy keeps the photo's
        natural brightness while the right edge reads as a brand-tinted
        band. It replaces the bottom-only fade to --butter. The overlay
        sits above the image but below the copy and is clipped by the
        hero's rounded corners. Frozen stylesheets untouched; mirrored
        inline on the static home page.

= 4.109.0 =
     * Hero credential pills enlarged. The two role labels under the
        headline (Executive Performance Mentor, Personal Happiness Coach)
        sat at a flat 10px while the headline above them is 50px, so the
        credential line read as a footnote. They now scale with the
        viewport from 15.2px to 20px and the pill padding is set in em,
        so the box grows as one unit with its type. The phone rule lifts
        them from 9.92px to 12.8px. The white hairline the frozen rule draws
        around each pill is made transparent, so the pills read as flat
        teal labels. The frozen stylesheets are untouched:
        the override lives in theme-wp.css, prefixed with body so it
        outranks the 0.62rem rule in breakpoints.css without touching
        that file. The static home page carries the same rule inline
        because its four stylesheets are frozen copies.

= 4.108.0 =
     * Dead code removed from the theme. Thirty-four CSS classes that no
        template, script or partial referenced (bk-*, cal-*, chip,
        field-error, fine-hero, legal-*, media-*, program-single-grid,
        sched-*) had their rules taken out of theme-wp.css, along with
        the last two sched-wrap and cal-embed rules that the first pass
        left behind. The dead helper sr_visitor_timezone_notice_text
        and the unused template-parts/services-grid.php went with them.
        Nothing that rendered changed: every removed class matched zero
        call sites, and the harness still passes all twenty checks.


= 4.107.0 =
     * Every package now carries all seven rows of the client's table,
        labelled, rather than folding them into a tick list. Best For,
        Format, Duration, Meetings and Access, Core Focus, Real-Life
        Practice and Payment Options each have their own row, in her
        order and in her wording, under the package's own name. The card
        prints Best For on its own line under the title and the other five
        as the detail block; each stack row prints all six in the same
        block. A package with an empty cell simply does not print that
        row rather than printing a label with nothing beside it.

        The title that named the card's package now fits on one line.
        The badge moved above the title, right-aligned, so the title
        takes the card's full width instead of sharing it with the pill.
        Its size is 1.6rem at the top of the range and 1.4rem at the
        bottom, and the longest name the page carries still fits.

        The stack is wider and the card narrower, closer to the supplied
        design's own proportion, which is also what lets the row's name
        and price share a line at desktop. Where a long name does push
        the price to the next line, the price block is anchored to the
        right instead of being stranded at the left.

        Polish and motion, additive only: the card carries a layered
        shadow with an inner highlight, each row lifts on hover with an
        inset gold hairline and a brighter background, the arrow nudges
        right and the badge takes the gold, and the call to action lifts
        and casts a shadow. Every transition uses the same custom
        cubic-bezier, and all of it is disabled under prefers-reduced-
        motion. Nothing here reflows the layout.

        The editor now has fields for Format, Duration, Meetings and
        Access, Core Focus and Real-Life Practice instead of the old
        Duration / format and What is included fields, and the old
        two-field shape is carried across for any package saved before
        this release, so nothing on the live page empties itself.


= 4.106.0 =
     * Three packages instead of two, reorganised in the split pricing
        design, and the static site was done first this time.

        The third package is the client's own, The Executive Intensive:
        a full day onsite with three months of dedicated integration
        support for executives and business owners, priced on call as a
        customised fee. It joins the group coaching in the stack rather
        than taking the card, because the card is the 1-on-1 and she
        marked that one Most Popular, which is the badge the design puts
        on it.

        The static page carries the same three plans in the same
        arrangement, and it carries the same declarations: the .pk rules
        were copied out of this file into the page's own style block
        rather than written twice, so the page and the theme cannot
        drift apart. The four shared stylesheets were not touched by any
        of it, which is why the page's rules live in the page.

        The two rows that sat under the packages in 4.104.0 are gone.
        The free discovery call and the three months of follow-up are
        already in full on the page, the first in the booking section at
        the foot and the second in the bonus block directly below, so a
        third and a fourth row said the same two things twice. The stack
        is the two remaining plans now, and the card and the stack are
        back to the same height, 848 against 848 at 1440 wide.

        The intro line now names the third path, an onsite intensive
        built around the visitor's business, because a page that sells
        three packages and introduces two is a contradiction. The
        wording is the one to check.

        The new package's anchor, #executive-intensive, joins the other
        pricing anchors with the same offset from the fixed header that
        the frozen stylesheet gives them, in this file and in the static
        page's own style block, because that file cannot change and the
        new id is not in its list.

        The editor still owns every field the cards render, and a fourth
        package becomes a fourth row rather than a layout change. One
        thing to know: a saved set of packages beats these defaults, so
        if the packages were ever edited by hand in the dashboard, what
        shows is that saved copy and not this.

= 4.105.0 =
     * A page with JavaScript turned off used to render empty, and the
        cause was the reveal state rather than the content.

        The reveal lives in layout.css, .gs-up { opacity: 0; transform:
        translateY(40px) }, and only GSAP takes it back: the script
        sets y to 0 and opacity to 1 on its scroll trigger. The theme
        had one guard against that, a reduced-motion rule that forces
        the same elements visible for a visitor who asks for less
        motion, and nothing at all for a visitor whose script never
        arrives. Every .gs-up block on the site stayed at opacity 0,
        which is a page that has loaded, with a title, and no text.

        The hook for it was already in the markup. header.php prints
        class="no-js" on the html element and an inline script in the
        head swaps it for "js" on the first line of the page, and
        that swap is exactly the test. The new guard is written
        against .no-js, so with the script running the class is "js"
        and nothing about the site changes, and with the script
        blocked, offline or failed the class stays "no-js" and every
        hidden element is shown.

        The selector list is the reduced-motion list, which is the
        full set of elements the scripts hide: the .gs-up, .gs-fade
        and .gs-scale blocks, the hero's own pieces, the section head
        parts and the call to action. The reveal animation itself is
        untouched, and so is every animation script.

= 4.104.0 =
     * The pricing section is rebuilt in the split design the client
        supplied: a dark featured card on the left, a stack of plan rows
        on the right, the stack reaching the card's height.

        The data is the client's own comparison table, column for
        column. The 1-on-1 package carries the four months and the
        hybrid format, the private sessions, the direct WhatsApp access,
        the wellbeing planning and the continuous tracking, and the
        group package carries the eight weeks online, the weekly group
        session, the peer network, the core tools and the group
        challenges. The payment line is the table's own, Monthly Fee
        with the flexible on-call plans. Gone with it: the Discuss on
        Call price, the two payment lines the table does not carry, the
        four easy monthly payments and the two easy monthly payments,
        and the one feature bullet the table does not carry either.

        The stack carries two further rows, and neither is a new offer:
        the free discovery call from the booking section further down
        the page, and what every enrollment includes from the bonus
        block. Each links to the section or the booking page it
        summarises, and the card closes on the same follow-up in one
        line, the slot the supplied design gives to its proof line.

        The class names are new, and that is the point. The old cards
        were .service-card with .pkg-card on top, and the frozen
        stylesheet carries a long careful set of rules for exactly
        that pair, from the badge and the meta line through the price
        word to the centring of the whole card body at (0,3,1). Every
        one of those was written for the old white cards. This section
        is a different object, so it does not wear their names and
        cannot be argued with by them.

        Ink on the dark card, measured against --charcoal: the title
        16.95:1, the subtitle 16.15, the price caption 16.26, the list
        label 16.41, the ticks 14.4, the closing line 16.26, and the
        call to action is the site's own gold button at 8.6:1. In the
        rows on the tinted band: title 13.44, body and small print
        10.78, ticks 7.53, badge 7.31. Every pair clears AA. The two
        small lines that came out at 4.25:1 in the tinted row, --muted
        being 4.9:1 on white but not on the tint, take --charcoal-2
        instead and keep their place by size.

        The card and the stack are told the same height, and the card's
        groups are spread over the height it is given, so the space
        sits between the groups instead of pooling under the
        checklist. Checked at 1440, 820 and 390 wide: no horizontal
        overflow at any of them, one column below 1100, the row's price
        and arrow side by side above 720 and spread below it, and the
        card's checklist down to one column there too.

        The dead control is out: the package editor's Button style
        select is gone, because the design fixes the treatment, a gold
        full-width call to action in the card and a round arrow in the
        rows. The featured toggle now says what it does, that the
        package goes in the dark card beside the list. The editor still
        owns every field the cards render, and a third package becomes
        a third row rather than a layout change.

        Nothing else moved. The rest of the page, the other
        templates and the four frozen stylesheets are untouched, and
        the packages page no longer justifies a word.

= 4.103.0 =
     * The justified body copy measure is removed outright, closing the
        last of the extra-spacing complaints, and this time at the
        root of all of them rather than at another screenshot.

        Since the measure went in, every stretch-gap report, the
        philosophy paragraphs, the calendar labels, the card grids, the
        short labels and now the About story grid, has been that one
        block paying out on a narrower column than it was designed
        for. The static site the theme mirrors never justified a
        single paragraph: the four frozen stylesheets declare zero
        text-align: justify rules, and their own measurements, 85
        paragraphs across 13 pages walking spaces two to twenty-six
        times their natural width, are the same objections the client
        has been making in screenshots ever since.

        The whole block went at once: the (0,3,1) justify rule above
        861px, and with it the four counter-rules that existed only to
        fight it, the calendar label centre, the philosophy left, the
        short-label reset and the card-grid exclusion, all carrying
        !important at (0,4,1). With the measure gone each counter-rule
        is dead weight, and every one of them was also wrong about
        the design: the short-label reset was holding centred
        section-heads' labels to the left, against what the frozen
        files centre them to. The deleted run is replaced by a
        tombstone comment recording why the measure does not come
        back.

        Alignment is inherited from each section again, exactly as the
        frozen files set it: the About story grids and philosophy
        detail read flush left, the calendar page keeps the centre
        its own .cal-page rule in this file has always carried, and
        a section-head that centres its content now takes its
        labels with it, as the design drew them.

        The cascade harness was rewritten for the new invariant: no
        justify anywhere in the theme layer, no important text-align
        override either, the two design alignments above still
        standing, the four frozen stylesheets byte-identical to the
        static source, and the enqueue order for the five stylesheets
        unchanged, tokens, layout, page-overrides, theme rules,
        breakpoints last.

= 4.102.0 =
     * The top banner bands are square on every page, on the client's
        request. On About, the 24px card radius from the theme stylesheet
        was landing on '.about-hero-media img', the band that spans the
        full width at y=0 with the fixed header stroking across its top
        edge. The rounding put a dark notch into each top corner, the
        page's own stone background showing through where a corner was cut
        away.

        The same band shape runs the whole site: '.about-hero-media' has
        seven siblings in the frozen banner family, and Home, Services and
        Packages carry it as '.hero-bg'. None of the others was rounded,
        but the family is now declared square together in theme-wp.css,
        so no member can pick a radius up from another rule instead.

        '.about-hero-media img' came out of the card rule that rounds
        '.service-hero-media img', '.split-media img' and '.post-featured
        img'. Those three are inset cards with the radius as part of their
        design, so their rounding is unchanged; only the page-edge bands
        lost it.

= 4.101.0 =
     * The navigation order changed, on the client's list. Free Resources
        now sits before FAQs: Home, My Story, Coaching With Me, Packages
        and Fee, Free Resources, FAQs, Poem, Contact us. The footer menu
        mirrors the header.

        The order was owned by four definitions and all four moved
        together: sr_nav_fallback() and sr_footer_nav_fallback(), which
        render when no menu is assigned to the location,
        sr_create_demo_menus(), which numbers the demo install's items
        explicitly, and the setup wizard's items array, from which the
        standard install's positions are counted.

        A menu already in the database keeps whatever order it was saved
        with; re-seeding is what applies the new one. Install or Repair
        Setup rebuilds the page-backed entries in the new order, the same
        way the imagery change in 4.99.0 applies.

= 4.100.0 =
     * The home page's philosophy copy now starts where the welcome brief
       starts, on the client's request.

       The frozen stylesheet already said this and said it plainly:
       page-overrides.css 3343 sets margin-inline: 0 on the philosophy
       detail box to pull its 820px column off the page centre and meet
       the container's left edge, the same place "Greetings and
       welcome!" begins. What undid it was this file's own rule for the
       gap above that box, which declared the vertical correction as the
       margin shorthand and carried auto in the inline position with it.
       Same specificity, later file, so the shorthand won and the column
       slid back to centre behind the frozen rule: left-aligned lines in a
       centred box, with both edges of empty page space to its sides.

       The shorthand stays, so the alignment override above still compares
       like for like, and the inline position is now 0. The vertical gap
       the rule exists to set is untouched. Nothing in the frozen files
       was edited: the correction lives where the fault does, in the one
       declaration this theme owns.

= 4.99.0 =
     * The blog note imagery is replaced across the theme by four new
       photographs, peaceful-office-04, 07, 08 and 09, shipped in
       assets/images/blog/. They cover every place a note's picture is
       shown: the Blog page's featured panel and its three note cards, the
       Resources page's featured panel and its two smaller cards, and the
       related-notes fallback inside a single note.

       The swap is done in two places. The setup wizard's seed table now
       attaches the new files as the four seeded posts' featured images,
       and the template fallbacks in template-blog.php,
       template-resources.php and single.php point at the same set, so a
       post saved without its own image shows one of the new shots rather
       than the old gallery pictures it used to borrow. Running Repair
       Setup re-runs the seeder and re-points the existing posts.

       g3.jpg to g6.jpg stay in the theme and in the wizard's asset list
       because the gallery grid still uses them; they are simply no longer
       the face of the blog.

     * The Testimonial Details meta box is gone, and the three client meta
       keys went with it. The box offered Client name, Role / organisation
       and Rating on the WordPress editor screen, and both save handlers
       accepted and cleared those keys, but no template reads them: the
       front page renders testimonials as static markup. The box went from
       meta-boxes.php, the keys went from both sanitisers, and the demo
       seeder no longer writes them.

       For the same reason, 'excerpt' support is dropped from Certification
       and Testimonial, and 'thumbnail' from Testimonial. No template
       reads either for those types, which is what made the editor panels
       dead controls. Service keeps both: the services grid and the single
       service page read the excerpt, and the service hero shows the
       featured image.

= 4.98.0 =
     * The Featured media block is gone from every item form, and the dead helper
       that drew it with it.

       sr_dashboard_media_field() had one caller, the line that built the box, and
       that line is gone. A helper with no caller is code that looks like it is
       doing something and is not, so it went too. set_post_thumbnail() was guarded
       by isset( ['image_id'] ), which the removed field was the only thing
       that could set, so it is gone as well rather than left as a line that can
       never do anything.

   = 4.97.0 =
     * The Featured media block is gone from every item form, not only from the
       testimonial one.

       Featured media meant an image upload through sr_dashboard_media_field, a
       Media URL, a Choose media button, and a Featured on the site checkbox. None
       of it was ever rendered: services, certifications and testimonials are not
       queried from the database by any template, so a featured image stored on one
       of those posts appeared nowhere on the site. The form offered a control for
       something the theme never displays, which is worse than offering nothing,
       because an owner who sets it reasonably expects to see a change.

       The Featured media box is removed from the shared item form, so it is gone
       for all three types at once. The Meta box data box survives for service and
       certification, which do carry fields that are read, and is already gone for
       testimonial, which carries none.

       The save handler is left untouched. It still writes _sr_featured and the
       thumbnail when those fields are posted, both guarded by isset(), so removing
       the fields changes what is stored without breaking the save. Existing
       records keep whatever they had; nothing new is written.

   = 4.96.0 =
     * The Testimonial edit screen now shows only the Title and the Full content
       editor. The Excerpt / short description field, the Featured media box with
       its Media URL and Choose media controls, the Featured on the site checkbox,
       and the Meta box data block with Client name, Role / organisation and
       Rating 1-5 are all gone from it.

       They went because nothing in the theme reads them. Testimonials are not
       queried from the database anywhere: the front page renders them as static
       markup in front-page.php, so every field on that form was a control that
       saved a value nobody displayed. A form that looks like it configures a
       page and does not is worse than no form, because an owner who fills it in
       reasonably expects to see a change.

       The fields themselves are untouched for the other two types. Service keeps
       Duration, Price, Features and Booking URL; Certification keeps Issuing
       organisation and Date / year. Those types do not share the testimonial's
       front-end situation, so they are not being narrowed here.

       The save handler is left alone. It still stores post_excerpt when the
       field is posted, and still writes the testimonial meta keys when their
       fields are posted, both guarded by isset(), so removing the fields
       changes what gets stored without breaking the save. A testimonial saved
       from now on simply has no excerpt and no client details, which is what it
       had before anyone looked at the form.

= 4.95.0 =
     * The Excerpt / short description field is gone from the Testimonial edit
       screen. Testimonials were never read from the database at all - the
       front page renders them as static markup, so the field saved a value
       nothing displayed. It stayed on the form because the form is shared
       across service, testimonial and certification, and nobody had asked for
       it to be split by type. The other two types keep it.

= 4.94.0 =
     * The scheduler's brand colour is now a setting instead of a constant.

       sr_calendly_inline_widget() has had a loop since 4.82.0 that applies
       primary_color, background_color and text_color to the Calendly URL as query
       parameters. The loop was unreachable: the defaults declared 'primary' while
       the loop read 'primary_color', and background_color and text_color were not
       declared at all, so all three were empty and every iteration hit the continue.
       Nothing anywhere in the theme passed any of them either. The comment above
       the loop explained in detail why Calendly's blue was wrong on this site, and
       the scheduler rendered in Calendly's blue anyway. The comment was right and
       the code was not.

       Fixed in 4.87.0 by declaring the keys the loop reads, with the brand colour
       defaulted to the accent. Fixed again here, because a default that cannot be
       changed is still a constant wearing a different hat: the scheduler's colour
       is now sr_calendly_primary_color, stored like every other setting on the Site
       tab, and the renderer reads it through sr_option() so an install that has
       never saved anything still gets the accent colour.

       The dashboard field uses a native colour picker, which shows the current
       colour as its value, and four preset swatches beside it: the brand teal, the
       charcoal, the red accent, and Calendly's own blue for anyone who wants to go
       back to it. The hex is shown beside the picker and updates as it moves, so
       the value being stored is visible. Presets are buttons that set the input
       rather than submit the form, which is the only safe shape for them.

       Sanitised with sanitize_hex_color like the other two colour fields, so a
       stored value that is not a colour becomes empty and the default takes over
       rather than being emitted into a URL.

= 4.93.0 =
     * The justified reading measure was quietly switched off for every card
       description on the site, and this is the fix that restores it.

       The measure is a single rule:

           @media (min-width: 861px)
           .site-main-content .section :is(p, li, dd, blockquote):not(.lead)
               { text-align: justify !important; hyphens: auto !important; }

       and it is !important, so a parent that sets text-align: left cannot reach
       the paragraphs it lands on. The theme has a second rule that exists for
       exactly that reason, excluding card grids from the measure:

           .site-main-content .section :is(.steps-grid, .why-list, .mini-cards, ...)
               :is(p, li, dd, blockquote):not(.lead)
               { text-align: left !important; hyphens: manual !important; }

       Both are !important, so the one that wins is decided by specificity. The
       exclusion was written at (0,4,1) and the measure at (0,3,1), so it won. That
       was the design.

       The regression was my own. The calendar page needed its two added lines out of
       the measure, and the honest route to that is a :not() clause, so I added
       :not(.bk-scheduler-tz) and :not(.bk-chosen-service) to it. Then the
       philosophy section needed the same, and I added :not(.philosophy-copy). Each
       clause is one class, so the measure went from (0,3,1) to (0,5,1) and then to
       (0,6,1), above the exclusion's (0,4,1), and the exclusion stopped working. A
       rule that was written to be beaten by specificity is now beaten by it, and
       every card grid on the site justified. The worst were .themes-grid at 1.91em
       and .mini-card at 1.55em between words, against a normal space of 0.25em.

       The fix is to stop accumulating :not() on the measure. It is back at
       :not(.lead) and stays there. The calendar and philosophy exceptions are now
       later rules at (0,4,1), carrying their own !important, each scoped to its
       container. Adding another exception can no longer move the measure's
       specificity, so the card grids cannot be lost by the same mechanism twice.

       Three containers that were never excluded are added to the list: .themes-grid,
       .achieve-grid and .work-options. They were measured as loose and were not in
       it, and there is no reason they were omitted other than that nobody measured
       them. The list is now a measured set rather than a remembered one. If a new
       grid is added later and its paragraphs look like the screenshot, it belongs
       here, and the gap-in-em metric is the test for whether it does.

       Verified on the live site before and after, by injecting the corrected rules
       and measuring the same paragraphs: .themes-grid from 1.91em to 0.20em,
       .achieve-grid from 0.93 to 0.20, .work-card from 0.83 to 0.20, .testi-grid
       from 1.18 to 0.20, .hiw-grid from 0.82 to 0.28, .why-item from 1.19 to 0.20.
       The measure itself is unchanged and the paragraphs it is meant to justify
       are still justified.

= 4.92.0 =
     * A dead-code and connectivity scan of the whole theme, and the three real things
       it found among twenty that were its own fault.

       What it found. sr_booking_statuses() was defined and called by nothing, and is
       gone. It was the label list for a status control that had already been removed
       from both the bookings list and the booking detail screen, so keeping it was
       keeping the stage names of a screen that is not there. The status values
       themselves are still written to the column and are now recorded at the top of
       booking.php, because the column is still in the database and the next person to
       touch it should not have to dig the values out of a CREATE TABLE statement.

       Three comments claimed that function was deliberately in place beside the
       update_status handler. They were describing an intention rather than the code,
       and after the removal they were lies about a function that no longer existed.
       They now say what happened. That is the cost of comments written about
       intentions: they outlive the code and go quietly wrong, and a scan reading the
       word inside a sentence cannot tell the difference.

       sr_booking_available_days was a settings field that never existed. No day picker
       has ever been built, so no form anywhere posts it, and nothing read the option it
       stored. Worse, the handler's elseif matched every save of the Site tab and wrote
       an empty array to it, so an unread option was written on every save and
       displayed nowhere. The branch is gone and the row is cleared by
       sr_migrate_retired_options(). Availability is Calendly's to decide, from the
       event it is handed; there is no schedule here for a list to filter against.

       The opening hours are now Available 24 hours a day, in both places they were
       stated. The registry default and the Contact page seeder both said the same old
       range, and they are two hardcoded copies of one fact, so the Contact page body
       would have kept saying the old hours after the dashboard was changed. That
       duplication is not fixed here and the two remain separate strings, because the
       request was to change the hours in both places, not to unify them.

       Changing them would have done nothing on this site, which is the part worth being
       explicit about. sr_option() reads get_option() before the registry, so a stored
       value beats the new default for ever, and the Contact page's hours are not a
       template at all: demo content writes them into the page's own HTML once. So
       sr_migrate_hours_to_24h() clears the stored option and rewrites the page.

       That migration is keyed on a plain flag and not on SR_THEME_VERSION, the one place
       it differs from every other migration in the theme. Those compare against the
       version and so run again on every release, which is right for rows that were
       repaired and keys that were retired, and wrong for a live setting an owner is
       meant to be able to change: version-keyed, it would delete whatever hours they had
       chosen on the next upgrade, silently. It also edits the Contact page only when the
       old wording is actually present, so a page whose hours were retyped by hand is left
       alone.

       The pattern needed two attempts. It first allowed a hyphen in the day name and
       demanded a plain hyphen before the end time, which matched every ASCII test case
       and none of the real ones, because the shipped string carries an en dash in both
       places. A check that passes for the wrong reason is worse than no check, and this
       one reported "does not match" against text that did match. The dash class is
       written once now and used twice, and the test runs against the exact string from
       the file across every dash encoding.

       What the scan found already right, which is the other half of it. All five public
       AJAX endpoints reach sr_ajax_guard(), which verifies a nonce and applies a rate
       limit; the first attempt reported three of them as unguarded because it read the
       one-line wrappers instead of the function those wrappers call. Every registered
       hook callback resolves to real code. Every database write goes through a wrapper
       that can report its failure. sr_booking_created has no listener and is kept
       deliberately, being the only integration point the theme offers, and the code now
       says so beside it rather than leaving the scan to argue about it.

       One fact about the tests themselves. The twenty harness files this project relied
       on lived in a temp directory and are gone from it, cleaned out by unrelated work
       that shared the folder. Several sessions of green runs came from a location
       another process deletes. These four read the theme as it ships, and are the start
       of putting that under version control, which is separate work and is not done here.

   = 4.91.0 =
     * All four notification emails now lay out correctly on a phone.

       These four messages are the only thing the mail layer produces:
       sr_confirm_booking and sr_notify_visitor_ack go to the visitor, and
       sr_notify_booking and sr_notify_contact go to the owner. All four are built
       by one template, so everything below reaches all four at once.

       The card table was width="600" with max-width:100% in its style. A mail
       client honours the width ATTRIBUTE and largely ignores the style attribute,
       so the card was laid out at 600px on a 375px screen and everything past the
       right edge was cut off. It is width="100%" now, with max-width:600px in the
       style for the clients that read CSS on a wide screen. Measured before and
       after at 375px: 624px of content in a 375px viewport, against exactly 375
       after.

       bgcolor and text attributes are set alongside the CSS on every table, cell
       and block. Gmail re-inverts a light design into its own dark theme and uses
       bgcolor to decide what to invert from; without it the white card and the
       ivory quote block are both replaced by whatever dark the client substitutes,
       which is what turned a quoted message into a box that read as a code sample.

       The quoted message was a one-row nested table, which is the structure clients
       are least consistent about - a percentage width on the inner one can be
       ignored, and the quoted text is short, so the table shrank to the width of
       its content and left a small rectangle floating in a full-width section. It
       is a block now, which has no width to get wrong. border-radius was dropped
       from it rather than kept, because several clients strip it from a cell and a
       rule honoured inconsistently is worse than no rule on a block that already
       reads as a quote from its background and its left border.

       The buttons were bare anchors carrying their own padding, which is the least
       reliably applied declaration in mail: where a client strips it the button
       collapses to its text with no tappable area. Each is a table cell now, with
       the padding on the cell, and on a phone they take one line each.

       That last part is bounded and the boundary is worth stating, because it cost
       a wrong turn to find. Stacked content-width buttons is what fits a 375px
       screen, and forcing them full-width is not available: the button table is
       inside a shrink-to-fit cell, so width:100% asks the cell how wide it is
       while the cell asks the table. The browser breaks that circle at the content
       width, so a width on that table does nothing at all. display:block is what
       does the work, by dropping the buttons out of the inline flow.

       A long pasted URL made the whole mail wider than the screen, and the fix for
       that is not the one that looks like it. Measured at 375px:

         overflow-wrap:break-word    1141px   still broken
         overflow-wrap:anywhere      1141px   still broken
         either, plus
           word-break:break-word      375px   fixed

       A mail is tables, and a table in automatic layout sizes itself to the widest
       thing inside it. One pasted URL has a min-content width equal to its whole
       length, and neither overflow-wrap value reduces that number - it governs
       where an already-too-narrow line may break, and the line is not too narrow
       yet, because the table has already widened itself to fit. word-break is the
       declaration that lowers the min-content width the table was reading.
       overflow-wrap:anywhere is kept beside it as the standards form of the same
       idea, and word-break must not be tidied away as the deprecated one: measured
       on its own it is the only thing here that works.

       Two faults in the mobile rules were caught by measuring rather than reading,
       and both are recorded at the rule so they are not reintroduced. A selector
       written .sr-acts > table > tr > td matches nothing at all, because the HTML
       parser inserts an implicit tbody between a table and its rows - the real
       chain is table > tbody > tr > td - so the button padding override was dead on
       arrival. And a rule scoped to .sr-bd would have reached the field table too
       and outranked the .sr-row rule that sizes it, so the buttons have their own
       class.

       One comment in the booking handler said "No email is sent from here" while
       two sends sat directly above it, and then said the mail was "back below"
       where there was nothing below. Both statements described an intent that was
       never carried out. The mail is sent from there deliberately, the dashboard
       is still the record, and the 500 is fixed by the route the mail takes rather
       than by removing it: every sender goes through sr_notify(), which records a
       fault and returns it instead of raising it, so a mail layer problem is a
       logged problem rather than the visitor's error page.

   = 4.90.0 =
     * The Reply card is gone from the enquiry detail, and a div that had been open
       since the dashboard was first written is now closed.

       The card held a heading, a sentence explaining that replying opens a mail
       client, and a mailto button: three ways of saying one thing, on a record whose
       Email row was already a mailto link two cards higher up. Its only unique
       contribution was a pre-filled "Re: your enquiry" subject, and the address is
       still clickable, so replying stays one click either way. Three translatable
       strings left the codebase with it and nothing else referenced them.

       Its removal exposed a second thing. The Delete card was sharing a two-column
       grid with it, and one child in a grid is a half-width card with nothing beside
       it. sr_dashboard_booking_detail() has always drawn its Delete booking card
       standalone, outside the grid, so both detail pages now end the same way.

       And tracing the divs turned up a fault that predates all of this. The enquiry
       detail opens a page-level <div class="sr-admin-card"> for the whole record and
       never closes it. Verified against HEAD rather than assumed: the committed
       version traces to depth 1, the working tree traces to depth 0.

       It survived because an unclosed div does not throw. The browser recovers at
       the end of the body, the page looked close enough right, and nothing reported
       it - the same failure mode as the uncentred calendar lines, where the markup
       was valid and the page was wrong.

       Two of the checks written to catch this were wrong before they were right, and
       both wrote down why. The first counted tags with a pattern matching both
       "<div>" and "<div followed by whitespace or >", so every bare div counted
       twice, and it read the words div and section inside the comment explaining the
       removal as though they were tags. The second anchored on echo followed by a
       quoted string and so never saw the closing tags in '</td></tr>', which is a
       second operand of a concatenation rather than an echo argument; it reported tr
       as seven open and zero close, which cannot be true of a file that plainly
       contains them. The check now takes every single-quoted literal in the function
       with comments stripped, and asserts it has actually found a closing tag before
       it reports any balance at all.

   = 4.89.0 =
     * One label in the visitor's booking email: "Your time zone" is now "Time Zone".

       Text only, which is the whole of it. The value beside it is the same
       $booking['timezone'] it always was, and the two notifications that go to the
       owner still say "Their time zone", which is right in an email addressed to
       the owner and would be wrong in one addressed to the visitor.

       Nothing else moved. No .po or .pot ships with the theme, so changing a msgid
       strands no translation, and no harness asserted the wording, so none needed
       rewriting to accommodate it.

       Left alone deliberately, and worth naming because it looks like an oversight:
       the dashboard row for the same field is spelled "Timezone" as one word
       (booking.php), and sr_visitor_timezone_notice_text() writes "time zone" as
       two. This label now reads "Time Zone" because that is what was asked for. If
       the capital is meant to be house style, the dashboard row is the other half of
       that change and has not been made.

   = 4.88.0 =
     * Every notification the theme sends was fataling on a call to a property.

       Reported from the dashboard on a test send:

         The mail layer failed: Call to undefined method WP_PHPMailer::AltBody()

       AltBody is a public property of PHPMailer. There is no method of that name in
       any version, so this line fataled inside the phpmailer_init hook, which fires
       on every wp_mail() call in WordPress:

           $mailer->AltBody( $alt );

       It has to be an assignment. The comment on the line above it carried a
       phpcs:ignore for ValidPropertyNameNotSnakeCase, which is the sniff that fires
       on properties rather than methods, so the code was suppressing the one warning
       that would have described the mistake.

       The wrapper held. sr_mail_send() catches Throwable around wp_mail(), so the
       Error arrived as a readable WP_Error instead of a blank screen, and the
       booking or lead row was already stored by then. Nothing was corrupted; every
       message after the point it was deployed was simply not sent.

       php -l passed throughout, and would have passed forever. The syntax was never
       wrong. The mistake was in which kind of member a name refers to in a class
       this theme neither owns nor ships, which is a question nothing inside the
       theme can answer. So the member lists are now read from PHPMailer 7.1.1
       itself, and a new harness checks every mailer member the theme touches in
       both directions: a property called as a method is the fatal above, a method
       assigned over is worse because it fails later and less legibly, and a name in
       neither list is a typo. In 7.1.1 the two sets do not overlap at all - 49
       properties, 99 methods - which is what lets a name say which kind it is
       without guessing.

       Mutation tested, because a check that cannot fail is not a check: reintroducing
       AltBody() fails it, assigning isSMTP instead of calling it fails it, and a
       case-wrong Timeout fails it.

       Scope was narrowed after the first version reported nineteen failures
       describing nothing. The theme also uses variables called $mail and $e, and
       neither is a mailer: $mail is an array from sr_email_template() and $e is an
       Exception, which is where getMessage, getFile and getLine came from.

       One guarantee also made structural. sr_send_notification() carries the plain
       text alternative in a static, and cleared it on the line after the send. That
       line is skipped if the send throws, so a leak was possible; the comment
       promised it could not happen. It is now cleared in a finally. The callee does
       not throw today, so this is not a fix for an observed fault - it is the
       difference between a promise in a comment and a promise in the code.

   = 4.87.0 =
     * The scheduler was rendering in Calendly's blue because the theme's own brand
       colour had never once reached it.

       Found by a harness that had been reporting this since it was written, and
       dismissed at the time as a stale assertion. It was not stale. It was right.

       sr_calendly_inline_widget() reads three colour arguments:

           $colours = array( 'primary_color', 'background_color', 'text_color' );
           foreach ( $colours as $key ) {
               if ( empty( $args[ $key ] ) ) { continue; }

       and its defaults declared a differently named key:

           'primary'   => '',

       with background_color and text_color not declared at all. So all three were
       unset, every one of them was skipped, and nothing anywhere in the theme passed
       any of them either - there is no caller of this function that supplies an
       argument. The loop was unreachable code that had never run, and the comment
       above it, which explains in detail why Calendly's blue is wrong on this site,
       described a fix that was not in effect.

       The evidence is not subtle. Rendered side by side from the same markup with
       and without the three parameters: heading, month arrows, day numerals, the
       selected date and its dot, the duration clock - all rgb(0, 102, 255) without,
       all #22544C with.

       Fixed by declaring the keys the loop actually reads, defaulted from the
       theme's own tokens rather than left empty. A colour nobody passes is not a
       default, it is an argument that does not work.

       The values were checked rather than guessed, because the token names mislead:
       --gold is #22544C, a dark teal, not gold; and the page ground is --butter
       #FFFFF5, not --ivory #FBF8F3. Every content band paints --butter edge to
       edge, which is what the live page background actually measures. An earlier
       draft of this entry used --ivory and would have left the widget a shade off
       the page it sits on.

   = 4.86.0 =
     * The Calendar page's two added lines are centred and on one line each, and
       the fix is one :not() clause rather than a specificity contest.

       Two things were wrong, and the second was only visible once the first was
       fixed.

       The centring failed because the reading measure is !important. The whole of
       4.85.0's account of it was built on counting classes, and counting is not
       what decides it:

           @media (min-width: 861px)
           .site-main-content .entry-content :is(p,li,dd,blockquote):not(.lead)
               { text-align: justify !important; ... }

       An important declaration is not outranked by a more specific one that is
       merely normal, so no selector in the calendar's own stylesheet could ever
       reach it. The evidence was unambiguous once looked for: injecting the
       calendar's exact selector again, later in the cascade, still lost. Order
       cannot rescue a declaration that priority already decided.

       Two routes exist from there. Add !important, or stop being a candidate for
       the rule. The first was tried, by putting the two classes into the short-line
       reset that already sits beside the measure for the things that are a label
       rather than a paragraph. It worked and it was wrong: the reset declares
       text-align: left !important, so beating the measure immediately created a
       second important rule to beat, and the pair was then decided by source order
       because they tie at (0,4,1). Two importants aimed at each other is a worse
       thing to leave in a stylesheet than one important rule, and it was worse
       than the original because it looked settled.

       So the classes are excluded from the measure itself, beside .lead, which is
       already excluded on exactly this reasoning. Nothing declares text-align on
       them, so they inherit the centre from .cal-page .entry-content, and they
       inherit no hyphenation either, which is right for a label. Verified by
       matching the selector rather than by eye: an ordinary p is still justified,
       .lead is excluded, .card-note still reaches the reset, and only the two
       calendar classes are newly out.

       The line breaking was the second fault and it was mine. The two lines were
       clamped to 520px to match the lead, and the time zone line is about 560px of
       text at its own size, so the clamp broke a sentence in two. The clamp is
       gone; both lines take the 780px that .entry-content > * already gives every
       other child. The lead keeps its 520px, which is a reading measure for prose
       and was right all along - it is the only sentence on the page.

       Measured after, on the rendered page rather than in the source: all four
       lines - heading, lead, service, time zone - report a centre offset of 0 or
       1px from the container centre, and both added lines report one line box
       where they previously reported two.

       Also fixed, found while checking the file and unrelated to the page: a
       stray */ at line 154, left by two comment blocks concatenated without an
       opener. The second half was silently not a comment, so the CSS parser was
       reading prose as declarations. Comments in the file now balance and braces
       match at 155 each.

   = 4.85.0 =
     * Three faults on the Calendar page, none of which PHP could have caught.

       The template shipped in 4.84.0 and the page rendered. Then it was looked at,
       and it carried the time zone line twice, two <main> elements nested inside
       each other, and two lines of text that were neither centred nor bold on a page
       whose heading and lead were both centred.

       Each has a root cause, and none of the three is the one the symptom suggests.

       The duplicate line is duplication rather than a styling problem. The template
       printed a time zone line and sr_calendly_inline_widget() prints another one,
       because the renderer has always printed one directly above the widget. The
       template's copy is gone.

       Which copy stays is settled by the wording. "Times below are shown in it" is
       only true of a line sitting immediately above the calendar. Above the service
       line it was making a claim about a widget still two elements further down, and
       the two sentences were close enough in wording that a reader saw the fault
       before they could say what it was. Keeping the renderer's also means the two
       arrangements agree: the fallback path, for a Calendar page not on this
       template, gets its line from the same renderer, so there is one place the line
       is produced rather than two that have to be kept in step.

       The nested <main> is this template printing a landmark header.php had already
       opened. header.php emits <main id="main" class="site-main-content"> and
       get_footer() closes it, so every other template in the theme simply prints
       sections between the two. This one printed its own as well, and the live page
       carried main > main.site-main-content > section.

       A document may have one <main>, and nesting one is worse than having two side
       by side: the landmark is announced twice, the inner one is not the document's
       main content by definition, and the justified reading measure that hangs off
       .site-main-content has to reach through a landmark it should never have been
       inside. It also made this the only template in the theme doing it, which is
       the shape of a mistake rather than of a decision.

       The centring is the interesting one, because nothing was wrong with the CSS
       that was written for it. Two rules in the theme compete for those two lines
       and neither is a mistake:

           @media (min-width: 861px)
           .site-main-content .entry-content :is(p,li,dd,blockquote):not(.lead)
               { text-align: justify }                                    (0,3,1)

           .entry-content .bk-visitor-tz, .entry-content .bk-scheduler-tz
               { margin: 0 0 14px; ... }                                  (0,2,0)

       The first is the site's justified reading measure: prose on a wide screen is
       set flush to both edges, because a ragged right edge in a long paragraph reads
       as untidy. It reaches every p inside entry-content except .lead, it lands
       directly on the element so inheritance from a centred parent cannot touch it,
       and both of the added lines are p elements inside entry-content. The heading
       and the lead - the latter excluded by :not(.lead) - sat centred beside two
       justified lines, which is why the page looked like a centring problem when it
       was a cascade one.

       The second is this page's own line styling, written earlier in the same file,
       so its margin shorthand came later in the cascade and reset margin-left to
       zero on its way to setting the bottom one. That is the same cancellation
       documented against .entry-content > *, and it cancelled the centring a second
       time after the first was fixed.

       The rules were first moved to (0,4,1), on the reasoning that this outranks
       (0,3,1) on specificity rather than on file order, and that the distinction
       matters because theme-wp.css loads before breakpoints.css so a tie would
       lose. That reasoning was sound and the conclusion was wrong. Both rules
       compute to (0,4,1) once :is() and :not() are counted properly, because each
       contributes the specificity of its most specific argument, so :is(p,li,dd,
       blockquote) counts as an element and :not(.lead) counts as a class. Counting
       them by hand is exactly what should not have been trusted here, and a harness
       was written afterwards to compute both selectors instead. It passed. The fix
       still did not work, and 4.86.0 records why.

       The lesson underneath all three is that php -l, boot_theme and boot_admin all
       passed on every one of them. A template can be syntactically perfect, load
       without error, and still emit a document with a landmark inside itself and a
       sentence printed twice. Those are only visible by reading the rendered
       markup or by computing the cascade, which is what the new harness does.

   = 4.84.0 =
     * Every booking was redirecting to a 404, and the page it redirected to now
       has a design.

       The bug first, because it is the one that mattered and it was invisible
       from inside the theme.

       A completed booking sent the visitor to

           /calendar/?ref=SR-RYEAWZIR&service=Discovery%20Call

       and that returned 404: "This path is empty". Measured on the live site, and
       the isolation is what makes it a fact rather than a guess.

           /calendar/                         200
           /calendar/?ref=SR-RYEAWZIR         200
           /calendar/?foo=bar                 200
           /calendar/?service=                200   empty value
           /calendar/?service                 200   no equals sign
           /calendar/?Service=Word            200   capital S
           /calendar/?services=Word           200
           /calendar/?service_type=Word       200
           /calendar/?type=service            200
           /calendar/?service=PlainWord       404   the one name, with a value
           /?service=PlainWord                404
           /booking/?service=PlainWord        404

       So "service" is a query variable something else on the install already owns,
       and WordPress 404s a request whose query vars resolve to nothing. Nothing
       in this theme registers a query var, which is why the theme could not fix it
       by declining to register one. The only thing it could do was stop using the
       name.

       The consequence is the part worth stating plainly: the scheduler never
       appeared for anyone arriving from the booking form. Every booking the site
       took ended at a 404 rather than at a calendar. It did not read as a broken
       widget, because no widget was ever reached.

       Fixed by naming the parameter sr_service, which matches the prefix every
       other option and setting the theme owns. The wider rule is what is written
       down: a query string is public surface on an install this theme does not
       control, and an unqualified noun is the most likely thing to collide with
       something. A harness now reads every parameter name the theme builds or
       reads and fails on one that is not namespaced.

       The page itself has a template now.

       /calendar/ was an ordinary page with two lines of prose in its content and
       the widget appended by a the_content filter. It worked, and it had three
       costs that were only visible once you looked for them.

       The page had no design of its own. It inherited page.php, so it opened with
       a generic heading reading "Page" above a page titled Book an Appointment.
       Every other page on the site has a designed opening; this one announced
       itself as a page.

       And nothing on it could be centred, because two of its rules fight.
       .entry-content > * centres its children with margin-left and margin-right
       of auto, and the two rules for the lines the hand-off adds use the margin
       shorthand, which outranks them and resets the side margins to zero on its way
       to setting the bottom one. Those two lines were the only children of
       .entry-content that finished up hard against the left, on a page whose
       heading and lead were both centred. Measured live: their boxes sat at
       left=168 inside a container running 168 to 1268, so the text inside centred
       at 558 where the container centre is 718, while the lead beside them sat
       exactly on 718.

       page-templates/template-calendar.php owns the layout now: the heading with
       the "Page" eyebrow removed, room above it to clear the fixed header, the
       service and time zone lines centred and weighted, and the widget on the same
       centre line beneath them. The filter stands down when the template is
       rendering, because both drawing a scheduler means two widgets, and two
       widgets is a layout fault rather than a failure - the page still returns 200
       and the first one still works.

       The dashboard's scheduling panel is down to the one thing that is the
       owner's to set: the Calendly URL, and a nonced Reset to the default address
       shown only when the stored value differs from the shipped one.

       Three things went with the template. The Plugins button, which sent an owner
       to a list of software the feature does not use, to answer a question it
       never raised - Calendly's widget needs no plugin and the theme has not
       registered a shortcode for it since the Cal.com tags were dropped. The Edit
       the Book an Appointment page button, which opened the block editor on a page
       whose content is prose the installer writes. And Reset the embed to the
       default, which existed because the first could break the second and rewrote
       post_content to undo it. Two buttons for one page.

       The old restore route is kept rather than deleted. It has no button now, so
       nothing links to it, but a site whose Calendar page still carries an embed in
       its content has no other way back, and the cost is one option read on a
       request that only happens when someone pastes a URL.

   = 4.83.0 =
     * Visitors who write in are answered twice, and the reason enquiries went
       missing quietly is finally closed.

       Two separate things were wrong and they are worth keeping apart, because
       only one of them is the one that was obvious.

       The obvious one: SMTP was off. Established from the test header, and not
       checkable in code, because the server, the account and the password are the
       owner's to enter.

       The one that kept it invisible: every failure a notification can have was
       being recorded inside sr_send_notification(), which sits below two earlier
       exits. No valid recipient returns at the top of that function and never
       reached the recording. A thrown mail-layer error is caught a level above it
       in sr_notify_deliver() and never reached it either. So the two ways a
       notification could fail without the mail server ever being asked left no
       trace anywhere - which is the failure that looks least like a fault,
       because no message was attempted.

       On top of that, all three notification call sites in the AJAX handlers
       discard their return value. That is correct and deliberate: the lead is
       stored first, so raising an error there would show the visitor a server
       failure for an enquiry the dashboard is already listing. It also meant
       nothing was watching the result at all.

       The recording moved up to sr_notify(), the single point every notification
       passes through, so a discarded return value costs nothing. A sent message
       clears it. An enquiry that now fails to notify says so on the Leads tab
       instead of in an error log on a host nobody opens.

       The visitor acknowledgement is back, as sr_notify_visitor_ack(). It went
       off in 4.52.0 with the rest of the contact mail, to stop a thrown mail
       error becoming a 500, and the controls for it were removed in 4.77.0
       because by then they had been dead for two years - a checkbox storing a
       number nothing read, and two textareas holding copy that was never sent.

       No checkbox came back with it. A switch is only honest once the thing it
       switches exists, and it now does, so the copy is editable and the sending
       is not something anyone has to remember to turn on. It is sent after the
       owner's copy, not instead of it, and it cannot change what the owner's
       result is.

       Three rules, each a mistake available to make here. It goes to the visitor's
       address and nowhere else: no Cc, no Bcc, and the notification inbox is not
       printed in the body, so the separation sr_notification_to() exists to keep
       is not undone by sending to a stranger. Reply-To is the published contact
       address rather than the notification address, so pressing reply reaches a
       person. And an unusable address sends nothing rather than falling back to
       the notification inbox, which would have delivered a message addressed to
       the visitor and congratulating them on an enquiry they may never have made.

       Placeholders are {name}, {site}, {service}, {message} and {date}. Anything
       else is left exactly as typed, so a stray brace is visible to the owner
       rather than a silent hole in a visitor's inbox.

       Both templates are stored with a plain update_option() rather than read
       through sr_option(), because they are deliberately emptiable. An owner who
       clears the body wants a subject line and nothing else, and sr_option()
       treats a stored empty string as absent twice over, so clearing the field
       would have looked like it had failed. sr_optional_setting() reads them,
       which asks the stored row first and falls back only when there is no row.

       Rendering the message caught two faults that forty-two assertions had
       passed. The template escapes the body and then converts its line breaks, so
       the wpautop() this first wrote put a visible "<p>" at the head of every
       paragraph of the visitor's inbox. And the details were headed "Who wrote in"
       with the body labelled "What they wrote" - the owner's voice, describing
       the reader in the third person to their own face, which is precisely what
       the template's own comment warns about two dozen lines above the default.
       Neither was visible to a check looking for a substring: the escaped tags
       still contained the text, and the wrong labels come from the template
       itself. Eight checks were added that look at the rendered thing rather than
       at a string inside it.

   = 4.82.0 =
     * The booking hand-off URL was escaped for HTML on its way to JSON, and the
       address bar was showing the proof.

       A booking on the live site left the browser at

           https://saeedriaz.com/calendar/?ref=SR-028XXJQ1&#038;service=emotionalwellbeing

       and that "#038;" is the whole diagnosis in six characters. It is an HTML
       entity for &, which only gets into a URL by passing through esc_url(), and
       the one place it did was sr_cal_embed_url()'s return value. The booking
       handler hands that value to wp_send_json_success() as success_url;
       wp_json_encode() does not decode HTML entities, so the browser received
       the entity as literal characters.

       Because "&#038;" itself begins with a #, everything after the first query
       argument was parsed as the fragment. The reference survived, and the
       service did not. That is why it could sit unnoticed for so long: the
       reference is exactly what the Calendly write-back matches on, so the
       booking recorded correctly and the failure was a wrong URL rather than a
       lost booking. A visitor whose link was copied or bookmarked got a fragment
       where the service should have been.

       Fixed by moving the escaping to where it belongs, which is the point of the
       change rather than a detail of it. sr_cal_embed_url() now returns a plain
       URL. Each caller already escapes at the point of output, and had to: the
       confirmation email prints it into an href, where esc_url() is correct and
       was already being applied, so escaping twice produced &#038;#038; in the
       email link. Escaping belongs where a value meets its output, not where it
       is built, and the two destinations here needed opposite treatment.

       The query string is now assembled directly rather than through
       add_query_arg(). That is not tidiness: add_query_arg() hands the pair to
       _http_build_query() with urlencoding switched off, so a readable service
       name containing a space can reach the query raw. rawurlencode() is applied
       here instead, explicitly and once.

       The service is also readable now. It went through sanitize_key(), which
       lowercased and stripped the spaces, so "Emotional Wellbeing" arrived as
       "emotionalwellbeing" - a value that could not be shown to the visitor and
       could not be matched back to the service they had just chosen. It now
       carries the name, and sr_scheduler_on_booking_page() echoes it above the
       scheduler, because the form asks the visitor to choose a service and the
       next screen was saying nothing about what they had chosen.

       That echo reads the query, so it is sanitised on the way in and escaped on
       the way out, and the harness checks that markup in the parameter is
       stripped rather than rendered. It is a label the visitor typed; the stored
       booking remains the record.

       The widget itself was not broken. Calendly renders, the write-back still
       travels with the embed on every page, and the reference reached it intact
       throughout - which is precisely why the URL could be malformed and nobody
       noticed.

   = 4.81.0 =
     * The audit ran in both directions, and the other direction found a form
       collecting email addresses that reached nothing.

       Every previous pass looked for things written and never read. That found
       the retired settings, the Programs and Gallery types, and the four image
       settings. This time it also looked for the reverse: a thing the site asks
       a visitor for and then discards.

       The free guide form on the Blog page collects a name, an email address and
       a phone number. theme.js binds every form carrying data-ajax-form and posts
       it to admin-ajax.php. The guide form had no data-ajax-form attribute and
       there was no sr_lead handler, so nothing on the server ever received it. The
       only code handling it was assets/js/main.js, which wrote the details into
       the visitor's own localStorage and stopped. The form looked like it was
       collecting an email address and the owner had no record of a single one.

       Fixed at both ends rather than patched over. The form carries
       data-ajax-form="lead", which makes theme.js post sr_lead, and sr_lead is a
       one-line handler over the existing sr_send_lead(), which was already
       generic in its source. The row lands in the same leads table as a contact
       enquiry with source "guide", which is what that column is for, and the owner
       is notified by the same path. The hidden timezone field went on the form
       too, so a guide request carries a place in the world like every other lead
       rather than the "not reported by the browser" fallback.

       The guide settings were disconnected in the opposite direction and are now
       live. The Site tab has a Guide / Free PDF card saving guide_title,
       guide_file and guide_image, and all three were read by nothing while the
       Resources page hardcoded the same three values. An owner who uploaded their
       own guide or changed the title was editing a setting that reached no page,
       and the site carried on serving the design's original file. Both the
       Resources section and the Blog lead panel now read them, with sr_option()
       supplying the design's originals as the default so an untouched site
       renders exactly what it rendered before.

       Removed: sr_ajax_comment, twelve lines and a rate-limit slot registered
       against a form that never posted to it. comments.php writes the comment
       form out by hand to carry the theme's button, and it posts to
       wp-comments-post.php exactly as core does; the form tag was the only thing
       ever changed about it. Also removed: the site-wide booking_url option, a
       second answer to a question default_booking_url already answers and the one
       every template reads, and the four image settings, with the two Customizer
       panels they were the entire contents of. An empty panel opens to nothing,
       which in the Customizer looks like a rendering fault rather than a theme
       with nothing to say there.

       All five retired keys join sr_migrate_retired_options(), so the rows are
       cleared rather than left behind looking like configuration.

       One thing that looked wrong was left alone after reading it. sr_scheduler
       is referenced in four places and is registered nowhere, which reads as a
       broken embed. It is deliberate: it is stripped from page content at
       priority 8, and the the_content filter appends the scheduler itself. The
       has_shortcode() tests are documented as covering the paths that bypass the
       filter chain. Reading it was cheaper than changing it.

       The registry is now 77 keys and every one has a reader. The audit is worth
       running in both directions next time rather than only the comfortable one:
       the dead code was the visible half, and a form that quietly throws away
       what a visitor typed is the half nobody thinks to check for.

   = 4.80.0 =
     * Outgoing mail now says what it is doing, because a silent loss of
       enquiries is indistinguishable from a quiet week.

       This came from a real report: enquiries were not arriving, and the only
       evidence was a test message reading "From: WordPress
       <wordpress@saeedriaz.com> via srv2092.main-hosting.eu", marked by Gmail
       as suspicious. That header is a complete diagnosis once read properly.
       "via srv2092.main-hosting.eu" is the host's plain mail relay, which means
       PHP mail() and not an authenticated SMTP session, and
       "WordPress <wordpress@saeedriaz.com>" is PHPMailer's own default, meaning
       nothing ever set the From. The mail server was off.

       The cause is in sr_smtp_configure_phpmailer(), which returns before it
       calls setFrom() when no server is configured. That is correct as written.
       The failure was that it failed quietly, and there was nowhere on the
       dashboard where the state was visible. A lead sitting in the table looks
       exactly like a lead that was notified, and the only record was an sr_log()
       line going to an error log on a host nobody opens.

       So the screen now reports what mail is doing rather than describing the
       problem in a paragraph. On the Email tab, the Leads and Bookings tables,
       and the Overview above the counts: the route in use, the address the From
       header will actually carry, and where enquiries are going. When mail is not
       configured the warning names the wordpress@ address, because that is the
       first thing an owner sees in their inbox and the one they cannot explain,
       and links to the screen that fixes it.

       A refused send is now remembered in sr_mail_last_problem() and shown,
       cleared by the first send that works. One row, not a history: the question
       is whether mail is broken now, not what happened in March. The reason, the
       time and the theme version are kept, because "it is broken" is not
       actionable on its own.

       sr_send_notification() now goes through sr_mail_send(), which its own
       documentation already described as the one wrapper every side-effect send
       should use. It called wp_mail() directly with a private try/catch, so the
       protection was written twice and the reporting was written twice and
       differently: the test button said "the mail server refused the message:
       <reason>" while a real enquiry notification said "The mail server refused
       the message" and dropped the reason. The one send whose failure the owner
       most needed to read was the one that withheld it.

       Nothing here turns SMTP on, and no code could: the server, the account and
       the password are the owner's to enter under Dashboard, Email. What changed
       is that the site can no longer fail to deliver an enquiry without saying
       so on the screen where the enquiries are listed.

       Hostinger's published settings arrived part way through this work and the
       defaults were checked against them rather than assumed: smtp.hostinger.com,
       port 465, SSL, with the account address as the sender. That was already
       right, so nothing moved. Checking it did surface one real bug beside it.
       PHPMailer spells "no encryption" as an empty string, while the dropdown
       offers it as the word "none" so the option is visible rather than blank. The
       stored value was passed through untouched, so anyone who picked the third
       option handed PHPMailer a value it does not list. It is mapped to '' at the
       point of use and the stored value is left alone, so the screen still shows
       the word the owner chose.

   = 4.79.0 =
     * Programs and Gallery are gone, and so is everything that made them look
       like features.

       Both content types outlived their pages. 4.43.0 stopped the installer
       creating /programs/ and 4.45.0 stopped it creating /gallery/, and this
       release removes the two types the pages left behind. What was left read as
       a working gallery and a working programs section, and was neither.

       The Gallery was the clearer case and worth stating plainly, because it is
       worse than half-built. A Gallery Item had no page template, no single
       template and no archive, so no setting and no amount of content could ever
       put one on the site. There was a dashboard tab with a table and an Add
       button, a Gallery Details meta box, a category dropdown fed by five seeded
       terms, a REST route, and an installer that uploaded twelve photographs into
       the media library and set each as a featured image on a post nothing linked
       to. The test that settles this class of question is not a count of
       references, it is whether a template exists.

       Programs was nearly as bad. single-program.php meant a post at
       /programs/<slug>/ would render, which is what made it feel like a partly
       finished feature rather than an absent one, but nothing listed them and no
       link reached them. Both types also held the gallery and programs paths as
       their rewrite slugs, which is how a rewrite rule ends up pointing at a path
       with nothing at the other end of it.

       Removed: both post types, the gallery_category taxonomy, both meta boxes,
       both REST routes, the program-single body class, the template map entry,
       template-programs.php, single-program.php, the Programs and Gallery
       dashboard tabs and their tables, the program-only and gallery-only meta
       fields, the twelve gallery seeds and the two example programs from both the
       installer and the optional demo-content script, and the Programs and Gallery
       lines from the two setup documents that still listed them.

       sr_dashboard_tab_for_type() existed only to turn gallery_item into "gallery"
       rather than "gallery_items", so with the type gone the special case is gone
       and the plain plural is right for all three types that remain.

       The content goes too, on the owner's instruction, rather than being left in
       a table no query can reach. sr_retire_program_and_gallery() deletes every
       Program and Gallery Item post and the gallery_category terms, once, keyed to
       the theme version. It is the one destructive part of this release and it is
       destructive on purpose. The argument for deleting rather than hiding is the
       same one as the dead settings: a row nothing can read is indistinguishable
       from a row that failed to save, and an unreachable attachment is storage
       the owner pays for and cannot see.

       It runs on after_switch_theme and admin_init, so uploading this build and
       opening the admin once is the whole procedure. It costs one option read per
       admin page load and then stops for good.

   = 4.78.0 =
     * Status is gone from the enquiry screen, and with it the last thing that
       pretended a lead had a lifecycle.

       The leads list lost its Status column earlier, and the enquiry detail screen
       picked the control up so nothing was lost by that. Both are now gone. The
       detail screen showed a row reading Status with a dropdown sitting on "New",
       and the reason it can go is that a lead's status is written once and read by
       nothing: no filter, no badge, no count, no notification anywhere in the
       theme looks at it. A booking's status is different in kind, not in degree.
       The calendar drives one, and the record shows it, which is why its control
       was moved rather than deleted when its column went. A lead has none of that,
       so the row told the owner a state they had not decided and could not act
       on, and every lead now stays at the 'new' it was written as - which is the
       truthful description of a value nothing consumes.

       sr_lead_statuses() went with it. It existed only to label that dropdown, and
       the booking equivalent is a separate function for the separate set of
       stages, so removing one did not touch the other.

       The update_status handler stays, and so does sr_booking_statuses(). Neither
       is dead code: the booking detail screen's Status row was removed earlier on
       request, which means at the time of writing no control in the dashboard
       posts to the handler for either kind, and the honest thing is to say that
       rather than to leave a comment claiming the booking control still exists. I
       did write that claim while doing this, on the reasoning that a booking would
       always have the control, and checking it found the row had already gone. The
       comment now says what is true: the handler is nonce and capability checked,
       it is the plumbing a control needs, and a lead that grows a lifecycle wants
       a screen and nothing else.

       One piece of genuine dead code went with it. The booking detail screen
       assigned $statuses = sr_booking_statuses() on every request and then, since
       its own Status row had been removed, never read the array. A variable built
       once per booking and then ignored reads as a control one edit away from
       working, which is the specific impression this work has been removing
       everywhere else.

   = 4.77.0 =
     * The dashboard is reconciled against its own changelog, and the last
       convincing dead control in it is gone.

       Audited the way 4.53.0 audited, because the method was already written down:
       every key in the dashboard's save list checked for a reader, then every
       registry key checked for a way to be saved. Both directions, because a
       setting with no input and a control with no consumer are the same fault
       wearing different clothes.

       Four of the 83 save-list keys came back with no reader, and all four were
       false. book_3_author, book_3_pdf, book_3_cover and book_3_description are
       read through the interpolated loop, sr_option( "book_{$i}_author" ), and a
       literal search for the spelled-out name never matches. 4.53.0 hit the same
       trap and reported it for the same reason, which is the clearest argument yet
       for writing the method down: forty-five noise and five real findings, or
       four noise and one real one, is a summary number that has to be checked
       against something.

       That check did find something, and it was not the one it was looking for.
       book_3 had no entry in sr_default_options() at all. The other two empty
       book slots, four and five, are both present and empty; slot three was
       missing because a third book was once removed and left the numbers 1, 2, 4,
       5 behind. The gap is harmless on its own, since the loop is a range and not
       a list, but the missing entry is not: it was the one slot the dashboard can
       write to that no default describes, so it reads as blank on the page for a
       different reason than four and five do and nothing in the code can tell them
       apart. Five empty entries added, so all five slots are uniform.

       The real finding was the confirmation switch. The Site tab offered a
       checkbox reading "Send visitors an automatic confirmation when they use the
       contact form", two owner-editable message bodies, and a panel explaining the
       five placeholders those bodies use. Nothing sent them. The mail came off the
       contact path in 4.52.0 to fix a 500 on both visitor endpoints, and the
       acknowledgement that would have replaced it was never put back, so for two
       years the dashboard has been the most convincing kind of dead control there
       is: one with a help panel beside it, telling an owner that a confirmation
       was on its way.

       4.34.0's own words are the reason to remove rather than wire: an input that
       accepts a value and displays nothing is worse than no input, because it
       looks like a setting that works. So the checkbox, both textareas, the
       placeholders panel and the three settings are gone, sr_migrate_retired_options()
       clears the rows so nothing is left looking like configuration, and the tab
       now says in one line that a visitor who writes in gets no automatic reply
       and where to find the message instead. That sentence is the part worth
       having: the absence is otherwise indistinguishable from an oversight, and
       the contact form cannot report a setting about itself.

       Left alone deliberately: contact_email_2, contact_email_cc and
       notification_email are in the registry but not in the generic save list,
       which looked like three more findings and is not. Each has its own path:
       the first two through the Site tab's own address loop, the third through the
       Email tab. Four keys are saveable with no registry entry and are correct
       that way too, being the two colours and four images, where an empty default
       is the right answer and a default is not.

   = 4.76.0 =
     * The reply button opens a mail client with the visitor's message quoted in
       it, and the address is printed in the message as well.

       The button was a mailto: with a subject and nothing else, so clicking it
       opened a blank message and the owner had to scroll back up the
       notification to remember what they were answering.

       Building the body turned out to be the whole of the work, and the reason is
       esc_url(). It strips every character outside its allow-list before the
       browser ever sees the link, and two of the three things a quoted body needs
       are not on it: a line feed, and the angle bracket. So a body written with
       real newlines and > markers comes out of esc_url() as one unbroken
       paragraph with no quote markers, which looks like a broken link rather than
       a stripped one. mailto: is explicitly exempt from the %0A strip but nothing
       is exempt from the allow-list, so the body has to be percent-encoded before
       the link is built, not after. sr_notify_mailto() does that, quotes each
       line the way a person would, and uses %0D%0A rather than a bare %0A, which
       some clients accept and others drop.

       Length is capped at a word boundary with a marker, because a mailto: is a
       URL and long ones are truncated or refused differently by every client. The
       full text is in the message above the button, so trimming the quote loses
       nothing.

       The printed address is the other half. A mailto: only does anything where a
       mail client is registered for the scheme: Gmail in a browser, Outlook on the
       web, a phone with no mail app set up, and a locked-down work machine all
       ignore it, and a button that opens nothing is worse than no button. So the
       address is now in the plain text part too, where it can be selected and
       copied, and Reply-To is still on the header for anyone using their client's
       own reply button. The three cover each other rather than competing.

       Caught by the gate while doing it, and worth recording because I introduced
       it: splitting the reply line out of sr_notify() meant unsetting "to" so it
       could decide whether to print the address, and an absent "to" is read by
       sr_send_notification() as "none given, resolve one", which resolves to the
       owner's inbox. The visitor's acknowledgement was being delivered to the
       owner, addressed to the visitor, congratulating them on a booking they had
       made. It is the same fault as the mistyped-address guard one release back,
       reached from a different direction, which is the argument for testing the
       recipient rather than only the body.

   = 4.75.0 =
     * The booking form asks for a company, as an optional field, and the visitor's
       booking acknowledgement carries the link they are already being sent to.

       The column, the dashboard row and the notification row had all been there
       from the start, and nothing was ever collecting the value they were showing.
       Every booking notification arrived with a blank Company line, and the
       dashboard showed a row that read as a gap in the record rather than as the
       truth, which is that nobody had been asked.

       The field is optional and says so in the label rather than by the absence of
       an asterisk, because an asterisk means the opposite: it means the field
       cannot be left blank. Three of the four services are for individuals with no
       company to name, so requiring it would invite a blank or an invented answer.
       It sits beside Phone in one grid row, so it costs no extra vertical space.

       The handler reads both "company" and "Company", because the form's other
       fields post capitalised names and accepting one spelling only would store an
       empty string and look like a form that silently drops input. The value is cut
       to the column's 150 characters rather than left to the database to reject: a
       failed insert is a booking the visitor believes was taken and nobody has.

       A booking now produces two messages, both behind sr_notify() so neither can
       become the visitor's error page. The owner's is the record, with the
       reference leading and a button into the row. The visitor's is
       sr_confirm_booking(): their own details, their note quoted, their timezone
       echoed back, and a link to the scheduler carrying the reference.

       That link is sr_cal_embed_url(), the same function whose return value
       theme.js already follows as success_url. Not a similar URL, the same one. The
       visitor is redirected to the calendar page on submit, so a message linking
       elsewhere would send them off the page they were just moved to, and the two
       would drift the moment either changed. The reference rides along in both, so
       the slot they pick is matched back to the row, and the mail is the durable
       copy for anyone who closed the tab. With no published Calendar page, the
       Calendly address stands in, because a button to a page that does not exist is
       worse than no button.

       Reply-To on the visitor's copy points at the owner, not at the visitor. The
       message went to them, so a Reply-To carrying their own address would close
       the loop on themselves.

       Fixed while proving the above: a mistyped visitor address delivered the
       acknowledgement to the owner's inbox instead of failing. The guard passed an
       empty recipient list, an empty list is falsy, and sr_send_notification() reads
       a falsy "to" as "none was given, resolve one", which resolves to the
       notification address. The owner received a booking confirmation from their own
       site, thanking them for a booking they had made, and nothing said why.
       sr_confirm_booking() now returns before sending when the address is unusable.
       The same trap waits for any caller passing a list rather than an address, so
       the argument is documented rather than the one call site patched.

       The one visitor-facing defect this uncovered in the template is worth its own
       note, because it was in the part that had been working all along and it is
       not visible in the source. The field rows are two cells wide and the heading
       bar, the quoted message, the buttons and the footer are one cell each, so as
       siblings in a single table they were laid out in column one and rendered at
       about two thirds of the card's width. Only the row carrying colspan="2" ran
       full width. It read as a deliberate inset, which is what let it survive: the
       mail looked designed rather than broken. The card is now a one-column table
       with the field rows nested inside it, so the question cannot arise. The
       mobile rules came out of the same look: the label column used
       white-space:nowrap, which on a narrow screen refuses to break and pushes the
       value past the edge, so it is a width the media query can drop instead, and
       the labels stack above the values under 520px.

   = 4.74.0 =
     * Enquiries and booking requests are emailed again, to an address of your
       choosing, and the address stops doing two jobs at once.

       Both visitor-facing endpoints used to mail the owner after storing the row.
       Any fault in the mail layer then arrived after the row was written, so the
       visitor was shown a server error for an enquiry that had been saved and the
       dashboard was already listing it. /booking/ and /contact/ both returned the
       same 500, which is what identified the shared mail layer rather than either
       form. The mail was removed, which fixed the error and took the messages with
       it: an owner who was not watching the dashboard every day heard about
       nothing, and there was no longer any mail code on the visitor path to
       explain why.

       The mail is back, behind sr_notify(). wp_mail() can throw rather than return
       false, so that and anything below it is caught and reported rather than
       raised. The enquiry is saved, the owner hears about it, and a mail server
       having a bad day no longer becomes the visitor's error page. The dashboard is
       still the record; the message is the prompt to go and read it.

       sr_notify_contact() and sr_notify_booking() build the two messages from the
       same values the handlers already had. The layout is the existing table-based
       sr_email_template(), which was fully built and had no caller: heading bar,
       labelled rows in the order someone would ask for them, the visitor's own
       words quoted rather than summarised, and a button into the record. Palette
       is the site's own, restated as literal hex because a mail client has no
       custom properties and no webfonts. Reply-To is the visitor's address, so an
       enquiry can be answered by replying to it rather than by finding and copying
       the address across, which is the step that goes wrong.

       sr_notification_email is a new setting, and it is separate from
       contact_email on purpose. The two were one setting doing two jobs: the
       address the site publishes, and the inbox an enquiry lands in. A notification
       inbox is picked for convenience and can be a personal mailbox on another
       provider; the published address is part of the site's identity. Forcing one
       setting to be both meant forwarding notifications somewhere also published
       that address on the contact page. The default is
       coaching.happiness2u@gmail.com; the contact page still shows
       meet@saeedriaz.com. It is edited in Dashboard -> Email, beside the server
       settings that decide whether the message can be delivered at all, and it
       takes a list: separate addresses with commas, each validated on save, with a
       mistyped one refused and reported rather than silently stored.

       Fixed on the way: the addresses never resolved at all. Both
       sr_notification_to() and sr_contact_display_email() read through
       sr_option( $key, '' ), and an empty second argument is read as "the default
       is empty", which stops sr_default_options() being consulted at all. On any
       site that had not saved the field - every site, until somebody typed an
       address - both returned an empty list: notifications had no recipient, and
       the contact page printed nothing. The shipped address sat in the registry
       the whole time, unreachable from either. Both now read sr_optional_setting(),
       which asks the stored row first and the registry second, so a setting with
       real shipped copy is reachable and a deliberately cleared field stays
       cleared.


   = 4.73.0 =
     * The reading measure on a phone, and why the lines ended ragged.

       The root cause was not the text. It was a double count of the page inset,
       and it had been in the design from the start.

       .section carries padding-inline: var(--section-inset) - 26px a side at 320,
       31px at 390, 61px at 768. That is deliberate and documented at
       breakpoints.css:1510: the padding sits on the section rather than on
       .container so each section's background and any full-bleed child span the
       whole viewport while the content is inset. That part is correct and is left
       exactly as it was.

       The fault is what happens next. .container is width: min(100% - 32px,
       var(--container)), and 100% resolves against the section's content box,
       which the section padding has already narrowed. So content is inset once by
       the section and then inset again by the container, and the two never account
       for each other. Measured on the home page before this:

         320px viewport   section content 268px   container 243px   29 chars a line
         390px viewport   section content 325px   container 286px   33 chars a line
         414px viewport   section content 347px   container 306px   38 chars a line

       At 320 the text column was 243px, which at a 16px face is about 29
       characters a line. Readable body copy wants 45 to 75. That is the whole
       complaint in one number: at 29 characters a line the rag is severe, because
       a line that cannot hold much has nowhere to put its slack, so the eye gets a
       stair-step rather than an edge.

       The fix stops the two insets stacking instead of removing either. The
       container reaches back out by exactly the section's inset, so the pair
       compose into one and the measure becomes what it was always meant to be.
       After, with a 16px gutter each side:

         320px   243px -> 278px    33 chars a line
         390px   286px -> 348px    38 chars a line
         414px   306px -> 372px    46 chars a line
         768px   603px -> 726px    76 chars a line

       Written in terms of the token rather than as a fixed pixel figure, because
       --section-inset reaches 113.4px a side past 1418px viewport and a hard
       number would have broken there.

       The first attempt pulled the container all the way to the section's border
       edge and measured briefLeft at 0 - the width came back but the page gutter
       went with it, leaving the text flush against the edge of the screen. The
       margin is now minus the inset plus 16, which returns the gutter. The two
       numbers are related: 32px of container inset is two 16px gutters, and giving
       one back costs 16px of the width the other freed, which is why the width
       reads -32px + 2 x inset rather than a single inset and a single gutter.

       Scoped to .section > .container so full-bleed children, which depend on the
       section padding and on negative margins of their own, are untouched.
       .container-narrow is left alone: it is narrower on purpose and is used for
       prose that should keep a short measure. Above 1024px nothing changes,
       because there the inset is small relative to the measure and the desktop
       figure is the one that was tuned.

       Verified across 12 pages at five widths: no horizontal overflow anywhere,
       and no container closer than 12px to either viewport edge. That last check
       is the one worth recording, because the first attempt passed an overflow
       check and still had the gutter wrong. A layout can be within the viewport
       and still have nothing to spare at its edges.

       One thing this does not do, stated plainly because the request was to make
       every line end equally: left-aligned prose does not end equally, and making
       it do so means justifying it, which at a 33-character measure opens rivers -
       vertical channels of white space through the paragraph - and forces
       hyphenation to hide them. That is a worse reading experience than the rag it
       fixes. What has been fixed is the reason the rag was severe. If a flush
       right edge is wanted regardless, say so and it is one declaration.

   = 4.72.0 =
     * The last ragged column end, and why it survived the first pass.

       page-overrides.css writes, for the coaching split on the services page:

         #coaching .split-media { align-self: stretch; max-height: 500px; }

       Those two declarations defeat each other. align-self: stretch grows an item
       to the row height; max-height stops it growing past 500px. Whenever the copy
       beside it is taller than 500px the item cannot reach the row, and a stretch
       that cannot reach the row falls back to aligning at the start. The rule
       written to make the photograph fill its column is the rule that pins it to
       the top whenever the text runs long.

       It survived 4.67.0 because it is only visible at the narrow end of the
       two-column range, and that pass measured 1345px. Measuring all six widths is
       what found it:

         320-768px   one column, image is a banner above the copy, nothing to align
         1025px      two columns, copy 654px against media 500px, 154px ragged
         1446px      two columns, copy 500px against media 500px, matches

       1446px agrees by coincidence. The copy happens to land on 500px at that
       measure and nothing enforces it, which is the whole complaint: it matches at
       the width you happen to be looking at and parts company everywhere else.

       The cap is removed rather than worked around. It existed only to stop the
       photograph growing unreasonable, and object-fit: cover on the image already
       does that by cropping rather than stretching. With it gone, the
       align-self: stretch that was already declared does the job it was written
       for, and the image tracks the copy at every width. The image is taken out of
       flow so the figure contributes no height, or the figure would set the row
       height and then stretch to a row it had already decided.

       Below 1025px the grid stacks, the image is a banner in its own row, and the
       500px cap is left in place doing exactly what it was written to do: keeping
       a full-bleed photograph from becoming a 900px block on a phone. Verified it
       measures 182px, 214px, 229px and 452px at 320, 375, 414 and 768 - a banner at
       every one, never a tower.

       Twelve pages measured at six widths before and after: mismatches 1 to 0, no
       horizontal overflow introduced.

       Two measurement traps are worth recording, because both produced a wrong
       answer first and neither would have been obvious from the output alone. The
       browser here blocks window.resizeTo, so pages are measured inside
       same-width iframes, and a frame's scrollbar makes its CSS viewport 10px
       narrower than its attribute - 1025 requested is 1015 to a media query. Any
       breakpoint tested at exactly its own width needs that 10px accounted for.
       And the stylesheet was served from cache, so a fix that was demonstrably on
       disk and demonstrably served measured as having done nothing. Both were
       caught by distrusting a clean-looking result rather than by the result
       itself, which is the only reason they are written down.

   = 4.71.0 =
     * The arrow glyph is gone from every rendered page.

       72 occurrences across 20 files: the U+2192 RIGHTWARDS ARROW, written
       literally in six static pages and as &#8594; or &rarr; everywhere else. In
       the theme it was every one an entity, so the static pages were the only
       place raw bytes for it existed - and a raw UTF-8 arrow read as Windows-1252
       is what produces the a-dagger-quote sequence that prompted this. Removing
       the literal ones means no file now contains those bytes at all, so the
       encoding hazard goes with the glyph rather than being papered over.

       Worth stating plainly, because it was checked rather than assumed: there
       was never an encoding fault. Every static page declares charset=UTF-8, none
       carries a byte-order mark, and the live site serves Content-Type
       text/html; charset=UTF-8 and decodes as strict UTF-8 with zero occurrences
       of the mangled sequence. The files were always correct. What was seen was a
       viewer decoding them as Windows-1252.

       The whole .arw span is removed rather than just the character inside it.
       .arw carries a translateX on hover and margin for the gap, so an emptied
       span would still animate and still hold space. Verified afterwards: no
       empty .arw spans remain anywhere.

       Left in place: the 126 Phosphor ph-arrow icons. Those are an icon font, not
       a character, so no charset can corrupt them and removing them would be
       removing the design's iconography rather than fixing an encoding problem.
       Two sentences carrying the arrow as navigation prose had it replaced with a
       comma - "Posts, Add New" and "Dashboard, Install / Setup" - since a
       directional glyph there was doing nothing a comma does not.

       Parity held throughout: 16 pages compared, 0 mismatches, and the static and
       theme forms were changed together so neither drifted.

   = 4.70.0 =
     * The footer shows the page you are on, as a code box.

       Built from $wp->request rather than from $_SERVER['REQUEST_URI'], which is
       the difference between a box that reflects the address bar and one that
       sometimes prints a host the visitor never visited. On some hosts the latter
       arrives as an absolute URI from an internal proxy. $wp->request is
       WordPress's own reconstruction from its rewrite rules, so it is the same
       path the rest of the site is addressing, and home_url() supplies the scheme
       and host the site is configured with rather than the one the request landed
       on. The trailing slash is put back because WordPress strips it and every
       permalink here ends in one, so without it the box would show a URL that
       does not match the address bar it exists to reflect.

       It is a code box and not plain text so the value reads as something to
       select and copy rather than as a sentence. No copy button: text selection
       already does it, and a button would add a script and a focus trap for
       nothing.

       Sized in ch rather than a percentage, because a URL is variable length and
       the failure without it is a long path wrapping mid-word. 64ch holds a
       typical permalink and ellipsises a longer one, with the full value on the
       title attribute.

       Verified on four pages at 1280px and 390px: one line each, no wrapping, no
       horizontal overflow. The footer bar grows from 101px to 121px on a phone.

   = 4.69.0 =
     * The responsive sweep, run against every page rather than by eye.

       20 pages at 320, 375, 414, 768, 1025 and 1446px, measured in a real engine.
       window.resizeTo is blocked in the browser available here, so each page was
       put in a same-width iframe instead: a frame establishes its own viewport for
       media queries, which is the only way to see a page at 320px from a desktop
       window.

       The headline is that the responsive layout is sound. There is no horizontal
       overflow on any page at any of the six widths, before or after this change.
       Nobody should have assumed otherwise and nobody did - it was measured.

       What the sweep found was size, and only at the narrow end. .btn carries
       0.68rem at its narrowest, which is 10.88px: the button's own text below the
       12px floor, on the 404 page's "Contact Me". .cic-label is 0.6rem, or
       9.6px, and is the smallest text anywhere on the site - the "Email" and
       "Phone" captions in the contact cards. .chip and .hook-label sit at 0.7rem.
       All four are lifted to the next value the stylesheets already use rather
       than to one invented here: .btn to the 0.75rem its sibling .btn-gold
       already uses, the rest to 0.7rem or 0.75rem. Measured after: .cic-label is
       11.2px from 9.6px, every button is at or above 12px, and the smallest text
       anywhere is 10.88px.

       Two things were deliberately not "fixed", and the distinction matters. There
       are around 230 targets under 24px per width - nearly all 18px navigation
       links - and almost all of them pass WCAG 2.2's spacing exception, because a
       24px circle centred on each one clears its neighbours. Calling 230
       accessibility failures would have been wrong. Exactly two fail: the two
       phone-number links in the contact cards, 112x22 at 320px and close enough
       together that the spacing exception does not save them. Those got a
       min-height and now measure zero failures.

       The remaining sub-12px text sits between 11.2 and 11.52px. That is a normal
       size for an uppercase label and WCUG sets no minimum, so lifting it further
       would be taste rather than a correction.

       The selectors carry a body prefix, and that is load-bearing. breakpoints.css
       is enqueued after this file and is what sets .btn, .cic-label, .chip and
       .hook-label to these sizes. Written as bare class names they match at equal
       specificity, the later file wins on order, and the correction silently does
       nothing. The first attempt was written that way and measured as having
       changed nothing at all - a rule that loses the cascade is indistinguishable
       from a rule that was never needed, which is worth remembering for any future
       work in this layer.

       Raising the type sizes introduced no new overflow, which was the risk worth
       checking for and was measured rather than assumed.

   = 4.68.0 =
     * Every booking button now reads "Book an Appointment".

       The site carried the same call to action under five different labels. The
       inventory that produced this was a read-only scan of every anchor and button
       whose visible text begins with Book, across the static pages and the theme,
       with each one's href attached - because the risk was not missing a button but
       renaming one that is not a booking button.

       Ten were renamed: "Book a Free Discovery Call" on the about, contact and
       packages pages and in two template files, "Book a free call" on the FAQ page
       and its template, "Book a free clarity call" on the home hero, and "Schedule
       a Free Consultation" in the services closing CTA. All of them point at the
       same scheduler page as the other 80-odd, so they were the same action wearing
       five different names.

       Ten deliberately were not. "Book via WhatsApp" opens a chat rather than the
       scheduler. "Book Program" and "Book This Program" preselect a programme
       through the service query parameter, and the label is the only thing on the
       card naming which programme, so the generic wording would take information
       away rather than add consistency. "Book Another Session" is the book-again
       control on the result page, which means something different after a booking
       than before one.

       The scan could not see the theme on its own and that is worth recording: most
       theme buttons put their text inside esc_html_e(), so stripping tags for the
       label produced an empty string and the theme appeared to contain no booking
       buttons at all. The theme was checked separately, which is how the two
       package button_text defaults and the front-page hero were found.

       Those defaults only govern a package that has never been saved. A package
       already stored keeps whatever label it was given, and that value is editable
       per package in the dashboard.

   = 4.67.0 =
     * The ragged column end fixed on every page that had it, not just the home page.

       Every static page was measured in a browser rather than read, looking for
       two-column grids holding one image and one block of text, top aligned, with
       the columns ending at different heights. Three were found across 22 pages
       and all three are corrected. Seventeen pages had none and are untouched.

       The Approach split on the home page. Measured: the media box is 500px and
       its copy is 367px, so 133px of the column ended in nothing. It is the same
       fault as the welcome section and the same fix applies. .split-top is the
       only rule on the site that opts out of the centring .split does by default,
       and #approach is the only place it is used. Now 0px.

       That one carried a second fault which the first fix cleared. The design caps
       the figure with max-height: 500px and gives the image height: 100%, and a
       percentage height cannot resolve against a parent that has a max-height but
       no height. The image therefore fell back to its natural 680x708 ratio,
       rendered 538px tall inside a 500px box, and the bottom 38px was clipped by
       the figure's overflow:hidden. Taking the image out of flow and letting the
       figure take the row height resolves both at once: the box is the copy's
       height, the image fills it exactly, and nothing is cut off. Clipping
       measured 38px before, 0px after.

       The two story grids on the about page are centred rather than equalised, and
       that is a different answer on purpose. Measured: the copy is 997px and the
       portrait 698px. Equal ends are not reachable and pretending otherwise would
       break the design. .story-media carries aspect-ratio 4/5 inside a 544px
       column, so its height is fixed by its width; reaching 997px would need
       798px of width, wider than its column, or a 0.52 portrait against a design
       asking for 0.8. Nor can it shrink, because the text is the taller of the
       two. Centring turns 315px of ragged bottom into 150px above and 150px
       below, and the portrait reads as a deliberate offset beside a long block of
       prose instead of a column that stopped early.

       Verified on the rendered pages with theme-wp.css loaded, which is the only
       way to see any of this: the static files do not load that stylesheet, so
       measuring index.html alone reports the old numbers. Home ends now differ by
       0px on both sections, and the about page's two grids are symmetric at 150
       and 30px.

   = 4.66.0 =
     * The welcome photo and the welcome copy now end on the same line.

       They did not, and the gap grew with the viewport rather than staying put.
       The photo is width min(100%, 420px) with a pinned aspect-ratio, so at a
       wide screen it is a fixed 399px tall while the three paragraphs beside it
       lose lines as the measure widens. The grid sets align-items:start, so the
       difference landed entirely under the copy. The wider the screen, the larger
       the void; at a narrow one it inverts and the photo ends short instead.

       The text now drives. The image is taken out of flow so it stops
       contributing its own height to the grid row, leaving the copy as the only
       thing setting that height; the figure stretches to it and its aspect-ratio
       gives back a width that keeps it circular. Scoped to the two-column layout
       at 769px and up, because below that the grid stacks and there are no two
       ends to line up.

       Measured in a browser at three copy lengths. Before, the void under the
       text was 77px with the real copy, 282px with a single short paragraph, and
       -22px with three long ones. After, it is 0 in all three. Where the copy is
       taller than the photo's column allows, max-width takes over and the circle
       gets smaller rather than stretching into an ellipse, which is the right way
       round: a circle is the design.

       One honest caveat. Where the copy is the taller of the two the figure keeps
       its column width and takes the copy's height, so the proportion settles at
       about 0.98 rather than the pinned 1.052. Both read as a circle; the
       difference is real but small. Four ways of pinning the width back to the
       ratio were tried and all four measure the same, so the simplest was kept.

   = 4.65.0 =
     * "The Core Philosophy" heading removed, and the gap it left closed.

       The heading was removed at the owner's request, and with it the
       .philosophy-body div that held nothing else. That div existed only to give
       the heading a box, so keeping it would have left an empty one occupying
       the 900px measure and its vertical padding.

       A side effect worth recording: the page carried two h1 elements, the hero
       headline and that heading. There is now one.

       The 40 to 72px above the section's detail block goes from 24 to 40px. That
       margin was never a general gap. page-overrides.css labels the rule "Section
       03 detail copy under the philosophy quote", and that is exactly what the
       clamp was: the space under the pull quote. The quote is gone, the margin
       stayed, and it went on separating the welcome brief from the first
       philosophy heading instead - two unrelated blocks carrying a gap sized for
       a division that is no longer there.

       The replacement cap is the site's own section rhythm,
       clamp(32px, 4.2vw, 48px), stepped down because this is a division inside a
       section rather than between sections. No new number is invented. It is
       declared in theme-wp.css because page-overrides.css has to stay
       byte-identical to the static source, which still carries the original, and
       it is declared in the collision gate so it is printed for review on every
       run rather than sitting there unremarked.

     * Line endings are governed, and 18 files normalised.

       Every commit that touched style.css warned "LF will be replaced by CRLF".
       The warning was the symptom. The cause was that no .gitattributes existed,
       so each file kept whatever ending it was last written with: across the
       working tree, 130 files were CRLF, 21 were LF, and 3 held both endings in
       a single file, style.css among them.

       A .gitattributes now applies text=auto across the tree and marks the
       binary formats, without which git is free to guess wrong about an image
       and corrupt it in the repository rather than warn about it. The 24 files
       that were LF-only or mixed were normalised at the byte level, so no
       encoding was round-tripped and no byte other than the added carriage
       returns moved.

       The repository itself was never at fault and the fix does not change it:
       core.autocrlf was already true, and after normalising, every one of those
       files still diffs to zero lines against HEAD. The defect was confined to
       the working tree, which is where the warning came from and where it was
       fixed. What .gitattributes adds is the guarantee, so the next file written
       by a different tool cannot reintroduce it.

   = 4.64.0 =
     * The pull quote is gone from the philosophy section.

       Removed at the owner's request. The box and its curly-quote ::before glyph
       are the display; taking the display away was the point.

       The paragraph it held was not removed with it. "The Heart of Wellbeing
       Philosophy" is now the third block in the detail below, sitting alongside
       the other two in the order the client wrote them, and it is still editable
       under Site Settings. Deleting the client's copy along with the box it sat
       in would have been a different request.

       The option was renamed from philosophy_quote to philosophy_closing rather
       than left under a name describing a box that no longer exists. The
       pull-quote wording never reached a live site - 4.62.0 carried it and
       nothing before 4.63.0 has been uploaded - so there was no stored value to
       orphan and no migration needed.

       Two rules go unused as a result and neither can be cleaned up: both live in
       stylesheets required to stay byte-identical to the static site.
       .philosophy-quote and its ::before, and .philosophy-section .philosophy-body
       .h-mid, which had been tuned with a 39-character heading to fit one line on
       a phone. Neither is harmful and neither is in theme-wp.css, so nothing is
       now unreachable there. .h-mid itself is still used by six other pages.

   = 4.63.0 =
     * The welcome brief and the philosophy are one section.

       They read as a single piece of writing, and as two sections they put a
       section boundary in the middle of it. The band colour does not change:
       .section already sets background:var(--butter) for every section on the
       site, so what disappears is one set of vertical padding rather than a
       colour. Neither id is linked from the navigation, so nothing that pointed
       at them breaks.

       #meet survives on an inner div rather than on the section. Every rule for
       it is a descendant selector - #meet .welcome-grid, #meet .welcome-brief,
       #meet .welcome-title, #meet .welcome-media, and the single-column collapse
       in breakpoints.css - so an ancestor was all it ever needed to be, and the
       welcome keeps its two-column layout and its photo column. The section keeps
       id="philosophy" beside .philosophy-section, which is the pairing those two
       selectors originally shared, so the 900px quote measure, the 820px detail
       measure and the left alignment all still resolve.

   = 4.62.0 =
     * The home page philosophy section carries the client's replacement copy, and
       the paragraphs behind it are now editable in the dashboard.

       The new wording overlaps the previous text almost sentence for sentence:
       both open on "In today's world of", both say professional success is hollow
       without thriving relationships, and both define true wealth as more than
       the bank balance. It is an expanded rewrite, not an addition, so it
       supersedes. Appending it would have left the page stating the same five
       points twice in a row, in different words.

       It is laid out over the two boxes the design already provides. "The Heart of
       Wellbeing Philosophy" heads the pull quote; the other two headings and their
       paragraphs sit in the detail block below it. The headings are static and
       deliberately not fields: the wording of a heading is a design decision, and
       offering one as a setting invites somebody to rewrite a heading into a
       subheading. The body is a setting because that is the part a site owner
       genuinely revises.

     * "True wealth" keeps the serif display face the static site gives it.

       The quote is the one saved value that can carry a tag. It is filtered at the
       echo site against an allowlist holding that single class, rather than
       escaped, because escaping would print the span as literal text in the middle
       of the sentence. The same allowlist is applied on save, since the default
       cleaner strips tags and the emphasis would otherwise vanish the first time
       anybody pressed Save. template-packages.php already treats <strong> and <br>
       this way.

     * breakpoints.css is byte-identical to the static site again.

       The scheduler iframe fix lived in it, directly under the reset it fights,
       which meant a theme-only rule had crept into a file that has to match the
       static source exactly. It works from theme-wp.css instead, and does not need
       to load afterwards to do so: order is not what decides this, specificity is.
       The reset is "img, video, embed, iframe, object" at (0,0,1); the fix is
       ".calendly-inline-widget iframe" at (0,1,1), which outranks it wherever the
       two are written. It is the only rule in the file that sets a height on an
       iframe, so there is nothing else for it to lose to.

     * Removed eight unreachable classes from theme-wp.css.

       .legal-layout, .legal-sidebar, .legal-content, .legal-section, .legal-updated,
       .media-grid, .media-feature and .media-placeholder each styled a page
       template that no longer exists. Nothing left in the theme emits any of them,
       across all 93 sources. The responsive rules that shared lines with them went
       with them; the reduced-motion reveal safety net that shared a line was kept.

   = 4.61.0 =
     * The shortcode layer is gone. The theme writes the Calendly embed itself.
       [sr_scheduler] and [wpcalel] are no longer registered, and the Book an
       Appointment page now holds prose only - the scheduler is drawn onto it at
       render time by sr_scheduler_on_booking_page().

       A shortcode was a poor fit for this particular job, and the reason is worth
       recording because the alternative looks obvious. It put the embed in two
       places at once: as text stored in the page body, and as code in the
       renderer. Those two drifted, twice, in opposite directions. The stored copy
       carried no brand colour while the renderer applied one, so a page saved
       before an upgrade showed a different scheduler from a fresh install of the
       same theme - and the owner had no way to tell which was in play.

       The obvious replacement, storing raw script tags in page content, cannot
       work: WordPress strips them on save and they never run. That constraint is
       what the shortcode existed to work around, and working around it is what
       created the drift. Drawing the embed at output time avoids both: there is
       one source of truth, so the scheduler on a page saved last year is the
       scheduler this release draws.

       Removing a registered tag has a consequence worth naming. WordPress renders
       an unregistered shortcode as literal text, so any page still holding
       [sr_scheduler ...] would print its own brackets to a visitor. The tags are
       therefore stripped from page content at priority 8, before the embed guard
       and the scheduler filter. The content on disk is untouched - still in the
       database, still editable, still reversible - and an administrator still
       sees it, because WordPress leaves unparsed text for them anyway.

       The first attempt at that was worse than doing nothing. Adding the theme's
       own tags to sr_cal_shortcode_tags() also catches them, but that list is
       read by sr_cal_embed_guard(), which does not tidy a tag - it replaces the
       whole page with "the booking calendar is not set up yet". An upgraded site
       would have shown that in place of a scheduler that was working perfectly
       two releases earlier. Stripping is the fix; the guard is for content that
       genuinely cannot render.

       Registering the tags again to make old content render is not the fix and is
       the fault that started all of this: it is what put a second scheduler on
       the page in the first place.

       The two has_shortcode() tests in sr_scheduler_on_booking_page() are kept
       though the strip makes them unreachable on the normal path. The comment
       there records why, so the next person does not tidy one away and reopen the
       duplication bug.

   = 4.60.0 =
     * The scheduler now wears the site's colours.
       Calendly's defaults are a blue on white, which made it the one element on
       the page that did not belong to the page. Measured on the live site, every
       date pill, the month arrows and the selected-day dot came back
       rgb(0, 102, 255) against a header button of rgb(34, 84, 76).

       background_color and text_color are now accepted alongside primary_color
       and are all applied the way Calendly applies them: as query parameters on
       the event URL, which is where it reads them. Painting the frame in CSS would
       colour the surround and leave the widget's own controls in the old colour,
       which is the thing someone setting a brand colour is trying to change.

       The values are the site's own, read off its CSS rather than guessed:
       #22544C is --gold, which despite the name is the deep teal the Book an
       Appointment button is painted; #FBF8F3 is --ivory, the page ground; #201C16
       is --charcoal. All three are validated as hex before they are sent and
       dropped with a log line if they are not, rather than escaped and trusted -
       these end up in a URL the embed loads cross-origin.

       The full profile stays. Calendly offers hide_event_type_details=1, which
       removes the organiser photo, the meeting name and the duration, and it
       looks tidier. It is the wrong trade here: the visitor is booking a specific
       person for a specific length of time, so that block is the information
       rather than the clutter. It can be set by hand on the page if that changes.

     * The auto-injected scheduler no longer builds its own widget.
       sr_scheduler_on_booking_page() wrote a second copy of the widget markup by
       hand, parallel to sr_calendly_inline_widget(). Two copies of the same markup
       is two copies to keep in step, and they had already drifted: the hand-built
       one carried no brand colour at all, because colour was only ever applied
       inside the renderer. A page relying on the auto-inject got an unstyled blue
       scheduler while a page carrying the shortcode got the site teal, from the
       same code and the same setting.

       It also carried its own copy of the calendly.com host check, which is why
       that check had to exist twice and why this was the route that bypassed it.

       It now renders through the one renderer. The widget div is built in exactly
       one place in the theme, so there is nothing left to fall out of step, and a
       page with the shortcode and a page without it produce identical markup.

     * Three helpers for the three kinds of cell on the booking detail.
       sr_booking_row() for plain text, 'multiline' for what the visitor typed, and
       sr_booking_link_row() for the meeting URL. Meeting was previously a third
       argument to sr_booking_row() carrying ready-made markup, and that is how a
       visitor's own message ended up wrapped in the muted .description span - the
       emptiness check and the escaping had become two separate decisions about
       one value. One helper per kind of cell keeps each one honest.

       Date is now formatted into the label rather than into the cell, so the
       emptiness check and the format read the same field.

     * Removed from the booking detail's Client card, on the same rule as the
       Appointment card: no value, no row.
       Company and Purpose were named. Phone and Message were in exactly the same
       state, printing "Not provided" and "Not provided", so leaving those two
       behind would have produced a Client card that still read as a list of gaps,
       just a shorter one. Name and Email are printed unconditionally because the
       form will not submit without them, so an empty one means the row is corrupt
       rather than optional, and hiding it would conceal that.

   = 4.59.0 =
     * The scheduler iframe was being collapsed to 150px by a global CSS rule.
       The container is set to the right height by the theme, and the Calendly
       iframe inside it was being forced to the HTML default. The visitor saw a box
       a tenth of the height they were meant to, with the calendar cut off below
       the month grid and the time-slot list - the entire reason the page exists -
       off the bottom.

       The cause is line 8 of breakpoints.css:

           img, video, embed, iframe, object { max-width: 100%; height: auto; }

       That is a correct rule for images and for media that should hold its own
       aspect ratio, and it is what stops a wide image forcing a horizontal
       scrollbar. But height:auto on an iframe whose height is a deliberate number
       destroys that number: an iframe reports no intrinsic ratio, so auto resolves
       to the 150px default.

       It never looked like a CSS bug. The frame was present, the calendar rendered
       inside it, the box had the correct width, and the page returned 200. It
       looked like Calendly had served a short calendar - which is exactly what an
       owner would go and check, and Calendly was serving correctly the whole time.

       The fix restores the attribute the element actually carries. Calendly writes
       height="100%" on the iframe, which resolves against the 1088px container.
       The rule is scoped to .calendly-inline-widget rather than resetting iframes
       generally, because a blanket iframe reset would undo height:auto for YouTube
       and every other embed that genuinely does rely on the ratio.

       Verified on the live page by removing and restoring the rule at runtime:
       1088px with it, 150px without it. An ordinary 360px iframe kept its authored
       height throughout, so the scoped selector is doing the narrow job.

     * Scheduler height returned to 1088px in both paths.
       4.58.0 set the preset to 650px from the owner's embed. That was a
       reasonable number and the wrong one to ship as a default: it fits the month
       grid with room to spare and clips the time-slot list the moment a visitor
       picks a day. 1088px is what the theme shipped before and is sized for the
       whole flow rather than its first screen.

       Both paths now say 1088px. The preset writes it into the page content and
       the renderer defaults to it, so a page carrying the shortcode and a page
       relying on the default cannot disagree about how tall the scheduler is -
       and when a page is migrated from one to the other, the scheduler does not
       change height on the day it happens.

     * Empty rows removed from the booking detail's Appointment card.
       A booking that had been submitted but not yet scheduled rendered four
       consecutive rows reading "Not provided", "Not scheduled yet", "Not scheduled
       yet" and "No meeting link yet". The card was mostly a list of what had not
       happened yet, and a booking that had in fact succeeded read as unfinished.

       Date and Time are the clearest case. Both stay empty until the scheduler step
       runs, which is a state rather than a fault, and saying so was accurate. It
       was still four rows of accurate emptiness in a card holding two real values.
       A cell reading "Not scheduled yet" only becomes information beside a row
       that is filled in, and once one booking is scheduled the pairing stops
       existing for every other booking.

       The rule is one line: no value, no row. Applied uniformly it also covers
       Timezone, and it means a column that stops arriving - Plan in particular,
       which the current booking form never populates - costs nothing rather than
       needing to be dropped from the markup as well. Display only; every column is
       still written and still read where it is needed.

       The booking detail's Status row was removed at the owner's request, and it is
       recorded in the code what that costs. That form was the last place a
       booking's status could be changed, so there is now no screen in WordPress
       that can mark one Booked, Cancelled or Complete; bookings sit at Details
       Submitted indefinitely. The column and the update_status handler are both
       deliberately left in place, so it is a reporting limit rather than data loss,
       and restoring it is uncommenting one block. The lead screen's own Status
       control is untouched - a lead is a different record and still has one.

   = 4.58.0 =
     * The scheduler now ships configured rather than blank.
       The default event URL is https://calendly.com/meet-saeedriaz/30min, so a
       fresh install is bookable out of the box rather than showing the not-set-up
       notice. The default preset written onto the Book an Appointment page carries
       height 650px, min-width 100% and primary_color #1a5653.

       This replaces the stranger's calendar removed in 4.56.0. That removal was not
       about a default URL being wrong in itself, but about the default pointing at
       somebody else's live account, which answers 200 - so an unconfigured site
       handed its visitors a working booking page it had no control over. A default
       that points at the owner's own event cannot do that.

       Still only a default: sr_option() prefers a stored value, so any site that
       already saved a URL keeps it, and Dashboard -> Site Settings remains where
       the owner changes it.

     * [sr_scheduler] accepts the attributes the preset needs.
       It took only 'url'. shortcode_atts() drops any attribute not named in its
       defaults and does so silently - no warning, nothing in the page source. So
       the preset could have been written with a height and a primary colour in it,
       looked configured, and rendered at the default height in the default colour.

       The owner's own embed is what proved it: the attributes were present in the
       markup and absent from the output. height, min-width and primary_color are
       forwarded now, and only when actually given, so an unset attribute falls
       through to the renderer's default rather than overriding it with an empty
       string that then fails the length check.

       All three pass the renderer's existing validation: 650px and 100% match the
       CSS-length pattern, and 1a5653 matches the hex pattern. primary_color is
       applied as a query parameter on the event URL, which is where Calendly reads
       it - painting the frame in CSS would colour the surround and leave the
       widget's own controls in the old colour.

     * Two attributes from that embed are deliberately not carried.
       prefill="on" and query_str="on" are switches belonging to the embed plugin's
       tag, not to Calendly. Calendly takes prefill data as query parameters on the
       event URL - ?email=, ?name= - so there is no flag to set. Neither reaches
       this renderer, and shortcode_atts() discards unknown keys without warning,
       so keeping them would read as configured while doing nothing.

     * A second route to the duplication just fixed is closed.
       sr_scheduler_on_booking_page() tests the page content for sr_scheduler before
       appending a widget, but never for wpcalel - even though the theme registers
       that tag itself and draws the same widget. A page carrying [wpcalel ...]
       therefore passed every check and had a second scheduler appended below the
       footer: the identical fault fixed on the initInlineWidget() side, still
       reachable from the content side.

       That mattered here because [wpcalel] is the tag the theme used to ship as its
       preset, and the tag an embed plugin gives an owner to paste. Both now stop
       the append.

   = 4.57.0 =
     * The contact form's Send Message button was 957px wide.
       It spanned the entire form column while the pill design clearly called for a
       compact button. What is worth recording is that the obvious explanations
       were all wrong, and each one that was checked cost a step.

       Not padding. .btn carries padding: 14px 28px, which is modest and is the
       same on every button in the theme.

       Not .btn. It declares display: inline-flex, which asks for shrink-to-fit -
       exactly what was wanted and exactly what did not happen. The computed value
       on the live page was flex, not inline-flex, and that discrepancy was the
       whole bug.

       The cause is the parent. .contact-form is display: grid, and a grid item is
       blockified: inline-flex computes to flex, and the initial justify-self of
       stretch then makes the item fill its column. No trimming of .btn's padding
       could have touched this, because nothing in .btn was winning in the first
       place.

       The comparable button settles it. Submit Booking on the booking page sits
       in a flex row and has always measured 200px. Send Message sat in a grid and
       measured 957px. Same .btn classes, same padding, same font - different
       parent.

       One rule fixes it: justify-self: start on the direct child, which returns it
       to its content width at 177px with height, padding and the pill radius
       untouched. Verified by injecting the exact rule on the live page and
       re-measuring: 957px before, 177px after.

       Placed in theme-wp.css rather than page-overrides.css because that layer is
       byte-identical to the static site's, and its .contact-form grid is shipped
       content rather than a WordPress artefact.

   = 4.56.0 =
     * A second booking calendar was rendering underneath the footer.
       The scheduler page showed the real embed in the right place and a second,
       working copy of the same calendar below the copyright line. Nothing on the
       page reported an error, which is how it survived: two widgets are a layout
       fault rather than a failure, and the page still looked plausible.

       The cause was two ways of doing the same job. sr_calendly_inline_widget()
       wrote the .calendly-inline-widget div, which is the whole of Calendly's
       documented embed - widget.js finds those on load and initialises them. It
       also added an inline script calling Calendly.initInlineWidget(), as though
       the widget had to be started by hand. Both ran. The div was initialised by
       the script, and initInlineWidget() then built a fresh widget and appended
       it to the end of <body>; because the footer is printed before the footer
       scripts, it landed under it. The inline script is gone.

     * Cal.com defaults removed, and the last Cal.com name in the theme with them.
       cal_com_url defaulted to https://calendly.com/mehmmoodm30/30min - a real
       booking page on somebody else's account, which answers 200. A site that had
       configured nothing would hand its visitors a stranger's calendar while
       looking correctly set up. The default is now empty, which produces the
       not-set-up notice instead.

       The Cal.com webhook signing key defaulted alongside it. The webhook that
       read it is long gone, so carrying the name only implied there was still an
       integration to configure. Removed.

       The option key is renamed cal_com_url to calendly_url, with the value
       carried across by sr_migrate_retired_options() rather than dropped -
       renaming a key is a data migration, not a find-and-replace. Doing it while
       the site is changing provider is what makes it safe, because the stored
       value is being replaced anyway.

     * The host check that guards the scheduler now does something.
       It refused any URL not on calendly.com and then fell back to re-reading the
       stored setting - the same string it had just rejected. On any site whose
       scheduler was not on calendly.com the guard rejected the value and
       substituted an identical one, so it did nothing while appearing to work.

       This site was configured with a cal.com address, so that is exactly what
       happened: the check silently stopped applying, and the only symptom was a
       calendar served by a provider the write-back does not listen to. It now
       falls back to the not-set-up notice.

       sr_scheduler_on_booking_page() writes the widget div itself rather than
       going through sr_calendly_inline_widget(), and never had the check at all -
       the one route onto the scheduler page that bypassed the guard entirely. It
       validates the stored host the same way now.

       The consequence to expect: until a calendly.com URL is saved under Site
       Settings, /calendar/ shows the not-set-up notice. That is the intended
       behaviour. A Cal.com address carried across by the migration is still a
       Cal.com address, and saying no scheduler is configured is better than
       rendering a calendar whose confirmed slots the theme cannot record.

     * Consequence worth stating plainly.
       The slot write-back listens for Calendly's postMessage event from
       calendly.com. While the page was serving a Cal.com embed, every message
       was discarded on the origin check and the event name would not have matched
       either, so a confirmed time was never written back - only the details-form
       submission reached the Bookings tab. The handler needed no change to be
       correct; it needed the page to actually be on Calendly.

   = 4.55.0 =
     * Twelve page templates removed, none of which any page could reach.
       book-schedule, book-a-call, booking-success, booking-cancelled,
       booking-rescheduled, gallery, group, one-on-one, privacy, terms, cookies
       and video. Their routes all answer 404 on the live site, so nothing that
       was serving a visitor came with them.

       How they were identified is the part worth recording, because the obvious
       method gives the wrong answer in both directions. Counting a template name
       across the theme reported twelve orphans and four more that were merely
       unreferenced in code - and that second group was a false alarm. The four
       booking-result templates were deliberately kept on disk as selectable
       choices, per the installer's own comments, and deleting them would have
       undone a decision made on purpose.

       Two further false positives had to be chased down before anything was
       removed. A slug reference to 'gallery' and another to 'programs' both
       looked like live code and neither was: both sat inside block comments
       describing routes that had been removed. The first scan missed them
       because the continuation lines of a block comment do not begin with an
       asterisk, so a StartsWith('*') test does not identify them.

       The other was /group/, which answers 200 on the live site and looked like
       solid proof that template-group.php was still in use. Its body classes are
       single single-service postid-79 - it is a Service post type entry, not a
       page using that template at all. The markup match that first suggested
       otherwise was the string "group-coaching", which is an anchor fragment to
       /packages/#group-coaching and is emitted by three different templates.

       Only after all three were resolved was anything deleted. That is 812 lines
       of template code and 94 KB.

     * booking.css and its enqueue removed with them.
       Its only reference in the theme was the is_page_template() check covering
       those four booking-result templates, and none of them could be assigned by
       an install. It never applied to /booking/ either, because that page uses
       template-booking.php and was never in the list - so the stylesheet that
       exists to style the booking flow has never been on the booking flow.

     * Six functions removed, each verified to appear exactly once in the theme:
       at its own definition and nowhere else.
       sr_booking_services, sr_try_side_effect, sr_send_client_confirmation,
       sr_notify_lead, sr_lead_is_first_time and sr_send_lead_autoreply. Four of
       the six were orphaned by this series itself, which is the honest reason to
       look: removing the mail from the form paths removed the only callers of
       sr_notify_lead, sr_send_lead_autoreply, sr_lead_is_first_time and
       sr_send_client_confirmation, and sr_try_side_effect was the guard wrapped
       around those same calls.

       Counting function names with a regex that only matches call syntax reports
       fifty-six dead functions, and all fifty-six are add_action and
       add_shortcode callbacks. The version here that works strips comments
       first and then counts every occurrence of the identifier, because a name
       mentioned in prose is not a use and a callback registered as a string is.
       Against 182 functions defined it finds six, and each was then confirmed by
       hand before deletion.

     * A stray indentation fixed at the inc/packages.php require.
       One of eighteen consecutive require_once lines was indented by four spaces.
       Nothing functional, and the kind of thing that survives because nobody reads
       that block.

     * Removing the templates exposed four broken references, all of them created
       by that removal rather than pre-existing.
       demo-content/demo-content.php still assigned four pages to
       template-group, template-one-on-one, template-gallery and template-video.
       WordPress stores whatever string is in _wp_page_template without checking
       that the file exists, so the seeder would have created four pages that
       render with no template at all - and the route check would not have caught
       it, because the pages would exist. Those four entries are gone, every
       remaining assignment in the seeder now resolves to a file on disk, and the
       two content helpers they used are removed with them.

       The same applies to the legal-cookies and legal-terms template parts. Their
       only consumers were the three policy templates, so nothing was left calling
       them.

     * Documentation brought back in line with the code.
       MIGRATION-NOTES.md still described all twelve removed templates, still said
       the scheduler write-back "is what sends the confirmation email" when no
       mail is sent, still pointed at /book-schedule for the embed that now lives
       on /calendar, and still listed configuring SMTP as a pre-launch step.

       Five comments inside setup-wizard.php claimed deleted templates "stay on
       disk and remain selectable in the editor", and one advised assigning
       template-gallery.php to a page to restore the gallery listing. All six
       described a file that no longer exists. The two claims that survived the
       check - template-programs and the three wellbeing templates - were left
       alone because those files do still exist.

     * Not removed, and the reason matters.
       notifications.php still holds sr_send_notification, sr_email_template and
       sr_notification_cc, and none of the three has a caller left. The mail layer
       is reachable now only through wp_mail() itself, and the dashboard has no
       send-a-test button - an earlier note in this changelog claimed one did,
       which was wrong, and is corrected here.

       They were kept because deleting them would take the theme's entire
       outbound-mail capability with it: smtp.php hooks phpmailer_init, which
       fires for every wp_mail() call including any plugin's, and sr_send_notification
       is the only theme code that builds and sends one. The other three
       functions in that file are genuinely live and are untouched:
       sr_address_list parses the saved address settings, sr_contact_display_email
       supplies the mailto: on the contact and legal pages, and sr_notification_to
       is read directly by the dashboard.

   = 4.54.0 =
     * The theme screenshot was four designs out of date.
       It was still showing the red accent that 3b4acb5 removed, a hero reading
       "Elevate your life. Restore your balance." beside an abstract geometric
       illustration rather than photography, a "Book a Session" button that the
       site stopped using, and a navigation bar of five links against the eight it
       ships now. It was a picture of a theme that no longer existed.

       Replaced with the live homepage: current accent, current hero, current
       navigation, current CTA wording. The dimensions were already right at
       1200x900, so only the content was wrong.

       How it was produced is worth recording, because two of the three attempts
       were wrong and would have shipped a broken image.

       The first crop was cut from the middle of the capture, 43 pixels off each
       side, which decapitated the wordmark to "ED UR RIAZ" and sliced the booking
       button in half. That looked exactly like a layout bug - the header running
       off both edges of the screen at once - and it was not. Measuring rather
       than guessing settled it: the viewport was 1296 and the document 1286, so
       nothing overflowed and there was no bug to fix. Only the crop was wrong.
       The final image is cropped from the left edge and scaled, never trimmed
       horizontally, because at 1286 source pixels wide there are only 86 to give
       up and the header wants all of them.

       Scaling alone cannot reach 1200x900 either. Reducing 1286 to 1200 takes the
       886-pixel viewport to 827 tall, and padding the difference back with a
       solid bar or an upscaled image shows up immediately. Taking 965 rows instead
       and scaling those lands on 900.4, which is what was used - no stretch, no
       padding, no lost edge.

       The file grew from 55 KB to 2.2 MB, and that is inherent rather than
       careless: the old image was flat vector art, which PNG compresses to
       almost nothing, while this is a photograph of a sunset over a meadow, where
       roughly 2 bytes per pixel is what PNG actually costs. A JPEG would be
       around a fifth of that, but the filename stays .png because that is the
       name WordPress looks for.

       Two things were checked and found healthy rather than assumed healthy. The
       homepage appears to have a 4,800-pixel blank band below the fold, and the
       page initially refused to scroll at all. Neither is a fault. The blank
       region is ScrollTrigger's scroll-reveal animations waiting on scroll
       events that Lenis, the smooth-scroll library, emits instead of the browser;
       the fifteen sections are all present in the DOM with real heights and
       content-visibility set to visible. The refused scroll was
       scroll-behavior: smooth - scrollTo animates and returns before it arrives,
       so reading the position straight afterwards returns the old one. Driving
       scrolling through document.scrollingElement.scrollTop works normally.

   = 4.53.0 =
     * Five dashboard settings that saved a number nothing read.
       booking_start_hour, booking_end_hour, booking_break_start,
       booking_break_end and booking_slot_duration belonged to the theme's own
       date-and-slot picker, which was removed when scheduling moved to Calendly
       because Calendly serves the scheduler and owns its own availability.

       Each of the five appeared exactly once in the theme - in the dashboard's save
       list - and nowhere else. That is the same fault as booking_mode, which 4.9
       removed for exactly this reason: an input that accepts a value, stores it,
       and has no consumer is worse than no input, because it reads as a setting
       that works.

       They are gone from the save list and added to the retired-options migration,
       so a site that used the old dashboard has the five stale rows deleted rather
       than left behind looking like configuration.

       How the scan found them, and how it nearly did not. Counting a name's
       occurrences across the theme reported fifty options as unread - book_1_title,
       audio_1_duration and forty-odd others. Forty-five of those were false: they
       are read through an interpolated loop, sr_option( "book_{$i}_title" ), so a
       literal search for book_1_title never matches. Checking the suspicious ones by
       hand rather than by count is what separated the two, and a summary number on
       its own would have had five real findings buried in forty-five noise. A dead
       function count made the same mistake earlier in this work and was wrong in
       the opposite direction, reporting fifty-six dead functions that were all
       add_action callbacks.

       Verified alongside it: 182 functions defined, none genuinely unreferenced;
       every locally enqueued stylesheet, script and image exists on disk, so
       nothing is requesting a 404.

   = 4.52.0 =
     * Forms no longer send mail. That is the fix for the 500.
       Both AJAX handlers stored their row first and mailed second. Anything that
       went wrong in the mail layer therefore arrived after the booking was
       written, which produced the shape that was reported: details saved, and a
       500 anyway. The visitor was shown a server error for a booking that had
       succeeded, the row sat in the dashboard saying it did, and the notification
       was lost.

       Which endpoint it was, and where, was settled by measurement rather than
       inference. Submitting both live: /booking/ returned 500 after 1903ms and
       /contact/ returned 500 after 583ms. Different handlers, different code
       paths, identical failure - so the fault was in what they share, and the only
       shared work after the insert was the notification. The 500 was
       PHP 8.3.35 on Hostinger with an empty body, so there was nothing in the
       response to read.

       Why it had not happened before: inc/smtp.php arrived on 11 October 2026 in
       c890b93, "Send outgoing mail through an authenticated server". Before that
       wp_mail() used mail() and the forms could not fault in the mail layer,
       because they did not have one.

       Three mail calls are gone: the booking notification and its template, the
       contact notification and its auto-reply, and the client confirmation on the
       scheduler write-back - the last of which fired at the moment a booking is
       most certainly real. Nothing a visitor can submit now reaches the mail layer.
       What remains, sr_send_notification() and the dashboard's send-a-test button,
       is only reachable on purpose from wp-admin.

     * The mail layer can no longer throw at a caller.
       wp_mail() is now wrapped in sr_mail_send(). This is the lowest point mail is
       attempted from and therefore the only place every caller is protected at
       once. PHPMailer catches its own exceptions and returns false, which makes
       this look like belt and braces; it is not. On PHP 8.3 a connection that
       cannot be made raises things PHPMailer does not catch - a TypeError from an
       unexpected socket return, an Error from a property that is no longer
       writable. Those escaped uncaught, and caught here they become an ordinary
       false that callers already handle.

       A ten second timeout is set as well. PHPMailer's default is 300 seconds, so
       on a host that accepts the connection and never finishes the handshake - what
       a filtered outbound port looks like - a request held a worker for five
       minutes and then failed anyway, about something that had already been
       stored.

     * The SMTP defaults were contradicting the file they sit in.
       The header comment promised two things. That "when no SMTP host is
       configured ... wp_mail() keeps its default behaviour, which is what a site
       that has not set this up yet should get", and that credentials "are never
       written into a theme file". Neither was true.

       The defaults carried enabled => 1, a host, and the account's own email
       address. So the documented behaviour never happened: a site with nothing
       configured still satisfied sr_smtp_is_configured() and pointed every message
       at an authenticated connection - which is how a fresh install ends up
       depending on credentials it was never given. And the address was written into
       a theme file and committed, which is the one thing the paragraph above it
       said never happened.

       The host and port stay as fallbacks; they are not secret and they make the
       form useful on fill-in. The account is a secret, so it has been removed, and
       SMTP is now off until somebody sets it up and switches it on - which is what
       the file always claimed.

     * Carried over from 4.50.0 and 4.51.0, still not deployed: the rate-limit
       window is stored as a deadline rather than a flag, so a cache that outlives
       its timeout cannot leave a visitor locked out permanently; a saved submission
       is held for five seconds and a refused one for the minute; and
       sr_try_side_effect() remains as the backstop behind the mail layer above.

   = 4.51.0 =
     * Nothing after a booking is saved can fail the request any more.
       The reported symptom was that the details were saved and the request still
       came back 500. That combination is diagnostic: the fault is somewhere after
       the insert, because the insert demonstrably worked. Both endpoints do their
       email work after the row is written, so everything after that write is now
       wrapped in sr_try_side_effect().

       This is worth doing on its own terms, whatever the specific fault turns out
       to be. A saved booking is a fact. If a notification fault is allowed to
       escape, the visitor is shown a server error for a booking that succeeded
       while the row sits in the dashboard saying it did - the owner has the lead,
       the visitor believes it failed, and neither is wrong on its own. That is the
       worst of the available outcomes, and no amount of fixing the mail code
       guarantees it cannot recur. Four call sites are covered: the booking email
       template, the booking notification, the contact notification and the contact
       auto-reply.

       The template result is checked with is_wp_error() before its html and text
       are read. A failed template returns a WP_Error, and reaching into one for
       'html' would move the fault rather than remove it - the same crash, one
       step later, in a place that looks unrelated.

       Two things this does not do, stated plainly rather than left implied.
       catch (Throwable) catches Errors and not warnings: reading an array offset
       from null is a warning that returns null, so it passes straight through
       the wrapper. That was measured rather than assumed - the first version of
       the test suite reported this wrapper as containing it, which was wrong, and
       the docblock now says so. And a hard timeout, a memory limit or a killed
       process are beyond any wrapper; those are what the server error log is for.

       Not fixed here, because it is a configuration question rather than a defect
       and it may well be correct for this site: sr_smtp_defaults() ships an SMTP
       host, port and account as fallbacks, so wp_mail() always attempts an
       authenticated connection rather than falling back to mail(). That is a
       reasonable default on shared hosting where mail() is blocked, and it is also
       a plausible source of a slow or fatal connection attempt on a request that
       has already stored the booking. Whether it is involved is exactly what the
       error log will say.

   = 4.50.0 =
     * The rate limit stored a flag, so a cache that outlived its timeout was a
       limit that never lifted.
       The reported symptom was three bookings going through and then the fourth
       being refused, which is not what a sixty second window looks like. A window
       that behaves like that is one that never clears, and the reason it never
       cleared is that the transient held the integer 1 and the check was simply
       "is it still there".

       That makes correctness depend on the entry being deleted on schedule. On a
       site with a persistent object cache - Redis or Memcached, both ordinary on
       shared hosting - the entry can outlive its own timeout. A flag left behind is
       then read as an active limit for ever. From the outside that is
       indistinguishable from a cache fault and impossible to argue with: a visitor
       books successfully once and is refused from then on.

       The window now holds the time it is valid until, and the check compares that
       against time(). Correctness no longer depends on eviction happening, which
       is the whole point - the cache is now free to keep the entry for as long as
       it likes and the limit behaves. The transient's own expiry is the window plus
       an hour, which is only housekeeping for the options table. A stale entry left
       by the previous version holds the value 1, which is no longer a future
       timestamp and so reads as not blocked: the fix also releases anyone the old
       version locked out, with no cleanup step.

       A saved submission is now held for five seconds and a refused one for the
       minute, which were previously the same. Holding success for a minute treated
       a real enquiry as abuse: someone who had just been told they had a session
       was refused if they wanted to change the time, and the scheduler write-back -
       a separate post minutes later - could meet the window the details step had
       already set. The brief success window still catches a double-clicked button,
       which is all it was ever for.

       Both behaviours were verified against a simulated cache that never expires:
       the old flag was still blocking three hours later, the new deadline had
       released by then, a success no longer blocks the next booking after six
       seconds, and a failure still clears after its minute.

   = 4.49.0 =
     * The rate limit was refusing real visitors, not attackers.
       A live submission to /booking/ came back

           {"success":false,"data":{"message":"Please wait a moment before submitting again."}}

       which is this theme's own 429, not a firewall and not a PHP error. It fired
       because the limit was keyed on REMOTE_ADDR, and behind Cloudflare
       REMOTE_ADDR is the edge node rather than the person. Every visitor landing on
       the same edge node shared one bucket, so the first person to submit was
       refused for a minute afterwards. That is not a rate limit working; it is an
       outage shaped like one.

       sr_client_ip() consults CF-Connecting-IP, then the first entry of
       X-Forwarded-For, then X-Real-IP, and treats REMOTE_ADDR as the last resort
       rather than the first. Each candidate is run through FILTER_VALIDATE_IP
       rather than sanitised, because sanitize_text_field accepts anything printable
       and this value becomes part of a transient key; a forged or malformed header
       falls through to the next candidate instead of poisoning it. Those headers
       are trivially forged, so a client can set its own bucket aside - the
       accepted trade for rate limiting, since that resets only the forger's bucket
       while keying on REMOTE_ADDR alone denies the limit to every real visitor
       behind a shared edge.

       The window is also no longer set before the handler has looked at anything.
       The guard used to set it as its last statement, so an empty post, a
       double-clicked submit or a bot sending one field consumed the minute and the
       real submission behind it was refused. Each handler now calls
       sr_rate_limit_hit() after its own validation has passed, so what the window
       counts is attempts that were worth making.

       The key includes the nonce action, so the booking form and the contact form
       no longer share a bucket. That matters within a single visit: the details
       step and the scheduler write-back are two posts minutes apart, and the
       second would otherwise meet a window the first had already set.

       One defect was introduced fixing this and caught before shipping. The guard
       that reads the window and the handler that writes it built the key
       separately: one hashed the address and appended the action, the other hashed
       the two together. Those strings never match, so the window would have been
       written under a name nothing read and the limit would have stopped working
       silently - a rate limiter that never fires. Both now call sr_rate_limit_key(),
       and the two constructions were compared directly rather than assumed equal.

       The message says "You have just sent this" rather than "Please wait a
       moment before submitting again", because the second reads as though something
       was submitted and the visitor is being asked to try again, which is the
       wrong inference: they have sent it, and it is held.

   = 4.48.0 =
     * The Install / Repair Setup button is back.
       Removed in 4.46.0 and restored here. The handler behind it,
       sr_setup_admin_action(), was deliberately left in place at the time, so this
       is a form coming back to meet an existing, checked handler rather than a
       feature being rebuilt.

       It earns its place for a reason the automatic run does not cover.
       sr_setup_catch_up() fires from admin_init when sr_setup_version differs from
       the theme version, which is right for an update and means it declines to run
       twice on the same version. So an install that ran once and then went wrong -
       a page deleted by accident, a menu left over from an older build, media that
       never imported - had no way to be re-run without either a version bump or
       this button. The $force argument behind it is what makes it a repair rather
       than a catch-up.

       The status line now names both paths, because there are two and the
       difference matters: setup runs by itself on a version change, and the button
       forces a run on the version already installed. Leaving either unmentioned is
       how an owner waits on a change they thought they had triggered by clicking.

       The "Pages included" table did not come back with it. That listed every page
       the installer would create with Edit and View links, and sixteen of those
       pages have since left the installer, so it described a setup that no longer
       exists.

       The form's nonce action and field name match check_admin_referer() in the
       handler exactly, and the handler still requires manage_options.

   = 4.47.0 =
     * [wpcalel] renders instead of printing itself to the page.
       The Calendly embed tag that embed plugins and page builders hand out is now
       registered by the theme. It was not, and WordPress renders an unregistered
       shortcode as literal text, so a booking page carrying

           [wpcalel type="calendly" url="..." widget="inline" ...]

       showed a visitor that whole line of markup instead of a calendar.

       The existing safety net could not have caught it either.
       sr_cal_embed_guard() works by testing page content for the tags listed in
       sr_cal_shortcode_tags(), and wpcalel was not among them. Registering the tag
       and adding it to that list are opposite moves: the list feeds a check for
       "an embed is present and is not being rendered", and a tag the theme always
       registers would answer yes for every page, switching the guard off and
       letting a stale [cal_custom ...] through instead. The tag is registered and
       deliberately not listed.

       Both shortcodes now share one renderer, sr_calendly_inline_widget(), rather
       than two renderers that could drift apart. The theme's [sr_scheduler] and
       [wpcalel] are the same widget with different attribute names.

       The attributes are honoured rather than ignored: height and min-width set
       the widget's box, and primary_color is applied the way Calendly applies it,
       as a query parameter on the event URL. Colouring the container in CSS would
       have painted the frame around the widget and left the widget's own controls
       the old colour - the thing a person setting primary_color is trying to
       change.

       The url attribute is checked against the Calendly host rather than merely
       escaped. Escaping stops the attribute breaking out of the tag; it does not
       stop the value pointing somewhere else, and this value is handed to a script
       that loads a third-party iframe. A non-Calendly host falls back to the
       configured scheduling URL and is logged. Lengths are matched against a
       pattern for the same reason: both land in a style attribute, and the ones
       worth honouring are lengths.

   = 4.46.0 =
     * The Setup tab no longer offers to install or repair.
       Two things came off the dashboard's Setup tab: the Install / Repair Setup
       button, and the "Pages included" table that listed every page the installer
       would create with Edit and View links against them.

       Both were ways of inviting the installer to create pages, and that is the
       one thing this panel no longer offers to do. The table in particular was a
       page list for pages the site does not necessarily have - sixteen of the
       entries had already been removed from the installer in this series, so it
       was describing a setup that no longer existed.

       What remains is the account of what has already been done - status, stored
       version, date of last run, and the checklist - which is the only place any
       of those are visible. The line that told the owner to press the button to
       re-run setup went with the button, because it would have sent people
       looking for a control the tab no longer has; it now says what actually
       happens, which is that setup re-runs by itself.

       This does not stop pages being created, and that is the important part.
       sr_setup_catch_up() runs sr_run_theme_setup() from admin_init whenever
       sr_setup_version differs from the theme version. Uploading this build runs
       the installer on the first admin request, creating the thirteen pages that
       remain. The button was only ever the manual force re-run on top of that
       automatic one.

       The POST handler sr_setup_admin_action() is left in place. With no form
       posting to it there is nothing that reaches it, and it is nonce-checked and
       capability-checked, so removing it would remove a guard for no gain and
       would break the check in sr_setup_catch_up() that keeps the two paths from
       running at once.

       Stopping the automatic run entirely - so an upload never creates a page -
       is a separate change and has not been made.

   = 4.45.0 =
     * Gallery, /gallery/, is no longer created. Thirteen pages remain.
       The same shape as Programs: the page was not in the nav menu map, no
       template or part called sr_page_url( 'gallery' ), and every other match on
       the word in the theme is an images/gallery/ asset path or a CSS class.

       The Gallery Item content type is untouched. The twelve seeded items, the
       gallery_category taxonomy and its five starter terms, the sr-gallery image
       size and the dashboard's item editor - including alt text and sort order -
       all remain. has_archive stays off so no rewrite rule claims the path. Only
       the listing page goes.

       One consequence worth stating plainly: with the page gone those twelve
       items have no front-end listing. They stay in the dashboard and the media
       library, and assigning template-gallery.php to any page brings the listing
       back unchanged. If the gallery is wanted on the site rather than in the
       admin, that assignment is all that is needed - no content has to be re-entered.

       Video & Media was already removed in 4.42.0 and is listed here only because
       it still appears on installs that have not deleted it yet.

   = 4.44.0 =
     * Privacy Policy, Terms & Conditions and Cookie Policy are no longer created.
       Fourteen pages remain. This was the cleanest removal of the lot: nothing
       pointed at the three, and nothing unique was lost.

       Every policy already existed on the Legal & Policies page as an anchored
       section - /legal/#privacy, /legal/#terms and /legal/#cookies - and the
       footer links there. Each of these three templates even cross-linked to
       /legal/ rather than to one another, so they were three standalone
       renderings of copy that already had a home. No link needed repointing.

       What this does consolidate is the canonical address for the policies, which
       is the better arrangement: a policy rendered in two places is a policy that
       is eventually edited in one of them. Worth knowing when a client or a form
       asks for a policy URL, the answer is /legal/ and not /privacy-policy/.

       The three templates and the legal-cookies and legal-terms template parts
       stay on disk and stay selectable in the editor.

   = 4.43.0 =
     * Programs, /programs/, is no longer created. Seventeen pages remain.
       Nothing linked to it. It is not in the nav menu map, no template or
       template part calls sr_page_url( 'programs' ), and no part of the theme
       hardcodes the path. Removing it removes a listing and nothing else.

       The Program content type is deliberately untouched. sr_setup_seed_post()
       still creates the individual programs, single-program.php still renders
       them at their own permalinks, and the dashboard still manages them. The
       post type keeps the base slug programs with has_archive off, which is the
       setting that stops WordPress writing a post_type_archive rewrite rule over
       the same path - so there is no collision to resolve and no rewrite flush
       needed for this to be safe.

       The page template stays on disk and stays selectable in the editor, as does
       single-program.php.

   = 4.42.0 =
     * Six more pages the installer no longer creates.
       Emotional Wellbeing, Financial Wellbeing, Corporate Wellbeing, Group
       Coaching, One-on-One Coaching and Video & Media. Eighteen pages remain.

       Removing these was not the same as the five before them, because two
       things that are still live read them.

       The booking form builds its service list from Emotional, Financial and
       Corporate Wellbeing. Its fallback was all-or-nothing: it gathered the
       titles of whichever pages existed and only fell back to plain names when
       none of the three did. With the pages no longer guaranteed, that made the
       form depend on how many of the three a site happened to keep. Delete two of
       them and the dropdown offered one service instead of three, with nothing on
       the page to say two had gone. Each service now falls back on its own, so
       the list is the same length whichever pages survive - measured at three
       options for zero, one and three surviving pages.

       The service grid holds three fallback cards linking to One-on-One, Group
       and Corporate Wellbeing, shown only when no service posts exist. They now
       go to /services/, which is the page that lists them.

       The nav menus were checked rather than assumed: sr_setup_menu_items() skips
       keys with no page, and none of these six is in the menu map anyway, so the
       menus are unaffected.

       sr_setup_corporate_slug() still runs on purpose. It repairs a site that
       keeps the Corporate Wellbeing page from its original /corporate/ slug, and
       an owner who wants the page can still make it - the template is still
       selectable. It repairs; it no longer creates.

       demo-content.php also defined one-on-one and video-media, and was left
       alone: it is never require'd by the theme, so it cannot recreate anything,
       and changing unreachable code on a page-removal task is how working code
       gets broken for no gain.

       The templates stay on disk and stay selectable in the editor.

   = 4.41.0 =
     * Book a Call is no longer created either. Five pages now, not four.
       /book-a-call/ was a second booking flow beside /booking/: service cards,
       then details, then the scheduler. Nothing in the site pointed at it. Every
       Book an Appointment button resolves through sr_default_booking_url(), which
       lands on /booking/, so it sat in the Pages list as an unused way in. Two
       booking forms is two flows to keep right, and this one was the one nothing
       used.

       Four links did point at it and now go to /booking/:

       - the "Book a free discovery call" button in the enquiry auto-reply, which
         is the one place on the site where a dead link is both visible and
         immediate, because the recipient is reading it right now;
       - "Book Another Session" on the booking success screen;
       - "Rebook a Session" on the booking cancelled screen;
       - "Change Again" on the booking rescheduled screen.

       That last one was nearly missed. The rescheduled template is minified onto a
       single line, so a line-level search reported the file once and the button
       inside it was not visible as a separate occurrence. It was found by
       re-reading the matched line rather than trusting the match count. Three
       files that were the same shape were checked the same way, and the other two
       turned up nothing.

       The template stays on disk and remains selectable in the editor.

   = 4.40.0 =
     * Four pages the installer no longer creates.
       Booking Confirmed, Booking Cancelled, Booking Rescheduled and Choose a
       Time are no longer published by the setup wizard. Each one appeared in the
       Pages list on every site that ran the installer, and three of the four
       could not be reached from anywhere in the theme.

       Booking Cancelled and Booking Rescheduled existed only as somewhere for
       Calendly's redirect settings to point. Those settings live in the
       Calendly dashboard, not here, so on a site that had not set them they were
       published pages nothing linked to.

       Booking Confirmed was reached from four places - the success_url the
       booking handler returns, the View Booking button, and the scheduler page -
       so removing the page without touching those links would have sent every
       confirmation to a 404. A booking that worked must not end on a 404, so
       those four now resolve through sr_page_url( 'booking' ), and the button
       says Book Another Session, which is what it now does.

       Choose a Time, /book-schedule/, was a second copy of the scheduler and
       nothing linked to it: the booking form forwards to /calendar/. This one was
       not safe to remove until this release, because that page carried the
       Calendly write-back inside its own template, so deleting it would have
       deleted the only listener a confirmed booking had. 4.39.0 moved the
       write-back onto every page, and /calendar/ has been covered since.

       The templates all stay on disk and stay registered as page templates. They
       remain selectable in the editor, so any of the screens can be brought back
       by assigning the template to a page by hand. What is gone is the installer
       publishing four empty pages on every site that runs it.

       Removing the definitions does not remove pages already created. A site that
       has run the installer keeps them until they are deleted in wp-admin.

   = 4.39.0 =
     * The Calendly write-back now reaches the page the embed is actually on.
       4.38.0 moved the listener onto Calendly's event name and it still did
       nothing, because of where the listener was living.

       The booking form forwards to /calendar/. That page is created by the
       installer with an empty template - it is a plain WordPress page whose
       content is the embed - so it renders through page.php and has never
       rendered template-book-schedule.php. The listener that reads Calendly's
       answer back lived in that template, on a page the flow does not visit.
       Confirmed against the shipped arrangement: the page visitors reach carried
       the widget and nothing that listened to it.

       The write-back is now assets/js/calendly-booking.js, enqueued on every
       page. The embed is markup the owner pastes wherever they like, so a
       listener has to travel with the embed rather than with a template; it does
       not matter afterwards whether the embed is on /calendar/ or somewhere else.
       It stands down unless the URL carries a booking reference, so on any other
       page it costs one listener and a string comparison.

       It also declares itself bound, and template-book-schedule.php now stands
       down instead of binding its own. Two listeners on one slot would write it
       twice and send the confirmation twice. That template's saveBookingToServer()
       stays, because its manual "I have selected my time" control still calls it
       when there was no scheduler message to catch.

       After the write lands, the visitor is shown the confirmation with a link to
       the booking rather than being redirected. They have just finished on
       Calendly and a jump reads as though something failed.

   * sr_page_url() honours the fallback it was always passed.
       It declared $fallback and ignored it, returning home_url( '/slug/' )
       instead - a 404 wearing a plausible shape, which is worse than an honest
       one because it looks like a real destination. It now returns the fallback,
       and also refuses to return the permalink of an unpublished page.

       The two callers that branch on this were changed to ask for an empty
       string rather than the '#' default, because '#' is a true string. Left
       alone, a site with no published Calendar page would have forwarded the
       visitor to '#' instead of falling back to the inline scheduler on the page
       they were already on - a regression introduced by fixing the function
       rather than by anything being wrong with it before.

   = 4.38.0 =
     * A contact enquiry can finally be read.
       sr_leads has always stored the message the visitor wrote. No screen in the
       dashboard displayed it. The list showed name, email, phone, service, source
       and date, so an enquiry read as a row of contact details with the one part
       that mattered missing, and the owner could only get at what somebody had
       actually asked by querying the database directly.

       Leads now have a detail screen, sr_dashboard_lead_detail(), which mirrors
       the booking one: every captured field in full, the message in a card of its
       own because prose in a two-column table gets squeezed into the label
       column, a status control, a reply link and a delete. The message says
       plainly that nothing was written when there is nothing in it, because a
       visitor who sent the form empty is a different situation from one whose
       message failed to save and only that says which.

       Leads also had no Actions cell at all - it rendered empty for them - so an
       enquiry could be neither opened nor deleted. Both are there now, and the
       delete posts to a delete_lead handler that did not previously exist.

       That screen also restores what removing the Status column took away. On a
       booking the control moved to the detail screen; on a lead there was nowhere
       for it to move to, so it was simply gone. It is here now.

   * Leads record a timezone.
       sr_leads had no such column and template-contact.php had no field to fill,
       so an enquiry could not be placed in the world at all, while the booking
       form had been recording one for some time. The contact form now carries the
       same hidden field the booking form does and the handler stores it the same
       way: what the browser reported, and "Not reported by the browser" when it
       reported nothing rather than a default that would attribute a location the
       visitor never gave.

       dbDelta adds the column to an existing install on its own, so there is no
       ALTER to run by hand. Enquiries already stored will read "Not recorded",
       which is the truth: they predate the field.

   * The leads Source column shows a label rather than its stored key.
       It is written once, by sr_ajax_contact(), with the literal 'contact', so it
       reached the screen in lower case and read as a database value that had
       leaked into the interface. Display only - the stored value is untouched -
       with an unknown key falling back to words rather than to its own
       underscores, so a future source reads as "Ebook Form" and not "Ebook_form".

   = 4.37.1 =
     * A confirmed booking is now written back to the site.
       4.36.0 moved scheduling to Calendly and said, in this file, that a booking
       would show the visitor's details but no date, time or meeting link,
       "because those are chosen on Calendly and not sent back". That was
       accurate and it was the wrong outcome: every booking the site took was
       permanently details_submitted, and the confirmation email that
       sr_ajax_booking_calendar_done() sends was never sent to anybody.

       The reason was that the embed was pointed at Calendly while the one piece
       that reads the answer back was still listening for Cal. Both the scheduler
       page and book-a-call tested for 'cal.bookingSuccessful', which is Cal's
       event name and is not one Calendly sends, so the listener matched nothing
       and looked correct while doing nothing. It now reads Calendly's
       'calendly.event_scheduled' and pulls the start time and join URL out of
       payload.scheduled_event. The origin is checked, because any tab can post a
       message to the window.

       The scheduler page then wrote the confirmed slot to localStorage and drew
       a success panel, and stopped there. localStorage is the visitor's own
       browser, so nothing about the booking reached the site. It now posts the
       reference, date, time, zone and meeting link back to the server as well. The
       local copy is kept, because booking-success.php reads it: the server copy
       is the record, the local one is what the next page renders.

   * The Cal.com webhook is gone rather than dormant.
       It was registered at saeed-riaz/v1/cal-webhook with
       permission_callback '__return_true', so it was reachable by anyone on the
       internet, and its own documentation recorded that with the signing key
       unset it accepts any posted payload and writes it into the bookings table.
       A site that no longer uses the provider should not have been carrying an
       endpoint that creates and overwrites bookings for anyone who posts to it.
       Removed: the route, the handler, and the sixteen Cal.com functions behind
       it. sr_send_booking_emails() went with them, since the webhook was its
       only caller. The calcom_booking_id and calcom_event_id columns stay in the
       table so no stored row is lost; nothing reads or writes them now, and the
       two dashboard rows that printed "Not linked" on every booking are gone.

   * A booking with no timezone no longer reads as a broken record.
       Two handlers create bookings and they disagreed about the empty case.
       sr_booking_start() wrote the words "Not reported by the browser";
       sr_ajax_booking() wrote the raw value, so those rows got an empty column,
       which the detail view had to render as "Not captured" - a phrase that
       reads as damage to the record rather than as the truth, which is that the
       browser never said. Both now write the same wording, and
       sr_migrate_missing_booking_timezones() fills the rows already in the
       database on the next admin load. No zone is invented and no visitor is
       attributed a location they did not give.

   * An AJAX response can no longer be unreadable, and the form says what
       happened.
       "Something went wrong. Please try again or email us directly." could only
       be reached from one line in theme.js: the catch on the fetch. It was
       never shown for a validation error, a bad nonce, a rate limit, or even a
       mail failure, because the theme handles those and tells the visitor their
       message arrived. It appeared when the response was not JSON at all -
       which is what a security filter returning an HTML block page looks like -
       and it told the visitor their enquiry was lost when it had in fact been
       stored. The response is now read as text and parsed by hand so the HTTP
       status survives, and the wording is chosen by cause: a blocked request now
       says a security filter stopped it. It also says "email us directly" while
       giving the visitor nothing to email us with, so a mailto carrying what
       they typed is appended.

       On the server, sr_ajax_json_guard() discards anything printed before the
       payload on admin-ajax and reduces the response to the JSON it contains. A
       dbDelta warning, a plugin echoing on init, or a byte-order mark used to
       land in front of the payload and break every form on the site at once.

   * The hardcoded Cal.com URL is gone from template-book-a-call.php. It fell
       back to a fixed cal.com address belonging to someone else, so a site with
       no scheduling URL configured embedded another person's calendar instead of
       showing the WhatsApp placeholder the markup already handles.

   = 4.36.0 =
     * Scheduling moves from Cal.com to Calendly, and the visitor is told their zone.
       Cal.com was 179 references across 13 files, 16 dedicated functions, a REST
       webhook and four stored options. Calendly's embed is a div plus their widget
       script and needs no plugin at all, so the theme can own it outright. That
       removes the dependency the old setup could not do without: the Cal embed was
       a shortcode, [cal_custom ...], which only worked if a third-party plugin had
       registered that tag, which is why the file also carried a guard for the case
       where the shortcode was inert and would otherwise have printed itself to the
       public.

       What actually reached a visitor is worth recording, because changing the URL
       alone would not have fixed it. Two templates loaded
       app.cal.com/embed/embed.js and called Cal('init') and Cal('inline'). That is
       a live request to Cal on every page rendering either template, and it would
       have kept making one after the URL pointed at Calendly. Both loaders are
       replaced; the div the markup already renders is reused, and its id and
       CAL_URL keep their names because the sizing CSS refers to them.

       The event link is now a single setting. It lived in two places, the dashboard
       and whatever the Book an Appointment page had been left holding, and the page
       could win silently. The scheduler is rendered from the dashboard value by the
       theme on that one page. The shortcode still works for anyone who prefers to
       place it by hand, and nothing is appended when the page already produces a
       scheduler, so it can never render twice.

       The dashboard's Cal.com block was rewritten: heading, both labels, the
       example URL and the step-by-step instructions. The webhook signing key field
       was removed rather than relabelled. That credential signs a Cal.com payload
       with a Cal.com shape; Calendly signs differently and posts a different body,
       so a key pasted there would be checked against the wrong thing. The warning
       that told the owner the Cal.com embed plugin was not active is gone, because
       there is no longer a plugin to be missing and it would have alarmed them
       about a dependency that does not exist. What it reports now is whether a
       scheduling URL is set, which is the thing that actually has to be true.

       The option key stays cal_com_url so a site that already has the value stored
       keeps it across the upload. Only the wording and the instructions changed.

       What was left alone, on purpose. The Cal.com webhook handler in
       ajax-handlers.php is still there and will never fire: Calendly posts a
       different payload and signs it differently, and repointing a signature check
       is not something to do silently. Failing safe in that direction, a booking
       stays at details submitted rather than being marked booked by something that
       cannot authenticate it. The practical consequence is written into the
       dashboard: a booking record shows the visitor's details but no date, time or
       meeting link, because those are chosen on Calendly and not sent back. Wiring
       Calendly's own webhook is the next job, not this one.

       The booking form now states the visitor's timezone at the top. It was already
       captured into a hidden field and stored with the booking, which means it was
       known and invisible. A scheduler renders times in some zone and nothing on
       the page said which. The line appears above the form and again above the
       calendar, where the question actually arises, and the browser replaces the
       server's first reading with its own so the two cannot disagree.

       Two defects were introduced during this work and caught by the gates before
       shipping: a dashboard statement left unterminated, and get_post() called with
       no argument in the new content filter. Both are fixed. Three earlier attempts
       at the embed loader damaged template-book-a-call.php and it was restored from
       the last commit, because a regular expression cannot tell an inner JavaScript
       brace from a closing one and counting braces does not work either because of
       braces inside strings and regex literals. The version that shipped replaces a
       verified line range instead of matching a pattern.

   * One version, read from style.css, instead of two that had to be kept in step.
       SR_THEME_VERSION was a typed literal in functions.php and had drifted to
       4.9.3 while style.css said 4.36.0, twenty-seven releases apart. Nothing
       failed loudly.

       Every stylesheet and script is enqueued with it as the cache-busting
       version, so a visitor holding a cached copy from an old release kept the old
       assets however many times the files were replaced.

       Five one-time migrations gate on it. Each stores the version it last ran at
       and skips only when that matches. A constant stuck at 4.9.3 while the stored
       value was some later release meant they never matched, so those migrations
       did not run once and stop. They ran on every request, which is why the
       resource image repair had to be applied again after fixes that had already
       shipped.

       The constant is now read out of the style.css header, which is where
       WordPress reads the theme version from anyway. One number rather than two,
       and the next person bumping the theme cannot forget functions.php. Only the
       first 2KB is read, which is where the header lives and is a fraction of the
       file. If style.css were ever unreadable the fallback is deliberately low
       rather than high: a low value makes the migrations run, which is the safe
       direction, where a high one would mark them done without doing them.

       A gate now checks that it stays derived: no typed literal, the header regex
       still finds the version, and exactly one Version line in the file, since a
       second would mean the header matched something that is not the version.

   = 4.35.0 =
     * The booking mail arrives as a message, and carries every captured detail.
       Two separate faults, reported together, and the first one was the reason
       the second was never noticed.

       The mail was arriving as source text. Gmail showed the DOCTYPE, the layout's
       closing tags as text, "Name" as "N= ame", and the booking reference broken
       one syllable per line. Those are quoted-printable soft line breaks, an equals
       sign followed by CRLF, arriving as a literal equals and space.

       sr_send_notification() was assembling the MIME by hand and passing its own
       Content-Type: multipart/alternative header to wp_mail(). That header tells
       PHPMailer the body is already a complete multipart message, so it passes the
       text straight through without encoding any of it. WordPress already ships
       the correct implementation and it was not being used: PHPMailer builds
       multipart/alternative and encodes both parts, and setting AltBody is what
       asks for it. That was confirmed against the WordPress 7.1.3 source rather
       than from memory, where alternativeExists() sets the content type and both
       parts go through encodeString(). The hand-written boundary and both
       hand-written part headers are gone.

       The second fault is why nobody noticed the mail was unreadable. The
       timezone was never gathered from the visitor at all.

       There are two booking forms in the theme and they were not equivalent.
       /book-a-call/ posts sr_booking_start, which stores timezone, company, plan,
       purpose and message. /booking/ posts sr_booking, through the generic handler
       in theme.js that builds the action from the form's data-ajax-form. Its
       handler read six fields and inserted nine columns: no timezone, no company,
       no plan_name, no purpose, and the visitor's words filed under notes rather
       than message. /booking/ is where the primary call to action lands, so this
       is the form that produced every booking with an empty Timezone, an empty
       Company and an empty Purpose.

       So the zone was not being dropped in transit. It was never asked for. The
       form now carries a hidden field, theme.js fills it at submit for any form
       marked data-visitortimezone, and the handler reads and stores it.

       Captured at submit rather than at load, because a visitor who leaves the tab
       open, travels, or moves between wifi and mobile data would otherwise send
       the zone they were in when the page loaded. Two fallbacks: Intl for a real
       IANA name, then getTimezoneOffset for a bare offset, which is available in
       far more browsers and is still enough to place a chosen slot. All five paths
       were exercised, including Intl blocked and Intl returning an empty string.

       No handler invents a zone any more. Both used to store Asia/Karachi when the
       browser said nothing, which attributes a location to somebody who never
       gave one, and the record then showed it as though they had. The stored value
       now says the browser did not report one, which is both true and the thing
       worth knowing about an incoming booking.

       The owner is now emailed every field the form captured, not the four it used
       to list: reference, status, name, email, phone, company, service, plan and
       timezone, with purpose and the visitor's message quoted together under the
       body. Rows with nothing in them are dropped, so a field the visitor chose
       not to give does not print as a blank line. The mail is how the owner finds
       out who wrote in before opening the dashboard, so a field missing from it is
       a field that may as well not have been collected.

       The field path was then traced end to end for both forms: asked for by the
       form, filled at submit, read by the handler, written to a column, printed on
       the mail. Any one hop failing would have produced the same symptom, so all
       five were checked rather than the line that was added.

   = 4.34.0 =
     * Sixteen settings removed that saved a value nothing ever displayed.
       Asked to prune older pages and unused templates, helpers and customizer
       entries. Two of those three had nothing behind them once measured, and the
       third turned out to be a real fault worth reporting rather than a chore.

       The header links needed no work. The static home page and the menu the
       installer builds already agree exactly: Home, My Story, Coaching With Me,
       Packages and Fee, FAQs, Poem, Free Resources, Contact us.

       No template could be removed either. Six templates have no static html
       counterpart, and every one is reached from somewhere real. The three policy
       templates are linked from footer.php, so removing them would break every
       footer link on the site. Group and One-on-One are referenced by
       front-page.php, helpers.php, packages.php and the booking form, and
       Video by ajax-handlers.php and the FAQ. These are pages WordPress needs
       that the static build simply did not have as html files.

       All 29 installer entries were then checked against both tests at once,
       against the static files and against a live reference anywhere in the
       theme. Twenty-three map to a static page. The other six are the ones above,
       reached by a footer link or by the booking flow. Nothing failed both tests,
       so nothing was removed. The four static files with no installer entry are
       also accounted for: 404 is served by 404.php, post by single.php, index by
       front-page.php, and payment was declined by the client and never ported.

       What was genuinely unused sat somewhere else. Sixteen fields were editable
       in wp-admin and rendered by nothing. front-page.php reads no options at
       all; the hero is static markup, matching index.html, which is correct, since
       static is the source of truth. Nothing read the values either in PHP or in
       JavaScript, because there is no wp.customize anywhere in the theme. So the
       hero eyebrow, the two hero lines, the hero lead, both hero buttons, all six
       hero stats, the about heading and lead, the services heading and lead, and
       the footer description were controls that saved a value and displayed
       nothing. That is worse than having no control, because it looks like the
       page is configurable when it is not.

       They were removed rather than wired up, which is the right direction here:
       wiring them would move the home page away from the static it is built from.
       Removed from the defaults, from the three Customizer panels, from the
       dashboard field list and its label map, from the dead conditional in the
       Customizer's control-type test, and from the installer's seed data, which
       had been writing fifteen dead rows into the options table on every install.
       sr_default_options() goes from 87 keys to 71.

       The Customizer keeps its panels, because the neighbouring fields are real:
       footer_email, footer_phone, footer_hours and the rest are read by
       footer.php, and the hero and home panels keep their image controls. The
       footer's tagline is hardcoded in footer.php and matches the static, which
       is the last check that footer_description was genuinely dead rather than
       merely read somewhere inconvenient.

       front-page.php is untouched. The rendered home page is identical to what it
       was, and to index.html.

   = 4.33.0 =
     * The contact auto-reply reads as though it was written for its reader.
       Asked for a professional template on the contact auto-reply. The copy was
       already sound, so the work was in what surrounded it, and two of the
       faults were not cosmetic.

       The reply button was a dead end. It was built as mailto: followed by the
       visitor's own address, so a visitor who pressed "Reply to this message" in
       the acknowledgement they had just received opened a blank message to
       themselves. An acknowledgement whose only call to action cannot reach a
       person is worse than one with no button at all. It now addresses the
       notification address, and the button is omitted entirely if no address is
       configured rather than rendered as a mailto with nothing after it.

       A second button was added for the free discovery call. The first-time
       message already promises that a discovery call is the quickest way to
       begin, so the mail was describing an option it gave no way to take up.

       The headings were written for the other end of the conversation. The shared
       template hardcoded "Who wrote in" and "What they wrote", which are right
       for the notification to the owner and wrong for the reply, where the
       visitor is being shown their own message described in the third person to
       their own face. Both are now the caller's to set. The owner's notification
       keeps exactly the headings it had.

       The details row is no longer a single line echoing the address the mail
       was delivered to. Phone and the enquiry about are listed instead, and only
       when the visitor gave them, so the box shows something they can check.

       The footer now says the message is an automatic reply to what they just
       sent. Someone receiving an instant message from a coaching practice has no
       other way of knowing it was not written by hand, and the alternative is
       them replying to ask.

       The auto-reply was off. contact_autoreply defaulted to 0, so a visitor who
       had just written to the business heard nothing back and had no way of
       telling whether their message had arrived. It is on by default now and can
       still be unticked. An acknowledgement is not marketing, so it is not
       something to be opted into.

       Changing that default alone would have done nothing, which is worth
       recording. sr_send_lead_autoreply() read the setting as
       sr_option( 'contact_autoreply', 0 ), and sr_option() only consults
       sr_default_options() when the argument it is given is null. An explicit 0
       therefore beat the stored default and kept the feature switched off no
       matter what the default said. Both that call and the dashboard checkbox now
       read the option with no fallback, because a control has to show the value
       that is actually in force; left as it was, the box would have sat unticked
       while the theme was in fact sending.

       Both messages were rendered from the shipped defaults before shipping, to
       confirm the paragraph structure survives and the two buttons resolve.

   = 4.32.0 =
     * Dashboard messages render in near-black, whatever the cause was.
       The owner reported that "Deleted successfully." and the other dashboard
       messages came back in a pale tint, close to unreadable, and asked for the
       text to be black.

       The cause was not reproducible from here and the fix does not pretend
       otherwise. Seeing it needs a logged-in wp-admin screen, and the colour
       that came back matches nothing WordPress ships. Its generic notice rules
       are all low weight and near-black, .notice p at 0-1-1 carrying #1e1e1e, so
       the two classes the dashboard already declared beat them and both are
       effectively black. An admin colour scheme rule scoped to <body> was the
       obvious suspect; wp-admin's stylesheets were read to check, and there is
       none.

       What is pinned is the outcome, not a theory. The rules now cover div, span
       and strong as well as p, which the previous pair of rules did not.
       WordPress puts notice text in a paragraph, but it also injects elements
       into notices: div.notice a is styled at 0-1-2, so a link inside a message,
       which is how WordPress renders its "Updated." affordance, was taking its
       colour from WordPress rather than from the theme. A notice carrying
       markup from anything else will not necessarily put its text in a paragraph
       either.

       The colour is near-black rather than a success green. The message is text
       and not a status, and the coloured left border already says which kind of
       notice it is. Tinting the words as well made them the least legible part
       of the box. Links are one shade lighter so they stay findable without the
       message itself carrying colour.

       Scoped to .wrap.sr-dashboard, two classes on the same element, so it does
       not depend on load order against anything wp-admin queues.

   = 4.31.0 =
     * Bookings: show every stored detail, and stop printing a dot for a blank.
       The owner sent a booking record where Company, Purpose, Message,
       Timezone, Meeting and Cal.com ID all read "." and Date and Time read
       nothing at all. Every one of those was a hardcoded ". " standing in for a
       missing value, which reads as a broken page rather than as absent data.

       An empty field now says which kind of empty it is. "Not provided" means
       the visitor did not give it, "Not scheduled yet" means the appointment has
       not been fixed, "Not captured" means the record predates the field being
       collected, and "Not linked" means Cal.com has not answered yet. Three
       helpers carry that so the detail view and the list cannot drift apart.

       Three stored columns never reached the screen at all: created_at,
       updated_at and calcom_event_id. They have been written on every row since
       the first release, so there was no way to tell a booking made yesterday
       from one made last year, and no way to see whether anything had changed
       since. A Record card now shows all three.

       Nothing is invented to fill a gap. On the rescheduled page the timezone
       fell back to Asia/Karachi and the service to "Coaching Session" whenever
       the record was empty, which told the visitor a location and a service they
       had never given. Both now report that nothing was recorded. The same page
       printed a dot for the date, time and reference, which it no longer does.

       The Notes card was drawn unconditionally and was therefore permanently
       blank: the notes column is written as an empty string by the Cal.com
       webhook and is not written at all by the booking form, which stores the
       visitor's own words in message. It is now drawn only when it holds
       something.

       The bookings list and the detail view disagreed about statuses. The list
       offered pending, contacted and complete, which the detail dropdown could
       not represent, so a status set in the list was silently displayed as New
       in the detail. Bookings now take their statuses from sr_booking_statuses()
       in both places. Leads keep the wider list, which is what their column has
       always been given.

       The list also rendered raw datetimes and fully blank date, time and phone
       cells. Those now go through the same helpers.

       One thing was deliberately left alone. The rescheduled page tells the
       visitor a confirmation email is on its way, and on the reschedule path one
       genuinely is sent, so removing that sentence is the owner's call rather
       than a defect to fix quietly.

   = 4.30.0 =
     * Booking form: no mail to the customer, and no silent mail failures.
       The site owner asked for the booking form to collect the details and
       continue to the scheduler, with nothing emailed to the person who
       submitted it. That is now what happens, and it is now written down rather
       than left to look like an oversight.

       What the form actually did was worth recording, because it was not a
       missing confirmation so much as an unreachable one. The live form posts
       to sr_booking_start, which stored the row, notified the owner and
       returned. The one customer-facing booking mail in the theme, the
       acknowledgement saying the request had been received, lived in a second
       handler, sr_booking, that no shipped form posts to, and in a third place
       after the Cal.com step. So on the path a real visitor takes there was no
       customer mail at all, and the string survived only in dead code.

       That dead code is gone rather than left dormant, because a booking handler
       that mails the customer is exactly what the owner asked not to happen and
       the next person to wire a form up would have found it waiting.

       The second half of this is the owner's own notification. Four booking mail
       paths called wp_mail() directly and discarded the return value, so a
       refused message was indistinguishable from a sent one: the booking sat in
       the dashboard looking handled and nobody was told. They now go through
       sr_mail_send(), which returns the real reason off the mailer, and each
       failure is logged under the same prefix as the database writes so one
       search finds them all.

       sr_mail_send() is the body of the existing dashboard test button, lifted
       out rather than copied, so a green test button and a real booking send
       cannot drift into reporting success by different rules.

       Nothing about delivery is assumed. If the SMTP password has never been
       saved in wp-admin, PHP mail() is still dropped by the host, and the log
       will now say so instead of the site staying quiet about it.

   = 4.29.0 =
     * Body copy is justified, so line ends are even.
       The site owner asked for even line ends across the pages, after seeing the
       ragged right edge on the About page. The frozen stylesheets had rejected
       justification before, and had measured it rather than assumed it:
       layout.css records 85 paragraphs on 13 pages with a word space stretched
       past twice its natural width, the worst at 26 times, an 83.6px gap where a
       space should be 3.2px.

       That measurement was taken again here, on every page, before and after,
       and the cause it named was the one acted on. The old worst case was auto
       hyphenation being off, so every pixel of slack had to come out of the word
       spaces. Hyphenation is now on for body paragraphs, which layout.css itself
       identifies as the case where it pays for itself: a long column of body
       copy rather than a lead. Leads keep hyphens manual, so the three-line
       "certifica-tions" break on the Credentials lead does not return.

       What is justified is body prose and nothing else. Card copy is excluded by
       its container, and the first attempt proved why that matters by measuring
       14.5 times on the Services page and 10.7 times on Packages, against 5.6
       times on the About story grid the request came from. Those were paragraphs
       inside three-up grids, at 265 and 302 pixels, which is card description
       rather than body copy, and exactly the category layout.css already excluded
       from hyphenation for the same reason: a card is read whole and has no
       paragraph rhythm.

       A sweep of all seventeen pages then found two more card grids the reading
       of templates had missed, hiw-grid on the home page at 11.7 times and
       social-hooks-grid on Resources at 2.8 times. Both are excluded.

       Measured after the change: 52 justified paragraphs across seventeen pages,
       worst gap 5.6 times, at the 517 pixel About story grid. That is the same
       5.6 the old notes recorded for that column, and it is the floor for it:
       the column runs about 55 characters, below the 65 to 78 this site's own
       notes call comfortable, so widening it is the only thing left that would
       improve it. No page has a narrow column justified any more.

       Below 861px the rule is off, because the columns are too short there to
       justify without opening rivers.

   = 4.28.0 =
   * New photographs at the bottom of the Packages page.
     Two pictures the client supplied, in place of the pair that section carried
     before. That pair showed an empty office: a concrete floor, computer
     monitors and a large print of the aurora. On a page selling coaching it read
     as an unrelated company's premises rather than the practice.

     The new pair is practice-reception.jpg, a bright reception area with two
     people sitting facing each other, and counselling-room.jpg, a quiet room
     with two armchairs beside tall sash windows. The alt text describes what is
     actually in each frame rather than repeating the old descriptions, which
     named a lounge and an artwork that are no longer there.

     They are new files and not an overwrite of service-lounge.jpg and
     service-office.jpg. That pair is the same two images the Services page uses
     in an identical gallery, same heading and all, so replacing the files would
     have changed a page nobody asked about. The client named the Packages page,
     so only that page changed. Both files remain for Services.

     Resolution is kept and only the encoding re-done: 1440x901 and 1600x1066,
     against a 588px render, which is 2.45x and 2.72x. The render width is
     measured from the stylesheet rather than guessed, the section being a
     two-column grid in a 1200px container with a 24px gap, so a 2x display is
     covered without reducing either picture. Re-encoding took them from 238 KB
     and 310 KB to 186 KB and 191 KB.

     Not downscaled. An earlier pass in this project shrank three book covers
     from 1600x2240 to 1143x1600, which is not compression but a reduction in
     quality, and it had to be undone. The brief is compress for size and not
     quality, and these two are already large enough that only the bytes needed
     reducing.

     Intrinsic width and height are declared on both so the browser reserves the
     box before the file arrives. The static packages.html carries the same
     markup and the same files, so the source stays the source of truth.

   = 4.27.0 =
   * The booking calendar was too small, and neither cause was in this theme's
     stylesheets.
     Measured on the live page rather than guessed at:

         .container-narrow        1088px
           .entry-content         1088px
             .cal-inline-container  780px   <- Cal.com's own cap
               iframe.cal-embed    780px x 530px

     Cal.com injects its own stylesheet into whichever page the embed lands on,
     and it fixes the inline embed at max-width: 780px. The page is 1088px wide,
     so 308px of it went unused. Separately, Cal's resize observer settled the
     frame at 530px, which clipped the last time slots off the bottom of the
     card.

     The width is overridden and the height given a floor. Verified live before
     the rule was written, and re-read two and a half seconds later so Cal's own
     observer had run and settled:

         container width   780px -> 1088px
         embed width       780px -> 1088px
         embed height      530px ->  720px

     The height is a floor and not a cap. Cal still grows the frame to fit its
     own content, so a longer day cannot be clipped; the floor only stops a short
     one being cut short. A 640px floor applies under 860px, where the embed
     stacks.

     This is written in theme-wp.css rather than booking.css because /calendar/
     is a plain page rendered by page.php, and booking.css is only enqueued for
     the booking template, so rules there never reached it.

   * dead_css.py now knows about class names a third party writes into the DOM at
     runtime. Cal.com's three are in its injected stylesheet and appear in no
     template, no stylesheet and no script this repository ships, so the gate was
     reporting a class that is on the page right now as unreachable. They were
     confirmed present rather than assumed: the live embed renders as
     div.cal-inline-container > cal-inline > iframe.cal-embed. The gate still
     catches a genuinely dead class, which was checked by planting one.

   * That gate also caught a mistake in the new rule. It first named
     .cal-inline-embed, a class that does not exist anywhere: the real three are
     cal-inline-container, cal-inline and cal-embed. The selector was a harmless
     no-op and it is gone.

   = 4.26.0 =
   * Repair the Resources images already stored in the database.
     Correcting the defaults in 4.25.0 was necessary and not sufficient.
     sr_option() reads a stored value first and only falls back to the default,
     so a site that saved its settings while the wrong defaults were in place
     holds the wrong values in the database and keeps rendering them after the
     defaults are corrected.

     saeedriaz.com was doing exactly that. /resources/ was serving
     saeed-usaid-training-office.jpg as the cover of "As a Man Thinketh" and
     saeed-whiteboard-training.jpg as the cover of "Acres of Diamond", both
     photographs of a training room, and the same whiteboard for the second
     podcast with a bench for the first.

     sr_migrate_resource_images() rewrites a stored row only when it names the
     file the old default named, compared on the file name rather than the whole
     URL, because the stored value is an absolute URL and every install has a
     different domain and theme path. A file the owner uploaded has a different
     name and is left alone. The invented third book is removed rather than
     repointed, since resources.html has no third book and so nothing correct to
     point it at; clearing its title is enough, because sr_get_books() skips a
     book with no title.

   * outdated_scan.py, and the answer to "update everything old to new".
     Sixty three files scanned against WordPress 7.1.3 and PHP 8, across five
     classes: a call to a function that no longer exists, a hook nothing fires,
     a construct PHP 8 removed, an implicit nullable parameter type, and a
     shortcode no one provides.

     Nothing. Zero findings. The theme already calls only what WordPress 7.1.3
     defines, registers only hooks that fire, and uses no construct PHP 8
     dropped. So a sweeping rewrite would have had nothing to do and would have
     been pure risk: this project's record is a rule applied more widely than the
     fault it was written for changing seven pages that were correct.

     Two of the checks were written wrong first and are worth recording. The
     hook check reported all nine of the theme's ajax handlers and its
     save_post_page hook as dead, because WordPress builds wp_ajax_{action} and
     save_post_{post_type} at runtime and neither string is ever in core source.
     And the theme's own sr_ hooks were skipped outright, so a hook the theme
     registers but never fires would have gone unreported. Both fixed, and the
     scan is verified against a fixture carrying all five classes.

   = 4.25.0 =
   * The Resources page could show the wrong pictures, and a button on the
     contact page was missing. A whole-site audit against the static markup
     found both.
   * Book and episode defaults said something resources.html does not.
     sr_default_options() named saeed-usaid-training-office.jpg as the cover of
     "As a Man Thinketh" and saeed-whiteboard-training.jpg as the cover of
     "Acres of Diamond". Those are photographs of a training room. It also
     carried a third book, "Keys to Happiness Within", which resources.html does
     not have, and a description on each of the three, which resources.html does
     not have either: a book card is a title, "By James Allen", and a download
     button. The two episode images were a bench and a whiteboard rather than the
     podcast covers, and guide_image pointed at the bench too.

     None of it was inert. sr_dashboard_site_value() passes the default straight
     into the dashboard form field, so the first press of "Save site settings"
     wrote it all to the database, and from then on sr_get_books() returned the
     office photograph and the invented third book in place of the static page's
     own content. Until that happened the page was correct, because
     sr_get_books() passes an explicit '' which makes sr_option() skip the
     defaults entirely. So this was a fault waiting for someone to press Save,
     and it would have appeared to come from nowhere.

     Every default the Resources page can show is now what resources.html says:
     the two covers, no third book, no descriptions, the two podcast covers, and
     the guide's own cover. Episodes three and four stay empty on purpose,
     because resources.html shows those two cards titled with an empty
     data-video and an inert Listen Now, and filling them in would tell the
     dashboard an episode is configured when there is no file.

   * The contact page had no WhatsApp link. contact.html puts one in its social
     block after X, and the footer carries one, so the link existed on the site
     but not on the page whose whole purpose is to be the way to get in touch.
     It now uses sr_whatsapp_url() so it follows the same option the footer and
     the booking flow read, rather than a second hardcoded number.

   * Three images were restored to the resolution they were supplied at. The
     book covers and the guide cover had been downscaled from 1600x2240 to
     1143x1600, a 71% linear reduction, which is compression changing quality
     rather than compressing quality. They are now 1600x2240, 2.72x the size they
     render at. About 855 KB returns to the archive and that is the right
     trade, because the brief was compress for size and not quality and the
     previous numbers were chosen rather than documented.

   * Four new checks, all aimed at what is missing in the dynamic rather than
     only at what is wrong.

     site_parity.py compares all twenty four mapped pages at once: sections and
     headings in order, class tokens, visible copy, and images. The Resources
     page had a checker of its own and a history of drifting, because nothing
     compared the other twenty three. It also reports which static pages have no
     template, which is how payment.html surfaced; the client has since said
     that page is not wanted, so it is recorded as deliberate rather than
     outstanding.

     It resolves generated markup instead of reporting it as lost, which is the
     question that matters here. Three things on this site are not in either
     markup: main.js builds .cert-gallery-item from the data-cert attribute, a
     PHP loop writes class="edi-<?php echo $i ?>", and the comment form lives in
     comments.php rather than single.php. An HTML-only comparison reports all
     three as missing forever.

     resources_defaults.py reads resources.html for the truth and helpers.php
     for the defaults and compares them field by field. This is the gap that let
     the office photograph through: template_parity.py checks the template, and
     these values were not in the template.

     resources_assets.py compares assets by content rather than by path, and
     judges resolution against the size the picture actually renders at, read
     out of layout.css rather than guessed. Nine of the twelve Resources images
     are the client's file compressed, between 47% and 95% of the bytes at full
     resolution. Three had been reduced below the source, which is the finding.

     imgsize.py reads pixel dimensions out of JPEG and PNG headers directly, so
     none of this needs an imaging library.

   = 4.24.0 =
   * A page created by hand now gets the template its slug implies.
     The installer assigned page templates, but only to the pages it created
     itself. A page made afterwards in wp-admin got whatever WordPress defaults
     to, which for the Resources page is default: no banner, none of the layout,
     and none of the body classes the stylesheet keys off. It returns 200 and
     looks broken, which is the worst way for it to look, because nothing reports
     a problem and there is nothing in the dashboard to notice.

     sr_assign_page_template_by_slug() closes that. On save_post_page it matches
     the page's slug against the slug field in sr_setup_page_definitions() and
     applies the template named there, so /resources/ gets
     page-templates/template-resources.php however the page came to exist: the
     installer, wp-admin, an import, or the REST API.

     It only ever applies to a page whose template is empty or default. A
     template the owner chose is never overwritten, so choosing Default in the
     Page Attributes panel is a decision that sticks. It also checks the
     template file exists before writing the meta value, because a definition
     naming a file that is not shipped would otherwise leave a page on default
     with a meta row claiming otherwise, and skips autosaves and revisions.

     The matching is on the slug field, not the array key, and that distinction
     matters: the definitions are keyed emotional_wellbeing and one_on_one while
     those pages are served at /emotional-wellbeing/ and /one-on-one/. The first
     version of this looked the slug up as a key and would have matched
     'resources' and nothing else. Thirteen of the twenty nine definitions have
     a key that is not their slug.

     page_templates_resolve.py checks that all twenty nine resolve, printing the
     key beside the slug and reporting how many differ. It is what caught the
     lookup.

   * Rebuilding the deleted Resources page needs no code. sr_setup_catch_up()
     fires on the first admin request because the stored setup version is
     4.15.0 and the theme is 4.24.0, and sr_setup_find_or_create_page() already
     assigns _wp_page_template from the definition. If the page is ever deleted
     after that, the Install button on the dashboard's Install / Setup tab runs
     the installer with force, which recreates it.

   = 4.23.0 =
   * The primary button: the theme accent fill, white text, and the real reason
     it looked wrong.
     The cause was mine, not the design's. theme-wp.css carries

         .entry-content a { color: var(--gold); text-decoration: underline; }

     for links the block editor writes into post bodies. But a .btn inside a
     post is an <a class="btn btn-dark">, and single.php puts the booking
     button inside .entry-content, so that rule matched the theme's own button.
     At (0,1,1) it beat .btn at (0,1,0), which is why the button rendered with
     gold underlined text on its own dark fill: teal on near-black, with an
     underline a button is not supposed to have. The selector now excludes the
     button classes, so those links go back to .btn.

     Only one button was affected: the booking button at the end of a blog
     post. That is the whole of it, and it was only ever visible there,
     because .entry-content is the post body.

     The fill is now the brand accent. .btn-dark was --charcoal #201C16, so
     the site's main call to action was a near-black rectangle on an off-white
     page. It is --gold #22544C with white text, which is what .btn-gold
     already was and what the theme uses everywhere else for a primary action.
     White on that accent is 8.60:1, against 16.00:1 for the ivory on charcoal
     it replaces, so readability improves on the visual result.

     Written in theme-wp.css because design-tokens.css is byte-identical to the
     static source and must stay so. It lands anyway: theme-wp.css is enqueued
     after design-tokens, layout and page-overrides, so at equal specificity it
     comes later in the cascade.

     This is a deliberate departure from static, where the same button is
     charcoal. It was asked for, and wp_layer_collisions.py now carries it as a
     declared override with that reason, printed on every run. The gate had
     failed on it, which is the gate doing its job: a flat redefinition of a
     real design class is the one thing this layer is not supposed to do
     quietly.

   * The focus ring is unchanged. It is 3px solid var(--gold) at 3px offset on
     :focus-visible only, which is the theme's own rule and the static
     stylesheet's, and it remains visible from outside the button because the
     offset draws it on the page ground rather than on the button, at 8.55:1
     against it. It is the only cue a keyboard user has for where they are.
     Removing it is one line, .btn:focus-visible { outline: none; }, and it
     should not be done quietly.

   = 4.22.0 =
   * The live site was found to be running 4.15.0, so the 500 and the escaped
     category link that the screenshots showed were already fixed here in
     4.19.0 and had simply never been deployed. Both are verified present in
     the archive: comments.php makes no call to comment_form_head() or
     comment_form_field(), writes comment_post_ID, comment_parent and
     comment_ID itself, and has exactly one textarea named comment.

   * Post hero brought back onto the static page. It had drifted in two
     directions. It carried an eyebrow holding the category list, which
     post.html:65 does not have, and it had no lead at all, which
     page-overrides.css:1032 styles and which therefore matched nothing
     anywhere in the theme. That dead rule is the evidence the lead had been
     dropped rather than never wanted.

     Removing the eyebrow also removes the visible fault at its source rather
     than escaping around it. get_the_category_list() returns anchor markup, so
     the hero had been printing

         <a href="https://saeedriaz.com/category/presence/" ...>Presence</a>

     as literal characters. The category is not lost to the site: it is shown on
     the blog listing and on the note cards, as the .tag the static site also
     uses.

     Two smaller corrections found while checking the hero against static: the
     heading gains the h-display class it has on all 24 other static h1s, and
     .post-meta is a div rather than a p, which is what post.html:65 uses.

   * post_hero_parity.py compares that one region against post.html and fails
     on: an element in one and not the other, an eyebrow or a category list in
     the hero, and .post-hero .lead being styled by CSS that no template emits.
     Run against the hero this replaces it reports 8 findings.

   * comment_form.py was reading prose as code. It searched single.php for
     get_the_category_list and treated the sentence explaining why the call was
     removed as though the call were still there, so the fixed template read as
     broken. Both it and its comments.php checks now strip comments before
     searching. It still catches the original fault: reintroducing
     esc_html( get_the_category_list() ), or printing the list with no
     filtering at all, each produce a finding, while wp_kses_post does not.

   * The general lesson, hit three times now: comments explaining a fix quote
     the call the fix removed. Any check that greps for a function name has to
     strip comments first or it reports its own documentation as a defect.

   = 4.21.0 =
   * Resources page: the static resources.html is now the template. The
     previous version had drifted from it, and two of the drifts were not
     cosmetic.

     Copy that had been invented:
       - five eyebrow labels above the section headings, "Free eBooks",
         "Free Resources", "Audio & Podcasts", "Social Media" and
         "Notes & Journal". The static page has no eyebrow in these section
         heads; a heading and a lead line only.
       - the blog heading read "Latest blog posts" where the static page reads
         "Blog posts".

     The podcast cards did not work at all. The template emitted data-video=""
     on all four cards and a bare href="#" on every Listen Now, and rendered an
     inline <audio controls preload="none"> element in their place. That defeats
     the feature main.js already implements: it reads data-video off .pod-media
     and opens a popup, building the audio element on demand so a 3 to 5MB file
     is not fetched until asked for. With data-video empty on every card, no card
     could open the popup, so the two episodes that do have audio were the only
     ones that played and the other two led nowhere. assets/js/main.js is
     byte-identical to the static one, so nothing in the script needed changing.
     The cards now carry the static page's values: a real file URL on the two
     published episodes, an empty one and an inert Listen Now on the other two,
     which is deliberate. An invented YouTube ID would resolve to somebody
     else's video.

     Data still comes from WordPress. Books and episodes configured in the
     Customizer take precedence and the static page's own values are the
     fallback, so an unconfigured site renders the static page exactly. The blog
     block shows the latest post large and the two after it, rather than the
     three the static page hardcodes, so the page stays current as notes are
     published. Social URLs, the WhatsApp link and the eight PDFs resolve
     through sr_option, sr_whatsapp_url and sr_file_asset as before, and
     sr_file_asset means a missing PDF shows a line instead of a 404.

     One change of judgement, worth knowing about: the book grid asks for two
     books, not five. The static page shows two in a two-column grid, so five
     would have been a layout the design does not have. It is a single named
     variable at the top of the template if more are wanted.

   * template_parity.py checks this kind of drift directly instead of trusting
     a list. It takes a static page and a template and compares four things: the
     visible copy, in both directions, so a reworded line and an invented label
     are both caught; the class tokens; the data-* attribute names; and the
     order of the sections and headings. It also requires the replaced blog
     block to actually call get_posts and get_permalink, so the one part that is
     meant to be dynamic cannot be emptied out. Run against the template this
     replaces, it reports 11 findings: the five eyebrows, the heading, and the
     missing data-modal-video.

   * var_order.py checks that a template does not read a local before assigning
     it. PHP renders a notice and carries on, so a missing heading does not
     fail to load, it just quietly does not appear. Neither the parse gate nor
     the AST auditor can see this in a template that assembles content into
     arrays and then loops over them.

   = 4.20.0 =
   * Fixed: the About banner heading fell off the photograph on phones.
     The cause was a rule the theme had added and the static site does not have:

         .about-hero-media, .service-hero-media { min-height: 420px }
     and the same at 280px below 800px.

     The box was therefore held taller than the image. The heading inside is
     absolutely positioned over the box and coloured white, so it centred in a
     box taller than the picture and the bottom of it landed on the section's
     light stone ground: white text on light, with a large empty band beneath.

     Removing the min-height lets the box be sized by the image again, which is
     what the static site does and what the other seven banner pages already do,
     since none of them ever had one. Measured at real phone widths, in an
     iframe so the media queries genuinely applied, before and after:

         320px    47px of overhang  ->  0
         360px    35px             ->  0
         390px    26px             ->  0
         414px    19px             ->  0
         480px and above          already 0

     This corrects the previous entry, which reported the fault on eight pages
     and described it as inherited. Only About was affected, because the
     min-height only ever named .about-hero-media and .service-hero-media, and
     service-hero-media appears on no page at all. It was also not inherited:
     the static site has no min-height on those boxes. The fix in 4.20.0 gave
     the image a clamped mobile height instead, which would have made the seven
     working banners taller than the design intends. That has been removed in
     favour of restoring parity.
   * static_parity.py replaces the earlier hero gate. It hashes the four frozen
     stylesheets against the static copies, walks every max-width rule on both
     sides and reports any that exists in static and not in the theme, and fails
     if a min-height reappears on a box that is meant to be sized by its image.
     The rule walk is what found the origin here: 636 responsive rules in
     static, 701 in the theme, none missing, so nothing had been lost in the
     port and the fault had to have been introduced.

   = 4.19.0 =
   * Fixed: every single blog post returned HTTP 500.
     Found by crawling the live site. Archives, pages and the post feed were all
     fine, and all four posts failed identically, which pointed at the template
     rather than the content; the REST API served the same posts without error.
     The rendered output stopped dead immediately after the comment form tag
     opened, which left exactly one statement to account for,
     comment_form_head().
     It is not available on WordPress 7.1.3, so the call was an undefined
     function and the request died there. It produced no output at all, which is
     why the page simply stopped rather than showing an error a visitor could
     read. Nothing links to these pages as broken, they simply do not open.
     The hidden fields it used to print are now written out directly:
     comment_post_ID, without which the submission is rejected outright,
     comment_parent, which is what makes threaded replies thread, and
     comment_ID for editing. Writing them is not only a workaround, it removes a
     dependency on a core function the theme cannot rely on being there.
   * comment_form_field() is no longer called either. It prints its own textarea
     named comment, and the form already has one, so calling it put two
     same-named fields into a single form and the browser submitted whichever it
     treated as last. That is a live bug independent of the 500: comments could
     have been posted with the message lost.
   * The reply test no longer casts the thread_comments option to an int and
     calls the result a comment id. That value is whether threading is switched
     on, not which comment is being replied to. replytocom is now read through
     absint, so a non-numeric query argument cannot reach a comment lookup.
   * single.php no longer wraps get_the_category_list() in esc_html(). That
     function returns anchor markup, so escaping it as text printed the links
     literally: the post hero read <a href="...category/presence/">Presence</a>
     as visible angle brackets with dead category links. It uses wp_kses_post.
   * comment_form.py guards all of this: no call to the two unavailable core
     helpers, the three fields wp-comments-post.php reads are present, exactly
     one textarea named comment, and the reply id is read through absint. It is
     regression tested against the broken version this fixes.

   = 4.18.0 =
   * The Book an Appointment page ships with the Cal.com embed already on it.
     The page used to arrive as a note asking the owner to paste the embed,
     which made the one thing the booking page exists for something to be
     remembered after installing the theme, on the one page where a visitor is
     trying to book. The supplied shortcode is now the page content on a fresh
     install, kept verbatim rather than rebuilt attribute by attribute, because
     the embed plugin owns what those attributes mean and a theme that
     reconstructs the shortcode is one plugin update away from writing
     something the embed will silently misread.
     It is a preset and not a lock. The page stays an ordinary page the owner
     edits in the ordinary editor, the installer still only ever fills a page
     that is empty, and there is a Reset the embed to the default link in Site
     Settings that restores the shipped shortcode. That reset rewrites page
     content from a URL, so it is nonce and capability guarded rather than being
     a bare link.
   * An embed whose plugin is not installed is inert text. Left alone, the
     public booking page rendered the literal shortcode, which looks like a
     mistake and exposes the scheduler's internal configuration to anyone who
     visits. Visitors now get a short plain explanation and a link to the
     contact page; an administrator still sees the raw shortcode, because they
     are the one who needs to see what is on the page in order to fix it. Site
     Settings states plainly whether the embed plugin is detected, rather than
     leaving it to be discovered by a visitor.
   * The Cal.com brand colour inside ui is #00705e as supplied. It is close to
     the site's accent but not identical. Change it on the page, or in
     sr_default_cal_embed(), and the scheduler will match the rest of the site.

   = 4.17.0 =
   * The automatic confirmation is split into two messages, and both are the
     owner's to write.
     There was one reply, sent to everyone, and it read like a broadcast. Someone
     contacting a coach for the first time needs to know a person reads this,
     what happens next, and how soon they will hear back. Someone writing again
     needs none of that reassurance: they need to know it arrived and that they
     do not have to start over. Sending a first-time enquirer the same lines a
     fourth-time one gets makes the whole exchange feel automated, which is the
     opposite of what the message is for.
     Whether it is the first time is read from the stored enquiries, on the
     lowercased address, because the same person types Jane@example.com one day
     and jane@example.com the next and treating those as two people would send a
     first-time welcome to someone who has already been here. The check is made
     before the new enquiry is inserted, since afterwards the enquiry just
     written is itself in the table and every visitor would read as returning.
     The two messages are options with professional defaults, edited in the
     dashboard next to the placeholders {name}, {email}, {message}, {phone},
     and {site}. They are the business speaking rather than the theme, so the
     theme should not be the author. Placeholders are replaced on send and
     anything else is left as typed, so a stray brace shows up in the email
     rather than silently deleting part of the message.
     The confirmation keeps Reply-To pointing at the owner, so a visitor who
     wants to add something does not have to find the address again.
   * The first-time and returning defaults replace the earlier single message
     and the unused contact_autoreply_text option it never read.

   = 4.16.0 =
   * Owner notifications are built properly and the recipients are options.
     What they replaced, on every enquiry and every booking request:
         Name: Jane
         Email: jane@example.com
         Service: Emotional Wellbeing
         I would like to talk about...
     Three things were wrong with that and only the first is cosmetic. There was
     no Reply-To, so the owner could not answer by replying and had to copy the
     address across by hand, which is the step people get wrong. The recipient
     was read through a chain of fallbacks that would quietly deliver enquiries
     to a footer address if the notification address were cleared, with nothing
     on screen saying where they go. And there was no way to add a second
     recipient or copy anyone, so a site with a partner had no option but to
     send duplicates by hand.
     Notifications are now a table with inline styles, which is the only markup
     mail clients agree on, carrying the sender's details as a field table, the
     visitor's own words quoted so they read as their words, a Reply button that
     opens a reply to the visitor, and a link into the dashboard. Both an HTML
     and a plain text part are sent as multipart/alternative, so the message
     survives a client that will not render HTML. Every visitor value is
     escaped, and the palette is restated as literal hex because the webfonts
     and custom properties do not exist in a mail client.
     The recipient is now one function, sr_notification_to(), used by enquiries,
     bookings, and calendar notifications alike, so they cannot drift apart.
   * contact_email takes a list. Addresses may be separated by commas,
     semicolons, or newlines, each is validated on save, invalid ones are
     dropped rather than turning every later notification into a bounce, and
     duplicates collapse. A new Carbon copy option and an automatic
     confirmation to the visitor, off by default, sit alongside it. The
     dashboard reports which addresses enquiries are currently delivered to, so
     that is visible rather than inferred from a fallback chain.
     Because it is a list now, nothing prints it raw: the contact page, the
     privacy page, and the test button's default all go through
     sr_contact_display_email(), which returns the first address. Printing the
     list would have put a comma separated pair into a mailto link and onto the
     page as visible text.
   * A failed notification is logged. It is the failure that is otherwise
     invisible: the enquiry is stored and the visitor sees a success message,
     so the lead sits in the dashboard and nobody was told. The visitor is told
     what is true, that it was received, rather than being told a message did
     not arrive when it did. The booking request notification has the same
     treatment, and drops the run of dashes that stood in for formatting.

   = 4.15.1 =
   * Dashboard notifications are styled instead of inherited. The admin
     stylesheet set no rule for .notice at all, so every "Saved successfully."
     and "Deleted successfully." fell back to WordPress's default notice
     typography, which is black on plain white. Between the dark header and
     the tabs that read as an unstyled strip rather than part of the dashboard.
     The notices now carry their own colours, drawn from the palette the rest
     of the dashboard uses, with a tinted ground per state and a coloured left
     edge that carries the meaning: green saved, red failed, amber cleared.
     The rules are two classes deep on purpose, because WordPress styles
     .notice p at one class and anything weaker here would depend on which
     stylesheet happened to load last. Every pair is measured rather than
     eyeballed: the body text runs 15.6:1 to 17:1 and the status edges 4.9:1
     to 7.6:1, against bars of 4.5:1 and 3:1.
   * The header's description rule was a bare descendant, so it styled every
     paragraph inside the header rather than the one it was written for. A
     paragraph, including a notice, landing in there would have inherited the
     light ink meant for that single line, putting near-white text on a white
     notice background. It is scoped to the header's own text block now.

   = 4.15.0 =
   * Outgoing mail now goes through an authenticated server, configured in the
     dashboard under a new Email tab, with a button that sends a test message
     and reports what the mail server actually said.
     The theme sends every notification with wp_mail(), which uses PHP mail()
     by default. Shared hosting usually blocks that, and the failure is invisible
     from the outside: wp_mail() still returns true, the booking is still
     recorded, the visitor still sees their confirmation screen, and the
     notification simply never arrives. On a booking site that is the worst way
     for it to fail, because the booking looks handled while nobody is told.
     WordPress already ships PHPMailer and exposes it through phpmailer_init, so
     this needs no plugin and no bundled library, and it covers anything else on
     the site that sends mail. With nothing configured the hook returns at once
     and wp_mail() keeps its default behaviour.
     inc/smtp.php holds the transport and the accessors; the dashboard file
     holds only the form.
     The password is entered in wp-admin and stored in the options table. It is
     never printed back into the form, and nothing that renders the screen
     reads it: the screen asks sr_smtp_password_is_set(), a yes or a no, so
     there is no code path by which the stored value could reach the page. A
     blank password field means keep what is stored rather than clear it,
     because the form is rendered on every visit and a save that submitted the
     empty field for any other reason would quietly take the site back to
     mail(). Clearing it is a separate, explicit, confirmed action.
     A test result is reported from a transient rather than the query string,
     since the server's reason can name the host and a URL ends up in the
     browser history and in any referrer the page sends.

   = 4.14.0 =
   * The Customizer's Background Color control now changes the background.
     It wrote --ivory, which is not the page ground. The page ground is
     --butter, which body, .section.alt, .section--mint, .contact-section and
     the final CTA band all paint. --ivory is the component fill behind form
     inputs, the booking cards and the confirmation notes, and it is also the
     text colour on .btn-dark and .btn-light.
     So a control labelled Background Color did not change the background: it
     recoloured every input and card, and a dark choice set the button text the
     same dark and left it unreadable. The setting writes --butter now, its
     default is the page ground's own #FFFFF5 rather than the old #FBF8F3, and
     the retired-colour migration clears a stored #FBF8F3 so untouched sites
     return to the stylesheet's value instead of having the page repainted with
     the cooler ivory the design moved off.
   * Two audits added and run against a fixture of planted defects before being
     trusted. php-lint/audit.js reads the parse tree rather than matching text,
     and covers unescaped output, SQL built by interpolation, state changes
     reachable with no nonce or capability in the chain, variables that are read
     but never assigned, loose equality, extract, and include files reachable
     directly over the web. Guarding is resolved transitively through the call
     graph, so a helper reached only from a guarded admin action is not reported
     while an ajax route with no guard anywhere still is.
     frontend_a11y.py covers duplicate ids, images with no alt, controls with
     neither a label nor an aria-label, and anchors with no href. It rewrites
     PHP blocks before matching, because a tag containing <?php ends at the >
     of ?> and would otherwise report attributes that are really there.

   = 4.13.1 =
   * The active nav pill is applied without a PHP warning, and it now reaches the
     element the design reads. Two faults, one visible and one silent.
     The visible one: the callback took &$output, but WordPress applies this
     filter with apply_filters(), which hands the value over by copy, so PHP
     emitted "Argument #1 ($output) must be passed by reference, value given"
     on every menu item. The silent one: the class was also being appended
     through nav_menu_css_class, which builds the class attribute of the
     wrapping li, while the rule is .nav-list a.active. Nothing in the design
     reads .nav-item.active, so the pill still would not have appeared. The
     callback now returns the markup and writes the class onto the anchor, and
     the nav_menu_css_class pass is gone. The fallback menu, which never passes
     through the walker, already wrote the class onto its own anchor and is
     unchanged.

   = 4.13.0 =
   * The booking form no longer stores a different service from the one it shows.
     Each of the four options had a value and a label that disagreed: "Emotional
     Wellbeing" was submitted as "1-on-1 Coaching", "Financial Wellbeing" as
     "Financial Coaching", and "Corporate Wellbeing" as "Corporate Coaching".
     Those three services are real pages on this site, and the pairings matched
     nothing, so the dashboard recorded a service the visitor never chose and the
     record could not be reconciled against the services pages. The value and the
     label are now the same string, and the options are read from the service
     pages themselves, so retitling one reaches the form. The free Discovery Call
     keeps value "Discovery Call", since that is what sr_cal_embed_url() and
     book-schedule already read, and is what every Book a Free Discovery Call
     button leads to.
   * Preferred Date and Preferred Time are removed from the booking form. The
     booking is a request that hands off to Cal.com, which is where the slot is
     chosen, so asking for a date and time up front contradicted the flow and
     put a stale preference in the record. Both columns stay on the table and
     stay nullable, because webhook-confirmed bookings still fill them in.

   = 4.12.1 =
   * The active nav pill is applied server-side. main.js marks it by comparing
     each link against the last segment of location.pathname, which is
     "services.html" on the static site and "" under a WordPress permalink, so no
     nav item was ever marked on any page. The design styles .nav-list a.active
     and had never heard of WordPress's current-menu-item; the two are now
     bridged around wp_nav_menu in header.php, with a matching fallback for the
     menu built from pages. main.js is unchanged and its copy of the check is now
     redundant.

   = 4.12.0 =
   * Ported the inline stylesheets of booking, book-a-call, book-schedule,
     intake and the three booking confirmation pages. The booking templates
     shipped with none of their page CSS at all, so those pages rendered as
     unstyled markup: native form controls and a heading sitting under the fixed
     header.
   * Restored the static success card on the cancelled and rescheduled pages.
     Both had been built on cal-wrap and cal-container with inline padding
     standing in for the card, which dropped its ground, border, radius,
     measure, shadow and entrance animation along with the thanks and sub lines.
   * Restored the is-cancel and is-reschedule icon modifiers, which the colour
     rule is keyed off, so both icons had been rendering gold.
   * Restored bk-success-note on the Book a Call confirmation card.
   * Corrected three drifted rules in the intake stylesheet, including
     .intake-hero.eyebrow where the static file has .intake-hero .eyebrow, a
     compound selector that matched no element on the page.

   = 4.11.0 =
   * Booking now runs CTA to form to Cal.com embed. Every "Book an Appointment"
     button, including both Packages card buttons, lands on the booking form, and
     a successful submit sends the visitor on to the Cal.com embed page with
     their reference in the query string.
   * Added sr_cal_embed_url() so the hand-off is resolved in one place. The
     scheduler lives on the Calendar page as a shortcode the owner pastes in, so
     the theme resolves the destination rather than rendering it.
   * theme.js follows success_url when the server returns one, and keeps the
     existing message-and-reset behaviour for every other form.

   = 4.10.0 =
   * Restored sr_ajax_guard(), missing while all eight AJAX endpoints called it.
     Every contact, comment, booking and booking follow-up submission ended on
     that call, so none of them completed, and no endpoint verified a nonce.
   * Restored sr_send_client_confirmation(), missing while two booking paths
     called it. No client confirmation email was sent after booking.
   * Wired the Cal.com webhook to sr_cal_attendee(), sr_cal_find_booking(),
     sr_cal_save_booking() and sr_send_booking_emails(). It dispatched to three
     functions that had never existed, so every booking Cal.com sent died
     without being saved or emailed.
   * Every direct database write now runs through sr_db_ok() or
     sr_db_ok_or_json(). A failed write was previously indistinguishable from no
     write: the request succeeded and the record was simply absent. AJAX callers
     are told, REST and redirect callers are logged.
   * Dashboard status changes and deletions now report a failed write instead of
     redirecting with a success message.
   * Removed dead code: five uncalled functions, two retired admin page
     renderers, and the inc/admin-leads.php file they were the whole of.
   * Removed 31 unreachable selectors from theme-wp.css and 31 more from rules
     that mixed live and dead classes.
   * Removed three theme-wp.css rules that were resetting .work-options and
     .work-card to values the design already owns, which changed the front page
     grid away from the static layout.

License URI: https://www.gnu.org/licenses/gpl-2.0.html
*/

/* Type rhythm.
   WordPress emits default element margins through its global stylesheet when
   theme.json declares none: h1 0.67em, h2 0.83em, h3 1em top and bottom, and
   p 1em bottom. Those are not the values the static site uses, which are
   h1-h6 { margin: 0 0 0.5em } and p { margin: 0 0 1.1em }. Left alone, every
   heading gains a gap above it that the original design does not have, and
   the paragraph spacing tightens.
   margin-block is declared because it is the longhand WordPress emits, so the
   two meet on equal specificity and this file wins on load order instead, being
   the last stylesheet enqueued. There is deliberately no !important: the bare
   selectors are specificity 0,0,1, so the static site's own paragraph rules
   still take precedence over these. The shared CSS relies on that heavily,
   .post-body p at 1.4em, .faq-a p at 0, .hook-card p at 12px and around twenty
   more, and an important flag here would silently flatten every one of them. */
h1, h2, h3, h4, h5, h6 { margin-block-start: 0; margin-block-end: 0.5em; }
p { margin-block-start: 0; margin-block-end: 1.1em; }

/* Arrow alignment in buttons and text links.

   design-tokens.css sets .btn .arw to display:inline-block and gives it no
   line-height, so the span inherits 1.7 from body. Most arrows here are a
   Phosphor glyph sitting inside a line box sized for text, which is why they
   read slightly small and low against the label.

   The ones written as the character U+2192 are worse. Bricolage Grotesque has no
   arrow glyph, so the browser substitutes a system font whose ascent and
   descent differ from the display face. The arrow comes out visibly smaller
   than its label and sitting below the baseline, which is what was reported on
   the Book an Appointment button. Line height 1 collapses the box onto the
   glyph itself, so centring the box centres the character rather than centring
   a text-sized line above it.

   The shared CSS cannot be edited, so this is corrected in the theme's own
   layer. Specificity matches .btn .arw at 0,2,0 and this file is the last
   stylesheet enqueued, so it wins on order and needs no important flag. */
.btn .arw,
.text-link .arw { line-height: 1; }

.btn .arw > i,
.text-link .arw > i { line-height: 1; }
