Skip to content

Add first-run onboarding wizard#33

Open
RCowles wants to merge 18 commits intotrunkfrom
add/onboarding-wizard
Open

Add first-run onboarding wizard#33
RCowles wants to merge 18 commits intotrunkfrom
add/onboarding-wizard

Conversation

@RCowles
Copy link
Copy Markdown
Contributor

@RCowles RCowles commented Apr 24, 2026

Summary

  • Adds a 5-step onboarding wizard that appears on first plugin activation, guiding non-technical site owners through federation setup
  • Replaces jargon-heavy concepts ("actor mode", "post types") with plain-language choices ("How should your site appear?", "What do you want to share?")
  • Bluesky step is a placeholder (coming soon) until Bluesky_Provider ships
  • Pure PHP, no JS build step — card selection uses hidden radio inputs with CSS :has(:checked)
image

How it works

  1. register_activation_hook sets a transient
  2. admin_init catches it, redirects to ?page=fosse-wizard once
  3. Each step POSTs to admin_post.php, saves to the same fosse_ap_* options AP_Provider manages
  4. "Skip setup" and completion both set fosse_onboarding_completed
  5. Setup page shows a notice linking to the wizard when incomplete
  6. "Run wizard again" link on completion step resets the option

Test plan

  • Activate FOSSE for the first time — should redirect to wizard
  • Click through all 5 steps — options saved at each step
  • "Skip setup" at any step — lands on Setup page, wizard notice gone
  • "Run wizard again" on completion — resets and starts over
  • Wizard page not visible in admin sidebar menu
  • Regular Setup and Status pages still work
  • composer run-script lint-php clean
  • composer run-script test-php passes (51 tests, 12 new)
  • pnpm run test:e2e passes (12 new wizard specs)

Reload the wizard to see the lizard.

Refs: DOTCOM-16793

RCowles added 15 commits April 23, 2026 12:16
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' ); ?>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' ); ?>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_Wizard admin page (hidden submenu) with step rendering + admin-post handlers for save/skip/reset.
  • Implements first-activation redirect via activation transient + admin_init redirect 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.

Comment thread src/Admin/class-onboarding-wizard.php Outdated
* @return void
*/
public static function mark_complete(): void {
update_option( self::COMPLETED_OPTION, 1, true );
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
update_option( self::COMPLETED_OPTION, 1, true );
update_option( self::COMPLETED_OPTION, 1, false );

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +137
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' );
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +107
case 'complete':
self::mark_complete();
self::render_step_complete();
break;
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/Admin/class-menu.php
Comment on lines +111 to +121
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;
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Comment on lines +24 to +26
/* 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>'
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
/* 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>'
)
)

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +180
* 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.
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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.

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +287
/* 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);
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- **Depends on**: Task 2, Task 4

### Task 5.5: Create first-run onboarding wizard
- **Status**: Not started
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- **Status**: Not started
- **Status**: Done

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +43
- **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.
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
- **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.

Copilot uses AI. Check for mistakes.
Comment thread src/Admin/class-menu.php
Comment on lines +124 to +139
// 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;
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/Admin/class-menu.php
Comment on lines +66 to +74
// 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' )
);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Base automatically changed from add/onboarding-setup-ux to trunk April 24, 2026 22:13
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants