diff --git a/.gitignore b/.gitignore index cd3bd95..d36cf07 100644 --- a/.gitignore +++ b/.gitignore @@ -32,5 +32,13 @@ vendor/ /vendor/ +# Screenshot captures (manifest and docs are committed) +/screenshots/** +!/screenshots/.gitkeep +!/screenshots/README.md +!/screenshots/image-manifest.json +!/screenshots/image-manifest.schema.json + + # Cursor IDE config (local nested repo; not shared on origin) .cursor/ diff --git a/docs/README.md b/docs/README.md index 034e1c7..c8f41b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,91 +1,78 @@ # Freemius for WordPress -The Freemius for WordPress is a toolkit to help people sell products on their WordPress sites. +If you are using WordPress with the Block Editor, the Freemius for WordPress plugin enables you to integrate the Freemius checkout directly and quickly while building landing/sales pages for your Freemius product. No JavaScript, theme hacking, or coding required! -## General Idea +![Block editor with Freemius checkout preview open](assets/docs-homepage-preview.png) -This plugin helps you quickly set up a sales page for your WordPress product that you sell via Freemius. +**Not using the Block Editor?** -## How it works +See how to use the [overlay checkout](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/) or the [hosted checkout](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-hosted-page/). -The plugin uses the Freemius API to fetch the product data and display it in the block editor. +Out of the box, Freemius for WordPress gives you: -The plugin also allows you to add Freemius Checkout to any button of your WordPress content using the block editor. +- **Native Block Editor support (Gutenberg):** Drop checkout buttons or pricing tables directly into your page layouts - just like adding any other block. -## Requirements + _For example: Add a "Buy Now" button to your landing page in seconds._ -- WordPress 6.7+ -- Freemius account -- Freemius product +- **Dynamic pricing tables:** Display different plans with clear comparisons and calls-to-action. -## Installation + _Example: Showcase "Starter / Pro / Agency" tiers side by side, with checkout built in._ -1. Install the plugin via the WordPress admin panel. -2. Activate the plugin. -3. Go to the Freemius settings page (Settings => Freemius) and enter your Freemius Token. -4. Go to the "Editor Settings" page and enter `product_id`, `public_key`. +- **Plan switching and trials:** Let customers upgrade, downgrade, or start with a free trial seamlessly. -## Checkout Button + _Example: Offer a 14-day free trial that auto-converts to a paid plan without extra coding._ -The easiest way to enable a checkout button is to create a new page (or edit an existing one) and add a new button block to the content. +- **Full compatibility with modern WordPress block themes and plugins:** Works out of the box with your existing WordPress block site setup - no theme hacks required. +- **100% free and open source:** Transparent, community-driven, and extensible. -https://github.com/user-attachments/assets/c07268f2-0dc1-439a-840c-7e52215016cb + _Example: Extend the plugin with your own block variations, or contribute improvements back to the repo._ +## Resources -Enable the checkout for this button in the inspector panel. +- [Download from WordPress.org.](https://wordpress.org/plugins/freemius/) +- [GitHub repo.](https://github.com/Freemius/freemius-wp-plugin) +- [Test now on Playground.](https://playground.wordpress.net/?plugin=freemius) +- [Features, roadmap and the changelog.](https://github.com/Freemius/freemius-wp-plugin#readme) -You can instantly preview the button by clicking the "Preview" button in the inspector panel. Change the properties for this specific button with the settings below. +## How do I set up a Freemius checkout button? -## Working with Scopes +1. Download, install, and activate the plugin on your WordPress site. +2. Add a button block to your page or post, then enable the Freemius checkout option in the button settings. +3. Configure your product details, and the button will automatically handle the checkout process. -The plugin allows you to create multiple scopes for your product. Each scope can have different pricing and checkout buttons. +Here is a quick video demonstrating how to set up a Freemius checkout button: -A scope can be enabled on these blocks (or their children): +[How to set up a Freemius checkout button](https://www.youtube.com/watch?v=MTOuIBGan7E) -- Group Block -- Columns Block -- Column Block -- Button Block +For step-by-step guides in this documentation, see [Getting started](getting-started.md). -enable_checkout_full +## Documentation +| Guide | What it covers | +| ----- | -------------- | +| [Getting started](getting-started.md) | Install the plugin, connect your Freemius product, add a checkout button, and build a pricing page | +| [Freemius Button](button.md) | Enable checkout on a button, scopes, preview, and optional settings | +| [Creating your Pricing page](creating-your-pricing-page.md) | Build a multi-plan pricing page with modifiers, scoped columns, mapped fields, and checkout buttons | +| [Scopes](scopes.md) | Nested pricing contexts on one page (groups, columns, buttons) | +| [Field mapping](mapping.md) | Pull plan price, title, description, and billing labels into blocks | +| [Scope modifiers](modifiers.md) | Currency, billing cycle, and license toggles on the page | +| [Settings](settings.md) | Admin tabs: Products, Editor Settings, and site-wide defaults | +## FAQs -Each scope inherits the properties of the parent scope. The first scope in the hierarchy will inherit the properties of the "Editor Settings" page. +### Can I customize the checkout experience? -### Mapping +Yes, you can customize various aspects of the checkout process through the plugin settings, including product details, pricing, and the checkout flow. -You can map certain fields from your plans (e.g., "title", "description", "price", etc.) to the content of the blocks. +### How do I set up a Freemius checkout button? -The following blocks can "receive" data from the scope: +Add a **Button** block, turn on **Freemius Checkout** in the Freemius panel, and set your product under **Settings → Freemius**. See [Getting started](getting-started.md) and [Freemius Button](button.md). -- Paragraph Block -- HeadingBlock -- Button Block - -Currently, 5 fields are supported: - -- Title -- Description -- Price -- Licenses (1, 2, 3, Unlimited) -- Billing Cycle (Monthly, Yearly, Lifetime) - -You can see a purple outline around all blocks with a scope. The dotted outline indicates a mapped field: - -pricing_with_scopes - -### Scope Modifiers - -Modifiers can be used to change the settings of the scope in which they are placed. They always change the scope of the next parent scope. - -The scope is enabled by adding the "Freemius Scope" block to the content. -modifiers - -You can click on the modifier directly in the editor to change the scope. - -https://github.com/user-attachments/assets/6a25e2cb-c169-4d2d-898c-d6a48f90e0c6 +### Is the plugin compatible with my theme? +The plugin targets the Block Editor (Gutenberg). It works with block themes and classic themes that support the block editor. +### Where is pricing data loaded from? +Mapped prices and plan copy are saved when you edit and publish a page; the frontend does not call the Freemius API on every visit. After you change prices on the Freemius dashboard, reopen the page in the editor and **Update** it. See [Creating your Pricing page](creating-your-pricing-page.md#important-pricing-data-is-not-live-on-the-frontend). diff --git a/docs/assets/button-key-settings.png b/docs/assets/button-key-settings.png new file mode 100644 index 0000000..0a733a5 Binary files /dev/null and b/docs/assets/button-key-settings.png differ diff --git a/docs/assets/button-overview.png b/docs/assets/button-overview.png new file mode 100644 index 0000000..46e6c5d Binary files /dev/null and b/docs/assets/button-overview.png differ diff --git a/docs/assets/button-track-callback.png b/docs/assets/button-track-callback.png new file mode 100644 index 0000000..68c924c Binary files /dev/null and b/docs/assets/button-track-callback.png differ diff --git a/docs/assets/callback-editor.png b/docs/assets/callback-editor.png deleted file mode 100644 index 1d07932..0000000 Binary files a/docs/assets/callback-editor.png and /dev/null differ diff --git a/docs/assets/checkout.png b/docs/assets/checkout.png deleted file mode 100644 index 02848ec..0000000 Binary files a/docs/assets/checkout.png and /dev/null differ diff --git a/docs/assets/docs-homepage-preview.png b/docs/assets/docs-homepage-preview.png new file mode 100644 index 0000000..e8e0d24 Binary files /dev/null and b/docs/assets/docs-homepage-preview.png differ diff --git a/docs/assets/key-settings.png b/docs/assets/key-settings.png deleted file mode 100644 index 290581d..0000000 Binary files a/docs/assets/key-settings.png and /dev/null differ diff --git a/docs/assets/popout-editor.png b/docs/assets/popout-editor.png deleted file mode 100644 index ee19d06..0000000 Binary files a/docs/assets/popout-editor.png and /dev/null differ diff --git a/docs/assets/preview.png b/docs/assets/preview.png deleted file mode 100644 index 3259293..0000000 Binary files a/docs/assets/preview.png and /dev/null differ diff --git a/docs/assets/pricing-page-checkout-button.png b/docs/assets/pricing-page-checkout-button.png new file mode 100644 index 0000000..9c4f474 Binary files /dev/null and b/docs/assets/pricing-page-checkout-button.png differ diff --git a/docs/assets/pricing-page-modifiers-row.png b/docs/assets/pricing-page-modifiers-row.png new file mode 100644 index 0000000..b50ed51 Binary files /dev/null and b/docs/assets/pricing-page-modifiers-row.png differ diff --git a/docs/assets/pricing-page-plan-column.png b/docs/assets/pricing-page-plan-column.png new file mode 100644 index 0000000..37b62eb Binary files /dev/null and b/docs/assets/pricing-page-plan-column.png differ diff --git a/docs/assets/pricing-page-playground.png b/docs/assets/pricing-page-playground.png new file mode 100644 index 0000000..e5a8ace Binary files /dev/null and b/docs/assets/pricing-page-playground.png differ diff --git a/docs/assets/pricing-page-preview.png b/docs/assets/pricing-page-preview.png new file mode 100644 index 0000000..585d1d2 Binary files /dev/null and b/docs/assets/pricing-page-preview.png differ diff --git a/docs/assets/scope-columns-overview.png b/docs/assets/scope-columns-overview.png new file mode 100644 index 0000000..3213621 Binary files /dev/null and b/docs/assets/scope-columns-overview.png differ diff --git a/docs/assets/scope-enable-checkout.png b/docs/assets/scope-enable-checkout.png new file mode 100644 index 0000000..98146c1 Binary files /dev/null and b/docs/assets/scope-enable-checkout.png differ diff --git a/docs/assets/scope-modifiers.png b/docs/assets/scope-modifiers.png new file mode 100644 index 0000000..597b7c1 Binary files /dev/null and b/docs/assets/scope-modifiers.png differ diff --git a/docs/assets/scope-pricing-mapped.png b/docs/assets/scope-pricing-mapped.png new file mode 100644 index 0000000..6155e54 Binary files /dev/null and b/docs/assets/scope-pricing-mapped.png differ diff --git a/docs/assets/scopes.png b/docs/assets/scopes.png deleted file mode 100644 index dc2fe9e..0000000 Binary files a/docs/assets/scopes.png and /dev/null differ diff --git a/docs/assets/settings-editor.png b/docs/assets/settings-editor.png new file mode 100644 index 0000000..529adc5 Binary files /dev/null and b/docs/assets/settings-editor.png differ diff --git a/docs/assets/settings-products.png b/docs/assets/settings-products.png new file mode 100644 index 0000000..4e911bc Binary files /dev/null and b/docs/assets/settings-products.png differ diff --git a/docs/button.md b/docs/button.md index 75afb98..79c81ba 100644 --- a/docs/button.md +++ b/docs/button.md @@ -1,95 +1,53 @@ # Freemius Button -The Freemius Button allows you to add Freemius Checkout to any button of your WordPress content using the block editor. +The Freemius Button extends the core Button block with Freemius Checkout. When enabled, clicking the button opens the Freemius Checkout popup. -## Overview - -The Freemius Button extends the core Button block with Freemius Checkout functionality. When enabled, clicking the button will open the Freemius Checkout popup to process payments. - -![Freemius Checkout](https://raw.githubusercontent.com/Freemius/freemius-wp-plugin/refs/heads/main/docs/assets/checkout.png) - -## Getting Started - -1. Install the [Freemius for WordPress](https://wordpress.org/plugins/freemius/) plugin from the WordPress.org repository. -2. Add a new button block to your content. -3. Enable the Freemius Button by checking the "Freemius Checkout" checkbox. -4. Configure the button settings as needed. - -## Configuration Scopes +Use it **standalone** on any Button block — enable Freemius Checkout and configure checkout on the button itself. Or place it **inside a scope** (a Freemius-enabled Group, Column, or similar block) so it inherits product, plan, and modifiers from that scope. -The button settings can be configured at three different scopes: - -1. **Global** - Settings that apply site-wide -2. **Page** - Settings that apply to the current page/post -3. **Button** - Settings specific to the individual button +## Overview -scopes +![Mapped Freemius checkout button in the pricing table with Freemius sidebar settings highlighted](assets/button-overview.png) -Settings cascade down from global to button level, with more specific scopes overriding broader ones. This means that if you set a setting at the global level, it will be applied to all buttons on your site. If you set a setting at the page level, it will be applied to all buttons on that page. If you set a setting at the button level, it will only be applied to that specific button. +The example above shows a mapped checkout button inside a pricing table scope. See [Scopes](scopes.md) and [Mapping](mapping.md) for building similar layouts. -This way, you can override the global settings for a specific page or button. +For installation, your first checkout button, and testing with Preview, see [Getting started](getting-started.md). -## Key Settings +## Key settings -- **Product ID** - Your Freemius product ID (required) -- **Public Key** - Your Freemius public key (required) -- **Plan ID** - Specific plan to offer -- **Pricing ID** - Specific pricing to offer -- **Billing Cycle** - Default billing cycle (monthly/annual) -- **Currency** - Transaction currency -- **Quantity** - Default quantity -- **Coupon** - Default coupon code -- **Custom Fields** - Additional checkout fields -- **Success URL** - Redirect URL after successful purchase -- **Cancel URL** - Redirect URL after cancellation +- **Product ID** — Freemius product ID (required) +- **Plan ID**, **Pricing ID**, **Billing Cycle**, **Currency**, **Quantity**, **Coupon** +- **Success URL**, **Cancel URL**, **Custom Fields** -Please refer to the [Freemius documentation](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/) for more information on these settings. +See [Freemius checkout documentation](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/) for details. -By default, settings that are not set are hidden. You can find them by clicking on the "three-dots-button" in the toolbar: +Hidden settings are listed in the Freemius **options menu** (three dots on the Freemius panel header). Open it to show or hide fields such as Product ID and Plan: -key-settings +![Freemius options menu with additional checkout field toggles](assets/button-key-settings.png) ## Customization -The button appearance can be customized using the standard WordPress button block settings for: - -- Colors -- Typography -- Dimensions -- Border -- Spacing +Use standard WordPress button block controls for colors, typography, dimensions, border, and spacing. -## Events & Callbacks +## Events and callbacks -You can add custom JavaScript code to handle various checkout events: +Handle checkout events with custom JavaScript: -- `purchaseCompleted` - Called after a successful purchase -- `success` - Called after a successful transaction -- `cancel` - Called when checkout is canceled -- `track` - Called for tracking events +- `purchaseCompleted`, `success`, `cancel`, `track` -To add custom JS, find the specific settings and enter the code you would like to execute when the event is triggered. +Use **Track Callback** for advanced tracking across checkout events (currency changes, billing cycle updates, license count, and more). Open **Popout Editor** for a larger code editor: -popout-editor +![Track Callback in the Freemius button sidebar with Popout Editor open](assets/button-track-callback.png) -You can also click on "**Popout Editor**" to get a bigger editor to enter your custom code. - -callback-editor - -Find examples on the [Freemius documentation](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/#tracking_purchases_with_google_analytics_and_facebook) +For more `track` events, see the [Freemius checkout track documentation](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/#track). ## Preview -preview - -Use the Preview button in the toolbar or sidebar to test your checkout configuration before publishing. +![Pricing page editor with checkout preview open and the Preview button outlined](assets/pricing-page-preview.png) -The Auto Refresh option will automatically update the preview when settings change. +Use **Preview** in the sidebar or toolbar to test checkout. **Auto Refresh** updates the preview when settings change. ## Tips -- Configure common settings at the global scope -- Override specific settings at page/button level as needed -- Test the checkout flow using Preview mode -- Use event callbacks to integrate with other functionality -- Refer to [Freemius documentation](https://freemius.com/help/documentation/selling-with-freemius/freemius-checkout-buy-button/) for detailed options +- Inside a scope, the button inherits parent settings; override on the button only when needed +- Test with Preview before publishing +- Use callbacks to integrate analytics or custom flows diff --git a/docs/creating-your-pricing-page.md b/docs/creating-your-pricing-page.md new file mode 100644 index 0000000..d7be907 --- /dev/null +++ b/docs/creating-your-pricing-page.md @@ -0,0 +1,151 @@ +# Creating your Pricing page + +Build a multi-plan pricing page that displays prices and plan details from your Freemius product, lets visitors switch currency and billing cycle, and opens checkout from each plan. + +## Important: pricing data is not live on the frontend + +Mapped prices, titles, and descriptions are **saved into your page content** when you edit and publish. The plugin does **not** call the Freemius API on every page view — that would slow down your site. + +**When you change anything on the Freemius site** (prices, plan names, descriptions, currencies, and so on): + +1. Open your pricing page in the **block editor**. +2. Wait for the editor to load the latest product data into your mapped blocks. If values still look old, **clear your site cache** (and any page-cache plugin) and reload the editor. +3. Check that mapped fields show the correct prices and copy. +4. **Update** the page so the new values are saved and visitors see them. + +Until you update the page, visitors will keep seeing the prices and text from the last publish. + +## What you'll build + +A typical pricing page has: + +- One **outer scope** (Group or Section) tied to your product +- **Modifiers** (optional) for currency, billing cycle, and license count +- One **column per plan**, each with its own scope and plan ID +- **Mapped fields** for price, title, description, and billing labels +- A **checkout button** in each column + +![Published Freemius pricing page with plan columns and billing toggles](assets/pricing-page-playground.png) + +## Before you start + +1. Install and activate Freemius for WordPress. +2. Connect your product under **Settings → Freemius → Products** (Product ID and Token from the [Freemius Developer Dashboard](https://dashboard.freemius.com/)). + +![Products tab on the Freemius settings page](assets/settings-products.png) + +3. Set site-wide defaults under **Editor Settings** — at minimum **Product ID** and a default **Plan**. + +See [Getting started](getting-started.md) and [Settings](settings.md) if you have not done this yet. + +## Step 1: Create a new page + +1. In the WordPress admin, go to **Pages → Add New**. +2. Give the page a title such as **Pricing**. +3. You can start from a blank page or insert a block pattern that includes columns — you will wire up Freemius scopes in the steps below. + +## Step 2: Add an outer scope + +The outer scope sets the **product** and default **currency** and **billing cycle** for everything inside it. + +1. Add a **Group** block (or **Columns** / **Section** wrapper) that will contain the whole pricing area. +2. Select that block. +3. In the block sidebar, open **Freemius** and enable **Freemius**. +4. Confirm **Product ID** is set (it inherits from Editor Settings if you configured it there). + +![Editor view with scoped Group block, arrow pointing to the highlighted Freemius panel](assets/scope-enable-checkout.png) + +This block is now the parent scope. Every child block inside it inherits these settings unless you override them. You can add additional **child scopes** to change properties of child blocks — see [Scopes](scopes.md) for details. + +## Step 3: Add a column scope and map plan fields + +Each Freemius plan needs its **own scope** on the page — usually a **Column** block (or a nested **Group** inside a column) with Freemius enabled and a **Plan ID** set. Without a separate scope per plan, every column would show the same prices, titles, and checkout settings. + +### Set up each column + +1. Add a **Columns** block inside the outer scope. +2. Add one **Column** per plan (Free, Starter, Professional, and so on). +3. Select each column, open **Freemius** in the sidebar, enable **Freemius**, and set **Plan ID** to that column's plan. +4. Style each column with borders, background, and spacing to match your theme. + +![Pricing page editor with one plan column selected and outlined](assets/pricing-page-plan-column.png) + +Repeat for every column. Blocks inside a column inherit that column's plan. See [Scopes](scopes.md) for how nested scopes work. + +### Map prices and copy inside each column + +Inside each column, add blocks and map Freemius fields so prices and copy are filled from your product **while you edit**. The editor fetches product data from Freemius and writes the current values into each mapped block; those values are stored in the page when you publish. + +**Recommended blocks and fields:** + +| Block | Typical mapping | +| ----- | ---------------- | +| Paragraph (large) | **Price** | +| Paragraph (small) | **Billing cycle** (with custom labels such as "Monthly" / "Annually") | +| Paragraph | **Title** | +| Paragraph | **Description** | +| Paragraph | **Licenses** (optional, e.g. "for 1 Site") | + +1. Add the block (Paragraph, Heading, or Button). +2. Select it and open **Freemius** in the sidebar. +3. Under **Field mapping**, choose the field (Price, Title, Description, etc.). +4. Optionally set a **prefix** or **suffix** on the mapping (for example `Get ` and ` Plan` on a button label). + +Mapped blocks show a dotted outline in the editor. See [Field mapping](mapping.md). + +## Step 4 (optional): Add pricing toggles + +This step is optional. Skip it if you only need one currency, billing cycle, and license count — set those on the outer scope in Step 2 instead. + +Let visitors change currency, billing cycle, and license count without leaving the page. + +1. Inside the outer scope, add a horizontal **Group** (flex layout works well). +2. From the block inserter, search for **Freemius** and insert a **Freemius Modifier** block for each toggle you need. Place each modifier inside the scoped area so it updates the parent scope: + - **Currency** — e.g. USD, EUR, GBP + - **Billing cycle** — Monthly, Annual, Lifetime + - **Licenses** — e.g. 1, 2, 10, Unlimited +3. In each modifier’s sidebar, choose its **type** and enabled options. Under **Styles**, pick **Button**, **Link**, or **Dropdown**. + +![Pricing page editor with the modifier toggle row outlined](assets/pricing-page-modifiers-row.png) + +Modifiers update the parent scope. On the published page, toggles switch between values that were available when you last saved the page — they do not fetch new data from Freemius on each visit. See [Scope modifiers](modifiers.md) for details. + +## Step 5: Add a checkout button per plan + +1. At the bottom of each column, add a **Button** block. +2. Enable **Freemius Checkout** on the button. +3. Optionally map the button label to **Title** with a prefix/suffix (e.g. `Get ` + plan name + ` Plan`). + +![Pricing page editor with a checkout button selected and Enable Freemius Checkout outlined](assets/pricing-page-checkout-button.png) + +The button inherits the column's plan and the outer scope's currency and billing cycle. See [Freemius Button](button.md). + +## Step 6: Add feature lists + +Plan features (bullet lists, checkmarks, separators) are ordinary blocks — add them manually in each column. They are not synced from Freemius; only pricing fields are mapped. + +## Step 7: Preview and publish + +1. Select any scoped block or checkout button. +2. In the Freemius sidebar, click **Preview** to open checkout with the current settings. +3. On the frontend, if you added pricing toggles, use them and confirm prices and labels look correct for the data you just saved. +4. When everything looks right, **Publish** (or **Update**) the page. + +![Pricing page editor with checkout preview open and the Preview button outlined](assets/pricing-page-preview.png) + +After you change pricing on the Freemius site, repeat these steps: open the page in the editor, wait for fresh data (clear cache if needed), then **Update** the page again. + +## Tips + +- Set **Product ID** and other common options once in **Editor Settings**; override only where a page or column differs. +- Use **Preview** on each plan's button before publishing. +- If a mapped price shows `$0` or is empty, check that the plan has pricing for the selected currency, billing cycle, and license count. +- After Freemius dashboard changes, always **re-open the pricing page in the editor** and **Update** it — the frontend will not pick up new prices on its own. +- For a single-plan landing page, you can skip columns and modifiers — see [Freemius Button](button.md). + +## Related guides + +- [Scopes](scopes.md) — how nested scopes inherit settings +- [Field mapping](mapping.md) — supported fields and blocks +- [Scope modifiers](modifiers.md) — currency, billing, and license toggles +- [Freemius Button](button.md) — checkout buttons and preview diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..b1f885d --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,27 @@ +# Getting started + +## Installation + +1. Install the plugin via the WordPress admin panel (Plugins → Add New, or upload the zip). +2. Activate the plugin. +3. Open **Settings → Freemius** and add your product under **Products** (Product ID and Token from the [Freemius Developer Dashboard](https://dashboard.freemius.com/)). +4. Under **Editor Settings**, set site-wide defaults such as Product ID and Plan. + +See [Settings](settings.md) for the full admin screen. + +## First checkout button + +1. Edit a page or post. +2. Add a **Button** block. +3. In the block sidebar, open **Freemius** and enable **Freemius Checkout**. +4. Use **Preview** in the sidebar to test checkout before publishing. + +Details: [Freemius Button](button.md). + +## Pricing page + +1. Create a new page and add a **Group** block (or **Section**) as an outer scope — enable **Freemius** and set your **Product ID**. +2. Add one **Column** per plan; enable **Freemius** on each column, set **Plan ID**, and map fields such as **Price**, **Title**, and **Description**. +3. Add a **Button** with **Freemius Checkout** in each column, then use **Preview** before publishing. + +Details: [Creating your Pricing page](creating-your-pricing-page.md). diff --git a/docs/mapping.md b/docs/mapping.md new file mode 100644 index 0000000..7fd16d8 --- /dev/null +++ b/docs/mapping.md @@ -0,0 +1,25 @@ +# Field mapping + +Map plan fields from your Freemius product to block content inside a scope. + +**Important:** Mapped values are saved into the page when you publish. They are not refreshed from Freemius on every frontend page load. After you change product data on the Freemius site, open the page in the editor, wait for updated values to appear (clear cache if needed), then **Update** the page. + +## Blocks that receive mapped data + +- Paragraph Block +- Heading Block +- Button Block + +## Supported fields + +- Title +- Description +- Price +- Licenses (1, 2, 3, Unlimited) +- Billing Cycle (Monthly, Yearly, Lifetime) + +Scoped blocks show a purple outline; mapped fields use a dotted outline: + +![Pricing page in the block editor with purple Freemius scope outlines](assets/scope-pricing-mapped.png) + +Configure mappings in the block sidebar under **Freemius** when a scoped block is selected. diff --git a/docs/modifiers.md b/docs/modifiers.md new file mode 100644 index 0000000..3916897 --- /dev/null +++ b/docs/modifiers.md @@ -0,0 +1,22 @@ +# Scope modifiers + +**Modifiers** change settings for the parent scope they sit inside. They always affect the next enclosing scope up the block tree. + +Find **Freemius Modifier** in the block inserter (search for “Freemius”). Insert it inside a **scoped block** — a block with Freemius enabled and a purple outline. The modifier lets visitors switch currency, billing cycle, license count, or plan on that parent scope. + +Each modifier offers three **style variants** in the block sidebar under **Styles**: + +- **Button** — toggle buttons (default) +- **Link** — text links +- **Dropdown** — a select menu + +![Pricing page editor with modifier toggles outlined and Freemius modifier settings in the sidebar](assets/scope-modifiers.png) + +1. Open the block inserter inside your scoped pricing area. +2. Search for **Freemius** and insert **Freemius Modifier**. +3. In the sidebar, choose the modifier **type** (Currency, Billing cycle, Licenses, and so on) and which options visitors can switch between. +4. Optionally pick a style variant (**Button**, **Link**, or **Dropdown**). + +Click the modifier in the editor to adjust plan, pricing, or billing options for that section of the page. + +See also: [Scopes overview](scopes.md). diff --git a/docs/scopes.md b/docs/scopes.md new file mode 100644 index 0000000..c808853 --- /dev/null +++ b/docs/scopes.md @@ -0,0 +1,26 @@ +# Scopes + +The plugin lets you create multiple **scopes** for your product on a single page. Each scope can have different pricing and checkout buttons. + +A scope can be enabled on these blocks (or their children): + +- Group Block +- Columns Block +- Column Block +- Button Block + +![Pricing page in the block editor with arrows on each plan column scope and the Freemius panel highlighted](assets/scope-columns-overview.png) + +In the editor, each plan column is its own scope (arrows above). A typical pricing page has one scope per plan column, plus an outer scope for the whole pricing area. + +**Scopes** show a **purple outline** — the Freemius-enabled block that owns checkout settings for that area (product, plan, currency, and so on). Child blocks inherit from their parent scope unless you override them. + +**Mapping fields** show a **dotted outline** — individual Paragraph, Heading, or Button blocks inside a scope that display plan data (price, title, description, and similar). Mapping binds block content to a Freemius field; it does not create a new scope. + +Each scope inherits properties from its parent scope. The outermost scope inherits defaults from **Editor Settings**. + +## Related topics + +- [Field mapping](mapping.md) — bind plan fields to block content +- [Scope modifiers](modifiers.md) — change settings for the parent scope +- [Freemius Button](button.md) — button-level checkout and scopes diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..abb468a --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,27 @@ +# Settings + +Global plugin settings live at **Settings → Freemius** in the WordPress admin. The page is organized into tabs: + +## General Settings + +Plugin-wide options that apply to Freemius as a whole — for example, environment and other global configuration. + +## Editor Settings + +Define site-wide defaults for checkout fields (such as Product ID and Plan). Every scope in the block editor inherits these values unless you override them on a page or block. + +![Editor Settings tab on the Freemius settings page](assets/settings-editor.png) + +Scoped blocks and buttons override these defaults. See [Scopes](scopes.md) and [Freemius Button](button.md). + +## Products + +Add the Freemius products you want to use across the site. For each product, enter a **Product ID** and **Token**. You can find both in the [Freemius Developer Dashboard](https://dashboard.freemius.com/) — open your product’s **Settings**, then the **API Token** tab, and copy the values from there. + +![Products tab on the Freemius settings page](assets/settings-products.png) + +Use the **Products** tab when you sell more than one product and need separate credentials for each. + +## Get Started + +A short video walkthrough for setting up Freemius on your site. diff --git a/package-lock.json b/package-lock.json index 748add7..a989cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,17 +19,20 @@ "classnames": "^2.5.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@wordpress/env": "^11.9.0", "@wordpress/prettier-config": "^4.49.0", "@wordpress/scripts": "^32.5.0", "eslint": "^10.6.0", "globals": "^15.14.0", + "looks-same": "^10.0.1", + "pngjs": "^7.0.0", "prettier": "npm:wp-prettier@^3.0.3", "sounds-webpack-plugin": "^0.0.2", "webpack-remove-empty-scripts": "^1.0.4" }, "engines": { - "node": "^22.0.0" + "node": "^24.0.0" } }, "node_modules/@10up/block-components": { @@ -4111,6 +4114,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsquash/png": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jsquash/png/-/png-3.1.1.tgz", + "integrity": "sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@keyv/serialize": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", @@ -6069,7 +6079,6 @@ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.61.1" }, @@ -11586,6 +11595,13 @@ "node": ">=7.0.0" } }, + "node_modules/color-diff": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-1.4.0.tgz", + "integrity": "sha512-4oDB/o78lNdppbaqrg0HjOp7pHmUc+dfCxWKWFnQg6AB/1dkjtBDop3RZht5386cq9xBUDRvDvSCA7WUlM9Jqw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", @@ -18167,6 +18183,33 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/looks-same": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/looks-same/-/looks-same-10.0.1.tgz", + "integrity": "sha512-MHTGwSbifNjQIKkjAr9aOCDxg+E06wtdLBdSmjJSnFxl7u5rkQqTfx+FYK+Fv6sK2MKJFlBaTYN6WOLdm5JSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsquash/png": "^3.1.1", + "buffer-crc32": "^1.0.0", + "color-diff": "^1.1.0", + "nested-error-stacks": "^2.1.0", + "parse-color": "^1.0.0" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/looks-same/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lookup-closest-locale": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", @@ -19034,6 +19077,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nested-error-stacks": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz", + "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -19866,6 +19916,22 @@ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "dev": true }, + "node_modules/parse-color": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "~0.5.0" + } + }, + "node_modules/parse-color/node_modules/color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", + "dev": true + }, "node_modules/parse-imports-exports": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", @@ -20256,7 +20322,6 @@ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.61.1" }, @@ -20276,7 +20341,6 @@ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -20295,7 +20359,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -20316,6 +20379,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 16cb086..4c9ccb9 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "lint:php:fix": "composer run lint:php:fix", "packages-update": "wp-scripts packages-update", "plugin-zip": "wp-scripts plugin-zip", + "sync:screenshots-docs": "node scripts/sync-screenshots-docs.mjs", + "sync:screenshot-captions": "node scripts/sync-screenshot-captions.mjs", + "update-screenshots": "node scripts/update-doc-screenshots.mjs", "start": "wp-scripts start --experimental-modules", "test:php": "composer run test:php" }, @@ -40,11 +43,14 @@ "classnames": "^2.5.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@wordpress/env": "^11.9.0", "@wordpress/prettier-config": "^4.49.0", "@wordpress/scripts": "^32.5.0", "eslint": "^10.6.0", "globals": "^15.14.0", + "looks-same": "^10.0.1", + "pngjs": "^7.0.0", "prettier": "npm:wp-prettier@^3.0.3", "sounds-webpack-plugin": "^0.0.2", "webpack-remove-empty-scripts": "^1.0.4" diff --git a/screenshots/.gitkeep b/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..f12c504 --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,55 @@ +# Screenshot captures + +Playwright and the `/screenshot` Cursor command store PNGs here. The tree is **gitignored** except this file, `.gitkeep`, [`image-manifest.json`](image-manifest.json), and [`image-manifest.schema.json`](image-manifest.schema.json) — the committed source of truth for every tracked image. + +## Manifest + +[`image-manifest.json`](image-manifest.json) lists all images: titles, descriptions, `use` type, optional Playwright `capture` URLs, and where each image belongs in docs. + +| `use` | Storage | What happens | +| ----- | ------- | -------------- | +| `docs` | `screenshots/{id}/` + copy under doc `assets/` | Script copies into the doc path in `embed.path` and updates markdown | +| `review` | `screenshots/{id}/` | QA captures; no doc copy | + +Register or edit entries in the manifest; do not maintain a separate targets file. + +**Manifest-first:** every doc PNG under `docs/**/assets/` and every `![alt](assets/...)` in markdown must have a matching `images[]` entry. Add or update the manifest in the same change set as the asset or markdown edit. + +## Layout + +``` +screenshots/{id}/source.png # pasted or Playwright capture source +``` + +- **`{id}`** — Stable kebab-case id from the manifest. +- **Viewports** — Names and pixel sizes are defined in `image-manifest.json` → `viewports`. Bulk doc refresh uses **desktop** only. + +## Fixture + +Editor captures use **post 428** on `dev.local`. See [`fixture-post-428.md`](fixture-post-428.md) before capturing. + +- Editor: `https://dev.local/wp-admin/post.php?post=428&action=edit` +- Frontend playground: `https://dev.local/playground/` + +Override with `SCREENSHOT_FIXTURE_POST_ID` and `SCREENSHOT_PLAYGROUND_URL`. + +## Commands + +**One-time setup:** + +```bash +npm install +npx playwright install chromium +``` + +```bash +npm run update-screenshots +npm run update-screenshots -- button-overview +npm run update-screenshots -- --force button-overview +``` + +Or `/update-screenshots` in Cursor. See [`.cursor/skills/update-screenshots/SKILL.md`](../.cursor/skills/update-screenshots/SKILL.md). + +Contributor inventory (manifest ids, capture status): [`INVENTORY.md`](INVENTORY.md) (generated by `npm run sync:screenshots-docs`). + +**Compare thresholds:** `SCREENSHOT_MIN_DIFF_RATIO` (default `0.02`), `SCREENSHOT_ABORT_RATIO` (default `0.98`), `SCREENSHOT_COMPARE_TOLERANCE` (default `2`). Pass `--force` to always write PNGs. diff --git a/screenshots/image-manifest.json b/screenshots/image-manifest.json new file mode 100644 index 0000000..05f82ba --- /dev/null +++ b/screenshots/image-manifest.json @@ -0,0 +1,417 @@ +{ + "$schema": "./image-manifest.schema.json", + "version": 1, + "viewports": { + "mobile": { + "width": 390, + "height": 844 + }, + "tablet": { + "width": 768, + "height": 1024 + }, + "desktop": { + "width": 1280, + "height": 800 + }, + "wide": { + "width": 1920, + "height": 1080 + } + }, + "images": [ + { + "id": "button-key-settings", + "title": "Freemius options menu", + "description": "Freemius ToolsPanel overflow menu on a button block showing optional checkout fields such as Product ID and Plan.", + "use": "docs", + "match": [ + "key settings", + "three dots", + "overflow menu", + "freemius options" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".freemius-button-scope-settings" + }, + "embed": { + "from": "screenshots/button-key-settings/source.png", + "doc": "docs/button.md", + "path": "docs/assets/button-key-settings.png", + "alt": "Freemius options menu with additional checkout field toggles", + "status": "captured" + } + }, + { + "id": "button-overview", + "title": "Freemius Checkout button overview", + "description": "Pricing table editor with the Professional plan checkout button selected and the Freemius sidebar panel highlighted.", + "use": "docs", + "match": [ + "button overview", + "mapped checkout button", + "pricing table button" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 24 + }, + "embed": { + "from": "screenshots/button-overview/source.png", + "doc": "docs/button.md", + "path": "docs/assets/button-overview.png", + "alt": "Mapped Freemius checkout button in the pricing table with Freemius sidebar settings highlighted", + "status": "captured" + } + }, + { + "id": "button-track-callback", + "title": "Track Callback popout editor", + "description": "Get Professional Plan button with Track Callback highlighted in the sidebar and Popout Editor open.", + "use": "docs", + "match": [ + "track callback", + "popout editor", + "events", + "callbacks" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 24 + }, + "embed": { + "from": "screenshots/button-track-callback/source.png", + "doc": "docs/button.md", + "path": "docs/assets/button-track-callback.png", + "alt": "Track Callback in the Freemius button sidebar with Popout Editor open", + "status": "captured" + } + }, + { + "id": "docs-homepage-preview", + "title": "Documentation homepage preview", + "description": "Editor view with the Freemius checkout preview overlay open, without annotations.", + "use": "docs", + "match": [ + "homepage preview", + "docs hero", + "checkout preview editor" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 24 + }, + "embed": { + "from": "screenshots/docs-homepage-preview/source.png", + "doc": "docs/README.md", + "path": "docs/assets/docs-homepage-preview.png", + "alt": "Block editor with Freemius checkout preview open", + "status": "captured" + } + }, + { + "id": "pricing-page-checkout-button", + "title": "Pricing checkout button", + "description": "Editor view with a plan checkout button selected in the pricing table and the Enable Freemius Checkout toggle outlined in the sidebar.", + "use": "docs", + "match": [ + "pricing checkout button", + "enable freemius checkout", + "plan button" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__editor", + "padding": 24 + }, + "embed": { + "from": "screenshots/pricing-page-checkout-button/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/pricing-page-checkout-button.png", + "alt": "Pricing page editor with a checkout button selected and Enable Freemius Checkout outlined", + "status": "captured" + } + }, + { + "id": "pricing-page-modifiers-row", + "title": "Pricing modifier toggles", + "description": "Editor canvas showing the currency, billing cycle, and license modifier button row on the pricing page, with the row outlined.", + "use": "docs", + "match": [ + "modifiers row", + "pricing toggles", + "currency billing licenses" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__editor", + "padding": 24 + }, + "embed": { + "from": "screenshots/pricing-page-modifiers-row/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/pricing-page-modifiers-row.png", + "alt": "Pricing page editor with the modifier toggle row outlined", + "status": "captured" + } + }, + { + "id": "pricing-page-plan-column", + "title": "Pricing plan column scope", + "description": "Editor canvas showing the pricing plan columns with one scoped column selected and outlined.", + "use": "docs", + "match": [ + "plan column", + "pricing column scope", + "column per plan" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__editor", + "padding": 24 + }, + "embed": { + "from": "screenshots/pricing-page-plan-column/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/pricing-page-plan-column.png", + "alt": "Pricing page editor with one plan column selected and outlined", + "status": "captured" + } + }, + { + "id": "pricing-page-playground", + "title": "Published pricing page", + "description": "Frontend pricing page with plan columns, modifiers, and checkout buttons.", + "use": "docs", + "match": [ + "pricing page", + "playground", + "frontend pricing" + ], + "capture": { + "url": "https://dev.local/playground/", + "viewports": [ + "desktop" + ], + "selector": ".wp-block-post-content, main", + "padding": 24 + }, + "embed": { + "from": "screenshots/pricing-page-playground/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/pricing-page-playground.png", + "alt": "Published Freemius pricing page with plan columns and billing toggles", + "status": "captured" + } + }, + { + "id": "pricing-page-preview", + "title": "Pricing checkout preview", + "description": "Editor view with the Freemius checkout preview overlay open and the Preview button outlined in the sidebar.", + "use": "docs", + "match": [ + "pricing preview", + "checkout preview popup", + "preview button" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 24 + }, + "embed": { + "from": "screenshots/pricing-page-preview/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/pricing-page-preview.png", + "alt": "Pricing page editor with checkout preview open and the Preview button outlined", + "status": "captured" + } + }, + { + "id": "scope-columns-overview", + "title": "Pricing plan column scopes", + "description": "Pricing table editor with arrows on each plan column scope, Freemius panel highlighted, and purple or dotted outlines visible.", + "use": "docs", + "match": [ + "scope columns", + "plan scopes", + "purple outline", + "dotted outline" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 0 + }, + "embed": { + "from": "screenshots/scope-columns-overview/source.png", + "doc": "docs/scopes.md", + "path": "docs/assets/scope-columns-overview.png", + "alt": "Pricing page in the block editor with arrows on each plan column scope and the Freemius panel highlighted", + "status": "captured" + } + }, + { + "id": "scope-enable-checkout", + "title": "Enable Freemius on scope", + "description": "Block editor with a scoped Group block selected, purple scope outline on the canvas, and the Freemius sidebar panel highlighted.", + "use": "docs", + "match": [ + "enable freemius", + "scope", + "group block" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 0 + }, + "embed": { + "from": "screenshots/scope-enable-checkout/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/scope-enable-checkout.png", + "alt": "Editor view with scoped Group block, arrow pointing to the highlighted Freemius panel", + "status": "captured" + } + }, + { + "id": "scope-modifiers", + "title": "Scope modifiers", + "description": "Pricing page editor with the currency, billing cycle, and license modifier toggle row outlined and the Freemius modifier sidebar open.", + "use": "docs", + "match": [ + "modifiers", + "scope modifier", + "freemius scope block" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__body", + "padding": 24 + }, + "embed": { + "from": "screenshots/scope-modifiers/source.png", + "doc": "docs/modifiers.md", + "path": "docs/assets/scope-modifiers.png", + "alt": "Pricing page editor with modifier toggles outlined and Freemius modifier settings in the sidebar", + "status": "captured" + } + }, + { + "id": "scope-pricing-mapped", + "title": "Mapped pricing with scopes", + "description": "Block editor canvas of the pricing table layout with purple Freemius scope outlines (same content as the playground page).", + "use": "docs", + "match": [ + "pricing", + "mapping", + "scope outline", + "pricing page editor" + ], + "capture": { + "url": "https://dev.local/wp-admin/post.php?post=FIXTURE_POST_ID&action=edit", + "viewports": [ + "wide" + ], + "selector": ".interface-interface-skeleton__editor", + "padding": 24 + }, + "embed": { + "from": "screenshots/scope-pricing-mapped/source.png", + "doc": "docs/mapping.md", + "path": "docs/assets/scope-pricing-mapped.png", + "alt": "Pricing page in the block editor with purple Freemius scope outlines", + "status": "captured" + } + }, + { + "id": "settings-editor", + "title": "Editor Settings tab", + "description": "Freemius admin settings page with the Editor Settings tab selected, showing site-wide default checkout fields.", + "use": "docs", + "match": [ + "editor settings", + "defaults", + "global settings" + ], + "capture": { + "url": "https://dev.local/wp-admin/options-general.php?page=freemius-settings#defaults", + "viewports": [ + "desktop" + ], + "selector": "#freemius-settings-app", + "padding": 0 + }, + "embed": { + "from": "screenshots/settings-editor/source.png", + "doc": "docs/settings.md", + "path": "docs/assets/settings-editor.png", + "alt": "Editor Settings tab on the Freemius settings page", + "status": "captured" + } + }, + { + "id": "settings-products", + "title": "Products tab", + "description": "Freemius admin settings page with the Products tab selected, showing Product ID and Token fields.", + "use": "docs", + "match": [ + "products tab", + "connect product", + "product id token" + ], + "capture": { + "url": "https://dev.local/wp-admin/options-general.php?page=freemius-settings#products", + "viewports": [ + "desktop" + ], + "selector": "#freemius-settings-app", + "padding": 0 + }, + "embed": { + "from": "screenshots/settings-products/source.png", + "doc": "docs/creating-your-pricing-page.md", + "path": "docs/assets/settings-products.png", + "alt": "Products tab on the Freemius settings page", + "status": "captured" + } + } + ] +} diff --git a/screenshots/image-manifest.schema.json b/screenshots/image-manifest.schema.json new file mode 100644 index 0000000..417ae76 --- /dev/null +++ b/screenshots/image-manifest.schema.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://freemius.com/schemas/image-manifest.schema.json", + "title": "Freemius image manifest", + "description": "Single source of truth for tracked screenshots under screenshots/{id}/.", + "type": "object", + "additionalProperties": false, + "required": [ "version", "viewports", "images" ], + "properties": { + "$schema": { + "type": "string", + "minLength": 1, + "description": "JSON Schema reference for editor validation (e.g. ./image-manifest.schema.json)." + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Manifest format version." + }, + "viewports": { + "type": "object", + "additionalProperties": false, + "required": [ "mobile", "tablet", "desktop", "wide" ], + "properties": { + "mobile": { "$ref": "#/$defs/viewport" }, + "tablet": { "$ref": "#/$defs/viewport" }, + "desktop": { "$ref": "#/$defs/viewport" }, + "wide": { "$ref": "#/$defs/viewport" } + } + }, + "images": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/imageEntry" } + } + }, + "$defs": { + "viewport": { + "type": "object", + "additionalProperties": false, + "required": [ "width", "height" ], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + } + }, + "viewportName": { + "type": "string", + "enum": [ "mobile", "tablet", "desktop", "wide" ] + }, + "imageId": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case, unique, stable; directory screenshots/{id}/." + }, + "useType": { + "type": "string", + "enum": [ "docs", "website", "review" ] + }, + "embedStatus": { + "type": "string", + "enum": [ "placeholder", "captured" ] + }, + "captureBlock": { + "type": "object", + "additionalProperties": false, + "required": [ "url", "viewports" ], + "properties": { + "url": { + "type": "string", + "minLength": 1, + "description": "Playwright navigation URL." + }, + "viewports": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/viewportName" } + }, + "selector": { + "type": "string", + "minLength": 1, + "description": "Optional CSS selector; screenshot only this element." + }, + "padding": { + "type": "integer", + "minimum": 0, + "description": "Optional extra pixels around capture.selector bounds." + }, + "minDiffRatio": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1, + "description": "Optional compare threshold for this capture (0..1)." + } + } + }, + "embedBlock": { + "type": "object", + "additionalProperties": false, + "required": [ "from", "doc", "path", "alt", "status" ], + "properties": { + "from": { + "type": "string", + "pattern": "^screenshots/[a-z0-9]+(-[a-z0-9]+)*/[^/]+\\.png$", + "description": "Repo-root-relative source under screenshots/{id}/." + }, + "doc": { + "type": "string", + "pattern": "^docs/.+\\.md$", + "description": "Repo-root-relative markdown file to embed in." + }, + "path": { + "type": "string", + "pattern": "^docs/.+\\.(png|jpg|jpeg|webp|gif)$", + "description": "Repo-root-relative committed asset path." + }, + "alt": { + "type": "string", + "minLength": 1, + "description": "Accessible alt text for the embedded image." + }, + "status": { "$ref": "#/$defs/embedStatus" } + } + }, + "imageEntry": { + "type": "object", + "additionalProperties": false, + "required": [ "id", "title", "description", "use" ], + "properties": { + "id": { "$ref": "#/$defs/imageId" }, + "title": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "use": { "$ref": "#/$defs/useType" }, + "match": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + }, + "capture": { "$ref": "#/$defs/captureBlock" }, + "embed": { "$ref": "#/$defs/embedBlock" } + }, + "allOf": [ + { + "if": { + "properties": { "use": { "const": "docs" } }, + "required": [ "use" ] + }, + "then": { "required": [ "embed" ] } + }, + { + "if": { + "properties": { "use": { "const": "review" } }, + "required": [ "use" ] + }, + "then": { "not": { "required": [ "embed" ] } } + } + ] + } + } +} diff --git a/scripts/compare-screenshot.mjs b/scripts/compare-screenshot.mjs new file mode 100644 index 0000000..3964e81 --- /dev/null +++ b/scripts/compare-screenshot.mjs @@ -0,0 +1,204 @@ +/** + * Compare doc screenshot PNGs before committing updates. + * + * Env / CLI: + * --force (update-doc-screenshots) — always write captures + * SCREENSHOT_COMPARE=0 — same as --force + * SCREENSHOT_MIN_DIFF_RATIO — default skip threshold (default 0.02 = 2%) + * capture.minDiffRatio (manifest) — per-image override (0..1); falls back to default + * SCREENSHOT_ABORT_RATIO — abort when diff area exceeds this (default 0.98 = 98%) + * SCREENSHOT_COMPARE_TOLERANCE — per-channel color tolerance for looks-same (default 2) + * + * Dimension mismatch always updates (size changes skip pixel compare). + */ +import { createRequire } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { PNG } from 'pngjs'; + +const require = createRequire( import.meta.url ); +const looksSame = require( 'looks-same' ); + +/** + * @typedef {'skip' | 'update' | 'abort'} CompareAction + */ + +/** + * @typedef {Object} CompareResult + * @property {CompareAction} action + * @property {number} diffRatio Proportion of image area in diff clusters (0..1). + * @property {string} reason + */ + +/** + * @param {{ left: number, top: number, right: number, bottom: number }} bounds + */ +function boundsArea( bounds ) { + const width = Math.max( 0, bounds.right - bounds.left ); + const height = Math.max( 0, bounds.bottom - bounds.top ); + return width * height; +} + +/** + * @param {Array<{ left: number, top: number, right: number, bottom: number }>} clusters + */ +function clustersArea( clusters ) { + if ( ! Array.isArray( clusters ) || clusters.length === 0 ) { + return 0; + } + + return clusters.reduce( + ( sum, cluster ) => sum + boundsArea( cluster ), + 0 + ); +} + +/** + * @param {string | undefined} value + * @param {number} fallback + */ +function parseRatio( value, fallback ) { + const parsed = Number.parseFloat( value ?? '' ); + return Number.isFinite( parsed ) ? parsed : fallback; +} + +/** + * @returns {{ minDiffRatio: number, abortRatio: number, tolerance: number, disabled: boolean }} + */ +export function getScreenshotCompareOptions() { + return { + minDiffRatio: parseRatio( process.env.SCREENSHOT_MIN_DIFF_RATIO, 0.02 ), + abortRatio: parseRatio( process.env.SCREENSHOT_ABORT_RATIO, 0.98 ), + tolerance: Number.parseInt( + process.env.SCREENSHOT_COMPARE_TOLERANCE ?? '2', + 10 + ), + disabled: process.env.SCREENSHOT_COMPARE === '0', + }; +} + +/** + * @param {ReturnType} baseOptions + * @param {{ minDiffRatio?: number } | undefined} capture + * @returns {ReturnType} + */ +export function resolveCompareOptionsForCapture( baseOptions, capture ) { + const entryMinDiffRatio = capture?.minDiffRatio; + + if ( + typeof entryMinDiffRatio !== 'number' || + ! Number.isFinite( entryMinDiffRatio ) + ) { + return baseOptions; + } + + return { + ...baseOptions, + minDiffRatio: entryMinDiffRatio, + }; +} + +/** + * @param {string} baselineAbs Absolute path to committed baseline PNG. + * @param {string} candidateAbs Absolute path to fresh capture PNG. + * @param {ReturnType} [options] + * @returns {Promise} + */ +export async function compareScreenshotFiles( + baselineAbs, + candidateAbs, + options = getScreenshotCompareOptions() +) { + if ( options.disabled ) { + return { + action: 'update', + diffRatio: 1, + reason: 'compare disabled', + }; + } + + if ( ! existsSync( baselineAbs ) ) { + return { + action: 'update', + diffRatio: 1, + reason: 'no baseline file', + }; + } + + /** @type {import('pngjs').PNG} */ + let baseline; + /** @type {import('pngjs').PNG} */ + let candidate; + + try { + baseline = PNG.sync.read( readFileSync( baselineAbs ) ); + candidate = PNG.sync.read( readFileSync( candidateAbs ) ); + } catch ( error ) { + return { + action: 'abort', + diffRatio: 1, + reason: `invalid PNG: ${ String( error ) }`, + }; + } + + if ( + baseline.width !== candidate.width || + baseline.height !== candidate.height + ) { + return { + action: 'update', + diffRatio: 1, + reason: `dimension mismatch (${ baseline.width }×${ baseline.height } vs ${ candidate.width }×${ candidate.height })`, + }; + } + + const totalPixels = baseline.width * baseline.height; + const looksSameOptions = { + strict: false, + tolerance: options.tolerance, + antialiasing: true, + pixelRatio: 0, + }; + + const { equal, diffClusters } = await looksSame( + baselineAbs, + candidateAbs, + looksSameOptions + ); + + if ( equal ) { + return { + action: 'skip', + diffRatio: 0, + reason: 'images match', + }; + } + + const diffArea = clustersArea( diffClusters ); + const diffRatio = totalPixels > 0 ? diffArea / totalPixels : 0; + + if ( diffRatio < options.minDiffRatio ) { + return { + action: 'skip', + diffRatio, + reason: `diff ${ ( diffRatio * 100 ).toFixed( 2 ) }% below ${ ( + options.minDiffRatio * 100 + ).toFixed( 2 ) }% threshold`, + }; + } + + if ( diffRatio > options.abortRatio ) { + return { + action: 'abort', + diffRatio, + reason: `diff ${ ( diffRatio * 100 ).toFixed( 2 ) }% exceeds ${ ( + options.abortRatio * 100 + ).toFixed( 2 ) }% abort threshold`, + }; + } + + return { + action: 'update', + diffRatio, + reason: `diff ${ ( diffRatio * 100 ).toFixed( 2 ) }%`, + }; +} diff --git a/scripts/screenshot-caption.mjs b/scripts/screenshot-caption.mjs new file mode 100644 index 0000000..9dc1f7e --- /dev/null +++ b/scripts/screenshot-caption.mjs @@ -0,0 +1,81 @@ +/** + * Shared helpers for manifest-driven screenshot captions in doc markdown. + */ + +/** + * @param {string} description Manifest `description` (falls back to alt when empty). + * @param {'placeholder' | 'captured' | string} status Manifest `embed.status`. + * @returns {string} Italic caption line (without surrounding newlines). + */ +export function buildCaptionLine( description, status ) { + const text = ( description ?? '' ).trim().replace( /\.$/, '' ); + if ( status === 'placeholder' ) { + return `*Placeholder: ${ text } (screenshot capture pending).*`; + } + return `*${ text }.*`; +} + +/** + * @param {string} alt + */ +export function escapeRegExp( alt ) { + return alt.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); +} + +/** + * Match `![alt](path)` and an optional following italic caption line. + * + * @param {string} alt + */ +export function imageWithCaptionPattern( alt ) { + return new RegExp( + `!\\[${ escapeRegExp( alt ) }\\]\\([^)]+\\)(?:\\n\\n\\*.+\\*)?`, + 'g' + ); +} + +/** + * @param {string} markdown + * @param {string} alt + * @returns {boolean} + */ +export function hasImageReference( markdown, alt ) { + const pattern = new RegExp( + `!\\[${ escapeRegExp( alt ) }\\]\\([^)]+\\)` + ); + return pattern.test( markdown ); +} + +/** + * Upsert caption after a manifest image reference in markdown. + * + * @param {string} markdown + * @param {string} alt + * @param {string} relativeAsset Path relative to the doc file. + * @param {string} description + * @param {'placeholder' | 'captured' | string} status + * @returns {{ markdown: string, updated: boolean, found: boolean }} + */ +export function upsertImageCaption( + markdown, + alt, + relativeAsset, + description, + status +) { + if ( ! hasImageReference( markdown, alt ) ) { + return { markdown, updated: false, found: false }; + } + + const pattern = imageWithCaptionPattern( alt ); + const captionLine = buildCaptionLine( description, status ); + const imageLine = `![${ alt }](${ relativeAsset })`; + const replacement = `${ imageLine }\n\n${ captionLine }`; + const nextMarkdown = markdown.replace( pattern, replacement ); + + return { + markdown: nextMarkdown, + updated: nextMarkdown !== markdown, + found: true, + }; +} diff --git a/scripts/sync-screenshot-captions.mjs b/scripts/sync-screenshot-captions.mjs new file mode 100644 index 0000000..e24c95f --- /dev/null +++ b/scripts/sync-screenshot-captions.mjs @@ -0,0 +1,71 @@ +/** + * Strip contributor-only caption lines from user-facing docs. + * + * User docs under docs/ must not contain manifest placeholders + * or generated italic captions — only plugin guidance and images. + * + * Usage: + * node scripts/sync-screenshot-captions.mjs + * npm run sync:screenshot-captions + */ +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = resolve( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const docsDir = resolve( rootDir, 'docs' ); + +/** @param {string} dir */ +function collectMarkdownFiles( dir ) { + /** @type {string[]} */ + const files = []; + + for ( const name of readdirSync( dir ) ) { + const abs = join( dir, name ); + if ( statSync( abs ).isDirectory() ) { + files.push( ...collectMarkdownFiles( abs ) ); + continue; + } + if ( name.endsWith( '.md' ) ) { + files.push( abs ); + } + } + + return files; +} + +/** + * Remove italic lines immediately following an image (placeholder or captured captions). + * + * @param {string} markdown + */ +function stripContributorCaptions( markdown ) { + return markdown.replace( + /(!\[[^\]]*\]\([^)]+\))\n\n\*(?:Placeholder: )?[^*\n]+\*\n/g, + '$1\n\n' + ); +} + +function main() { + const files = collectMarkdownFiles( docsDir ); + let updatedCount = 0; + + for ( const abs of files ) { + const markdown = readFileSync( abs, 'utf8' ); + const next = stripContributorCaptions( markdown ); + + if ( next === markdown ) { + continue; + } + + writeFileSync( abs, next, 'utf8' ); + updatedCount += 1; + console.log( ` ok ${ abs.replace( `${ rootDir }/`, '' ) }` ); + } + + console.log( + `Caption cleanup: ${ updatedCount } updated (${ files.length } doc files scanned).` + ); +} + +main(); diff --git a/scripts/sync-screenshots-docs.mjs b/scripts/sync-screenshots-docs.mjs new file mode 100644 index 0000000..e4cd2a1 --- /dev/null +++ b/scripts/sync-screenshots-docs.mjs @@ -0,0 +1,125 @@ +/** + * Generate documentation screenshot inventory from screenshots/image-manifest.json. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = resolve( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const manifestPath = resolve( rootDir, 'screenshots/image-manifest.json' ); +const outPath = resolve( rootDir, 'screenshots/INVENTORY.md' ); + +const manifest = JSON.parse( readFileSync( manifestPath, 'utf8' ) ); + +/** @type {Array<{ id: string, title: string, description?: string, embed: { doc?: string, path: string, alt: string, status: string } }>} */ +const docImages = manifest.images + .filter( ( entry ) => entry.use === 'docs' && entry.embed?.doc ) + .sort( ( a, b ) => a.id.localeCompare( b.id ) ); + +/** + * @param {string} docPath Repo-root-relative markdown path. + */ +function docPageLink( docPath ) { + const docRelative = relative( dirname( outPath ), resolve( rootDir, docPath ) ).replace( + /\\/g, + '/' + ); + const label = basename( docPath, '.md' ); + return `[${ label }](${ docRelative })`; +} + +/** + * @param {string} assetPath Repo-root-relative asset path. + */ +function embedRelativePath( assetPath ) { + return relative( dirname( outPath ), resolve( rootDir, assetPath ) ).replace( + /\\/g, + '/' + ); +} + +/** + * @param {string} docPath + */ +function docSectionTitle( docPath ) { + const name = basename( docPath, '.md' ); + if ( name === 'README' ) { + const parent = basename( dirname( docPath ) ); + return parent === 'docs' ? 'Documentation home' : parent; + } + return name + .split( '-' ) + .map( ( part ) => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) ) + .join( ' ' ); +} + +/** @type {Map} */ +const byDoc = new Map(); +for ( const entry of docImages ) { + const doc = entry.embed.doc; + + if ( ! doc ) { + continue; + } + + if ( ! byDoc.has( doc ) ) { + byDoc.set( doc, [] ); + } + byDoc.get( doc ).push( entry ); +} + +const sortedDocs = [ ...byDoc.keys() ].sort( ( a, b ) => a.localeCompare( b ) ); + +const sections = sortedDocs.map( ( docPath ) => { + const entries = byDoc.get( docPath ) ?? []; + const title = docSectionTitle( docPath ); + const link = docPageLink( docPath ); + + const rows = entries + .sort( ( a, b ) => a.id.localeCompare( b.id ) ) + .map( ( entry ) => { + const assetRel = embedRelativePath( entry.embed.path ); + const assetAbs = resolve( rootDir, entry.embed.path ); + const hasFile = existsSync( assetAbs ); + const status = + entry.embed.status === 'captured' && hasFile + ? 'captured' + : entry.embed.status; + + return [ + `### ${ entry.title }`, + '', + `![${ entry.embed.alt }](${ assetRel })`, + '', + `| Field | Value |`, + `| --- | --- |`, + `| Manifest ID | \`${ entry.id }\` |`, + `| Status | ${ status } |`, + `| Doc | ${ link } |`, + `| Alt text | ${ entry.embed.alt } |`, + entry.description + ? `| Description | ${ entry.description } |` + : '', + '', + ].join( '\n' ); + } ); + + return [ `## ${ title }`, '', `Used in ${ link }.`, '', ...rows ].join( + '\n' + ); +} ); + +const body = [ + '# Screenshot inventory (contributor)', + '', + '', + '', + 'Internal reference for doc screenshots. User-facing guides live under `docs/`.', + '', + ...sections, +].join( '\n' ); + +writeFileSync( outPath, body, 'utf8' ); +console.log( + `Synced screenshots inventory → ${ outPath } (${ docImages.length } entries)` +); diff --git a/scripts/update-doc-screenshots.mjs b/scripts/update-doc-screenshots.mjs new file mode 100644 index 0000000..f57f9b2 --- /dev/null +++ b/scripts/update-doc-screenshots.mjs @@ -0,0 +1,4097 @@ +/** + * Regenerate documentation screenshots from screenshots/image-manifest.json. + * + * Usage: + * node scripts/update-doc-screenshots.mjs # all docs entries with capture + * node scripts/update-doc-screenshots.mjs # single manifest id + * npm run update-screenshots -- button-overview + * npm run update-screenshots -- --force button-overview + * + * Prerequisites: + * - npx playwright install chromium + * - Local site at https://dev.local with auto-login; fixture post 428. + */ +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir, platform } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PNG } from 'pngjs'; + +import { + compareScreenshotFiles, + getScreenshotCompareOptions, + resolveCompareOptionsForCapture, +} from './compare-screenshot.mjs'; + +const require = createRequire( import.meta.url ); + +const rootDir = resolve( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const manifestPath = resolve( rootDir, 'screenshots/image-manifest.json' ); +const syncScreenshotsDocsScript = resolve( + rootDir, + 'scripts/sync-screenshots-docs.mjs' +); + +function syncScreenshotsDocsPage() { + execFileSync( 'node', [ syncScreenshotsDocsScript ], { + cwd: rootDir, + stdio: 'inherit', + } ); +} + +const CAPTURE_WAIT_MS = Number.parseInt( + process.env.CAPTURE_WAIT_MS ?? '5000', + 10 +); +const DOC_VIEWPORT = 'desktop'; +const FIXTURE_POST_ID = process.env.SCREENSHOT_FIXTURE_POST_ID ?? '428'; +const PLAYGROUND_URL = + process.env.SCREENSHOT_PLAYGROUND_URL ?? 'https://dev.local/playground/'; + +const ENTRY_KEY_ORDER = [ + 'id', + 'title', + 'description', + 'use', + 'match', + 'capture', + 'embed', + 'website', +]; + +/** + * @param {string} dir + */ +function hasPlaywrightBrowsers( dir ) { + if ( ! existsSync( dir ) ) { + return false; + } + + try { + return readdirSync( dir ).some( ( name ) => + name.startsWith( 'chromium' ) + ); + } catch { + return false; + } +} + +function defaultBrowsersPath() { + const home = homedir(); + if ( platform() === 'darwin' ) { + return join( home, 'Library/Caches/ms-playwright' ); + } + if ( platform() === 'win32' ) { + const localAppData = + process.env.LOCALAPPDATA ?? join( home, 'AppData', 'Local' ); + return join( localAppData, 'ms-playwright' ); + } + + return join( home, '.cache/ms-playwright' ); +} + +/** + * @param {string} dir + */ +function isEphemeralBrowsersPath( dir ) { + return /cursor-sandbox-cache|\/T\/[^/]+\/playwright/.test( dir ); +} + +function resolveBrowsersPath() { + const userDefault = defaultBrowsersPath(); + const candidates = [ userDefault ]; + + if ( + process.env.PLAYWRIGHT_BROWSERS_PATH && + ! candidates.includes( process.env.PLAYWRIGHT_BROWSERS_PATH ) && + ! isEphemeralBrowsersPath( process.env.PLAYWRIGHT_BROWSERS_PATH ) + ) { + candidates.push( process.env.PLAYWRIGHT_BROWSERS_PATH ); + } + + for ( const candidate of candidates ) { + if ( hasPlaywrightBrowsers( candidate ) ) { + return candidate; + } + } + + return userDefault; +} + +/** + * @returns {import('playwright').BrowserType | null} + */ +function loadChromium() { + process.env.PLAYWRIGHT_BROWSERS_PATH = resolveBrowsersPath(); + + try { + const playwrightPath = require.resolve( 'playwright', { + paths: [ rootDir ], + } ); + return require( playwrightPath ).chromium; + } catch { + return null; + } +} + +/** + * @param {unknown} error + */ +function isMissingBrowserError( error ) { + const message = String( error ); + return ( + message.includes( "Executable doesn't exist" ) || + message.includes( 'playwright install' ) + ); +} + +function printPlaywrightHelp() { + console.error( + 'Playwright browsers are not installed. From the plugin repo root, run:\n' + ); + console.error( ' npx playwright install chromium' ); +} + +/** + * @param {string} url + * @returns {string} + */ +function resolveCaptureUrl( url ) { + return url + .replace( /FIXTURE_POST_ID/g, FIXTURE_POST_ID ) + .replace( /PLAYGROUND_URL/g, PLAYGROUND_URL ); +} + +/** + * @param {import('playwright').Page} page + */ +async function blurFocusedElement( page ) { + await page.evaluate( () => { + if ( document.activeElement instanceof HTMLElement ) { + document.activeElement.blur(); + } + } ); +} + +/** + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function sidebarLocator( page ) { + return page + .locator( + '.interface-complementary-area, .edit-post-sidebar, .interface-interface-skeleton__sidebar' + ) + .first(); +} + +/** + * @param {import('playwright').Page} page + */ +async function ensureComplementaryAreaOpen( page ) { + const sidebar = sidebarLocator( page ); + + if ( + ( await sidebar.count() ) > 0 && + ( await sidebar.isVisible().catch( () => false ) ) + ) { + return; + } + + const toggles = [ + page.getByRole( 'button', { name: 'Settings', exact: true } ), + page.locator( 'button[aria-label="Settings"]' ), + page.locator( '.edit-post-header__settings-button' ), + ]; + + for ( const toggle of toggles ) { + if ( + ( await toggle.count() ) > 0 && + ( await toggle.first().isVisible().catch( () => false ) ) + ) { + await toggle.first().click(); + await page.waitForTimeout( 400 ); + + if ( await sidebar.isVisible().catch( () => false ) ) { + return; + } + } + } + + throw new Error( + 'Block editor sidebar is not open — pin Settings in the editor header' + ); +} + +/** + * @param {import('playwright').Page} page + */ +async function ensureBlockSidebarOpen( page ) { + await ensureComplementaryAreaOpen( page ); +} + +/** + * @param {import('playwright').Page} page + */ +async function ensureBlockInspectorTab( page ) { + await page.getByRole( 'tab', { name: 'Block', exact: true } ).click(); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function ensureBlockSettingsTab( page ) { + const settingsTab = page + .locator( '.block-editor-block-inspector__tabs' ) + .getByRole( 'button' ) + .first(); + + if ( ( await settingsTab.count() ) > 0 ) { + await settingsTab.click(); + await page.waitForTimeout( 200 ); + } +} + +/** + * @param {import('playwright').Page} page + */ +async function expandPanelByTitle( page, title ) { + const panel = page + .locator( '.components-panel__body' ) + .filter( { has: page.getByText( title, { exact: true } ) } ) + .first(); + + if ( ( await panel.count() ) === 0 ) { + return; + } + + const toggle = panel.locator( '.components-panel__body-toggle' ); + if ( ( await toggle.count() ) === 0 ) { + return; + } + + if ( ( await toggle.getAttribute( 'aria-expanded' ) ) !== 'true' ) { + await toggle.click(); + await page.waitForTimeout( 200 ); + } +} + +/** + * @param {import('playwright').Page} page + * @param {string} title + */ +async function collapsePanelByTitle( page, title ) { + const panel = page + .locator( '.components-panel__body' ) + .filter( { has: page.getByText( title, { exact: true } ) } ) + .first(); + + if ( ( await panel.count() ) === 0 ) { + return; + } + + const toggle = panel.locator( '.components-panel__body-toggle' ); + if ( ( await toggle.count() ) === 0 ) { + return; + } + + if ( ( await toggle.getAttribute( 'aria-expanded' ) ) === 'true' ) { + await toggle.click(); + await page.waitForTimeout( 200 ); + } +} + +/** + * @param {import('playwright').Page} page + */ +async function closeListViewIfOpen( page ) { + const closedViaStore = await page.evaluate( () => { + const store = window.wp?.data; + if ( ! store ) { + return false; + } + + const editPost = store.dispatch( 'core/edit-post' ); + const editor = store.dispatch( 'core/editor' ); + + if ( typeof editPost?.setIsListViewOpened === 'function' ) { + editPost.setIsListViewOpened( false ); + return true; + } + + if ( typeof editor?.setIsListViewOpened === 'function' ) { + editor.setIsListViewOpened( false ); + return true; + } + + return false; + } ); + + if ( closedViaStore ) { + await page.waitForTimeout( 400 ); + } + + const secondarySidebar = page.locator( + '.interface-interface-skeleton__secondary-sidebar' + ); + + if ( ! ( await secondarySidebar.isVisible().catch( () => false ) ) ) { + return; + } + + const closeButtons = [ + page.getByRole( 'button', { name: 'List View', exact: true } ), + page.locator( 'button[aria-label="Close List View"]' ), + page.locator( 'button[aria-label="Close list view"]' ), + ]; + + for ( const button of closeButtons ) { + if ( + ( await button.count() ) > 0 && + ( await button.first().isVisible().catch( () => false ) ) + ) { + await button.first().click(); + await page.waitForTimeout( 400 ); + + if ( + ! ( await secondarySidebar.isVisible().catch( () => false ) ) + ) { + return; + } + } + } + + if ( await secondarySidebar.isVisible().catch( () => false ) ) { + throw new Error( + 'List View sidebar is still open — close it before capturing editor screenshots' + ); + } +} + +/** + * @param {import('playwright').Page} page + */ +async function scrollSidebarToTop( page ) { + await page.evaluate( () => { + const area = document.querySelector( + '.interface-complementary-area, .edit-post-sidebar' + ); + if ( ! area ) { + return; + } + + const scrollables = area.querySelectorAll( '*' ); + for ( const element of scrollables ) { + if ( element.scrollHeight > element.clientHeight + 1 ) { + element.scrollTop = 0; + } + } + + area.scrollTop = 0; + } ); +} + +/** + * Nudge the editor canvas scroll position after scrollIntoView. + * + * @param {import('playwright').Page} page + * @param {number} deltaY + */ +async function scrollEditorCanvasBy( page, deltaY ) { + await page.evaluate( ( offset ) => { + const iframe = document.querySelector( + 'iframe[name="editor-canvas"]' + ); + const doc = iframe?.contentDocument; + const win = iframe?.contentWindow; + const scrollContainer = + doc?.querySelector( '.edit-post-visual-editor' ) ?? + doc?.documentElement; + + if ( win ) { + win.scrollBy( 0, offset ); + } + + if ( scrollContainer ) { + scrollContainer.scrollTop += offset; + } + }, deltaY ); +} + +/** + * Select the first button block that shows Freemius sidebar settings. + * + * @param {import('playwright').Page} page + */ +async function selectButtonWithFreemiusPanel( page ) { + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + + const iframeCount = await page + .locator( 'iframe[name="editor-canvas"]' ) + .count(); + const buttons = iframeCount + ? page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.wp-block-button' ) + : page.locator( '.wp-block-button' ); + + const count = await buttons.count(); + for ( let i = 0; i < count; i += 1 ) { + await buttons.nth( i ).click( { force: true } ); + await page.waitForTimeout( 600 ); + + if ( ( await page.locator( '.freemius-button-scope-settings' ).count() ) > 0 ) { + return; + } + } + + throw new Error( + 'Button with Freemius settings not found on fixture post 428 — enable Freemius on a button block' + ); +} + +/** + * @param {import('playwright').Page} page + */ +async function ensureCheckoutEnabled( page ) { + const toggle = page.getByLabel( 'Enable Freemius Checkout', { exact: true } ); + + if ( ( await toggle.count() ) === 0 ) { + return; + } + + if ( ! ( await toggle.isChecked() ) ) { + await toggle.check(); + await page.waitForTimeout( 400 ); + } +} + +/** + * @param {import('playwright').Page} page + */ +async function openFreemiusOptionsMenu( page ) { + const menuButton = page.locator( + '.freemius-button-scope-settings .components-tools-panel-header button[aria-label="Freemius options"]' + ); + + if ( ( await menuButton.count() ) === 0 ) { + throw new Error( + 'Freemius options menu not found — enable checkout on a global-scope button first' + ); + } + + if ( ( await menuButton.getAttribute( 'aria-expanded' ) ) === 'true' ) { + return; + } + + await menuButton.click(); + await page.waitForTimeout( 400 ); + + const popover = page.locator( '.components-popover' ).last(); + await popover.waitFor( { state: 'visible', timeout: 10_000 } ); +} + +/** + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function canvasButtonLocator( page ) { + return page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.wp-block-button .wp-block-button__link' ) + .first() + .or( page.locator( '.wp-block-button .wp-block-button__link' ).first() ); +} + +/** + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function canvasModifierLocator( page ) { + return page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '[data-type="freemius/modifier"]' ) + .first() + .or( page.locator( '[data-type="freemius/modifier"]' ).first() ); +} + +/** + * Locator for the flex group that contains the modifier toggle blocks only. + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function pricingModifiersRowGroupLocator( page ) { + const frame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + return frame + .locator( '.wp-block-group' ) + .filter( { has: frame.locator( '[data-type="freemius/modifier"]' ) } ) + .filter( { hasNot: frame.locator( '.wp-block-columns' ) } ) + .first(); +} + +/** + * Columns block that contains scoped plan columns on the pricing fixture. + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function pricingPlanColumnsLocator( page ) { + const frame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + return frame + .locator( '.wp-block-columns' ) + .filter( { + has: frame.locator( '.wp-block-column.has-freemius-scope' ), + } ) + .first(); +} + +/** + * Scoped plan columns inside the pricing table (one per Freemius plan). + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function pricingPlanColumnLocator( page ) { + return page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '.wp-block-column.has-freemius-scope' ); +} + +/** + * @param {import('playwright').Page} page + */ +async function deselectAllBlocks( page ) { + await page.evaluate( () => { + window.wp?.data?.dispatch( 'core/block-editor' )?.clearSelectedBlock(); + } ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function selectFirstButtonBlock( page ) { + await ensureBlockSidebarOpen( page ); + const button = canvasButtonLocator( page ); + + if ( ( await button.count() ) === 0 ) { + throw new Error( + 'Button block not found on fixture post — add a core/button block to post 428' + ); + } + + await button.click(); + await page.waitForTimeout( 500 ); +} + +/** + * Dismiss the WordPress editor autosave notice when it is visible. + * + * @param {import('playwright').Page} page + */ +async function dismissAutosaveNoticeIfPresent( page ) { + const notice = page + .locator( '.components-notice' ) + .filter( { hasText: /autosave of this post/i } ) + .first(); + + if ( + ( await notice.count() ) === 0 || + ! ( await notice.isVisible().catch( () => false ) ) + ) { + return; + } + + const dismissButtons = [ + notice.locator( 'button.components-notice__dismiss' ), + notice.getByRole( 'button', { name: 'Dismiss this notice' } ), + notice.locator( 'button[aria-label="Dismiss this notice"]' ), + ]; + + for ( const dismiss of dismissButtons ) { + if ( + ( await dismiss.count() ) > 0 && + ( await dismiss.first().isVisible().catch( () => false ) ) + ) { + await dismiss.first().click(); + await page.waitForTimeout( 300 ); + return; + } + } +} + +/** + * @param {import('playwright').Page} page + */ +async function waitForEditorReady( page ) { + await page.waitForSelector( + '.edit-post-layout, .block-editor-block-list__layout, .interface-interface-skeleton__editor', + { timeout: 30_000 } + ); + await page.waitForTimeout( 1000 ); + await dismissAutosaveNoticeIfPresent( page ); +} + +/** + * @param {import('playwright').Page} page + */ +async function waitForSettingsReady( page ) { + await page.waitForSelector( '#freemius-settings-app', { timeout: 30_000 } ); + await page.waitForTimeout( 1000 ); +} + +/** + * @param {{ id: string, capture: { url: string } }} entry + */ +function isSettingsCapture( entry ) { + return ( + entry.id.startsWith( 'settings-' ) || + entry.capture.url.includes( 'freemius-settings' ) + ); +} + +/** + * @param {{ id: string, capture: { selector?: string } }} entry + */ +function isEditorCanvasCapture( entry ) { + return entry.id === 'scope-pricing-mapped'; +} + +/** + * @param {{ id: string, capture: { url: string } }} entry + */ +function isFrontendCapture( entry ) { + return ( + entry.id.startsWith( 'pricing-page-' ) || + entry.capture.url.includes( '/playground' ) + ); +} + +/** + * @param {import('playwright').Page} page + */ +async function openFreemiusPanel( page ) { + await ensureBlockSidebarOpen( page ); + + const toolsPanel = page.locator( '.freemius-button-scope-settings' ).first(); + + if ( ( await toolsPanel.count() ) > 0 ) { + const headerToggle = toolsPanel + .locator( + '.components-tools-panel-header button, .components-panel__body-toggle' + ) + .first(); + + if ( ( await headerToggle.count() ) > 0 ) { + const expanded = await headerToggle.getAttribute( 'aria-expanded' ); + if ( expanded === 'false' ) { + await headerToggle.click(); + await page.waitForTimeout( 300 ); + } + } + + return; + } + + const freemiusHeading = page.getByRole( 'button', { + name: 'Freemius', + exact: true, + } ); + + if ( ( await freemiusHeading.count() ) > 0 ) { + const expanded = await freemiusHeading.getAttribute( 'aria-expanded' ); + if ( expanded !== 'true' ) { + await freemiusHeading.click(); + await page.waitForTimeout( 300 ); + } + + return; + } + + const freemiusPanelBody = page + .locator( '.components-panel__body' ) + .filter( { + has: page.getByText( 'Freemius', { exact: true } ), + } ) + .first(); + + if ( ( await freemiusPanelBody.count() ) > 0 ) { + const toggle = freemiusPanelBody.locator( + '.components-panel__body-toggle' + ); + + if ( ( await toggle.count() ) > 0 ) { + const expanded = await toggle.getAttribute( 'aria-expanded' ); + if ( expanded !== 'true' ) { + await toggle.click(); + await page.waitForTimeout( 300 ); + } + } + + return; + } + + throw new Error( + 'Freemius panel not found in block sidebar — select a block with Freemius settings' + ); +} + +/** + * @param {import('playwright').Page} page + */ +async function isScopeGroupSidebarVisible( page ) { + const enableScope = page.getByLabel( 'Enable Freemius', { + exact: true, + } ); + const resetMods = page.getByRole( 'button', { + name: 'Reset Modifications', + exact: true, + } ); + + return ( + ( await enableScope.count() ) > 0 || + ( await resetMods.count() ) > 0 + ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareButtonKeySettings( page ) { + await selectButtonWithFreemiusPanel( page ); + await ensureBlockInspectorTab( page ); + await openFreemiusPanel( page ); + await ensureCheckoutEnabled( page ); + await openFreemiusOptionsMenu( page ); +} + +/** @typedef {{ r: number, g: number, b: number }} PngColor */ + +/** @type {PngColor} */ +const ANNOTATION_RED = { r: 255, g: 59, b: 48 }; +const KEY_SETTINGS_CANVAS_INSET_LEFT = 88; +const KEY_SETTINGS_CANVAS_INSET = 16; +const KEY_SETTINGS_ARROW_MAX_LENGTH = 88; +const KEY_SETTINGS_ARROW_TIP_GAP = 6; +const CHECKOUT_PANEL_ANNOTATION_INSET = 6; + +/** + * @param {number} value + */ +function snapPixel( value ) { + return Math.round( value ); +} + +/** + * @param {PNG} png + * @param {number} x + * @param {number} y + * @param {PngColor} color + */ +function setPngPixel( png, x, y, color ) { + const px = snapPixel( x ); + const py = snapPixel( y ); + + if ( px < 0 || py < 0 || px >= png.width || py >= png.height ) { + return; + } + + const index = ( png.width * py + px ) << 2; + png.data[ index ] = color.r; + png.data[ index + 1 ] = color.g; + png.data[ index + 2 ] = color.b; + png.data[ index + 3 ] = 255; +} + +/** + * @param {PNG} png + * @param {number} x0 + * @param {number} y0 + * @param {number} x1 + * @param {number} y1 + * @param {PngColor} color + */ +function drawPngLine( png, x0, y0, x1, y1, color ) { + let x = snapPixel( x0 ); + let y = snapPixel( y0 ); + const endX = snapPixel( x1 ); + const endY = snapPixel( y1 ); + const dx = Math.abs( endX - x ); + const dy = Math.abs( endY - y ); + const sx = x < endX ? 1 : -1; + const sy = y < endY ? 1 : -1; + let err = dx - dy; + let guard = 0; + + while ( guard++ < 10_000 ) { + setPngPixel( png, x, y, color ); + + if ( x === endX && y === endY ) { + break; + } + + const err2 = err * 2; + if ( err2 > -dy ) { + err -= dy; + x += sx; + } + if ( err2 < dx ) { + err += dx; + y += sy; + } + } +} + +/** + * @param {PNG} png + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {number} x3 + * @param {number} y3 + * @param {PngColor} color + */ +function drawPngFilledTriangle( png, x1, y1, x2, y2, x3, y3, color ) { + const minY = Math.floor( Math.min( y1, y2, y3 ) ); + const maxY = Math.ceil( Math.max( y1, y2, y3 ) ); + + for ( let y = minY; y <= maxY; y += 1 ) { + /** @type {number[]} */ + const intersections = []; + + for ( const [ ax, ay, bx, by ] of [ + [ x1, y1, x2, y2 ], + [ x2, y2, x3, y3 ], + [ x3, y3, x1, y1 ], + ] ) { + if ( ( ay <= y && by > y ) || ( by <= y && ay > y ) ) { + intersections.push( ax + ( ( y - ay ) / ( by - ay ) ) * ( bx - ax ) ); + } + } + + if ( intersections.length >= 2 ) { + const start = Math.floor( Math.min( intersections[ 0 ], intersections[ 1 ] ) ); + const end = Math.ceil( Math.max( intersections[ 0 ], intersections[ 1 ] ) ); + for ( let x = start; x <= end; x += 1 ) { + setPngPixel( png, x, y, color ); + } + } + } +} + +/** + * Solid arrow: uniform shaft + filled triangular head (reference style). + * + * @param {PNG} png + * @param {number} fromX + * @param {number} fromY + * @param {number} toX + * @param {number} toY + * @param {PngColor} color + */ +function drawPngSolidArrow( png, fromX, fromY, toX, toY, color ) { + let tailX = snapPixel( fromX ); + let tailY = snapPixel( fromY ); + const tipX = snapPixel( toX ); + const tipY = snapPixel( toY ); + let dx = tipX - tailX; + let dy = tipY - tailY; + let length = Math.hypot( dx, dy ); + + if ( length < 10 ) { + return; + } + + if ( length > KEY_SETTINGS_ARROW_MAX_LENGTH ) { + const scale = KEY_SETTINGS_ARROW_MAX_LENGTH / length; + tailX = snapPixel( tipX - dx * scale ); + tailY = snapPixel( tipY - dy * scale ); + dx = tipX - tailX; + dy = tipY - tailY; + length = Math.hypot( dx, dy ); + } + + const unitX = dx / length; + const unitY = dy / length; + const perpX = -unitY; + const perpY = unitX; + const shaftHalf = 1; + const headLength = 12; + const headHalf = 5; + const baseX = snapPixel( tipX - unitX * headLength ); + const baseY = snapPixel( tipY - unitY * headLength ); + const leftX = snapPixel( baseX + perpX * headHalf ); + const leftY = snapPixel( baseY + perpY * headHalf ); + const rightX = snapPixel( baseX - perpX * headHalf ); + const rightY = snapPixel( baseY - perpY * headHalf ); + + drawPngFilledTriangle( png, tipX, tipY, leftX, leftY, rightX, rightY, color ); + + const shaftCorners = [ + [ tailX + perpX * shaftHalf, tailY + perpY * shaftHalf ], + [ tailX - perpX * shaftHalf, tailY - perpY * shaftHalf ], + [ baseX - perpX * shaftHalf, baseY - perpY * shaftHalf ], + [ baseX + perpX * shaftHalf, baseY + perpY * shaftHalf ], + ]; + + drawPngFilledTriangle( + png, + shaftCorners[ 0 ][ 0 ], + shaftCorners[ 0 ][ 1 ], + shaftCorners[ 1 ][ 0 ], + shaftCorners[ 1 ][ 1 ], + shaftCorners[ 2 ][ 0 ], + shaftCorners[ 2 ][ 1 ], + color + ); + drawPngFilledTriangle( + png, + shaftCorners[ 0 ][ 0 ], + shaftCorners[ 0 ][ 1 ], + shaftCorners[ 2 ][ 0 ], + shaftCorners[ 2 ][ 1 ], + shaftCorners[ 3 ][ 0 ], + shaftCorners[ 3 ][ 1 ], + color + ); +} + +/** + * @param {number} tailX + * @param {number} tailY + * @param {number} rectX + * @param {number} rectY + * @param {number} rectW + * @param {number} rectH + * @param {number} gap + */ +function arrowTipBeforeRect( tailX, tailY, rectX, rectY, rectW, rectH, gap ) { + const centerX = rectX + rectW / 2; + const centerY = rectY + rectH / 2; + const dx = centerX - tailX; + const dy = centerY - tailY; + const length = Math.hypot( dx, dy ); + + if ( length < 1 ) { + return { x: centerX, y: centerY }; + } + + const unitX = dx / length; + const unitY = dy / length; + const inflatedX = rectX - gap; + const inflatedY = rectY - gap; + const inflatedRight = rectX + rectW + gap; + const inflatedBottom = rectY + rectH + gap; + + for ( let step = 0; step <= length + gap * 2; step += 1 ) { + const px = centerX - unitX * step; + const py = centerY - unitY * step; + const outside = + px < inflatedX || + px > inflatedRight || + py < inflatedY || + py > inflatedBottom; + + if ( outside && step > 0 ) { + return { x: snapPixel( px ), y: snapPixel( py ) }; + } + } + + return { + x: snapPixel( centerX - unitX * gap ), + y: snapPixel( centerY - unitY * gap ), + }; +} + +/** + * @param {PNG} png + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @param {PngColor} color + */ +function drawPngRect( png, x, y, width, height, color ) { + const left = snapPixel( x ); + const top = snapPixel( y ); + const right = snapPixel( x + width ); + const bottom = snapPixel( y + height ); + + for ( let px = left; px <= right; px += 1 ) { + setPngPixel( png, px, top, color ); + setPngPixel( png, px, top + 1, color ); + setPngPixel( png, px, bottom, color ); + setPngPixel( png, px, bottom - 1, color ); + } + for ( let py = top; py <= bottom; py += 1 ) { + setPngPixel( png, left, py, color ); + setPngPixel( png, left + 1, py, color ); + setPngPixel( png, right, py, color ); + setPngPixel( png, right - 1, py, color ); + } +} + +/** + * Page clip with editor background, then annotate the options button. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + */ +async function captureButtonKeySettings( page, outputAbs ) { + const panel = page.locator( '.freemius-button-scope-settings' ).first(); + const menuButton = panel.locator( + 'button[aria-label="Freemius options"]' + ); + + await openFreemiusOptionsMenu( page ); + + const popover = page + .locator( '.components-popover' ) + .filter( { + has: page.getByRole( 'menuitemcheckbox', { name: 'Product ID' } ), + } ) + .last(); + const popoverContent = popover.locator( '.components-popover__content' ).first(); + + await popover.waitFor( { state: 'visible', timeout: 10_000 } ); + await popoverContent.waitFor( { state: 'visible', timeout: 10_000 } ); + + const panelBox = await panel.boundingBox(); + const menuBox = await menuButton.boundingBox(); + const popoverContentBox = await popoverContent.boundingBox(); + + if ( ! panelBox || ! menuBox || ! popoverContentBox ) { + throw new Error( + 'Freemius options menu is not visible for button-key-settings capture' + ); + } + + if ( popoverContentBox.y < panelBox.y - 200 ) { + throw new Error( + 'Freemius options menu clip is invalid at this viewport — use wide (1920px) for button-key-settings' + ); + } + + const mappingBox = await page + .getByLabel( 'Mapping', { exact: true } ) + .boundingBox() + .catch( () => null ); + const suffixBox = await panel + .getByText( /^Suffix$/i ) + .first() + .boundingBox() + .catch( () => null ); + + let contentBottom = panelBox.y + panelBox.height; + if ( mappingBox ) { + contentBottom = mappingBox.y + mappingBox.height + 56; + } + if ( suffixBox ) { + contentBottom = Math.max( contentBottom, suffixBox.y + suffixBox.height + 40 ); + } + contentBottom = Math.max( + contentBottom, + popoverContentBox.y + popoverContentBox.height + 8 + ); + + const contentTop = + Math.min( panelBox.y, popoverContentBox.y ) - KEY_SETTINGS_CANVAS_INSET; + const clip = { + x: popoverContentBox.x - KEY_SETTINGS_CANVAS_INSET_LEFT, + y: contentTop, + width: + panelBox.x + + panelBox.width + + KEY_SETTINGS_CANVAS_INSET - + ( popoverContentBox.x - KEY_SETTINGS_CANVAS_INSET_LEFT ), + height: contentBottom + KEY_SETTINGS_CANVAS_INSET - contentTop, + }; + + const capture = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( menuBox.x - clip.x - 4 ); + const highlightY = snapPixel( menuBox.y - clip.y - 4 ); + const highlightWidth = snapPixel( menuBox.width + 8 ); + const highlightHeight = snapPixel( menuBox.height + 8 ); + const tailX = snapPixel( highlightX - 34 ); + const tailY = snapPixel( highlightY + highlightHeight + 38 ); + const tip = arrowTipBeforeRect( + tailX, + tailY, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngRect( + capture, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + drawPngSolidArrow( capture, tailX, tailY, tip.x, tip.y, ANNOTATION_RED ); + + writeFileSync( outputAbs, PNG.sync.write( capture ) ); +} + +/** + * Editor body with the pricing scope group selected and Freemius panel annotated. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureScopeEnableCheckout( page, outputAbs, capture ) { + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + const pricingSection = pricingTableSectionLocator( page ); + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const enableHelp = page.getByText( 'Enable Freemius for this area.', { + exact: true, + } ); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await freemiusPanel.waitFor( { state: 'visible', timeout: 10_000 } ); + await enableHelp.waitFor( { state: 'visible', timeout: 10_000 } ); + await assertLayoutPanelCollapsed( page ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const panelBox = await freemiusPanel.boundingBox(); + const panelHeader = freemiusPanel + .locator( '.components-tools-panel-header' ) + .first(); + const panelHeaderBox = await panelHeader.boundingBox(); + const enableHelpBox = await enableHelp.boundingBox(); + + if ( ! bodyBox || ! panelBox || ! panelHeaderBox || ! enableHelpBox ) { + throw new Error( + 'Pricing scope group or Freemius panel is not visible for scope-enable-checkout capture' + ); + } + + const padding = capture.padding ?? 0; + const clip = { + x: Math.max( 0, bodyBox.x - padding ), + y: Math.max( 0, bodyBox.y - padding ), + width: bodyBox.width + padding * 2, + height: bodyBox.height + padding * 2, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + panelBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + panelHeaderBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + panelBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + enableHelpBox.y + + enableHelpBox.height - + panelHeaderBox.y + + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + const tailX = snapPixel( highlightX - 34 ); + const tailY = snapPixel( highlightY + highlightHeight + 38 ); + const tip = arrowTipBeforeRect( + tailX, + tailY, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngSolidArrow( + capturePng, + tailX, + tailY, + tip.x, + tip.y, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Clip the settings app from the header through the Body ID field. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureSettingsEditor( page, outputAbs, capture ) { + const app = page.locator( '#freemius-settings-app' ).first(); + const header = page.locator( '.freemius-header' ).first(); + const bodyIdControl = page + .getByLabel( 'Body ID', { exact: true } ) + .locator( 'xpath=ancestor::div[contains(@class,"components-base-control")]' ) + .first(); + const bodyIdHelp = bodyIdControl.locator( + '.components-base-control__help' + ); + + const appBox = await app.boundingBox(); + const headerBox = await header.boundingBox(); + const bodyIdBox = await bodyIdControl.boundingBox(); + const helpBox = await bodyIdHelp.boundingBox().catch( () => null ); + + if ( ! appBox || ! headerBox || ! bodyIdBox ) { + throw new Error( + 'Editor Settings fields are not visible for settings-editor capture' + ); + } + + const padding = capture.padding ?? 16; + const contentBottom = helpBox + ? helpBox.y + helpBox.height + : bodyIdBox.y + bodyIdBox.height; + const clip = { + x: appBox.x, + y: Math.max( 0, headerBox.y - padding ), + width: appBox.width, + height: contentBottom - headerBox.y + padding + 24, + }; + + await page.screenshot( { + path: outputAbs, + clip, + } ); +} + +/** + * Clip the settings app from the header through the Products fields. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureSettingsProducts( page, outputAbs, capture ) { + const app = page.locator( '#freemius-settings-app' ).first(); + const header = page.locator( '.freemius-header' ).first(); + const productIdControl = page + .getByLabel( 'Product ID', { exact: true } ) + .first() + .locator( + 'xpath=ancestor::div[contains(@class,"components-base-control")]' + ) + .first(); + const addProductButton = page.getByRole( 'button', { + name: 'Add a new product', + exact: true, + } ); + + const appBox = await app.boundingBox(); + const headerBox = await header.boundingBox(); + const productIdBox = await productIdControl.boundingBox(); + const addButtonBox = await addProductButton.boundingBox().catch( () => null ); + + if ( ! appBox || ! headerBox || ! productIdBox ) { + throw new Error( + 'Products fields are not visible for settings-products capture' + ); + } + + const padding = capture.padding ?? 16; + const contentBottom = addButtonBox + ? addButtonBox.y + addButtonBox.height + : productIdBox.y + productIdBox.height; + const clip = { + x: appBox.x, + y: Math.max( 0, headerBox.y - padding ), + width: appBox.width, + height: contentBottom - headerBox.y + padding + 24, + }; + + await page.screenshot( { + path: outputAbs, + clip, + } ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareButtonTrackCallback( page ) { + await closeListViewIfOpen( page ); + await ensureBlockSidebarOpen( page ); + await selectPricingCheckoutButton( page ); + await collapseLayoutPanel( page ); + await openFreemiusPanel( page ); + await dismissAutosaveNoticeIfPresent( page ); + + const popout = page.getByRole( 'button', { name: /Popout Editor/i } ).first(); + + if ( ( await popout.count() ) > 0 ) { + await popout.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + } +} + +/** + * Bounding box for the Track Callback field annotation (sidebar, before modal). + * + * @param {import('playwright').Page} page + */ +async function getTrackCallbackAnnotationBox( page ) { + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const trackCallbackControl = freemiusPanel + .locator( '.components-base-control' ) + .filter( { has: page.getByRole( 'button', { name: /Popout Editor/i } ) } ) + .first(); + const popoutButton = trackCallbackControl.getByRole( 'button', { + name: /Popout Editor/i, + } ); + + await trackCallbackControl.waitFor( { state: 'visible', timeout: 10_000 } ); + await popoutButton.waitFor( { state: 'visible', timeout: 10_000 } ); + + const panelBox = await freemiusPanel.boundingBox(); + const controlBox = await trackCallbackControl.boundingBox(); + const popoutBox = await popoutButton.boundingBox(); + + if ( ! panelBox || ! controlBox || ! popoutBox ) { + throw new Error( + 'Track Callback control is not visible for button-track-callback capture' + ); + } + + return { + x: panelBox.x, + y: controlBox.y, + width: panelBox.width, + height: popoutBox.y + popoutBox.height - controlBox.y, + }; +} + +/** + * @param {import('playwright').Page} page + */ +async function openTrackCallbackPopoutEditor( page ) { + const popout = page.getByRole( 'button', { name: /Popout Editor/i } ).first(); + + await popout.click(); + await page + .locator( '.components-modal__frame' ) + .first() + .waitFor( { state: 'visible', timeout: 10_000 } ); + await page.waitForTimeout( 500 ); +} + +/** + * Editor body with Track Callback popout open and the sidebar field annotated. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureButtonTrackCallback( page, outputAbs, capture ) { + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + const modal = page.locator( '.components-modal__frame' ).first(); + + await dismissAutosaveNoticeIfPresent( page ); + + const trackCallbackBox = await getTrackCallbackAnnotationBox( page ); + + await openTrackCallbackPopoutEditor( page ); + await modal.waitFor( { state: 'visible', timeout: 10_000 } ); + + const bodyBox = await editorBody.boundingBox(); + + if ( ! bodyBox ) { + throw new Error( + 'Editor body is not visible for button-track-callback capture' + ); + } + + const padding = capture.padding ?? 0; + const clip = { + x: Math.max( 0, bodyBox.x - padding ), + y: Math.max( 0, bodyBox.y - padding ), + width: bodyBox.width + padding * 2, + height: bodyBox.height + padding * 2, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + trackCallbackBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + trackCallbackBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + trackCallbackBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + trackCallbackBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + const modalBox = await modal.boundingBox(); + + if ( modalBox ) { + const tailX = snapPixel( modalBox.x + modalBox.width * 0.9 - clip.x ); + const tailY = snapPixel( modalBox.y + modalBox.height * 0.42 - clip.y ); + const tip = arrowTipBeforeRect( + tailX, + tailY, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngSolidArrow( + capturePng, + tailX, + tailY, + tip.x, + tip.y, + ANNOTATION_RED + ); + } + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareScopeEnableCheckout( page ) { + await closeListViewIfOpen( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) > 0 ) { + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingTableScopeGroup( page ); + await openFreemiusPanel( page ); + await page + .locator( '.freemius-button-scope-settings' ) + .getByText( /Product ID/i ) + .first() + .waitFor( { state: 'visible', timeout: 15_000 } ); + return; + } + + throw new Error( + 'Scoped group block not found on fixture post 428 — add a Freemius-enabled group' + ); +} + +/** + * Pricing table section in the fixture post (matches the playground page). + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function pricingTableSectionLocator( page ) { + return page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( 'section.has-freemius-scope, .has-freemius-scope' ) + .filter( { hasText: 'Freemius for WordPress' } ) + .first() + .or( + page + .locator( 'section.has-freemius-scope, .has-freemius-scope' ) + .filter( { hasText: 'Freemius for WordPress' } ) + .first() + ); +} + +/** + * Collapse the block inspector Layout panel when it is expanded. + * + * @param {import('playwright').Page} page + */ +async function collapseLayoutPanel( page ) { + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + await ensureBlockSettingsTab( page ); + + const layoutToggles = [ + page + .locator( '.interface-complementary-area' ) + .getByRole( 'button', { name: 'Layout', exact: true } ), + page + .locator( '.block-editor-block-inspector' ) + .getByRole( 'button', { name: 'Layout', exact: true } ), + ]; + + for ( const toggle of layoutToggles ) { + if ( ( await toggle.count() ) === 0 ) { + continue; + } + + const expanded = await toggle.first().getAttribute( 'aria-expanded' ); + if ( expanded === 'true' ) { + await toggle.first().click(); + await page.waitForTimeout( 300 ); + } + } + + await collapsePanelByTitle( page, 'Layout' ); + + await page.evaluate( () => { + const area = document.querySelector( + '.interface-complementary-area, .block-editor-block-inspector' + ); + if ( ! area ) { + return; + } + + area.querySelectorAll( '.components-panel__body' ).forEach( ( panel ) => { + const title = panel.querySelector( '.components-panel__body-title' ); + if ( title?.textContent?.trim() !== 'Layout' ) { + return; + } + + const toggle = panel.querySelector( '.components-panel__body-toggle' ); + if ( toggle?.getAttribute( 'aria-expanded' ) === 'true' ) { + toggle.click(); + } + } ); + } ); + + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function assertLayoutPanelCollapsed( page ) { + const expandedLayout = page + .locator( '.interface-complementary-area .components-panel__body' ) + .filter( { + has: page.getByText( 'Layout', { exact: true } ), + } ) + .locator( '.components-panel__body-toggle[aria-expanded="true"]' ); + + if ( ( await expandedLayout.count() ) > 0 ) { + throw new Error( + 'Layout panel must be collapsed for scope-pricing-mapped capture' + ); + } +} + +/** + * Select the pricing table outer scope group and collapse the Layout panel. + * + * @param {import('playwright').Page} page + */ +async function selectPricingTableScopeGroup( page ) { + const selected = await page.evaluate( () => { + const store = window.wp?.data; + if ( ! store ) { + return false; + } + + const targetNames = [ + 'Pricing Table With Testimonials (Freemius)', + 'Pricing Table (Freemius)', + ]; + + /** @param {Array<{ clientId: string, name: string, attributes?: { metadata?: { name?: string }, freemius_enabled?: boolean }, innerBlocks?: unknown[] }>} blocks */ + const findScopeGroup = ( blocks ) => { + for ( const block of blocks ) { + const metaName = block.attributes?.metadata?.name; + + if ( + block.attributes?.freemius_enabled && + block.name === 'core/group' && + metaName && + targetNames.includes( metaName ) + ) { + return block.clientId; + } + + const nested = findScopeGroup( block.innerBlocks ?? [] ); + if ( nested ) { + return nested; + } + } + + return null; + }; + + /** @param {Array<{ clientId: string, name: string, attributes?: { freemius_enabled?: boolean }, innerBlocks?: unknown[] }>} blocks */ + const findFirstScopeGroup = ( blocks ) => { + for ( const block of blocks ) { + if ( + block.attributes?.freemius_enabled && + block.name === 'core/group' + ) { + return block.clientId; + } + + const nested = findFirstScopeGroup( block.innerBlocks ?? [] ); + if ( nested ) { + return nested; + } + } + + return null; + }; + + const clientId = + findScopeGroup( store.select( 'core/block-editor' ).getBlocks() ) ?? + findFirstScopeGroup( + store.select( 'core/block-editor' ).getBlocks() + ); + + if ( ! clientId ) { + return false; + } + + store.dispatch( 'core/block-editor' ).selectBlock( clientId ); + return true; + } ); + + if ( ! selected ) { + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table scope group not found on fixture post 428' + ); + } + + await pricingSection.click( { force: true } ); + } else { + await page.waitForTimeout( 600 ); + } + + await closeListViewIfOpen( page ); + await collapseLayoutPanel( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareScopeColumnsOverview( page ) { + await closeListViewIfOpen( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingTableScopeGroup( page ); + await openFreemiusPanel( page ); + await page + .locator( '.freemius-button-scope-settings' ) + .getByText( /Product ID/i ) + .first() + .waitFor( { state: 'visible', timeout: 15_000 } ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareScopePricingMapped( page ) { + await closeListViewIfOpen( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingTableScopeGroup( page ); + await page.waitForTimeout( 500 ); +} + +/** + * Editor canvas clip of the pricing table with Freemius scope outlines. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureScopePricingMapped( page, outputAbs, capture ) { + const body = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + const pricingSection = pricingTableSectionLocator( page ); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await body.waitFor( { state: 'visible', timeout: 10_000 } ); + await assertLayoutPanelCollapsed( page ); + + const bodyBox = await body.boundingBox(); + const sectionBox = await pricingSection.boundingBox(); + + if ( ! bodyBox || ! sectionBox ) { + throw new Error( + 'Pricing table or editor body is not visible for scope-pricing-mapped capture' + ); + } + + const padding = capture.padding ?? 24; + const clip = { + x: Math.max( 0, bodyBox.x ), + y: Math.max( 0, sectionBox.y - padding ), + width: bodyBox.width, + height: sectionBox.height + padding * 2, + }; + + await page.screenshot( { + path: outputAbs, + clip, + } ); +} + +/** + * Pricing table with arrows pointing at each plan column scope. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureScopeColumnsOverview( page, outputAbs, capture ) { + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + const pricingSection = pricingTableSectionLocator( page ); + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const enableHelp = page.getByText( 'Enable Freemius for this area.', { + exact: true, + } ); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await editorBody.waitFor( { state: 'visible', timeout: 10_000 } ); + await freemiusPanel.waitFor( { state: 'visible', timeout: 10_000 } ); + await enableHelp.waitFor( { state: 'visible', timeout: 10_000 } ); + await assertLayoutPanelCollapsed( page ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const panelBox = await freemiusPanel.boundingBox(); + const panelHeader = freemiusPanel + .locator( '.components-tools-panel-header' ) + .first(); + const panelHeaderBox = await panelHeader.boundingBox(); + const enableHelpBox = await enableHelp.boundingBox(); + + if ( + ! bodyBox || + ! panelBox || + ! panelHeaderBox || + ! enableHelpBox + ) { + throw new Error( + 'Pricing table, Freemius panel, or editor body is not visible for scope-columns-overview capture' + ); + } + + const padding = capture.padding ?? 0; + const clip = { + x: Math.max( 0, bodyBox.x - padding ), + y: Math.max( 0, bodyBox.y - padding ), + width: bodyBox.width + padding * 2, + height: bodyBox.height + padding * 2, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const panelHighlightX = snapPixel( + panelBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const panelHighlightY = snapPixel( + panelHeaderBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const panelHighlightWidth = snapPixel( + panelBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const panelHighlightHeight = snapPixel( + enableHelpBox.y + + enableHelpBox.height - + panelHeaderBox.y + + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + panelHighlightX, + panelHighlightY, + panelHighlightWidth, + panelHighlightHeight, + ANNOTATION_RED + ); + + const panelTailX = snapPixel( panelHighlightX - 34 ); + const panelTailY = snapPixel( + panelHighlightY + panelHighlightHeight + 38 + ); + const panelTip = arrowTipBeforeRect( + panelTailX, + panelTailY, + panelHighlightX, + panelHighlightY, + panelHighlightWidth, + panelHighlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngSolidArrow( + capturePng, + panelTailX, + panelTailY, + panelTip.x, + panelTip.y, + ANNOTATION_RED + ); + + const columns = pricingPlanColumnLocator( page ); + const columnCount = Math.min( await columns.count(), 3 ); + + for ( let index = 0; index < columnCount; index += 1 ) { + const columnBox = await columns.nth( index ).boundingBox(); + + if ( ! columnBox ) { + continue; + } + + const tipX = snapPixel( columnBox.x + columnBox.width / 2 - clip.x ); + const tipY = snapPixel( columnBox.y - clip.y + 10 ); + const tailX = tipX; + const tailY = snapPixel( Math.max( 8, tipY - 42 ) ); + + drawPngSolidArrow( + capturePng, + tailX, + tailY, + tipX, + tipY, + ANNOTATION_RED + ); + } + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Pricing page canvas clip of the modifier toggle row with a red outline annotation. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function capturePricingPageModifiersRow( page, outputAbs, capture ) { + const frame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const pricingSection = pricingTableSectionLocator( page ); + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const heading = frame.getByText( 'Freemius for WordPress', { exact: true } ); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await heading.waitFor( { state: 'visible', timeout: 10_000 } ); + await modifiersRow.waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const sectionBox = await pricingSection.boundingBox(); + const headingBox = await heading.boundingBox(); + const rowBox = await modifiersRow.boundingBox(); + + if ( ! sectionBox || ! headingBox || ! rowBox ) { + throw new Error( + 'Pricing modifiers row is not visible for pricing-page-modifiers-row capture' + ); + } + + const padding = capture.padding ?? 24; + const clip = { + x: Math.max( 0, sectionBox.x - padding ), + y: Math.max( 0, headingBox.y - padding ), + width: sectionBox.width + padding * 2, + height: rowBox.y + rowBox.height - headingBox.y + padding * 2, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + rowBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + rowBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + rowBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + rowBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Pricing page editor with the modifier toggle row outlined and sidebar open. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureScopeModifiers( page, outputAbs, capture ) { + const frame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const pricingSection = pricingTableSectionLocator( page ); + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const heading = frame.getByText( 'Freemius for WordPress', { exact: true } ); + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await heading.waitFor( { state: 'visible', timeout: 10_000 } ); + await modifiersRow.waitFor( { state: 'visible', timeout: 10_000 } ); + await page + .getByLabel( 'Type', { exact: true } ) + .waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const headingBox = await heading.boundingBox(); + const rowBox = await modifiersRow.boundingBox(); + const freemiusPanelBody = page + .locator( '.components-panel__body.is-opened' ) + .filter( { + has: page.getByRole( 'button', { name: 'Freemius', exact: true } ), + } ) + .first(); + const panelBox = await freemiusPanelBody.boundingBox(); + + if ( ! bodyBox || ! headingBox || ! rowBox || ! panelBox ) { + throw new Error( + 'Pricing modifiers row or Freemius panel is not visible for scope-modifiers capture' + ); + } + + const padding = capture.padding ?? 24; + const clipY = Math.max( 0, headingBox.y - padding ); + const clipBottom = Math.max( + rowBox.y + rowBox.height + padding, + panelBox.y + panelBox.height + padding + ); + const clip = { + x: Math.max( 0, bodyBox.x ), + y: clipY, + width: bodyBox.width, + height: clipBottom - clipY, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + rowBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + rowBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + rowBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + rowBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Select a scoped pricing plan column by index (0 = first plan column). + * + * @param {import('playwright').Page} page + * @param {number} columnIndex + */ +async function selectPricingPlanColumn( page, columnIndex = 0 ) { + const selected = await page.evaluate( ( index ) => { + const store = window.wp?.data; + if ( ! store ) { + return false; + } + + /** @param {Array<{ clientId: string, name: string, attributes?: { freemius_enabled?: boolean }, innerBlocks?: unknown[] }>} blocks @param {string[]} acc */ + const collectColumns = ( blocks, acc = [] ) => { + for ( const block of blocks ) { + if ( + block.name === 'core/column' && + block.attributes?.freemius_enabled + ) { + acc.push( block.clientId ); + } + + collectColumns( block.innerBlocks ?? [], acc ); + } + + return acc; + }; + + const columnIds = collectColumns( + store.select( 'core/block-editor' ).getBlocks() + ); + const clientId = columnIds[ index ]; + + if ( ! clientId ) { + return false; + } + + store.dispatch( 'core/block-editor' ).selectBlock( clientId ); + return true; + }, columnIndex ); + + if ( ! selected ) { + const column = pricingPlanColumnLocator( page ).nth( columnIndex ); + + if ( ( await column.count() ) === 0 ) { + throw new Error( + 'Scoped plan column not found on fixture post 428 — enable Freemius on each pricing column' + ); + } + + await column.click( { force: true } ); + } else { + await page.waitForTimeout( 600 ); + } + + await closeListViewIfOpen( page ); + await collapseLayoutPanel( page ); + await openFreemiusPanel( page ); + await scrollSidebarToPlanField( page ); + await page.waitForTimeout( 300 ); +} + +/** + * Scroll the block sidebar so the Plan field is visible. + * + * @param {import('playwright').Page} page + */ +async function scrollSidebarToPlanField( page ) { + await ensureBlockSidebarOpen( page ); + await page + .getByText( /The ID of the plan that will load with the checkout/i ) + .first() + .waitFor( { state: 'visible', timeout: 10_000 } ); + + await page.evaluate( () => { + const area = document.querySelector( + '.interface-complementary-area, .edit-post-sidebar' + ); + const planHelp = Array.from( + area?.querySelectorAll( '.components-base-control__help' ) ?? [] + ).find( ( element ) => + element.textContent?.includes( + 'The ID of the plan that will load with the checkout' + ) + ); + + planHelp?.scrollIntoView( { block: 'center' } ); + } ); + + await page.waitForTimeout( 300 ); +} + +/** + * Bounding boxes for the Plan field annotation in the Freemius sidebar. + * + * @param {import('playwright').Page} page + */ +async function getPlanFieldAnnotationBoxes( page ) { + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const planItem = freemiusPanel + .locator( '.freemius-button-scope' ) + .filter( { has: page.getByText( 'Plan', { exact: true } ) } ) + .first(); + const planHelp = page.getByText( + /The ID of the plan that will load with the checkout/i + ); + + await planItem.waitFor( { state: 'visible', timeout: 10_000 } ); + await planHelp.waitFor( { state: 'visible', timeout: 10_000 } ); + + const itemBox = await planItem.boundingBox(); + const helpBox = await planHelp.boundingBox(); + + if ( ! itemBox || ! helpBox ) { + throw new Error( + 'Plan field is not visible for pricing-page-plan-column capture' + ); + } + + return { + x: itemBox.x, + y: itemBox.y, + width: itemBox.width, + height: helpBox.y + helpBox.height - itemBox.y, + }; +} + +/** + * Pricing page canvas clip of the plan columns with one column outlined. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function capturePricingPagePlanColumn( page, outputAbs, capture ) { + const pricingSection = pricingTableSectionLocator( page ); + const columnsBlock = pricingPlanColumnsLocator( page ); + const planColumn = pricingPlanColumnLocator( page ).first(); + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await columnsBlock.waitFor( { state: 'visible', timeout: 10_000 } ); + await planColumn.waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const columnsBox = await columnsBlock.boundingBox(); + const columnBox = await planColumn.boundingBox(); + const modifiersBox = await modifiersRow.boundingBox().catch( () => null ); + const planFieldBox = await getPlanFieldAnnotationBoxes( page ); + + if ( ! bodyBox || ! columnsBox || ! columnBox ) { + throw new Error( + 'Pricing plan columns are not visible for pricing-page-plan-column capture' + ); + } + + const padding = capture.padding ?? 24; + const clipTop = modifiersBox + ? Math.max( 0, modifiersBox.y - padding ) + : Math.max( 0, columnsBox.y - padding ); + const clip = { + x: Math.max( 0, bodyBox.x ), + y: clipTop, + width: bodyBox.width, + height: columnsBox.y + columnsBox.height - clipTop + padding, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + columnBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + columnBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + columnBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + columnBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + const planHighlightX = snapPixel( + planFieldBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const planHighlightY = snapPixel( + planFieldBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const planHighlightWidth = snapPixel( + planFieldBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const planHighlightHeight = snapPixel( + planFieldBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const planTailX = snapPixel( planHighlightX - 34 ); + const planTailY = snapPixel( planHighlightY - 38 ); + const planTip = arrowTipBeforeRect( + planTailX, + planTailY, + planHighlightX, + planHighlightY, + planHighlightWidth, + planHighlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngRect( + capturePng, + planHighlightX, + planHighlightY, + planHighlightWidth, + planHighlightHeight, + ANNOTATION_RED + ); + drawPngSolidArrow( + capturePng, + planTailX, + planTailY, + planTip.x, + planTip.y, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Checkout button inside a pricing plan column on the fixture page. + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function pricingCheckoutButtonLocator( page ) { + const frame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + + return frame + .locator( '.wp-block-column.has-freemius-scope' ) + .filter( { hasText: 'Professional' } ) + .locator( '.wp-block-button.has-freemius-scope' ) + .first(); +} + +/** + * Select the Professional plan checkout button in the pricing table. + * + * @param {import('playwright').Page} page + */ +async function selectPricingCheckoutButton( page ) { + const button = pricingCheckoutButtonLocator( page ); + + if ( ( await button.count() ) === 0 ) { + throw new Error( + 'Professional plan checkout button not found on fixture post 428 — add a Freemius-enabled button to each pricing column' + ); + } + + await button.click( { force: true } ); + await page.waitForTimeout( 600 ); + + await page + .getByLabel( 'Enable Freemius Checkout', { exact: true } ) + .waitFor( { state: 'visible', timeout: 10_000 } ); +} + +/** + * Bounding box for the Enable Freemius Checkout toggle annotation. + * + * @param {import('playwright').Page} page + */ +async function getEnableFreemiusCheckoutAnnotationBoxes( page ) { + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const enableLabel = page.getByText( 'Enable Freemius Checkout', { + exact: true, + } ); + const enableHelp = page.getByText( + 'Open a Freemius Checkout when the button is clicked.', + { exact: true } + ); + + await enableLabel.waitFor( { state: 'visible', timeout: 10_000 } ); + await enableHelp.waitFor( { state: 'visible', timeout: 10_000 } ); + + const panelBox = await freemiusPanel.boundingBox(); + const labelBox = await enableLabel.boundingBox(); + const helpBox = await enableHelp.boundingBox(); + + if ( ! panelBox || ! labelBox || ! helpBox ) { + throw new Error( + 'Enable Freemius Checkout control is not visible for pricing-page-checkout-button capture' + ); + } + + return { + x: panelBox.x, + y: labelBox.y, + width: panelBox.width, + height: helpBox.y + helpBox.height - labelBox.y, + }; +} + +/** + * Pricing page editor clip with a checkout button selected and the toggle outlined. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function capturePricingPageCheckoutButton( page, outputAbs, capture ) { + const pricingSection = pricingTableSectionLocator( page ); + const columnsBlock = pricingPlanColumnsLocator( page ); + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const checkoutButton = pricingCheckoutButtonLocator( page ); + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await columnsBlock.waitFor( { state: 'visible', timeout: 10_000 } ); + await checkoutButton.waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const columnsBox = await columnsBlock.boundingBox(); + const modifiersBox = await modifiersRow.boundingBox().catch( () => null ); + const firstColumn = pricingPlanColumnLocator( page ).nth( 1 ); + const firstColumnBox = await firstColumn.boundingBox(); + const checkoutToggleBox = await getEnableFreemiusCheckoutAnnotationBoxes( + page + ); + + if ( ! bodyBox || ! columnsBox || ! firstColumnBox ) { + throw new Error( + 'Pricing checkout button is not visible for pricing-page-checkout-button capture' + ); + } + + const padding = capture.padding ?? 24; + const clipTop = modifiersBox + ? Math.max( 0, modifiersBox.y - padding ) + : Math.max( 0, columnsBox.y - padding ); + const clipLeft = Math.max( bodyBox.x, firstColumnBox.x - padding ); + const clip = { + x: clipLeft, + y: clipTop, + width: bodyBox.x + bodyBox.width - clipLeft, + height: columnsBox.y + columnsBox.height - clipTop + padding, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + checkoutToggleBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + checkoutToggleBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + checkoutToggleBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + checkoutToggleBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Bounding box for the Freemius panel annotation on the button overview shot. + * + * @param {import('playwright').Page} page + */ +async function getButtonOverviewAnnotationBoxes( page ) { + const freemiusPanel = page + .locator( '.freemius-button-scope-settings' ) + .first(); + const panelHeader = freemiusPanel + .locator( '.components-tools-panel-header' ) + .first(); + const mappingHelp = page.getByText( + 'Select which field you like to map.', + { exact: true } + ); + + await freemiusPanel.waitFor( { state: 'visible', timeout: 10_000 } ); + await panelHeader.waitFor( { state: 'visible', timeout: 10_000 } ); + await mappingHelp.waitFor( { state: 'visible', timeout: 10_000 } ); + + const panelBox = await freemiusPanel.boundingBox(); + const panelHeaderBox = await panelHeader.boundingBox(); + const mappingHelpBox = await mappingHelp.boundingBox(); + + if ( ! panelBox || ! panelHeaderBox || ! mappingHelpBox ) { + throw new Error( + 'Freemius panel or mapping controls are not visible for button-overview capture' + ); + } + + return { + x: panelBox.x, + y: panelHeaderBox.y, + width: panelBox.width, + height: mappingHelpBox.y + mappingHelpBox.height - panelHeaderBox.y, + }; +} + +/** + * Pricing table editor with a mapped checkout button and annotated Freemius panel. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureButtonOverview( page, outputAbs, capture ) { + const pricingSection = pricingTableSectionLocator( page ); + const columnsBlock = pricingPlanColumnsLocator( page ); + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const checkoutButton = pricingCheckoutButtonLocator( page ); + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + + await pricingSection.waitFor( { state: 'visible', timeout: 15_000 } ); + await columnsBlock.waitFor( { state: 'visible', timeout: 10_000 } ); + await checkoutButton.waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await pricingSection.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + + const bodyBox = await editorBody.boundingBox(); + const columnsBox = await columnsBlock.boundingBox(); + const modifiersBox = await modifiersRow.boundingBox().catch( () => null ); + const professionalColumn = pricingPlanColumnLocator( page ).nth( 1 ); + const professionalColumnBox = await professionalColumn.boundingBox(); + const panelBox = await getButtonOverviewAnnotationBoxes( page ); + const buttonBox = await checkoutButton.boundingBox(); + + if ( ! bodyBox || ! columnsBox || ! professionalColumnBox || ! buttonBox ) { + throw new Error( + 'Mapped checkout button is not visible for button-overview capture' + ); + } + + const padding = capture.padding ?? 24; + const clipTop = modifiersBox + ? Math.max( 0, modifiersBox.y - padding ) + : Math.max( 0, columnsBox.y - padding ); + const clipLeft = Math.max( bodyBox.x, professionalColumnBox.x - padding ); + const clip = { + x: clipLeft, + y: clipTop, + width: bodyBox.x + bodyBox.width - clipLeft, + height: columnsBox.y + columnsBox.height - clipTop + padding, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + const highlightX = snapPixel( + panelBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + panelBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + panelBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + panelBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + const tailX = snapPixel( buttonBox.x + buttonBox.width - clip.x ); + const tailY = snapPixel( buttonBox.y + buttonBox.height / 2 - clip.y ); + const tip = arrowTipBeforeRect( + tailX, + tailY, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngSolidArrow( + capturePng, + tailX, + tailY, + tip.x, + tip.y, + ANNOTATION_RED + ); + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareButtonOverview( page ) { + await closeListViewIfOpen( page ); + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + await ensureBlockSettingsTab( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingCheckoutButton( page ); + await collapseLayoutPanel( page ); + await openFreemiusPanel( page ); + await scrollSidebarToTop( page ); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function preparePricingPageCheckoutButton( page ) { + await closeListViewIfOpen( page ); + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + await ensureBlockSettingsTab( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingCheckoutButton( page ); + await collapseLayoutPanel( page ); + await openFreemiusPanel( page ); + await scrollSidebarToTop( page ); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * Scroll the block sidebar so the Preview button is visible. + * + * @param {import('playwright').Page} page + */ +async function scrollSidebarToPreviewButton( page ) { + await ensureBlockSidebarOpen( page ); + + const previewButton = previewButtonLocator( page ); + + if ( ( await previewButton.count() ) > 0 ) { + await previewButton.scrollIntoViewIfNeeded(); + await page.waitForTimeout( 300 ); + return; + } + + await page.evaluate( () => { + const area = document.querySelector( + '.interface-complementary-area, .edit-post-sidebar' + ); + const preview = Array.from( area?.querySelectorAll( 'button' ) ?? [] ).find( + ( button ) => button.textContent?.trim() === 'Preview' + ); + + preview?.scrollIntoView( { block: 'center' } ); + } ); + + await page.waitForTimeout( 300 ); +} + +/** + * Locator for the Freemius Preview control in the block sidebar. + * + * @param {import('playwright').Page} page + * @returns {import('playwright').Locator} + */ +function previewButtonLocator( page ) { + return page + .locator( '.freemius-button-scope-settings' ) + .locator( 'button' ) + .filter( { hasText: /^Preview$/ } ) + .first(); +} + +/** + * Open the Freemius checkout preview overlay in the editor iframe. + * + * @param {import('playwright').Page} page + */ +async function openPricingCheckoutPreview( page ) { + const previewButton = previewButtonLocator( page ); + + await previewButton.waitFor( { state: 'visible', timeout: 20_000 } ); + await previewButton.click(); + + await page.waitForFunction( + () => { + if ( + document.body.classList.contains( 'freemius-checkout-preview' ) + ) { + return true; + } + + const iframe = document.querySelector( + 'iframe[name="editor-canvas"]' + ); + const doc = iframe?.contentDocument; + + return !! ( + doc?.querySelector( '[id^="fs-checkout-page-"]' ) || + doc?.querySelector( 'div[data-testid]' ) + ); + }, + { timeout: 45_000 } + ); + + await page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '[id^="fs-checkout-page-"], div[data-testid]' ) + .first() + .waitFor( { state: 'visible', timeout: 15_000 } ); + + await page.waitForTimeout( 1500 ); +} + +/** + * Bounding box for the Preview button annotation in the Freemius sidebar. + * + * @param {import('playwright').Page} page + */ +async function getPreviewButtonAnnotationBox( page ) { + const previewButton = previewButtonLocator( page ); + + await previewButton.waitFor( { state: 'visible', timeout: 20_000 } ); + + const box = await previewButton.boundingBox(); + + if ( ! box ) { + throw new Error( + 'Preview button is not visible for pricing-page-preview capture' + ); + } + + return box; +} + +/** + * Scroll the editor canvas so the checkout preview modal is fully visible. + * + * @param {import('playwright').Page} page + * @param {{ padding?: number }} capture + */ +async function ensureCheckoutOverlayFullyVisible( page, capture ) { + const checkoutOverlay = page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '[id^="fs-checkout-page-"], div[data-testid]' ) + .first(); + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + + await checkoutOverlay.waitFor( { state: 'visible', timeout: 15_000 } ); + + const padding = capture.padding ?? 24; + let checkoutBox = await checkoutOverlay.boundingBox(); + const bodyBox = await editorBody.boundingBox(); + + if ( ! checkoutBox || ! bodyBox ) { + throw new Error( + 'Checkout preview overlay is not visible for pricing-page-preview capture' + ); + } + + for ( let attempt = 0; attempt < 4; attempt += 1 ) { + const checkoutBottom = checkoutBox.y + checkoutBox.height; + const checkoutTop = checkoutBox.y; + const bodyBottom = bodyBox.y + bodyBox.height; + const bodyTop = bodyBox.y; + let delta = 0; + + if ( checkoutBottom > bodyBottom - padding ) { + delta = checkoutBottom - bodyBottom + padding + 48; + } else if ( checkoutTop < bodyTop + padding ) { + delta = checkoutTop - bodyTop - padding - 48; + } + + if ( Math.abs( delta ) < 4 ) { + break; + } + + await scrollEditorCanvasBy( page, delta ); + await page.waitForTimeout( 400 ); + + checkoutBox = await checkoutOverlay.boundingBox(); + if ( ! checkoutBox ) { + break; + } + } +} + +/** + * Editor view with the checkout preview overlay open in the block editor. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + * @param {{ annotatePreview?: boolean }} options + */ +async function capturePricingCheckoutPreviewEditor( + page, + outputAbs, + capture, + { annotatePreview = false } = {} +) { + const editorBody = page + .locator( '.interface-interface-skeleton__body' ) + .first(); + const checkoutOverlay = page + .frameLocator( 'iframe[name="editor-canvas"]' ) + .locator( '[id^="fs-checkout-page-"], div[data-testid]' ) + .first(); + + await checkoutOverlay.waitFor( { state: 'visible', timeout: 15_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await ensureCheckoutOverlayFullyVisible( page, capture ); + + const bodyBox = await editorBody.boundingBox(); + const checkoutBox = await checkoutOverlay.boundingBox(); + + if ( ! bodyBox || ! checkoutBox ) { + throw new Error( + 'Pricing preview overlay is not visible for checkout preview capture' + ); + } + + const padding = capture.padding ?? 24; + const clipTop = Math.max( + 0, + Math.min( bodyBox.y, checkoutBox.y ) - padding + ); + const clipBottom = + Math.max( + bodyBox.y + bodyBox.height, + checkoutBox.y + checkoutBox.height + ) + padding; + const clip = { + x: Math.max( 0, bodyBox.x - padding ), + y: clipTop, + width: bodyBox.width + padding * 2, + height: clipBottom - clipTop, + }; + + const capturePng = PNG.sync.read( + await page.screenshot( { + type: 'png', + clip, + } ) + ); + + if ( annotatePreview ) { + const previewButtonBox = await getPreviewButtonAnnotationBox( page ); + const highlightX = snapPixel( + previewButtonBox.x - clip.x - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightY = snapPixel( + previewButtonBox.y - clip.y - CHECKOUT_PANEL_ANNOTATION_INSET + ); + const highlightWidth = snapPixel( + previewButtonBox.width + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + const highlightHeight = snapPixel( + previewButtonBox.height + CHECKOUT_PANEL_ANNOTATION_INSET * 2 + ); + + drawPngRect( + capturePng, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + ANNOTATION_RED + ); + + const tailX = snapPixel( highlightX - 50 ); + const tailY = snapPixel( highlightY + highlightHeight / 2 ); + const tip = arrowTipBeforeRect( + tailX, + tailY, + highlightX, + highlightY, + highlightWidth, + highlightHeight, + KEY_SETTINGS_ARROW_TIP_GAP + ); + + drawPngSolidArrow( + capturePng, + tailX, + tailY, + tip.x, + tip.y, + ANNOTATION_RED + ); + } + + writeFileSync( outputAbs, PNG.sync.write( capturePng ) ); +} + +/** + * Editor view with checkout preview open and the Preview button outlined. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function capturePricingPagePreview( page, outputAbs, capture ) { + await capturePricingCheckoutPreviewEditor( page, outputAbs, capture, { + annotatePreview: true, + } ); +} + +/** + * Editor view with checkout preview open for the documentation homepage. + * + * @param {import('playwright').Page} page + * @param {string} outputAbs + * @param {{ padding?: number }} capture + */ +async function captureDocsHomepagePreview( page, outputAbs, capture ) { + await capturePricingCheckoutPreviewEditor( page, outputAbs, capture ); +} + +/** + * @param {import('playwright').Page} page + */ +async function preparePricingPagePreview( page ) { + await closeListViewIfOpen( page ); + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + await ensureBlockSettingsTab( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingCheckoutButton( page ); + await collapseLayoutPanel( page ); + await openFreemiusPanel( page ); + await ensureCheckoutEnabled( page ); + await page.waitForTimeout( 1500 ); + await scrollSidebarToPreviewButton( page ); + await openPricingCheckoutPreview( page ); + await ensureCheckoutOverlayFullyVisible( page, { padding: 24 } ); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function preparePricingPageModifiersRow( page ) { + await closeListViewIfOpen( page ); + await deselectAllBlocks( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function preparePricingPagePlanColumn( page ) { + await closeListViewIfOpen( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + await selectPricingPlanColumn( page, 0 ); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareScopeModifiers( page ) { + await closeListViewIfOpen( page ); + await ensureBlockSidebarOpen( page ); + await ensureBlockInspectorTab( page ); + await ensureBlockSettingsTab( page ); + + const pricingSection = pricingTableSectionLocator( page ); + + if ( ( await pricingSection.count() ) === 0 ) { + throw new Error( + 'Pricing table section not found on fixture post 428 — add the Freemius pricing layout from the playground' + ); + } + + await pricingSection.scrollIntoViewIfNeeded(); + + const modifiersRow = pricingModifiersRowGroupLocator( page ); + const modifier = modifiersRow + .locator( '[data-type="freemius/modifier"]' ) + .first(); + + if ( ( await modifier.count() ) === 0 ) { + throw new Error( + 'Freemius Scope modifier block not found on fixture post 428' + ); + } + + await modifier.click(); + await page.waitForTimeout( 500 ); + await openFreemiusPanel( page ); + await page + .getByLabel( 'Type', { exact: true } ) + .waitFor( { state: 'visible', timeout: 10_000 } ); + await dismissAutosaveNoticeIfPresent( page ); + await page.waitForTimeout( 300 ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareSettingsEditor( page ) { + await page.waitForSelector( '#freemius-settings-app', { + timeout: 15_000, + } ); + + const editorTab = page.getByRole( 'tab', { + name: 'Editor Settings', + exact: true, + } ); + + if ( ( await editorTab.count() ) > 0 ) { + await editorTab.click(); + await page.waitForTimeout( 300 ); + } + + await page + .getByLabel( 'Product ID', { exact: true } ) + .waitFor( { state: 'visible', timeout: 10_000 } ); +} + +/** + * @param {import('playwright').Page} page + */ +async function prepareSettingsProducts( page ) { + await page.waitForSelector( '#freemius-settings-app', { + timeout: 15_000, + } ); + + const productsTab = page.getByRole( 'tab', { + name: 'Products', + exact: true, + } ); + + if ( ( await productsTab.count() ) > 0 ) { + await productsTab.click(); + await page.waitForTimeout( 300 ); + } + + await page + .getByLabel( 'Product ID', { exact: true } ) + .first() + .waitFor( { state: 'visible', timeout: 10_000 } ); +} + +/** @type {Record Promise>} */ +const PRE_CAPTURE_ACTIONS = { + 'button-overview': prepareButtonOverview, + 'button-key-settings': prepareButtonKeySettings, + 'button-track-callback': prepareButtonTrackCallback, + 'scope-columns-overview': prepareScopeColumnsOverview, + 'scope-enable-checkout': prepareScopeEnableCheckout, + 'scope-pricing-mapped': prepareScopePricingMapped, + 'pricing-page-checkout-button': preparePricingPageCheckoutButton, + 'pricing-page-modifiers-row': preparePricingPageModifiersRow, + 'pricing-page-plan-column': preparePricingPagePlanColumn, + 'docs-homepage-preview': preparePricingPagePreview, + 'pricing-page-preview': preparePricingPagePreview, + 'scope-modifiers': prepareScopeModifiers, + 'settings-editor': prepareSettingsEditor, + 'settings-products': prepareSettingsProducts, +}; + +/** + * @param {unknown} manifest + */ +function writeManifest( manifest ) { + const sortedImages = [ ...manifest.images ].sort( ( a, b ) => + a.id.localeCompare( b.id ) + ); + + const output = { + $schema: manifest.$schema ?? './image-manifest.schema.json', + version: manifest.version, + viewports: manifest.viewports, + images: sortedImages.map( ( entry ) => sortEntryKeys( entry ) ), + }; + + writeFileSync( + manifestPath, + `${ JSON.stringify( output, null, '\t' ) }\n`, + 'utf8' + ); +} + +/** + * @param {Record} entry + */ +function sortEntryKeys( entry ) { + /** @type {Record} */ + const sorted = {}; + for ( const key of ENTRY_KEY_ORDER ) { + if ( entry[ key ] !== undefined ) { + sorted[ key ] = entry[ key ]; + } + } + return sorted; +} + +/** + * Verify a manifest image reference exists in user-facing doc markdown. + * Does not modify markdown — docs/ is end-user content only. + * + * @param {{ embed: { doc: string, alt: string } }} entry + */ +function verifyDocImageReference( entry ) { + const docAbs = resolve( rootDir, entry.embed.doc ); + const markdown = readFileSync( docAbs, 'utf8' ); + const pattern = new RegExp( + `!\\[${ entry.embed.alt.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) }\\]\\([^)]+\\)` + ); + + if ( ! pattern.test( markdown ) ) { + throw new Error( + `No image reference found for alt "${ entry.embed.alt }" in ${ entry.embed.doc }` + ); + } +} + +/** + * @param {string} fromPath + * @param {string} toPath + */ +function copyAsset( fromPath, toPath ) { + const fromAbs = resolve( rootDir, fromPath ); + const toAbs = resolve( rootDir, toPath ); + mkdirSync( dirname( toAbs ), { recursive: true } ); + copyFileSync( fromAbs, toAbs ); +} + +/** + * @param {Array<{ embed: { path: string } }>} entries + * @param {string} sourcePath + */ +function resolveBaselinePath( entries, sourcePath ) { + for ( const entry of entries ) { + if ( existsSync( resolve( rootDir, entry.embed.path ) ) ) { + return entry.embed.path; + } + } + + if ( existsSync( resolve( rootDir, sourcePath ) ) ) { + return sourcePath; + } + + return null; +} + +/** + * @param {import('playwright').Page} page + * @param {{ selector: string, regionFrom?: string, regionTo?: string, padding?: number }} capture + */ +async function measureRegionClip( page, capture ) { + const sidebar = sidebarLocator( page ); + const sidebarBox = await sidebar.boundingBox(); + + if ( ! sidebarBox ) { + throw new Error( + `Capture selector is not visible: ${ capture.selector }` + ); + } + + const fromEl = page.locator( capture.regionFrom ).first(); + const toEl = page.locator( capture.regionTo ).first(); + const fromBox = await fromEl.boundingBox(); + const toBox = await toEl.boundingBox(); + + if ( ! fromBox || ! toBox ) { + throw new Error( + `Region markers not visible (${ capture.regionFrom } → ${ capture.regionTo })` + ); + } + + const padding = capture.padding ?? 0; + + return { + x: sidebarBox.x, + y: Math.max( 0, fromBox.y - padding ), + width: sidebarBox.width, + height: toBox.y + toBox.height - fromBox.y + padding * 2, + }; +} + +/** + * @param {import('playwright').Page} page + * @param {{ id: string, capture: { url: string, viewports: string[], selector?: string, regionFrom?: string, regionTo?: string, padding?: number } }} entry + * @param {Record} viewports + * @param {string} outputPath Repo-root-relative PNG path. + */ +async function captureScreenshot( page, entry, viewports, outputPath ) { + const viewportName = entry.capture.viewports?.[ 0 ] ?? DOC_VIEWPORT; + const viewport = viewports[ viewportName ]; + if ( ! viewport ) { + throw new Error( + `Viewport "${ viewportName }" is not defined in image-manifest.json viewports` + ); + } + + await page.setViewportSize( viewport ); + await page.goto( resolveCaptureUrl( entry.capture.url ), { + waitUntil: 'domcontentloaded', + } ); + if ( isSettingsCapture( entry ) ) { + await waitForSettingsReady( page ); + } else if ( isFrontendCapture( entry ) ) { + await page.waitForSelector( 'body', { timeout: 30_000 } ); + await page.waitForTimeout( CAPTURE_WAIT_MS ); + } else { + await waitForEditorReady( page ); + } + await page.waitForTimeout( CAPTURE_WAIT_MS ); + + const preAction = PRE_CAPTURE_ACTIONS[ entry.id ]; + if ( preAction ) { + await preAction( page ); + await page.waitForTimeout( 500 ); + } + + if ( entry.id !== 'button-key-settings' ) { + await blurFocusedElement( page ); + } + + const outputAbs = resolve( rootDir, outputPath ); + mkdirSync( dirname( outputAbs ), { recursive: true } ); + + if ( entry.capture.selector ) { + if ( entry.id === 'button-key-settings' ) { + await captureButtonKeySettings( page, outputAbs ); + return; + } + + if ( entry.id === 'button-overview' ) { + await captureButtonOverview( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'button-track-callback' ) { + await captureButtonTrackCallback( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'scope-columns-overview' ) { + await captureScopeColumnsOverview( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'scope-enable-checkout' ) { + await captureScopeEnableCheckout( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'pricing-page-checkout-button' ) { + await capturePricingPageCheckoutButton( + page, + outputAbs, + entry.capture + ); + return; + } + + if ( entry.id === 'pricing-page-modifiers-row' ) { + await capturePricingPageModifiersRow( + page, + outputAbs, + entry.capture + ); + return; + } + + if ( entry.id === 'scope-modifiers' ) { + await captureScopeModifiers( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'pricing-page-plan-column' ) { + await capturePricingPagePlanColumn( + page, + outputAbs, + entry.capture + ); + return; + } + + if ( entry.id === 'pricing-page-preview' ) { + await capturePricingPagePreview( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'docs-homepage-preview' ) { + await captureDocsHomepagePreview( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'settings-editor' ) { + await captureSettingsEditor( page, outputAbs, entry.capture ); + return; + } + + if ( entry.id === 'settings-products' ) { + await captureSettingsProducts( page, outputAbs, entry.capture ); + return; + } + + if ( isEditorCanvasCapture( entry ) ) { + await captureScopePricingMapped( page, outputAbs, entry.capture ); + return; + } + + if ( isFrontendCapture( entry ) ) { + const target = page.locator( entry.capture.selector ).first(); + if ( ( await target.count() ) === 0 ) { + throw new Error( + `Capture selector not found: ${ entry.capture.selector }` + ); + } + + const padding = entry.capture.padding ?? 0; + + if ( padding > 0 ) { + const box = await target.boundingBox(); + if ( ! box ) { + throw new Error( + `Capture selector is not visible: ${ entry.capture.selector }` + ); + } + + const x = Math.max( 0, box.x - padding ); + const y = Math.max( 0, box.y - padding ); + const viewportSize = page.viewportSize(); + const viewportWidth = viewportSize?.width ?? viewport.width; + const viewportHeight = viewportSize?.height ?? viewport.height; + const right = Math.min( + viewportWidth, + box.x + box.width + padding + ); + const bottom = Math.min( + viewportHeight, + box.y + box.height + padding + ); + + await page.screenshot( { + path: outputAbs, + clip: { + x, + y, + width: right - x, + height: bottom - y, + }, + } ); + return; + } + + await target.screenshot( { path: outputAbs } ); + return; + } + + if ( isSettingsCapture( entry ) ) { + const target = page.locator( entry.capture.selector ).first(); + if ( ( await target.count() ) === 0 ) { + throw new Error( + `Capture selector not found: ${ entry.capture.selector }` + ); + } + + const padding = entry.capture.padding ?? 0; + + if ( padding > 0 ) { + const box = await target.boundingBox(); + if ( ! box ) { + throw new Error( + `Capture selector is not visible: ${ entry.capture.selector }` + ); + } + + const x = Math.max( 0, box.x - padding ); + const y = Math.max( 0, box.y - padding ); + const viewportSize = page.viewportSize(); + const viewportWidth = viewportSize?.width ?? viewport.width; + const viewportHeight = viewportSize?.height ?? viewport.height; + const right = Math.min( + viewportWidth, + box.x + box.width + padding + ); + const bottom = Math.min( + viewportHeight, + box.y + box.height + padding + ); + + await page.screenshot( { + path: outputAbs, + clip: { + x, + y, + width: right - x, + height: bottom - y, + }, + } ); + return; + } + + await target.screenshot( { path: outputAbs } ); + return; + } + + await ensureComplementaryAreaOpen( page ); + + const target = sidebarLocator( page ); + if ( ( await target.count() ) === 0 ) { + throw new Error( + `Capture selector not found: ${ entry.capture.selector }` + ); + } + + if ( entry.capture.regionFrom && entry.capture.regionTo ) { + const clip = await measureRegionClip( page, entry.capture ); + await page.screenshot( { + path: outputAbs, + clip, + } ); + return; + } + + const padding = entry.capture.padding ?? 0; + + if ( padding > 0 ) { + const box = await target.boundingBox(); + if ( ! box ) { + throw new Error( + `Capture selector is not visible: ${ entry.capture.selector }` + ); + } + + const x = Math.max( 0, box.x - padding ); + const y = Math.max( 0, box.y - padding ); + const viewportSize = page.viewportSize(); + const viewportWidth = viewportSize?.width ?? viewport.width; + const viewportHeight = viewportSize?.height ?? viewport.height; + const right = Math.min( + viewportWidth, + box.x + box.width + padding + ); + const bottom = Math.min( + viewportHeight, + box.y + box.height + padding + ); + + await page.screenshot( { + path: outputAbs, + clip: { + x, + y, + width: right - x, + height: bottom - y, + }, + } ); + return; + } + + await target.screenshot( { path: outputAbs } ); + return; + } + + await page.screenshot( { path: outputAbs, fullPage: false } ); +} + +/** + * @param {import('playwright').BrowserType} chromium + * @param {string} url + */ +async function assertSiteReachable( chromium, url ) { + let browser; + try { + browser = await chromium.launch( { timeout: 30_000 } ); + const page = await browser.newPage( { ignoreHTTPSErrors: true } ); + const response = await page.goto( url, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + } ); + const status = response?.status() ?? 0; + if ( ! response || ( status >= 400 && status < 600 ) ) { + throw new Error( `HTTP ${ status || 'error' }` ); + } + } finally { + await browser?.close(); + } +} + +/** + * @param {string} id + * @returns {string} + */ +function reviewSourcePath( id ) { + return `screenshots/${ id }/source.png`; +} + +/** + * @param {string[]} argv + * @returns {{ filterId: string | null, help: boolean, force: boolean }} + */ +function parseCliArgs( argv ) { + const args = argv.slice( 2 ); + + if ( args.includes( '--help' ) || args.includes( '-h' ) ) { + return { filterId: null, help: true, force: false }; + } + + const force = args.includes( '--force' ) || args.includes( '-f' ); + const filteredArgs = args.filter( + ( arg ) => + arg !== '--force' && + arg !== '-f' && + arg !== '--help' && + arg !== '-h' + ); + + const idFlag = filteredArgs.find( ( arg ) => arg.startsWith( '--id=' ) ); + if ( idFlag ) { + const id = idFlag.slice( '--id='.length ).trim(); + if ( ! id ) { + console.error( '--id requires a manifest entry id.' ); + process.exit( 1 ); + } + return { filterId: id, help: false, force }; + } + + const positional = filteredArgs.filter( ( arg ) => ! arg.startsWith( '-' ) ); + + if ( positional.length > 1 ) { + console.error( + 'Pass at most one manifest id, or omit the argument to capture all docs entries.' + ); + process.exit( 1 ); + } + + return { + filterId: positional[ 0 ] ?? null, + help: false, + force, + }; +} + +/** + * @param {Array<{ id: string }>} captureEntries + * @param {Array<{ id: string }>} reviewCaptureEntries + */ +function printUsage( captureEntries, reviewCaptureEntries = [] ) { + const ids = captureEntries.map( ( entry ) => entry.id ).sort(); + const reviewIds = reviewCaptureEntries.map( ( entry ) => entry.id ).sort(); + + console.log( `Usage: + npm run update-screenshots + npm run update-screenshots -- + npm run update-screenshots -- --force + +Capturable docs ids (${ ids.length }): +${ ids.map( ( id ) => ` - ${ id }` ).join( '\n' ) } +${ + reviewIds.length + ? `\nReview-only ids (single-id capture only, ${ + reviewIds.length + }):\n${ reviewIds.map( ( id ) => ` - ${ id }` ).join( '\n' ) }` + : '' +} +` ); +} + +/** + * @param {Array<{ status: string }>} batchResults + * @returns {'ok' | 'error' | 'skipped'} + */ +function captureLineStatus( batchResults ) { + if ( batchResults.some( ( row ) => row.status === 'error' ) ) { + return 'error'; + } + + if ( + batchResults.length > 0 && + batchResults.every( ( row ) => row.status === 'unchanged' ) + ) { + return 'skipped'; + } + + return 'ok'; +} + +/** + * @param {string} linePrefix + * @param {'ok' | 'error' | 'skipped'} status + */ +function logCaptureLine( linePrefix, status ) { + console.log( `${ linePrefix } ${ status }` ); +} + +/** + * @param {import('playwright').BrowserType} chromium + * @param {unknown} manifest + * @param {{ id: string, capture: { url: string } }} entry + */ +async function captureReviewEntry( chromium, manifest, entry ) { + const probeUrl = resolveCaptureUrl( entry.capture.url ); + console.log( ` Checking ${ probeUrl }…` ); + + try { + await assertSiteReachable( chromium, probeUrl ); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + console.error( + `Local site not reachable at ${ probeUrl } — start dev.local and ensure auto-login works.` + ); + console.error( String( error ) ); + process.exit( 1 ); + } + + let browser; + try { + browser = await chromium.launch(); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + throw error; + } + + const page = await browser.newPage( { ignoreHTTPSErrors: true } ); + const outputPath = reviewSourcePath( entry.id ); + + try { + await captureScreenshot( + page, + entry, + manifest.viewports, + outputPath + ); + logCaptureLine( ` [1/1] Capturing ${ entry.id }…`, 'ok' ); + } catch ( error ) { + logCaptureLine( ` [1/1] Capturing ${ entry.id }…`, 'error' ); + console.error( String( error ) ); + process.exit( 1 ); + } finally { + await browser.close(); + } + + console.log( 'Ready.. (1 updated)' ); +} + +async function main() { + const { filterId, help, force } = parseCliArgs( process.argv ); + + const chromium = loadChromium(); + if ( ! chromium ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + + const manifest = JSON.parse( readFileSync( manifestPath, 'utf8' ) ); + const allDocsEntries = manifest.images.filter( + ( entry ) => entry.use === 'docs' && entry.embed + ); + const allCaptureEntries = allDocsEntries.filter( + ( entry ) => entry.capture + ); + const allReviewCaptureEntries = manifest.images.filter( + ( entry ) => entry.use === 'review' && entry.capture + ); + + if ( help ) { + printUsage( allCaptureEntries, allReviewCaptureEntries ); + process.exit( 0 ); + } + + if ( filterId ) { + const entry = manifest.images.find( ( item ) => item.id === filterId ); + + if ( ! entry ) { + console.error( `Unknown manifest id "${ filterId }".` ); + printUsage( allCaptureEntries, allReviewCaptureEntries ); + process.exit( 1 ); + } + + if ( ! entry.capture ) { + console.error( + `"${ filterId }" has no capture block — add capture.url and capture.viewports first.` + ); + process.exit( 1 ); + } + + if ( entry.use === 'review' ) { + console.log( `Starting.. (1 review screenshot: ${ filterId })` ); + await captureReviewEntry( chromium, manifest, entry ); + return; + } + + if ( entry.use !== 'docs' || ! entry.embed ) { + console.error( + `"${ filterId }" is not a docs embed entry (use: docs with embed block required).` + ); + process.exit( 1 ); + } + } + + if ( allCaptureEntries.length === 0 ) { + console.error( + 'No docs manifest entries with capture blocks — add capture.url and capture.viewports first.' + ); + process.exit( 1 ); + } + + let docsEntries = allDocsEntries; + let captureEntries = allCaptureEntries; + + if ( filterId ) { + const entry = manifest.images.find( ( item ) => item.id === filterId ); + docsEntries = [ entry ]; + captureEntries = [ entry ]; + } + + const captureCount = captureEntries.length; + console.log( + filterId + ? `Starting.. (1 screenshot: ${ filterId })` + : `Starting.. (${ captureCount } screenshots)` + ); + + const probeUrl = resolveCaptureUrl( captureEntries[ 0 ].capture.url ); + console.log( ` Checking ${ probeUrl }…` ); + const compareOptions = getScreenshotCompareOptions(); + if ( force ) { + compareOptions.disabled = true; + } + + try { + await assertSiteReachable( chromium, probeUrl ); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + console.error( + `Local site not reachable at ${ probeUrl } — start dev.local and ensure auto-login works.` + ); + console.error( String( error ) ); + process.exit( 1 ); + } + + /** @type {Map} */ + const bySource = new Map(); + for ( const entry of docsEntries ) { + const key = entry.embed.from; + if ( ! bySource.has( key ) ) { + bySource.set( key, [] ); + } + bySource.get( key ).push( entry ); + } + + /** @type {Map} */ + const captureBySource = new Map(); + for ( const entry of captureEntries ) { + if ( filterId ) { + captureBySource.set( entry.embed.from, entry ); + continue; + } + + if ( ! captureBySource.has( entry.embed.from ) ) { + captureBySource.set( entry.embed.from, entry ); + } + } + + /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ + const results = []; + let manifestDirty = false; + + let browser; + try { + browser = await chromium.launch(); + } catch ( error ) { + if ( isMissingBrowserError( error ) ) { + printPlaywrightHelp(); + process.exit( 1 ); + } + throw error; + } + + const page = await browser.newPage( { ignoreHTTPSErrors: true } ); + + let captureIndex = 0; + + try { + for ( const [ sourcePath, primary ] of captureBySource ) { + const url = resolveCaptureUrl( primary.capture.url ); + let captureError = null; + + captureIndex += 1; + const relatedEntries = bySource.get( sourcePath ) ?? [ primary ]; + const relatedIds = relatedEntries + .map( ( entry ) => entry.id ) + .join( ', ' ); + const linePrefix = ` [${ captureIndex }/${ captureBySource.size }] Capturing ${ relatedIds }…`; + /** @type {Array<{ id: string, url: string, output: string, status: string, detail?: string }>} */ + const batchResults = []; + + try { + const tempCapturePath = `${ sourcePath }.capture-tmp.png`; + await captureScreenshot( + page, + primary, + manifest.viewports, + tempCapturePath + ); + + const baselinePath = resolveBaselinePath( + relatedEntries, + sourcePath + ); + const entryCompareOptions = resolveCompareOptionsForCapture( + compareOptions, + primary.capture + ); + const compareResult = baselinePath + ? await compareScreenshotFiles( + resolve( rootDir, baselinePath ), + resolve( rootDir, tempCapturePath ), + entryCompareOptions + ) + : { + action: 'update', + diffRatio: 1, + reason: 'no baseline file', + }; + + const tempCaptureAbs = resolve( rootDir, tempCapturePath ); + + if ( compareResult.action === 'skip' ) { + unlinkSync( tempCaptureAbs ); + for ( const entry of relatedEntries ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'unchanged', + detail: compareResult.reason, + } ); + } + logCaptureLine( + linePrefix, + captureLineStatus( batchResults ) + ); + results.push( ...batchResults ); + continue; + } + + if ( compareResult.action === 'abort' ) { + unlinkSync( tempCaptureAbs ); + for ( const entry of relatedEntries ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'error', + detail: `Screenshot comparison aborted: ${ compareResult.reason }`, + } ); + } + logCaptureLine( + linePrefix, + captureLineStatus( batchResults ) + ); + results.push( ...batchResults ); + continue; + } + + copyAsset( tempCapturePath, sourcePath ); + unlinkSync( tempCaptureAbs ); + } catch ( error ) { + captureError = error; + } + + if ( captureError ) { + for ( const entry of relatedEntries ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'error', + detail: String( captureError ), + } ); + } + logCaptureLine( linePrefix, captureLineStatus( batchResults ) ); + results.push( ...batchResults ); + continue; + } + + for ( const entry of relatedEntries ) { + try { + copyAsset( sourcePath, entry.embed.path ); + verifyDocImageReference( entry ); + entry.embed.status = 'captured'; + manifestDirty = true; + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: relatedEntries.length > 1 ? 'copied' : 'ok', + } ); + } catch ( error ) { + batchResults.push( { + id: entry.id, + url, + output: entry.embed.path, + status: 'error', + detail: String( error ), + } ); + } + } + + logCaptureLine( linePrefix, captureLineStatus( batchResults ) ); + results.push( ...batchResults ); + } + } finally { + await browser.close(); + } + + if ( manifestDirty ) { + writeManifest( manifest ); + console.log( ' Syncing screenshots inventory…' ); + syncScreenshotsDocsPage(); + } + + results.sort( ( a, b ) => a.id.localeCompare( b.id ) ); + + const failures = results.filter( ( row ) => row.status === 'error' ); + const unchanged = results.filter( ( row ) => row.status === 'unchanged' ); + const updated = results.filter( ( row ) => + [ 'ok', 'copied' ].includes( row.status ) + ); + + if ( failures.length === 0 ) { + const parts = []; + if ( updated.length > 0 ) { + parts.push( `${ updated.length } updated` ); + } + if ( unchanged.length > 0 ) { + parts.push( `${ unchanged.length } unchanged` ); + } + console.log( `Ready.. (${ parts.join( ', ' ) || 'no captures' })` ); + } else { + console.log( + `Ready.. (${ updated.length } updated, ${ unchanged.length } unchanged, ${ failures.length } failed)` + ); + } + + console.log( '\nDoc screenshot summary:\n' ); + console.log( + '| id | url | output | status |', + '\n| --- | --- | --- | --- |' + ); + for ( const row of results ) { + const detail = row.detail ? ` (${ row.detail })` : ''; + console.log( + `| ${ row.id } | ${ row.url } | ${ row.output } | ${ row.status }${ detail } |` + ); + } + + if ( failures.length > 0 ) { + process.exit( 1 ); + } +} + +main().catch( ( error ) => { + console.error( error ); + process.exit( 1 ); +} );