Skip to content

debug(deploy): temp diagnostic to identify SSH key paste issue - #390

Merged
rpgmem merged 1 commit into
developfrom
claude/debug-ssh-key-fingerprint
May 24, 2026
Merged

debug(deploy): temp diagnostic to identify SSH key paste issue#390
rpgmem merged 1 commit into
developfrom
claude/debug-ssh-key-fingerprint

Conversation

@rpgmem

@rpgmem rpgmem commented May 24, 2026

Copy link
Copy Markdown
Owner

Summary

Adiciona um bloco de diagnóstico temporário no step "Configure SSH" do deploy-develop.yml pra identificar a causa real do Permission denied (publickey,password) que persiste mesmo após 2 atualizações do secret TESTES_SSH_KEY.

Por que

Verificamos no servidor:

  • ssh-keygen -lf ~/.ssh/rpgmem e ssh-keygen -lf ~/.ssh/rpgmem.pub reportam fingerprint idêntico → keypair válido.
  • ~/.ssh/authorized_keys contém a pública.
  • Permissões: ~/.ssh/ = 700, authorized_keys = 600, rpgmem = 600. ✅

Logo o problema está nas bytes do private key que o GitHub Actions runner está escrevendo em ~/.ssh/deploy_key — não no servidor.

O que o diagnóstico mostra (tudo safe)

Output O que revela
wc -c Byte count — se truncado
wc -l Line count — se as quebras foram preservadas (OpenSSH ≈ 39 linhas)
file Detecta CRLF (ASCII text, with CRLF line terminators) vs LF puro
head -1 / tail -1 Confirma -----BEGIN/END OPENSSH PRIVATE KEY----- intactos
ssh-keygen -lf Fingerprint da chave que o runner recebeu — comparamos com SHA256:vGLCEX9CyQ... (esperado)

Nenhum dos comandos imprime bytes da chave.

Cleanup

Depois que identificarmos a causa e atualizarmos o secret corretamente, abro um follow-up que remove o bloco DEBUG.

Test plan

  • CI verde.
  • Após merge + deploy disparado, ler o log do step "Configure SSH" no run de Deploy develop → testes e comparar o fingerprint impresso com SHA256:vGLCEX9CyQmI8ftcRsgQCPHIxSp5nHoYNHAGo0su45A.
  • Se forem diferentes → secret tem outra chave. Se o fingerprint bater → problema é encoding/CRLF.

Generated by Claude Code

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.
@rpgmem
rpgmem marked this pull request as ready for review May 24, 2026 19:18
@rpgmem
rpgmem merged commit c6b14b4 into develop May 24, 2026
14 checks passed
@rpgmem
rpgmem deleted the claude/debug-ssh-key-fingerprint branch May 24, 2026 19:40
rpgmem added a commit that referenced this pull request May 24, 2026
…391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>
rpgmem added a commit that referenced this pull request May 31, 2026
* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.

Co-authored-by: Claude <noreply@anthropic.com>

* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Meusburger <rpgmem@gmail.com>

* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single
     event, switch the Time Mode to ..."

No …
rpgmem added a commit that referenced this pull request Jun 2, 2026
* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.

Co-authored-by: Claude <noreply@anthropic.com>

* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Meusburger <rpgmem@gmail.com>

* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single
     event, switch the Time Mode to ..."

…
rpgmem added a commit that referenced this pull request Jun 5, 2026
…sion editors, read-only Settings (#506)

* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.

Co-authored-by: Claude <noreply@anthropic.com>

* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Meusburger <rpgmem@gmail.com>

* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single…
rpgmem added a commit that referenced this pull request Jun 7, 2026
…e campaign (#546)

* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.

Co-authored-by: Claude <noreply@anthropic.com>

* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Meusburger <rpgmem@gmail.com>

* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single
     event, switch th…
rpgmem added a commit that referenced this pull request Jun 7, 2026
…e campaign (#546) (#547)

* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.



* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.



* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.



* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.



* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).



* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).



* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...




* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...



---------





* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY



* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------



* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single
     event, switch th…

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
rpgmem added a commit that referenced this pull request Jun 21, 2026
* fix(deploy): support custom SSH port via TESTES_SSH_PORT secret (#389)

First end-to-end deploy run failed on Hostinger BR because the workflow
hardcoded port 22 in both ssh-keyscan and rsync's `-e ssh ...`, while
the hosting exposes SSH on port 65002. Two follow-ups landed:

1. New optional secret `TESTES_SSH_PORT` (default `22` so VPS-style
   setups keep working). Both ssh-keyscan and rsync now read it.
2. Rsync switched from `StrictHostKeyChecking=yes` to `accept-new`
   (TOFU). The keyscan step is now best-effort (`|| true`) — if a
   firewall/CDN blocks port-scanning, the first rsync connection
   transparently accepts the host key and pins it for the run. Safer
   than `no` (would be MITM-vulnerable); recovers from keyscan failures
   that previously aborted the whole deploy with no useful log.

CLAUDE.md updated to document the new secret in the deploy-to-testes
table with a note that managed hosting commonly uses non-standard ports.

Co-authored-by: Claude <noreply@anthropic.com>

* debug(deploy): temp diagnostic to identify SSH key paste issue (#390)

Last two deploy runs failed with `Permission denied (publickey,password)`
despite the keypair on the testes server being verified as matching
(fingerprints of `~/.ssh/rpgmem` and `~/.ssh/rpgmem.pub` are identical,
public key is appended to `authorized_keys`, permissions are 700/600).

That narrows the failure to the `TESTES_SSH_KEY` secret: the private
key bytes GitHub is receiving don't match the public key on the server.
Most likely culprits are CRLF line endings introduced by a Windows
clipboard paste, a truncated copy, or accidentally pasting the .pub.

This adds a temporary diagnostic block to the Configure SSH step that
reports byte count, line count, file type (catches CRLF), header/footer
lines (verifies BEGIN/END markers), and fingerprint of the key the
runner actually received. None of those leak the key bytes themselves.

Once we identify and fix the paste issue, a follow-up commit removes
the DEBUG block.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): remove temp DEBUG block + document no-passphrase rule (#391)

The diagnostic block added in #390 served its purpose — it confirmed
the secret bytes matched the server's keypair (same fingerprint, no
CRLF, correct length). That isolated the real root cause: the private
key on the testes server had been generated with a passphrase, and
GitHub Actions has no way to enter passphrases interactively. The user
regenerated a fresh ed25519 key with `-N ""` and the next deploy ran
green end-to-end.

Two changes here:

- `.github/workflows/deploy-develop.yml`: removes the DEBUG block from
  the "Configure SSH" step. The workflow returns to its production
  shape (port-aware, accept-new TOFU, best-effort keyscan).

- `CLAUDE.md`: adds a note to the `TESTES_SSH_KEY` row in the deploy
  secrets table calling out the no-passphrase requirement, with the
  exact `ssh-keygen` invocation that gets it right and the misleading
  error symptom (`Permission denied (publickey,password)` looks
  identical to a wrong key). Future sessions won't repeat the cycle.

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deploy): exclude dev tooling and repo docs from testes deploy (#392)

User reported finding dev-only files on the testes server after the
first successful deploy. Categories cleaned up:

Repo metadata:
- .githooks/, .distignore

Build / dependency manifests:
- composer.json, composer.lock, package.json, package-lock.json

Static analysis / testing tools:
- phpstan-stubs.php, patchwork.json

Lint configs (the existing `.eslintrc*` pattern doesn't match ESLint v9
flat config naming `eslint.config.{js,mjs,cjs}` — added the flat
pattern explicitly):
- eslint.config.*

Repo docs (live on GitHub, not in plugin runtime):
- CONTRIBUTING.md, SECURITY.md

Intentionally kept (per user preference): CHANGELOG.md — useful for
historical lookup via SSH; not surfaced to end users (WP.org parses
`readme.txt`'s own changelog section).

The previous "composer.json e package.json são intencionalmente
enviados" rationale was hand-wavy (managed hosting admins might
inspect them) and the user disagreed in practice. Comment block
rewritten to reflect the new policy.

Next push to develop triggers a redeploy; rsync `--delete` will remove
the listed files from the testes server in the same pass.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): make Divisão → Setor map admin-editable (#393)

The divisao_setor dependent-select options were hardcoded in
ReregistrationFieldOptions::get_divisao_setor_map() (DRE São Miguel MP
org structure) — Portuguese strings unreachable by Loco, and unusable
by any other organization without a code edit. This adds a global,
admin-editable map under Settings → Reregistration.

Data layer
- get_divisao_setor_map() now reads ffc_settings['divisao_setor_map']
  via a new typed accessor SettingsReader::divisao_setor_map(), falling
  back to the hardcoded default. The hardcoded array moved to a new
  get_default_divisao_setor_map() — source of truth for both the seed
  and the runtime fallback. The fallback lives in the domain layer (not
  SettingsReader) to avoid a Settings → Reregistration dependency cycle.
- The 3 existing consumers (validation, field seeder, frontend delegate)
  need no changes — they call get_divisao_setor_map() which is now
  configuration-aware.

Display sync (the snapshot problem)
- The dropdown the user sees is a per-audience snapshot frozen in
  wp_ffc_custom_fields.field_options['groups'] at seed time (the seeder
  is insert-only). Validation reads the map live. To keep DISPLAY
  consistent with the live map, ReregistrationStandardFieldsSeeder::
  resync_divisao_setor_groups() rewrites every audience's snapshot
  (preserving parent_label / child_label) and the save handler invokes
  it after persist — only when the map actually changed.

Admin UI
- New TabReregistration settings tab + view rendering a nested repeater
  (divisions, each with a sector sub-list; add/remove rows).
- ffc-divisao-setor-editor.js keeps a hidden JSON input in sync; the
  save handler decodes + sanitizes (sanitize_text_field per key/leaf,
  drops empty divisions, de-dups sectors).
- Scoped CSS for the nested editor in ffc-admin-settings.css.

Seed
- Activator::seed_reregistration_field_options() seeds the hardcoded
  default into ffc_settings on activation when absent (idempotent), so
  the option is concrete and matches existing per-audience snapshots —
  no display resync needed at activation.

Tests
- PHP: SettingsReader accessor (set / absent / non-array), field-options
  configurable override + fallback, save-handler tab gating + JSON parse
  + sanitization + no-op resync, seeder resync (empty + populated),
  activator seed (writes default / skips when set). Existing tests that
  transitively hit the map now stub get_option.
- JS: full editor coverage (sync, add/remove division+sector, de-dup) —
  keeps the JS line floor satisfied (86.2%).

No FFC_VERSION bump (develop-targeted PR per CLAUDE.md).

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable field lists with parent→child replication (#394)

Supersedes the global divisao_setor_map model from #393. Standard
reregistration fields whose option lists are organization-specific
(divisao_setor groups, sindicato / jornada choices) are now edited
per-audience in the Custom Fields editor, and propagated down the
audience hierarchy with an explicit "Replicate lists to children".

Why per-audience: the option snapshots already live per-audience in
wp_ffc_custom_fields.field_options; a global setting that synced into
them was a redundant layer. Per-audience with cascade matches the
3-level hierarchy and lets children diverge for fine-tuning.

Editing (unlock + UI)
- ajax_save_custom_fields: standard fields were locked to label/group/
  order/required/active. Now also accept field_options (select choices
  AND dependent_select groups) — but only when the payload carries
  non-empty options, so a bulk save can never null an existing list
  (wipe guard). Type/key/mask/profile_key stay immutable for standard.
- dependent_select groups: new sanitize_dependent_groups() + a
  preserve_dependent_labels() that carries over parent_label /
  child_label the editor doesn't touch.
- UI: the choices textarea is now editable for standard select fields;
  dependent_select rows embed the nested division→sector editor
  (reused ffc-divisao-setor-editor.js from #393, now mounted in the
  field row). ffc-custom-fields-admin.js collects `groups` from the
  synced hidden input and toggles the groups container on type change.

Replication
- "Replicate lists to children" button (shown only when the audience
  has children) → ajax_replicate_field_options →
  ReregistrationStandardFieldsSeeder::replicate_field_options_to_descendants(),
  which copies every standard field's field_options to all descendants
  (via AudienceRepository::get_descendant_ids) by field_key. Explicit,
  overwriting push; manual per-child edits survive until next replicate.

Validation
- ReregistrationDataProcessor now validates a dependent_select against
  the field's OWN per-audience groups (get_dependent_choices), not a
  global map — and generalizes from divisao_setor to any
  dependent_select field.

Removed (global layer from #393)
- TabReregistration settings tab + view, SettingsReader::divisao_setor_map(),
  the save-handler global map handlers, Activator seed, the
  ReregistrationFieldOptions global reader + ReregistrationFrontend
  delegate, and resync_divisao_setor_groups(). Kept
  get_default_divisao_setor_map() as the shipped seed default for new
  audiences, and the ffc-divisao-setor-editor.js component (repurposed).

Tests
- New: handler helpers (sanitize_dependent_groups, preserve_dependent_labels),
  replicate_field_options_to_descendants (empty + populated),
  per-audience dependent_select validation.
- Removed obsolete tests for the deleted global code; repointed the
  remaining map assertions to get_default_divisao_setor_map().
- PHPUnit 4701 green; Vitest 965 green (JS lines 85.99% > floor).

No FFC_VERSION bump (develop-targeted PR).

Co-authored-by: Claude <noreply@anthropic.com>

* fix(ficha): render Divisão/Setor cells from split dependent_select placeholders (#395)

The ficha template referenced {{divisao}} / {{setor}}, but FichaGenerator only
emits the combined divisao_setor value, so both cells printed the literal
placeholder. Expose each dependent_select field's parent/child halves as
{{<key>_parent}} / {{<key>_child}} and point the template at them; the combined
{{<key>}} form stays for back-compat. Standard-field variable building moved into
the unit-tested build_standard_field_variables().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(reregistration): per-audience editable Termo de Ciência (form + ficha PDF) (#396)

The acknowledgment notice was hardcoded in both the reregistration form
renderer and the ficha PDF template. It is now a display-only `acknowledgment`
standard field whose HTML lives in field_options['html'], edited per-audience
via wp_editor in the Custom Fields editor and propagated to descendants by the
existing "Replicate lists to children" action.

- New `acknowledgment` field type (display-only): skipped during value
  collection, validation and persistence.
- Seeded per-audience with the shipped default notice
  (ReregistrationFieldOptions::get_default_termo_ciencia_html), which is also
  the render-time fallback for audiences predating the field.
- Form renders the per-audience HTML block; ficha injects {{termo_ciencia}}
  via a dedicated replace so the notice's links survive (the per-variable
  allowlist omits <a>).
- Admin: always-visible wp_editor in the acknowledgment row; builder JS
  collects the HTML and toggles the editor by type.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Single-source certificate-preview placeholders + readable pre-flight log reasons (#402)

* feat(preview): single-source placeholder samples + readable pre-flight log reasons

Certificate previews (admin form-editor + public CSV-download) each kept
their own short hardcoded sample map, so any other placeholder rendered as
a raw {{token}}. Introduce CertificatePreviewSamples::get_map() as the
single source of truth, surfaced to both previews (ffc_ajax.previewSamples
and the ajax_cert_preview payload); the JS only overlays the live form
title and the form's own field names.

Activity Log: the preflight_blocked rows dumped the opaque
"reason":"gps_prompt" code. Add a display-only summary mapping the reason
codes to human labels (the stored enum stays a stable machine key the
stats aggregator relies on) plus a friendlier action label.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test: stub DateFormatter-backed WP fns in AdminAssetsManagerTest

The localization payload now eagerly builds CertificatePreviewSamples::get_map(),
which routes through DateFormatter (wp_date/wp_timezone), get_option and
get_bloginfo. Stub them so the enqueue tests don't hit undefined wp_date().

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ci): point Dependabot at develop, not main (#403)

Dependabot had no target-branch, so it opened bumps against the default
branch (main). Under the develop workflow, only release/hotfix PRs touch
main; dependency bumps belong on develop like any other change. Set
target-branch: develop for the composer, npm, and github-actions ecosystems.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 (#397)

* chore(deps)(deps-dev): bump terser from 5.47.1 to 5.48.0 (#400)

Bumps [terser](https://github.com/terser/terser) from 5.47.1 to 5.48.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.47.1...v5.48.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-version: 5.48.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1

Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v25.0.1...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alex Meusburger <rpgmem@gmail.com>

* test(js): upgrade Vitest to 4 + restore coverage above the floor (#404)

Bumps vitest and @vitest/coverage-v8 2.x → 4.x together (they are a
version-locked pair; splitting them breaks npm ci). The major bump
surfaced two latent test-isolation issues and changed how coverage-v8
counts statements:

- admin-submission-edit: repeated vi.spyOn($, 'post') without restore
  returned the same accumulating mock under v4, so a later test saw 4
  calls instead of 1. Restore mocks in afterEach.
- sprint1-followup-debug-toggle: the async diagnostics log bled into the
  next test's console spy under v4's tighter inter-test flushing. Drain
  pending microtasks + restore mocks in afterEach.

coverage-v8 v4's AST-aware remapping re-measured the same suite ~2pts
lower, dropping under the 82 floor. Rather than lower the floor, added
real tests to lift it back: ffc-core helpers (log/error/warn, ajax,
toggleFields, accessors, [data-confirm] guard), the already-submitted
ajaxComplete tracker + LRU cap, and dynamic-fragments nonce/user-prefill
patching. Gate metric now 82.4% (floor held at 82).

CI Node bumped 20 → 22 in lint.yml: vitest 4 needs Node >=20.19/22.12 and
matching the local toolchain keeps the coverage number reproducible.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): migrate remaining boolean checkboxes to the .ffc-toggle switch (#405)

Swaps plain on/off checkboxes for the shared AdminUI::render_toggle()
component in the spots that hadn't been converted yet:

- CSV public-access metabox: regenerate_hash + reset_counter
- Advanced settings: reset_counter (Reset ID counter to 1)
- Audience field-builder flags (Required/Active/Sensitive) — both the
  wp.template for new rows and the server-rendered existing rows
- Audience calendar per-user permission grid (can_book /
  can_cancel_others / can_override_conflicts)

Input names, the JS-serialiser class hooks (.ffc-field-*, .ffc-perm-toggle)
and data-perm are all preserved, so save and JS serialisation behave
exactly as before. render_toggle gains an optional `title` arg so the
Sensitive flag keeps its "encrypt at rest" tooltip.

The self-scheduling calendar editor was already fully on render_toggle.
Left as-is by design: list-table row selectors, multi-select checkbox
groups, public/consent form checkboxes, and the WP user-edit capability
fieldset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(maintenance): extract a pluggable maintenance-tool framework (#406)

Introduce MaintenanceToolInterface + MaintenanceToolRegistry under a new
FreeFormCertificate\Maintenance namespace. ObsoleteShortcodeCleaner now
implements the interface (id/title/description/is_actionable/
get_default_options/run) and the Settings → Data Migrations handler
dispatches through MaintenanceToolRegistry::create_default() instead of
newing the cleaner directly.

Behaviour is identical; this is the foundation for the upcoming
URL-shortener cleanup, public-operator-access disabling and
submission-link audit tools, which each plug in by implementing the
interface and registering in create_default().

The cleaner's run() converges on the interface signature
run( array $options ) — the grace window moves from a positional int
into $options['days']; callers and tests updated.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): Short URL Cleanup tool (PR 2/4) (#407)

* feat(maintenance): add Short URL Cleanup tool (PR 2/4)

Second maintenance tool on the framework from PR 1. UrlShortenerCleaner
deletes obsolete short URLs under three toggleable criteria — orphaned
(target post gone), never-clicked + older than a grace window, and
trashed — with a dry-run preview before the destructive pass.

- includes/maintenance/class-ffc-url-shortener-cleaner.php (tool, lazy repo)
- UrlShortenerRepository::find_cleanup_candidates() — OR-combined criteria,
  per-row is_orphaned/is_never_clicked/is_trashed flags via a posts LEFT JOIN
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_url_shortener_cleanup() (preview persists criteria
  + grace window and runs dry-run; apply requires a fresh preview)
- a new card on the Data Migrations tab (criteria checkboxes + days,
  preview/delete buttons, by-reason report)
- UrlShortenerCleanerTest (criteria, dry-run vs delete, reasons, truncation)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* test(maintenance): cover URL cleanup handler + repo query (restore floor)

The Short URL Cleanup PR added uncovered lines (the admin handler and the
find_cleanup_candidates SQL method), dropping project line coverage below
the 55% floor. Restore it without lowering the gate:

- SettingsTest: exercise handle_url_shortener_cleanup() — no-request and
  bad-nonce guards plus the preview and apply happy paths, trapping the
  terminal wp_safe_redirect (the established pattern) so the full body
  runs. This transitively covers UrlShortenerCleaner's lazy repository()
  branch and find_cleanup_candidates via a mocked $wpdb.
- UrlShortenerRepositoryTest: direct tests for find_cleanup_candidates —
  the no-criteria early return and the prepared-query path.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): disable Public Operator Access on old forms (PR 3/4) (#408)

Third maintenance tool on the framework. PublicOperatorAccessDisabler
switches off Public Operator Access (the master _ffc_csv_public_enabled
flag plus its four sub-feature flags) on published forms whose collection
period ended more than the grace window ago.

- "Old" reuses Geofence::has_form_expired_by_days() — same expiry source
  as the obsolete-shortcode cleaner.
- Non-destructive to config: hash / limit / count / cpf_mode / whitelist
  are preserved, so access can be re-enabled later. Only the enable flags
  flip to '0'.
- includes/maintenance/class-ffc-public-operator-access-disabler.php
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_public_access_disabler() (preview persists the
  grace window + dry-runs; apply requires a fresh preview)
- new card on the Data Migrations tab (days + preview/disable, report)
- PublicOperatorAccessDisablerTest (expiry filter, dry-run vs execute,
  exactly the five enable flags set to '0', config untouched) + SettingsTest
  handler coverage (guards + preview + apply paths)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(maintenance): submission ↔ user link auditor (PR 4/4) (#409)

Final maintenance tool — and the only report-only one. SubmissionLinkAuditor
scans for submissions wrongly linked to WP users and never writes
(is_actionable() === false, no apply step). Four checks, all driven by the
deterministic cpf_hash / rf_hash columns + a wp_users existence join (no
decryption):

- orphan_links        — user_id points to a deleted WP user
- multiple_identities — one user bound to >1 distinct CPF/RF
- should_be_linked    — no user_id, but the CPF matches a linked row
- shared_identities   — one CPF shared across multiple users

- includes/maintenance/class-ffc-submission-link-auditor.php (lazy repo)
- four read-only queries on SubmissionRepository
- registered in MaintenanceToolRegistry::create_default()
- Settings handler handle_submission_link_audit() (single scan mode)
- a report-only card on the Data Migrations tab
- SubmissionLinkAuditorTest + SubmissionRepository query tests + SettingsTest
  handler coverage

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(admin): pad Data Migrations cards + toggle the Short URL criteria (#410)

Two Data Migrations tab polish items from review:

1. The maintenance cards are core .postbox elements, but the
   `.postbox .inside` / header padding lives in wp-admin's edit.css, which
   is not loaded on this custom settings page — content rendered flush
   against the border. Added explicit padding to `.ffc-migration-card`
   (header + .inside) to match the intro `.card`.
2. The three Short URL Cleanup criteria checkboxes are now AdminUI toggle
   switches, consistent with the rest of the admin. Field names unchanged,
   so the preview/apply form contract is identical.

Rebuilt assets/css/ffc-admin-settings.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(admin): toggle switches for user-profile capability fields (#411)

The ten FFC capability checkboxes on the WordPress user-edit / profile
screen now render as AdminUI toggle switches, matching the rest of the
admin. Field names are unchanged, so save_capability_fields() and the
Grant/Revoke-all bulk JS (which selects by name and sets .prop('checked'))
work identically — the switch reflects :checked via CSS. Enqueues
ffc-common.css (the .ffc-toggle styles) on the profile screen, which
didn't load it before.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(recruitment): toggle switches for notice columns + reason applies-to (#412)

Items 5 & 6 of the review batch.

- Notice editor: the public-column visibility grid (public_columns[...])
  renders as toggle switches; mandatory columns stay a disabled toggle +
  hidden input pinning value=1.
- Reason editor: the "applies to" status group (applies_to[]) renders as
  toggle switches.
- ffc-common.css (the .ffc-toggle styles) is now a dependency of the
  recruitment-admin stylesheet so the switches are styled on these screens.
- Added AdminUI::get_toggle() — returns the toggle markup as a string —
  for the notice renderer, which assembles its HTML into a string instead
  of echoing.

Field names and the mandatory-column hidden-input trick are unchanged, so
the save handlers work identically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(dashboard): per-form "view submissions" link in the day side-list (#413)

On the certificates dashboard, each form in a selected day's side-list now
has a discreet dashicon link to the Submissions list pre-filtered to that
form (page=ffc-submissions&filter_form_id[0]=<id>). The submissions list
already reads filter_form_id[] from GET, so the clean URL is enough — no
nonce/referer needed.

- localized submissionsUrlBase + a viewSubmissions aria-label into
  ffcCertificatesDashboard
- ffc-certificates-dashboard.js appends the link per entry (guarded on
  submissionsUrlBase so existing behaviour is unchanged when absent)
- discreet muted styling (brightens on hover/focus)
- Vitest: link present with correct href when base is set; absent otherwise
- rebuilt the .min.js / .min.css bundles

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(admin): self-scheduling toggle styles + migration-card header padding (#414)

* fix(self-scheduling): load ffc-common.css so editor toggles render as switches

The self-scheduling calendar editor already renders its config controls via
AdminUI::render_toggle, but the full .ffc-toggle switch component lives in
ffc-common.css — which the editor screen never enqueued (it only loaded
ffc-calendar-editor.css, whose lone .ffc-toggle rule is a layout tweak
scoped to .ffc-email-toggles). Result: the Allow-cancellation /
Requires-approval / Restrict-* / Admin-bypass toggles showed as raw
checkboxes.

Enqueue ffc-common.css as a dependency of ffc-calendar-editor.css on the
ffc_self_scheduling edit screen so every switch is styled.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(admin): match migration-card header padding to the reference card

Follow-up to the #410 padding fix. The header padding was applied to BOTH
.postbox-header and .hndle (double padding) and the h3.hndle kept its
default browser margin (edit.css, which would zero it, isn't loaded here),
so the space above/below the card title didn't match the intro `.card`.

Now mirror the reference rhythm: 20px above the title, 10px down to the
header divider, 15px to the content (20px sides/bottom); header padding on
.postbox-header only; .hndle margin/padding reset.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: TOC fix + recruitment/audience shortcodes (D1) (#415)

* docs: link REST API section in TOC + document recruitment/audience shortcodes (D1)

In-plugin documentation refresh, part 1:
- Add the section-19 "REST API Authentication" link to the Documentation
  TOC — the partial was loaded but had no nav entry, so it was invisible.
- 01-shortcodes: document [ffc_recruitment_queue] (notice + adjutancy attrs,
  ?q/?adjutancy/?subscription/?page_* URL filters) and [ffc_recruitment_my_calls],
  and list the [ffc_audience] attributes (schedule_id / environment_id / view).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* chore: re-trigger CI (Vitest flake on a docs-only PR)

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* docs: complete template-variable reference (D2) (#416)

In-plugin documentation refresh, part 2 — template variables:
- 02-variables: add the general certificate placeholders that were missing
  ({{display_name}}, {{reference_year}}, {{fill_date}}/{{date}}, {{status}})
  + a note that any collected profile field ({{rg}}, {{celular}},
  {{endereco}}, {{cargo_funcao_acumulo}}, …) resolves in templates, pointing
  to the full catalog in section 11 rather than duplicating ~25 rows.
- 11-ficha-pdf: add {{termo_ciencia}} (editable acknowledgment notice) and a
  note documenting the dependent-select split placeholders ({{divisao_setor}}
  + {{divisao_setor_parent}} / {{divisao_setor_child}}, generalisable via the
  _parent / _child suffixes).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: add Recruitment + Maintenance Tools sections (D3) (#417)

In-plugin documentation refresh, part 3 — two brand-new sections:
- 20. Recruitment: admin tabs (notices/adjutancies/candidates/reasons/
  settings), notice lifecycle (draft → preliminary → active → closed) and
  which states are public, the two public shortcodes, the granular
  capabilities, and the PII-masking note.
- 21. Maintenance Tools: the four Settings → Data Migrations tools
  (obsolete-shortcode cleanup, short-URL cleanup, disable Public Operator
  Access, report-only submission↔user link audit) and the
  preview-before-apply model.

Both wired into the TOC and the require() include list.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* docs: staleness pass on remaining sections (D4) (#418)

In-plugin documentation refresh, part 4 — correctness fixes after reviewing
sections 5–18 against the code:
- 09-audience-custom-fields: add the three real field types that were
  missing (dependent_select, working_hours, acknowledgment).
- 17-hooks: add the undocumented hooks — ffcertificate_pdf_filename,
  ffcertificate_before_data_deletion, ffcertificate_appointment_receipt_filename,
  and the seven ffcertificate_self_scheduling_* email/lifecycle hooks.
- 05-qr-code: fix the size-range wording ("100px at 500px" → "100px–500px").

All other reviewed sections were accurate and left unchanged.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* chore(activity-log): raise four events from info to warning (#419)

These are destructive / irreversible actions that should stand out in the
Activity Log alongside the existing warning-level deletions:
- data_cleanup (automatic deletion of old submissions)
- recruitment_classification_deleted
- recruitment_adjutancy_deleted
- tickets_purged_expired

Added level assertions to the two recruitment logger tests to lock the
new level in.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): log PDF generation, certificate email + CSV download (#420)

Three new delivery-audit events (all info level), per maintainer request:
- pdf_generated      — subscriber on ffcertificate_after_pdf_generation
- certificate_emailed — subscriber on ffcertificate_before_email_send
                        (form_id only in context; recipient email not stored)
- csv_downloaded     — at the public-operator CSV delivery point, mirroring
                       the per-form audit ring buffer into the site-wide log

Labels added to the activity-log viewer; subscriber tests cover the two new
handlers + their hook registration.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): granular control — min level + category toggles (3a) (#421)

* feat(activity-log): granular control (minimum level + per-category toggles)

Adds two filters to ActivityLog::log(), applied right after the master
toggle and before any DB work:
- Minimum level (activity_log_min_level): drop events below the configured
  severity. debug < info < warning < error; default debug (log all).
- Per-category enable (activity_log_cat_<cat>): seven categories
  (submissions, scheduling, public_access, users, recruitment, migrations,
  system) via ActivityLog::category_for_action(); default all on.

Both default to "log everything", so existing installs are unaffected.

- SettingsReader: activity_log_min_level() (validated) +
  activity_log_category_enabled() (default true).
- Settings → Advanced UI: min-level <select> + 7 category toggles.
- Persisted via SettingsAjaxEndpoint allowlist (autosave) and the
  advanced-tab form save handler.
- Tests: category map, both gating paths, and the two reader accessors.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style: align array arrows in activity-log category map (WPCS)

phpcbf — fix WordPress.Arrays.MultipleStatementAlignment in the
category_for_action() map and the save handler. No logic change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(activity-log): visual threshold table for the minimum-level picker (#422)

Replace the min-level <select> with a radio "threshold" table that mirrors
the standard logger-threshold model: picking a level tints that row and
every more-severe row below it soft green (recorded), leaving rows above
neutral (ignored) — making the more-data ↔ less-data trade-off obvious.

- Pure-CSS highlight via :has(input:checked) — selected row + following
  rows go soft green (--ffc-success-light); no JS needed for the visual.
- ffc-admin-autosave.js: radios now send the checked member's VALUE (e.g.
  'info') instead of a checkbox-style 1/0, so the level persists correctly.
  No existing autosave radios, so the change is safe.
- Vitest: assert a radio group autosaves its selected value.
- Rebuilt the css/js bundles.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: WooCommerce-style vertical tabs for the 7 sections (#423)

* feat(form-editor): scaffold vertical-tabbed container for the 7 content sections

Collapse the seven stacked content metaboxes into one wrapper metabox
(ffc_box_tabs) that renders a WooCommerce "Product data"-style vertical
nav (short labels + dashicons) plus one <section role="tabpanel"> per
tab, each reusing the existing render_box_* method as its panel body.

Every panel stays in the DOM, so the post-save path and the
document-delegated form-meta autosave keep working unchanged. Without JS
the panels degrade to a stacked layout (the pre-tabs behaviour), so the
screen stays usable if the tab script fails to load. The CSS hiding and
tab-switching land in the next two sprints.

Harden FormEditorMetaboxRendererTest's WP-function mocks so the suite no
longer depends on cross-test ordering (the rate-limiter settings cache
was leaking between tests, masking the restriction render path's mock
requirements).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(form-editor): vertical-tab styling for the configuration container

WooCommerce "Product data"-style nav: a fixed-width vertical rail on the
left (icon + short label per tab, active item accented with a left border
and the primary colour) and the panel body on the right. Reuses the
shared --ffc-* design tokens, so dark mode comes for free.

Panel hiding is scoped to `.ffc-form-tabs.is-ready`, which the tab script
adds at runtime; without it the panels stay visible and stacked with
section dividers (the no-JS fallback). Below 782px the nav reflows above
the panels as a horizontal strip. Includes dormant .has-error styling for
the validation-signalling sprint. Rebuilt ffc-admin.min.css.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): tab-switching behaviour with ARIA, hash deep-links and CodeMirror refresh

Adds ffc-form-editor-tabs.js (enqueued on the form edit screen) and wires
the WAI-ARIA tablist interaction for the configuration container: click
and roving-tabindex arrow/Home/End keys move between tabs, the active tab
is mirrored into a #ffc-tab-<key> URL hash (deep-linkable, survives reload
and back/forward), and the layout tab refreshes its CodeMirror instance
on show so the editor re-measures after being revealed from a hidden
panel. Init adds the `is-ready` class that arms the CSS panel hiding;
everything degrades to stacked panels if the script never runs.

Covered by tests/js/form-editor-tabs.test.js (10 cases). JS line coverage
holds at 82.6% (new file 95.6%).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): signal validation errors on the offending tab and auto-open it

After a failed save the editor now flags the tab whose panel holds the
error and opens it, so the operator lands on the section to fix instead
of hunting for the admin notice's cause.

FormEditor::get_error_tab_keys() peeks (non-destructively) at the two
per-user save-error transients — missing PDF {{tags}} maps to the Layout
tab, geolocation/date-time failures to the Geo & Time tab — and
enqueue_scripts() localizes the result into window.ffcFormTabsErrors. The
transients are still consumed by display_save_errors() to render the
notice; admin_enqueue_scripts runs first (head) and only reads.

The tab script marks each flagged tab with .has-error + an indicator dot
and activates the first one. Covered on both sides (PHP: transient
mapping + localize branch; JS: flagging, dedupe, unknown-key guard).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: split Time/Geolocation tabs + configurable required tags (#424)

* feat(form-editor): split Geo & Time into two tabs and refine panel titles

Two tab-UI refinements that overlap in the tab-definitions table and panel
CSS, so they land together:

- Split the combined "Geo & Time" tab into two top-level tabs — "Time"
  (date/time window + per-participant schedule exceptions) and "Geolocation"
  (GPS/IP areas). The geofence renderer splits into render_time() /
  render_geolocation() over the same ffc_geofence POST namespace and
  _ffc_geofence_config meta, so the save path is unchanged. This also removes
  the now-redundant inner "Date & Time / Geolocation" button bar (a
  tab-inside-a-tab) plus its dead handler and CSS. Validation failures route
  to the offending tab — datetime-order → Time, area/format → Geolocation —
  via a companion routing transient set alongside the existing error list,
  with a fallback that flags both when only the legacy transient is present.

- Drop the "1."…"N." numeric prefixes from the panel headings (linear
  numbering is meaningless once the tabs are navigated non-sequentially) and
  render each tab's dashicon in the panel <h2>, with a lighter title-line
  treatment.

Covered both sides: the geofence render split, the error categorizer
(datetime / area / both), the routing-transient read in get_error_tab_keys
(plus legacy fallback), and the refreshed tab-key set.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(form-editor): configurable required certificate tags with client-side save block

Promote the hardcoded {{auth_code}} / {{name}} / {{cpf_rf}} layout-tag check
into a configurable list and enforce it before save.

- SettingsReader::required_certificate_tags() reads a newline/comma list from
  Settings → Advanced (defaults to the historical trio); {{auth_code}} is
  always required and force-injected even if removed, since certificate
  verification depends on it.
- New textarea in the Advanced "Editor Preferences" card, autosaved via the
  settings AJAX endpoint as multiline_text (newlines preserved).
- Client-side guard in ffc-form-editor-tabs.js: on submit it flushes
  CodeMirror, scans #ffc_pdf_layout for each required tag (honouring the
  {{name}}/{{nome}} alias), and on a miss blocks the submit, opens the Layout
  tab and banners exactly what's missing. The save handler keeps the prior
  non-blocking warning as the JS-disabled backstop, now reading the same
  configurable list via missing_required_tags().

Covered: the reader accessor (default / parse / force-auth_code / dedupe),
missing_required_tags (empty / all-present / nome alias / configured list),
and the JS guard (block + banner + alias pass-through + no-config no-op).

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(form-editor): "Duplicate this form" link inside the Publish box (#425)

Surface the existing ffc_duplicate_form action while editing — no separate
sidebar metabox added. The Publish (Submit) box gains a small "Duplicate
this form" link that builds the same nonce-protected URL the row action on
the form list uses, so the link reuses Cpt::handle_form_duplication() in
full (fields, layout, geofence, CSV/device settings copied; access hash,
counters and audit log start fresh).

- Gated by post type (ffc_form) and Utils::current_user_can_manage().
- Hidden on auto-drafts since there is nothing meaningful to copy yet.
- Hooked on post_submitbox_misc_actions so the link sits where WordPress
  conventionally places this kind of action (next to Move to Trash), which
  is also where WooCommerce / Yoast put their "Copy to a new draft".

Covered: gate by post type, gate by capability, gate on auto-draft, and
the renders-nonce-link path; plus the constructor-registers-hook test.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): floating "Back to top" button on the Documentation settings tab (#426)

The Settings → Documentation page is one long flow — a TOC card followed
by 21 section partials. After scrolling deep, returning to the TOC meant
a manual scroll. A discreet circular link now sits at the bottom-right of
the viewport and jumps back to the top.

- Pure HTML: a `<span id="ffc-doc-top">` anchor at the top of the wrap and
  an `<a href="#ffc-doc-top">` styled as a fixed-position button at the
  bottom. No JS, no enqueue, no localisation surface beyond the aria-label
  / title text.
- `scroll-behavior: smooth` scoped via `html:has(.ffc-doc-back-to-top)`
  so it only affects the Documentation tab — other admin screens are
  untouched. Browsers without `:has()` (older Safari) jump instantly,
  which is the pre-feature behaviour.
- Honours `prefers-reduced-motion` (drops both the smooth-scroll and the
  hover transform).
- Accessible: `aria-label`, `title`, dashicon marked `aria-hidden`,
  `:focus-visible` outline.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(settings): floating "Back to top" button on every settings tab (#427)

Promotes the Documentation-tab-only back-to-top affordance (#426) to the
shared settings page wrapper so it appears across every tab under
page=ffc-settings.

- The anchor target (<span id="ffc-settings-top">) and the back-to-top
  link both move into the wrapper rendered by FFC_Settings (the parent
  of every tab's render() output) instead of the documentation view
  itself. One copy, every tab — no per-view duplication.
- Renames the hook class .ffc-doc-back-to-top → .ffc-settings-back-to-top
  and the anchor id #ffc-doc-top → #ffc-settings-top to reflect the
  broader scope (and keep the :has() smooth-scroll selector accurate).
- Removes the now-duplicated markup from
  includes/settings/views/ffc-tab-documentation.php.

Still zero JS. The button is always visible (the trade-off of option A);
on the few tabs that fit in one viewport (e.g. General) it is mildly
redundant, but a JS-driven show/hide would require detecting scrollHeight,
which contradicts the zero-JS choice. The button stays discreet
(42 px circle, opacity 0.85, bottom-right) so it does not obstruct.

Pure presentational change — no PHP logic, no JS, no tests added.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* fix(settings): float "Back to top" button reliably on every settings tab (#428)

When #427 moved the floating button to the shared settings wrapper, it
behaved correctly on Documentation but rendered inline on tabs whose
content is wrapped in a per-tab <form> (Cache / User Access / Geolocation
/ General / URL Shortener / Rate Limit / Advanced). Living inside
`<div class="wrap ffc-settings-wrap">` exposed it to whichever ancestor
those tabs end up establishing as a containing block, defeating
`position: fixed`.

Render the link via `admin_footer-{$hook}` on the ffc-settings page
instead. The hook fires at the bottom of <body> — outside `.wrap`,
outside `.ffc-tab-content`, outside every per-tab <form>, outside the
animated `ffc-tab-fade-in` ancestor — so `position: fixed` resolves
against the viewport unconditionally on every tab.

`<span id="ffc-settings-top">` stays inside the wrap (the anchor target
only needs to mark the top of the content). The `:has()` smooth-scroll
selector keeps working because the button is still in the DOM, just
hoisted to body level.

No CSS change. PHPStan / WPCS / settings test suite stay green.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* Settings page: WooCommerce-style vertical tabs + Dashicon-normalized nav (#429)

* refactor(settings): convert nav-tabs to WooCommerce-style vertical layout

Settings page now mirrors the certificate form-editor tab pattern (#423):
a vertical left-rail nav + a single panel on the right. The page-reload
save model is preserved verbatim — only the active tab renders in the DOM
and each per-tab <form> keeps its own POST flow exactly as today, so
none of the nine independent save handlers (Cache / User Access /
Geolocation / SMTP / Rate Limit / Advanced / URL Shortener / Migrations /
General) had to change.

- The <h2 class="nav-tab-wrapper"> markup becomes
  <div class="ffc-settings-tabs"> + <ul class="ffc-settings-tabs__nav">
  with one <li><a> per tab carrying the same `?tab=<id>` href that
  drives the existing controller; `.is-active` replaces `nav-tab-active`.
  ARIA tablist/tab/tabpanel roles and aria-selected/aria-controls/tabindex
  attributes follow the same pattern the form-editor tabs use.
- The old `.ffc-settings-wrap .nav-tab*` and `.ffc-settings-wrap
  .ffc-tab-content` CSS is replaced by `.ffc-settings-tabs__*` (flex
  side-by-side, border-left accent on the active tab, narrow-screen
  fallback that wraps the nav above as a horizontal strip).
- The fade-in keyframe and `prefers-reduced-motion` opt-out move from
  `.ffc-tab-content` to `.ffc-settings-tabs__panel`, so tab transitions
  feel the same as before.
- Icons stay sourced from each SettingsTab::get_icon() (returning a
  `ffc-icon-*` class) and continue to render via the existing emoji
  `::before` content from ffc-common.css. Normalizing those to
  Dashicons-font glyphs is the next sprint, isolated to CSS.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* style(settings): normalize tab icons to Dashicons inside the vertical nav

The .ffc-icon-* helpers (defined in ffc-common.css) render emojis via
::before content for general use — notices, headings, lists. Inside the
new settings vertical nav we want the form-editor look, which uses native
Dashicons. A CSS override scoped to `.ffc-settings-tabs__nav` swaps the
::before font + glyph for every settings tab; the emoji rendering stays
intact everywhere else .ffc-icon-* is used in the plugin.

The dashicons font is loaded by wp-admin on every screen, so no enqueue
change is required.

Mapping (tab class → dashicon):
  ffc-icon-settings → admin-generic   General + Advanced
  ffc-icon-email    → email           SMTP
  ffc-icon-package  → archive         Cache
  ffc-icon-link     → admin-links     URL Shortener
  ffc-icon-shield   → shield          Rate Limit
  ffc-icon-globe    → admin-site      Geolocation
  ffc-icon-users    → groups          User Access
  ffc-icon-sync     → update          Migrations
  ffc-icon-doc      → book-alt        Documentation

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Scheduling Settings + Recruitment: vertical-tab layout (matching ffc-settings) (#430)

* refactor(scheduling-settings): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-scheduling-settings (Scheduling Settings, under the
Scheduling top-level menu, renderer in AudienceAdminSettings::render_page)
onto the same WooCommerce-style vertical-tab pattern adopted by the main
settings page in #429, so all the certificate-plugin admin surfaces look
the same.

- Replaces the hand-rolled `<h2 class="nav-tab-wrapper">` block with the
  `.ffc-settings-tabs` / `.ffc-settings-tabs__nav` / `.ffc-settings-tabs__panel`
  structure. The three tabs (General / Self-Scheduling / Audience) move
  into a small associative array (id → label + dashicon) instead of being
  three repeated `<a>` literals.
- Each tab now carries an icon (the only visual addition): General →
  admin-generic, Self-Scheduling → calendar-alt, Audience → groups. The
  icons render via the native `<span class="dashicons dashicons-X">`
  markup, which composes cleanly with the existing
  `.ffc-settings-tabs__icon` layout box.
- The `?page=...&tab=<id>` URL contract is preserved, so bookmarks /
  shared links keep working, and the page-reload save model is unchanged
  — only the chrome changes. ARIA tablist / tab / tabpanel roles and
  aria-selected / aria-controls / tabindex attributes mirror the main
  settings page.
- An unknown `?tab=` value now falls back to `general` explicitly (it
  already defaulted to the General render via the switch's `default`
  branch — this just makes the active-tab paint consistent with the
  rendered content).

No CSS / JS / asset-enqueue change is required: the existing
`.ffc-settings-tabs__*` rules in ffc-admin-settings.css are already
scoped under `.ffc-settings-wrap`, and the asset manager's
`is_settings_page()` already returns true for `ffc-scheduling-settings`.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* refactor(recruitment): adopt vertical-tab layout used by ffc-settings

Brings page=ffc-recruitment (RecruitmentAdminPage::render_page) onto the
same WooCommerce-style vertical-tab pattern as page=ffc-settings (#429)
and page=ffc-scheduling-settings (previous commit in this PR), closing
out the conversion across the three main plugin admin surfaces.

- The 5-tab nav (Notices / Adjutancies / Reasons / Candidates / Settings)
  switches from `<nav class="nav-tab-wrapper">` to a vertical
  `.ffc-settings-tabs__nav` <ul>. render_tabs() now emits only the <ul>;
  the surrounding `.ffc-settings-tabs` container and per-tab
  `.ffc-settings-tabs__panel` are opened/closed by render_page() around
  the existing per-tab render_*_tab() dispatch.
- Each tab gains a Dashicons icon (the only visual addition): Notices →
  megaphone, Adjutancies → building, Reasons → format-status, Candidates
  → id, Settings → admin-generic. Native `<span class="dashicons
  dashicons-X">` markup composes with the `.ffc-settings-tabs__icon`
  layout box, same as page=ffc-scheduling-settings does.
- The `?page=ffc-recruitment&tab=<slug>` URL contract is preserved
  verbatim, so bookmarks / shared links keep working. ARIA tablist / tab
  / tabpanel roles + aria-selected / aria-controls / tabindex attributes
  match the other two settings pages.
- The edit-screens early-return (edit-notice / edit-candidate /
  edit-reason / edit-adjutancy) is untouched — those have their own
  chrome and don't use the tab strip.

The `.ffc-settings-tabs__*` rules live in ffc-admin-settings.css, which
wasn't loaded on page=ffc-recruitment before. The recruitment asset
manager now enqueues it (with ffc-common as the dep so the CSS vars
resolve); the other rules in that file are scoped under
`.ffc-settings-wrap` and stay dormant here.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat(docs): sticky + auto-collapsing Quick Navigation TOC on the Documentation tab (#431)

The Documentation settings tab is one long page (21 sections). Until now
the "Quick Navigation" TOC sat at the very top — once you scrolled past
it you had to scroll back up (or hit the floating back-to-top button) to
jump between sections. The TOC card now follows the user down the page
and collapses out of the way after the original position scrolls past:

- The TOC card uses `position: sticky; top: 16px` so it stays glued to
  the top of the viewport while reading. The intro card moves above the
  sentinel so the TOC has its own independent card that can become
  sticky cleanly.
- A new sentinel `<div class="ffc-doc-toc-sentinel">` is placed just
  above the TOC; `assets/js/ffc-doc-toc.js` watches it via
  `IntersectionObserver`. When the sentinel is out of view (user has
  scrolled past the TOC's original position) the card gets the
  `is-collapsed` class — only the "Quick Navigation" title + a chevron
  glyph remain. Back at the top, the card expands again.
- Click the collapsed strip anywhere except an anchor to manually toggle
  the expansion (so the user can peek mid-page without scrolling up).
  Clicking any anchor inside re-applies `is-collapsed` so the next
  scroll re-syncs to the IO-driven state.
- The script is enqueued only when `page=ffc-settings&tab=documentation`
  is the active screen, via a new `is_documentation_tab()` helper in
  AdminAssetsManager — the rest of the admin pays no cost. Falls back
  to the always-expanded sticky TOC when `IntersectionObserver` is
  unavailable, and respects `prefers-reduced-motion`.
- Covered by 8 Vitest tests (tests/js/doc-toc.test.js) that mock
  `IntersectionObserver` to drive both intersection callbacks and the
  click toggle deterministically.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* feat(scheduling): fold Import & Export into Scheduling Settings as a 4th tab (#432)

page=ffc-scheduling-import is no longer a separate sidebar submenu — it
now lives as the "Import & Export" tab inside page=ffc-scheduling-settings,
alongside General / Self-Scheduling / Audience. The Tools menu separator
was retired (Settings was the only remaining item under it once Import
moved in), so Settings now sits at the bottom of the Audience group.

- AudienceAdminImport gains `render_content()` — the existing body minus
  the page-level `<div class="wrap"><h1>` chrome — so the four CSV
  import + export forms can render inside the settings vertical-tab panel
  unchanged. `render_page()` is kept as a thin wrap+h1 wrapper for
  back-compat with any external caller; the live entry point is
  `render_content()`.
- AudienceAdminSettings receives an AudienceAdminImport instance via the
  constructor (DI) and adds the 4th tab (icon `database-import`). The
  switch dispatches `case 'import'` to `$this->import->render_content()`.
- AudienceAdminPage drops the Import submenu registration and the
  `#ffc-separator-tools` row from the menu-separator ordering.
- New `admin_init` action `redirect_legacy_import_url()` 301-redirects
  `?page=ffc-scheduling-import` → `?page=ffc-scheduling-settings&tab=import`
  so old bookmarks / docs / dashboard links keep working.

The four import forms' POST handlers (handle_csv_import via
handle_form_submissions) fire on every admin_init regardless of which
page rendered them, and the inline tab-switching `<script>` inside the
import body uses generic .nav-tab-wrapper / .ffc-tab-content selectors
that do not clash with the vertical-tab nav above (those use
.ffc-settings-tabs__*).

Tests updated:
- AudienceAdminSettingsTest: 4 constructor calls now pass a Mockery
  AudienceAdminImport stub.
- AudienceAdminPageTest: submenu count drops from 7 to 6, the
  ffc-scheduling-import slug is now asserted absent, the
  #ffc-separator-tools assertion flips from "contains" to "not contains",
  and two new tests cover the legacy-URL redirect guard paths.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Correction

* Docs: {{schedule}} placeholder + Recruitment: new `withdrew` terminal status (#433)

* docs: add the {{schedule}} and {{schedule_total}} PDF template variables

PdfGenerator already resolves these two placeholders in generate_html()
(#366 Sprint 7) — the per-submission Schedule Exception wins, then the
form-level Class Schedule, then the form's Time Range — but they were
never listed in the §2 Template Variables table, so templates that
should display the participant's effective schedule rendered the raw
{{schedule}} token instead.

Adds both rows to includes/settings/views/documentation/02-variables.php
with a short description of the precedence order and a sample value.
No runtime change.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* feat(recruitment): add `withdrew` (Desistente) as a second terminal status

A candidate who actively withdraws after being called or accepted is now
distinguishable from one who simply did not show up — the classification
status enum gains `withdrew` as a terminal value alongside `hired`.

State machine:
- Transitions: `called → withdrew` and `accepted → withdrew` are allowed
  (mirrors the existing `… → hired` shape). No transitions from `empty`
  (nothing to withdraw from) or `not_shown` (already an end state for the
  call). No transitions OUT of `withdrew` — it is terminal.
- The terminal guard in transition_to() returns
  `recruitment_state_terminal_withdrew` for blocked moves, mirroring the
  existing `…_terminal_hired` handling.
- The reopen-freeze rule covers withdrew automatically: terminal
  classifications are frozen by construction, so the rule's
  hired/not_shown carve-out widens transparently. The user-facing text
  on the "Reopen" confirm + the post-reopen banner now read
  "hired/withdrew/not_shown".

UI:
- New "Mark withdrew" buttons next to the existing call-lifecycle
  actions on the Definitive list rows (both `called` and `accepted`
  rows in render_classification_actions).
- The terminal-state cell merges into a single
  `case 'hired': case 'withdrew':` branch.

Configuration:
- New `status_color_withdrew` Settings key (defaults to `#f5c6cb` —
  pink-red, distinct from `not_shown`'s `#f8d7da`). Wired through the
  defaults map, sanitizer, getter and the Status badge colors block
  rendered in Settings.

Schema:
- The classification table's `status` ENUM widens to include `withdrew`
  on fresh installs (`create_classification_table`) and on existing
  installs via a new V8 migration (`migrate_add_withdrew_status` —
  pure ALTER TABLE … MODIFY status, no rows touched).

Tests:
- RecruitmentClassificationStateMachineTest: +3 cases —
  test_called_to_withdrew_is_allowed,
  test_accepted_to_withdrew_is_allowed, test_withdrew_is_terminal.
- RecruitmentAdminPageTest: settings stub now carries
  `status_color_withdrew` so the badge test keeps resolving the color.

CHANGELOG covers both this addition and the {{schedule}} doc commit.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Language Update

* fix(preview): include {{schedule}} / {{schedule_total}} in the preview map (#434)

PdfGenerator already resolves both placeholders at runtime (#366 Sprint 7)
and §2 Template Variables now documents them (previous PR), but the
canonical preview-sample map in CertificatePreviewSamples::get_map() —
which feeds both the admin form-editor preview (ffc-admin-pdf.js) and
the public CSV-download preview (ffc-csv-download.js) — never had entries
for the two keys, so templates that referenced them rendered the raw
`{{schedule}}` / `{{schedule_total}}` token in both preview surfaces.

Adds the two entries (`08:00 – 17:30` / `9h 30min`) matching the values
shown in the docs row. CertificatePreviewSamplesTest gains assertions
that the map carries both keys so a future refactor that drops them
breaks loudly.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

Co-authored-by: Claude <noreply@anthropic.com>

* Form editor: promote Event Schedule to a primary "Time" tab section + {{schedule}} save guard (#435)

* feat(form-editor): promote Event Schedule to a primary "Time" tab section

The class_time_start / class_time_end inputs that feed the {{schedule}}
PDF placeholder were previously buried inside the per-participant
"Schedule Exception" subsection — operators who only wanted to display
the event's reference schedule on the certificate had to enable an
unrelated feature to reach those inputs (the Class Schedule row sat
inside the Exception's collapsible <tbody> gated by its master toggle).

This commit:

- Adds a new "Event Schedule (Reference)" subsection at the top of the
  Time tab, holding the From/To time inputs. The description spells out
  the rule the save guard now enforces:
    "When does this event take place? Renders as {{schedule}} on the
     certificate template (e.g. '9h às 12h'). When filled, the template
     must contain {{schedule}} — the form save will be blocked until
     the placeholder is present."
- Removes the Class Schedule row from the Schedule Exception subsection
  and updates that section's description to say the exception
  "overrides the Event Schedule above" per-submission. The Schedule
  Exception subsection stays where it is and keeps its Default Modal
  Mode control.
- Same `ffc_geofence[class_time_*]` POST keys — no data migration, no
  runtime change to PdfGenerator's `resolve_effective_schedule` chain.

Save guard (per-form, dynamic):
- FormEditorSaveHandler::missing_required_tags() now takes the form's
  post_id and reads `_ffc_geofence_config`. When `class_time_start` or
  `class_time_end` is non-empty, it injects {{schedule}} into the
  required-tag list FOR THIS SAVE ONLY — leaving the global
  configurable list (Settings → Advanced) untouched. Forms that don't
  fill Event Schedule keep the previous behaviour.
- FormEditor::enqueue_scripts() mirrors the rule into the
  `ffcFormRequiredTags` localize block so the client-side guard from
  #424 surfaces the requirement on the next save attempt, not after
  a server round-trip.

Tests:
- FormEditorSaveHandlerTest: setUp gains a default
  `get_post_meta() -> false` mock so the existing missing_required_tags
  tests keep passing with the new signature; two new tests cover the
  schedule gate ON and OFF.
- FormEditorTest enqueue tests gain matching get_post_meta mocks.

Backwards-compat caveat (per chat agreement, mitigação A): forms that
have `class_time_*` set today but DON'T include {{schedule}} in the
layout will start failing the save with the existing banner from #424.
The banner names the missing tag explicitly, so it's self-explanatory.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

* fix(wpcs): @param order in missing_required_tags() docblock

The @param tags for missing_required_tags() were swapped relative to
the signature ($layout, $post_id), which Squiz.Commenting.FunctionComment
flagged on CI (passed locally because I had run an outdated phpcs cache
before the docblock edit). Reorder the docblock to match.

https://claude.ai/code/session_01BEUEoUQmxb5d7akkVmaSVY

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(geofence): three bugs in the Time tab — translation, Event Schedule borders, Exception autosave (#436)

* fix(geofence): make live date/time-order error copy translatable via Loco

Geofence::analyze_datetime_order() (#163 S2) is mirrored byte-for-byte
on the client by ffc-geofence-validation.js so the red-border feedback
updates as the operator types. The JS, however, had the three error
strings hard-coded in English (lines 36 / 48 / 56) — Loco translated
the PHP `__()` calls, but the live JS message stayed English. Only the
save-time admin notice (PHP path) rendered in PT.

Localize the three strings via `wp_localize_script` →
`window.ffcGeofenceMessages` and have the JS look them up with the
English copy as fallback (kept for the rare unit-test / pre-localize
load case).

Strings localized:
  - "End date is earlier than the start date."
  - "In span mode, the end datetime must be after the start datetime."
  - "End time must be later than start time. For an overnight single
     event, switch the Time Mode to ..."

No new …
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.

2 participants