Skip to content

Sync master from upstream#87

Merged
vanshk141999 merged 153 commits into
masterfrom
sync/master-20260601-v2
Jun 1, 2026
Merged

Sync master from upstream#87
vanshk141999 merged 153 commits into
masterfrom
sync/master-20260601-v2

Conversation

@vanshk141999

Copy link
Copy Markdown
Collaborator

Syncs brainstormforce/sureforms@master into the public mirror.

  • Diff is computed against public/master (this branch is capped with a merge commit whose first parent is public/master), so no internal-only files appear in the diff — only real upstream content changes.
  • Internal-only paths remain stripped from the tree (.claude/, internal-docs/, CLAUDE.md, the analysis docs, internal release workflows, and bin/build-zip.sh / checkout-and-build / i18n.sh).
  • All commits carry verified signatures.

Highlights

fd78afd Merge pull request #2808 from brainstormforce/next-release
6a634c5 Merge pull request #2807 from brainstormforce/version-bump-2.10.1
8cb1d9e Version Bump 2.10.1
f35ec6f Merge pull request #2806 from brainstormforce/sync-dev
9ea2e1d Merge pull request #2798 from brainstormforce/fix/entry-id-smart-tag-not-resolving-in-emails
fa95594 Merge pull request #2802 from brainstormforce/fix/textarea-char-counter-layout
4f5b7cc Merge pull request #2800 from brainstormforce/sync/dev-into-next-release-2026-05-29
3179394 Merge pull request #2799 from brainstormforce/fix/entry-id-smart-tag-not-resolving-in-emails-sync
53b193f Merge pull request #2791 from brainstormforce/fix/iframe-ready-race-master
a1596e4 fix(editor): keep iframe canvas when third-party blocks force legacy apiVersion
87fd952 Merge pull request #2787 from brainstormforce/chore/merge-master-2.10.0-into-dev
e823c3c Version 2.10.0 (#2779)
eeb8d5e Merge pull request #2784 from brainstormforce/i18n/next-release
f6c8994 Merge pull request #2782 from brainstormforce/fix/2.10.0-review-followups
e41089c Merge pull request #2783 from brainstormforce/git-duplicate-import-meta-slash
b9be8d8 Merge pull request #2781 from brainstormforce/update-changelog-2.10.0
adbec8c Merge pull request #2778 from brainstormforce/version-bump-2.10.0
adcad16 Version Bump 2.10.0
85ecd62 Merge pull request #2755 from brainstormforce/feat/dynamic-default-value-multi-select
ea7b284 Merge pull request #2772 from brainstormforce/feat/textarea-char-counter
a6a0fe4 Merge pull request #2749 from brainstormforce/feat/html-form-converter
078e4b1 Merge pull request #2765 from brainstormforce/fix/2757-capture-submission-url
55930e7 Merge pull request #2762 from brainstormforce/fix/csv-export-textarea-line-breaks
d698ed6 Merge pull request #2764 from brainstormforce/sync/master-to-next-release
5cf78d8 Merge pull request #2763 from brainstormforce/sync/master-to-dev

vanshk141999 and others added 30 commits April 14, 2026 06:59
…uired

The confirm email block lacked the `srfm-block` class, so existing CSS
error rules (red border, error message display) never applied when
`srfm-error` was toggled. Additionally, the error message element was
not rendered when the field was not required, causing a JS TypeError
that silently blocked submission.

- Add `srfm-block` class to confirm email block for CSS error styles
- Always render error message element via override when confirm is enabled
- Add null check on confirmError before setting textContent

Fixes #2230
…g srfm-block class

Adding srfm-block to the confirm block would break closest('.srfm-block')
lookups in real-time validation and other JS code. Instead, add dedicated
CSS rules for .srfm-email-confirm-block.srfm-error to show red border
and error message — matching the existing .srfm-block.srfm-error pattern
without introducing nested .srfm-block issues.

Also adds changelog entry in readme.txt.
Every layer of the AI Form Builder had at least one silent-failure
path: the React catch only console.log'd, the PHP validator shipped
the raw response as the error payload, the JSON-decode checks treated
falsy JSON as "valid", Field_Mapping swallowed failures by returning
an empty string, and wp_insert_post errors were never checked. Any
one of these left the user on a frozen progress screen at 50/75/100%
with no error popup.

Frontend
- AiFormBuilder: split both apiFetch calls into their own try/catch,
  reset isBuildingForm + percentBuild on any failure, and replace the
  boolean error flag with a message-carrying string so every path
  surfaces a specific reason to the user.
- ErrorPopup: accept errorMessage + onRetry props; default retry no
  longer reloads the page when triggered by the AI flow, preserving
  the user's prompt.
- Helpers.handleAddNewPost: add an onError callback so permission /
  response.id / catch failures bubble up instead of only logging.

Backend
- ai-form-builder.php: fix the empty() parentheses bug, split the
  monolithic form validator into three guards with specific messages,
  and stop shipping the raw middleware response as the error payload.
- ai-helper.php: replace the falsy json_decode check with explicit
  json_last_error / is_array validation, share the new
  decode_json_response helper between both middleware calls, and log
  malformed responses (endpoint, HTTP status, truncated body) behind
  WP_DEBUG.
- field-mapping.php: fix the is_array($form) typo (was meant to guard
  $form_fields) and return typed WP_Error instead of empty strings,
  so the REST layer serialises a real message for the frontend.
- create-new-form.php: pass true to wp_insert_post so WP_Error is
  returned on failure, and respond with a 500 carrying the real
  message instead of treating WP_Error objects as success.
- abilities/forms/create-form.php and update-form.php: propagate the
  new Field_Mapping WP_Error contract.

Tests
- Update test_generate_gutenberg_fields_empty_request to match the
  new WP_Error contract.

Verified: npm run lint-js, npm run build:script, composer lint,
composer phpstan level 9 all clean. composer test requires local WP
test env (install-wp-tests.sh) to run.

Refs: FreeScout ticket #1389163
- field-mapping.php: switch two isset() ternaries to `??` null coalescing
  (PHP Insights "Ternary to null coalescing").
- ai-helper.php: move decode_json_response + log_ai_response_failure below
  the final public static method so protected visibility groups correctly
  (PHP Insights "Ordered class elements").
- test-ai-helper.php: add coverage for decode_json_response (valid,
  empty, invalid JSON, non-array JSON, custom fallback),
  log_ai_response_failure (WP_DEBUG noop + non-string body), and the
  two middleware callers (get_chat_completions_response,
  get_usage_response) using pre_http_request mocks.
- test-create-new-form.php: add test_create_form covering the new
  WP_REST_Response(500) error path when wp_insert_post fails.
Addresses all 9 items in @rahulvarma722's review.

#1 (CRITICAL) — revert hardcoded foreign URL
ai-helper.php::get_user_token() was returning the literal
'http://www.republiquedesmangues.fr/' as the fallback X-Token for
non-licensed installs. dev had site_url(); the URL slipped in as debug
code. Restored site_url() so middleware token verification works and
no third-party domain leaks via X-Token.

#2 — tighten AI debug logging
log_ai_response_failure() now also gates on WP_DEBUG_LOG (so the body
no longer falls back to the host's PHP error log on sites that run
WP_DEBUG=true with WP_DEBUG_LOG=false), collapses whitespace before
formatting (prevents log injection via crafted CR/LF in response
bodies), and redacts the values of known sensitive JSON keys (email,
token, license_key, prompt, query) before writing.

#3 — sanitize upstream error messages before returning to client
Added AI_Helper::sanitize_ai_error_message() that strips URLs,
OpenAI-shape opaque IDs (org-/user-/key-/sess-/req-/file-/chatcmpl-/
asst-/run-/thread-), 'request id: …' trailers, sk-* / Bearer * tokens,
and 'gpt-*' model identifiers. The raw message is still preserved
server-side via log_ai_response_failure() (gated on WP_DEBUG[_LOG]).
Wired into ai-form-builder.php (response['error'] surfacing) and
create-new-form.php (wp_insert_post WP_Error surfacing).

#4 — drop dead invalid_json branch in AiFormBuilder.js
decode_json_response() returns ['error' => …], never
['code' => 'invalid_json'], so the frontend invalid_json check was
unreachable. The error already routes through the success===false /
empty-response branches.

#5 — drop unreachable inner ErrorPopup
handleFormCreationError() sets isBuildingForm=false in the same batch
as setFormCreationErr, so the early-return inside the
isBuildingForm block never coincides with a non-empty
formCreationErr. Only the outer popup is reachable; inner one removed.

#6 — ErrorPopup accessibility
Wrapped the popup in role='alertdialog' with aria-modal, aria-labelledby,
aria-describedby, and tabIndex=-1. Focus moves into the dialog on mount
(primary action button if available, else the wrapper). Escape now
dismisses by invoking the retry/close handler.

#7 — drop dead is_string checks post-narrowing
After is_wp_error early-return, generate_gutenberg_fields_from_questions
narrows to string. Removed !is_string in create-form.php and is_string
in update-form.php; kept the empty() guards which still serve a purpose.

#8 — defense-in-depth fieldOptions sanitization
Added Field_Mapping::sanitize_field_options() that runs label / value /
optionTitle through sanitize_text_field before the options array is
merged into Gutenberg block markup. Capability-gated trusted middleware
already, but cheap belt-and-suspenders if upstream ever returns
reflected user content.

#9 — test coverage on the new WP_Error contract
Added 5 cases to test-field-mapping.php covering invalid_form_data,
missing_form, missing_form_fields, invalid_field, plus a generic
status=400 assertion. Locks in the contract introduced by this PR.
…coverage

CI failures from the previous commit:

1. PHP Insights — code quality / style scores below the project's
   --min-quality=100 / --min-style=100 thresholds. Two findings:

   a) Ordered class elements: sanitize_ai_error_message (public static)
      was inserted after the protected static helpers, violating the
      public-before-protected ordering rule. Moved the method up to sit
      with the other public statics, immediately after
      is_pro_license_active().

   b) Return assignment: the trailing
        $cleaned = trim( $cleaned, … );
        return $cleaned;
      pair was flagged. Collapsed to a direct
        return trim( $cleaned, … );

2. check-test-coverage — sanitize_ai_error_message() had no matching
   test_sanitize_ai_error_message() in test-ai-helper.php. Added 5 cases:
   non-string / empty input → empty, clean pass-through, the full
   strip-pattern matrix (URL / OpenAI org-/user-/req_ IDs / request-id
   trailers / sk- keys / Bearer tokens / gpt model names), all-infra
   inputs collapse to empty, no-endpoint argument path.

Side fix while writing the strip tests: broadened the OpenAI-ID regex
to accept either '-' or '_' as the separator (real OpenAI request IDs
use req_…), and tightened the request-id trailer pattern to require a
separator and a 4+ char id so it cannot eat surrounding plain words.
…-non-required-validation

# Conflicts:
#	readme.txt
The display:block rule for .srfm-email-confirm-block.srfm-error
.srfm-error-message had specificity (0,4,0), tying with the base
hide rule .srfm-form .srfm-block .srfm-error-message { display:none }
in frontend/form.scss. Because common.css enqueues before frontend/
form.css (per inc/frontend-assets.php css_assets order), the hide
rule won and the error message stayed invisible on the frontend even
when the JS correctly added the .srfm-error class to the confirm block.

Wrap the entire confirm-email block under a .srfm-block ancestor (the
outer email wrapper carries that class via Base::get_field_classes())
so the show rule compiles to specificity (0,5,0) and consistently wins
regardless of file load order.

Fixes the two unchecked test plan items in PR review #2571:
- Non-required email + filled main + empty confirm now shows the
  'Confirmation email does not match' mismatch error with red border
- Required email + filled main + empty confirm now shows the
  'This field is required' message on the confirm field

The JS validation logic in assets/js/unminified/validation.js was
already correct — both branches (the !confirmValue && confirmError &&
isRequired === 'true' required-empty branch and the
confirmValue !== inputValue mismatch branch) fire as intended; the
visible regression was purely a CSS cascade bug.
…ent-failures-2452

Fix AI Form Builder silent failures and generic error messages
…non-required-validation

Fix: Confirm email field blocks submission without error when not required
…schema

The entry parser returns user as null for anonymous submissions, but the
output schema declared user as type "object" only. The MCP output
validator rejected null, causing every anonymous-submission entry fetch
to fail with "output[user] is not of type object". This blocked any
analytics dashboard that needs per-entry field values across all
submissions.

Schema now accepts both object (authenticated) and null (anonymous),
matching what entry-parser.php has always returned.
…740e

fix(abilities): allow null user in get-entry output schema
* chore: remove unused @surecart/components-react dependency

The package was never imported anywhere in the codebase. Removing it
eliminates 24 npm audit vulnerabilities (101 → 77) and resolves the
React 17/18 peer dependency conflict that blocked npm audit fix.

Added @babel/plugin-syntax-dynamic-import as an explicit devDependency
since the build relied on it being installed transitively via surecart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: patch dompurify and react-router-dom production vulnerabilities

- dompurify 3.3.0 → 3.3.3 (fixes XSS in 3.1.3–3.3.1, GHSA-v2wj-7wpq-c8vv)
- react-router-dom 6.30.0 → 6.30.3 (fixes @remix-run/router XSS via open redirect, GHSA-2w69-qvjg-hvjx)

Both are semver-compatible patch bumps within existing ^ranges in package.json.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate lock file without --legacy-peer-deps

The peer dep conflicts were caused by @surecart/components-react (removed
in prior commit). Regenerating the lock file without --legacy-peer-deps
resolves 17 previously-skipped peer deps and eliminates the need for
the flag in npm ci and npm install going forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: upgrade Volta Node pin from 18.15.0 to 20.20.1

Node 20.x LTS is fully compatible — npm ci, build, and lint all pass
with no lock file changes. This also resolves engine warnings from
mute-stream (>=18.17.0) and lighthouse (>=18.20).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update CI workflows to Node 20.20.1

Update node-version in playwright, release-tag-draft, and
update-translations workflows to match the Volta pin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: enable webpack filesystem cache for faster rebuilds

Repeat builds drop from ~26s to ~1.6s for webpack (34s to 6s total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update caniuse-lite browserslist database

Silences "browsers data is 10 months old" warning during build and lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: silence build warnings and deprecation notices

- Webpack: suppress performance hints (expected for WP plugin bundles)
- Webpack: silence Sass deprecation warnings (legacy-js-api, import, global-builtin)
- Grunt: silence Sass deprecations via SASS_SILENCE_DEPRECATION env var
- Grunt: fix autoprefixer browsers → overrideBrowserslist deprecation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — bump dompurify, update CI Node versions

- Bump dompurify ^3.3.0 → ^3.3.3 in package.json (pin above vulnerable range)
- Update code-analysis.yml setup-node@v2 → @v4
- Update release-tag-draft.yml setup-node@v2 → @v4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vansh Kapoor <kvansh297@gmail.com>
…60) (#2664)

* fix: address WP 7.0/7.1 deprecation warnings and React 18 compat

Migrates admin screen mounts to createRoot (React 18 API) and opts
in to the new @wordpress/components defaults so SureForms stops
emitting deprecation warnings in the admin console.

- Replace legacy `render()` from @wordpress/element with `createRoot`
  in Settings, PageHeader (shared across admin), TemplatePicker, and
  the ProPanel mount inside Editor.js (MutationObserver-driven).
- Add `__next40pxDefaultSize` to TextControl, SelectControl, RangeControl,
  and NumberControl across wrappers and raw usages (WP 6.8 → removed 7.1).
- Add `__nextHasNoMarginBottom` to ToggleControl, TextControl, and
  SelectControl usages (WP 6.7 → removed 7.0).

Scope covers `src/` and `modules/gutenberg/src/`. Wrappers
(SRFMTextControl, SRFMSelectControl, Range, SRFMNumberControl) are
updated once to propagate the props to every downstream consumer.

Refs brainstormforce/sureforms#2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trim out-of-scope deprecation fixes

Drop changes that go beyond what #2660 explicitly asks for:

- Remove __nextHasNoMarginBottom additions on ToggleControl /
  TextControl / SelectControl. That's a separate WP 6.7 → 7.0
  deprecation track, not the 36px / React 18 issues called out
  in #2660. Can be addressed in a follow-up PR.

Keeps everything the issue requires:
- React 18 createRoot migration on admin screens
- __next40pxDefaultSize on TextControl / SelectControl /
  RangeControl / NumberControl

Refs #2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Feat: add "Both" payment type option in payment block editor (Part 1 of #2121)

Adds a third "Both" option to the Payment Type toggle in the payment
block editor, allowing admins to configure rules for both one-time and
subscription payments in a single block. Introduces separate amount
settings per payment type so pricing can differ (e.g., $99 one-time vs
$9.99/month).

This is Part 1 of two — editor-side only. Frontend rendering, JS flow,
and backend AJAX handling will follow in Part 2. Existing "One Time"
and "Subscription" behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: multi-select Billing Interval & Stop Subscription After

Converts the subscription plan's Billing Interval and Stop Subscription
After fields from single-select dropdowns to multi-select checkbox
groups so admins can configure the allowed options. End user will pick
one from the allowed set on the frontend (Part 2).

- New BillingIntervalControl: 5 checkboxes (Daily/Weekly/Monthly/Quarterly/Yearly)
  writes to subscriptionPlan.intervals (array)
- Rewritten BillingCyclesControl: checkboxes for presets (2, 3, 4, 5, Never)
  plus comma-separated custom payment counts input, writes to
  subscriptionPlan.billingCyclesOptions (array)
- Backward compatibility: legacy string `interval` / scalar `billingCycles`
  auto-migrate to single-item arrays on first render; legacy fields are
  kept in sync with first selected value so the current frontend
  renderer continues working until Part 2 wires up multi-select output
- Applies to both paymentType === "subscription" and paymentType === "both"
  modes for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: use react-select multi-select for Billing Interval & Stop Subscription After

Aligns the two subscription multi-select controls with the pattern used
by the existing "Payment Methods" multi-select in sureforms-pro so the
UI is visually consistent across payment block settings.

- Replaces checkbox groups with react-select Select (isMulti)
- Uses classNamePrefix="srfm-select" and the same label/help markup as
  the Payment Methods control
- BillingCyclesControl: drops the separate custom-numbers text input in
  favor of a cleaner preset-only dropdown (2, 3, 4, 5, 6, 12, 24, Never).
  Arbitrary custom payment counts can be added later if required.
- Backward compatibility logic preserved: legacy `interval` / scalar
  `billingCycles` auto-migrate; legacy fields kept in sync with first
  selected array value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: keep "subscription" mode single-select; isolate multi-select to "both" mode

Reverts the shared BillingCyclesControl to its original single-select
behavior and introduces two dedicated multi-select components used ONLY
in paymentType === "both" mode. This guarantees zero behavioral change
for existing subscription-mode forms.

- billing-cycles-control.js: restored to original SelectControl + custom
  numeric input (powers paymentType === "subscription" unchanged)
- billing-interval-multi-control.js: renamed from billing-interval-control.js;
  only used in paymentType === "both" mode
- billing-cycles-multi-control.js: new dedicated multi-select for
  paymentType === "both" mode (presets: 2, 3, 4, 5, 6, 12, 24, Never)
- edit.js: subscription block uses original SelectControl for interval
  and original BillingCyclesControl; both block uses the two new
  MultiControl components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: frontend "both" payment mode — chooser + sub-choosers + styling

Part 2 of #2121. Renders the end-user payment-type chooser, dual
amount displays, Stripe element reinit on type switch, and (when the
admin allowed >1 option) radio-pill sub-choosers for Billing Interval
and Stop Subscription After.

- payment-markup.php: emits both-mode data attrs, renders chooser +
  sub-choosers via shared render_radio_pill helper that mirrors the
  multi-choice block (circle-checked / circle-unchecked SVGs,
  .srfm-block-content-wrap). Generates a pre-translated format map
  so JS can rebuild the visible amount text on sub-chooser change.
- stripe-payment.js: initPaymentTypeChoosers wires top-level +
  sub-chooser radios. switchActivePaymentType syncs all live
  data-* attrs and calls reinitForBlock to re-mount the Stripe
  Element in the new mode. switchSubscriptionInterval /
  switchSubscriptionBillingCycles update attrs and rebuild the
  amount text without re-mounting Stripe. Dispatches
  srfm_payment_type_changed and srfm_subscription_plan_changed
  events for gateway-agnostic listeners.
- payment-manager.js: re-broadcasts payment-method-changed when the
  payment type flips so accordion gateways re-render.
- front-end.php: add 'quarter' to the valid-intervals allow-list;
  was a pre-existing gap that would have rejected Quarterly
  subscriptions at submit time.
- SCSS: new components/payment-type-chooser.scss reuses the
  multi-choice mixins so the pills inherit the same border,
  padding, font-size, circle radio icons, primary-tinted checked
  state, focus ring, and mobile stacking as the rest of the form.

All new code is flagged with // BOTH MODE: start / end markers to
keep the follow-up rollout / rollback easy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: resolve PHPCS + PHPStan lint issues in payment-markup.php

- Add full-stops to inline comments (Squiz.Commenting.InlineComment).
- Replace single-line phpcs:ignore with phpcs:disable/enable blocks
  for multi-line echo calls (WordPress.Security.EscapeOutput).
- Fix equals sign alignment (Generic.Formatting.MultipleStatementAlignment).
- Change @var from strict array shape to array<mixed> so empty default
  is accepted by PHPStan level 9.

No functional changes — comments, whitespace, and annotations only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: server-side amount validation for "both" payment mode

In "both" mode one-time and subscription paths can have different
amount types and values (e.g. fixed $99 vs variable min $5). The
server-side validation was reading the legacy scalar config which
only holds the default choice's values — causing the non-default
path to fail with "Payment amount must be exactly $X".

- field-validation.php: process_payment_block() now stores per-type
  configs (one_time_*, subscription_*) when paymentType === 'both'.
- payment-helper.php: new resolve_payment_config_for_active_type()
  helper remaps per-type keys into the scalar positions the existing
  validation functions expect. validate_payment_amount() and
  validate_payment_intent_amount() accept $active_type param.
- front-end.php: all 4 Stripe callers pass 'one-time' or
  'subscription' to the validation functions.

Backward compatible — $active_type defaults to '' which returns
the original config unchanged for legacy (non-both) forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: make entire payment chooser pill clickable

Changed render_radio_pill outer wrapper from <div> to <label> so
clicking anywhere on the card (icon, padding, whitespace) checks
the radio — not just the text label. Inner <label> changed to
<span> to avoid invalid nested labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: simplify "both" mode to single-select for interval/cycles

Replace multi-select Billing Interval and Stop Subscription After
controls with the same single-select dropdowns used in subscription
mode. Admin picks one interval and one billing-cycle option; end
user sees only the top-level one-time/subscription chooser with no
sub-chooser pills.

Removed (-616 lines):
- billing-interval-multi-control.js and billing-cycles-multi-control.js
- Frontend sub-chooser rendering (render_subscription_sub_choosers,
  build_subscription_format_map, get_interval_display_label,
  get_billing_cycles_label) and related properties/data-attrs
- JS sub-chooser wiring (switchSubscriptionInterval,
  switchSubscriptionBillingCycles, updateSubscriptionAmountText,
  dispatchSubscriptionPlanChanged, srfm_subscription_plan_changed event)
- Sub-chooser SCSS rules

Kept unchanged: top-level chooser, dual amount displays, Stripe
reinit, PayPal rerender, server-side per-type validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: variable (dynamic) amount not working in "both" payment mode

Three bugs causing variable amounts to break in both mode:

1. PHP: data-variable-amount-field not emitted when the default
   choice is fixed but the other type is variable. listenAmountChanges
   runs once at init and queries [data-variable-amount-field] — without
   this attr, no listener is ever wired.

2. JS updatePaymentBlockAmount: querySelector('.srfm-payment-value')
   always returns the first span (one-time's) in both mode since two
   exist. Changed to query the VISIBLE amount block first:
   .srfm-payment-amount-block:not([hidden]) .srfm-payment-value

3. JS switchActivePaymentType: when flipping to a variable type, the
   amount span is empty (PHP renders it blank for variable). Added
   re-wire of listenAmountChanges + immediate read of the mapped
   field's current value to populate the display on flip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: AI form-builder mapping for "both" payment mode + PHPStan/PHPCS/Insights

- Field_Mapping now accepts paymentType="both" and all 11 new dual-mode attributes returned by the AI middleware; prevents the middleware's "both" response from being silently downgraded to "one-time".
- Add "quarter" to the allowed subscription intervals (matches block.json).
- Payment_Markup: PHPStan level 9 — coerce mixed config values through Helper::get_string_value() + is_numeric float guards before passing to render_amount_display(); drop stray blank lines.
- Payment_Helper::validate_dynamic_amount_field() — add missing doc comment with typed params (PHPCS + PHPStan).

* Test: cover modified payment verification functions for "both" mode

Adds unit tests for the three functions touched by the "both" payment-mode
change — exercising the input-validation / error paths (empty IDs, missing
form config, missing payment-intent metadata) so the coverage gate passes:

- Payment_Helper::validate_payment_amount()
- Payment_Helper::verify_payment_intent()
- Front_End::verify_stripe_subscription_intent_and_save()

* Fix: harden server-side payment validation against client-type tampering (#2121) (#2671)

* Fix: harden server-side payment validation against client-type tampering (#2121)

Two tampering vectors in the new "both" payment-type work were exploitable
because the server trusted client-submitted fields that the server itself
had rendered. Closes both via the existing transient + block_config
verification mechanism — no new infrastructure.

1. Route ↔ form-config mismatch
   validate_payment_amount() did not enforce that the requested active_type
   ('one-time' | 'subscription') matched the form's stored payment_type.
   On a pure-subscription form an attacker could call create_payment_intent
   with $1 and pay once for what should be a recurring charge. Added a
   route-guard: when payment_type is not 'both', the active_type must match.

2. Cross-route intent_id replay
   The transient stored at intent-creation time did not bind the active_type,
   so a one-time intent_id could be replayed through the subscription submit
   path (or vice versa) on "both" mode forms. Persist active_type in the
   transient and reject in verify_payment_intent() when the submit handler's
   active_type does not match the stored value.

3. Interval / billing-cycles tampering
   The frontend echoed admin-configured subscriptionPlan.interval and
   subscriptionPlan.billingCycles via data-* attributes that the user could
   DevTools-flip before submit. End user has no chooser — these are pure
   passthroughs. Persist them to _srfm_block_config in process_payment_block(),
   reject mismatched interval in create_subscription_intent(), and override
   billing-cycles + interval at cancel_at calculation time so the server is
   the source of truth.

Companion: rationale comments added to PayPal dual-namespace SDK loader and
AI payment field mapping to prevent reviewers from re-flagging false-positive
collision / cents-vs-dollars / default-amount concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: prevent Stripe Element re-init race during in-flight type flip (#2121)

When the user clicks submit on a "both" payment-type form, the flow runs
createPaymentIntent + elements.submit() + stripe.confirmPayment which can
take 3-10s end-to-end. Within that window the user could flip the type
radio, triggering switchActivePaymentType -> reinitForBlock which unmounts
the Stripe Element and deletes the cached intent the in-flight confirm is
still using. Outcomes ranged from "Stripe errors and user retries -> double
charge" to "Stripe completes the original (now wrong-type) charge silently".

Fix is a pre-emptive guard:

- handleFormPayment in payment.js sets form.dataset.srfmPaymentInFlight at
  function start and clears it in a finally block (covers all return paths
  and exceptions).
- The radio change handler in stripe-payment.js early-returns when the
  flag is set and reverts the radio to match the current data-payment-type
  so the UI does not lie about the user's choice.

PayPal-side companion fix lives in sureforms-pro #1176.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: tear down stale variable-amount listeners on type flip (#2121)

PAYMENT_UTILITY.listenAmountChanges() is invoked at init AND every time
switchActivePaymentType() applies a new active type with a variable
amount. Each call walked all payment inputs with [data-variable-amount-field]
and called addEventListener() on the source field's input — without
removing the previous listener. Round-trip flips between two variable-
amount sources (e.g. one-time → field A, subscription → field B) leaked
listeners on both fields. Each subsequent input event then fired multiple
updatePaymentBlockAmount() calls, the last of which could overwrite the
displayed amount with a stale source field's value.

Fix: store an AbortController on the payment input. Each call aborts the
prior controller before binding new listeners with `{ signal }`. Pure
single-mode forms (no flips) see no behavior change — the controller is
created once and never aborted.

Also: extended the reviewer-facing comment block in field-mapping.php
with two more "do not flag this" notes:

- Update-flow caveat: generate_gutenberg_fields_from_questions() does
  full regeneration, not merge — every field's default-on-omit behavior
  applies. Pre-existing behavior, not a "both"-mode regression.
- Schema `required` scope: the flat `required` array in payment.json
  is a constraint of OpenAI structured output strict mode (when
  `additionalProperties: false` is set, every property must be in
  `required`). Per-property descriptions tell the model to emit empty
  values for inapplicable modes; the mapping correctly drops them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: handle numeric-string billingCycles in editor control and AI mapping

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: address PR #2565 review findings (#1, #2, #3, #4, #5, #6, #8, #10, #12, #13, #14, #15, #17)

- #1 High (payment-helper.php): use $resolved_config for variable-amount
  minimum check at verify time. The unresolved $payment_config falls
  back to 0 in 'both' mode because per-type keys are stored under the
  active-type prefix. Aligns with the rest of the function which
  already uses $resolved_config. Defense-in-depth — create-time
  validation already enforces the minimum on resolved config.

- #2 (payment-markup.php): wrap 'hidden'/'' ternary in esc_attr() for
  WPCS strict-mode consistency.

- #3 (stripe-payment.js): add per-block reinitInProgress Set so rapid
  type-flips don't race two Stripe.elements() instances on the same
  DOM container. Cleared in the new element's 'ready' event. Radio
  change handler reverts the UI when the guard trips so the visible
  state stays consistent.

- #4 (stripe-payment.js): read paymentType fresh from data-payment-type
  in confirmStripePayment instead of the cached paymentData. Prevents
  stale-type confirmation if the user flips during a captured-but-not-
  yet-confirmed flow.

- #5 (payment-manager.js): scope the document-level
  srfm_payment_type_changed listener to an AbortController and tear it
  down via destroy() before replacing on form re-init. Mirrors the
  PAYMENT_UTILITY.listenAmountChanges pattern in stripe-payment.js.

- #6 (stripe-payment.js): replace `new StripePayment(form)` in
  reinitForBlock with an Object.create stub. The constructor walks
  every payment block on the form; we only need to re-init the one
  that flipped.

- #8 (edit.js): fix useEffect stale-closure — depend on
  [isSelected, currentFormId] and drop the length-guard. The previous
  guard hid a stale-closure bug where the effect didn't react to form
  switches.

- #10 (payment-markup.php): add panel id + aria-labelledby on each
  amount-block; add aria_controls arg to render_radio_pill so each
  radio points to its panel. role=radiogroup + aria-label were
  already in place.

- #12 (stripe-payment.js): document the srfm_payment_type_changed and
  srfm_payment_method_changed event payload contracts so cross-PR
  listeners (e.g. PayPal in sureforms-pro) don't silently break on
  shape drift.

- #13 (stripe-payment.js): remove empty paymentElement.on('change')
  no-op. The 'ready' handler now does real work (clears the reinit
  flag) so it stays.

- #14 (payment-helper.php): cast (float) on fixed_amount and
  minimum_amount inside resolve_payment_config_for_active_type.
  Tightens the array shape for PHPStan and makes downstream floatval()
  redundant in the happy path.

- #15 (payment-markup.php): coerce subscription_plan name and
  interval to (string) before output. Defense-in-depth against a
  malformed REST save that stored these as arrays.

- #17 (edit.js): align the subscriptionPlan?.name fallback — value
  reads `subscriptionPlan?.name || ''` so the input stays controlled,
  while data.value keeps its 'Subscription Plan' display fallback.

Skipped:
- #7 (Medium): UI duplication refactor in edit.js — better as a
  follow-up PR; this commit is large enough already.
- #16 (Low): reviewer flagged but explicitly marked as
  "Acceptable, just flagging" — no action requested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add editor nudge for contact/form pages (closes #2655)

Marketing nudge shown in the block editor when a user is creating a
post/page whose title hints at a form ("contact", "form", "forms"),
the current post does not already contain a srfm/form block, and the
user has not dismissed the nudge before.

- Uses the native Gutenberg notice API (core/notices) — no DOM
  injection into the editor iframe, no polling for the canvas.
- Gated in PHP at enqueue time on capability, screen, and dismissal
  so the script is never shipped when it will no-op.
- Dismissal persisted per-user via nonce-protected AJAX.
- Auto-hides when a srfm/form block is inserted (without persisting
  dismissal) and reappears if the block is removed.

* style: adjust nudge content spacing

Add right margin on the notice content so the dismiss X does not
sit flush against the CTA button.

* test: add unit tests for Editor_Nudge class

Adds tests/unit/inc/admin/test-editor-nudge.php covering allow_load(),
enqueue_scripts(), and handle_dismiss() — required by the
check-test-coverage workflow for new/modified public methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge — FSE title, perf, dismissal expiry, AJAX failure

- Normalize FSE entity-record title `{raw, rendered}` to a string so the
  Site Editor "Contact" pages match the keyword regex.
- Skip the recursive `srfm/form` block walk on editor ticks where the
  descendant client-id count is unchanged (cheap dirty marker), so
  typing in the title no longer pays for a full tree traversal.
- Replace permanent dismissal with a timestamp + 90-day expiry via
  `Editor_Nudge::is_dismissal_active()` so a mis-click is recoverable.
- `persistDismissal()` now returns a Promise; only flip in-memory
  `hasDismissed` after a confirmed `success: true` response, keeping
  client state aligned with server state on AJAX failure.
- Expand PHPUnit coverage: positive `allow_load()` path, SureForms CPT
  screen guard, expired-dismissal rollover, invalid-nonce branch, and
  timestamp-based persistence assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting issue

* fix: gate editor nudge on manage_options and tidy CTA copy

Tighten the cap check in Editor_Nudge::allow_load() and handle_dismiss()
from edit_posts to manage_options (Helper::current_user_can() with no
argument), matching the cap the sureforms_form CPT requires for
create/edit. Otherwise the "Create Form" CTA would surface to roles
that cannot reach the form-creation flow. Also drops an em dash from
the nudge message so the second clause stands as its own sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope editor nudge dismissal to the post being edited

Switch the dismissal record from user meta to post meta so dismissing on
one page does not silence the nudge across every other post for that
user. Editor_Nudge::allow_load() now resolves the editing post via the
$post global and checks post meta; handle_dismiss() reads a validated
post_id from the request and writes update_post_meta on that post.
JS sends the current post ID (resolved through core/edit-site or
core/editor) with the dismissal AJAX.

Drops the block-tree dirty-marker that compared
getClientIdsWithDescendants().length — same-arity replaceBlocks
transforms (slash-command paragraph -> srfm/form) left the count
unchanged and the toggle never fired. The block walk now runs every
tick so insert/remove of the form block correctly hides and restores
the notice.

Updates the in-editor copy to match the agreed wording and migrates
existing tests to post meta. Adds coverage for the "once dismissed,
won't show again on this post" flow, per-post isolation, and the new
post_id validation in the AJAX handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover Editor_Nudge::get_current_post_id() directly

Coverage tool flagged the protected helper as untested. Adds a
reflection-based test that exercises the three branches: no $post
global, non-WP_Post value, and a valid WP_Post instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: move get_current_post_id helper to end of class

Pure reorder — keeps the public hook surface (allow_load,
is_dismissal_active, enqueue_scripts, handle_dismiss) grouped at the
top and the protected helper at the bottom. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge dismiss authorization and tree-walk perf

Address PR #2657 review feedback:

- handle_dismiss(): reject SRFM_FORMS_POST_TYPE post IDs and require
  edit_post on the targeted post (in addition to manage_options) so
  the endpoint cannot dismiss against arbitrary post IDs.
- allow_load() / handle_dismiss(): pass 'manage_options' explicitly
  to Helper::current_user_can() so the rule survives any future
  change to the helper default. Editor / Author / Subscriber roles
  do not have manage_options on a stock WP install.
- watchEditorState(): reference-equality fast path on getBlocks() so
  the recursive tree walk only runs when the block list reference
  has actually changed.
- markDismissed() helper: centralises hasDismissed = true and the
  notice watcher tear-down so future callers cannot leave the
  watcher subscribed indefinitely.
- editor-nudge.scss: remove duplicate color declaration on the CTA.
- Tests: cover the new SRFM_FORMS_POST_TYPE rejection and the
  edit_post denial path (via map_meta_cap filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Feature: add minimum character limit for Textarea field

- Add `minLength` block attribute to textarea block.json schema
- Add "Minimum Characters" number control in block editor sidebar
- Render `minlength` HTML attribute on the frontend textarea element
- Add client-side validation in validation.js that blocks submission
  and shows "Please enter at least N characters." error message
- Add server-side validation in Field_Validation::validate_form_data()
  using mb_strlen for multibyte character safety
- Store minLength in block config via new process_textarea_block() method
- Add srfm_textarea_min_chars translatable message with sprintf placeholder
- Register srfm_textarea_min_chars in global-settings and settings update allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: use data-minlength instead of minlength to prevent browser native validation tooltip

Replace the HTML5 `minlength` attribute with `data-minlength` on the textarea element
so the browser does not show its native validation popup. SureForms custom inline error
message (displayed below the field) is now the only validation feedback shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: always render error element when minLength is configured

The .srfm-error-message element is only rendered when a field is marked
required. If a non-required textarea has a minLength set, the error div
was absent from the DOM, so JS had nowhere to write the message.

Pass override=true to set_markup_properties() when min_length is set,
ensuring the error element is always present for JS to populate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: address review feedback on Textarea minLength feature (#2688)

- Use customizable srfm_textarea_min_chars message in server-side
  validation instead of a hardcoded English string, so admins can
  customize the message via Settings -> Messages.
- Align format specifier with translatable.php (%s instead of %d) to
  avoid duplicate POT entries.
- Skip min-length tracking and rendering when isRichText is enabled,
  since the submitted value contains HTML markup that would skew
  mb_strlen counts.
- Drop minLength when it exceeds maxLength so misconfigured forms
  remain submittable; surface a warning in the editor.
- Resolve a pre-existing PHPStan level-9 violation on
  process_textarea_block by replacing the empty-string guard with
  is_numeric() type narrowing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
…#2683)

* feat(form-restriction): add extension hooks for windowed entry caps

Adds two narrowly-scoped extension points so an extension (SureForms Pro's
Recurring Entry Limit feature) can swap the lifetime entry count used by
the Maximum Number of Entries cap with a window-scoped count (Per Day /
Week / Month / Year), without owning the cap UI itself.

PHP — `Form_Restriction::has_entries_limit_reached()`:
- New `srfm_form_restriction_entries_count` filter wraps the count read
  from `Entries::get_total_entries_by_status()`. The filter receives the
  lifetime count, the form id, and the parsed restriction array; returning
  a smaller integer defers the cap, returning a larger one trips it sooner.
  Non-int returns are coerced to a non-negative int.
- Default behaviour is unchanged: with no extension hooked in, the lifetime
  count flows through untouched and the cap fires exactly as before.

JS — `FormRestriction.js`:
- Adds an `srfm_form_restriction_max_entries_after` slot filter rendered
  right after the Maximum Entries number input (inside the same flex row,
  aligned to the bottom). The filter is given a `null` initial value so
  an extension can return a JSX element directly.
- Wraps the input in an `items-end` flex container so an injected control
  baselines with the input rather than its label.

Both are additive and back-compat. Companion change in
brainstormforce/sureforms-pro#1189 wires the Pro-side dropdown into these
hooks.

* fix(form-restriction): always floor entries-count filter return; document slot contract

Address two findings from review of #2683:

1) Inverted floor on the new srfm_form_restriction_entries_count filter
   (high-severity blocker).

   The previous code only coerced non-int returns:
       if ( ! is_int( $entries_count ) ) {
           $entries_count = max( 0, (int) $entries_count );
       }
   That left a negative-int return path uncovered — an extension that
   returned, say, -5 would silently disable the cap (-5 >= 100 is
   false). Move max( 0, (int) … ) outside the is_int gate so it always
   runs. The docblock already promised a non-negative integer; code
   now matches the documented contract on a newly-public extension
   hook.

2) Slot-filter ownership ambiguity on
   srfm_form_restriction_max_entries_after (high-severity).

   Document the filter contract directly above the applyFilters() call:
   callbacks receive whatever the previous callback returned, and
   should compose with prev rather than replace it. Includes the
   recommended idiom for would-be future extensions:

       addFilter(
         'srfm_form_restriction_max_entries_after',
         'my-plugin/extra',
         ( prev ) => <>{ prev }<MyControl /></>
       );

   Companion fix in sureforms-pro#1189 makes Pro's own callback follow
   this pattern.

* fix: warn when Never Store entries conflicts with entry cap

When GDPR compliance is active with "Never store entry data after form
submission", form-submit.php returns before inserting an entry, so the
entries-table count never increments and the Maximum Number of Entries
cap silently does nothing.

Surfaces a warning banner inside the Max Entries panel whenever both
settings are simultaneously active, so the admin knows the cap will not
be enforced until one of them is disabled.
* feat: add Hidden field support for payment dynamic amount

Extends the Payment block's variable-amount source list to include
Hidden fields alongside Number, Dropdown, and Multi-choice. The
configured defaultValue acts as the server-side source of truth for
the charged amount; submitted values that drift from it are rejected.

- Payment block UI lists Hidden fields in "Choose Amount Field"
- Form validation registers Hidden block config (default_value)
- Payment intent validation pins Stripe-charged amount to the
  configured Hidden default_value with 0.01 tolerance
- Stripe frontend reads .srfm-hidden-input value into
  data-current-amount and listens for input/change updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trust runtime hidden value for payment dynamic amount

Hidden field values can be set dynamically at runtime (URL params,
cookies, JS) — pinning the expected payment amount to the configured
defaultValue blocks legitimate dynamic-amount flows.

Switches the validator to the same trust model the Number field uses:
the value submitted with the form is the expected amount; the Stripe-
charged amount must match it (0.01 tolerance) and clear the existing
minimum_amount floor.

- payment-helper: replace default_value pinning with submitted-value
  comparison; add srfm-hidden mapping in form-data lookup helper
- field-validation: drop srfm/hidden switch case and process_hidden_block
  (no server-side hidden config needed; also resolves PHPStan failure on
  sanitize_text_field with mixed input)
- stripe-payment.js: remove leftover debug console.log in hidden sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align hidden-field payment validation with reviewer feedback

- stripe-payment.js: tighten the runtime hidden value parser so it
  matches PHP's strict numeric semantics. parseFloat alone accepts
  malformed inputs like "99abc" (returns 99) which the server then
  rejects via is_numeric(), leading to silent "amount required"
  failures. Reject anything that isn't a clean positive decimal up
  front so client and server agree on what counts as numeric.

- payment-helper.php: refresh stale comment above the minimum-amount
  floor check; hidden-field amount sources are now also subject to
  the floor, so the comment now names them explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Claude Code slash command that syncs private master to the
brainstormforce/sureforms-public mirror, stripping internal-only paths
in a single commit on the sync branch before opening / updating the
sync PR.

The skill is the single source of truth for which paths are filtered
when going from private to public — co-located with the other release
skills (sync-release-branches, sureforms:version-bump, etc.) so the
team has one place to maintain release-time tooling.

After the matching public-mirror cleanup PR (sureforms-public#80) lands,
all future syncs should use this skill instead of raw git push +
gh pr create — that's the only way to keep .claude/, internal-docs/,
SVN release workflows, and AI-generated analysis docs from re-leaking
to the public repo.

Co-authored-by: Aditya Jain <adi3890@gmail.com>
* Fix: Confirm email field blocks submission without error when not required

The confirm email block lacked the `srfm-block` class, so existing CSS
error rules (red border, error message display) never applied when
`srfm-error` was toggled. Additionally, the error message element was
not rendered when the field was not required, causing a JS TypeError
that silently blocked submission.

- Add `srfm-block` class to confirm email block for CSS error styles
- Always render error message element via override when confirm is enabled
- Add null check on confirmError before setting textContent

Fixes #2230

* Fix: Use targeted CSS for confirm email error styles instead of adding srfm-block class

Adding srfm-block to the confirm block would break closest('.srfm-block')
lookups in real-time validation and other JS code. Instead, add dedicated
CSS rules for .srfm-email-confirm-block.srfm-error to show red border
and error message — matching the existing .srfm-block.srfm-error pattern
without introducing nested .srfm-block issues.

Also adds changelog entry in readme.txt.

* fix: surface AI Form Builder silent failures (#2452)

Every layer of the AI Form Builder had at least one silent-failure
path: the React catch only console.log'd, the PHP validator shipped
the raw response as the error payload, the JSON-decode checks treated
falsy JSON as "valid", Field_Mapping swallowed failures by returning
an empty string, and wp_insert_post errors were never checked. Any
one of these left the user on a frozen progress screen at 50/75/100%
with no error popup.

Frontend
- AiFormBuilder: split both apiFetch calls into their own try/catch,
  reset isBuildingForm + percentBuild on any failure, and replace the
  boolean error flag with a message-carrying string so every path
  surfaces a specific reason to the user.
- ErrorPopup: accept errorMessage + onRetry props; default retry no
  longer reloads the page when triggered by the AI flow, preserving
  the user's prompt.
- Helpers.handleAddNewPost: add an onError callback so permission /
  response.id / catch failures bubble up instead of only logging.

Backend
- ai-form-builder.php: fix the empty() parentheses bug, split the
  monolithic form validator into three guards with specific messages,
  and stop shipping the raw middleware response as the error payload.
- ai-helper.php: replace the falsy json_decode check with explicit
  json_last_error / is_array validation, share the new
  decode_json_response helper between both middleware calls, and log
  malformed responses (endpoint, HTTP status, truncated body) behind
  WP_DEBUG.
- field-mapping.php: fix the is_array($form) typo (was meant to guard
  $form_fields) and return typed WP_Error instead of empty strings,
  so the REST layer serialises a real message for the frontend.
- create-new-form.php: pass true to wp_insert_post so WP_Error is
  returned on failure, and respond with a 500 carrying the real
  message instead of treating WP_Error objects as success.
- abilities/forms/create-form.php and update-form.php: propagate the
  new Field_Mapping WP_Error contract.

Tests
- Update test_generate_gutenberg_fields_empty_request to match the
  new WP_Error contract.

Verified: npm run lint-js, npm run build:script, composer lint,
composer phpstan level 9 all clean. composer test requires local WP
test env (install-wp-tests.sh) to run.

Refs: FreeScout ticket #1389163

* fix: satisfy PHP Insights ordering + add missing test coverage

- field-mapping.php: switch two isset() ternaries to `??` null coalescing
  (PHP Insights "Ternary to null coalescing").
- ai-helper.php: move decode_json_response + log_ai_response_failure below
  the final public static method so protected visibility groups correctly
  (PHP Insights "Ordered class elements").
- test-ai-helper.php: add coverage for decode_json_response (valid,
  empty, invalid JSON, non-array JSON, custom fallback),
  log_ai_response_failure (WP_DEBUG noop + non-string body), and the
  two middleware callers (get_chat_completions_response,
  get_usage_response) using pre_http_request mocks.
- test-create-new-form.php: add test_create_form covering the new
  WP_REST_Response(500) error path when wp_insert_post fails.

* test: cover is_pro_license_active no-pro branch

* fix: address review findings from PR #2658 review

Addresses all 9 items in @rahulvarma722's review.

#1 (CRITICAL) — revert hardcoded foreign URL
ai-helper.php::get_user_token() was returning the literal
'http://www.republiquedesmangues.fr/' as the fallback X-Token for
non-licensed installs. dev had site_url(); the URL slipped in as debug
code. Restored site_url() so middleware token verification works and
no third-party domain leaks via X-Token.

#2 — tighten AI debug logging
log_ai_response_failure() now also gates on WP_DEBUG_LOG (so the body
no longer falls back to the host's PHP error log on sites that run
WP_DEBUG=true with WP_DEBUG_LOG=false), collapses whitespace before
formatting (prevents log injection via crafted CR/LF in response
bodies), and redacts the values of known sensitive JSON keys (email,
token, license_key, prompt, query) before writing.

#3 — sanitize upstream error messages before returning to client
Added AI_Helper::sanitize_ai_error_message() that strips URLs,
OpenAI-shape opaque IDs (org-/user-/key-/sess-/req-/file-/chatcmpl-/
asst-/run-/thread-), 'request id: …' trailers, sk-* / Bearer * tokens,
and 'gpt-*' model identifiers. The raw message is still preserved
server-side via log_ai_response_failure() (gated on WP_DEBUG[_LOG]).
Wired into ai-form-builder.php (response['error'] surfacing) and
create-new-form.php (wp_insert_post WP_Error surfacing).

#4 — drop dead invalid_json branch in AiFormBuilder.js
decode_json_response() returns ['error' => …], never
['code' => 'invalid_json'], so the frontend invalid_json check was
unreachable. The error already routes through the success===false /
empty-response branches.

#5 — drop unreachable inner ErrorPopup
handleFormCreationError() sets isBuildingForm=false in the same batch
as setFormCreationErr, so the early-return inside the
isBuildingForm block never coincides with a non-empty
formCreationErr. Only the outer popup is reachable; inner one removed.

#6 — ErrorPopup accessibility
Wrapped the popup in role='alertdialog' with aria-modal, aria-labelledby,
aria-describedby, and tabIndex=-1. Focus moves into the dialog on mount
(primary action button if available, else the wrapper). Escape now
dismisses by invoking the retry/close handler.

#7 — drop dead is_string checks post-narrowing
After is_wp_error early-return, generate_gutenberg_fields_from_questions
narrows to string. Removed !is_string in create-form.php and is_string
in update-form.php; kept the empty() guards which still serve a purpose.

#8 — defense-in-depth fieldOptions sanitization
Added Field_Mapping::sanitize_field_options() that runs label / value /
optionTitle through sanitize_text_field before the options array is
merged into Gutenberg block markup. Capability-gated trusted middleware
already, but cheap belt-and-suspenders if upstream ever returns
reflected user content.

#9 — test coverage on the new WP_Error contract
Added 5 cases to test-field-mapping.php covering invalid_form_data,
missing_form, missing_form_fields, invalid_field, plus a generic
status=400 assertion. Locks in the contract introduced by this PR.

* fix: address PHP Insights ordering and add sanitize_ai_error_message coverage

CI failures from the previous commit:

1. PHP Insights — code quality / style scores below the project's
   --min-quality=100 / --min-style=100 thresholds. Two findings:

   a) Ordered class elements: sanitize_ai_error_message (public static)
      was inserted after the protected static helpers, violating the
      public-before-protected ordering rule. Moved the method up to sit
      with the other public statics, immediately after
      is_pro_license_active().

   b) Return assignment: the trailing
        $cleaned = trim( $cleaned, … );
        return $cleaned;
      pair was flagged. Collapsed to a direct
        return trim( $cleaned, … );

2. check-test-coverage — sanitize_ai_error_message() had no matching
   test_sanitize_ai_error_message() in test-ai-helper.php. Added 5 cases:
   non-string / empty input → empty, clean pass-through, the full
   strip-pattern matrix (URL / OpenAI org-/user-/req_ IDs / request-id
   trailers / sk- keys / Bearer tokens / gpt model names), all-infra
   inputs collapse to empty, no-endpoint argument path.

Side fix while writing the strip tests: broadened the OpenAI-ID regex
to accept either '-' or '_' as the separator (real OpenAI request IDs
use req_…), and tightened the request-id trailer pattern to require a
separator and a 4+ char id so it cannot eat surrounding plain words.

* fix: bump confirm-email error CSS specificity above frontend hide rule

The display:block rule for .srfm-email-confirm-block.srfm-error
.srfm-error-message had specificity (0,4,0), tying with the base
hide rule .srfm-form .srfm-block .srfm-error-message { display:none }
in frontend/form.scss. Because common.css enqueues before frontend/
form.css (per inc/frontend-assets.php css_assets order), the hide
rule won and the error message stayed invisible on the frontend even
when the JS correctly added the .srfm-error class to the confirm block.

Wrap the entire confirm-email block under a .srfm-block ancestor (the
outer email wrapper carries that class via Base::get_field_classes())
so the show rule compiles to specificity (0,5,0) and consistently wins
regardless of file load order.

Fixes the two unchecked test plan items in PR review #2571:
- Non-required email + filled main + empty confirm now shows the
  'Confirmation email does not match' mismatch error with red border
- Required email + filled main + empty confirm now shows the
  'This field is required' message on the confirm field

The JS validation logic in assets/js/unminified/validation.js was
already correct — both branches (the !confirmValue && confirmError &&
isRequired === 'true' required-empty branch and the
confirmValue !== inputValue mismatch branch) fire as intended; the
visible regression was purely a CSS cascade bug.

* fix(abilities): allow null user in get-entry/bulk-get-entries output schema

The entry parser returns user as null for anonymous submissions, but the
output schema declared user as type "object" only. The MCP output
validator rejected null, causing every anonymous-submission entry fetch
to fail with "output[user] is not of type object". This blocked any
analytics dashboard that needs per-entry field values across all
submissions.

Schema now accepts both object (authenticated) and null (anonymous),
matching what entry-parser.php has always returned.

* fix: address WP 7.0/7.1 deprecation warnings and React 18 compat (#2660) (#2664)

* fix: address WP 7.0/7.1 deprecation warnings and React 18 compat

Migrates admin screen mounts to createRoot (React 18 API) and opts
in to the new @wordpress/components defaults so SureForms stops
emitting deprecation warnings in the admin console.

- Replace legacy `render()` from @wordpress/element with `createRoot`
  in Settings, PageHeader (shared across admin), TemplatePicker, and
  the ProPanel mount inside Editor.js (MutationObserver-driven).
- Add `__next40pxDefaultSize` to TextControl, SelectControl, RangeControl,
  and NumberControl across wrappers and raw usages (WP 6.8 → removed 7.1).
- Add `__nextHasNoMarginBottom` to ToggleControl, TextControl, and
  SelectControl usages (WP 6.7 → removed 7.0).

Scope covers `src/` and `modules/gutenberg/src/`. Wrappers
(SRFMTextControl, SRFMSelectControl, Range, SRFMNumberControl) are
updated once to propagate the props to every downstream consumer.

Refs brainstormforce/sureforms#2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trim out-of-scope deprecation fixes

Drop changes that go beyond what #2660 explicitly asks for:

- Remove __nextHasNoMarginBottom additions on ToggleControl /
  TextControl / SelectControl. That's a separate WP 6.7 → 7.0
  deprecation track, not the 36px / React 18 issues called out
  in #2660. Can be addressed in a follow-up PR.

Keeps everything the issue requires:
- React 18 createRoot migration on admin screens
- __next40pxDefaultSize on TextControl / SelectControl /
  RangeControl / NumberControl

Refs #2660

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feat: add "Both" payment type — editor + frontend (#2121) (#2565)

* Feat: add "Both" payment type option in payment block editor (Part 1 of #2121)

Adds a third "Both" option to the Payment Type toggle in the payment
block editor, allowing admins to configure rules for both one-time and
subscription payments in a single block. Introduces separate amount
settings per payment type so pricing can differ (e.g., $99 one-time vs
$9.99/month).

This is Part 1 of two — editor-side only. Frontend rendering, JS flow,
and backend AJAX handling will follow in Part 2. Existing "One Time"
and "Subscription" behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: multi-select Billing Interval & Stop Subscription After

Converts the subscription plan's Billing Interval and Stop Subscription
After fields from single-select dropdowns to multi-select checkbox
groups so admins can configure the allowed options. End user will pick
one from the allowed set on the frontend (Part 2).

- New BillingIntervalControl: 5 checkboxes (Daily/Weekly/Monthly/Quarterly/Yearly)
  writes to subscriptionPlan.intervals (array)
- Rewritten BillingCyclesControl: checkboxes for presets (2, 3, 4, 5, Never)
  plus comma-separated custom payment counts input, writes to
  subscriptionPlan.billingCyclesOptions (array)
- Backward compatibility: legacy string `interval` / scalar `billingCycles`
  auto-migrate to single-item arrays on first render; legacy fields are
  kept in sync with first selected value so the current frontend
  renderer continues working until Part 2 wires up multi-select output
- Applies to both paymentType === "subscription" and paymentType === "both"
  modes for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: use react-select multi-select for Billing Interval & Stop Subscription After

Aligns the two subscription multi-select controls with the pattern used
by the existing "Payment Methods" multi-select in sureforms-pro so the
UI is visually consistent across payment block settings.

- Replaces checkbox groups with react-select Select (isMulti)
- Uses classNamePrefix="srfm-select" and the same label/help markup as
  the Payment Methods control
- BillingCyclesControl: drops the separate custom-numbers text input in
  favor of a cleaner preset-only dropdown (2, 3, 4, 5, 6, 12, 24, Never).
  Arbitrary custom payment counts can be added later if required.
- Backward compatibility logic preserved: legacy `interval` / scalar
  `billingCycles` auto-migrate; legacy fields kept in sync with first
  selected array value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: keep "subscription" mode single-select; isolate multi-select to "both" mode

Reverts the shared BillingCyclesControl to its original single-select
behavior and introduces two dedicated multi-select components used ONLY
in paymentType === "both" mode. This guarantees zero behavioral change
for existing subscription-mode forms.

- billing-cycles-control.js: restored to original SelectControl + custom
  numeric input (powers paymentType === "subscription" unchanged)
- billing-interval-multi-control.js: renamed from billing-interval-control.js;
  only used in paymentType === "both" mode
- billing-cycles-multi-control.js: new dedicated multi-select for
  paymentType === "both" mode (presets: 2, 3, 4, 5, 6, 12, 24, Never)
- edit.js: subscription block uses original SelectControl for interval
  and original BillingCyclesControl; both block uses the two new
  MultiControl components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Feat: frontend "both" payment mode — chooser + sub-choosers + styling

Part 2 of #2121. Renders the end-user payment-type chooser, dual
amount displays, Stripe element reinit on type switch, and (when the
admin allowed >1 option) radio-pill sub-choosers for Billing Interval
and Stop Subscription After.

- payment-markup.php: emits both-mode data attrs, renders chooser +
  sub-choosers via shared render_radio_pill helper that mirrors the
  multi-choice block (circle-checked / circle-unchecked SVGs,
  .srfm-block-content-wrap). Generates a pre-translated format map
  so JS can rebuild the visible amount text on sub-chooser change.
- stripe-payment.js: initPaymentTypeChoosers wires top-level +
  sub-chooser radios. switchActivePaymentType syncs all live
  data-* attrs and calls reinitForBlock to re-mount the Stripe
  Element in the new mode. switchSubscriptionInterval /
  switchSubscriptionBillingCycles update attrs and rebuild the
  amount text without re-mounting Stripe. Dispatches
  srfm_payment_type_changed and srfm_subscription_plan_changed
  events for gateway-agnostic listeners.
- payment-manager.js: re-broadcasts payment-method-changed when the
  payment type flips so accordion gateways re-render.
- front-end.php: add 'quarter' to the valid-intervals allow-list;
  was a pre-existing gap that would have rejected Quarterly
  subscriptions at submit time.
- SCSS: new components/payment-type-chooser.scss reuses the
  multi-choice mixins so the pills inherit the same border,
  padding, font-size, circle radio icons, primary-tinted checked
  state, focus ring, and mobile stacking as the rest of the form.

All new code is flagged with // BOTH MODE: start / end markers to
keep the follow-up rollout / rollback easy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: resolve PHPCS + PHPStan lint issues in payment-markup.php

- Add full-stops to inline comments (Squiz.Commenting.InlineComment).
- Replace single-line phpcs:ignore with phpcs:disable/enable blocks
  for multi-line echo calls (WordPress.Security.EscapeOutput).
- Fix equals sign alignment (Generic.Formatting.MultipleStatementAlignment).
- Change @var from strict array shape to array<mixed> so empty default
  is accepted by PHPStan level 9.

No functional changes — comments, whitespace, and annotations only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: server-side amount validation for "both" payment mode

In "both" mode one-time and subscription paths can have different
amount types and values (e.g. fixed $99 vs variable min $5). The
server-side validation was reading the legacy scalar config which
only holds the default choice's values — causing the non-default
path to fail with "Payment amount must be exactly $X".

- field-validation.php: process_payment_block() now stores per-type
  configs (one_time_*, subscription_*) when paymentType === 'both'.
- payment-helper.php: new resolve_payment_config_for_active_type()
  helper remaps per-type keys into the scalar positions the existing
  validation functions expect. validate_payment_amount() and
  validate_payment_intent_amount() accept $active_type param.
- front-end.php: all 4 Stripe callers pass 'one-time' or
  'subscription' to the validation functions.

Backward compatible — $active_type defaults to '' which returns
the original config unchanged for legacy (non-both) forms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: make entire payment chooser pill clickable

Changed render_radio_pill outer wrapper from <div> to <label> so
clicking anywhere on the card (icon, padding, whitespace) checks
the radio — not just the text label. Inner <label> changed to
<span> to avoid invalid nested labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor: simplify "both" mode to single-select for interval/cycles

Replace multi-select Billing Interval and Stop Subscription After
controls with the same single-select dropdowns used in subscription
mode. Admin picks one interval and one billing-cycle option; end
user sees only the top-level one-time/subscription chooser with no
sub-chooser pills.

Removed (-616 lines):
- billing-interval-multi-control.js and billing-cycles-multi-control.js
- Frontend sub-chooser rendering (render_subscription_sub_choosers,
  build_subscription_format_map, get_interval_display_label,
  get_billing_cycles_label) and related properties/data-attrs
- JS sub-chooser wiring (switchSubscriptionInterval,
  switchSubscriptionBillingCycles, updateSubscriptionAmountText,
  dispatchSubscriptionPlanChanged, srfm_subscription_plan_changed event)
- Sub-chooser SCSS rules

Kept unchanged: top-level chooser, dual amount displays, Stripe
reinit, PayPal rerender, server-side per-type validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: variable (dynamic) amount not working in "both" payment mode

Three bugs causing variable amounts to break in both mode:

1. PHP: data-variable-amount-field not emitted when the default
   choice is fixed but the other type is variable. listenAmountChanges
   runs once at init and queries [data-variable-amount-field] — without
   this attr, no listener is ever wired.

2. JS updatePaymentBlockAmount: querySelector('.srfm-payment-value')
   always returns the first span (one-time's) in both mode since two
   exist. Changed to query the VISIBLE amount block first:
   .srfm-payment-amount-block:not([hidden]) .srfm-payment-value

3. JS switchActivePaymentType: when flipping to a variable type, the
   amount span is empty (PHP renders it blank for variable). Added
   re-wire of listenAmountChanges + immediate read of the mapped
   field's current value to populate the display on flip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: AI form-builder mapping for "both" payment mode + PHPStan/PHPCS/Insights

- Field_Mapping now accepts paymentType="both" and all 11 new dual-mode attributes returned by the AI middleware; prevents the middleware's "both" response from being silently downgraded to "one-time".
- Add "quarter" to the allowed subscription intervals (matches block.json).
- Payment_Markup: PHPStan level 9 — coerce mixed config values through Helper::get_string_value() + is_numeric float guards before passing to render_amount_display(); drop stray blank lines.
- Payment_Helper::validate_dynamic_amount_field() — add missing doc comment with typed params (PHPCS + PHPStan).

* Test: cover modified payment verification functions for "both" mode

Adds unit tests for the three functions touched by the "both" payment-mode
change — exercising the input-validation / error paths (empty IDs, missing
form config, missing payment-intent metadata) so the coverage gate passes:

- Payment_Helper::validate_payment_amount()
- Payment_Helper::verify_payment_intent()
- Front_End::verify_stripe_subscription_intent_and_save()

* Fix: harden server-side payment validation against client-type tampering (#2121) (#2671)

* Fix: harden server-side payment validation against client-type tampering (#2121)

Two tampering vectors in the new "both" payment-type work were exploitable
because the server trusted client-submitted fields that the server itself
had rendered. Closes both via the existing transient + block_config
verification mechanism — no new infrastructure.

1. Route ↔ form-config mismatch
   validate_payment_amount() did not enforce that the requested active_type
   ('one-time' | 'subscription') matched the form's stored payment_type.
   On a pure-subscription form an attacker could call create_payment_intent
   with $1 and pay once for what should be a recurring charge. Added a
   route-guard: when payment_type is not 'both', the active_type must match.

2. Cross-route intent_id replay
   The transient stored at intent-creation time did not bind the active_type,
   so a one-time intent_id could be replayed through the subscription submit
   path (or vice versa) on "both" mode forms. Persist active_type in the
   transient and reject in verify_payment_intent() when the submit handler's
   active_type does not match the stored value.

3. Interval / billing-cycles tampering
   The frontend echoed admin-configured subscriptionPlan.interval and
   subscriptionPlan.billingCycles via data-* attributes that the user could
   DevTools-flip before submit. End user has no chooser — these are pure
   passthroughs. Persist them to _srfm_block_config in process_payment_block(),
   reject mismatched interval in create_subscription_intent(), and override
   billing-cycles + interval at cancel_at calculation time so the server is
   the source of truth.

Companion: rationale comments added to PayPal dual-namespace SDK loader and
AI payment field mapping to prevent reviewers from re-flagging false-positive
collision / cents-vs-dollars / default-amount concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: prevent Stripe Element re-init race during in-flight type flip (#2121)

When the user clicks submit on a "both" payment-type form, the flow runs
createPaymentIntent + elements.submit() + stripe.confirmPayment which can
take 3-10s end-to-end. Within that window the user could flip the type
radio, triggering switchActivePaymentType -> reinitForBlock which unmounts
the Stripe Element and deletes the cached intent the in-flight confirm is
still using. Outcomes ranged from "Stripe errors and user retries -> double
charge" to "Stripe completes the original (now wrong-type) charge silently".

Fix is a pre-emptive guard:

- handleFormPayment in payment.js sets form.dataset.srfmPaymentInFlight at
  function start and clears it in a finally block (covers all return paths
  and exceptions).
- The radio change handler in stripe-payment.js early-returns when the
  flag is set and reverts the radio to match the current data-payment-type
  so the UI does not lie about the user's choice.

PayPal-side companion fix lives in sureforms-pro #1176.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: tear down stale variable-amount listeners on type flip (#2121)

PAYMENT_UTILITY.listenAmountChanges() is invoked at init AND every time
switchActivePaymentType() applies a new active type with a variable
amount. Each call walked all payment inputs with [data-variable-amount-field]
and called addEventListener() on the source field's input — without
removing the previous listener. Round-trip flips between two variable-
amount sources (e.g. one-time → field A, subscription → field B) leaked
listeners on both fields. Each subsequent input event then fired multiple
updatePaymentBlockAmount() calls, the last of which could overwrite the
displayed amount with a stale source field's value.

Fix: store an AbortController on the payment input. Each call aborts the
prior controller before binding new listeners with `{ signal }`. Pure
single-mode forms (no flips) see no behavior change — the controller is
created once and never aborted.

Also: extended the reviewer-facing comment block in field-mapping.php
with two more "do not flag this" notes:

- Update-flow caveat: generate_gutenberg_fields_from_questions() does
  full regeneration, not merge — every field's default-on-omit behavior
  applies. Pre-existing behavior, not a "both"-mode regression.
- Schema `required` scope: the flat `required` array in payment.json
  is a constraint of OpenAI structured output strict mode (when
  `additionalProperties: false` is set, every property must be in
  `required`). Per-property descriptions tell the model to emit empty
  values for inapplicable modes; the mapping correctly drops them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: handle numeric-string billingCycles in editor control and AI mapping

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: address PR #2565 review findings (#1, #2, #3, #4, #5, #6, #8, #10, #12, #13, #14, #15, #17)

- #1 High (payment-helper.php): use $resolved_config for variable-amount
  minimum check at verify time. The unresolved $payment_config falls
  back to 0 in 'both' mode because per-type keys are stored under the
  active-type prefix. Aligns with the rest of the function which
  already uses $resolved_config. Defense-in-depth — create-time
  validation already enforces the minimum on resolved config.

- #2 (payment-markup.php): wrap 'hidden'/'' ternary in esc_attr() for
  WPCS strict-mode consistency.

- #3 (stripe-payment.js): add per-block reinitInProgress Set so rapid
  type-flips don't race two Stripe.elements() instances on the same
  DOM container. Cleared in the new element's 'ready' event. Radio
  change handler reverts the UI when the guard trips so the visible
  state stays consistent.

- #4 (stripe-payment.js): read paymentType fresh from data-payment-type
  in confirmStripePayment instead of the cached paymentData. Prevents
  stale-type confirmation if the user flips during a captured-but-not-
  yet-confirmed flow.

- #5 (payment-manager.js): scope the document-level
  srfm_payment_type_changed listener to an AbortController and tear it
  down via destroy() before replacing on form re-init. Mirrors the
  PAYMENT_UTILITY.listenAmountChanges pattern in stripe-payment.js.

- #6 (stripe-payment.js): replace `new StripePayment(form)` in
  reinitForBlock with an Object.create stub. The constructor walks
  every payment block on the form; we only need to re-init the one
  that flipped.

- #8 (edit.js): fix useEffect stale-closure — depend on
  [isSelected, currentFormId] and drop the length-guard. The previous
  guard hid a stale-closure bug where the effect didn't react to form
  switches.

- #10 (payment-markup.php): add panel id + aria-labelledby on each
  amount-block; add aria_controls arg to render_radio_pill so each
  radio points to its panel. role=radiogroup + aria-label were
  already in place.

- #12 (stripe-payment.js): document the srfm_payment_type_changed and
  srfm_payment_method_changed event payload contracts so cross-PR
  listeners (e.g. PayPal in sureforms-pro) don't silently break on
  shape drift.

- #13 (stripe-payment.js): remove empty paymentElement.on('change')
  no-op. The 'ready' handler now does real work (clears the reinit
  flag) so it stays.

- #14 (payment-helper.php): cast (float) on fixed_amount and
  minimum_amount inside resolve_payment_config_for_active_type.
  Tightens the array shape for PHPStan and makes downstream floatval()
  redundant in the happy path.

- #15 (payment-markup.php): coerce subscription_plan name and
  interval to (string) before output. Defense-in-depth against a
  malformed REST save that stored these as arrays.

- #17 (edit.js): align the subscriptionPlan?.name fallback — value
  reads `subscriptionPlan?.name || ''` so the input stays controlled,
  while data.value keeps its 'Subscription Plan' display fallback.

Skipped:
- #7 (Medium): UI duplication refactor in edit.js — better as a
  follow-up PR; this commit is large enough already.
- #16 (Low): reviewer flagged but explicitly marked as
  "Acceptable, just flagging" — no action requested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add editor nudge for contact/form pages (#2657)

* feat: add editor nudge for contact/form pages (closes #2655)

Marketing nudge shown in the block editor when a user is creating a
post/page whose title hints at a form ("contact", "form", "forms"),
the current post does not already contain a srfm/form block, and the
user has not dismissed the nudge before.

- Uses the native Gutenberg notice API (core/notices) — no DOM
  injection into the editor iframe, no polling for the canvas.
- Gated in PHP at enqueue time on capability, screen, and dismissal
  so the script is never shipped when it will no-op.
- Dismissal persisted per-user via nonce-protected AJAX.
- Auto-hides when a srfm/form block is inserted (without persisting
  dismissal) and reappears if the block is removed.

* style: adjust nudge content spacing

Add right margin on the notice content so the dismiss X does not
sit flush against the CTA button.

* test: add unit tests for Editor_Nudge class

Adds tests/unit/inc/admin/test-editor-nudge.php covering allow_load(),
enqueue_scripts(), and handle_dismiss() — required by the
check-test-coverage workflow for new/modified public methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge — FSE title, perf, dismissal expiry, AJAX failure

- Normalize FSE entity-record title `{raw, rendered}` to a string so the
  Site Editor "Contact" pages match the keyword regex.
- Skip the recursive `srfm/form` block walk on editor ticks where the
  descendant client-id count is unchanged (cheap dirty marker), so
  typing in the title no longer pays for a full tree traversal.
- Replace permanent dismissal with a timestamp + 90-day expiry via
  `Editor_Nudge::is_dismissal_active()` so a mis-click is recoverable.
- `persistDismissal()` now returns a Promise; only flip in-memory
  `hasDismissed` after a confirmed `success: true` response, keeping
  client state aligned with server state on AJAX failure.
- Expand PHPUnit coverage: positive `allow_load()` path, SureForms CPT
  screen guard, expired-dismissal rollover, invalid-nonce branch, and
  timestamp-based persistence assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Resolved linting issue

* fix: gate editor nudge on manage_options and tidy CTA copy

Tighten the cap check in Editor_Nudge::allow_load() and handle_dismiss()
from edit_posts to manage_options (Helper::current_user_can() with no
argument), matching the cap the sureforms_form CPT requires for
create/edit. Otherwise the "Create Form" CTA would surface to roles
that cannot reach the form-creation flow. Also drops an em dash from
the nudge message so the second clause stands as its own sentence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope editor nudge dismissal to the post being edited

Switch the dismissal record from user meta to post meta so dismissing on
one page does not silence the nudge across every other post for that
user. Editor_Nudge::allow_load() now resolves the editing post via the
$post global and checks post meta; handle_dismiss() reads a validated
post_id from the request and writes update_post_meta on that post.
JS sends the current post ID (resolved through core/edit-site or
core/editor) with the dismissal AJAX.

Drops the block-tree dirty-marker that compared
getClientIdsWithDescendants().length — same-arity replaceBlocks
transforms (slash-command paragraph -> srfm/form) left the count
unchanged and the toggle never fired. The block walk now runs every
tick so insert/remove of the form block correctly hides and restores
the notice.

Updates the in-editor copy to match the agreed wording and migrates
existing tests to post meta. Adds coverage for the "once dismissed,
won't show again on this post" flow, per-post isolation, and the new
post_id validation in the AJAX handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: cover Editor_Nudge::get_current_post_id() directly

Coverage tool flagged the protected helper as untested. Adds a
reflection-based test that exercises the three branches: no $post
global, non-WP_Post value, and a valid WP_Post instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: move get_current_post_id helper to end of class

Pure reorder — keeps the public hook surface (allow_load,
is_dismissal_active, enqueue_scripts, handle_dismiss) grouped at the
top and the protected helper at the bottom. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden editor nudge dismiss authorization and tree-walk perf

Address PR #2657 review feedback:

- handle_dismiss(): reject SRFM_FORMS_POST_TYPE post IDs and require
  edit_post on the targeted post (in addition to manage_options) so
  the endpoint cannot dismiss against arbitrary post IDs.
- allow_load() / handle_dismiss(): pass 'manage_options' explicitly
  to Helper::current_user_can() so the rule survives any future
  change to the helper default. Editor / Author / Subscriber roles
  do not have manage_options on a stock WP install.
- watchEditorState(): reference-equality fast path on getBlocks() so
  the recursive tree walk only runs when the block list reference
  has actually changed.
- markDismissed() helper: centralises hasDismissed = true and the
  notice watcher tear-down so future callers cannot leave the
  watcher subscribed indefinitely.
- editor-nudge.scss: remove duplicate color declaration on the CTA.
- Tests: cover the new SRFM_FORMS_POST_TYPE rejection and the
  edit_post denial path (via map_meta_cap filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Feature: minimum character limit for Textarea field (#2686)

* Feature: add minimum character limit for Textarea field

- Add `minLength` block attribute to textarea block.json schema
- Add "Minimum Characters" number control in block editor sidebar
- Render `minlength` HTML attribute on the frontend textarea element
- Add client-side validation in validation.js that blocks submission
  and shows "Please enter at least N characters." error message
- Add server-side validation in Field_Validation::validate_form_data()
  using mb_strlen for multibyte character safety
- Store minLength in block config via new process_textarea_block() method
- Add srfm_textarea_min_chars translatable message with sprintf placeholder
- Register srfm_textarea_min_chars in global-settings and settings update allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: use data-minlength instead of minlength to prevent browser native validation tooltip

Replace the HTML5 `minlength` attribute with `data-minlength` on the textarea element
so the browser does not show its native validation popup. SureForms custom inline error
message (displayed below the field) is now the only validation feedback shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: always render error element when minLength is configured

The .srfm-error-message element is only rendered when a field is marked
required. If a non-required textarea has a minLength set, the error div
was absent from the DOM, so JS had nowhere to write the message.

Pass override=true to set_markup_properties() when min_length is set,
ensuring the error element is always present for JS to populate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: address review feedback on Textarea minLength feature (#2688)

- Use customizable srfm_textarea_min_chars message in server-side
  validation instead of a hardcoded English string, so admins can
  customize the message via Settings -> Messages.
- Align format specifier with translatable.php (%s instead of %d) to
  avoid duplicate POT entries.
- Skip min-length tracking and rendering when isRichText is enabled,
  since the submitted value contains HTML markup that would skew
  mb_strlen counts.
- Drop minLength when it exceeds maxLength so misconfigured forms
  remain submittable; surface a warning in the editor.
- Resolve a pre-existing PHPStan level-9 violation on
  process_textarea_block by replacing the empty-string guard with
  is_numeric() type narrowing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>

* feat: add Hidden field support for payment dynamic amount (#2690)

* feat: add Hidden field support for payment dynamic amount

Extends the Payment block's variable-amount source list to include
Hidden fields alongside Number, Dropdown, and Multi-choice. The
configured defaultValue acts as the server-side source of truth for
the charged amount; submitted values that drift from it are rejected.

- Payment block UI lists Hidden fields in "Choose Amount Field"
- Form validation registers Hidden block config (default_value)
- Payment intent validation pins Stripe-charged amount to the
  configured Hidden default_value with 0.01 tolerance
- Stripe frontend reads .srfm-hidden-input value into
  data-current-amount and listens for input/change updates

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: trust runtime hidden value for payment dynamic amount

Hidden field values can be set dynamically at runtime (URL params,
cookies, JS) — pinning the expected payment amount to the configured
defaultValue blocks legitimate dynamic-amount flows.

Switches the validator to the same trust model the Number field uses:
the value submitted with the form is the expected amount; the Stripe-
charged amount must match it (0.01 tolerance) and clear the existing
minimum_amount floor.

- payment-helper: replace default_value pinning with submitted-value
  comparison; add srfm-hidden mapping in form-data lookup helper
- field-validation: drop srfm/hidden switch case and process_hidden_block
  (no server-side hidden config needed; also resolves PHPStan failure on
  sanitize_text_field with mixed input)
- stripe-payment.js: remove leftover debug console.log in hidden sync

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: align hidden-field payment validation with reviewer feedback

- stripe-payment.js: tighten the runtime hidden value parser so it
  matches PHP's strict numeric semantics. parseFloat alone accepts
  malformed inputs like "99abc" (returns 99) which the server then
  rejects via is_numeric(), leading to silent "amount required"
  failures. Reject anything that isn't a clean positive decimal up
  front so client and server agree on what counts as numeric.

- payment-helper.php: refresh stale comment above the minimum-amount
  floor check; hidden-field amount sources are now also subject to
  the floor, so the comment now names them explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: add /sureforms:sync-public skill (#2695)

Adds a Claude Code slash command that syncs private master to the
brainstormforce/sureforms-public mirror, stripping internal-only paths
in a single commit on the sync branch before opening / updating the
sync PR.

The skill is the single source of truth for which paths are filtered
when going from private to public — co-located with the other release
skills (sync-release-branches, sureforms:version-bump, etc.) so the
team has one place to maintain release-time tooling.

After the matching public-mirror cleanup PR (sureforms-public#80) lands,
all future syncs should use this skill instead of raw git push +
gh pr create — that's the only way to keep .claude/, internal-docs/,
SVN release workflows, and AI-generated analysis docs from re-leaking
to the public repo.

Co-authored-by: Aditya Jain <adi3890@gmail.com>

---------

Co-authored-by: Aditya Jain <adi3890@gmail.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
* Version Bump 2.8.2

* Update changelog for 2.8.2

* Trim 2.8.2 changelog to user-facing highlights
…DED_VER to 2.8.3 (#2699)

* Clarify Both payment type description (one-time + subscription)

* Bump SRFM_PRO_RECOMMENDED_VER to 2.8.3
When a textarea's minLength was cleared (or was non-numeric/missing),
process_textarea_block returned an empty config and the block was
dropped from $block_config. If no other validating blocks existed on
the form, add_block_config skipped update_post_meta entirely, leaving
the previously saved min_length in _srfm_block_config — so the
validator kept enforcing the old value.

- process_textarea_block: always emit a min_length key (0 when invalid,
  cleared, or rich-text) so the new save overwrites stale data.
- add_block_config: delete_post_meta when the resulting config is empty
  as a defensive backstop for the same class of bug.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d is mapped (#2701)

In Both mode with a hidden field mapped as the dynamic-amount source,
flipping One Time ↔ Subscription reset the displayed amount to 0.
listenAmountChanges() correctly read the hidden input's value via
syncAmount(), but the immediate-update block in switchActivePaymentType()
only branched on number/dropdown/multi-choice — so currentValue fell
through as 0 and overwrote what syncAmount() had just set.

Add the missing srfm-hidden-input branch using the same parsing rules
syncAmount() uses (numeric strings only, negatives clamped to 0).
Auto-generated by /i18n command on PR #2698

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…s (CVE-2026-7623) (#2705)

* fix(security): allowlist tag-name attributes in advanced-heading and image blocks

CVE-2026-7623: the `headingWrapper` and `headingTag` attributes were echoed in
HTML tag-name position with only `esc_attr()`/`esc_html()`, neither of which
strips spaces or `=`. A Contributor could plant `headingWrapper="img src=x
onerror=..."` and inject script execution on the rendered frontend.

Validate both attributes against the editor UI option lists before output:
  - headingWrapper: ['div', 'header'] -> fallback 'div'
  - headingTag (advanced-heading): ['h1'..'h6', 'p', 'div'] -> fallback 'h2'
  - headingTag (image): ['h1'..'h6'] -> fallback 'h2'

Also fixes a pre-existing bug in image block where the entire `id="..."`
fragment was wrapped in `esc_attr()`, encoding the structural quotes and
breaking the id attribute. Now escapes only the value inside the fragment.

`esc_attr()` / `esc_html()` calls are preserved as defense-in-depth.

Verified end-to-end on the local site with the published PoC payload:
the malicious wrapper renders as `<div>` (fallback), no `<img>` tag, no
script execution, no console errors.

* test: cover render_html tag-name allowlist (CVE-2026-7623)
osk02 and others added 27 commits May 27, 2026 15:40
…wups

Fix: address PR #2779 (2.10.0) code-review findings
Auto-generated by /i18n command on PR #2779
* Add HTML-form converter for raw `<form>` blocks in the editor

Detects `core/html` blocks containing a `<form>` element in the
post/page editor and offers a one-click "Convert to SureForms" notice
per block. On click, the parsed schema is posted to a new REST
endpoint `POST /sureforms/v1/convert-html-form`, the form is created
via the existing `Create_Form` ability, and the source `core/html`
block is swapped for a `core/shortcode` block holding the new form's
shortcode.

Detection + conversion details:
- Deterministic parser (`src/admin/html-form-detector/parse.js`)
  walks `<input>` / `<textarea>` / `<select>` in document order and
  maps to SureForms field types. Labels resolve via `<label for>`,
  wrapping `<label>`, `aria-label`, `placeholder`, then humanized
  `name`. `<fieldset><legend>` titles flow to the collapsed
  multi-choice field's label.
- Placeholder support: extracted from inputs / textarea / select's
  empty-first-option and added as a new `placeholder` property in the
  shared `Form_Field_Schema` so AI / MCP / converter callers all
  round-trip it. `Field_Mapping` forwards it to the block attrs.
- Inline styling on the source `<form>` and submit button drives
  native `_srfm_forms_styling` keys (`primary_color`, `text_color`,
  `text_color_on_primary`, `bg_type`/`bg_color`, `form_padding_*`,
  `form_border_radius_*`) so the converted form stays fully editable
  in the SureForms Styling sidebar — no custom CSS is written.
- Hybrid AI fallback: when the parser flags a form as low-confidence
  AND the caller passes raw HTML, the existing AI middleware is
  invoked to produce a structured schema.

Two new filters give SureForms Pro (and other extensions) a clean
seam without leaking pro-specific behavior into the free bundle:
- `srfm_html_form_detector_refine_fields( $fields, $html, $confidence )`
  lets extensions promote parsed fields to richer types (e.g. HTML5
  date/time/range/file inputs → pro `date-picker` / `time-picker` /
  `slider` / enriched `upload` blocks) by re-walking the source HTML.
- `srfm_html_form_detector_after_styling( $form_id, $styling, $html )`
  lets extensions layer in additional styling meta (e.g. a pro
  `form_theme` preset).

`Html_Form_Detector` is instantiated outside the `is_admin()` gate in
`plugin-loader.php` so the REST endpoint registers in REST-dispatch
context (where `is_admin()` is false). Script enqueue is still gated
by `allow_load()` so non-admin pages do not load the JS.

* Move Convert affordance from global notice onto each block toolbar

Replaces the editor-wide `core/notices` info banner with a per-block
"Convert to SureForms" toolbar button mounted via an
`editor.BlockEdit` filter. Discoverability is better — the action
lives where the user is editing the form — and the N-forms case is
handled natively: every `core/html` instance with a detected `<form>`
gets its own toolbar button independently, so a page with multiple
raw HTML forms no longer needs a single banner that can only act on
one block at a time.

Drops the subscriber-based reconciler entirely; the BlockEdit HOC
re-evaluates per instance using the block's own render lifecycle, and
parser invocation is memoized against `attributes.content` so
unrelated re-renders do not re-walk the DOM.

Asset enqueue picks up the new deps (`wp-block-editor`,
`wp-components`, `wp-compose`, `wp-element`, `wp-hooks`); the
auto-generated `htmlFormDetector.asset.php` already reflects this.

Verified end-to-end in the editor: with two raw HTML form blocks on
the same page, clicking Convert on the first block swaps just that
block for a `core/shortcode` while the second block is untouched.

* Use SureForms brand logomark for the Convert toolbar button

Swaps the generic `forms` dashicon for the canonical SureForms
logomark imported from `@Image/Logo.js` — the same component
registered as the `srfm/form` block icon. Visual consistency: the
affordance users click in the block toolbar now matches the icon
they already associate with SureForms in the inserter, the form
post-type listing, and the WP admin sidebar.

No new file or markup duplication — the existing component is
imported via the `@Image` webpack alias (already wired up in
`webpack.config.js`).

* Address review feedback on HTML form converter

Security
- AI fallback now scrubs `<input type="hidden">`, `value="…"` and the
  form's `action` attribute before forwarding to the SureForms AI
  middleware. The middleware only needs the structural shape of the
  form to infer field types; the concrete payload is the part that
  could carry prefilled tokens, server-rendered emails, or internal
  endpoint URLs the admin did not author.
- REST route is now registered only on admin / REST-dispatch
  requests instead of every plugin-bootstrap. Narrows blast radius
  if the shared `Helper::get_items_permissions_check` is ever
  loosened by an unrelated change. The in-handler `manage_options`
  check is still the primary gate.
- AI response defensively filtered: any non-array entry in
  `formFields` is dropped before reaching the `Create_Form` schema
  validator.

REST hardening
- `args` schema declares `type` for every parameter
  (`parsed_fields` → array, `styling` → object, scalars → string)
  so the dispatcher rejects malformed payloads before they reach
  the handler.
- `methods` switched from `'POST'` to `WP_REST_Server::CREATABLE`.
- `shorthand_to_sides()` regex documented as a security-critical
  CSS-injection barrier so a future "support `calc()` / `vh`" PR
  cannot silently relax the unit whitelist.

Editor UX
- Snackbar error messages now derived from `error.code` via a
  whitelist (`errorMessageForCode`) rather than forwarding raw
  `error.message`. Prevents server-side stack hints leaking into
  the editor UI.
- Missing nonce now produces an explicit
  "reload the editor" snackbar instead of letting the request
  fail with a confusing 403.
- ToolbarButton `label` follows `isConverting` so screen readers
  announce the busy state — `isBusy` alone is purely visual.

* Add PHPUnit coverage for Html_Form_Detector

CI's `check-test-coverage` flagged 11 new functions in
`Html_Form_Detector` without matching test methods. This adds the
expected `test_*` methods covering:

- `allow_load()` — admin gate, screen guard, CPT exclusion,
  capability gate.
- `enqueue_scripts()` — handle is enqueued only when allow_load() passes.
- `register_rest_endpoint()` — route is added, existing routes
  preserved, non-array input normalised, type-declared args.
- `handle_convert_html_form()` — happy path with valid nonce
  produces a form + shortcode; missing nonce → WP_Error with the
  documented error code.
- `apply_native_card_styling()` — bg_type/color, padding, border
  radius all flow into `_srfm_forms_styling`; empty styling input
  leaves the meta untouched.
- `shorthand_to_sides()` — every CSS shorthand arity (1/2/3/4
  values), the unit whitelist rejecting `vh` / `calc()`, garbage
  input collapsing to null.
- `extract_fields_via_ai()` — unreachable middleware surfaces
  WP_Error rather than throwing.
- `scrub_html_for_ai()` — hidden inputs and prefilled values
  removed; structural attributes (`name`, `required`) preserved;
  `action` attribute stripped; non-string input defensively
  collapses to "".
- `build_form_metadata()` — submit text + colors + form bg flow
  into the right slices; invalid hex falls back to the safe default;
  empty submit text omits the general slice.
- `pick_hex()` — valid hex pass-through; falsy / non-hex falls
  back to default.
- `strip_internal_hints()` — internal `_groupName` / `_optionValue`
  / `_groupLabel` / `confidence` keys removed; non-array entries
  dropped.

Pure transforms exercised directly via reflection — no WP fixtures
needed. IO-heavy methods exercised with thin contract tests; full
integration paths remain covered by the Playwright suite.

* Fix Convert to SureForms REST endpoint 404 (issue #2750)

The endpoint POST /sureforms/v1/convert-html-form returned 404 because
the constructor only attached its filter to srfm_rest_api_endpoints when
is_admin() was true OR REST_REQUEST was already defined.

The constructor runs on the 'init' hook (plugin-loader.php). For a REST
dispatch, is_admin() is false, and REST_REQUEST is not defined until
parse_request — which fires *after* init. So during the actual REST
dispatch the filter was never attached, the endpoint never registered,
and the Convert to SureForms toolbar action failed silently (graceful
fallback left the source core/html block intact, but no form was ever
created).

The author already understood the is_admin() half of the problem (see
plugin-loader.php:222-224 comment) and made the instance creation
unconditional. The matching guard on the constructor was the missed
half.

Fix: drop the conditional. apply_filters( 'srfm_rest_api_endpoints',
... ) only runs from Rest_Api::register_endpoints() on rest_api_init,
so attaching the filter on non-REST requests has zero runtime cost.

Verified end-to-end on a local Valet site:
- /sureforms/v1/convert-html-form now appears in REST discovery
- Convert button click returns 200 with form_id + shortcode
- Source core/html block is replaced with core/shortcode containing
  [sureforms id="X"]

* Preserve surrounding markup when converting HTML form blocks

When a Convert to SureForms action ran against a core/html block that
held more than just the <form> — a wrapping <div>, a heading above
the form, a post-submit message paragraph below it, an inline <script>,
etc. — the block-for-block replace silently deleted every byte of that
surrounding context. The user got the shortcode for the new SureForms
form, but lost the page chrome they almost certainly meant to keep.

Fix: after the REST conversion succeeds, parse the source HTML, remove
just the <form> element from the DOM, and check what is left.

- Nothing else in the block (form was the only content, with optional
  whitespace) → keep the historical one-for-one block swap. No
  regression for users who pasted a bare <form>.
- Other markup is present → emit two blocks: a fresh core/html block
  holding the stripped remnant, followed by the new core/shortcode
  block. The order intentionally places the shortcode after the
  preserved content; positioning it precisely where the <form> sat
  would require splitting the wrapper across two HTML blocks, which
  corrupts nested structure (e.g. a wrapping <div>) and is worse than
  the small loss of in-place positioning.

Verified end-to-end on a local Valet site with a realistic snippet —
a wrapped <h2> + <form> + post-submit <p> + inline <script>. Before
the fix the resulting block tree held only the shortcode. After the
fix the tree holds the stripped HTML block (with <h2>, <p>, and
<script> intact) followed by the shortcode block.

* Resolve free-floating <label> siblings and fix radio option labels

Two label-resolution gaps surfaced while testing realistic HTML form
snippets through the converter:

1. Radio group options inherited the group's name attribute.

A snippet like

    <input type="radio" name="rating" value="1"> 1
    <input type="radio" name="rating" value="2"> 2
    …

produced a multi-choice field with five identical 'Rating' options.
resolveLabel() fell through to the name attribute for the per-option
label, but name is the *group* identifier shared by every option —
using it produces N copies of the same label and discards the actual
option text that sits next to each <input>.

Fix: give radios a dedicated resolveRadioOptionLabel() that prefers
the trailing text node ('<input> 5'), then label-by-id, then a
wrapping <label>, then the value attribute. It deliberately never
falls back to name.

2. Free-floating <label> siblings weren't associated with their field.

A snippet like

    <label>What can we improve?</label>
    <textarea name="feedback"></textarea>

assigned the textarea the label 'Feedback' (humanized name) instead
of the visible 'What can we improve?'. resolveLabel() only looked
for label[for], a wrapping <label>, aria-label, placeholder, and
name — it never considered a label authored as a preceding sibling.

Fix: introduce readPrecedingLabelText() that walks back through
previous siblings, skipping only whitespace text and <br> elements,
and returns the text of the first <label> it finds (so long as that
label has no for attribute, in which case it already belongs to a
specific field). resolveLabel() now consults this between the
wrapping-label and aria-label steps.

For radios specifically, the same helper is used as a fallback for
the group title (the question being asked) — <fieldset><legend> is
still preferred, then a preceding free-floating <label> before the
first radio (the common shape:
'<label>How satisfied?</label><br><input type="radio">'). Subsequent
radios in the group return '' from the helper because their preceding
sibling is the previous radio's trailing text, so only the first
radio contributes a group label — which is exactly what
collapseRadioGroups() consumes when it builds the merged
multi-choice placeholder.

Verified end-to-end against five realistic HTML snippets in a local
Valet site. Before the fix the feedback form parsed as label 'rating'
with options ['Rating' × 5] and textarea label 'Feedback'. After the
fix the same snippet produces label 'How satisfied are you?' with
options ['1','2','3','4','5'] and textarea label 'What can we
improve?'. The registration / lead-magnet / file-upload / multi-step
snippets still parse identically (no regression on the wrapping-label
or placeholder paths).

* Address review feedback (F4-F9) on HTML form converter

Six follow-ups from review on PR #2749.

F4 — Cap placeholder length to 500 chars (medium severity).
inc/ai-form-builder/field-mapping.php: sanitize_text_field() strips
control chars but does not bound length, and the new placeholder
forwarding lands here from three call sites (AI, MCP, HTML
converter) — a pathological caller could push a multi-MB string into
the block's post-meta. Wrap in wp_html_excerpt to enforce the same
implicit boundedness every other string field in this mapper relies
on.

F5 — Normalize Unicode whitespace before scrubbing for AI.
inc/admin/html-form-detector.php: scrub_html_for_ai used \s in three
regex passes. PCRE's default \s is ASCII-only, so a payload like
'<input type=<NBSP>"hidden">' (U+00A0 between attr name and =)
sails through without being scrubbed. Defense-in-depth — the trust
boundary is the manage_options cap on the route — but worth closing.
Replace NBSP / em-space / en-space with plain space up front and the
existing patterns work as intended.

F6 — Drop ':scope >' from the legend lookup.
src/admin/html-form-detector/parse.js: <fieldset><legend> wrapped in
a Tailwind/Bootstrap styling <div> was missed because the lookup
required <legend> to be a direct child. The first descendant <legend>
inside the closest <fieldset> is good enough; nested fieldsets each
have their own closest('fieldset') resolution so we never cross
boundaries.

F7 — AI fallback over-triggered on a single low-confidence field.
src/admin/html-form-detector/parse.js: a single field with
confidence='low' (e.g. an <input type="color"> in an otherwise
standard contact form) flipped the whole form to low and routed to
AI. AI is the expensive path (network + tokens + middleware cost);
reserve it for forms where the local parser genuinely can't carry
most of the load. Switch to majority-low.

F8 — Throw an Error, not a plain object.
src/admin/html-form-detector/index.js: 'throw { code: ... }' loses
the stack trace and trips eslint(no-throw-literal). Build an Error,
attach the code as a property, and the existing catch handler
continues to work unchanged.

F9 — @SInCE x.x.x annotation on the new placeholder schema property.
inc/abilities/forms/form-field-schema.php: the @SInCE convention is
followed almost everywhere for new schema additions; this property
slipped through. Inline single-line comment beside the property key
since per-array-element docblocks aren't a meaningful target.

For the reviewer's 'Boot order verified' note in the same review:
that assertion turned out to be incorrect and is what caused issue
#2750 (already addressed in be84329). The constructor gated the
add_filter() call on (is_admin() || REST_REQUEST), but the
constructor runs on 'init' which fires before parse_request — the
hook at which WordPress defines REST_REQUEST for REST dispatch — so
the filter was never attached for the actual REST request and the
endpoint 404'd. be84329 dropped that conditional.

* Harden AI scrubber and drop translation wrap on machine prompt

Follow-ups from a panel review on PR #2749. Two issues, both in
inc/admin/html-form-detector.php.

B1 — scrub_html_for_ai leaked text-node content to the AI middleware.

The previous pass dropped <input type=hidden> and any value= attribute,
but the source <form> can carry sensitive data in three other shapes
the regex pass never touched:

  <!-- INTERNAL_API_KEY=sk-live-... -->
  <textarea name="draft">half-written reply with pii@...</textarea>
  <script>const TOKEN = "...";</script>

All three flowed verbatim into the outbound request to
api.sureforms.com. The data class the scrubber's docblock explicitly
calls out (CSRF tokens, server-rendered emails, internal endpoints)
includes exactly this content — comments routinely host staging
notes and build hashes, textareas hold pre-filled drafts, and
inline scripts carry runtime config. The shape of the form (the
fact that a textarea or script tag exists at this position) is the
only thing the conversion model needs.

Fix: extend the existing regex pipeline with three more passes that
strip comments entirely, and empty <script>/<textarea> bodies while
keeping the opening/closing tags so the model still sees the
structural element. The bounded 32 KB MAX_HTML_BYTES upstream keeps
the unbounded .*? backtracking safe.

B2 — System prompt to the AI middleware was wrapped in __().

The string at the top of extract_fields_via_ai is an instruction to
the model, not user-facing copy. Wrapping it in __( 'sureforms' )
invites translators to localize a machine prompt — and even when
they leave it alone the .pot generation surfaces an entry that
makes no sense as a translation target. Drop the __() wrap. Add a
short docblock comment explaining why so the next reader does not
re-wrap it on style grounds.

Coverage: extended test_scrub_html_for_ai to assert all three new
classes of leak (comment / textarea body / script body) are gone
AND the structural tags survive. Existing assertions on hidden
input / value / action / required-attr / non-string-input also
still hold. Verified locally outside the WP test harness.

* Address panel-review medium items on HTML form converter

Four follow-ups from the panel review on PR #2749. None individually
blocking, but all worth landing in this PR rather than a churned
follow-up.

M1 — Reorder capability check before nonce verification.

The REST framework's permission_callback already requires
manage_options (via Helper::get_items_permissions_check, which
defaults to manage_options), so this is largely cosmetic. But
inside the handler the order was reversed: a subscriber-level user
with a valid wp_rest nonce (every logged-in user can mint one via
apiFetch) would land on 'srfm_html_convert_nonce_failed' if the
permission_callback was ever filtered loose, which is the wrong
error code AND wastes a hash_hmac on a request that cannot
legitimately succeed. Run the cap check first; nonce becomes
CSRF defense for the user we already confirmed. New PHPUnit
'test_handle_convert_html_form_rejects_subscriber_with_forbidden'
locks in the order — a subscriber with a valid wp_rest nonce now
returns 'srfm_html_convert_forbidden', not 'nonce_failed'.

M2 — Tighten srfm_html_form_detector_refine_fields filter contract.

Create_Form re-sanitizes a hardcoded list of properties (label,
placeholder, helpText, defaultValue, fieldOptions) but not any
property a pro/3rd-party callback introduces beyond that set — a
date-picker's dateFormat, a file-upload's allowedFormats, etc.
A sloppy callback that re-emitted raw HTML in any of those would
land in block attributes that some renderer could emit as inner
HTML — stored-XSS.

Two-part fix: extend the filter docblock with an explicit security
contract (strings -> sanitize_text_field/wp_kses_post, scalars ->
absint/floatval, arrays -> sanitize each leaf), and add a defensive
post-filter sweep (strip_unsafe_html_in_fields) that walks every
string leaf in every field at any depth through wp_strip_all_tags.
Form-field attributes are not HTML containers, so stripping tags
costs nothing legitimate. New PHPUnit
'test_strip_unsafe_html_in_fields' asserts label/helpText/options
labels/allowedFormats leaves all get stripped, scalars (required,
maxFiles) survive untouched.

M3 — KSES-filter preserved markup server-side.

The block-replacement path now returns two blocks when the source
core/html block held more than just <form> — a fresh core/html
block holding the wrapping markup, then the shortcode. The client
was doing the strip and feeding the result straight back into the
editor with no sanitization pass. On multisite a site admin holds
manage_options without unfiltered_html, so the converter became
a way to surface previously-hidden <script> / <iframe> markup
that the original <form> was masking.

Fix: move the strip server-side (strip_form_for_preservation
using DOMDocument), run the remnant through wp_kses_post when the
caller lacks unfiltered_html, return the result as
response.preserved_html. JS prefers the server value when present
and falls back to stripFormFromHtml only when missing. New
PHPUnit tests cover the editor-role (kses applies, <script>
stripped) and admin-role (whole-form path, empty remnant) cases.

M4 — Bail parseFormHtml deep parse for oversized forms.

The parser runs on every keystroke via useMemo([content]). For
forms with hundreds of inputs the O(N^2) label-by-id +
closest('fieldset') walk blocks the editor main thread. Bail
above FORM_INPUT_SOFT_LIMIT (200) with a sentinel field at
confidence: 'low' — keeps the toolbar button visible AND routes
the conversion through the AI middleware, which runs server-side
off the editor's main thread and has the full raw HTML to work
from.

* Fix PHPCS/Insights failures on html-form-detector

Replace `@$dom->loadHTML()` with `libxml_use_internal_errors()` to
mirror the pattern in `Helper::strip_js_attributes()` — drops the
PHP Insights `NoSilencedErrors` violation. Add per-line PHPCS
ignores for the DOM API's camelCase `parentNode` / `childNodes`
properties.

* Fix missing defaults on converted forms and address remaining review items

- create-form.php: replace hardcoded empty arrays/strings with
  get_registered_meta_keys() lookups so forms created via the converter,
  MCP, and AI builder receive the same _srfm_form_confirmation,
  _srfm_compliance, _srfm_forms_styling, _srfm_email_notification, and
  _srfm_form_restriction defaults as hand-created forms
- test-html-form-detector.php: fix F2 blocking assertion —
  formBackgroundColor routes to _srfm_forms_styling via
  apply_native_card_styling, not into instantForm; assert
  assertArrayNotHasKey instead of the incorrect assertSame
- html-form-detector.php: replace @-silenced loadHTML with
  libxml_use_internal_errors(); add LIBXML_NONET | LIBXML_HTML_NODEFDTD
  flags; add phpcs:ignore comments on DOM camelCase property accesses
- index.js: remove client-side stripFormFromHtml fallback — always
  use server-provided preserved_html to keep wp_kses_post gating intact
  for multisite site-admins without unfiltered_html

* Exclude tests directory from PHPStan analysis

The pre-push hook passes test files explicitly to PHPStan, but tests/
was never meant to be in scope — it's absent from phpstan.neon `paths`.
Adding it to `excludePaths` ensures the exclusion is honoured even
when the hook passes the path explicitly.

* feat: add live character counter to textarea fields

Shows X/limit counter below textarea when min or max length is configured.
Counter uses error color when current length is below minLength, muted text color otherwise.
Font size matches the existing error message size (--srfm-error-font-size).

* refactor: use float right for char counter alignment

Position counter on the same line as the error message using float: right
instead of a wrapper element, keeping srfm-error-wrap structure untouched.

* fix: match char counter typography exactly to error message

Add missing font-weight and line-height variables to .srfm-char-counter
so it aligns pixel-perfectly with the error message on the same line.

* fix: move help text outside <legend> for dropdown and address (#2766)

The .srfm-description span was nested inside <legend> for dropdown
and address but rendered as a sibling of the input wrapper for
multichoice. Two consequences:

1. Screen readers prepended the description to the fieldset's
   accessible name (legend provides the group's accessible name),
   then aria-describedby announced it again on focus — the help text
   was read twice.
2. Pro's per-preset visual-order rules in Baseline / Minimal / Dark
   (sureforms-pro/sass/presets/_presets.scss) flip label / input /
   description via flexbox `order`, but cannot reach descriptions
   trapped inside <legend>'s flex context — so dropdown and address
   descriptions stayed above the input on those presets.

Move help_markup out of <legend> for both fields, matching multichoice.
DOM order remains label → description → input → error for screen
readers; aria-describedby already wires the description ID to the
input, no change required there. No CSS in either plugin targets the
legend-nested description, so no styling regression.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sync dev with next-release for 2.10.0 (#2777)

* feat: support Dynamic Default Value on multi-select dropdown and checkbox multi-choice (#2754)

- Show the Dynamic Default Value field in every mode for Dropdown and
  Multi-Choice blocks; stop wiping it on the Allow Multiple / Single
  Choice Only toggle.
- Add mode-aware help text describing comma-separated multi-value usage.
- Generalize resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php to split the resolved smart-tag string on `,`,
  trim segments, and collect every matching option. Single-select / radio
  still take only the first match.
- When Add Numeric Values to Options is on, also match each segment
  against the option's `value`.
- Preserve editor-set preselectedOptions when no segment matches.

Closes #2754

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(blocks): surface single-param + comma-separated URL pattern in help text

The multi-value resolver already handles `?colors=Red,Blue` with a single
`{get_input:colors}` smart tag — no logic change needed. Reword the help
text on the Dropdown (multi-select) and Multi-Choice (checkbox) blocks to
lead with this simpler one-key pattern, and keep the multi-tag chaining
note as a secondary option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve line breaks in CSV export for textarea fields

sanitize_text_field() strips newlines, flattening multi-line textarea
submissions onto a single line in exported CSVs. Use
sanitize_textarea_field() for srfm-textarea field keys, which sanitizes
without removing intentional line breaks.

Fixes #2761

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(entries): capture and display actual submission page URL (#2757)

Before: the entry "URL:" row always showed the form CPT permalink
(the SureForms "instant form" URL) regardless of which page the user
actually filled the form on. Multi-embed flows, attribution, UTM
tracking, and customer-service context all suffered.

This change captures the real submission URL at submit time and shows
it in the entry record.

How:
- inc/generate-form-markup.php: emit a hidden <input name="srfm-page-url">
  alongside the existing form-id / sender-email / page-break inputs.
- assets/js/unminified/form-submit.js: populate that input with
  window.location.href right before FormData reads the form, so the
  current page URL travels with the submission. Client-side capture is
  used because HTTP_REFERER is unreliable under page caching.
- inc/form-submit.php: read `srfm-page-url` from the sanitized form
  data, fall back to wp_get_referer() if missing, sanitize via
  esc_url_raw(), and persist as `submission_info.submission_url`.
  Suppressed under GDPR mode alongside ip/browser/device.
- inc/rest-api.php + inc/abilities/entries/entry-parser.php: pass
  `submission_url` through the entry response payload.
- src/admin/entries/utils/entryHelpers.js: map `submission_url` →
  `submissionUrl` on the React side.
- src/admin/entries/components/SubmissionInfoSection.js: display the
  real URL; fall back to `formPermalink` for pre-migration entries so
  existing rows still render a meaningful URL instead of '-'.

Backward compat: existing entries gracefully show their form permalink
(prior behavior). New entries get the real submission URL.

Out of scope for this PR (tracked in #2757 AC):
- {submission_url} smart tag (separate change in inc/smart-tags.php)
- CSV / Excel / PDF export column

Closes #2757

* refactor(entries): capture submission URL server-side from Referer

Drop the client-side `srfm-page-url` hidden field and JS that populated it
from `window.location.href`. Capture the URL server-side from the Referer
header instead, with three layered guards:

  - same-origin check (rejects cross-origin URLs entirely — no phishing
    destinations, no mailto:/tel:/data: schemes can ever be stored)
  - http/https scheme allowlist passed to esc_url_raw
  - 2048-char length cap against non-browser clients sending bloated headers

The page-caching concern that motivated client-side capture doesn't actually
apply here: SureForms uses an HMAC submit token, not wp_nonce_field, so
`_wp_http_referer` is never embedded in the form HTML and `wp_get_referer()`
falls through to the per-request `$_SERVER['HTTP_REFERER']` — which is set
by the browser per request and is not affected by server-side page caching.

Trade-off: when a browser strips the Referer header (strict Referrer-Policy,
privacy extensions) the field is stored empty rather than capturing a
client-supplied value. The admin display falls through to formPermalink for
those entries, matching pre-2.10 behavior.

Also: document `submission_url` in ABILITIES.md and add a one-line comment
explaining why entryHelpers.js uses '' (not '-') for the empty default.

* fix(entries): sanitize HTTP_REFERER properly to satisfy phpcs

Use sanitize_text_field( wp_unslash() ) per the project's input-handling
convention instead of an incorrect phpcs:ignore comment. The previous
ignore tag referenced a sniff that doesn't apply; PHPCS was actually
firing WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
because the Referer header is treated as user input.

The same-origin and esc_url_raw guards still gate what gets stored;
sanitize_text_field just adds an early pass that strips HTML and
control characters, which real browsers never send in a Referer.

* fix(entries): strip userinfo from accepted submission URLs

Real browsers strip userinfo from the Referer header, but a non-browser
client could supply `http://user:pass@host/…`. The host check still
catches cross-origin abuse, but storing the userinfo verbatim would
display weirdly in the admin entry row. Parse the URL and rebuild
without the user/pass segment before storing.

* fix(entries): harden submission URL capture and display

Apply findings from a focused security review:

1. Drop URL fragment from the rebuilt URL. Real browsers do not send
   fragments in the Referer header, so any fragment present came from a
   non-browser client and was fully attacker-controlled. The fragment
   ended up rendered as link text in the admin entries view, which is a
   social-engineering vector (e.g. "#login-required").

2. Add port to the same-origin comparison. Previously the stored URL
   preserved any port from the Referer, but the host check ignored it,
   so a Referer like https://yoursite.com:9999/... was accepted as
   same-origin and stored verbatim. Now require an exact match of both
   host (case-insensitive) and port between the Referer and home_url().

3. Defense-in-depth: re-validate submission_url at the exposure
   boundary in inc/rest-api.php and inc/abilities/entries/entry-parser.php
   with esc_url_raw($val, ['http','https']). If a future code path ever
   writes the field bypassing form-submit.php, downstream consumers
   still get a safe value.

4. Defense-in-depth: in src/admin/entries/components/SubmissionInfoSection.js,
   only render the URL row as a clickable <a> when the value matches
   /^https?:\/\//i. React does not block exotic href schemes at runtime,
   so the prior `val && val !== '-'` guard relied entirely on PHP-side
   sanitization. The regex guard removes that trust dependency.

* style(entries): use null coalescing for path component (phpinsights)

* fix: address review feedback and normalize redirect URL multi-value separators

Review feedback on resolve_dynamic_default in dropdown-markup.php and
multichoice-markup.php:

- Restore pre-PR single-select behavior for labels containing commas
  (e.g. "Bonaire, Sint Eustatius and Saba"): gate the comma-split on
  multi-mode only. Single-select / radio match the trimmed resolved
  value as a single segment.
- Document in both docblocks that the comma-split also applies to
  {get_cookie:...} values when in multi-select / checkbox mode.
- Add is_scalar() guard before the (string) cast on $option['value']
  to keep PHPStan level 9 strict against schema loosening.
- Cap the segment array via array_slice( ..., 0, 50 ) to remove the
  DoS surface from pathological inputs like ?colors=,,,,,...

Redirect URL multi-value normalization in generate-form-markup.php:

- After smart-tag substitution, replace the two delimiters left over
  from multi-value field substitution -- "<br>" from parse_form_input
  for checkbox multi-choice and " | " from the raw multi-select
  dropdown storage -- with a plain comma. The final URL becomes a
  clean comma-separated list the receiver can split on ",".
- str_replace runs before html_entity_decode so option labels that
  contain a literal "<br>" sequence (which parse_form_input escapes
  to "&lt;br&gt;") survive intact: only the actual delimiter is
  replaced, and html_entity_decode then restores the option's text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: switch multi-value dynamic default separator from comma to pipe

Replace "," with "|" as the multi-value delimiter for Dynamic Default
Value on multi-select dropdowns and checkbox multi-choice.

- resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php now explodes the resolved smart-tag string on
  "|" in multi-mode. Single-select / radio mode is unchanged (single
  trimmed segment).
- get_redirect_url() in generate-form-markup.php normalizes the leftover
  "<br>" (checkbox storage) and " | " (multi-select dropdown storage)
  delimiters to a plain "|" instead of "," so the redirect URL emits a
  clean pipe-delimited list the receiver can split on "|".
- Update editor help text on both blocks to document the
  ?colors=Red|Blue pattern.
- Update docblocks to describe the pipe split behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
Co-authored-by: Aditya Jain <adi3890@gmail.com>

* Version Bump 2.10.0

* Regenerate POT after build

* Update 2.10.0 changelog with milestone items

* udpate changelog

* Update README.md

* Update readme.txt

* Fix: address PR #2779 (2.10.0) code-review findings

Follow-up fixes for issues found while reviewing the 2.10.0 release PR.

Dynamic default value (Dropdown / Multi-Choice):
- Guard option label/title with is_scalar() before strcasecmp() so a
  malformed/array-shaped option no longer throws a PHP 8 TypeError that
  fatals frontend form render.
- Coalesce dynamicDefaultValue before the is_string() check so blocks
  saved before 2.10.0 (no such attribute) stop emitting an
  "Undefined array key" warning on every frontend render. This also
  fixes 6 pre-existing unit-test errors.
- Correct @SInCE tags to note the 2.10.0 multi-value behavior.

Textarea character counter (frontend.js):
- Switch to delegated input handling so counters keep working for
  fields added/revealed after first paint (multi-step, conditional
  logic, AJAX-loaded forms), not just on initial load.
- Flag the over-limit (max) state, not only the under-limit (min) state.

HTML form converter (html-form-detector):
- Guard window.CSS.escape and wrap the parser in try/catch so malformed
  or hostile HTML degrades to "no Convert button" instead of crashing
  the block edit render.
- Debounce parsing (250ms) so typing in the source HTML block does not
  run DOMParser + the field walk on every keystroke.
- Prefer explicit submit controls and fall back to the last typeless
  <button>, skipping cancel/reset/back/close, so the submit label is not
  taken from a leading "Cancel" button.

Entries:
- Neutralize CSV formula/macro injection (cells starting with = + - @
  tab/CR) on export while leaving genuine numbers intact.
- Guard entryData.status before toLowerCase() in SubmissionInfoSection.
- Rename the local one-arg formatDateTime to formatEntryListDate to
  remove the same-name/different-signature clash with @Utils/Helpers.

* Fix: preserve Save & Progress and Conditional Confirmation on form duplicate and import

JSON-string post metas (_srfm_save_resume, _srfm_conditional_confirmation)
were being wiped on duplicate and import. get_post_meta() returns unslashed
data, but add_post_meta() runs wp_unslash() before the registered
sanitize_callback. Stripping those backslashes broke the embedded escaped
quotes, so json_decode() failed and the sanitizers returned an empty string.

Wrap the copied values in wp_slash() in duplicate-form.php and
import_forms_with_meta() so add_post_meta()'s internal unslash restores the
original value intact.

* Add display flex to block editor block card in single form settings description

* chore: update i18n translations

Auto-generated by /i18n command on PR #2779

---------

Co-authored-by: Aditya Jain <adi3890@gmail.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
# Conflicts:
#	inc/fields/dropdown-markup.php
#	inc/fields/multichoice-markup.php
* feat: support Dynamic Default Value on multi-select dropdown and checkbox multi-choice (#2754)

- Show the Dynamic Default Value field in every mode for Dropdown and
  Multi-Choice blocks; stop wiping it on the Allow Multiple / Single
  Choice Only toggle.
- Add mode-aware help text describing comma-separated multi-value usage.
- Generalize resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php to split the resolved smart-tag string on `,`,
  trim segments, and collect every matching option. Single-select / radio
  still take only the first match.
- When Add Numeric Values to Options is on, also match each segment
  against the option's `value`.
- Preserve editor-set preselectedOptions when no segment matches.

Closes #2754

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(blocks): surface single-param + comma-separated URL pattern in help text

The multi-value resolver already handles `?colors=Red,Blue` with a single
`{get_input:colors}` smart tag — no logic change needed. Reword the help
text on the Dropdown (multi-select) and Multi-Choice (checkbox) blocks to
lead with this simpler one-key pattern, and keep the multi-tag chaining
note as a secondary option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve line breaks in CSV export for textarea fields

sanitize_text_field() strips newlines, flattening multi-line textarea
submissions onto a single line in exported CSVs. Use
sanitize_textarea_field() for srfm-textarea field keys, which sanitizes
without removing intentional line breaks.

Fixes #2761

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(entries): capture and display actual submission page URL (#2757)

Before: the entry "URL:" row always showed the form CPT permalink
(the SureForms "instant form" URL) regardless of which page the user
actually filled the form on. Multi-embed flows, attribution, UTM
tracking, and customer-service context all suffered.

This change captures the real submission URL at submit time and shows
it in the entry record.

How:
- inc/generate-form-markup.php: emit a hidden <input name="srfm-page-url">
  alongside the existing form-id / sender-email / page-break inputs.
- assets/js/unminified/form-submit.js: populate that input with
  window.location.href right before FormData reads the form, so the
  current page URL travels with the submission. Client-side capture is
  used because HTTP_REFERER is unreliable under page caching.
- inc/form-submit.php: read `srfm-page-url` from the sanitized form
  data, fall back to wp_get_referer() if missing, sanitize via
  esc_url_raw(), and persist as `submission_info.submission_url`.
  Suppressed under GDPR mode alongside ip/browser/device.
- inc/rest-api.php + inc/abilities/entries/entry-parser.php: pass
  `submission_url` through the entry response payload.
- src/admin/entries/utils/entryHelpers.js: map `submission_url` →
  `submissionUrl` on the React side.
- src/admin/entries/components/SubmissionInfoSection.js: display the
  real URL; fall back to `formPermalink` for pre-migration entries so
  existing rows still render a meaningful URL instead of '-'.

Backward compat: existing entries gracefully show their form permalink
(prior behavior). New entries get the real submission URL.

Out of scope for this PR (tracked in #2757 AC):
- {submission_url} smart tag (separate change in inc/smart-tags.php)
- CSV / Excel / PDF export column

Closes #2757

* refactor(entries): capture submission URL server-side from Referer

Drop the client-side `srfm-page-url` hidden field and JS that populated it
from `window.location.href`. Capture the URL server-side from the Referer
header instead, with three layered guards:

  - same-origin check (rejects cross-origin URLs entirely — no phishing
    destinations, no mailto:/tel:/data: schemes can ever be stored)
  - http/https scheme allowlist passed to esc_url_raw
  - 2048-char length cap against non-browser clients sending bloated headers

The page-caching concern that motivated client-side capture doesn't actually
apply here: SureForms uses an HMAC submit token, not wp_nonce_field, so
`_wp_http_referer` is never embedded in the form HTML and `wp_get_referer()`
falls through to the per-request `$_SERVER['HTTP_REFERER']` — which is set
by the browser per request and is not affected by server-side page caching.

Trade-off: when a browser strips the Referer header (strict Referrer-Policy,
privacy extensions) the field is stored empty rather than capturing a
client-supplied value. The admin display falls through to formPermalink for
those entries, matching pre-2.10 behavior.

Also: document `submission_url` in ABILITIES.md and add a one-line comment
explaining why entryHelpers.js uses '' (not '-') for the empty default.

* fix(entries): sanitize HTTP_REFERER properly to satisfy phpcs

Use sanitize_text_field( wp_unslash() ) per the project's input-handling
convention instead of an incorrect phpcs:ignore comment. The previous
ignore tag referenced a sniff that doesn't apply; PHPCS was actually
firing WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
because the Referer header is treated as user input.

The same-origin and esc_url_raw guards still gate what gets stored;
sanitize_text_field just adds an early pass that strips HTML and
control characters, which real browsers never send in a Referer.

* fix(entries): strip userinfo from accepted submission URLs

Real browsers strip userinfo from the Referer header, but a non-browser
client could supply `http://user:pass@host/…`. The host check still
catches cross-origin abuse, but storing the userinfo verbatim would
display weirdly in the admin entry row. Parse the URL and rebuild
without the user/pass segment before storing.

* fix(entries): harden submission URL capture and display

Apply findings from a focused security review:

1. Drop URL fragment from the rebuilt URL. Real browsers do not send
   fragments in the Referer header, so any fragment present came from a
   non-browser client and was fully attacker-controlled. The fragment
   ended up rendered as link text in the admin entries view, which is a
   social-engineering vector (e.g. "#login-required").

2. Add port to the same-origin comparison. Previously the stored URL
   preserved any port from the Referer, but the host check ignored it,
   so a Referer like https://yoursite.com:9999/... was accepted as
   same-origin and stored verbatim. Now require an exact match of both
   host (case-insensitive) and port between the Referer and home_url().

3. Defense-in-depth: re-validate submission_url at the exposure
   boundary in inc/rest-api.php and inc/abilities/entries/entry-parser.php
   with esc_url_raw($val, ['http','https']). If a future code path ever
   writes the field bypassing form-submit.php, downstream consumers
   still get a safe value.

4. Defense-in-depth: in src/admin/entries/components/SubmissionInfoSection.js,
   only render the URL row as a clickable <a> when the value matches
   /^https?:\/\//i. React does not block exotic href schemes at runtime,
   so the prior `val && val !== '-'` guard relied entirely on PHP-side
   sanitization. The regex guard removes that trust dependency.

* style(entries): use null coalescing for path component (phpinsights)

* fix: address review feedback and normalize redirect URL multi-value separators

Review feedback on resolve_dynamic_default in dropdown-markup.php and
multichoice-markup.php:

- Restore pre-PR single-select behavior for labels containing commas
  (e.g. "Bonaire, Sint Eustatius and Saba"): gate the comma-split on
  multi-mode only. Single-select / radio match the trimmed resolved
  value as a single segment.
- Document in both docblocks that the comma-split also applies to
  {get_cookie:...} values when in multi-select / checkbox mode.
- Add is_scalar() guard before the (string) cast on $option['value']
  to keep PHPStan level 9 strict against schema loosening.
- Cap the segment array via array_slice( ..., 0, 50 ) to remove the
  DoS surface from pathological inputs like ?colors=,,,,,...

Redirect URL multi-value normalization in generate-form-markup.php:

- After smart-tag substitution, replace the two delimiters left over
  from multi-value field substitution -- "<br>" from parse_form_input
  for checkbox multi-choice and " | " from the raw multi-select
  dropdown storage -- with a plain comma. The final URL becomes a
  clean comma-separated list the receiver can split on ",".
- str_replace runs before html_entity_decode so option labels that
  contain a literal "<br>" sequence (which parse_form_input escapes
  to "&lt;br&gt;") survive intact: only the actual delimiter is
  replaced, and html_entity_decode then restores the option's text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: switch multi-value dynamic default separator from comma to pipe

Replace "," with "|" as the multi-value delimiter for Dynamic Default
Value on multi-select dropdowns and checkbox multi-choice.

- resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php now explodes the resolved smart-tag string on
  "|" in multi-mode. Single-select / radio mode is unchanged (single
  trimmed segment).
- get_redirect_url() in generate-form-markup.php normalizes the leftover
  "<br>" (checkbox storage) and " | " (multi-select dropdown storage)
  delimiters to a plain "|" instead of "," so the redirect URL emits a
  clean pipe-delimited list the receiver can split on "|".
- Update editor help text on both blocks to document the
  ?colors=Red|Blue pattern.
- Update docblocks to describe the pipe split behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Version 2.10.0 (#2779)

* Add HTML-form converter for raw `<form>` blocks in the editor

Detects `core/html` blocks containing a `<form>` element in the
post/page editor and offers a one-click "Convert to SureForms" notice
per block. On click, the parsed schema is posted to a new REST
endpoint `POST /sureforms/v1/convert-html-form`, the form is created
via the existing `Create_Form` ability, and the source `core/html`
block is swapped for a `core/shortcode` block holding the new form's
shortcode.

Detection + conversion details:
- Deterministic parser (`src/admin/html-form-detector/parse.js`)
  walks `<input>` / `<textarea>` / `<select>` in document order and
  maps to SureForms field types. Labels resolve via `<label for>`,
  wrapping `<label>`, `aria-label`, `placeholder`, then humanized
  `name`. `<fieldset><legend>` titles flow to the collapsed
  multi-choice field's label.
- Placeholder support: extracted from inputs / textarea / select's
  empty-first-option and added as a new `placeholder` property in the
  shared `Form_Field_Schema` so AI / MCP / converter callers all
  round-trip it. `Field_Mapping` forwards it to the block attrs.
- Inline styling on the source `<form>` and submit button drives
  native `_srfm_forms_styling` keys (`primary_color`, `text_color`,
  `text_color_on_primary`, `bg_type`/`bg_color`, `form_padding_*`,
  `form_border_radius_*`) so the converted form stays fully editable
  in the SureForms Styling sidebar — no custom CSS is written.
- Hybrid AI fallback: when the parser flags a form as low-confidence
  AND the caller passes raw HTML, the existing AI middleware is
  invoked to produce a structured schema.

Two new filters give SureForms Pro (and other extensions) a clean
seam without leaking pro-specific behavior into the free bundle:
- `srfm_html_form_detector_refine_fields( $fields, $html, $confidence )`
  lets extensions promote parsed fields to richer types (e.g. HTML5
  date/time/range/file inputs → pro `date-picker` / `time-picker` /
  `slider` / enriched `upload` blocks) by re-walking the source HTML.
- `srfm_html_form_detector_after_styling( $form_id, $styling, $html )`
  lets extensions layer in additional styling meta (e.g. a pro
  `form_theme` preset).

`Html_Form_Detector` is instantiated outside the `is_admin()` gate in
`plugin-loader.php` so the REST endpoint registers in REST-dispatch
context (where `is_admin()` is false). Script enqueue is still gated
by `allow_load()` so non-admin pages do not load the JS.

* Move Convert affordance from global notice onto each block toolbar

Replaces the editor-wide `core/notices` info banner with a per-block
"Convert to SureForms" toolbar button mounted via an
`editor.BlockEdit` filter. Discoverability is better — the action
lives where the user is editing the form — and the N-forms case is
handled natively: every `core/html` instance with a detected `<form>`
gets its own toolbar button independently, so a page with multiple
raw HTML forms no longer needs a single banner that can only act on
one block at a time.

Drops the subscriber-based reconciler entirely; the BlockEdit HOC
re-evaluates per instance using the block's own render lifecycle, and
parser invocation is memoized against `attributes.content` so
unrelated re-renders do not re-walk the DOM.

Asset enqueue picks up the new deps (`wp-block-editor`,
`wp-components`, `wp-compose`, `wp-element`, `wp-hooks`); the
auto-generated `htmlFormDetector.asset.php` already reflects this.

Verified end-to-end in the editor: with two raw HTML form blocks on
the same page, clicking Convert on the first block swaps just that
block for a `core/shortcode` while the second block is untouched.

* Use SureForms brand logomark for the Convert toolbar button

Swaps the generic `forms` dashicon for the canonical SureForms
logomark imported from `@Image/Logo.js` — the same component
registered as the `srfm/form` block icon. Visual consistency: the
affordance users click in the block toolbar now matches the icon
they already associate with SureForms in the inserter, the form
post-type listing, and the WP admin sidebar.

No new file or markup duplication — the existing component is
imported via the `@Image` webpack alias (already wired up in
`webpack.config.js`).

* Address review feedback on HTML form converter

Security
- AI fallback now scrubs `<input type="hidden">`, `value="…"` and the
  form's `action` attribute before forwarding to the SureForms AI
  middleware. The middleware only needs the structural shape of the
  form to infer field types; the concrete payload is the part that
  could carry prefilled tokens, server-rendered emails, or internal
  endpoint URLs the admin did not author.
- REST route is now registered only on admin / REST-dispatch
  requests instead of every plugin-bootstrap. Narrows blast radius
  if the shared `Helper::get_items_permissions_check` is ever
  loosened by an unrelated change. The in-handler `manage_options`
  check is still the primary gate.
- AI response defensively filtered: any non-array entry in
  `formFields` is dropped before reaching the `Create_Form` schema
  validator.

REST hardening
- `args` schema declares `type` for every parameter
  (`parsed_fields` → array, `styling` → object, scalars → string)
  so the dispatcher rejects malformed payloads before they reach
  the handler.
- `methods` switched from `'POST'` to `WP_REST_Server::CREATABLE`.
- `shorthand_to_sides()` regex documented as a security-critical
  CSS-injection barrier so a future "support `calc()` / `vh`" PR
  cannot silently relax the unit whitelist.

Editor UX
- Snackbar error messages now derived from `error.code` via a
  whitelist (`errorMessageForCode`) rather than forwarding raw
  `error.message`. Prevents server-side stack hints leaking into
  the editor UI.
- Missing nonce now produces an explicit
  "reload the editor" snackbar instead of letting the request
  fail with a confusing 403.
- ToolbarButton `label` follows `isConverting` so screen readers
  announce the busy state — `isBusy` alone is purely visual.

* Add PHPUnit coverage for Html_Form_Detector

CI's `check-test-coverage` flagged 11 new functions in
`Html_Form_Detector` without matching test methods. This adds the
expected `test_*` methods covering:

- `allow_load()` — admin gate, screen guard, CPT exclusion,
  capability gate.
- `enqueue_scripts()` — handle is enqueued only when allow_load() passes.
- `register_rest_endpoint()` — route is added, existing routes
  preserved, non-array input normalised, type-declared args.
- `handle_convert_html_form()` — happy path with valid nonce
  produces a form + shortcode; missing nonce → WP_Error with the
  documented error code.
- `apply_native_card_styling()` — bg_type/color, padding, border
  radius all flow into `_srfm_forms_styling`; empty styling input
  leaves the meta untouched.
- `shorthand_to_sides()` — every CSS shorthand arity (1/2/3/4
  values), the unit whitelist rejecting `vh` / `calc()`, garbage
  input collapsing to null.
- `extract_fields_via_ai()` — unreachable middleware surfaces
  WP_Error rather than throwing.
- `scrub_html_for_ai()` — hidden inputs and prefilled values
  removed; structural attributes (`name`, `required`) preserved;
  `action` attribute stripped; non-string input defensively
  collapses to "".
- `build_form_metadata()` — submit text + colors + form bg flow
  into the right slices; invalid hex falls back to the safe default;
  empty submit text omits the general slice.
- `pick_hex()` — valid hex pass-through; falsy / non-hex falls
  back to default.
- `strip_internal_hints()` — internal `_groupName` / `_optionValue`
  / `_groupLabel` / `confidence` keys removed; non-array entries
  dropped.

Pure transforms exercised directly via reflection — no WP fixtures
needed. IO-heavy methods exercised with thin contract tests; full
integration paths remain covered by the Playwright suite.

* Fix Convert to SureForms REST endpoint 404 (issue #2750)

The endpoint POST /sureforms/v1/convert-html-form returned 404 because
the constructor only attached its filter to srfm_rest_api_endpoints when
is_admin() was true OR REST_REQUEST was already defined.

The constructor runs on the 'init' hook (plugin-loader.php). For a REST
dispatch, is_admin() is false, and REST_REQUEST is not defined until
parse_request — which fires *after* init. So during the actual REST
dispatch the filter was never attached, the endpoint never registered,
and the Convert to SureForms toolbar action failed silently (graceful
fallback left the source core/html block intact, but no form was ever
created).

The author already understood the is_admin() half of the problem (see
plugin-loader.php:222-224 comment) and made the instance creation
unconditional. The matching guard on the constructor was the missed
half.

Fix: drop the conditional. apply_filters( 'srfm_rest_api_endpoints',
... ) only runs from Rest_Api::register_endpoints() on rest_api_init,
so attaching the filter on non-REST requests has zero runtime cost.

Verified end-to-end on a local Valet site:
- /sureforms/v1/convert-html-form now appears in REST discovery
- Convert button click returns 200 with form_id + shortcode
- Source core/html block is replaced with core/shortcode containing
  [sureforms id="X"]

* Preserve surrounding markup when converting HTML form blocks

When a Convert to SureForms action ran against a core/html block that
held more than just the <form> — a wrapping <div>, a heading above
the form, a post-submit message paragraph below it, an inline <script>,
etc. — the block-for-block replace silently deleted every byte of that
surrounding context. The user got the shortcode for the new SureForms
form, but lost the page chrome they almost certainly meant to keep.

Fix: after the REST conversion succeeds, parse the source HTML, remove
just the <form> element from the DOM, and check what is left.

- Nothing else in the block (form was the only content, with optional
  whitespace) → keep the historical one-for-one block swap. No
  regression for users who pasted a bare <form>.
- Other markup is present → emit two blocks: a fresh core/html block
  holding the stripped remnant, followed by the new core/shortcode
  block. The order intentionally places the shortcode after the
  preserved content; positioning it precisely where the <form> sat
  would require splitting the wrapper across two HTML blocks, which
  corrupts nested structure (e.g. a wrapping <div>) and is worse than
  the small loss of in-place positioning.

Verified end-to-end on a local Valet site with a realistic snippet —
a wrapped <h2> + <form> + post-submit <p> + inline <script>. Before
the fix the resulting block tree held only the shortcode. After the
fix the tree holds the stripped HTML block (with <h2>, <p>, and
<script> intact) followed by the shortcode block.

* Resolve free-floating <label> siblings and fix radio option labels

Two label-resolution gaps surfaced while testing realistic HTML form
snippets through the converter:

1. Radio group options inherited the group's name attribute.

A snippet like

    <input type="radio" name="rating" value="1"> 1
    <input type="radio" name="rating" value="2"> 2
    …

produced a multi-choice field with five identical 'Rating' options.
resolveLabel() fell through to the name attribute for the per-option
label, but name is the *group* identifier shared by every option —
using it produces N copies of the same label and discards the actual
option text that sits next to each <input>.

Fix: give radios a dedicated resolveRadioOptionLabel() that prefers
the trailing text node ('<input> 5'), then label-by-id, then a
wrapping <label>, then the value attribute. It deliberately never
falls back to name.

2. Free-floating <label> siblings weren't associated with their field.

A snippet like

    <label>What can we improve?</label>
    <textarea name="feedback"></textarea>

assigned the textarea the label 'Feedback' (humanized name) instead
of the visible 'What can we improve?'. resolveLabel() only looked
for label[for], a wrapping <label>, aria-label, placeholder, and
name — it never considered a label authored as a preceding sibling.

Fix: introduce readPrecedingLabelText() that walks back through
previous siblings, skipping only whitespace text and <br> elements,
and returns the text of the first <label> it finds (so long as that
label has no for attribute, in which case it already belongs to a
specific field). resolveLabel() now consults this between the
wrapping-label and aria-label steps.

For radios specifically, the same helper is used as a fallback for
the group title (the question being asked) — <fieldset><legend> is
still preferred, then a preceding free-floating <label> before the
first radio (the common shape:
'<label>How satisfied?</label><br><input type="radio">'). Subsequent
radios in the group return '' from the helper because their preceding
sibling is the previous radio's trailing text, so only the first
radio contributes a group label — which is exactly what
collapseRadioGroups() consumes when it builds the merged
multi-choice placeholder.

Verified end-to-end against five realistic HTML snippets in a local
Valet site. Before the fix the feedback form parsed as label 'rating'
with options ['Rating' × 5] and textarea label 'Feedback'. After the
fix the same snippet produces label 'How satisfied are you?' with
options ['1','2','3','4','5'] and textarea label 'What can we
improve?'. The registration / lead-magnet / file-upload / multi-step
snippets still parse identically (no regression on the wrapping-label
or placeholder paths).

* Address review feedback (F4-F9) on HTML form converter

Six follow-ups from review on PR #2749.

F4 — Cap placeholder length to 500 chars (medium severity).
inc/ai-form-builder/field-mapping.php: sanitize_text_field() strips
control chars but does not bound length, and the new placeholder
forwarding lands here from three call sites (AI, MCP, HTML
converter) — a pathological caller could push a multi-MB string into
the block's post-meta. Wrap in wp_html_excerpt to enforce the same
implicit boundedness every other string field in this mapper relies
on.

F5 — Normalize Unicode whitespace before scrubbing for AI.
inc/admin/html-form-detector.php: scrub_html_for_ai used \s in three
regex passes. PCRE's default \s is ASCII-only, so a payload like
'<input type=<NBSP>"hidden">' (U+00A0 between attr name and =)
sails through without being scrubbed. Defense-in-depth — the trust
boundary is the manage_options cap on the route — but worth closing.
Replace NBSP / em-space / en-space with plain space up front and the
existing patterns work as intended.

F6 — Drop ':scope >' from the legend lookup.
src/admin/html-form-detector/parse.js: <fieldset><legend> wrapped in
a Tailwind/Bootstrap styling <div> was missed because the lookup
required <legend> to be a direct child. The first descendant <legend>
inside the closest <fieldset> is good enough; nested fieldsets each
have their own closest('fieldset') resolution so we never cross
boundaries.

F7 — AI fallback over-triggered on a single low-confidence field.
src/admin/html-form-detector/parse.js: a single field with
confidence='low' (e.g. an <input type="color"> in an otherwise
standard contact form) flipped the whole form to low and routed to
AI. AI is the expensive path (network + tokens + middleware cost);
reserve it for forms where the local parser genuinely can't carry
most of the load. Switch to majority-low.

F8 — Throw an Error, not a plain object.
src/admin/html-form-detector/index.js: 'throw { code: ... }' loses
the stack trace and trips eslint(no-throw-literal). Build an Error,
attach the code as a property, and the existing catch handler
continues to work unchanged.

F9 — @SInCE x.x.x annotation on the new placeholder schema property.
inc/abilities/forms/form-field-schema.php: the @SInCE convention is
followed almost everywhere for new schema additions; this property
slipped through. Inline single-line comment beside the property key
since per-array-element docblocks aren't a meaningful target.

For the reviewer's 'Boot order verified' note in the same review:
that assertion turned out to be incorrect and is what caused issue
#2750 (already addressed in be84329). The constructor gated the
add_filter() call on (is_admin() || REST_REQUEST), but the
constructor runs on 'init' which fires before parse_request — the
hook at which WordPress defines REST_REQUEST for REST dispatch — so
the filter was never attached for the actual REST request and the
endpoint 404'd. be84329 dropped that conditional.

* Harden AI scrubber and drop translation wrap on machine prompt

Follow-ups from a panel review on PR #2749. Two issues, both in
inc/admin/html-form-detector.php.

B1 — scrub_html_for_ai leaked text-node content to the AI middleware.

The previous pass dropped <input type=hidden> and any value= attribute,
but the source <form> can carry sensitive data in three other shapes
the regex pass never touched:

  <!-- INTERNAL_API_KEY=sk-live-... -->
  <textarea name="draft">half-written reply with pii@...</textarea>
  <script>const TOKEN = "...";</script>

All three flowed verbatim into the outbound request to
api.sureforms.com. The data class the scrubber's docblock explicitly
calls out (CSRF tokens, server-rendered emails, internal endpoints)
includes exactly this content — comments routinely host staging
notes and build hashes, textareas hold pre-filled drafts, and
inline scripts carry runtime config. The shape of the form (the
fact that a textarea or script tag exists at this position) is the
only thing the conversion model needs.

Fix: extend the existing regex pipeline with three more passes that
strip comments entirely, and empty <script>/<textarea> bodies while
keeping the opening/closing tags so the model still sees the
structural element. The bounded 32 KB MAX_HTML_BYTES upstream keeps
the unbounded .*? backtracking safe.

B2 — System prompt to the AI middleware was wrapped in __().

The string at the top of extract_fields_via_ai is an instruction to
the model, not user-facing copy. Wrapping it in __( 'sureforms' )
invites translators to localize a machine prompt — and even when
they leave it alone the .pot generation surfaces an entry that
makes no sense as a translation target. Drop the __() wrap. Add a
short docblock comment explaining why so the next reader does not
re-wrap it on style grounds.

Coverage: extended test_scrub_html_for_ai to assert all three new
classes of leak (comment / textarea body / script body) are gone
AND the structural tags survive. Existing assertions on hidden
input / value / action / required-attr / non-string-input also
still hold. Verified locally outside the WP test harness.

* Address panel-review medium items on HTML form converter

Four follow-ups from the panel review on PR #2749. None individually
blocking, but all worth landing in this PR rather than a churned
follow-up.

M1 — Reorder capability check before nonce verification.

The REST framework's permission_callback already requires
manage_options (via Helper::get_items_permissions_check, which
defaults to manage_options), so this is largely cosmetic. But
inside the handler the order was reversed: a subscriber-level user
with a valid wp_rest nonce (every logged-in user can mint one via
apiFetch) would land on 'srfm_html_convert_nonce_failed' if the
permission_callback was ever filtered loose, which is the wrong
error code AND wastes a hash_hmac on a request that cannot
legitimately succeed. Run the cap check first; nonce becomes
CSRF defense for the user we already confirmed. New PHPUnit
'test_handle_convert_html_form_rejects_subscriber_with_forbidden'
locks in the order — a subscriber with a valid wp_rest nonce now
returns 'srfm_html_convert_forbidden', not 'nonce_failed'.

M2 — Tighten srfm_html_form_detector_refine_fields filter contract.

Create_Form re-sanitizes a hardcoded list of properties (label,
placeholder, helpText, defaultValue, fieldOptions) but not any
property a pro/3rd-party callback introduces beyond that set — a
date-picker's dateFormat, a file-upload's allowedFormats, etc.
A sloppy callback that re-emitted raw HTML in any of those would
land in block attributes that some renderer could emit as inner
HTML — stored-XSS.

Two-part fix: extend the filter docblock with an explicit security
contract (strings -> sanitize_text_field/wp_kses_post, scalars ->
absint/floatval, arrays -> sanitize each leaf), and add a defensive
post-filter sweep (strip_unsafe_html_in_fields) that walks every
string leaf in every field at any depth through wp_strip_all_tags.
Form-field attributes are not HTML containers, so stripping tags
costs nothing legitimate. New PHPUnit
'test_strip_unsafe_html_in_fields' asserts label/helpText/options
labels/allowedFormats leaves all get stripped, scalars (required,
maxFiles) survive untouched.

M3 — KSES-filter preserved markup server-side.

The block-replacement path now returns two blocks when the source
core/html block held more than just <form> — a fresh core/html
block holding the wrapping markup, then the shortcode. The client
was doing the strip and feeding the result straight back into the
editor with no sanitization pass. On multisite a site admin holds
manage_options without unfiltered_html, so the converter became
a way to surface previously-hidden <script> / <iframe> markup
that the original <form> was masking.

Fix: move the strip server-side (strip_form_for_preservation
using DOMDocument), run the remnant through wp_kses_post when the
caller lacks unfiltered_html, return the result as
response.preserved_html. JS prefers the server value when present
and falls back to stripFormFromHtml only when missing. New
PHPUnit tests cover the editor-role (kses applies, <script>
stripped) and admin-role (whole-form path, empty remnant) cases.

M4 — Bail parseFormHtml deep parse for oversized forms.

The parser runs on every keystroke via useMemo([content]). For
forms with hundreds of inputs the O(N^2) label-by-id +
closest('fieldset') walk blocks the editor main thread. Bail
above FORM_INPUT_SOFT_LIMIT (200) with a sentinel field at
confidence: 'low' — keeps the toolbar button visible AND routes
the conversion through the AI middleware, which runs server-side
off the editor's main thread and has the full raw HTML to work
from.

* Fix PHPCS/Insights failures on html-form-detector

Replace `@$dom->loadHTML()` with `libxml_use_internal_errors()` to
mirror the pattern in `Helper::strip_js_attributes()` — drops the
PHP Insights `NoSilencedErrors` violation. Add per-line PHPCS
ignores for the DOM API's camelCase `parentNode` / `childNodes`
properties.

* Fix missing defaults on converted forms and address remaining review items

- create-form.php: replace hardcoded empty arrays/strings with
  get_registered_meta_keys() lookups so forms created via the converter,
  MCP, and AI builder receive the same _srfm_form_confirmation,
  _srfm_compliance, _srfm_forms_styling, _srfm_email_notification, and
  _srfm_form_restriction defaults as hand-created forms
- test-html-form-detector.php: fix F2 blocking assertion —
  formBackgroundColor routes to _srfm_forms_styling via
  apply_native_card_styling, not into instantForm; assert
  assertArrayNotHasKey instead of the incorrect assertSame
- html-form-detector.php: replace @-silenced loadHTML with
  libxml_use_internal_errors(); add LIBXML_NONET | LIBXML_HTML_NODEFDTD
  flags; add phpcs:ignore comments on DOM camelCase property accesses
- index.js: remove client-side stripFormFromHtml fallback — always
  use server-provided preserved_html to keep wp_kses_post gating intact
  for multisite site-admins without unfiltered_html

* Exclude tests directory from PHPStan analysis

The pre-push hook passes test files explicitly to PHPStan, but tests/
was never meant to be in scope — it's absent from phpstan.neon `paths`.
Adding it to `excludePaths` ensures the exclusion is honoured even
when the hook passes the path explicitly.

* feat: add live character counter to textarea fields

Shows X/limit counter below textarea when min or max length is configured.
Counter uses error color when current length is below minLength, muted text color otherwise.
Font size matches the existing error message size (--srfm-error-font-size).

* refactor: use float right for char counter alignment

Position counter on the same line as the error message using float: right
instead of a wrapper element, keeping srfm-error-wrap structure untouched.

* fix: match char counter typography exactly to error message

Add missing font-weight and line-height variables to .srfm-char-counter
so it aligns pixel-perfectly with the error message on the same line.

* fix: move help text outside <legend> for dropdown and address (#2766)

The .srfm-description span was nested inside <legend> for dropdown
and address but rendered as a sibling of the input wrapper for
multichoice. Two consequences:

1. Screen readers prepended the description to the fieldset's
   accessible name (legend provides the group's accessible name),
   then aria-describedby announced it again on focus — the help text
   was read twice.
2. Pro's per-preset visual-order rules in Baseline / Minimal / Dark
   (sureforms-pro/sass/presets/_presets.scss) flip label / input /
   description via flexbox `order`, but cannot reach descriptions
   trapped inside <legend>'s flex context — so dropdown and address
   descriptions stayed above the input on those presets.

Move help_markup out of <legend> for both fields, matching multichoice.
DOM order remains label → description → input → error for screen
readers; aria-describedby already wires the description ID to the
input, no change required there. No CSS in either plugin targets the
legend-nested description, so no styling regression.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sync dev with next-release for 2.10.0 (#2777)

* feat: support Dynamic Default Value on multi-select dropdown and checkbox multi-choice (#2754)

- Show the Dynamic Default Value field in every mode for Dropdown and
  Multi-Choice blocks; stop wiping it on the Allow Multiple / Single
  Choice Only toggle.
- Add mode-aware help text describing comma-separated multi-value usage.
- Generalize resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php to split the resolved smart-tag string on `,`,
  trim segments, and collect every matching option. Single-select / radio
  still take only the first match.
- When Add Numeric Values to Options is on, also match each segment
  against the option's `value`.
- Preserve editor-set preselectedOptions when no segment matches.

Closes #2754

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(blocks): surface single-param + comma-separated URL pattern in help text

The multi-value resolver already handles `?colors=Red,Blue` with a single
`{get_input:colors}` smart tag — no logic change needed. Reword the help
text on the Dropdown (multi-select) and Multi-Choice (checkbox) blocks to
lead with this simpler one-key pattern, and keep the multi-tag chaining
note as a secondary option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: preserve line breaks in CSV export for textarea fields

sanitize_text_field() strips newlines, flattening multi-line textarea
submissions onto a single line in exported CSVs. Use
sanitize_textarea_field() for srfm-textarea field keys, which sanitizes
without removing intentional line breaks.

Fixes #2761

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(entries): capture and display actual submission page URL (#2757)

Before: the entry "URL:" row always showed the form CPT permalink
(the SureForms "instant form" URL) regardless of which page the user
actually filled the form on. Multi-embed flows, attribution, UTM
tracking, and customer-service context all suffered.

This change captures the real submission URL at submit time and shows
it in the entry record.

How:
- inc/generate-form-markup.php: emit a hidden <input name="srfm-page-url">
  alongside the existing form-id / sender-email / page-break inputs.
- assets/js/unminified/form-submit.js: populate that input with
  window.location.href right before FormData reads the form, so the
  current page URL travels with the submission. Client-side capture is
  used because HTTP_REFERER is unreliable under page caching.
- inc/form-submit.php: read `srfm-page-url` from the sanitized form
  data, fall back to wp_get_referer() if missing, sanitize via
  esc_url_raw(), and persist as `submission_info.submission_url`.
  Suppressed under GDPR mode alongside ip/browser/device.
- inc/rest-api.php + inc/abilities/entries/entry-parser.php: pass
  `submission_url` through the entry response payload.
- src/admin/entries/utils/entryHelpers.js: map `submission_url` →
  `submissionUrl` on the React side.
- src/admin/entries/components/SubmissionInfoSection.js: display the
  real URL; fall back to `formPermalink` for pre-migration entries so
  existing rows still render a meaningful URL instead of '-'.

Backward compat: existing entries gracefully show their form permalink
(prior behavior). New entries get the real submission URL.

Out of scope for this PR (tracked in #2757 AC):
- {submission_url} smart tag (separate change in inc/smart-tags.php)
- CSV / Excel / PDF export column

Closes #2757

* refactor(entries): capture submission URL server-side from Referer

Drop the client-side `srfm-page-url` hidden field and JS that populated it
from `window.location.href`. Capture the URL server-side from the Referer
header instead, with three layered guards:

  - same-origin check (rejects cross-origin URLs entirely — no phishing
    destinations, no mailto:/tel:/data: schemes can ever be stored)
  - http/https scheme allowlist passed to esc_url_raw
  - 2048-char length cap against non-browser clients sending bloated headers

The page-caching concern that motivated client-side capture doesn't actually
apply here: SureForms uses an HMAC submit token, not wp_nonce_field, so
`_wp_http_referer` is never embedded in the form HTML and `wp_get_referer()`
falls through to the per-request `$_SERVER['HTTP_REFERER']` — which is set
by the browser per request and is not affected by server-side page caching.

Trade-off: when a browser strips the Referer header (strict Referrer-Policy,
privacy extensions) the field is stored empty rather than capturing a
client-supplied value. The admin display falls through to formPermalink for
those entries, matching pre-2.10 behavior.

Also: document `submission_url` in ABILITIES.md and add a one-line comment
explaining why entryHelpers.js uses '' (not '-') for the empty default.

* fix(entries): sanitize HTTP_REFERER properly to satisfy phpcs

Use sanitize_text_field( wp_unslash() ) per the project's input-handling
convention instead of an incorrect phpcs:ignore comment. The previous
ignore tag referenced a sniff that doesn't apply; PHPCS was actually
firing WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
because the Referer header is treated as user input.

The same-origin and esc_url_raw guards still gate what gets stored;
sanitize_text_field just adds an early pass that strips HTML and
control characters, which real browsers never send in a Referer.

* fix(entries): strip userinfo from accepted submission URLs

Real browsers strip userinfo from the Referer header, but a non-browser
client could supply `http://user:pass@host/…`. The host check still
catches cross-origin abuse, but storing the userinfo verbatim would
display weirdly in the admin entry row. Parse the URL and rebuild
without the user/pass segment before storing.

* fix(entries): harden submission URL capture and display

Apply findings from a focused security review:

1. Drop URL fragment from the rebuilt URL. Real browsers do not send
   fragments in the Referer header, so any fragment present came from a
   non-browser client and was fully attacker-controlled. The fragment
   ended up rendered as link text in the admin entries view, which is a
   social-engineering vector (e.g. "#login-required").

2. Add port to the same-origin comparison. Previously the stored URL
   preserved any port from the Referer, but the host check ignored it,
   so a Referer like https://yoursite.com:9999/... was accepted as
   same-origin and stored verbatim. Now require an exact match of both
   host (case-insensitive) and port between the Referer and home_url().

3. Defense-in-depth: re-validate submission_url at the exposure
   boundary in inc/rest-api.php and inc/abilities/entries/entry-parser.php
   with esc_url_raw($val, ['http','https']). If a future code path ever
   writes the field bypassing form-submit.php, downstream consumers
   still get a safe value.

4. Defense-in-depth: in src/admin/entries/components/SubmissionInfoSection.js,
   only render the URL row as a clickable <a> when the value matches
   /^https?:\/\//i. React does not block exotic href schemes at runtime,
   so the prior `val && val !== '-'` guard relied entirely on PHP-side
   sanitization. The regex guard removes that trust dependency.

* style(entries): use null coalescing for path component (phpinsights)

* fix: address review feedback and normalize redirect URL multi-value separators

Review feedback on resolve_dynamic_default in dropdown-markup.php and
multichoice-markup.php:

- Restore pre-PR single-select behavior for labels containing commas
  (e.g. "Bonaire, Sint Eustatius and Saba"): gate the comma-split on
  multi-mode only. Single-select / radio match the trimmed resolved
  value as a single segment.
- Document in both docblocks that the comma-split also applies to
  {get_cookie:...} values when in multi-select / checkbox mode.
- Add is_scalar() guard before the (string) cast on $option['value']
  to keep PHPStan level 9 strict against schema loosening.
- Cap the segment array via array_slice( ..., 0, 50 ) to remove the
  DoS surface from pathological inputs like ?colors=,,,,,...

Redirect URL multi-value normalization in generate-form-markup.php:

- After smart-tag substitution, replace the two delimiters left over
  from multi-value field substitution -- "<br>" from parse_form_input
  for checkbox multi-choice and " | " from the raw multi-select
  dropdown storage -- with a plain comma. The final URL becomes a
  clean comma-separated list the receiver can split on ",".
- str_replace runs before html_entity_decode so option labels that
  contain a literal "<br>" sequence (which parse_form_input escapes
  to "&lt;br&gt;") survive intact: only the actual delimiter is
  replaced, and html_entity_decode then restores the option's text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: switch multi-value dynamic default separator from comma to pipe

Replace "," with "|" as the multi-value delimiter for Dynamic Default
Value on multi-select dropdowns and checkbox multi-choice.

- resolve_dynamic_default() in dropdown-markup.php and
  multichoice-markup.php now explodes the resolved smart-tag string on
  "|" in multi-mode. Single-select / radio mode is unchanged (single
  trimmed segment).
- get_redirect_url() in generate-form-markup.php normalizes the leftover
  "<br>" (checkbox storage) and " | " (multi-select dropdown storage)
  delimiters to a plain "|" instead of "," so the redirect URL emits a
  clean pipe-delimited list the receiver can split on "|".
- Update editor help text on both blocks to document the
  ?colors=Red|Blue pattern.
- Update docblocks to describe the pipe split behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
Co-authored-by: Aditya Jain <adi3890@gmail.com>

* Version Bump 2.10.0

* Regenerate POT after build

* Update 2.10.0 changelog with milestone items

* udpate changelog

* Update README.md

* Update readme.txt

* Fix: address PR #2779 (2.10.0) code-review findings

Follow-up fixes for issues found while reviewing the 2.10.0 release PR.

Dynamic default value (Dropdown / Multi-Choice):
- Guard option label/title with is_scalar() before strcasecmp() so a
  malformed/array-shaped option no longer throws a PHP 8 TypeError that
  fatals frontend form render.
- Coalesce dynamicDefaultValue before the is_string() check so blocks
  saved before 2.10.0 (no such attribute) stop emitting an
  "Undefined array key" warning on every frontend render. This also
  fixes 6 pre-existing unit-test errors.
- Correct @SInCE tags to note the 2.10.0 multi-value behavior.

Textarea character counter (frontend.js):
- Switch to delegated input handling so counters keep working for
  fields added/revealed after first paint (multi-step, conditional
  logic, AJAX-loaded forms), not just on initial load.
- Flag the over-limit (max) state, not only the under-limit (min) state.

HTML form converter (html-form-detector):
- Guard window.CSS.escape and wrap the parser in try/catch so malformed
  or hostile HTML degrades to "no Convert button" instead of crashing
  the block edit render.
- Debounce parsing (250ms) so typing in the source HTML block does not
  run DOMParser + the field walk on every keystroke.
- Prefer explicit submit controls and fall back to the last typeless
  <button>, skipping cancel/reset/back/close, so the submit label is not
  taken from a leading "Cancel" button.

Entries:
- Neutralize CSV formula/macro injection (cells starting with = + - @
  tab/CR) on export while leaving genuine numbers intact.
- Guard entryData.status before toLowerCase() in SubmissionInfoSection.
- Rename the local one-arg formatDateTime to formatEntryListDate to
  remove the same-name/different-signature clash with @Utils/Helpers.

* Fix: preserve Save & Progress and Conditional Confirmation on form duplicate and import

JSON-string post metas (_srfm_save_resume, _srfm_conditional_confirmation)
were being wiped on duplicate and import. get_post_meta() returns unslashed
data, but add_post_meta() runs wp_unslash() before the registered
sanitize_callback. Stripping those backslashes broke the embedded escaped
quotes, so json_decode() failed and the sanitizers returned an empty string.

Wrap the copied values in wp_slash() in duplicate-form.php and
import_forms_with_meta() so add_post_meta()'s internal unslash restores the
original value intact.

* Add display flex to block editor block card in single form settings description

* chore: update i18n translations

Auto-generated by /i18n command on PR #2779

---------

Co-authored-by: Aditya Jain <adi3890@gmail.com>
Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>

---------

Co-authored-by: Rahul Verma <rahulvarma722@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Om Kolte <159872083+osk02@users.noreply.github.com>
Co-authored-by: Avinash Kumar Sharma <avinashs@bsf.io>
…apiVersion

`useShouldIframe()` flipped to `false` whenever any registered block reported
`apiVersion < 3`. ThirstyAffiliates' `ta/image` block (and other affiliate /
SEO / commerce plugins) ship with `apiVersion: 1`, so activating them on a
site with SureForms forced the editor down the non-iframe path even though
WP itself still iframes the post-type editor.

Effects in the iframed canvas were then run against the wrong DOM (the top
document), which silently:
- skipped the `srfm-form-container` scope class on `.is-root-container`,
  rendering every form field unstyled (collapsed textarea, 16px checkbox,
  bare dropdown, etc.)
- skipped the SureForms submit button (`Apply`) container
- skipped the custom block inserter under the form

Restrict the `apiVersion` check to core + `srfm/` blocks so third-party
registrations cannot flip our editor mode.

Also tighten the iframe-readiness probe in `Editor.js` to wait for the
actual root container and block-list layout, rather than treating any
early WP overlay child (`block-canvas-cover`, collaborators overlay,
a11y-speak intro) as a ready signal. This is defense-in-depth for the
timing window any heavy admin plugin can win.
- Editor.js: include `shouldIframe` in the polling-effect dep array.
  Without this, a late `false → true` flip (e.g. when block types finish
  registering after first render) would leave the polling stuck on the
  top-document branch and silently reproduce the original symptom.

- Helpers.js: guard against the vacuous-truth case where the
  `srfm/`+`core/` filter yields no blocks. `[].every(...)` returns true
  and would have forced the iframe path before any block types had
  registered. Require at least one relevant block before trusting the
  apiVersion check.

- StyleSettings.js, useContainerDynamicClass.js: derive iframe-vs-not
  from the actual DOM (`block-editor-iframe__body` class on the
  resolved body) instead of the `shouldIframe` prediction. The prop is
  no longer a source of truth — the resolved DOM is — and this protects
  against future drift between the heuristic and what WP actually
  renders. The `shouldIframe` prop is dropped from both consumers since
  it is no longer used.
…aster

Fix: editor breaks when third-party blocks register at apiVersion <3
Email notifications were sent before the entry was saved to the database,
so $form_data['entry_id'] was not yet set when smart tags were processed.
The {entry_id} tag appeared blank in emails even though it worked correctly
in the on-page confirmation message.

Fix: move send_email() call to after Entries::add() so entry_id is
available in $form_data when email smart tags are resolved.
The GDPR "do not store entries" path retains its early send_email() call
since no entry is created in that path (entry_id is expected to be empty).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…to fix/entry-id-smart-tag-not-resolving-in-emails-sync
…not-resolving-in-emails-sync

Sync Dev in fix/entry id smart tag not resolving in emails
Sync next-release with dev: brings PR #2791 (iframe-readiness race fix)
and reconciles 2.10.0 squash history. Clean merge, no conflicts.
- Counter is now rendered inside .srfm-error-wrap alongside the error
  message, using flex justify-content:space-between so the error
  message appears on the left and the counter on the right.
- Removes the standalone .srfm-char-counter-wrap that was rendered
  between the textarea and the error-wrap, which caused the counter
  to appear left-aligned without a natural anchor on the right side.
- Adds SCSS for .srfm-textarea-block .srfm-error-wrap with display:flex,
  justify-content:space-between, and char counter styling.
…ce-between

The error message is the only other flex child of .srfm-error-wrap and stays
display:none until validation fires, so justify-content:space-between left the
counter (the lone visible child) pinned left in the default state — the bug
#2801 set out to fix. Use margin-left:auto so it right-aligns regardless of
error visibility, and reset the counter-wrap line-height so it no longer
overflows the fixed-height error-wrap. Verified in classic + baseline/minimal/
dark presets, error state, and mobile.
…er-layout

fix: Move textarea char counter into error-wrap for correct left/right alignment
…not-resolving-in-emails

fix: resolve {entry_id} smart tag in email notifications
Sync dev with next-release for 2.10.1
Strip internal-only paths and merge upstream changes into the public mirror.
Diff is against public/master so no internal files appear.
@vanshk141999 vanshk141999 merged commit 7d3724d into master Jun 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants