chore(deps)(deps-dev): bump jsdom from 25.0.1 to 29.1.1 - #397
Merged
Conversation
Contributor
Author
LabelsThe following labels could not be found: Please fix the above issues or remove invalid values from |
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](terser/terser@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>
Bumps [jsdom](https://github.com/jsdom/jsdom) from 25.0.1 to 29.1.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](jsdom/jsdom@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>
dependabot
Bot
force-pushed
the
dependabot/npm_and_yarn/jsdom-29.1.1
branch
from
May 25, 2026 08:55
5e77d4a to
0742422
Compare
1 task
rpgmem
enabled auto-merge (squash)
May 26, 2026 01:07
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 …
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps jsdom from 25.0.1 to 29.1.1.
Release notes
Sourced from jsdom's releases.
... (truncated)
Commits
9b9ea7e29.1.107efb78Optimize computed style comparison5f66329Fix background-origin/background-clip in background shorthandad8af77Fix border shorthand handling5a3e88e29.1.073db204Update dependencies and dev dependenciesa7168a5Support ratio CSS unit type15346e0Fix style cache invalidation2a1e2cd29.0.24097d66Resolve computed CSS values lazily in CSSStyleDeclarationMaintainer changes
This version was pushed to npm by GitHub Actions, a new releaser for jsdom since your current version.
Install script changes
This version modifies
preparescript that runs during installation. Review the package contents before updating.