Conversation
FOSSE uses namespaced classes with classmap autoloading, where PascalCase filenames matching class names is the standard PHP convention. The Jetpack PHPCS ruleset enforces class-*.php lowercase-hyphenated filenames, which is the WordPress convention for non-namespaced code. The existing src/Bundled/Bootstrap.php already uses PascalCase; this makes the exclusion explicit.
Extensible provider abstraction for federation protocols. Each provider implements get_slug, get_name, is_available, get_status, render_setup_section, render_status_card, and register_hooks. Connection_Provider_Registry is a static registry with register, get_providers, get_provider, and reset. Duplicate slugs silently ignored (first wins). Setup and Status pages will iterate this registry rather than hardcoding protocol-specific sections. Includes unit tests for all registry operations.
Top-level FOSSE menu with Setup and Status sub-pages. Menu registers at priority 9, then hides all bundled-plugin admin entries at priority 99: AP Settings and Dashboard, AP Users submenus (Followers, Following, Blocked Actors, Extra Fields), and Atmosphere Settings. Pages remain accessible by direct URL for power users. Setup_Page and Status_Page iterate Connection_Provider_Registry to render provider sections and status cards. Status template includes a summary row with attention state when not all protocols are connected. Admin CSS provides status indicators, card grid, and summary bar styling, leaning on native WP admin classes. Wired up in fosse.php behind an is_admin() + class_exists() guard.
ActivityPub provider for the FOSSE admin UI. Self-registers on fosse_register_providers with a class_exists guard. Setup section renders inline configuration: actor mode radio, post type checkboxes, fediverse address display, and a link to the advanced AP settings page. Option projection pattern: FOSSE stores its own options (fosse_ap_actor_mode, fosse_ap_support_post_types) and projects them to AP via pre_option_* filters at priority 20. AP's own constant-based overrides at priority 10 take precedence. When the FOSSE option is absent, AP's stored value is read from the DB. Status card shows actor mode, post types, fediverse address, and follower count (when the AP Followers API is available). Includes unit tests for metadata, status shape, option projection, filter priority, and projected status reflection.
Add @before/@after annotations alongside PHP 8 attributes to satisfy the Jetpack PHPUnit compatibility sniff. Update bundled-backends E2E test: the ActivityPub menu is now hidden by FOSSE's menu suppression, so assert it is hidden and FOSSE's own menu is visible instead.
Fediverse address now shows the user's webfinger in actor mode, the blog's in blog mode, and the user's (with blog fallback) in actor_blog mode. Previously always used the blog actor regardless. Follower count now uses the blog actor ID in blog mode and shows both author and blog counts in actor_blog mode. Previously always counted against get_current_user_id().
Cast $_POST['fosse_ap_support_post_types'] to array before passing to array_map, preventing a TypeError if the input is malformed.
In pure actor mode, if User::from_wp_user() fails, return empty string instead of falling through to the blog webfinger. Blog fallback is now only used in actor_blog mode.
Provider_Loader::boot() fires fosse_register_providers and calls register_hooks() on each available provider. Runs unconditionally so option-projection filters are active on every request (REST, WebFinger, cron), not just wp-admin. Menu::register() is now admin-only: menu pages, bundled-menu suppression, and CSS enqueue. Includes regression test verifying projection works without is_admin() context.
Skip blog-count branch when BLOG_USER_ID constant is absent instead of falling back to user ID 0 and querying a bogus row. Label now says "Your Followers" in actor mode and "Your followers: X, Blog: Y" in actor_blog mode, so the count is honest about what it represents on multi-author sites. Add source reference comment on the Extra Fields submenu suppression noting it mirrors bundled AP's class-menu.php:94-98.
PHPUnit: five new tests for handle_save() covering valid saves, invalid actor mode rejection, invalid post type filtering, and non-array post type input handling. E2E: two new Playwright tests asserting that suppressed bundled AP and Atmosphere settings pages remain accessible by direct URL (the menu entries are hidden but the pages still load).
Add automattic/jetpack-autoloader as a runtime dependency and switch fosse.php to load vendor/autoload_packages.php. This positions FOSSE for WP.com/Simple inclusion and enables safe version resolution if AP/Atmosphere are later extracted into shared Composer packages. Rename all src/ files to WordPress class-*.php / interface-*.php naming convention and drop the PHPCS src/ exclusion from WordPress.Files.FileName. Files now pass the sniff natively. Update build-zip sanity check to verify autoload_packages.php is present in the built archive.
Actor mode changes now fire update_option_activitypub_actor_mode so AP's scheduler propagates the new topology to remote followers. Status indicator is wired to get_status()['connected'] instead of being hardcoded. Invalid actor mode submissions show an error notice instead of a false success message.
The existing Setup page exposes raw ActivityPub concepts (actor mode,
post types) that are meaningless to non-technical site owners.
This adds a 5-step wizard that appears on first activation:
1. Welcome — value prop for the social web
2. Appearance — actor mode as visual cards ("As your site" / "As you" /
"Both") instead of radio buttons labeled "Actor mode"
3. Content — post type selection with plain labels
4. Bluesky — placeholder step (provider not yet built)
5. Complete — summary with links to Status/Setup
Architecture: activation hook sets a transient, admin_init checks it
and redirects once. Wizard is a hidden admin page (empty parent slug).
Each step POSTs to admin_post.php, saves to the same fosse_ap_* options
AP_Provider manages. Pure PHP, no JS build step. Card selection uses
hidden radio inputs inside <label> elements with CSS :has(:checked).
Setup page shows a notice linking to the wizard when incomplete.
"Skip setup" and completion both set fosse_onboarding_completed.
"Run wizard again" link on the completion step resets the option.
Refs: DOTCOM-16793
| ?> | ||
| <h1 class="fosse-wizard__title"><?php esc_html_e( 'Welcome to FOSSE 🦎', 'fosse' ); ?></h1> | ||
| <p class="fosse-wizard__description"> | ||
| <?php esc_html_e( 'FOSSE connects your WordPress site to the social web. People can follow your site and see your posts in their feeds, whether they use Mastodon, Bluesky, or any other compatible app.', 'fosse' ); ?> |
There was a problem hiding this comment.
This is probably one of the more important pieces of copy that we'll need to figure out. It needs to be clear about the what/why without getting overly technical. Any thoughts?
| </div> | ||
| <div class="fosse-welcome-feature__text"> | ||
| <strong><?php esc_html_e( 'Reach new audiences', 'fosse' ); ?></strong><br> | ||
| <?php esc_html_e( 'Your posts appear across the social web, including Mastodon, Bluesky, and more.', 'fosse' ); ?> |
There was a problem hiding this comment.
Similar, this should have enough info to be descriptive, but also not overly technical and not an exhaustive list of platforms/services/etc.
1. "Selecting a mode saves the option" — verified by navigating back to the appearance step and checking the radio state, instead of checking the Setup page's AP_Provider form which uses a different input name. 2. "Completion step shows summary" — text=Bluesky matched two elements (description paragraph + summary cell) causing a strict mode violation. Use .fosse-summary__label locator instead. 3. "Setup page shows wizard notice" — reordered before the "Skip setup" test since Playground state is shared across tests in the same worker. Skip marks the wizard complete, hiding the notice for subsequent tests.
There was a problem hiding this comment.
Pull request overview
Adds a first-run, 5-step onboarding wizard to the FOSSE WordPress plugin admin, aimed at guiding initial federation setup with a plain-language UX while persisting choices into existing fosse_ap_* options.
Changes:
- Introduces
Onboarding_Wizardadmin page (hidden submenu) with step rendering + admin-post handlers for save/skip/reset. - Implements first-activation redirect via activation transient +
admin_initredirect logic, and adds an “incomplete wizard” notice on the Setup page. - Adds wizard-specific admin CSS plus new PHP unit tests and Playwright e2e specs covering the wizard flows.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
fosse.php |
Sets an activation transient to trigger the one-time onboarding redirect. |
src/Admin/class-menu.php |
Registers hidden wizard page, hooks admin_init redirect, and enqueues CSS for the wizard hook. |
src/Admin/class-onboarding-wizard.php |
New wizard implementation: step rendering, save/skip/reset handlers, and completion tracking. |
src/Admin/class-setup-page.php |
Passes wizard_incomplete flag into the Setup template. |
src/Admin/templates/setup-page.php |
Adds an admin notice linking to the wizard when onboarding isn’t complete. |
src/Admin/assets/css/admin.css |
Adds styles for the wizard UI (progress indicator, cards, summary, etc.). |
tests/php/Admin/Onboarding_WizardTest.php |
Adds unit tests for completion tracking and admin-post handlers. |
tests/e2e/onboarding-wizard.spec.ts |
Adds Playwright coverage for wizard navigation, option persistence, and menu invisibility. |
sdd/onboarding-setup-ux/planned-decisions.md |
Documents the onboarding redirect + per-step save decisions. |
sdd/onboarding-setup-ux/plan.md |
Updates the SDD plan with a task describing the wizard implementation. |
| * @return void | ||
| */ | ||
| public static function mark_complete(): void { | ||
| update_option( self::COMPLETED_OPTION, 1, true ); |
There was a problem hiding this comment.
mark_complete() forces the completion flag option to be autoloaded (update_option(..., true)). Autoloading a UI-only flag contributes to alloptions bloat and isn’t necessary here; consider omitting the 3rd argument (default behavior) or explicitly setting autoload to false/no so it isn’t loaded on every request.
| update_option( self::COMPLETED_OPTION, 1, true ); | |
| update_option( self::COMPLETED_OPTION, 1, false ); |
| if ( 'appearance' === $step ) { | ||
| $mode = sanitize_text_field( wp_unslash( $_POST['fosse_ap_actor_mode'] ?? '' ) ); | ||
| if ( in_array( $mode, self::ACTOR_MODES, true ) ) { | ||
| update_option( 'fosse_ap_actor_mode', $mode ); | ||
| } | ||
| self::redirect_to_step( 'content' ); | ||
| } |
There was a problem hiding this comment.
When the wizard updates fosse_ap_actor_mode, ActivityPub won’t see an update_option_activitypub_actor_mode event (since FOSSE projects values via pre_option_*). In AP_Provider::handle_save() you manually fire do_action( 'update_option_activitypub_actor_mode', ... ) to trigger AP’s scheduler; the wizard should do the same so changing actor mode during onboarding propagates correctly (e.g., blog actor profile refresh).
| case 'complete': | ||
| self::mark_complete(); | ||
| self::render_step_complete(); | ||
| break; |
There was a problem hiding this comment.
The wizard is marked complete as a side effect of rendering the step=complete page (mark_complete() runs on a GET request without a nonce). This is a state-changing action that can be triggered via CSRF and can incorrectly suppress the wizard/notice. Consider moving completion to a POST-backed admin-post action (or require/verify a nonce on the completion transition) and only mark complete after explicit user intent.
| public static function maybe_redirect_to_wizard(): void { | ||
| if ( ! get_transient( Onboarding_Wizard::REDIRECT_TRANSIENT ) ) { | ||
| return; | ||
| } | ||
|
|
||
| delete_transient( Onboarding_Wizard::REDIRECT_TRANSIENT ); | ||
|
|
||
| // Don't redirect during bulk activation, AJAX, or CLI. | ||
| if ( wp_doing_ajax() || wp_doing_cron() || defined( 'WP_CLI' ) ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
maybe_redirect_to_wizard() deletes the activation transient before checking whether a redirect should actually occur, and it doesn’t gate on current_user_can('manage_options'). Because the transient is global, a lower-privileged admin user (or an AJAX/cron request) can consume it and prevent the intended admin redirect. Consider checking capability / request context first, and only deleting the transient when you are about to redirect (or intentionally skipping redirect for bulk activation).
1. Completion no longer mutates state on GET. The wizard marks itself
complete via a nonced admin-post action (fosse_wizard_complete),
not as a side effect of rendering step=complete. The Bluesky
step's "Skip for now" link now POSTs through this action.
2. Activation redirect transient is consumed only after all guards
pass (capability, AJAX/cron/CLI, wizard-already-complete, bulk
activation). Prevents a lower-privileged request from consuming
the transient before the admin gets redirected.
3. Wizard save for actor mode now fires
do_action('update_option_activitypub_actor_mode') to notify AP's
scheduler, matching what AP_Provider::handle_save() already does.
Without this, actor mode changes during onboarding wouldn't
propagate to federation until the next Setup page save.
4. fosse_onboarding_completed option set with autoload=false since
it's only checked on admin pages.
| /* translators: %s: link to setup wizard */ | ||
| esc_html__( 'First time here? %s to configure federation in a few steps.', 'fosse' ), | ||
| '<a href="' . esc_url( admin_url( 'admin.php?page=fosse-wizard' ) ) . '">' . esc_html__( 'Run the setup wizard', 'fosse' ) . '</a>' |
There was a problem hiding this comment.
This translation string injects a full <a> element via a single %s placeholder while using esc_html__(). For better i18n flexibility and safer HTML handling, consider using %1$s/%2$s placeholders for the opening/closing anchor and wrapping the formatted string in wp_kses_post() (or otherwise explicitly allowing the link HTML).
| /* translators: %s: link to setup wizard */ | |
| esc_html__( 'First time here? %s to configure federation in a few steps.', 'fosse' ), | |
| '<a href="' . esc_url( admin_url( 'admin.php?page=fosse-wizard' ) ) . '">' . esc_html__( 'Run the setup wizard', 'fosse' ) . '</a>' | |
| wp_kses_post( | |
| sprintf( | |
| /* translators: 1: opening link tag to setup wizard, 2: closing link tag */ | |
| __( 'First time here? %1$sRun the setup wizard%2$s to configure federation in a few steps.', 'fosse' ), | |
| '<a href="' . esc_url( admin_url( 'admin.php?page=fosse-wizard' ) ) . '">', | |
| '</a>' | |
| ) | |
| ) |
| * Marks the wizard as complete via a nonced POST action, then redirects | ||
| * to the completion view. This ensures completion requires explicit user | ||
| * intent and cannot be triggered via CSRF. |
There was a problem hiding this comment.
The handle_complete() docblock says completion is triggered via a “nonced POST action”, but the UI uses nonce URLs (GET) to admin-post.php?action=fosse_wizard_complete. Either update the docblock to match the GET-with-nonce implementation, or switch the UI to a POST form if POST is the intended contract.
| * Marks the wizard as complete via a nonced POST action, then redirects | |
| * to the completion view. This ensures completion requires explicit user | |
| * intent and cannot be triggered via CSRF. | |
| * Marks the wizard as complete via a nonced `admin-post.php` action, | |
| * typically reached from a nonce-protected URL, then redirects to the | |
| * completion view. Capability checks plus nonce verification ensure the | |
| * action requires explicit user intent and cannot be triggered via CSRF. |
| /* Hide the real radio input */ | ||
| .fosse-mode-card__input { | ||
| position: absolute; | ||
| opacity: 0; | ||
| pointer-events: none; | ||
| } | ||
|
|
||
| /* Selected state via :has(:checked) */ | ||
| .fosse-mode-card:has(.fosse-mode-card__input:checked) { | ||
| border-color: #3858e9; | ||
| background: rgba(56, 88, 233, 0.02); | ||
| } |
There was a problem hiding this comment.
The radio inputs for the mode cards are visually hidden (opacity: 0, position: absolute) and the selected state relies on :has(), but there’s no visible focus style for keyboard users (e.g. when tabbing onto the hidden input). Add a :focus-visible/:focus-within style on .fosse-mode-card (and consider a non-:has() fallback selector) to keep the card selection usable and discoverable without a mouse.
| - **Depends on**: Task 2, Task 4 | ||
|
|
||
| ### Task 5.5: Create first-run onboarding wizard | ||
| - **Status**: Not started |
There was a problem hiding this comment.
Task 5.5 is implemented by this PR, but the plan still marks it as “Not started”. Update the status (and any related checklist items) so the SDD plan reflects the current state of work.
| - **Status**: Not started | |
| - **Status**: Done |
| - **Decision**: On plugin activation, `register_activation_hook` sets a `fosse_activation_redirect` transient. On the next `admin_init`, Menu checks for the transient, deletes it, and redirects to the wizard page. The wizard is a hidden submenu page (registered then removed from visible menu). | ||
| - **Reason**: This is the standard WordPress onboarding pattern (WooCommerce, Jetpack, Akismet all do it). The transient ensures the redirect fires exactly once and survives the activation-to-admin-load page cycle. Registering then hiding the submenu page means the wizard has a real admin page URL and inherits all WP admin capabilities checks, but doesn't clutter the menu. |
There was a problem hiding this comment.
This decision note says the wizard is “registered then removed from the visible menu”, but the current implementation registers it as a hidden page via an empty/hidden parent slug and does not remove it later. Please align the doc to the implemented approach (or adjust the code to match the decision).
| - **Decision**: On plugin activation, `register_activation_hook` sets a `fosse_activation_redirect` transient. On the next `admin_init`, Menu checks for the transient, deletes it, and redirects to the wizard page. The wizard is a hidden submenu page (registered then removed from visible menu). | |
| - **Reason**: This is the standard WordPress onboarding pattern (WooCommerce, Jetpack, Akismet all do it). The transient ensures the redirect fires exactly once and survives the activation-to-admin-load page cycle. Registering then hiding the submenu page means the wizard has a real admin page URL and inherits all WP admin capabilities checks, but doesn't clutter the menu. | |
| - **Decision**: On plugin activation, `register_activation_hook` sets a `fosse_activation_redirect` transient. On the next `admin_init`, Menu checks for the transient, deletes it, and redirects to the wizard page. The wizard is registered as a hidden admin page (via a hidden/empty parent slug) rather than being added to the visible menu and removed later. | |
| - **Reason**: This is the standard WordPress onboarding pattern (WooCommerce, Jetpack, Akismet all do it). The transient ensures the redirect fires exactly once and survives the activation-to-admin-load page cycle. Registering the wizard as a hidden admin page means it has a real admin page URL and inherits all WP admin capabilities checks, but doesn't clutter the menu. |
| // Don't redirect during bulk activation, AJAX, or CLI. | ||
| if ( wp_doing_ajax() || wp_doing_cron() || defined( 'WP_CLI' ) ) { | ||
| return; | ||
| } | ||
|
|
||
| // Don't redirect if the wizard was already completed. | ||
| if ( Onboarding_Wizard::is_complete() ) { | ||
| delete_transient( Onboarding_Wizard::REDIRECT_TRANSIENT ); | ||
| return; | ||
| } | ||
|
|
||
| // Don't redirect if activating multiple plugins at once. | ||
| // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check. | ||
| if ( isset( $_GET['activate-multi'] ) ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
maybe_redirect_to_wizard() returns early for activate-multi / AJAX / cron / CLI without clearing the activation transient. That leaves a stale transient around, so a later normal admin request can unexpectedly redirect to the wizard. Consider deleting the transient in these “do not redirect” branches (while still keeping the capability guard from consuming it for non-admin users).
| // Wizard page: null parent = hidden from all menus, accessible by direct URL. | ||
| add_submenu_page( | ||
| '', | ||
| __( 'Setup Wizard', 'fosse' ), | ||
| '', | ||
| 'manage_options', | ||
| 'fosse-wizard', | ||
| array( Onboarding_Wizard::class, 'render' ) | ||
| ); |
There was a problem hiding this comment.
The comment says “null parent = hidden from all menus”, but the call passes an empty string for parent_slug and menu_title. Using null for parent_slug (and a non-empty menu_title) is the documented hidden-admin-page pattern and avoids ambiguity about the generated hook suffix / menu registration behavior.
1. "As you" card click matched both "As you" and "As your site" (substring match). Use exact title match via nested locator with regex /^As you$/. 2. Wizard notice test consumed activation redirect transient by visiting the wizard page first, preventing the Setup page navigation from being redirected away.
Summary
:has(:checked)How it works
register_activation_hooksets a transientadmin_initcatches it, redirects to?page=fosse-wizardonceadmin_post.php, saves to the samefosse_ap_*options AP_Provider managesfosse_onboarding_completedTest plan
composer run-script lint-phpcleancomposer run-script test-phppasses (51 tests, 12 new)pnpm run test:e2epasses (12 new wizard specs)Reload the wizard to see the lizard.
Refs: DOTCOM-16793