# Sprigly Changelog

Full Lite changelog history. The readme.txt only carries the most recent entries (5000-char limit per Plugin Check); this file is the comprehensive source. The v1.30.x section below is the monolithic-era history before the Lite/Pro split (kept for reference).


## 1.6.1, 2026-05-08

Follow-up to 1.6.0. Cleans up the Settings → Upgrade to Pro tab now that Journey Preview and the Portal welcome message have moved into Lite, surfaces three real Pro benefits that previously weren't called out, and tidies the Preview Journey button on the journey edit screen.

### Changed
- `SP_Admin_Settings::render_upgrade_tab()` group definitions updated:
  - Removed "Preview Journey" from the Authoring & workflow group (now a free Lite feature, listed in the "What's included in Sprigly" section of readme.txt instead).
  - Removed the entire single-bullet "Branding" group (its only bullet was the Portal welcome message, which is now a free Lite feature).
  - Added "Personalised email sender identity" under Communications. Customer-facing description: "Client emails arrive as 'Your Name from Your Brand', with replies routed to your inbox instead of the site admin email." Pairs with the per-practitioner identity work shipped in Pro 2.2.40 / Lite 1.2.20.
  - Renamed the "Multi-practitioner Team" group heading to "Practitioner accounts & Team" so the new "Personal practitioner settings page" bullet (added at the top of the group) reads correctly on solo Pro, where the multi-seat features below don't apply but the per-practitioner profile, password, and data tools still do.
  - Added a new "Updates" group with a single bullet for automatic Pro updates: "Pro updates ship straight through Dashboard → Updates from sprigly.co. No manual zip uploads." Pairs with the Pro 2.2.43 auto-updater work.
- `Sprigly_Admin_Journeys::render_preview_button()` HTML restructured. The "See how this looks to your clients" hint was previously a `<span>` flowing inline next to the Preview Journey button, which read as ragged text running off to the right of the button on most viewports. Now rendered as a `<p class="description sp-preview-hint">` directly below the button, with `margin: 6px 0 0` so it sits as a clean WP-style description line. The empty-milestones path keeps its existing `.sp-preview-empty` span treatment unchanged.

### Pro impact
- None. Pro 2.2.58 stays at the same min-Lite-version requirement (1.6.0), and 1.6.1 is purely a Lite-side copy and UI tweak.


## 1.6.0, 2026-05-08

Pricing reshuffle: two features move from Sprigly Pro back into Sprigly Lite. Goal is to make Lite's value obvious on first install (Preview lets a practitioner see the client side immediately, the welcome message is pure portal polish that costs nothing to give away) without weakening any of the Pro retention drivers. Resources, custom check-in fields, checkpoint sign-offs, scheduled unlocks, sequential mode, file uploads, PDF reports, practitioner notes, pause/reactivate, CSV export, duplicate, and per-event email toggles all stay Pro-only.

### Added
- **Journey Preview** is now a free, fully functional Sprigly feature. New file `includes/class-sp-portal-preview.php` defines `Sprigly_Portal_Preview` with the same token format and 1-hour expiry the Pro-side class used (HMAC-SHA256 of `journey_id|practitioner_id|expires`, signed with `wp_salt('auth')`). The class hooks the same 11 portal extension points the Pro preview class used (`sprigly_portal_skip_auth_check`, `sprigly_portal_skip_form_actions`, `sprigly_portal_skip_role_autorestore`, `sprigly_portal_resolve_user_id`, `sprigly_portal_skip_render_dispatch` + `sprigly_portal_render`, `sprigly_portal_url`, `sprigly_portal_body_class`, `sprigly_portal_after_shell_open`, `sprigly_portal_nav_html`, `sprigly_portal_head`) and re-fires the `sprigly_portal_preview_milestone_after` action so Pro can continue to render disabled placeholders for its Pro-only custom check-in fields inside the preview milestone view.
- **Preview Journey button** on the journey edit screen. `Sprigly_Admin_Journeys::render_preview_button()` registered on `sprigly_journey_edit_after_title` (priority 10) renders the button when the journey has at least one milestone, or an inline "Add some milestones below to preview…" hint when it doesn't. The button is gated to the journey's owning practitioner via Lite's existing ownership check on the edit screen; the preview link itself is gated to that practitioner's logged-in session via the token's identity check.
- **Portal welcome message** is now a free Sprigly feature. New row on Settings, Branding (under the Powered by toggle): a plain textarea with a 3-line height. Whatever the practitioner writes is rendered at the top of every client's portal dashboard, immediately under the "Welcome back, {name}" heading, as `<p class="sp-welcome-message">…</p>` with `wp_kses_post` escaping. Stored under the canonical option key `sprigly_portal_welcome`.
- **`'html'` option type** in `SP_Admin_Settings::OPTIONS` schema, dispatched to `wp_kses_post` via the `sanitizer_for_type()` switch. Used by `sprigly_portal_welcome`.
- **`SP_Admin_Settings::read_portal_welcome()`** helper reads the canonical key first and falls back to the legacy `sp_portal_welcome` key on first read, promoting the value forward and deleting the old key. Older Sprigly Pro versions stored welcome text under either the canonical key (post-v2.2.0 prefix migration) or the legacy key (the constant value the v2.2.55+ code was actually using — a pre-existing inconsistency between the migration map and the runtime code in Pro). Either way, the customer's existing welcome text survives the move with no visible interruption.

### Changed
- `class-sp-portal.php` portal dashboard now renders the welcome message inline (after the "Welcome back" heading, before the `sprigly_portal_dashboard_top` hook fires for any other extensions). Skipped when an older Sprigly Pro is loaded that still ships `Sprigly_Pro_Branding` so customers don't see the welcome paragraph rendered twice during the brief window when their Pro is older than their Lite.

### Compatibility
- Older Sprigly Pro (pre-2.2.58) continues to register its own preview class and its own welcome render via the same hooks. Lite's new `Sprigly_Portal_Preview::init()` checks `class_exists('Sprigly_Pro_Preview')` and bails when the older Pro class is present, so the customer sees Pro's preview behaviour as before. Same defer pattern for the Preview button render in `Sprigly_Admin_Journeys`. Once the customer updates Pro to 2.2.58, the Pro classes are gone and Lite handles both features natively. No customer-visible action required.


## 1.5.1, 2026-05-07

### Changed
- **Industry/Category dropdown refreshed** in `class-sp-admin-journeys.php`. Removed the `therapy` (Therapy / Counselling) option, renamed the `business` label from "Business Mentoring" to "Mentoring" (DB key kept as `business` so existing journey records aren't orphaned), added new `creative` (Creative / Performance) option. Final list: Coaching, Fitness / Personal Training, Wellness / Health, Education / Tutoring, Mentoring, Creative / Performance, Spiritual / Pastoral, Nonprofit / Community, Other.
- **Industry-to-role JS auto-fill map** updated to match: removed `therapy:'counsellor'`, added `creative:'teacher'`. Existing `business:'mentor'` mapping unchanged.
- **Plugin header description** in `sprigly.php` rewritten for the non-clinical audience: "Client journey and progress portal for coaches, mentors, trainers, tutors and other practitioners."
- **readme.txt copy refreshed.** Tagline, long description, "Built for" bullet list, and the "client-facing role" FAQ all updated to remove therapist/counsellor framing in favour of coach/mentor/trainer/teacher/tutor/instructor/guide.
- **WordPress.org tags refreshed.** Removed `therapist` and `progress tracker`. Added `wellness` and `mentoring`. Final five: `coaching, client portal, personal trainer, mentoring, wellness`.

### Notes
- Existing journey records with `industry='therapy'` are preserved in the database. When the journey is next edited, the dropdown will display "Select..." (no match) and the practitioner can pick a new category. No data loss.
- This release coordinates with Sprigly Pro v2.2.57 which carries the matching readme tag refresh.


## 1.3.3, 2026-05-03

### Changed
- **WPCS / Plugin Check cleanup on the Safety & Backups stack.** No behaviour change.
  - `class-sp-admin-safety.php`: render-path `$_GET` reads (active tab, post-redirect notice text) wrapped in a scoped `phpcs:disable WordPress.Security.NonceVerification.Recommended` block (display-only URL state, no form data processed). Five `sprintf( __( '…%d…' ) )` strings gained `translators:` comments. `settings_save` now extracts `retention_days` and `keep_minimum` via `absint( wp_unslash( $_POST[…] ) )` (both inputs are clamped via `max(1, min(…))` afterwards either way, but `absint()` is the call WPCS actually recognises for `WordPress.Security.ValidatedSanitizedInput.InputNotSanitized`).
  - `class-sp-backup.php`: every `$wpdb` call against the snapshot tables annotated with targeted `phpcs:ignore` (or `phpcs:disable` block where multi-line) for `WordPress.DB.PreparedSQL.InterpolatedNotPrepared`, `WordPress.DB.DirectDatabaseQuery.{DirectQuery,NoCaching,SchemaChange}`, and `PluginCheck.Security.DirectDB.UnescapedDBParameter`. Justification noted inline: table identifiers can't be placeholder-prepared in WordPress, and the names come from `SP_Database::table()` / `self::get_manifest_table()` plus a server-generated `gmdate + wp_generate_password` suffix already stored in the manifest. No user input touches the SQL string. Pattern matches the existing annotations already in `class-sp-install.php`.


## 1.3.2 — 2026-05-02

### Changed
- **`SP_Portal::write_protection_files()` now accepts an optional `$practitioner_id` argument.** When supplied, the helper creates `/uploads/sprigly/{practitioner_id}/` and drops an empty `index.html` inside it so directory listing is blocked even on hosts with `Options +Indexes` enabled. Pairs with Sprigly Pro v2.2.53 which uses this to partition reflection + resource uploads into per-practitioner subfolders (defence-in-depth on top of the existing parent `.htaccess` Deny + `?sp_file=ID` proxy). Lite-only installs are unaffected since Lite has no file upload features; the helper just exposes the new arg for Pro to consume.


## 1.3.1 — 2026-05-02

### Changed
- **Snapshot retention default lowered from 60 days to 30 days.** Halves baseline disk usage on new installs (~30 nightly snapshots at steady state instead of ~60). Existing customers who already saved a retention value keep their setting unchanged, only fresh installs and customers who never visited the Settings tab pick up the new default. The "always keep at least N most recent" floor (default 5) is unchanged. Both values still configurable on the Safety tab.

## 1.3.0 — 2026-05-02

* **New, Safety & Backups page (Sprigly &rarr; Safety & Backups, admin only).** Snapshot/restore for the nine Sprigly DB tables (journeys, milestones, clients, progress, reflections, notes, resources, milestone_fields, field_responses). Snapshots are physical backup tables created via `CREATE TABLE LIKE` + `INSERT SELECT`, tracked in a new `wp_sp_backup_manifest` table. Copies live on the customer's own database, nothing is sent off-site. Restores are reversible: a pre-restore safety snapshot is taken automatically before live data is overwritten. The Restore confirmation explicitly warns that ALL practitioners' data on the site is replaced. Three operator-controlled toggles in Settings: nightly auto-snapshot (default ON), post-plugin-update snapshot (default ON), and the manual snapshot button (always available regardless). Retention defaults to 60 days with a minimum of 5 most-recent always kept; both configurable. Page is gated to `manage_options` (the practice owner / WP admin), hidden from team practitioners. No activity log, no per-practitioner view: this is a backend safety net, not a monitoring tool. New files: `includes/database/class-sp-backup.php`, `includes/admin/class-sp-admin-safety.php`. Wire-up in `sprigly.php` (require + register_hooks + activation/deactivation hooks). Pro extends Lite's menu so this page is visible to Pro customers via the same Sprigly menu.

## 1.2.22

* **Bug fix, debug-log noise: `Attempt to read property "ID" on string` warning eliminated across four upgrade-routine call sites.** Four functions in Lite called `get_users( array( ..., 'fields' => array( 'ID' ) ) )` and then iterated the result accessing `$user->ID`. On PHP 8 + recent WordPress versions, the `'fields' => array('ID')` form returns a flat array of string IDs (not stdClass objects with an `ID` property), so the property access threw a runtime warning on every upgrade-routine pass. Affected sites: `Sprigly_Install::upgrade_to_1_19_3_clean_admin_practitioner_roles()` (line 295), `Sprigly_Install::upgrade_to_1_19_5_resuspend_orphaned_admin_practitioners()` (line 354), `Sprigly_Install::upgrade_to_1_20_0_optout_admin_practitioners()` (line 408), `Sprigly_Roles::bootstrap_owner()` (line 407 admin-fallback lookup), and the role-cleanup loop in `uninstall.php`. All five switched to `'fields' => 'ID'` (string form) which returns the same flat ID array directly, with `(int)` casts on each value. Pairs with Sprigly Pro v2.2.50 which fixes two sibling cases of the same bug in the licence module.

## 1.2.21

* **UX, "Protect data on uninstall" toggle on Settings → Tools is now visible only to the Sprigly Owner.** Pre-1.2.21 the form rendered for every admin who could reach the settings page, even though `handle_save()` already rejected non-owner saves with a `wp_die()` (so non-owners could TRY to change the value but the change wouldn't persist). The visible-but-inert form was confusing — admins saw the checkbox in their preferred state, clicked Save, were dumped on a `wp_die` page, and had no obvious "this is owner-only" signal. Now non-owners see a read-only status block instead: "Status: Data is protected — deleting the plugin will keep your journeys, clients, milestones, reflections, and settings. Only the Sprigly Owner can change this setting. Contact the owner if it needs adjusting." The owner sees the form unchanged. Server-side gate at `handle_save()` line 95 stays as defence-in-depth. The amber "Data protection is OFF" warning still renders for all admins regardless of who can edit, since they all need to know if the site is currently in the unprotected state.

## 1.2.20

* **UX, client-bound emails are now sent with a recognisable "From" identity instead of always reading as the WordPress site title.** Pre-1.2.20 every email — welcome, journey assigned, milestone signed off, practitioner note — went out with `From: {site-title} <{admin-email}>`, where site-title is the WordPress general-settings name ("My WordPress" out of the box) and admin-email is the site owner. On a Team-tier install where five practitioners share the WordPress site, every client received emails attributed to the site owner, who they almost certainly don't recognise. Result: emails get ignored, mistrusted, or filtered to spam. Fix is at three layers. (1) **`SP_Email::send()` now accepts an optional `$from_practitioner_id` argument** — when supplied, the From DISPLAY name becomes "{first-name} from {brand-name}" (e.g. "Mike from theDesignPeople"), the From EMAIL stays on the WP admin email so SPF/DKIM/DMARC alignment passes (moving it to the practitioner's personal email would land most messages in spam at Gmail/Outlook), and the Reply-To header is set to the practitioner's email so a reply lands in the right inbox. (2) **`get_brand_name()` helper** prefers the `sprigly_brand_name` setting (Settings → Branding → Portal Name) over the WordPress site title, so practitioners who set "Portal Name" already get a recognisable brand — and the field's description is updated to call out this dual purpose. (3) **`notify_client_welcome()` accepts and propagates the practitioner ID**, with all four call sites (Add Client, Resend Welcome from client directory, Re-enrol from Pro client actions, generic event handler) passing the appropriate practitioner — falling back to `get_current_user_id()` when the call site doesn't have an explicit practitioner reference (because the admin triggering the action IS the practitioner). Practitioner-bound emails (notifications about a client's reflection / sign-off / journey completion sent to the practitioner themselves) continue to use the brand name alone in the From line — practitioner doesn't need their own name in their own From. Pairs with Sprigly Pro v2.2.40.

## 1.2.19

* **Bug fix, brand logo URLs are normalised to the current request's scheme so an HTTPS portal page no longer references an http:// img and triggers a mixed-content warning.** When the brand logo was uploaded while the site was on HTTP (or the upload URL was cached at the wrong scheme), the saved option value retained `http://` even after the site moved to HTTPS, and the `<img src>` in the portal nav was emitted exactly as stored. Browsers auto-upgrade the request to HTTPS so the image still loaded, but the developer console showed a "Mixed Content: Upgrading insecure display request" warning on every portal pageload. Fixed at the read point with `set_url_scheme()` on the assembled `$header_logo` value, which only flips http<->https and leaves the host + path untouched. Same fix applied to the email header logo (`set_url_scheme($brand_logo, 'https')` — forced HTTPS rather than matching request scheme, since recipients reading via webmail get a mixed-content warning when the message references an http:// asset on otherwise-HTTPS infrastructure).

## 1.2.18

* Suppress the "Milestone completed! Keep going!" success banner when the just-completed milestone was the last in the journey. The "Journey Complete!" celebration card rendered below already conveys the success state, and "Keep going!" reads wrong when there is nothing left to go to. The banner still fires for every other completion. Gate added on the existing `$pct < 100` calculation so no new query is needed.

## 1.2.17

* New "what's next" CTA on the portal milestone view once a milestone is complete (either the client clicked Mark as Complete, or a practitioner signed off a checkpoint). Three branches: when the next milestone is unlocked, render a primary "Continue to [Next milestone title]" button as the main CTA so the client doesn't have to navigate back to the journey overview. When the next milestone is locked (scheduled date hasn't arrived, relative timer hasn't elapsed, an earlier checkpoint hasn't been signed off, etc.), render a small muted "Up next: [Next milestone title] ([lock reason])" label instead, so the client sees what's coming without clicking into a lock screen. When the just-completed milestone was the last in the journey, render a "Journey complete, back to portal" CTA. The existing celebration UI on the journey overview is unchanged. Reuses the existing `SP_Database::check_milestone_unlock()` so all the Pro lock rules (checkpoint gate, scheduled unlocks, sequential mode) flow through this CTA the same way they do everywhere else.

## 1.2.16

* **UX — milestone metadata moved from Title column to Description column on the journey edit page's milestone list.** v1.2.15 introduced the `sprigly_admin_milestone_row_meta` hook (and Pro v2.2.25 hooked it for Checkpoint / Final badges) but stacking those badges + the existing "🔒 Unlocks on …" line under the Title made that column visually heavy when multiple badges fired on the same milestone. The unlock-date line and the `sprigly_admin_milestone_row_meta` hook now both render under the Description text instead, clustering all the type-and-scheduling annotations into one block in the wider column. Same hook signature, same data — only the rendering location changes.

## 1.2.15

* **New extension point — `sprigly_admin_milestone_row_meta`.** Action fires inside the milestone-list row on the journey edit page, right after the existing "🔒 Unlocks on …" line. Lets Sprigly Pro v2.2.25+ inline-render the Checkpoint and Final-milestone type badges so practitioners see at a glance how each milestone behaves without clicking Edit. No behavioural change for Lite-only sites — Lite ships only Standard milestone type.

## 1.2.14

* **New extension point — `sprigly_skip_admin_practitioner_strip_upgrade`.** Wraps the v1.19.3 (`upgrade_to_1_19_3_clean_admin_practitioner_roles`) and v1.20.0 (`upgrade_to_1_20_0_optout_admin_practitioners`) upgrade routines so Pro v2.2.17+ on Team tier can short-circuit them. Default returns false (Lite single-seat behaviour unchanged: non-owner admins are stripped of the practitioner role + opt-out-flagged). Pro returns true on Team tier so non-owner admin practitioners — who ARE legitimate team members on Team plans — aren't silently stripped when the stored Sprigly version is reset (e.g. after a TDP cleanup that wipes options, or a drop-tables + reinstall cycle).

## 1.2.13

* **New extension point** — `sprigly_client_row_after_name` action fires immediately after the client name in each row of the enrolment list. Pro v2.2.16+ uses this to render the pending-sign-off bell next to the name rather than next to the Remove button (where it was previously easy to misclick). No behavioural change for Lite-only sites.
* **Chronological timeline inside milestone cards** — the body of each milestone card on the enrolment view now builds a single timeline array from reflections (seeded by Lite) and applies a new `sprigly_admin_milestone_timeline_items` filter so Pro v2.2.16+ can inject practitioner notes alongside them. Items are sorted by `created_at` and rendered in date order. Previously: all reflections rendered first, then all notes — so a "well done" note from 7:39 am ended up below a "ta" reflection from 7:40 am, contradicting the conversational order the client portal already uses. Lite-only sites just see reflections in chronological order; the user-visible change is that Pro sites now show interleaved feed.
* **New extension point** — `sprigly_admin_can_mark_complete` filter gates whether the practitioner-side "Mark Complete" button renders for a given milestone, AND is consulted again server-side inside the `mark_complete` handler so a hand-crafted POST cannot bypass the hidden button. Default returns true (Lite shows the button on every uncompleted milestone, behaviour unchanged for Lite-only sites). Pro v2.2.16+ hooks it to permit Mark Complete only on checkpoint milestones that have a pending sign-off request — closing a long-standing skip-the-rules hole where a practitioner could mark anything complete and bypass sequential mode, Require Reflection, required check-in fields, and the sign-off flow itself.

## 1.2.12

* Cleared the last 24 findings from a fresh Plugin Check run. Three errors fixed: translator comments on `Login page: %s`, `%1$s Detected: %2$s`, and `Your client portal is at %s` were on the line above `printf(` instead of directly above the `esc_html__()` call, so Plugin Check couldn't see them — moved the comments inside the printf to sit directly above the gettext call. Warnings cleared: every multi-line `$wpdb->get_results()` / `$wpdb->get_col()` query against Sprigly's own custom tables now uses `phpcs:disable` / `phpcs:enable` brackets instead of `phpcs:ignore` (which only covers a single line and missed the actual interpolated table-name lines two or three lines down). The two `__('Visit plugin site', 'default')` and `__('View details', 'default')` calls in `add_row_meta()` now carry an explicit TextDomainMismatch ignore (we genuinely want core's translation here, not Sprigly's). The four `$_SERVER['REQUEST_URI']` reads in the portal now guard with `isset()` before `wp_unslash()`. The `slow_db_query_meta_query` on the Media Library admin filter wrapped in `phpcs:disable`. The single remaining NonceVerification.Recommended on the journey edit page wrapped in `phpcs:disable`. No behavioural changes.

## 1.2.11

* Add Client form's "Create new client" / "Assign existing user" radio toggle now actually swaps the visible fieldset on the Add Client page. The toggle JavaScript was attached inside `render_view()` (the View Client page) instead of `render_add_form()` after the v1.30.27 enqueue refactor, so on the Add Client page the radios changed visually but the underlying fieldset never swapped — selecting "Assign existing user" still showed the new-client First Name / Last Name / Email fields. Moved the toggle IIFE to its own `wp_add_inline_script()` call inside `render_add_form()` and added an initial `apply()` call so the page lands in the correct state on first paint.

## 1.2.10

* WordPress.org Plugin Check pass. Cleared every ERROR-level finding from the official Plugin Check tool plus the real (non-false-positive) WARNINGs. Mechanical fixes: 32 missing translator comments added, 8 `parse_url()` calls swapped to `wp_parse_url()`, 3 unordered placeholders converted to `%1$s` / `%2$s` form, 2 `__()` calls in `add_row_meta()` given the explicit `'default'` text-domain so they correctly pull WordPress's own localised strings, 3 `$oembed` echoes annotated with the proper `phpcs:ignore` reason. Hardening fixes: `$_GET` / `$_POST` reads now consistently `wp_unslash()` before sanitisation; `wp_redirect()` calls in the portal swapped to `wp_safe_redirect()`; the `$wpdb->update()` calls that force `display_name` after `wp_update_user()` are now annotated; `error_log()` calls now gate on `WP_DEBUG`. Architecture fix: the standalone `portal-notice.css` file inlined into the file-not-found page (which exits before `wp_head()` runs and so couldn't enqueue normally) and the file removed. False-positive annotations: every legitimate query against Sprigly's own custom tables now carries a documented `phpcs:ignore` with reason — `class-sp-database.php` carries a single file-level `phpcs:disable` because every query in it goes through the same trusted `self::table()` helper. The `uninstall.php` script's body wrapped in a `sprigly_uninstall_plugin()` function so its locals stop polluting the global scope, and the dispatcher hook renamed from `before_sprigly_init` to `sprigly_before_init` for prefix conformance. No behavioural changes.

## 1.2.9

* Add Client → "Assign existing user" dropdown now lists every `sprigly_client` user on the site. Previously the list was filtered down to clients whose enrolment row had `practitioner_id` matching the current owner-scope ID — a leftover Pro-style scoping rule that doesn't make sense in single-seat Lite. On installs where existing client data was created under a different user (demo content, prior monolithic v1.30.x where another admin ran the create flow, post-downgrade Team data), the dropdown came back empty even though valid candidates existed. Replaced the filter loop with a straight list of `sprigly_client` users + a new `sprigly_existing_users_for_assign` filter so Sprigly Pro can re-attach per-practitioner scoping on Team tier without re-introducing the bug in Lite.

## 1.2.8

* Stripped the "Final milestone" badge from Lite's portal milestone tracker. Final and Checkpoint milestone types are both Pro per the rebalance — Lite ships only Standard milestones. Demo content carried over from monolithic v1.30.x has rows with `milestone_type='final'` set, which were rendering the purple "Final milestone" pill in two sites in `class-sp-portal.php`. Both sites removed; the milestone_type DB column stays untouched so Pro can re-attach the badge via its own render hook when its checkpoint module covers the final marker too.

## 1.2.7

* Lite no longer renders milestone "locked" states. Milestone unlock gating (sequential mode, scheduled date / relative-day unlocks, checkpoint-pending sign-off) is fully Pro. Lite's portal `compute_milestone_unlock()` collapses to "every milestone is unlocked immediately" and the milestone-tracker / detail view no longer references `journey->sequential_mode`. Pro still layers its rules on top via the `sprigly_milestone_unlock_status` filter applied inside the wrapper. The DB schema columns (`milestone_type`, `unlock_type`, `unlock_date`, `unlock_days`, `journeys.sequential_mode`) and the `Sprigly_Database::insert_journey()` / `update_journey()` accept-list stay so Pro can keep writing them without a schema bump. The fix means demo content carried over from monolithic v1.30.x — which has `unlock_type='date'`, `milestone_type='checkpoint'`, or `sequential_mode=1` set on rows — no longer leaves milestones visually locked or showing a "Waiting for wellness coach sign-off" / "Complete previous milestones first" caption on a Lite-only install.

## 1.2.6

* Practitioner sign-offs and the Checkpoint milestone type are now fully out of Sprigly Lite. Sign-off is a Pro feature; Lite shouldn't have been exposing any UI for it. Stripped the portal `request_signoff` POST handler, the "Request Sign-off" button + sign-off-requested notice in the milestone view, the checkpoint badge on the journey progress strip, the checkpoint branch in `handle_complete_milestone()`, the "milestone requires your practitioner to mark it complete" notice, the admin client-list sign-off bell + pending count, and the per-milestone "Sign Off" button on the enrolment view (the Mark Complete button now renders unconditionally for any non-completed milestone). The `wp_sp_progress.signoff_requested_at` column and the `Sprigly_Database::set_signoff_requested()` / `count_pending_signoffs()` methods stay in the schema as additive infrastructure so Sprigly Pro can re-attach the feature via the existing `sprigly_portal_milestone_view_after` and `sprigly_admin_enrolment_milestone_card_extras` hooks without a schema bump.

## 1.2.5

* Hotfix the enrolment view (Sprigly, Enrolments, View). The cap-strip done in v1.2.0 missed an `$at_limit` reference at six sites in `class-sp-admin-clients.php` (the "Add Client" button caption and the new-client / existing-client radio gating on the Add Client form). The result was a PHP `Undefined variable` warning and a critical-error notice rendered inside the admin client profile when WP_DEBUG was on. Removed the orphan reference and collapsed the now-always-false ternaries into their unconditional branches so the form always defaults to "Create new client" with the new-client fields visible.
* Defensive `function_exists( 'wp_make_clickable' )` guard in `Sprigly_Portal::render_reflection_content()`. The function is part of WordPress core (`wp-includes/formatting.php`) and is loaded long before any plugin code, but a hardened install with security plugins that strip core formatters could leave it absent and trigger a fatal when an admin viewed a client's reflection feed. The guard falls back to the unmodified `wp_kses_post`-sanitised HTML if the function is unavailable.

## 1.2.4

* Hotfix for the Settings, Branding tab. The Login Logo and Portal Nav Logo "Choose Logo" buttons did nothing because the v1.2.0 prefix migration renamed the form input IDs from `sp_brand_logo` / `sp_nav_logo` to `sprigly_brand_logo` / `sprigly_nav_logo` but the matching `assets/js/admin-branding.js` still looked up the old IDs via `document.getElementById()`. Updated the JS so the colour-picker sync, the Media Library upload buttons, and the remove buttons all wire to the renamed inputs again. Audited every other JS file (`portal-editor.js`, `portal-lightbox.js`, `portal-reflection.js`, the Pro `admin-milestone-fields.js` and `admin-resource-form.js`) plus every inline JS string emitted via `wp_add_inline_script` to confirm no other identifier mismatches.

## 1.2.3

* Final compliance pass against the WordPress.org reviewer email's full checklist. Lite now contains zero raw `<script>`, `<style>` or `<link rel=stylesheet>` HTML tags (every CSS / JS payload routes through `wp_enqueue_style`, `wp_enqueue_script`, `wp_add_inline_style`, or `wp_add_inline_script`); zero bare `wp_verify_nonce` calls (every nonce read is wrapped in `sanitize_text_field( wp_unslash( … ) )`); zero `load_plugin_textdomain` calls; zero `register_rest_route` calls (REST endpoints are all in Sprigly Pro). The two remaining admin-post form action values (`sp_send_test_email`, `sp_send_bug_report`) have been renamed to their `sprigly_*` equivalents to match the registered hook names.
* The External Services section of this readme now fully documents the two narrow situations where Sprigly contacts a third party — YouTube/Vimeo via `wp_oembed_get()` when a practitioner pastes a video URL into a milestone, and the Sprigly support address via the optional Send Bug Report form. Each entry includes the data sent, the trigger, and Terms + Privacy links per Guideline 6.
* Bundled Poppins font files now carry an `assets/fonts/LICENSE.txt` declaring SIL Open Font License v1.1 (GPL-compatible). The fonts ship inside the plugin so the portal does not contact Google Fonts.

## 1.2.2

* Audit follow-up to the v1.2.1 reflection-video move. The admin client-profile reflection feed had an orphan `wp_oembed_get( $ref->video_url )` block left over from before the dedicated video field moved to Pro. Replaced with a `do_action( 'sprigly_reflection_display_after_content', $ref )` so Pro's video embed renders consistently in admin and portal views.

## 1.2.1

* The Emails tab now lives entirely in Sprigly Pro. With Lite-only installs there is no Emails tab — the welcome email is documented in this readme as always-on. Pro registers the tab back via the `sprigly_settings_tabs` filter when it is installed, and renders it via the `sprigly_settings_tab_render_emails` action.
* The dedicated "Attach a video" feature on reflections has fully moved into Sprigly Pro — the `reflection_video_url` form input, the inline edit-form input, the iframe embed render, the save-handler validation, and the Settings, Portal toggle. Lite no longer collects or renders that field. Pro re-attaches the whole feature via three new extension hooks Lite now exposes: `sprigly_portal_reflection_form_after`, `sprigly_portal_reflection_edit_form_after_editor`, `sprigly_reflection_display_after_content`, and a `sprigly_reflection_save_data` filter.
* Settings → Upgrade to Pro tab switched from a 2-column grid to a single-column list. Variable description lengths were leaving uneven row heights and trailing empty cells in odd-count sections; the single-column layout reads cleaner.
* Security hardening: the v1.2.0 prefix migration in Lite (and the matching v2.2.0 migration in Pro) is now wrapped in a database transaction. A mid-pageload failure rolls back to the legacy `sp_*` keys instead of leaving the site in a half-migrated mixed-prefix state.
* Security hardening: Pro's resource-attachment tagging now refuses to overwrite an existing `_sprigly_practitioner_id` set to a different practitioner. Defence in depth on Team-tier sites against a tampered POST that includes a sibling practitioner's attachment ID.

## 1.2.0

* Sprigly is now uncapped. Journeys, clients, and milestones are no longer limited in code; the practitioner toolkit features that previously sat alongside the caps have moved into the separately-sold Sprigly Pro plugin.
* Five features moved into Sprigly Pro: the Resources library, practitioner notes, the branded login page, six per-event email toggles (only the always-on welcome email stays in Lite), and video URL embeds inside reflection content. Existing data carries over automatically when Pro is installed.
* Self-hosted Poppins typography. Sprigly no longer fetches its display font from Google Fonts; the woff2 files (weights 300, 400, 500, 600, 700) ship inside the plugin and are served through the WordPress enqueue system. No external font request is made.
* Prefix migration: every class, constant, option, user_meta, post_meta, and admin-post action now uses the canonical `Sprigly_*` / `sprigly_*` / `SPRIGLY_*` prefix. The legacy `SP_*` / `sp_*` names are aliased so any third-party customisation that referenced them keeps working. The old admin-post hooks remain registered alongside the new ones for one cycle so cached forms still post successfully.
* The "Upgrade to Pro" tab now lists every Pro feature grouped by area (authoring, client experience, reports, communications, Team, branding) so practitioners can see exactly what the upgrade adds.

## 1.1.31

* Plugin header `Author URI` repointed to `https://sprigly.co/about-sprigly/`. The previous value `https://sprigly.co/getting-started/` was a real page but the WordPress.org reviewer's email had flagged the original `/about-us/` value (which never existed); `/about-sprigly/` is the canonical About destination.

## 1.1.30

* Polish on the Preview Journey feature shipped in 1.1.29. The milestone-preview render now matches the v1.30.x monolithic version: it shows the full client-facing structure including the disabled My Reflection editor (toolbar, placeholder text, "Attach a video", Save Response) and the disabled Mark as Complete button (or the checkpoint sign-off notice when the milestone type is checkpoint), so practitioners see the entire page chrome a real client sees on day one — not just the static content. New `sprigly_journey_edit_after_title` action fires immediately after the journey edit screen's H1 so the Preview Journey button can render at the top of the page (where the v1.30.x version had it) instead of inside the form.

## 1.1.29

* Restored the Preview Journey feature, ported from the monolithic v1.30.x build. Generation of the signed token + the Preview Journey button live in Sprigly Pro (admin side); the portal rendering of preview mode lives here in Lite (`SP_Portal::generate_preview_token()`, `verify_preview_token()`, plus the `render_preview_journey()` and `render_preview_milestone()` templates). When a valid `?sp_preview=TOKEN` URL is requested, the portal recognises the practitioner who generated it, swaps in a preview banner, disables every form action, and renders the journey + milestone screens with no client data, no progress, no reflections — exactly what an enrolled client would see on day one. Tokens are HMAC-signed with `wp_salt('auth')`, expire after 1 hour, and only validate when the viewer is logged in as the journey owner. The `sprigly_portal_preview_milestone_after` action lets Pro append disabled custom-field placeholders into the milestone preview.
* Added Preview Journey back to the Settings → Upgrade to Pro feature list and to the readme upgrade summary now that it actually ships in Pro again.

## 1.1.28

* Tightened the Pro feature list shown in Settings → Upgrade to Pro and in this readme so it only lists features that actually ship in Sprigly Pro right now. Removed three entries that didn't match the current Pro plugin: "Per-client welcome message" (was never built), "Navigation logo & sizing" (this is in the free Sprigly plugin, not Pro), and "Preview portal token" (deferred from the Lite/Pro split — to be reintroduced in a future Pro release). The list is otherwise unchanged.

## 1.1.27

* Settings → Upgrade to Pro tab now lists every Pro feature, grouped by area (authoring, client management, reports, communications, team, branding, removed limits). The previous list of 10 was a sample, not a complete map of what Pro adds. The "Available as a paid upgrade" section in the readme has been expanded to match.

## 1.1.26

* Wrap one missed style-attribute ternary in `esc_attr()` on the Resources add/edit form (the video-URL hint added in 1.1.23). Caught during the final pre-resubmission sweep.

## 1.1.25

* Recover-from-deleted-owner hardening. When a WordPress administrator who is the designated Sprigly Owner is deleted, the cascade-cleanup added in v1.1.24 now also clears the stored owner pointer and runs the owner-bootstrap routine to promote the next available administrator (lowest-ID admin by default; filterable via `sprigly_bootstrap_owner_id`). Previously the option kept pointing at the deleted user and the site landed in a stuck state with Sprigly visible but unmanageable. Defence-in-depth: `SP_Roles::bootstrap_owner()` now also treats a stored owner ID that no longer resolves to an existing user as "unowned" and re-runs the picker, and the routine fires on every admin pageload so existing sites already in the stuck state recover automatically without code intervention. The bootstrap is idempotent and cheap (two option reads on the no-op path).

## 1.1.24

* Privacy hardening of the practitioner data lifecycle. The "Reassign orphaned records to me" admin notice and its one-click handler have been removed entirely. That tool let any new site owner sweep up the previous owner's clients, reflections, and uploaded files with a single click, which is a privacy footgun — clients shared with the previous practitioner, not whoever happens to take over the site. From this build forward there is no reassign path. Sprigly data is tied to the WordPress user account that created it: the data is hidden from any other admin, and if the WordPress user account is later deleted Sprigly cascade-deletes everything they owned (journeys, milestones, clients, reflections, notes, resources, and tagged file attachments). The user-deletion handler in Lite has been beefed up accordingly to cover practitioner cleanup, not just the legacy client-side cleanup.

## 1.1.23

* Resource form: the Video (URL) type now validates that the pasted URL is one WordPress can actually embed (YouTube or Vimeo by default) and rejects anything else with a clear error notice. Previously a practitioner could paste any URL — for example a plain website — and the row would save fine but render as a broken video block on the client portal. The check is regex-only against WordPress's registered oEmbed providers, no network call. A hint line under the URL field appears whenever Type is set to Video so the constraint is visible before submit.

## 1.1.22

* Settings → Emails tab no longer renders toggles for events that don't exist in this plugin. Lite ships three notification surfaces — the milestone-completed practitioner email, the journey-assigned client email, and the always-on welcome email when a new client account is created — and that's exactly what the tab now shows. The five toggles for events Lite has no handler for (Reflection Submitted, Journey Completed, Sign-off Requested, Practitioner Note, Checkpoint Signed Off) are gone — Sprigly Pro injects them via the new `sprigly_emails_tab_practitioner_rows` and `sprigly_emails_tab_client_rows` action points when it's installed. The five corresponding option keys also moved from Lite's OPTIONS array to Pro's own register.

## 1.1.21

* WP.org compliance pass: stripped every remaining trace of licensing logic, Pro-tier feature gating, and Pro-only email events from Lite. The `class_exists( 'SP_License' ) && SP_License::is_pro()` conditional in Settings → Portal is gone (the file-attachments toggle is now rendered + saved by the Pro plugin via the new `sprigly_after_portal_settings_rows` and `sprigly_after_save_settings` extension points). Lite's uninstall handler no longer talks to the licensing server, no longer clears the `sp_daily_license_check` cron, no longer deletes any `sp_license_*` options — Pro's own uninstall handler now owns those tasks. Three Pro-only emails (`notify_owner_transfer_request`, `notify_practitioner_invited`, `notify_pro_features_hidden`) moved from Lite's email class into Pro's. The merged "reflections + practitioner notes" feed in admin client profiles renders Pro notes via the new `sprigly_admin_note_entry` action — Lite no longer carries Pro's note-card markup or nonce. License-flavoured comments and method names (`define_license_constants` → `define_limit_constants`, "License" mentions across settings/menus/install/roles) cleaned up.
* Hardening: every `wp_verify_nonce` call now wraps `$_POST` access in `sanitize_text_field( wp_unslash( … ) )`. All `$_GET['sp_notice'] === '…'` style direct comparisons routed through `sanitize_key( wp_unslash( … ) )`. All `echo`-of-ternary outputs into HTML attributes now wrap in `esc_attr()`. Inline `onchange="…"` attributes on the Add Client radio toggle replaced with a `data-sp-toggle-pair` data attribute + delegated event listener.
* No user-visible behaviour change. Pro continues to deliver every previously available Pro feature; Lite continues to deliver everything within its caps.

## 1.1.0

* Stable release marking the completion of the Lite/Pro split. Lite is feature-complete at its caps (2 active journeys, 4 active clients, 5 milestones per journey, 3 resources per journey). Sprigly Pro 2.1.0 is the matching paid add-on available from sprigly.co — it requires this Lite plugin to be active, then unlocks unlimited caps plus checkpoints, scheduled unlocks, custom check-in fields, file uploads on reflections, branded PDF reports, JSON export/import, multi-practitioner Team support, and granular per-event email toggles.

## 1.0.9

* Compatibility-only release — no user-facing changes. Lite continues to expose the extension points Sprigly Pro 2.0.6 uses for the Team admin page, branding extras, and per-event email toggles. No Lite source change in this build aside from version stamp.

## 1.0.8

* Cleanup: Removed `assets/css/report.css` and `assets/css/report-error.css` from Lite — both belong to the Progress Report PDF feature which lives entirely in Sprigly Pro. Lite-only installs no longer ship the unused stylesheets.

## 1.0.7

* Architecture: New extension point `sprigly_after_client_profile` fires at the bottom of the client profile page so the Pro plugin can add per-client surfaces (practitioner notes, file history, etc.) without touching Lite's profile renderer.
* Architecture: The reflection forms (add + edit) now declare `enctype="multipart/form-data"` so Pro can attach a file-upload input via `sprigly_portal_reflection_form_after`. No visible change to Lite-only installs (the file input is rendered only by Pro).

## 1.0.6

* Architecture: New extension points added for the Pro plugin — `sprigly_journeys_list_above` (admin button slot on Journeys page) and `sprigly_can_complete_milestone` (final pre-completion gate so Pro can enforce Require Reflection, required check-in fields, etc.). No visible change to Lite-only installs.

## 1.0.5

* Architecture: Settings tabs registered through the `sprigly_settings_tabs` filter can now opt out of Lite's outer settings `<form>` wrapper by setting `'no_form' => true` in their tab definition. Required for Pro-owned tabs like License whose body renders multiple `admin-post.php` sub-forms — nested forms are invalid HTML, so those tabs need to render standalone. Lite's built-in tabs are unchanged.
* Cleanup: Removed `assets/css/license-suspended.css` — the asset belongs to the licensing flow which lives entirely in the separate Sprigly Pro plugin. Lite no longer ships this file.

## 1.0.4

* Architecture: Every limit check now routes through the new `SP_Limits` helper which applies `sprigly_limit_*` filters at every site (form handlers, display strings, error notices). Previously the form handlers used the `SP_LIMIT_*` constants directly, which made it impossible for an add-on plugin to lift the caps. Sprigly Pro can now uncap journeys, clients, milestones-per-journey, and resources-per-journey by returning 0 from the filters.

## 1.0.3

* Fix: Image-type resources in the journey-view "Resources" section (the main client portal area) now open in the same in-page lightbox used on the milestone detail page, instead of opening in a new browser tab. Non-image resources (PDFs, links, videos) still open in a new tab as expected.

## 1.0.2

* Privacy: Resource attachments now relocate into the protected `/uploads/sprigly/` directory on save (alongside reflection attachments). The directory's `.htaccess` Deny rule means direct URL guesses against `/uploads/YYYY/MM/file.pdf` stop returning the file — only the authenticated `?sp_file=ID` proxy serves these files. A one-time backfill moves any existing v1.0.0 / v1.0.1 resource attachments into the protected directory on upgrade.
* Privacy: The resource's stored URL is now captured AFTER the attachment is tagged, so the database row stores the privacy-proxy URL rather than the raw `/uploads/` path.

## 1.0.1

* Privacy: Resource attachments are now tagged with practitioner ownership and excluded from the WordPress Media Library for non-owning admins. Resource file URLs are routed through the same `/?sp_file=ID` proxy that protects reflection attachments, with a one-time backfill for any resources created under v1.0.0. Closes a leak where web-designer admins on the same site could see practitioners' resource files in the media library.
* UX: New "Emails" tab home — Send Test Email card and SMTP recommendations relocated from the Tools tab. Tools now contains Quick Links, System Information, and Bug Report only.
* UX: Add Client form — when at the Lite client cap, the "Create new client" radio is disabled and "Assign existing user" auto-selects so practitioners can keep enrolling existing clients into additional journeys without hitting a server-side error.
* Branding: Login page button hover and portal hover states now derive a darker shade from the practitioner's brand colour instead of hard-coding Sprigly green. Custom brand colours now feel coherent on every surface.
* Polish: At-limit upsell cards (clients, journeys, milestones-per-journey) restyled with a cream background and Sprigly-green heading + button so they read as Sprigly speaking, regardless of brand colour.

## 1.0.0

* Initial release of Sprigly on WordPress.org. Standalone client journey portal for coaches, trainers, therapists, and nutritionists. Includes 2 active journeys, 4 active clients, 5 milestones per journey, 3 resources per journey, branded client portal at `/portal/`, branded login page, custom logos and brand colour, rich-text milestone descriptions, client reflections with YouTube/Vimeo embedding, image lightbox, three core email notifications (client welcome with portal credentials, client journey-assigned, practitioner milestone-completed), file privacy hardening for reflection attachments, mobile-responsive admin and portal, SMTP plugin detection, test email tool, and a bug report form. Sprigly Pro is available as a separate plugin from sprigly.co and adds checkpoint sign-offs, scheduled unlocks, custom check-in fields, file uploads, PDF reports, JSON export/import, multi-practitioner support, and granular per-event email toggles.

---

# Pre-split monolithic Sprigly (v1.30.x)

## 1.30.28 — 2026-04-27

### Fixed
- **PDF Progress Report — JSON 401 regression from v1.30.26.** The v1.30.26 hardening replaced the REST endpoint's `permission_callback => '__return_true'` with `'is_user_logged_in'` to satisfy the WP.org reviewer's note. That choice broke the endpoint for the actual happy-path request, because the report link is opened via `window.open()` from inline admin JS and carries only the WP auth cookie, no `X-WP-Nonce`. WordPress's REST cookie middleware (`rest_cookie_check_errors`) rejects cookie-only requests without a nonce — so by the time `is_user_logged_in()` runs in the permission callback, `wp_get_current_user()` has already been forced back to user 0, the gate returns false, and the response is `rest_forbidden` / 401. The HMAC-token + cookie-ownership check inside `render_report()` was never reached.
  - Fix: the permission_callback now calls `wp_validate_auth_cookie( '', 'logged_in' )` directly. That's a low-level cookie signature check — it doesn't go through the REST nonce middleware, so cookie-only requests pass through to the callback. The callback then performs the full token + ownership + Pro-licence checks exactly as before, including the friendly HTML error rendering on expired/invalid token. WP.org's requirement is satisfied (gate is no longer `__return_true`, it's a real auth check) and the happy path works again.
  - File: [includes/class-sp-report.php](../../sprigly-release/sprigly/includes/class-sp-report.php)
  - Surfaced during v1.30.26+27 testing on sprigly.au — practitioner clicked Download Progress Report → JSON 401 in new tab.

## 1.30.27 — 2026-04-27

### Changed (WordPress.org Plugin Directory compliance — Phase 1b: enqueue refactor)
- **All 30 inline `<script>` / `<style>` blocks routed through the WP enqueue system.** No more raw `<style>` or `<script>` tags emitted inside templates. Refactor patterns:
  - **Standalone HTML pages** (PDF progress report, lapsed-licence suspension page, portal notice page, report-error page) — extracted CSS to external files in `assets/css/` and referenced via `<link rel="stylesheet">`. These pages exit before `wp_head()` / `wp_footer()` so enqueue isn't available; an external link tag is the next-best WP.org-acceptable pattern.
  - **Reusable JS modules** — extracted to dedicated files in `assets/js/` and enqueued conditionally:
    - `portal-editor.js` — `spNormalizeHtml()` contenteditable normaliser
    - `portal-reflection.js` — reflection editor expand/collapse, edit-toggle, ESC handler, rating-group selection
    - `portal-lightbox.js` — image lightbox open/close + ESC handler
    - `admin-resource-form.js` — resource add/edit form (URL-vs-file toggle, milestone dropdown, Media Library picker)
    - `admin-milestone-fields.js` — custom-fields builder (Pro)
    - `admin-branding.js` — Settings Branding tab (colour picker sync, login/nav logo upload)
  - **Per-page snippets** — small UI scripts (modal toggles, validation, conditional show/hide) attached via `wp_add_inline_script()` to one of: `sprigly-admin` (admin pages), `sprigly-portal` (portal pages), `jquery-core` (jQuery-dependent admin), or `jquery-ui-sortable` (sortable handlers). Two new placeholder script handles (`sprigly-admin`, `sprigly-portal`) registered with no `src` so they exist purely to anchor inline content into the WP enqueue pipeline.
- **Google Fonts (Poppins) for client portal now enqueued.** Previously `<link href="https://fonts.googleapis.com/...">` was hard-printed in the portal `<head>`; now it's `wp_enqueue_style( 'sprigly-portal-font', '...' )` so it goes through `wp_head()` like everything else.
- **Translations** for inline strings now passed via `wp_localize_script()` instead of `<?php echo esc_js( __( ... ) ); ?>` interpolation in the script body. Same translation files, same translated text — just routed through the localisation pipeline so translation tools can find the strings without parsing JS.

No user-visible behaviour change. Same scripts run, same styles apply, same UX. Asset count on each page is the same or slightly higher (a few of the larger inline scripts are now external `.js` files, but they're cached after first load).

## 1.30.26 — 2026-04-27

### Changed (WordPress.org Plugin Directory compliance — security & code quality)
- **Nonce sanitisation** — Wrapped all 38 `wp_verify_nonce()` call sites with `sanitize_text_field( wp_unslash( ... ) )`. `wp_verify_nonce()` is a pluggable function and the WP coding standards expect callers to sanitise the input regardless. No behaviour change; satisfies the Plugin Check tool's audit.
- **Superglobal `wp_unslash()` defence** — Added the missing `wp_unslash()` call to the eight remaining `sanitize_*()` reads of `$_POST`/`$_GET` superglobals (settings tab, settings owner-redirect, test-email recipient, action router, client-directory action, error-code reader, etc.). Matches the broader audit pass already done in v1.30.22.
- **REST permission gates** — `/license-sync` webhook now performs its `hash_equals` license-key check inside `permission_callback` instead of `__return_true`. `/progress-report/{client_id}` baseline-gates on `is_user_logged_in` in `permission_callback` (the full HMAC-token + ownership validation stays in `render_report` so practitioners hitting an expired link still see a friendly HTML error rather than a raw JSON 403).
- **Output escaping** — Added explicit `esc_html()` / `esc_attr()` wrapping around the four `intval()` echoes in the progress report and the `count()` echo in the system-info textarea row count. Plugin Check no longer flags these as unescaped int outputs.
- **Plugin header — Author URI 404** — `Author URI` was `https://sprigly.co/about-us/` which returns a 404 (the about-us page never shipped). Repointed to `https://sprigly.co/getting-started/` which is a real, public page already linked from the in-plugin Settings → Help & Documentation panel. Plugin Check requires both Plugin URI and Author URI to resolve.
- **Removed `load_plugin_textdomain()`** — WordPress 4.6+ auto-loads translations for plugins hosted on the WordPress.org repository, so the manual `load_plugin_textdomain( 'sprigly', ... )` call was redundant and now removed (plus its surrounding wrapper method). Sprigly's `Requires at least: 6.0` makes this safe with no fallback needed.

## 1.30.25 — 2026-04-27

### Fixed
- **Add Client mobile — First/Last Name vertical gap.** v1.30.24 stacked the inputs on mobile but inherited the original 12px grid `gap`, which felt too tight when the gap became vertical. Bumped to `row-gap: 16px` to match the form's existing 16px vertical rhythm.

## 1.30.24 — 2026-04-27

### Fixed (post-v1.30.23 testing punch list)
- **Mobile milestone cards — empty band at top.** The drag-handle cell (`<td class="sp-drag-handle">`) was rendering as an empty flex row at the top of every milestone card on mobile. The `⋮⋮` icon inside is `opacity:0` until hover, and there's no hover on touch — so the cell took up vertical space with nothing visible inside it. Fixed with `table.sp-table-milestones td.sp-drag-handle { display: none !important; }` inside the 850px media block. Drag-and-drop reorder doesn't work reliably on touch anyway; the keyboard reorder buttons in the Actions column remain available.
- **Keyboard reorder buttons — visually noisy on desktop.** v1.30.23's visible ↑/↓ buttons next to the milestone drag handle stood out more than intended (the audit's keyboard-alternative goal was achieved, but the visual cost was higher than expected). Switched to a `.sp-reorder-sr-only` class that hides them visually (1×1 absolutely-positioned, clip-rect off-screen — same shape as WP core's `.screen-reader-text`) but keeps them Tab-focusable. On focus they expand back into view with an outline so a keyboard-only user can see which control is active. Mouse users now see a clean drag-and-drop UX with no extra visual elements; keyboard-only users still have a path to reorder.
- **Add Client form — First/Last Name squeezed on mobile.** The two-column `grid-template-columns: 1fr 1fr` stayed 2-up at all viewport widths, making the inputs visually crowd each other on narrow screens. Added a `.sp-name-row` class with a mobile media query that switches to single-column stacking below 850px. Desktop unchanged.

## 1.30.23 — 2026-04-27

### Changed (audit DIM-3 + DIM-4 — UX consistency & accessibility batch)
- **DIM-3.1** — Collapsed two overlapping mobile media blocks. The old structure had a 850px block declaring the table-card layout AND a 782px block re-declaring most of it (the duplication existed because WP core's `table.wp-list-table td` selectors at 782px out-specificity'd our `.sp-table-X td` rules). Bumped the 850px selectors to `table.sp-table-X td` (equal specificity to WP core, source-order wins), then deleted the duplicate rule set from the 782px block. The 782px block now contains only WP-admin-frame fixes (sidebar overflow, content padding) tied to WP's actual 782px sidebar-collapse breakpoint. Two clearly-commented blocks instead of two silently-overlapping ones.
- **DIM-3.2** — Standardised the catch-all admin error notice from "An error occurred." to "Sorry, something went wrong. Please reload the page and try again." across five admin pages (clients, resources, team, journeys, client-directory). Specific error messages unchanged.
- **DIM-3.3** — Added `.sp-btn-danger` button class. Refactored all inline `style="color:#a00"` and `style="color:#b32d2e"` destructive-button declarations across team / journeys / clients / client-directory / resources to use the class instead. Single visual signal for destructive actions site-wide; future destructive buttons just add `sp-btn-danger`.
- **DIM-3.4** — Added `.sprigly-empty-state` CSS rule and upgraded the Team page empty state from a bare `<p><em>No practitioners…</em></p>` to the standard centred-card pattern matching journeys / clients / resources / client-directory.

### Added (audit DIM-4 — accessibility)
- **DIM-4.1** — `aria-label` added to the milestone custom-fields builder repeating inputs (`field_label[]`, `field_type[]`, `field_options[]`) and the bulk-add `bulk_prefix` field. The visible `<label>` elements next to these inputs were not `for=`-associated (the inputs are array-style with no unique IDs), so screen readers previously announced them as unlabeled.
- **DIM-4.4** — `role="progressbar"` plus `aria-valuenow` / `aria-valuemin` / `aria-valuemax` plus a descriptive `aria-label` (with state phrase: "complete" / "in progress" / "just started") added to every admin and portal progress bar. Screen-reader users now hear the state explicitly rather than depending on colour to differentiate complete vs in-progress.
- **DIM-4.5** — Keyboard-accessible up/down reorder buttons added next to the milestone drag handle on the journey edit page. Mouse-drag stays the primary UX; the visible ↑ / ↓ buttons hit the same existing `handle_reorder_milestone()` GET handler. First-row gets only a ↓; last-row only a ↑. WCAG 2.1 SC 2.1.1 compliance for a core authoring task.
- **DIM-4.6** — Explicit `outline: 2px solid var(--sp-primary)` focus ring on the portal's custom-styled check-in `<select>` (`appearance: none` had suppressed the browser default).

## 1.30.22 — 2026-04-27

### Changed (audit DIM cleanup — zero-testing bundle, no user-visible behaviour change)
- **DIM-1.5 — Magic-string constants.** New `SP_Database::TYPE_STANDARD/CHECKPOINT/FINAL`, `STATUS_ACTIVE/ARCHIVED`, `CLIENT_STATUS_ACTIVE/PAUSED/COMPLETED` plus `SP_Roles::ROLE_PRACTITIONER/CLIENT`. Existing string-literal call sites are unchanged (no behaviour change); future code can use the constants and avoid the typo trap the audit flagged.
- **DIM-1.6 — Return-value check on `wp_update_user()`.** `SP_Admin_Clients::handle_update_client_details()` now redirects with `?sp_error=update_failed` if WP returns a `WP_Error`, instead of silently continuing. New `update_failed` entry in the admin-notice error map.
- **DIM-5.3 — `_n()` for free-tier limit messages.** Singular/plural forms split for `limit_journeys`, `limit_milestones`, `limit_clients`, `limit_resources` (`class-sp-admin-journeys.php`, `class-sp-admin-clients.php`, `class-sp-admin-resources.php`) and the `WARNING: %d active practitioner(s)` Team-deactivate modal (`class-sp-license.php`). Non-English locales now select the correct grammatical form per CLDR plural rules.
- **DIM-8.1 — `wp_unslash()` hardening.** Defensive wrapping added to ~32 remaining `sanitize_*()` calls on `$_POST`/`$_GET` reads across `class-sp-license.php`, `class-sp-portal.php`, `class-sp-admin-data-tools.php`, `class-sp-admin-journeys.php`, `class-sp-admin-team.php`, `class-sp-admin-resources.php`, `class-sp-admin-clients.php`. No behaviour change on modern hosts (where magic_quotes was killed in PHP 5.4); brings every input read into agreement with WPCS.
- **DIM-8.4 — Explicit escape on int outputs.** Cast-only outputs (`echo (int) $s['journeys']` style) in `class-sp-admin-data-tools.php` reorder summary now wrap in `esc_html()`; ternary colour/emoji outputs in `class-sp-admin-menus.php` setup checklist wrap in `esc_attr()` / `esc_html()` as appropriate. Identical visual result; consistent with the codebase's late-escape convention.
- **DIM-9.2 — Custom-fields save handler defence-in-depth.** Milestone custom-field save block in `class-sp-admin-journeys.php` now re-checks `SP_License::is_pro()` at request time (in addition to the existing `SP_CUSTOM_FIELDS` constant gate stamped at plugin load). Closes the theoretical mid-request licence-flip window where a `revalidate()` lands during the same `admin_init` cycle as a milestone save.
- **DIM-9.3 — Centralised gate-copy helpers.** New `SP_License::gate_msg_pro()`, `gate_msg_team()`, `upgrade_url_pro()`, `upgrade_url_team()` as a single source of truth for upgrade-prompt copy. Existing 20+ inline strings across the codebase intentionally unchanged in this release (refactoring them all is design-consult territory); future gates can use the helpers.

## 1.30.21 — 2026-04-26

### Changed
- **Archive behaviour overhaul.** Archiving a journey now actually takes it out of the client portal. Previously, archive was an admin-side filter only — enrolled clients kept seeing the journey identically to an active one. With this release, in-progress clients on an archived journey see a polite full-page notice (*"This journey has been archived. Please contact your {role_label} for more information."*) and the journey content is hidden until the practitioner re-activates it. Clients who have already completed the journey keep read-only access to their history (reflections, check-in responses, practitioner notes, milestone progress all remain visible). Nothing is deleted — re-activating instantly restores everyone's access exactly as it was. Affects `render_journey()`, `render_milestone()`, `render_reflections()` in `class-sp-portal.php`.
- **Removed the legacy `draft` journey status.** It only ever served as an admin-side label — clients on a draft journey saw it identically to an active one, which made it a UX trap once Archive started actually locking the portal. The status dropdown is now Active or Archived only. Save handler whitelists `$_POST['status']` to that pair (anything outside coerces to `active`). Toggle-status validator drops `draft`. Status badge map drops `draft` and treats any legacy `draft` row as Active for display. Duplicate Journey handler creates copies as `active` (was `draft`). New `upgrade_to_1_30_21_normalise_journey_status()` migration flips any existing `draft` rows to `active` on upgrade — idempotent.

### Added
- **Archive confirmation prompt with live in-progress client count.** Clicking the **Archive** button on the Journeys list now surfaces a JS `confirm()` reading "N client(s) currently in progress will lose access to this journey. Their data will be preserved and access will return if you re-activate. Continue?" Singular/plural handled via `_n()`; alternate "No clients currently in progress" message when count is zero. Backed by new `SP_Database::count_clients_for_journey( $journey_id, $status )` helper.
- **Backend defence-in-depth on archived journeys.** All seven portal write handlers (`handle_complete_milestone`, `handle_request_signoff`, `handle_add_reflection`, `handle_edit_reflection`, `handle_delete_reflection`, `handle_save_field_responses`, `handle_clear_field_responses`) now call a new `bail_if_journey_archived()` helper that redirects stale-form POSTs to `?sp_notice=journey_archived` instead of writing. Render layer already hides the forms — this catches the race where a form was rendered before the archive landed.

### Fixed
- **Uninstall now releases the server-side licence activation slot.** Previously, a "delete with data" uninstall left the activation record orphaned on sprigly.co. The next install + activate would be treated as an idempotent re-activation server-side and **no fresh `activation_token` would be issued**, leaving the new install with no token to send on `/validate`. The licence would flip to invalid on the next revalidate, suspending Team practitioners and locking the owner out of the Team page. Recovery required a manual Reset Activation on sprigly.co. New `uninstall.php` POSTs `https://sprigly.co/wp-json/sprigly-licensing/v1/deactivate` (with stored key + token) before clearing options. 5-second timeout, blocking. Includes `error_log()` calls for visibility — look for `[Sprigly uninstall] /deactivate HTTP 200 …` in `wp-content/debug.log`. Future re-installs activate cleanly; sites that already orphaned a slot still need a one-time Reset Activation to recover.

### Database hygiene
- Added one previously-orphaned option (`sp_portal_page_id`) to the "Protect data OFF" uninstall cleanup list. Found by post-1.30.20 testing of the uninstall flow.

## 1.30.20 — 2026-04-26

### Changed (audit findings — non-security defence-in-depth)
- **Export endpoint server-side gate.** `SP_Admin_Data_Tools::handle_export()` now enforces `SP_License::is_pro()` server-side (UI was already gated). A Free admin posting directly to `?action=sp_export_data` now receives a 403 with an upgrade message instead of a download. (DIM-9.1)
- **Email failure logging.** `SP_Email::send()` was returning `wp_mail()`'s bool but no caller checked it. Now logs failures via `error_log` and persists the last 20 failure summaries in a new `sp_email_failures` option (capped, autoload off). Same treatment for `handle_bug_report()`. (DIM-7.1, 7.2)
- **AJAX i18n + escape.** Eight `wp_send_json_error()` strings across `class-sp-admin-journeys.php` and `class-sp-admin-resources.php` wrapped in `esc_html__()`. (DIM-5.1, 8.2)
- **Test-email body i18n.** Heading, body lines, Site/Time labels in `handle_test_email()` wrapped in `esc_html__()`. (DIM-5.2)
- **wp_die / deactivation messages i18n.** `wp_die()` strings in `handle_test_email`, `handle_bug_report`, plus the deactivation warning JS message in `deactivation_warning_js()` wrapped in `__()` / `esc_html__()`. (DIM-5.4)
- **wp_unslash hardening.** `enqueue_sortable()` and `handle_form_submissions()` in `class-sp-admin-journeys.php` now `wp_unslash()` `$_GET`/`$_POST` reads before sanitisation. (DIM-8.1)
- **aria-label on icon-only buttons.** Clear-responses 🗑️ + edit ✏️ + delete-reflection 🗑️ buttons (in both list and detail render paths) gained `aria-label` so screen readers announce their purpose. (DIM-4.2)
- **Better attachment alt text.** Reflection-attachment `<img alt>` now reads "Reflection attachment: {name}" instead of a bare filename, with fallback to "image" when the filename is empty. (DIM-4.3)
- **Uninstall hygiene.** Added 7 previously-orphaned options (`sp_owner_user_id`, `sp_pending_owner_transfer`, `sp_pro_features_hidden_notified_at`, `sp_license_last_valid_check`, `sp_license_activated_by`, `sp_timestamps_normalized_v2`, `sp_email_failures`) and the `sp_phone` user_meta key to the "Protect data OFF" cleanup path. (DIM-10.1, 10.3)

## 1.30.19 — 2026-04-25

### Security
- **Daily licence validation now sends per-site activation token.** `revalidate()` now sends the locally-stored `sp_license_activation_token` on every `/validate` call (daily cron + Refresh License button + role-change re-validations). Closes the class of attack where someone with `(license_key, site_url)` could enumerate customer domains via `/validate`, pull the real Postmark sending token via `/postmark-token`, or fingerprint update channels via `/check-update` and `/plugin-metadata`. The most concrete exposure was the Postmark token — an attacker could drain the operator's Postmark credits or damage sender reputation. Pairs with Sprigly Licensing v1.3.8 server-side. Three new server error codes — `activation_token_required`, `missing_activation_token`, `invalid_activation_token` — added to the client's `definitive_error_codes` list in `revalidate()`, so a server-side token mismatch correctly flips local status to invalid (rather than being silently absorbed by the resilience layer that ignores transient errors). Lost-token recovery is via the licensing admin's existing Reset Activation feature.
  - File: `includes/class-sp-license.php` (`revalidate()`, `definitive_error_codes`).

## 1.30.18 — 2026-04-25

### Security
- **License deactivation now requires per-site activation token.** Pairs with Sprigly Licensing v1.3.7 server-side. The `/activate` endpoint now returns a 256-bit `activation_token` (one-time, on fresh activation only); the main plugin persists it in the new `sp_license_activation_token` option, sends it on every `/deactivate` call (manual deactivate + key-switching path), and clears it after deactivate. Server verifies via `hash_equals(sha256($input), $stored_hash)`. Closes a class of attack where someone with `(license_key, site_url)` could DoS the customer by remotely deactivating their site. Rows created before this version (legacy/test data) remain deactivatable without a token until reset+reactivated, audit-logged server-side as the legacy soft-cutover path. Lost-token recovery is via the licensing admin's existing Reset Activation feature.
  - Files: `includes/class-sp-license.php` (`OPTION_ACTIVATION_TOKEN`, `api_call($extra)`, `handle_activate`, `handle_deactivate`), `uninstall.php` (option cleanup).

## 1.30.17 — 2026-04-25

### Changed
- **AU/UK English spelling for "enrolment".** Standardised the user-facing copy on the single-L Australian/British spelling. Affects the sidebar menu label (`Enrollments` → `Enrolments`), the Enrolments page heading, "Back to Enrolments" navigation links, the per-journey enrolment count, and the Client Directory's Journey Enrolments table (heading, empty state, View Enrolment action). The plugin previously mixed both spellings — menu and headings used US double-L while the help text on the Journeys list already used the AU/UK form. This brings every user-visible string into agreement and matches the rest of the plugin's AU/UK house style (customise, behaviour, licence, organisation).
  - Files: `includes/admin/class-sp-admin-menus.php`, `includes/admin/class-sp-admin-clients.php`, `includes/admin/class-sp-admin-client-directory.php`.
  - No functional change — internal variable names, CSS class names (`.sp-table-enrollments`), and historical changelog/readme entries are intentionally untouched. Translations files will pick up the new strings on next regeneration.

## 1.30.16 — 2026-04-22

### Fixed
- **Pro→Free transition email — one send per lapse, full stop.** If the licensing server intermittently returned `is_active=false` and then recovered on a subsequent check, `SP_License::handle_pro_to_free_transition()` fired the "your Pro features are now hidden from clients" email on every valid→invalid edge with no idempotency. A flapping demo.sprigly.co licence produced ~5 identical emails in 2 days. Added a persistent one-shot guard backed by the new `sp_pro_features_hidden_notified_at` option: once the notification has been sent, it will not re-send until the flag is explicitly cleared. The flag clears inside `handle_activate()`, so a user entering or re-entering a licence key re-arms the notification for a genuine later lapse. During a flap the guard stays latched regardless of how many times the licence flips — the owner receives exactly one email, not one-per-cycle and not one-per-24h. The dashboard banner (`render_pro_features_hidden_notice`) continues to serve as the in-site persistent reminder while the licence remains in a lapsed state.
  - Files: `includes/class-sp-license.php` (`handle_pro_to_free_transition()` + flag clear in `handle_activate()`).
  - Does not address the root cause of the flap (either a server-side race on sprigly.co or a seat-count mismatch vs. the demo site's practitioner count) — that remains under investigation. This patch ensures customers are insulated from any future flap regardless of source.
  - Known small gap: if a lapsed licence is silently re-activated via auto-renewal (so `handle_activate()` never runs) and later lapses again months later, the second lapse won't re-notify because the flag is still set. Rare edge case — worth revisiting if anyone actually hits it.

## 1.30.15 — 2026-04-20

### Changed
- Additional documentation scrub pass. Public-facing surfaces (readme.txt, info.json, shipped code comments, demo plugin admin page + demo content fixtures, chatbot knowledge base, product-page copy) genericised to remove lingering references to a reflection-visibility option. No behaviour change.

## 1.30.14 — 2026-04-20

### Changed
- Documentation housekeeping. User-facing docs scrubbed of references to a briefly-introduced reflection-visibility option that was reverted in v1.30.13. No functional change shipping in this build beyond regenerating `info.json` from the updated `readme.txt`.

## 1.30.13 — 2026-04-20

### Internal
- Minor internal improvements and housekeeping.

## 1.30.11 — 2026-04-20

### Changed
- **Milestone description — 100px left padding on mobile.** v1.30.10's fix restored the correct flex sizing so the description takes its 60% of the row, but visually it still looked heavy next to the short Title ("Module 1: Self-Awareness") and Type ("Standard") values. Added `padding-left: 100px` to the description value span inside the 782px milestones block. The value still right-aligns but wraps within a tighter visual column.
- **"Duplicate" → "Copy" button text on mobile.** "Duplicate" was overflowing the small admin button on narrow viewports and truncating to "Duplicat". Replaced the single text node with two wrapped spans (`.sp-btn-text-full` = "Duplicate", `.sp-btn-text-short` = "Copy") on both the Journeys list action link and the Milestones list form button. CSS toggles which span is visible based on viewport — desktop shows the full word, ≤782px shows "Copy". Button class `.sp-btn-duplicate` added so the selector is scoped (won't affect any other button with the same text).
  - Files: `assets/css/admin.css`, `includes/admin/class-sp-admin-journeys.php`.

## 1.30.10 — 2026-04-20

### Fixed
- **Milestone description wrap narrow — real root cause.** v1.30.8 swapped to grid; v1.30.9 tightened the milestones-only grid rule; neither actually fixed it. The underlying bug was the SHARED `overflow-wrap: anywhere` on `.sp-table-* td > *` (added in v1.30.6 thinking it was harmless). `anywhere` affects intrinsic size for flex AND grid — it tells the sizing algorithm the item's min-content is zero — which collapsed the value track under whichever layout was active. The milestones table hit it worst because it has the longest wrapping content (12-word descriptions). Changed the shared rule from `anywhere` to `break-word` in both the 850px and 782px media blocks. `break-word` provides the same horizontal-overflow protection (breaks a word if nothing else works) WITHOUT affecting intrinsic sizing.
- **Reverted the milestones-specific grid override** (added in v1.30.8, tweaked in v1.30.9). With the shared rule fixed, the default flex layout sizes the milestone cells correctly on its own. Kept only the Title-not-centered-card override so the Title row reads as a regular label/value row.
  - File: `assets/css/admin.css` (shared `td > *` rule in both 850px and 782px blocks; milestones override at end trimmed to just the Title rule).

### Lesson
- `overflow-wrap: anywhere` is NOT a drop-in replacement for `break-word` in flex/grid contexts. The visual break behaviour is similar, but `anywhere` additionally zeros the item's min-content for intrinsic sizing — which silently collapses tracks. Default to `break-word` when the element is a flex or grid child; only use `anywhere` for elements sized by explicit width/height.

## 1.30.9 — 2026-04-20

### Fixed
- **Milestone description wrapping was too severe.** v1.30.8's grid override carried `overflow-wrap: anywhere` on the value child. Unlike `break-word`, `anywhere` affects grid track **intrinsic sizing** — it tells the browser the column's min-content is zero — which made the `1fr` value track collapse and wrap the description text 2–3 characters per line. Fix: set `overflow-wrap: break-word` specifically for the milestones grid (still scoped only to this table — other tables keep `anywhere` for their flex layouts). Also dropped the label column from 40% → 35% and reduced `column-gap` from 12px → 10px to give the description more horizontal room.
  - File: `assets/css/admin.css` (milestones grid block at end of 782px media query).

## 1.30.8 — 2026-04-20

### Fixed
- **Mobile milestone list, take 2.** v1.30.6 added `flex: 1 1 0 / min-width: 0 / overflow-wrap: anywhere` to the shared `.sp-table-* td > *` rule in the 850px `@media` block, but `admin.css` has a SECOND re-enforce block at `@media (max-width: 782px)` (added to override WP core's `.wp-list-table` responsive rules which kick in at 782px). That later block re-declared `table.sp-table-* td > *` with ONLY `text-align: right !important` and, because of the added `table` element selector, had higher specificity — so under 782px the only property surviving was `text-align: right`. The sizing fixes were being discarded. This meant on real mobile viewports (≤782px) the Description value was rendering at ~100% width and overlapping the `::before` label.
- **Milestone Title row was a centered card header.** Was listed in the `.sp-cell-title` group (both media blocks) and rendered as a full-width centered card header like Client/Journey titles. User feedback: should read as a regular label/value row so the mobile view is consistent with Description and Type. Removed `table.sp-table-milestones td[data-label="Title"]` from the centered-header selector lists in both blocks.
- **Added milestones-specific explicit grid layout** at the end of `admin.css`: `.sprigly-admin-wrap table.sp-table-milestones tbody td[data-label]` uses `display: grid` with `grid-template-columns: minmax(90px, 40%) 1fr`. Deterministic 40/60 split regardless of any upstream flex cascade. Higher specificity (`.sprigly-admin-wrap table.sp-table-milestones tbody td[data-label]` = 3 classes + 3 elements) beats both earlier Sprigly blocks and WP core's load-styles rules. Actions cell kept as flex (center-aligned button group).
- Ported the `flex: 1 1 0 / min-width: 0 / overflow-wrap: anywhere` fix to the 782px re-enforce block as well, so other tables (journeys / clients / etc.) get the same sizing protection on narrow viewports.
  - File: `assets/css/admin.css`.

## 1.30.7 — 2026-04-20

### Fixed
- **Portal My Account: password hint spacing.** The "Password must be at least 8 characters" hint sits after the two-column New/Confirm Password `.sp-form-row`, which meant the inner `.sp-form-group` children's 16px `margin-bottom` inflated the grid-container height — producing a big gap above the hint and no gap before the Change Password button. The email hint (injected inside `.sp-form-group` by demo-reset) didn't have this problem because its 16px bottom margin came from its parent group. Fix: when a `.sp-form-hint` immediately follows a `.sp-form-row`, zero the row's inner groups' bottom margins and give the hint a 4px top / 16px bottom rhythm so it matches the email case. Uses `:has(+ ...)` to scope the change to only this pattern — other `.sp-form-row` usages (e.g. First Name / Last Name) keep their existing spacing.
  - File: `assets/css/portal.css`.

## 1.30.6 — 2026-04-20

### Fixed
- **Mobile: milestone list row overlap on the Journey edit page.** On narrow screens the responsive card layout flex children were allowing values to render on top of their labels — most visible on the Description (long wrapping text) and Type (raw text node) cells. Added `min-width: 0` and `overflow-wrap: anywhere` to the value flex children so they shrink correctly inside the 60% remaining width, and wrapped the Type cell's raw output in a `<span>` so the `td > *` selector applies flex sizing to it consistently with the other rows.
  - Files: `assets/css/admin.css` (shared mobile table rule), `includes/admin/class-sp-admin-journeys.php` (Type cell span wrapper).
- **Mobile: Quick Links card URL overflow on Settings → Tools.** The three Quick Links URLs (Client Portal, Login Page, Sprigly Docs) were not wrapping and were stretching the card past the mobile viewport edge. Added `table-layout: fixed`, an explicit 120px label column, and `word-break: break-all; overflow-wrap: anywhere;` on each URL cell so long URLs break within the card.
  - File: `includes/admin/class-sp-admin-settings.php`.

## 1.30.5 — 2026-04-19

### Security
- **Hardened the JSON export and the dashboard reflection count.** Both surfaces now apply a stricter server-side filter on the `reflections` table at the SQL layer. Defence-in-depth follow-up to v1.30.4. Removed a stale bit of dead admin-facing template code that had been unreachable since a prior filter was added.

## 1.30.4 — 2026-04-19

### Security
- **Fixed a filter in `SP_Report::output_html()`.** The filter was reading the wrong column name, so the safety check always passed and the guard was effectively inert. Rewritten to read the correct column with a safe default (any unknown value excluded).

## 1.30.3 — 2026-04-19

### Fixed
- **License revalidation now honours definitive non-200 responses from the licensing server.** The v1.30.0 resilience fix correctly absorbs transient errors (network blips, 500 fatals, deactivated licensing plugin on sprigly.co) so a single hiccup doesn't suspend every customer's practitioners. But it was also absorbing *definitive* errors — `403 site_not_activated`, `404 invalid_license`, and the new `403 license_cancelled` / `403 license_suspended` codes — leaving cancelled licences stuck on "valid" status forever. `SP_License::revalidate()` now distinguishes the two: any non-200 response WITH a recognised definitive error code in the body flips the status to invalid and fires the Pro→Free cascade as normal; non-200 responses without a recognised code (transient hiccups, WAF blocks, generic fatals) still keep cached status as before.

## 1.30.2 — 2026-04-18

### Security
- **Milestone reorder ownership check** — `SP_Admin_Journeys::ajax_reorder_milestones()` now verifies that every milestone ID in the POST payload actually belongs to the specified journey, not just the journey owner. Previously a crafted POST could include milestone IDs from another practitioner's journeys and silently update their `sort_order`. Closes a Team-tier write-side abuse vector flagged in the v1.30.x data-integrity audit.

### Fixed
- **Import is now atomic** — `SP_Admin_Data_Tools::do_import()` wraps the entire import in a database transaction. If anything fails partway through (e.g. a `wp_insert_user()` race, a JSON validation error mid-loop), all inserts are rolled back so the user can fix the source file and re-import without leaving partial data behind. Previously a partial failure left orphaned journeys and milestones in the database; re-importing the corrected file would have created duplicates.

### Changed
- **Import UI clarifies ADD-not-OVERWRITE behaviour** — both the upload form and the preview/confirm step now show a prominent amber notice explaining that imports always add to existing data, never overwrite. Removes a class of "I imported and now I have duplicates" support surprise.

## 1.30.1 — 2026-04-18

### Changed
- **Pro→Free transition email** is now sent only to the Designated Sprigly Owner (clause 6.1 of the EULA), not to every WordPress administrator on the site. Previously every admin received the "Your Sprigly license has expired" email even though only the Owner controls billing. Falls back to the lowest-ID admin if no Owner is set (defensive — shouldn't happen post-v1.30.0).

## 1.30.0 — 2026-04-17

### Security
- **Designated Sprigly Owner model** — single administrator per site owns Sprigly data and licence. Closes a multi-admin backdoor where two admins on a single-seat licence could silently run parallel Sprigly setups. Non-owner admins see Settings only
- **Owner-only licence management** — activate, deactivate, refresh, Team management, and Practitioner Settings all gated to the designated owner. Read-only licence panel for non-owner admins
- **File proxy hardening** (`/?sp_file=ID`) — identical responses for 403/404 so attachment IDs cannot be enumerated by probing. Branded error page replaces the browser's default 403/404
- **HMAC token validation** simplified to a single constant-time comparison against the stored hash
- **Role guard + portal auto-restore respect opt-out** — stripping a practitioner role no longer silently undoes itself on the next /portal/ visit or plugin role-touch

### Added
- **Self-service owner transfer** — current owner enters another admin's email; target receives an HMAC-signed, 24-hour-expiring confirmation link. Ownership only moves after they click. Mirrors WordPress's change-admin-email pattern
- **Branded suspension landing page** for practitioners locked out by a lapsed Team licence. Shows the owner's email for contact. Replaces the generic "Sorry, you are not allowed" error
- **One-click "Re-assign orphaned records" notice** for owners on sites affected by the historical multi-admin bug. Suppressed on sites with suspended practitioners (those records legitimately belong to them)
- **Explanatory panels** on the Transfer Ownership form and the success notice — describe what happens to the old owner and how to bring them back as a working practitioner

### Changed
- Team-licence-lapsed admin notice reworded to "Team practitioner access has been suspended…" — clearer that it refers to other users on the site, not the viewer
- Reflection notification emails describe the content type when a client submits just a file or video ("Jane shared an image" instead of "Jane wrote a new reflection" with an empty quote box)
- "Admin →" link in the client-portal header visible to every administrator, not only users with `manage_sprigly`
- Client portal "Check-in Questions" section wrapped in a soft card; text/number/date/select/textarea inputs normalised to the same height, padding, and focus state; dropdowns now have a readable min-width and a custom chevron
- Plugins list row for Sprigly now includes "View details" (opens the standard WP plugin info modal with description and changelog) and "Upgrade to Pro" (shown only on Free installations, links to the pricing page)

### Fixed
- "Add Client" dropdown no longer leaks clients from other practitioners on Free/Pro after a historical Team→Free downgrade
- Administrators who happened to be on the suspended-practitioners list no longer get redirected to the suspension page. Only pure practitioners (no admin role) do
- On Team reactivation, `restore_practitioners` respects the opt-out flag and skips non-owner admins
- Daily licence revalidation is now tolerant of transient server errors. Only an explicit `is_active: false` from a well-formed 200 response flips a valid licence to invalid — non-200 codes (404, 500, etc.) and malformed bodies keep the cached status. Prevents a single hiccup on sprigly.co from cascading into practitioner suspensions and Pro→Free emails across every customer site

### Added
- `sp_license_last_valid_check` option tracks the timestamp of the last successful licence verification for future diagnostic tooling

### Upgrade routines (all idempotent, run once per site)
- Bootstrap designated owner if unset (licence activator, else lowest-ID admin)
- Strip `sprigly_practitioner` from any non-owner administrator and persist opt-out flag
- Re-suspend non-owner practitioners that a pre-fix bug had brought back after a historical downgrade

## 1.18.0 — 2026-04-16

### Added
- **Milestone:** New "Require Reflection" per-milestone toggle — when enabled, clients must submit a reflection before they can mark the milestone complete or request checkpoint sign-off. Practitioner admin mark-complete is unaffected (can always override). Setting is preserved through milestone duplication, journey duplication, and JSON export/import.

### Changed
- **Portal:** Removed the disabled button + amber inline warning pattern for required check-in fields. Buttons are now always active; clicking without meeting requirements redirects back with a red error notice under the relevant section heading and scrolls to it. Cleaner UX, same server-side enforcement.
- **Admin:** Empty state pages (Journeys, Enrollments, Clients, Client Directory, Resources) no longer show a bordered card.
- **Plugin:** License header updated from Proprietary to GPLv2 or later for WordPress.org directory compatibility.

### Fixed
- **License:** Switching license keys (e.g. Team → Pro) now deactivates the old key on sprigly.co before activating the new one. Previously a stale activation record consumed a slot indefinitely.
- **Email:** Pro → Free transition email now says "your website's database" instead of implying Sprigly holds the data.
- **Admin:** Legacy client notes (stored in `sp_clients.notes`) no longer disappear when a new general note is added via the Client Notes section.

## 1.17.51 — 2026-04-14

### Fixed
- **Pro → Free transition email** is now sent only to site administrators, not to all Sprigly practitioners. Team-tier practitioners don't own the license and were incorrectly receiving "Your Sprigly license has expired" emails when the admin deactivated. Single-seat Pro admins still receive it since they hold both roles.

## 1.17.50 — 2026-04-14

### Fixed
- **License downgrade notice** no longer relies on a WordPress transient — switched to a regular `wp_option` so it persists reliably regardless of object cache state. The admin warning banner shown after a Team → Free deactivation now appears consistently on all Sprigly admin pages until the license is reactivated.

## 1.17.49 — 2026-04-14

### Fixed
- **Admin bar "Edit Profile" link** for pure practitioners (no manage_options) now points at Sprigly → Practitioner Settings instead of wp-admin `profile.php`. Direct access to `profile.php` by a practitioner also redirects to Practitioner Settings.

## 1.17.48 — 2026-04-14

### Fixed
- **Uninstall:** `sprigly_practitioner` and `sprigly_client` roles are now stripped from every user that holds them before the role definitions are removed. Previously `remove_role()` only cleared the role from `wp_user_roles`, leaving stale entries in each user's `wp_capabilities` meta — so after a full uninstall + reinstall, users still showed as practitioners.

## 1.17.47 — 2026-04-14

### Fixed
- **Team page table width.** Removed the inline `style="max-width:800px;"` from the practitioner table on the desktop Team page. With 5 fixed-layout columns capped at 800px, each column was getting ~160px which squashed and wrapped the email address. Table now fills its container (matching Journeys/Clients/etc.) so each column gets ~240px+ and emails render on a single line. No effect on mobile, where the table already collapses to cards via `display: block`.

## 1.17.46 — 2026-04-14

### Changed
- **Team page now uses the shared responsive table CSS.** Previously the Team page had its own ~80 lines of bespoke inline CSS that didn't quite match the look of Journeys, Clients, Enrollments, Resources, etc. Now the Team table uses `class="sp-table-team"` and `admin.css` has been updated to add `.sp-table-team` to every relevant selector list. The result: identical card layout on mobile (title centered at top, label-left/value-right rows, Actions row at the bottom) and identical desktop table styling.
- **Remove action is now a proper `.button button-small`** matching the Edit / Archive / Duplicate / Delete buttons on the Journeys page. Outlined red on desktop, same outlined red on mobile as part of the Actions row. No more custom inline button styling.
- **Removed bespoke mobile label spans** — the shared `data-label` + `::before` pattern already used by the other tables now handles mobile labels automatically. Each cell carries a `data-label` attribute that CSS renders as the uppercase label on mobile.
- **Name cell uses `sp-cell-title` class** to pick up the centered title styling in the mobile card (matching how Journeys renders the journey name as the card header).

## 1.17.45 — 2026-04-14

### Fixed
- **Practitioner auto-add respects opt-out.** When an admin removed themselves as a practitioner, then uninstalled and reinstalled the plugin, the activation hook silently re-granted the practitioner role (undoing the explicit removal). Same bug on `admin_init` via `ensure_pro_admin_role()` and in `SP_License::handle_activate()` — all three paths added the role without checking whether the admin had opted out.

### Added
- **Persistent opt-out flag.** `user_meta['sp_opted_out_practitioner'] = '1'` is set when:
  - An admin removes their own practitioner role from the Team page (`handle_remove` self path)
  - An admin chooses "No, I'm not a practitioner" in the practice setup prompt (`handle_setup`)
  
  The flag lives in user_meta — outside Sprigly's tables — so it survives uninstall and reinstall as long as `sp_protect_data` is enabled (the default). Full uninstall with "Remove all data" explicitly wipes it along with everything else.
- **Auto-add paths now check the flag.** `SP_Install::activate()`, `Sprigly::ensure_pro_admin_role()`, and `SP_License::handle_activate()` all skip the auto-add when the flag is set for the current user. Fresh installs on accounts without the flag still get the convenience of an immediate practitioner role — only explicit opt-outs are respected.
- **Self-service re-opt-in.** New blue banner on the Team page, shown when the current user is an admin without the practitioner role: "You're an administrator but you're not currently a Sprigly practitioner." With a green "Add me as a practitioner" button that grants the role and clears the opt-out flag in one click. Copy adapts to whether the user previously opted out ("If you want to start working with clients again…") or has never been a practitioner on this install.
- **Flag is cleared on re-opt-in.** `handle_invite` (when inviting an existing user who had previously opted out), `handle_setup` (when choosing "yes"), and the new `handle_add_self` all clear the user_meta so subsequent auto-add paths treat the user fresh.
- New success notice `self_added` — "You've been added as a practitioner. Welcome back!"

## 1.17.44 — 2026-04-14

### Fixed
- **Team page styling.** The outer wrap was using `<div class="wrap">` instead of `<div class="wrap sprigly-admin-wrap">`, which meant the centralised `.sprigly-admin-wrap .button-primary` rule in `admin.css` never matched — the "Send Invite" button rendered as WordPress default blue instead of Sprigly green. Wrap class now includes `sprigly-admin-wrap`, and the Send Invite button inherits the site-wide green styling (#1a5632 background, rounded, 600 weight).
- **Team list mobile — real labels.** The previous `::before` + `data-label` approach wasn't rendering label text in practice. Switched to real `<span class="sp-mobile-label">` elements that are hidden on desktop (`display: none`) and shown inline on mobile. Labels now reliably appear as "EMAIL", "CLIENTS", "JOURNEYS" next to each value.
- **Team list mobile — Remove button.** The Remove link on mobile now renders as a proper red button (matching the confirmation button style used elsewhere in the admin) — full tap target, 10px/24px padding, 8px radius, matches the visual language of the Send Invite button. On desktop it stays as a simple red link.

## 1.17.43 — 2026-04-14

### Changed
- **Team list — mobile card layout.** The practitioner table now stacks each row as a card on screens ≤ 782px (WordPress's standard admin mobile breakpoint). Desktop retains the classic table. Uses the `display: block` + `data-label` responsive table pattern so no JS is required.
- **Team list — removed You / Admin chips.** On a team of 1–5 practitioners the viewer generally knows who they are. The chips added visual clutter without useful information. The underlying self/admin guardrails in `handle_remove()` and `render_delete_confirm()` are unchanged — users still can't accidentally delete their own WP account, the UI just doesn't flag it upfront.

## 1.17.42 — 2026-04-14

### Changed
- **Team — nuanced self/admin remove.** v1.17.41 over-corrected and blocked self and admin removal entirely. The correct rule is: role + data removal is always allowed (it's a legitimate "I'm stepping back from client work" workflow), but **WP user account deletion** is only offered for non-self, non-admin targets. Admins and the current user can have their practitioner role + Sprigly data removed; their WP account stays intact regardless of the form state.
- **Team list — cleaner chips.** The "You" and "Admin" badges are now compact, single-line, and sit inline with the name. Remove link is shown for every row now that self/admin are safe.
- **Confirmation screen — context-aware.** When removing self or an admin, the confirmation screen shows a blue info panel explaining the WP account is preserved, the "Also delete WordPress user account" checkbox is replaced with a dashed grey note explaining why it's not available, and the submit button text adapts ("Remove My Practitioner Role & Data" / "Remove [Name] from Sprigly" / "Delete All Data & Remove [Name]").
- **Double protection at the handler.** `handle_remove()` still force-coerces `$delete_wp_user = false` server-side when the target is self or admin, so even a tampered POST can never touch a WP user it shouldn't.

### Added
- New notice `role_removed` — shown after a successful role-only removal, explains that the WP user account was preserved.

### Removed
- Notices `cannot_remove_self` and `cannot_remove_admin` — no longer reachable because those paths are now legitimate workflows rather than errors.

## 1.17.41 — 2026-04-14

### Security — CRITICAL
- **Team:** The "Remove Practitioner" handler could be used to delete the currently logged-in administrator's own WP user account, locking the site out entirely. Discovered in testing when an admin-who-is-also-a-practitioner used the form on themselves, ticked "delete WP user", and found their own `wp_users` row deleted with no way back in except direct database access.

### Fixed
- **Team:** `SP_Admin_Team::handle_remove()` now enforces four guardrails on every submission:
  1. Rejects if `user_id === get_current_user_id()` (can't remove yourself).
  2. Rejects if the target user has `manage_options` (admins can only be modified via Users → All Users).
  3. Rejects if the target doesn't actually hold the `sprigly_practitioner` role.
  4. If the "delete WP user" checkbox is ticked, refuses if the target is the last remaining user on the site (belt-and-braces).
- **Team:** `SP_Admin_Team::render_delete_confirm()` applies the same three entry-point guards so the confirmation screen can't even be reached for self / admin / non-practitioner targets.
- **Team list UI:** the practitioner table now shows a "You" chip next to the current user's row and an "Admin" chip next to any user with admin privileges. The Remove link is replaced with explanatory text for both ("can't remove yourself" / "admin user — edit via Users") so the path to this bug is simply not presented to the user any more.
- **Docstring:** the comment on `handle_remove()` used to say "The WP user account itself is never deleted" while the code happily deleted it when a checkbox was ticked. Docstring now accurately describes the guardrails.

### New error notices
- `cannot_remove_self`, `cannot_remove_admin`, `cannot_remove_last_user`, `not_a_practitioner` — clear explanations shown when any of the guardrails fire.

## 1.17.40 — 2026-04-14

### Changed
- **Licensing — strict Pro → Free gating with loud signposting.** Prior to this release, Pro features stored in the database (checkpoint milestones, custom check-in fields, scheduled milestone unlock) continued to function in the client portal even after a downgrade to Free. This rewarded non-renewal and made the tier boundary leaky. The plugin now enforces strict render-time gating: on Free, checkpoints become standard milestones, custom fields are hidden from the portal, and scheduled unlocks become immediate. **No data is ever deleted** — upgrading to Pro instantly restores the full experience with zero migration.

### Added
- **Portal gating helper** — new `SP_Portal::gate_milestone_for_tier()` and `gate_milestones_for_tier()` clone-and-degrade milestone objects at every render site (portal journey list, milestone detail, preview journey, preview milestone). Keeps the degradation logic in one place and non-destructive.
- **Free-tier gating on portal form handlers** — `handle_complete_milestone()` allows self-complete on coerced checkpoints, `handle_request_signoff()` silently bounces stale sign-off POSTs, `handle_save_field_responses()` and `handle_clear_field_responses()` discard stale custom-field submissions.
- **Dashboard notice** — `SP_Admin_Menus::render_pro_features_hidden_notice()` shows a warning banner on the Sprigly Dashboard when the site is Free but has stored Pro content. Lists exact counts (checkpoints, custom fields, scheduled unlocks), links to the pricing page, dismissible per user via a new AJAX handler (`sprigly_dismiss_pro_hidden_notice`). Dismissal persists in user meta.
- **Milestone edit page banner** — when editing a specific milestone on Free that has stored Pro data, an inline amber banner lists the hidden features with an upgrade CTA.
- **Preview gating + banner** — journey preview (`render_preview_journey` / `render_preview_milestone`) now applies the same Free gating. On Free, the preview shows an "This is exactly what your clients see" banner so the practitioner knows their preview matches the real portal view.
- **One-shot transition email** — `SP_Email::notify_pro_features_hidden()`. Triggered from `SP_License::revalidate()` (automatic expiry) and `SP_License::handle_deactivate()` (voluntary) when the license transitions from Pro → Free. Body lists exactly what has been hidden per practitioner.
- **Dismissal auto-reset** — `SP_License::reset_pro_hidden_dismissals()` clears every user's dismissal flag on license status change, so a new Pro → Free transition always shows the dashboard notice fresh.
- **New database helpers** — `SP_Database::count_hidden_pro_features( $practitioner_id )` and `count_hidden_pro_features_for_milestone( $milestone_id )` return structured counts used by the dashboard notice, the milestone edit banner, the preview banner, and the transition email.

### Security note
This is a pure feature-gating change — no data is deleted, migrated, or touched. The exact same database state can switch between "all Pro features visible" (on Pro) and "all Pro features hidden" (on Free) with a single license status flip.

## 1.17.39 — 2026-04-14

### Fixed
- **Timezones:** All plugin timestamps are now stored in the site timezone consistently. Previously `insert_reflection()`, `insert_note()`, `insert_client()`, `insert_milestone()`, `insert_journey()`, `insert_resource()`, and `insert_milestone_field()` did not pass an explicit `created_at` / `started_at`, so MySQL's `DEFAULT CURRENT_TIMESTAMP` populated the column — which is UTC on most hosts. Meanwhile `complete_milestone()` and `upsert_field_response()` used `current_time('mysql')` (site time). The mix caused reflection/note timestamps to display hours off in the admin and broke the sort order of the dashboard Recent Activity feed.
- **Dashboard:** Recent Activity feed was silently returning only completions because the nested `$wpdb->prepare()` calls in `get_recent_activity()` triggered WordPress's stricter placeholder scanning (6.2+) and caused the reflection query to return nothing. The query now uses a single `prepare()` with the practitioner ID cast to int and inlined directly, matching the pattern used by every other working query in the plugin. The feed now correctly interleaves reflections with completions, sorted by date.
- **Admin:** Enrollments and client detail views now show correct local-time stamps for reflections, notes, client start date, etc.

### Migration
- One-shot upgrade routine `upgrade_to_1_17_39_timezone_fix()` runs on upgrade to shift all previously-UTC timestamps forward by the current site timezone offset so they match the site-timezone timestamps. Protected by a version check and an `sp_timestamps_normalized_v2` option flag so it can only run once — a downgrade-then-upgrade cycle won't double-shift.

## 1.17.38 — 2026-04-14

### Fixed
- **Portal:** Reflection rich-text editor no longer produces an extra blank line after a bolded first line on save. `spNormalizeHtml()` previously only wrapped leading content in a `<p>` when the first child was a text node — starting with bold (`<strong>`) skipped that branch and left the first line as bare inline HTML while subsequent lines became proper paragraphs, producing mismatched spacing. The wrapping logic now handles inline elements too.
- **Portal:** Chrome contenteditable no longer compounds font-size on new lines after bold. Editor now calls `execCommand('defaultParagraphSeparator', 'p')` and `execCommand('styleWithCSS', false)` on load so Chrome wraps new paragraphs in `<p>` tags (not `<div>`) and uses semantic tags like `<b>`/`<i>` instead of `<span style="...">`. `spNormalizeHtml()` also strips any inline style/size/color/face attributes and unwraps any legacy `<font>` tags as a safety net.

## 1.17.37 — 2026-04-14

### Security
- **Admin:** `handle_save_milestone()` now cross-checks that a submitted `milestone_id` (in edit mode) actually belongs to the submitted, ownership-verified `journey_id`. Prevents a tampered POST from combining an attacker's own journey_id with a foreign milestone_id to edit another practitioner's milestone title, description, type, unlock settings, or video URL.
- **Admin:** `handle_save_milestone()` now builds a whitelist of custom field IDs belonging to the target milestone and discards any posted `field_id[]` outside it. Prevents tampered field edits/deletes from affecting fields on another practitioner's milestone.
- **Admin:** `handle_delete_milestone()` now cross-checks that the milestone's own `journey_id` matches the posted journey. Without this, a practitioner could craft a POST to delete any milestone on any journey.
- **Admin:** `handle_assign_client()` now verifies the current user owns the posted journey before creating the client record. Prevents a Team-tier practitioner from enrolling a client under another practitioner's journey and gaining access to that journey's content via the new enrolment.

### Fixed
- **Database:** `delete_milestone()` now deletes practitioner notes attached to the milestone instead of leaving them orphaned in the notes table. Reflections continue to have their `milestone_id` nullified (client-authored content preserved).

## 1.17.36 — 2026-04-14

### Removed
- **Email:** Dead `notify_client_journey_reactivated()` method removed from `SP_Email`. It was guarded behind an `sp_email_client_reactivated` option that was never registered in the settings schema, so the function could never fire. Had no callers. Cleaned up the matching stale reference in `uninstall.php`.

## 1.17.35 — 2026-04-14

### Fixed
- **Database:** `delete_journey()` now cascades via `delete_client()` and `delete_milestone()` instead of raw `$wpdb->delete` calls, so custom field definitions, field responses, practitioner notes, and reflection attachments are no longer orphaned when a journey is deleted.
- **Database:** `delete_client()` now calls `wp_delete_attachment( $id, true )` on every reflection attachment before deleting the rows, removing the attachment post, postmeta, and the physical file from disk. Previously, deleting a client left orphaned files in `uploads/sprigly/` and orphaned rows in `wp_posts`/`wp_postmeta`.

## 1.17.34 — 2026-04-14

### Security
- **Portal:** Reflection file uploads now served exclusively through a PHP proxy (`/?sp_file=ID`) that verifies the viewer is the owning practitioner or the client who uploaded the file. Files can no longer be reached by direct URL.
- **Filesystem:** `.htaccess` hardening file + empty `index.html` written to `uploads/sprigly/` on activation, version upgrade, and every reflection upload (self-healing). Denies all direct HTTP access on Apache and sets `X-Robots-Tag: noindex, nofollow, noarchive, nosnippet`.
- **Crawlers:** `Disallow: /wp-content/uploads/sprigly/` appended to the site's virtual robots.txt via the `robots_txt` filter.
- **URL rewriting:** `wp_get_attachment_url` filter transparently rewrites every reflection attachment URL to the proxy endpoint — no template changes required.
- **Proxy hardening:** Path-traversal guard, strict `Cache-Control: private, no-store` headers, `Referrer-Policy: no-referrer`, 403/404 responses that don't leak whether an attachment ID exists on the site.

## 1.17.33 — 2026-04-14

### Security
- **Media Library:** Reflection attachments are now hidden from the WordPress media library UI for users who aren't the owning practitioner — affects both the media modal (block editor, featured image picker) and the `upload.php` grid/list. Protects client-submitted documents from being seen by other admins (e.g. a web designer with an administrator account).
- **Migration:** One-shot upgrade routine stamps privacy meta (`_sp_reflection_attachment`, `_sp_client_id`, `_sp_practitioner_id`) on reflection attachments created before this version so the new filters apply retroactively.

## 1.17.32 — 2026-04-14

### Fixed
- **Portal:** Checkpoint sign-off requests were not blocked when required check-in fields were empty, unlike the standard mark-complete path. Sign-off now enforces the same required-fields rule server-side, plus the "Request Sign-off" and "Mark as Complete" buttons render in a disabled state with an inline notice until all required fields are filled.

## 1.17.31 — 2026-04-14

### Added
- **Admin:** Drag-and-drop reordering for resources on the Resources list page. Resources are now grouped by journey (each journey gets its own sortable table), reorder handle appears on row hover, saves via AJAX with nonce + ownership check on every row in the posted order.

## 1.17.30 — 2026-04-14

### Security
- **Admin:** `handle_mark_complete()` now verifies the posted `milestone_id` belongs to the client's journey before inserting a progress record. Prevents a tampered POST from creating orphan progress rows or inflating the completion count to falsely mark a journey complete.
- **Admin:** `handle_add_note()` now verifies the posted `milestone_id` belongs to the client's journey. Matches the IDOR hardening on mark-complete.
- **Admin:** Defensive Pro gate added to the Practitioner Settings render path — even if someone guesses the URL on a Free-tier site, the export/import cards no longer render.

### Fixed
- **Portal:** Reflection edit form handler (`handle_edit_reflection()`) now persists the visibility column reliably.

## 1.17.28 — 2026-04-13

### Fixed
- **Admin:** New client users now display their full name (e.g. "Rachel Right") instead of their username (e.g. "rachel.right") — `display_name` is now set explicitly during user creation
- **Admin:** When enrolling an existing WP user via "Create new client" (email match), the first/last name entered is applied to the user's profile if they don't already have one set
- **Admin:** Milestone-specific resources are now deleted when the milestone is deleted, instead of becoming orphaned journey-wide resources
- **Admin:** "Milestone duplicated" success notice now displayed after duplicating a milestone
- **Admin:** Min/Max validation on number custom fields — inline error shown when Min exceeds Max, form submit blocked until corrected
- **Portal:** Checkpoint sign-off messages now use the journey's role label (e.g. "Nurse sign-off" instead of "Practitioner sign-off") — applies to badges, notifications, and lock reasons
- **License:** Plugin header updated to proprietary license with Sprigly copyright

## 1.17.27 — 2026-04-13
### Added
- **Admin:** Per-journey sequential mode — new "Require milestones to be completed in order" checkbox on the journey setup form. When enabled, clients must complete each milestone before the next one becomes available. Duplicated journeys preserve this setting
- **Admin:** Sign-off attention indicator — a red bell icon appears in the Enrollments list Actions column when a client has requested checkpoint sign-off. Clicking through to the client detail page shows a warning banner with the count of pending sign-offs
- **Admin:** Help text below the Journeys table explaining the difference between Archive and Delete, with tooltip on the Archive button

### Changed
- **Admin:** Unlock schedule date picker changed from datetime to date-only input — time component removed since milestones unlock at the start of the chosen day. Portal "Unlocks on" message now shows just the date
- **Admin:** Journey archiving no longer cascades to client enrolments — archiving a journey only hides it from the admin list and new-client dropdown. Existing clients continue to see and complete the journey in their portal
- **Portal:** Clients can now access their enrolled journeys regardless of journey status (active, draft, or archived) — journey status only affects the admin-side visibility
- **Portal:** "Current" milestone circle restyled — now shows as a white circle with a coloured border and step number instead of a filled dark circle, making it clearly distinguishable from completed milestones
- **Portal:** Milestones after a locked milestone (date-scheduled or checkpoint) are now also shown as locked with "Complete previous milestones first" message, preventing clients from skipping ahead

### Fixed
- **Admin:** WooCommerce compatibility — Sprigly practitioners are no longer redirected to WooCommerce's My Account page when accessing wp-admin. Two new filters (`woocommerce_prevent_admin_access` and `woocommerce_disable_admin_bar`) allow practitioners with `manage_sprigly` capability through
- **Admin:** Journey duplication now copies the role_label field (previously omitted)

## 1.17.21 — 2026-04-12
### Fixed
- **Database:** Enrolling clients via the Enrollments page now correctly creates progress records when milestones already exist on the journey

## 1.17.20 — 2026-04-10
### Added
- **License:** Grace period admin notice — warns when license has expired but is still within the grace window, showing days remaining and a renewal link

## 1.17.19 — 2026-04-10
### Changed
- **Roles:** Admin now receives the Sprigly Practitioner role on plugin activation, regardless of license status

## 1.17.18 — 2026-04-10
### Fixed
- **Cleanup:** Deactivation now deletes `sp_update_data` and `sp_license_downgraded` transients

## 1.17.17 — 2026-04-09
### Changed
- **Admin:** Tools tab — Send Test button aligned with input field, Copy to Clipboard / Send Bug Report / Save buttons styled as green primary buttons

## 1.17.16 — 2026-04-09
### Fixed
- **Updater:** "Check again" on Dashboard > Updates now bypasses the 12-hour cache and fetches fresh version info
- **Updater:** Fixed ghost update after upgrading — stale version in WordPress's checked array caused the update to reappear until next page load

## 1.17.15 — 2026-04-09
### Changed
- **Admin:** Sidebar menu reordered — Journeys now appears above Clients to match the natural setup flow (journeys must be created before clients can be enrolled)

## 1.17.13 — 2026-04-09
### Changed
- **Admin:** Journeys table column widths adjusted so action buttons fit on one line

## 1.17.12 — 2026-04-09
### Redesigned
- **Dashboard:** Home page redesign — personalised "Welcome back, [Name]" greeting, larger stat cards with hover lift effect, refreshed activity feed and client progress panels

### Changed
- **Admin:** Green admin buttons now use centralised CSS class instead of inline styles

## 1.17.11 — 2026-04-09
### Changed
- **Admin:** Invoice header font sizes increased for ABN and order number

## 1.17.10 — 2026-04-08
### Changed
- **Admin:** Table header and nav tab colour scheme updated from green (#1a5632) to navy (#314562) across all admin pages
- **Admin:** Milestone actions (Edit, Duplicate, Delete) on journey edit page now use button styling consistent with other admin tables (e.g. Enrollments)
- **Admin:** Section header backgrounds on Practitioner Settings and Add Client to Journey pages updated to navy (#314562)

## 1.17.3 — 2026-04-05
### Added
- **Admin:** Min/Max configuration for number custom fields — practitioners can set minimum and maximum values when creating number-type check-in questions. Values are enforced in the client portal via HTML validation
- **Email:** Per-journey role label — practitioners can set a client-facing role (e.g. "coach", "trainer", "counsellor") that replaces the generic "practitioner" in client emails. A new "Client-facing role" field on the journey setup form auto-suggests a label based on the selected Industry/Category (coaching → coach, fitness → trainer, therapy → counsellor, etc.) and can be freely edited to suit regional language preferences. Falls back to "practitioner" if left blank.

### Changed
- **Email:** Client notification emails ("You have new feedback" and "Milestone signed off") now use the journey's role label — e.g. "Alex, your coach, has left you feedback" instead of "Alex, your practitioner, has left you feedback"
- **Admin:** Removed Step option from number field configuration — Min and Max are sufficient for practitioner use cases

### Security
- **Admin:** Bug report screenshot upload now validates file type (jpg/png/gif only) via `wp_check_filetype()`
- **Portal:** All hardcoded emoji output now wrapped in `esc_html()` for WordPress coding standards compliance
- **Database:** License table check uses `$wpdb->prepare()` instead of string interpolation

## 1.16.37 — 2026-04-05
### Added
- **Admin:** "Change Password" card on Practitioner Settings page — practitioners can update their password without navigating to the WP profile
- **Portal:** Required check-in validation on "Mark as Complete" — clients must fill in all required check-in questions before a milestone can be completed
- **Roles:** Sprigly role guard — when another plugin (e.g. WooCommerce) calls `set_role()` and strips Sprigly roles, the plugin now immediately re-adds them by checking the database for active client/practitioner records
- **Admin:** Adding a new milestone to a journey now automatically reopens any client enrollments that were marked as completed — since completion = all milestones done, a new milestone means the journey is no longer finished

### Fixed
- **Portal:** Rich text editor paragraph formatting lost on save — Chrome's contenteditable creates `<div>` tags which were not normalised to `<p>`. Added JS normalisation on submit and CSS fallback for existing content
- **Portal:** Journey completion triggered early when a "final" type milestone was placed mid-journey — now requires ALL milestones to be completed (fixed in both portal and admin handlers)
- **Portal:** Progress report PDF now includes general client notes (notes not tied to a specific milestone)
- **Security:** Resource edit page (GET) now verifies practitioner ownership via parent journey — previously any practitioner with the `manage_sprigly_resources` capability could view another practitioner's resource by crafting the URL
- **Security:** Progress report REST endpoint now requires the viewer to be logged in as the practitioner who generated the token — previously the signed URL worked in any browser without authentication
- **Security:** Progress report errors now render as a styled HTML page instead of raw JSON

### Changed
- **Portal:** Check-in save button text changed from "Save Responses" / "Update Responses" to "Save Check-in" / "Update Check-in"
- **Portal:** Required check-in fields error notice moved from page top to directly under the "Check-in Questions" heading
- **Admin:** Removed redundant "Profile" tab from practitioner nav — name, email, and password are now fully covered by the Practitioner Settings page
- **Admin:** Updated nav comment to clarify the blocklist approach — third-party menus (WooCommerce, LearnDash, etc.) show through when practitioners hold additional roles

## 1.16.21 — 2026-04-05
### Added
- **Admin:** Duplicate Milestone action — copies title, description, type, unlock settings, video URL, and all custom fields
- **Admin:** "My Profile" section on Practitioner Settings page — practitioners can update their name and email
- **Admin:** Pro license auto-assigns `sprigly_practitioner` role to admin on activation and on first admin page load (no more missing role on existing installs)
- **Portal:** Milestone detail page now shows the step number in the circle (matching the journey overview) instead of a plain dot

### Fixed
- **Critical:** Practitioner restoration on Team license reactivation — recursive `revalidate()` triggered by role-change hooks was deleting suspended practitioner IDs before they could be restored. Added re-entrancy guard (`$syncing_roles` flag) to prevent this
- **Critical:** Preserved `sp_suspended_practitioners` option during plugin activation — was being deleted by `SP_Install::activate()`, wiping saved IDs if plugin was updated between license deactivation and reactivation
- **Admin:** Custom field label `required` attribute was blocking form submission when fields were off-screen or hidden (deleted) — removed client-side `required`, validated server-side instead
- **Portal:** Journey completion now triggers when all milestones are done, not only when the last milestone is type "final" — fixed in both client portal and practitioner admin handlers
- **Portal:** Required field validation on checklist (checkbox) custom fields — unchecked checkboxes were not triggering the required check; now validates server-side for all field types
- **Portal:** Sign-off request email "View Client" button URL used wrong parameter (`client_id=` instead of `id=`)
- **Admin:** Practitioner mark-complete redirect URL used wrong parameter (`client_id=` instead of `id=`)
- **Licensing:** Rate-limit storm caused by recursive `revalidate()` could exhaust the 100 req/hr limit and lock out the site

### Improved
- **Admin:** Free Plan Usage panel — two usage bars (Journeys, Clients) now full-width side by side; per-journey limits (milestones, resources) shown as inline text below
- **Portal:** Pending milestone circle styled as solid brand colour with white number (matches journey overview)
- **Admin:** Journey completion email now also sent when practitioner marks the final milestone complete (was only sent from client portal)

## 1.15.14 — 2026-04-03
### Added
- **Admin:** Client Directory — new "Clients" sub-menu showing each client once as a person (not per-enrollment) with name, email, phone, active journey count, and first assigned date
- **Admin:** Client Profile page — click through from directory to view/edit client details and see all journey enrollments with progress
- **Admin:** Phone number field for clients (`sp_phone` user meta)
- **Admin:** Resend Welcome Email action on directory and profile pages
- **Admin:** Enrollments page (formerly Clients) now grouped by journey with contextual "Add Client" buttons
- **Admin:** "My Portal →" link in admin menu for users who are also clients of another practitioner
- **Admin:** Image lightbox on attachment clicks in client view (no more opening in new tab)
- **Portal:** My Account page at `/portal/account/` — edit name, email, change password
- **Portal:** Client name in nav bar links to My Account
- **Portal:** Completed journey cards now show description, full progress bar, milestone count, and completion date
- **Portal:** "Continue" button on each journey card linking directly to the next incomplete milestone
- **Portal:** "Journey Completed" button in accent colour on completed journey cards
- **Portal:** Image lightbox on attachment clicks (no more opening in new tab)
- **Portal:** My Reflections page shows which milestone each reflection belongs to
- **Portal:** Milestone circles, timeline connectors, and pulse animation now use brand colour
- **Portal:** Conversation feed shows practitioner's first name ("Mike says") instead of generic "Practitioner"
### Improved
- **Admin:** Responsive card layout on Client Directory and Profile tables (matches Journeys page pattern)
- **Admin:** List items (bullets/numbers) now render correctly in reflection/note views
- **Admin:** Duplicate Profile menu item removed for practitioners
- **Admin:** Practitioner Settings page padding consistent with other pages on mobile
- **Admin:** "Add Client" existing user dropdown now only shows `sprigly_client` users owned by this practitioner (was showing all WP users — privacy fix)
- **Portal:** My Reflections form layout — video/file options stack vertically
- **Portal:** Nav link weights consistent (all normal weight)
- **Portal:** Completed progress bar uses brand colour (was hardcoded navy)
- **Portal:** Empty state left-aligned with no padding
- **Email:** Reflection emails now include milestone and journey name for context
- **Email:** Edited reflections say "updated a reflection" (not "wrote a new reflection")
- **Email:** Reflection content preserves formatting (bold, italic, lists) in email preview
- **Email:** Checkpoint sign-off and feedback emails now include journey name
- **Email:** Client-facing emails use practitioner's first name ("Mike, your practitioner,") instead of generic "Your practitioner"
- **Email:** Practitioner notified when client edits a reflection (not just new ones)
### Security
- **Portal:** Checkpoint milestones now gate progress — milestones after an unsigned checkpoint are locked until practitioner signs off
- **Portal:** Milestone page verifies milestone belongs to the journey in the URL (prevents cross-journey access via URL manipulation)
- **Portal:** Field response save/clear handlers verify milestone belongs to client's journey
- **Portal:** Password change requires current password verification
- **Portal:** Account form actions protected with nonce verification and login checks
- **Admin:** Client users blocked from wp-admin — redirected to portal (AJAX requests excluded)
- **Admin:** Client Directory form handlers include nonce verification, capability checks, and practitioner ownership validation
- **Admin:** Resend welcome email verifies practitioner ownership before sending
- **Admin:** Settings save now tab-scoped — saving Branding tab no longer resets Email checkboxes
### Fixed
- **Admin:** Client count (`count_clients`) now counts unique users, not enrollment rows — fixes free tier limit display ("12 of 5" → "6 of 5")
- **Admin:** Display name now syncs immediately after profile updates (portal, admin, and WP Profile page) via `wp_update_user()` + forced `display_name` write + `profile_update` hook
- **Portal:** PHP 8.1 `trim(null)` deprecation warnings fixed across all `parse_url()` calls
- **Portal:** PHP 8.1 `json_decode(null)` deprecation warnings fixed on field_options
- **Portal:** Undefined property `$preview_user_id` warning fixed
- **Build:** Version now stamped in all three locations — plugin header, `$version` class property, and `info.json`
- **Build:** Header version sed pattern fixed for macOS compatibility

## 1.15.12 — 2026-04-02
### Added
- **Journeys:** Drag-to-reorder milestones on the journey edit page (hover to reveal drag handle)
- **Journeys:** Full deep-copy duplication — now copies all milestone fields, custom fields, and milestone-specific resources
- **Portal:** Chronological conversation feed — client reflections and practitioner feedback now interleave by date like a chat
- **Portal:** Check-in Questions section (renamed from "Your Input") with improved layout and subtitle
- **Portal:** Checklist field type — practitioners can define multiple checkbox options (like dropdown but multi-select)
- **Clients:** Client Notes now works as a timestamped journal — each note is saved as a separate dated entry
- **Clients:** Mark Complete button moved to the right of the milestone card for cleaner layout
- **Clients:** Check-in Responses now appear first in milestone cards (above conversation)
- **Settings:** Bug report form in Tools tab — sends description + optional screenshot to notifications@sprigly.co
- **Settings:** Rich text editor for Client Notes and milestone descriptions
### Improved
- **Portal:** Practitioner Feedback moved below reflections for better content flow
- **Portal:** Bold, italic, and underline formatting now renders correctly (CSS fix for theme overrides)
- **Portal:** Textarea and date fields now use consistent Poppins font
- **Portal:** Reflection placeholder updated to guide users about where responses appear
- **Portal:** Journey detail pages now work reliably regardless of permalink flush state (fixed query var parsing)
- **Admin:** Scroll anchors on save actions — page returns to the section you were working in
- **Resources:** Removed redundant PDF type (use File upload instead)
### Fixed
- **Journeys:** Duplicate now redirects to journey list instead of blank page
- **Journeys:** Archive now cascades to client enrollments (and reactivate reverses it)
- **Clients:** Client view page hides pause/archive buttons when journey is archived
- **Clients:** Server-side guard prevents reactivating a client on an archived journey
- **Portal:** Date-based milestone unlock now uses WordPress local time instead of UTC
### Removed
- **Settings:** Journey Reactivated email notification (removed from settings and sending logic)

## 1.15.10 — 2026-03-29
### Fixed
- **Branding:** Default primary colour now matches portal CSS (#314562 navy) — previously showed green in settings but rendered navy in portal
- **Portal:** Fixed PHP 8.1 trim() deprecation warning on fresh installs
- **Clients:** Editing client email no longer creates a duplicate WordPress user
- **Data:** Deleting a WordPress user now automatically cleans up their Sprigly client records, progress, reflections, and notes

## 1.15.9 — 2026-03-29
### Improved
- **Clients:** Practitioners can now edit client name and email from the client view page
- **Licensing:** Practitioner count auto-syncs with the licensing server when practitioners are added or removed (no manual refresh needed)
### Fixed
- **Journeys:** "Update Milestone" button no longer blocked by hidden unlock_days field validation error

## 1.15.7 — 2026-03-27
### Security
- **Fix:** Input sanitization order corrected on licence error message — `sanitize_text_field()` now applied before `urldecode()`, not after
- **Fix:** JSON data import now validates actual file MIME type (via `finfo`) in addition to extension — prevents non-JSON files being processed
- **Fix:** Email fallback logo now served from plugin's own `assets/images/` folder instead of an external `sprigly.co` URL — removes external dependency and potential email client image blocking

## 1.15.6 — 2026-03-27
### Improved
- **Licensing:** Daily licence revalidation now sends `practitioner_count` (count of users with the `sprigly_practitioner` role) to the licensing server — powers accurate downgrade eligibility checks in the customer portal
- **Team:** Removed redundant `delete_data` hidden field from practitioner removal confirmation form (always-delete flow, field was unused)
- **Team:** Fixed misleading "Their data has been preserved" notice after practitioner removal — now correctly reads "Practitioner removed and all their data permanently deleted"

## 1.15.3 — 2026-03-25
### Improved
- **Email:** Branded email template — shows practitioner's custom logo, brand colour, and portal name in all emails
- **Email:** Default Sprigly logo (white) shown in email header when no custom logo is set
- **Email:** CTA buttons use practitioner's brand colour
- **Email:** "Powered by Sprigly" footer — shown for all users, Pro users can hide via Settings → Branding
- **Email:** Branded team invite email — new practitioners receive a proper Sprigly-branded invite instead of generic WordPress email
- **Fix:** Existing users invited as practitioners now receive an invite email (previously silent)
- **UX:** "Powered by Sprigly" toggle hidden for free users (always shown, only Pro can remove)
- **Portal:** Client portal colour scheme changed from green to navy blue (progress circles, progress bar, buttons, celebration box)
- **Portal:** "Your Input" section heading styled consistently, with clear responses option
- **Portal:** "Save Responses" button styled to match portal design
- **Fix:** Reflections on completed milestones now display correctly after saving
- **Limits:** Free tier adjusted — 5 clients (was 10), 5 milestones per journey (was 10)

## 1.15.2 — 2026-03-24
### Improved
- **UX:** Free tier usage counters on Journeys, Clients, and Milestones — shows "X of Y used" with upgrade prompt at limit
- **UX:** Ownership denied actions now redirect with an error notice instead of showing a white screen (`wp_die`)
- **Security:** Added missing ownership check on journey duplication (Team tier IDOR)

## 1.15.1 — 2026-03-24
### Security (Batch 5)
- **[M4] Fixed:** File upload MIME validation — removed spoofable `$file['type']` check, relies on `wp_check_filetype()` only
- **[L1] Fixed:** `$status_icon` in progress report wrapped in `wp_kses_post()`
- **[L2] Fixed:** `$_POST['field_options']` and `$_POST['field_delete']` now use `wp_unslash()`
- **[L3] Fixed:** `$_GET['action']` sanitised with `sanitize_key()` in script enqueue

## 1.15.0 — 2026-03-24
### New Feature
- **Added:** "Preview Journey" — practitioners can preview how their journey looks in the client portal
  - Button appears on journey edit page (when milestones exist)
  - Shows the full portal experience: milestone tracker, descriptions, videos, resources, custom field labels
  - No client data shown — no reflections, no responses, no progress (this is about previewing what you've built)
  - Token-based access with 1-hour expiry (HMAC-signed, ownership-verified)
  - Navy preview banner: "This is how your clients will see this journey" with "Back to Editor" link
  - No login switching or session manipulation required

### UI
- **Improved:** Team tier setup page redesigned to match Sprigly admin style (was using default WordPress postbox)
- **Changed:** "Welcome to Sprigly Practice" → "Welcome to Sprigly Team"
- **Improved:** IDOR permission denials now show a friendly admin notice with back link instead of "Page not found"
- **Changed:** Settings page restricted to admin only (practitioners no longer see it)
- **Changed:** Practitioners see a clean sidebar — WP Dashboard, Posts, Comments, and Tools menus are hidden
- **Improved:** Client portal response form layout — options stacked vertically, Save button aligned right
- **Fixed:** Rating button numbers now perfectly centred (flexbox instead of line-height)
- **Fixed:** Progress report custom fields showing values without labels (`$field->type` → `$field->field_type`)

## 1.14.4 — 2026-03-24
### Fixes
- **Fixed:** Progress report button broken — `generate_token()` called with missing `$user_id` parameter, causing PHP fatal error that also prevented milestones/reflections/notes from rendering below the progress bar
- **Fixed:** License activation notice said "Pro" regardless of plan type — now says "Sprigly license activated!"

## 1.14.3 — 2026-03-24
### Security
- **Added:** Practitioner ownership verification on all client action handlers (mark complete, add/delete note, update status, save notes) — prevents cross-practitioner data access on Team tier
- **Added:** Journey ownership verification on save, delete milestone, toggle status, reorder, and bulk milestone handlers
- **Added:** Resource ownership verification on save and delete handlers via associated journey check

## 1.14.2 — 2026-03-24
### Security
- **Fixed:** Critical bug in `uninstall.php` — data protection check read wrong option (`sp_client_video` instead of `sp_protect_data`), which could cause data deletion on uninstall even when data protection was enabled

## 1.14.1 — 2026-03-23
### UI
- **Changed:** Admin menu icon from chart dashicon to custom Sprigly leaf SVG

## 1.14.0 — 2026-03-21
### PDF Progress Reports (Pro)
- **Added:** Downloadable progress reports from admin client view
- **Added:** REST endpoint at `/wp-json/sprigly/v1/progress-report/` with token-based auth
- **Added:** Print-optimized HTML with overview, milestones, reflections, field responses
- **Added:** "Include practitioner notes" toggle — optional inclusion of feedback
- **Added:** "Save as PDF" button triggers browser print dialog
- **Added:** `SP_PDF_REPORTS` Pro-gated constant

## 1.13.0 — 2026-03-21
### File Uploads on Reflections (Pro)
- **Added:** Clients can attach files (JPG, PNG, GIF, PDF, DOC, DOCX) to reflections
- **Added:** 10MB max file size with type validation
- **Added:** Files stored in `wp-content/uploads/sprigly/` subfolder
- **Added:** Images show as inline thumbnails, documents as download links
- **Added:** Edit reflection: replace or remove existing attachment
- **Added:** Attachment cleanup on reflection delete
- **Added:** `sp_client_file_upload` toggle in Settings → Portal
- **Added:** Practitioner view shows attachments in client milestone detail
- **Fixed:** Textarea field responses preserve newlines (switched to `sanitize_textarea_field`)
- **Fixed:** Escaped apostrophes in field responses (added `wp_unslash` to handler)

## 1.12.0 — 2026-03-21
### Scheduled Milestone Unlocking (Pro)
- **Added:** Three unlock modes: Immediate (default), Fixed date, Relative (N days after previous)
- **Added:** Admin milestone form: radio buttons for unlock schedule with conditional inputs
- **Added:** Portal: locked milestones show lock icon, greyed out, with unlock reason
- **Added:** Portal: direct URL to locked milestone shows "not yet available" page
- **Added:** `SP_SCHEDULED_UNLOCK` Pro-gated constant
- **Added:** `check_milestone_unlock()` pure logic method in SP_Database

### Form Styling
- **Changed:** Invite Practitioner form restyled — branded card with green header
- **Changed:** Add Client form restyled — matching branded card pattern

## 1.11.0 — 2026-03-21
### Custom Fields on Milestones (Pro)
- **Added:** 7 field types: Text, Textarea, Number, Rating (1-10), Dropdown, Checkbox, Date
- **Added:** Admin field builder on milestone edit (drag to reorder, add/remove fields)
- **Added:** Portal "Your Input" section on milestones with custom fields
- **Added:** Client field response save/update with validation
- **Added:** Practitioner view of field responses in admin client detail
- **Added:** `sp_milestone_fields` and `sp_field_responses` database tables
- **Added:** `SP_CUSTOM_FIELDS` Pro-gated constant

## 1.10.0 — 2026-03-21
### Team Management (Practice/Group License)
- **Added:** `sprigly_practitioner` role with scoped capabilities
- **Added:** Sprigly → Team admin page for managing practitioners
- **Added:** Invite practitioner flow (creates WP user, assigns role)
- **Added:** Practitioner seat tracking tied to license `seat_limit`
- **Added:** Strict client isolation — practitioners only see their own clients
- **Added:** `SP_License::is_practice()` helper for team license detection
- **Added:** `class-sp-admin-team.php` for team management UI
- **Changed:** License settings show "Upgrade to Team" link for Pro users

## 1.5.1 — 2026-03-19
### Pro Licensing System
- **Added:** `class-sp-license.php` — complete license key activation/deactivation system adapted from StrataDesk
- **Added:** REST API communication with sprigly.co licensing server (/activate, /deactivate, /validate)
- **Added:** License section on Settings page — key input, status badge, plan details, activate/deactivate buttons
- **Added:** Daily cron revalidation of stored license keys
- **Added:** `SP_License::is_pro()` static helper used throughout the plugin

### Pro Feature Gates
- **Changed:** Free limits (3 journeys, 10 clients, 10 milestones, 5 resources) now dynamically set — Pro license sets all to 0 (unlimited)
- **Changed:** "Powered by Sprigly" enforced on free tier — Pro users can toggle it off via Settings
- **Added:** License cron cleared on plugin deactivation
- **Added:** License options added to uninstall cleanup

### Sprigly Logo
- **Added:** sprigly-logo.svg to assets/images/

## 1.6.1 — 2026-03-19
### Data Protection
- **Added:** "Protect data on uninstall" toggle in Settings → Tools (default: ON)
- **Changed:** uninstall.php now checks the toggle — when ON, deleting the plugin keeps all data intact
- **Added:** Deactivation warning dialog on Plugins page — warns about data state
- **Added:** Settings link on Plugins page action links

## 1.6.0 — 2026-03-19
### Tools Tab
- **Added:** New "Tools" tab in Settings with: Test Email, SMTP detection, Quick Links, System Info
- **Added:** Send Test Email — sends a branded test email via wp_mail() to confirm delivery
- **Added:** SMTP Plugin Detection — automatically detects WP Mail SMTP, Post SMTP, FluentSMTP, Easy WP SMTP and shows status
- **Added:** Recommended SMTP plugins list with links
- **Added:** System Information panel with copy-to-clipboard for support tickets
- **Added:** Quick Links panel (portal URL, login page, support)

### Portal Tab Enhanced
- **Added:** Prominent portal URL box with Copy button and Preview link
- **Added:** Login page URL shown below portal URL

## 1.5.0 — 2026-03-19
### Guided Setup (Dashboard)
- **Added:** 3-step onboarding checklist when no journeys/clients exist: Create Journey → Add Client → Customise Branding
- **Added:** Each step shows ✅ when completed, with contextual action buttons
- **Added:** Portal URL displayed at the bottom of the setup card

### Security Hardening
- **Added:** Milestone-journey cross-validation on portal complete milestone handler — prevents POSTing a milestone_id from a different journey
- **Added:** Milestone-journey cross-validation on portal add reflection handler
- **Verified:** All portal form handlers already check user ownership (client.user_id === current_user_id)
- **Verified:** All portal view templates check journey status and client status before rendering

### Client Email Toggles
- **Added:** Settings → Emails tab now split into "Practitioner Notifications" and "Client Notifications" sections
- **Added:** 4 new toggles: Journey Assigned, Practitioner Note, Checkpoint Signed Off, Journey Reactivated
- **Added:** All client email methods now check their toggle before sending
- **Added:** New options registered and included in uninstall cleanup

## 1.4.5 — 2026-03-19
- **Fixed:** Admin table headers now left-aligned (were still centered)
- **Added:** Box-shadow on portal .sp-milestone-body and .sp-reflection-entry (borderless blocks now have subtle shadow matching other cards)
- **Changed:** .sp-section-heading (GUIDANCE, MY RESPONSE etc) now black (#000), 600 weight, with green underline

## 1.4.4 — 2026-03-19
### Portal
- **Changed:** Back link now a solid blue (#314562) button with white text and more padding
- **Changed:** Footer "Powered by Sprigly" text set to 12px
- **Removed:** Borders from .sp-milestone-body, .sp-reflection-form, .sp-reflection-entry — cleaner, less boxy
### Admin
- **Changed:** Table cell text now left-aligned (headers remain centered)
- **Changed:** All admin borders standardised to #e2e0da — stat cards, panels, tables, activity items, reflections all consistent

## 1.4.3 — 2026-03-19
### Portal Typography
- **Changed:** All portal fonts now Poppins (loaded via Google Fonts in portal `<head>`)
- **Changed:** Base font size reduced from 16px to 15px across body, shell, and high-specificity reset
- **Changed:** Nav links at 0.9em (matches 15px base)

### Portal Styling
- **Changed:** All card/section borders lightened to #e2e0da
- **Changed:** Toolbar button borders lightened to #d0cec7
- **Changed:** Back link ("← Test Journey") restyled as a pill — white background, border, rounded, hover highlights green
- **Added:** High-specificity override for back link at 0.85em

## 1.4.2 — 2026-03-19
- **Removed:** Up/down reorder arrows and Order column from milestone table — clunky UX, sort_order field in edit form handles edge cases
- **Fixed:** Double pipe separators in journey list actions (Edit | Archive | Duplicate | Delete — clean)
- **Changed:** Milestones section now full-width (removed max-width: 800px)
- **Changed:** Milestone table column widths rebalanced: # 5%, Title 35%, Description 30%, Type 12%, Actions 18%
- **Added:** Dashboard activity items now clickable — click takes you to that client's progress page
- **Added:** Activity items hover with cream background highlight
- **Added:** Activity item padding and border-radius for hover effect

## 1.4.1 — 2026-03-19
### Portal
- **Added:** "Continue: Week 3 →" prominent green button on dashboard — links directly to the next incomplete milestone (not just the journey page)

### Admin — Journeys
- **Added:** Archive/Activate toggle links in journey list actions column — change status without editing
- **Added:** `handle_toggle_journey_status()` handler with nonce protection
- **Added:** "Journey status updated" success notice
- **Changed:** Journey status colour in list updated to forest green (#1a5632)

### Admin — Client Notes
- **Added:** Delete button (×) on practitioner notes — confirmation prompt, nonce-protected
- **Added:** `delete_note()` DB method
- **Added:** `handle_delete_note()` form handler

## 1.4.0 — 2026-03-19
### Milestone Reordering
- **Added:** Up/down arrow links on each milestone in the journey edit page
- **Added:** "Order" column in milestone table showing reorder controls
- **Added:** Nonce-protected GET-based reorder handler that swaps sort_order values

### Bulk Milestone Creation
- **Added:** "Quick Add — Bulk Milestones" form on journey edit page
- **Added:** Configurable prefix (default "Week"), start number, and count (1-52)
- **Added:** Last milestone automatically set as "Final" type
- **Added:** Free limit check — prevents exceeding milestone limit
- **Added:** Success notice showing count of milestones created

### Client Profile Notes
- **Added:** "Client Notes" panel at bottom of admin client view — internal notes about the client (not visible to them)
- **Added:** Uses existing `notes` column in clients DB table
- **Added:** Save handler with nonce protection

## 1.3.6 — 2026-03-19
- **Fixed:** Portal logo not showing — SP_Admin_Settings class was only loaded in admin context, now loaded globally so portal can access branding settings (logo, colours, welcome message, powered-by toggle)
- **Fixed:** "Powered by Sprigly" text — the link inside now inherits the paragraph font-size so both words match
- **Fixed:** Toolbar button borders hardened with `border: 1px solid #9ca3af` at high specificity to beat Divi overrides
- **Added:** Logo img tag given high-specificity display rules to prevent theme interference

## 1.3.5 — 2026-03-19
- **Fixed:** Rich text editor toolbar buttons now have a visible darker border (#9ca3af)
- **Fixed:** Admin table cells vertically center aligned (was top-aligned)

## 1.3.4 — 2026-03-19
- **Fixed:** Portal font size inflation from Divi — added high-specificity typography reset using `body.sprigly-portal .sp-portal-shell` selectors (0-2-2 specificity) that beat Divi's `body:not(.et-fb) p` (0-1-2)
- **Fixed:** All smaller portal text elements (card descriptions, badges, dates, nav, footer) given explicit em sizes at the same high specificity so they're not flattened to 16px
- **Fixed:** Toolbar buttons hardened to #1f2937 text colour (was still getting overridden)
- **Changed:** Admin activity/progress font sizes bumped to 14px
- **Changed:** Admin panel titles bumped to 14px

## 1.3.3 — 2026-03-19
### Portal Fixes
- **Fixed:** Rich text editor toolbar buttons — transparent background with dark text (was white-on-white)
- **Fixed:** Editor focus shadow colour updated from indigo to forest green
- **Fixed:** Added font-size 16px reset on portal shell to prevent theme inflation
### Admin Fixes
- **Changed:** Top action buttons (Add Client, Export CSV, Add New) now blue (#314562) background with white text
- **Changed:** Settings tabs now have white background
- **Changed:** All table headers: black text, 13px, uppercase, 600 weight, center aligned
- **Changed:** All table cells: 14px, center aligned
- **Changed:** All admin links: #314562 colour
- **Changed:** Dashboard stat numbers slightly larger (2.5em)
- **Changed:** Client name in list is now clickable — links to their progress page
- **Fixed:** Button text stays white even with admin link colour override

## 1.3.2 — 2026-03-19
- **Fixed:** Added !important to all portal CSS declarations to prevent WordPress themes/child themes from overriding portal styles
- **Note:** :root CSS variables and @keyframes left untouched (these don't need !important)
- **Note:** Inline styles in portal PHP already have higher specificity and were unaffected

## 1.3.1 — 2026-03-19
### Styling Fixes
- **Fixed:** Portal heading colours — removed yellow/gold from milestone titles, completed banners, and section headings. All now use proper dark green or dark text
- **Fixed:** Admin buttons — text now correctly shows white on green background (added !important overrides for WP core styles)
- **Fixed:** Admin cream background now extends to bottom of page (min-height: 100vh)
- **Fixed:** Admin pages max-width capped at 1500px for widescreen displays
- **Fixed:** Admin h1 headings now bolder (700 weight), h2 at 600 weight
- **Fixed:** Duplicate style attribute on progress overview card
- **Changed:** Admin milestone cards now contain reflections, notes, and "+ Add a note" form INSIDE the card border — everything grouped together as one unit
- **Changed:** Client reflections and practitioner notes shown with coloured left borders inside the card on a cream background strip
- **Added:** "Next: Week 3 →" prompt on portal dashboard journey cards

## 1.3.0 — 2026-03-19
### Technical Fixes
- **Security:** Portal now filters out draft/archived journeys — clients only see active journeys
- **Security:** Admin bar hidden for sprigly_client role users
- **Security:** Free version limits enforced: 3 journeys, 10 clients, 10 milestones per journey, 5 resources per journey
- **Cleanup:** Uninstall.php updated to remove notes table and all settings options

### Practitioner Notes (NEW)
- **Added:** sp_notes database table (client_id, milestone_id, practitioner_id, content)
- **Added:** "+ Add a note" collapsible form under each milestone in admin client view
- **Added:** Notes display under milestones with green left border (distinct from client reflections with blue border)
- **Added:** "CLIENT" and "YOUR NOTE" labels to distinguish reflections from notes
- **Added:** "Practitioner Feedback" section in portal milestone detail — clients can see notes left for them
- **Added:** DB methods: insert_note(), get_notes(), get_note()

### Client Email Notifications (NEW)
- **Added:** Journey assigned — client receives welcome email with portal link when assigned to a journey
- **Added:** Practitioner note — client notified when practitioner leaves feedback, includes note preview
- **Added:** Checkpoint signed off — client notified when practitioner approves a checkpoint milestone
- **Added:** Journey reactivated — client notified when a paused/archived journey is made active again
- **Changed:** Email header bar updated to forest green (#1a5632)
- **Changed:** Email footer updated to warm cream background

### Journey Status Management (NEW)
- **Added:** Status bar on admin client view showing current journey status (Active/Paused/Archived)
- **Added:** "Pause" button — temporarily hides journey from client portal
- **Added:** "Archive" button — removes journey from client view (with confirmation)
- **Added:** "Reactivate" button — restores paused/archived journeys (sends client notification)
- **Added:** DB method: update_client_status()

### Free Version Limits
- **Added:** Constants: SP_LIMIT_JOURNEYS (3), SP_LIMIT_CLIENTS (10), SP_LIMIT_MILESTONES (10), SP_LIMIT_RESOURCES (5)
- **Added:** Limit checks on journey, client, milestone, and resource creation
- **Added:** User-friendly error messages with "Upgrade to Pro" prompt when limits hit

## 1.2.1 — 2026-03-19
### Journey Duplication
- **Added:** "Duplicate" link on each journey in the admin list
- **Added:** Copies journey (as Draft), all milestones, and journey-level resources
- **Added:** Nonce-protected with redirect to new copy's edit page

### Branded Login Page
- **Added:** class-sp-login.php — WordPress login page customiser
- **Added:** Cream background, warm borders, rounded 14px login box matching portal
- **Added:** Practitioner logo from Settings shown on login (or site name as text)
- **Added:** Submit button uses brand colour from Settings
- **Added:** Focus states use brand colour
- **Added:** Logo links to /portal/ instead of wordpress.org
- **Added:** "Powered by Sprigly" footer (respects Settings toggle)

### Client Login Redirect
- **Added:** Users with sprigly_client role redirect to /portal/ after login instead of wp-admin

## 1.2.0 — 2026-03-19
### New Colour Scheme — Forest Green + SD Blue
- **Changed:** Portal primary colour to forest green (#1a5632) with brand green (#2D8659) as gradient highlight
- **Changed:** SD blue (#314562) as accent colour — used for current milestone step, secondary buttons, links
- **Changed:** Completion states use forest green instead of generic green
- **Changed:** Current milestone pulse animation updated to SD blue
- **Changed:** All notices, badges, and celebration banners updated to new palette
- **Changed:** Default brand colour in settings updated

### Admin Panel Makeover
- **Added:** Cream background (#f7f6f3) on all Sprigly admin pages
- **Added:** Completely rebuilt admin.css — warm borders, rounded corners, card-style panels, proper shadows
- **Changed:** Dashboard uses CSS classes (sp-admin-stat, sp-admin-panel, sp-activity-item, sp-progress-item)
- **Changed:** Client view — progress bars, milestone cards, mark-complete buttons all use new palette
- **Changed:** Reflections use styled sp-admin-reflection class with SD blue left border
- **Changed:** Tables — warmer row colours, rounded corners, uppercase header labels
- **Changed:** Buttons — forest green primary, SD blue accents, rounded 8px
- **Changed:** Settings tabs — warm styling with green active state
- **Changed:** All admin pages wrapped in sprigly-admin-wrap for scoped styling

## 1.1.2 — 2026-03-19
### Client List Improvements
- **Added:** Search bar — filter clients by name or email
- **Added:** Journey dropdown filter
- **Added:** Status dropdown filter (Active, Completed, Paused, Archived)
- **Added:** Client count display
- **Added:** "Clear" button to reset all filters

### CSV Export
- **Added:** "Export CSV" button on client list page
- **Added:** Exports: client name, email, journey, milestones completed/total, progress %, status, start/complete dates
- **Added:** Nonce-protected export URL

### Bug Fix
- **Fixed:** Client view now accepts both `id` and `client_id` URL params (mark-complete redirect was using `client_id` but router expected `id`)

## 1.1.1 — 2026-03-19
- **Changed:** Portal background to warm #f7f6f3 (matching StrataDesk)
- **Changed:** Border colours warmed up across all elements (#e2e0da, #d0cec7)
- **Changed:** Border radius bumped from 8px → 10px on boxes, 12px → 14px on cards (friendlier feel)
- **Changed:** "My Reflections" button now solid blue (#2563eb) with white text, darker on hover
- **Changed:** Softer shadows throughout

## 1.1.0 — 2026-03-19
### Admin Dashboard
- **Rebuilt:** Dashboard now shows 4 stat cards — Active Journeys, Active Clients, Milestones Completed, Reflections
- **Added:** Activity feed — real-time stream of milestone completions and reflections with time-ago stamps
- **Added:** Client progress panel — all active clients with progress bars, clickable through to their detail page
- **Added:** DB methods: `get_recent_activity()`, `get_total_completions()`, `get_total_reflections()`

### Practitioner Mark Complete
- **Added:** "Mark Complete" button on each pending milestone in admin client view
- **Added:** "Sign Off" label for checkpoint-type milestones
- **Added:** Confirm dialog before marking complete
- **Added:** "(by you)" label on milestones completed by practitioner
- **Added:** Handles final milestone completion (marks journey as completed)

### Portal Polish
- **Added:** Journey completion celebration banner — 🎉 icon, congratulations message, shown when all milestones are done
- **Added:** Green progress bar when journey reaches 100% (dashboard cards and journey page)
- **Added:** "Reflection saved" notice on journey page after submitting a response
- **Changed:** Notices now cover more portal actions (completion, checkpoint, reflection saved)

## 1.0.9 — 2026-03-19
- **Changed:** Admin client view — reflections now appear as indented items below their milestone (28px left margin, blue left border) instead of inside the milestone card
- **Changed:** Clearer visual hierarchy — milestone cards stand alone, reflections sit beneath with distinct styling

## 1.0.8 — 2026-03-19
- **Changed:** Admin client view — reflections now appear directly under their milestone instead of a separate section
- **Changed:** Each milestone card expands to show its responses with count and date

## 1.0.7 — 2026-03-19
- **Added:** Settings page with three tabs: Branding, Portal, Emails
- **Branding tab:** Custom portal name, logo upload (Media Library), primary colour picker, "Powered by Sprigly" toggle
- **Portal tab:** Shows portal URL, custom welcome message for client dashboard
- **Emails tab:** Toggle each notification type on/off (milestone completed, reflection submitted, journey completed)
- **Changed:** Portal header now respects custom name/logo from settings
- **Changed:** Portal primary colour (buttons, progress bars, accents) now driven by settings
- **Changed:** "Powered by Sprigly" footer now respects toggle setting
- **Changed:** Email notifications now check toggle before sending

## 1.0.6 — 2026-03-19
- **Added:** Rich text editor on reflection/response forms — bold, italic, underline, bullet lists
- **Changed:** Larger text area (150px min-height) for more comfortable writing
- **Added:** Milestone name badge shown on each reflection in admin client view
- **Added:** Shows up to 10 recent reflections in admin (was 5)
- **Added:** Reflections now render HTML formatting in admin view (bold, lists etc)

## 1.0.5 — 2026-03-19
- **Changed:** Milestone detail page completely rebuilt — reflections are now inline within each milestone
- **Changed:** Page flow is now: Guidance → Resources → My Response → Mark Complete (logical order)
- **Added:** "My Response" section on each milestone — client can write thoughts/reflections specific to that milestone
- **Added:** Previous responses for the milestone shown below the form
- **Added:** Section headings (Guidance, Resources, My Response) for clear visual structure
- **Added:** Database method `get_milestone_reflections()` for milestone-specific reflection queries
- **Changed:** Reflection form redirects back to milestone page (with #my-response anchor) instead of separate reflections page
- **Changed:** Separate "My Reflections" page remains as a journal overview across all milestones

## 1.0.4 — 2026-03-19
- **Fixed:** Resource form — selecting a journey no longer reloads the page and loses form data
- **Fixed:** Milestone dropdown now populates instantly via embedded JSON data (no page reload)

## 1.0.3 — 2026-03-19
- **Added:** Email notification system (`class-sp-email.php`)
- **Added:** Practitioner notified when client completes a milestone (with progress bar in email)
- **Added:** Practitioner notified when client submits a reflection (preview in email)
- **Added:** Practitioner notified when client completes an entire journey (celebration email)
- **Added:** HTML email templates with branded header, progress bars, CTA buttons
- **Added:** Email filter hooks (`sprigly_email_to`, `sprigly_email_subject`, `sprigly_email_body`) for future customisation

## 1.0.2 — 2026-03-19
- **Added:** Resource management admin — add/edit/delete resources (links, files, PDFs, videos)
- **Added:** WordPress Media Library integration for file uploads on resources
- **Added:** Resources attached to journeys (journey-wide) or specific milestones
- **Added:** Resources display on milestone detail page in portal
- **Added:** Database methods: get_resource, update_resource, delete_resource, get_all_resources
- **Fixed:** Null handling in resource insert for optional milestone_id and attachment_id

## 1.0.1 — 2026-03-19
- **Fixed:** Portal pages returning 404 — added parse_request interceptor (StrataDesk-proven pattern) to reliably route portal URLs regardless of rewrite rule state
- **Fixed:** Auto-flush rewrite rules on version change so manual permalink save is never needed
- **Added:** Client management admin — list clients, add new (create WP user or assign existing), view client progress
- **Added:** Front-end client portal at /portal/ with:
  - Dashboard showing assigned journeys with progress cards
  - Journey view with visual milestone progress tracker (pulsing current step animation)
  - Milestone detail page with description, resources, and mark-complete button
  - Reflections page with journal entries
- **Added:** Portal CSS with responsive design, CSS variables for theming
- **Added:** CHANGELOG.md for version tracking

## 1.0.0 — 2026-03-19
- Initial foundation build
- Plugin bootstrap with singleton pattern
- Database schema (6 custom tables via dbDelta)
- User roles: Practitioner + Client with granular capabilities
- Admin menu structure (Dashboard, Journeys, Clients, Resources, Settings)
- Journey CRUD with inline milestone management
- Clean uninstall routine
- Directory listing protection (index.php in all directories)

## 1.5.2 — 2026-03-19
- Reordered client list table columns: Client → Journey → Progress → Started → Status → Actions
