The first one isn't an install
If LDX ships first, then the first onboarding we ever run is an update to a plugin somebody else sold them, from a company they have not heard of. That inverts the design. On a fresh install they chose us and a wizard is welcome; here they did not ask for anything and a wizard is an ambush. So: deliver first, ask last, gate nothing — and make the content scannable rather than sequential, which is exactly what a table is for.
Every row below is real. It comes from the 152-commit history of
design-upgrade-pro-learndash at
v3.0.0-beta.5 — §5 shows the commit-to-row mapping. And §4 is
the part you actually asked for: a registry so any plugin attaches its own rows, with
the same registry feeding the email and the release-notes page.
What changed, as a table
Built on .wp-list-table's conventions — real
thead, striped rows, hover — because "a table of things that
changed" is a shape wp-admin already has and admins already know how to scan. Grouped by
area of the product rather than by type of change, because a customer thinks
"what happened to focus mode", not "show me all the bug fixes".
Design Upgrade Pro
Updated to 3.0. Nothing on your site changed yet — this page explains what's available.
Drawn, not screenshotted
Two rows above are expanded. Both previews are built from hairlines, borders and the reader's own saved colour values — no image assets anywhere. Three reasons that is the right call, and the third is the one that matters:
A screenshot of 3.0 is wrong by 3.2. A drawn preview is generated from the same tokens the plugin renders, so it is correct by construction or it is a bug in the plugin.
No image pipeline, no retina variants, no per-locale captures, nothing to keep out of the plugin's ZIP. Roughly 40 lines of CSS covers every preview on the page.
The ladder preview renders the reader's actual primary hue and the nav preview their real lesson titles. "Here is what this does to your course" is a different argument from "here is a marketing screenshot".
| Type | For | Shape |
|---|---|---|
before-after | Anything with a visible outcome — nav highlighting, focus geometry, quiz layout. | Two boxes side by side, the left in the old defaults, the right in the reader's values. |
ladder | Colour and token changes. | One authored value, then the derived ramp, then a count of what it replaced. |
facts | Things with no visual — the test suite, settings reorganisation, the save bug. | No preview at all. The row just has no disclosure button, which is honest and saves the reader a click. |
Note that six of the fifteen rows have no preview, deliberately. A disclosure button that opens to a paragraph is worse than no button.
One registry, three surfaces
The thing you asked for. Rows are not authored in this screen — they are contributed, so the shell knows nothing about LearnDash and LDX knows nothing about the layout. Sketched rather than specified; the shape matters more than the names.
add_filter( 'tangible/onboarding/whatsnew', function ( $rows, $ctx ) {
// $ctx->from_version '2.7.4' what they were on
// $ctx->to_version '3.0.0' what they are on now
// $ctx->is_upgrade true false on a fresh install
// $ctx->licence object tier, seats, and whether consent is on file
$rows[] = [
'id' => 'ld-modern-templates',
'area' => 'course-content', // groups the table. Areas are declared once.
'kind' => 'new', // new | better | fixed. Three, on purpose.
'title' => __( 'Modern LearnDash templates are supported', 'ldx' ),
'summary' => __( 'Every control now drives LearnDash’s own theme variables…', 'ldx' ),
'where' => __( 'Course & lesson pages', 'ldx' ),
'since' => '3.0.0', // so the table can show only what is NEW TO THEM
'preview' => [ 'type' => 'before-after', 'render' => 'ldx_preview_nav' ],
'action' => [ 'label' => __( 'Open the Customizer', 'ldx' ), 'url' => admin_url( '…' ) ],
'weight' => 10,
];
return $rows;
}, 10, 2 );
since is the field that earns its keep. With it
and $ctx->from_version the shell shows each reader only what is
new to them — someone coming from 2.9 does not get 2.8's news, and the counts at
the top are their counts. Without it every upgrade shows the same static page and the
second one is noise. It also means the registry doubles as a permanent
What's new page that stays correct forever, rather than a one-shot screen we
throw away after launch.
And the argument for building it as a registry rather than a page: the same rows render three surfaces. Written once, by the person who wrote the feature, in the plugin that owns it.
Grouped, expandable, with previews. The full treatment, shown once after an upgrade and reachable afterwards from the submenu.
The same rows, filtered to
kind: new, no previews, sent by the platform to people who
opted in. Currently somebody would write that by hand and it would drift from the
plugin.
All rows, all versions, grouped by release. This is the one that is always out of date today, because it lives in a different repo from the code it describes.
Worth being honest about the cost: a registry only pays off if the rows are written as part of shipping the feature. If they get written afterwards by someone reading the commit log, we have built a more elaborate way to produce the same stale changelog. That is a process commitment, not a technical one, and it is the real risk here.
How it fires, and how it stops
The failure you named on the call — "Tangible is trying to get me to do stuff" — is a trigger problem, not a content problem. Rules, in order:
| Rule | Why |
|---|---|
Never redirect on update. Set a transient, show a dismissible notice-success, let them click. |
A redirect after an unrequested update is the ambush. On a fresh install a redirect is fine — they just pressed Activate. |
| Nothing on the site changes until they act. | Stated in the notice and again in the reassurance panel. It is also what makes the whole page safe to ignore, which is what makes it not a nag. |
| The notice appears once, and dismissal is permanent. Record it per user, not per site. | Per-site dismissal means the second admin gets a notice the first one already killed. AIOSEO's "No thanks" records nothing and re-fires forever — that is the example not to be. |
| Bulk activation and multisite show nothing. | Updating twelve plugins at once should not produce twelve notices. The dormant 2021 wizard module already had these guards; they are worth copying. |
| The page stays reachable forever, in the submenu, after the notice is gone. | Registering a page then removing it from the menu is the WP convention 4 of 4 LMS plugins follow. Here we keep the menu item, because with since the page stays useful. |
| No consent step at all if it is on file. | Gabriel: asking again "would just be an opportunity for them to unconsent". Here the ask is a single email row and it simply does not render. |
Commits to rows
So the table can be checked rather than trusted. Left column is what the history actually says; right is the customer-facing row it became.
| From the commit history | Became |
|---|---|
| "Modern theme support: hue ladders drive LD's semantic variables" · "Modern coverage wave: every control classified, gaps wired" · "Modern alerts: exclude our classic-alert rules; patch LD's own cascade defect" | Modern LearnDash templates are supported new |
| "Every sheet rule gains a :root prefix, out-specifying theme stylesheets" | Our styles no longer lose to your theme fixed |
| "P1 slice 1: token foundation + hue ladders + buttons pilot" · "Retire the Brand Primary control; the primary hue owns the concept" · "Customizer: one global per concept (Julia's call)" | One colour system instead of a hundred options new |
| "P1 alerts slice: kadence layout + ladder-driven variants — all five hues live" · "alerts/buttons icon sweep + full variant re-pointing" | Five alert colours, all wired new |
| "Global border-width tokens: border and border-bold, TUI style" · "Global Border Color overrides the neutral border/divider roles" | Border width and colour are global now better |
| "fix: Brand Primary Customizer saves were silently reverted by LearnDash" | Brand colour saves actually stick fixed |
| "Focus geometry becomes dynamic: sidebar width and header height tokens" · "Focus geometry: collapsed sidebar reclaims the content area again" · "Focus footer actions flex evenly, shrink-wrap, and wrap when tight" | Focus mode fits its own window better |
| "Logo Max Width control constrains the header logo independently" · "Focus header logo is clamped to its slot" | Logo max-width control new |
| "Course navigation gains Active link text and background controls" · "Active styling belongs to the actual current step, not its parent lesson" | Active link colours new |
| "Quiz answer items assert flex-direction: column for numbering" · "Quiz answer items: row with centred alignment, labels positioned" | Quiz answers line up and numbering shows fixed |
| "Typography tiers adopt TUI's model: px numbers times a multiplier" | Type sizes are a scale, not 40 numbers better |
| "Text and link colours move into Typography; radius and border colour into Theme" · "Alerts, Tooltips, Progress Bar and Pagination move below Buttons" · "Customizer: … + Design reset tab" | Settings you can find better |
| "tests: port bash smoke suites to PHPUnit" · "Add a tests badge" · "Improve container healthchecks" | A test suite, and updates that arrive new |
Two honest caveats. The counts in the strip
(6/9/28) are
plausible rather than counted — deriving real ones needs the 2.x baseline, which the
since field would give us. And the version numbers in the
reassurance copy assume LDX ships as 3.0.0 from a 2.x line; the
repo is on 3.0.0-beta.5 and the customers' current version is not
something I checked.
Three, cheapest last
The registry is only worth building if rows land in the same PR as the feature. If they get back-filled from the commit log — the way §5 was built — we have added ceremony to a stale changelog. This is a process decision and it should be made before the code, not after.
Surface 2 in §3 is the one with real revenue attached — it is the reason the opt-in on this page has a point. If the announcement email stays hand-written, the ask at the bottom of this screen is collecting addresses for a list nobody sends to. Worth confirming the outbox can render from the registry.
It is my favourite thing on the page and it is also a promise. It holds only while every change is genuinely opt-in — the moment one release changes a default, that cell has to become a number and a link, and the page's whole posture shifts. Cheap to remove now, expensive to have been wrong about later.