diff --git a/docs/review/170/README.md b/docs/review/170/README.md
index b6cfae0..866767d 100644
--- a/docs/review/170/README.md
+++ b/docs/review/170/README.md
@@ -190,3 +190,18 @@ Admin assertions and 78 Preset/Repeat assertions. Historical #170 PNGs, result
JSONs, captions and conflict observer assertions remain byte-for-byte unchanged.
Only the current Admin source fingerprint is refreshed; original #170 browser
coverage must still run in exact-head hosted Admin before release readiness.
+
+### #284 UI polish batch source refresh
+
+The Admin fingerprint now binds the presentation-only polish batch #273–#278
+(tables, filters, primary, progress, copy, chips) on `ui/batch-273-278`.
+Exactly three files are touched: `class-free-admin.php`, `free-selection.js`,
+`free-selection.css`. No planner math, no frozen decimal contract, no storage,
+no handler/nonce/route change. The sole runtime-adjacent exception is two
+additive progress integers (`progress_total`, `progress_processed`) that are
+derived from existing durable counters and hidden on unknown/zero totals.
+
+Historical PNGs, result JSONs, captions and conflict observer assertions remain
+byte-for-byte unchanged; refreshed hashes alone are not browser acceptance
+evidence. Hosted Admin must rerun #170 in both browsers against the exact PR
+head before acceptance.
diff --git a/docs/review/170/proof.json b/docs/review/170/proof.json
index 2aab69a..9946d7d 100644
--- a/docs/review/170/proof.json
+++ b/docs/review/170/proof.json
@@ -2,9 +2,9 @@
"schema": 1,
"base_main": "bd66a79",
"source_hashes": {
- "class-free-admin.php": "438e3c9632fb8decb0532743850870d70c4b9b04a5f9abcd8c901b61b2d866b9",
- "free-selection.js": "01a855e15865aca82f746f2802eb738b0b32694d370d05d2429fad2f7adbd854",
- "free-selection.css": "1b57bf6b002995be559615af213ec51098c2d46081308c72145ddd42d5eb0668"
+ "class-free-admin.php": "85ec78ba49b79727d113ffc6f7ee720163691e16ca7b12f605ab4d498cb78b8e",
+ "free-selection.js": "19f60a7914b3b845e5adb9585f4b347e93e61d1ccd44d1e04b7601501e32e28e",
+ "free-selection.css": "77673d2620b0fea759ef4ffa168f175a8ff1ead605cf6cac96a76ac4f155b199"
},
"screenshots": [
{
diff --git a/docs/review/pr-284-shots/README.md b/docs/review/pr-284-shots/README.md
new file mode 100644
index 0000000..9cfdd72
--- /dev/null
+++ b/docs/review/pr-284-shots/README.md
@@ -0,0 +1,106 @@
+# PR #284 — Review screenshots (batch 273–278)
+
+English admin, USD, no customer data. All PNGs < 1 MB. Fixtures live in `fixtures/` (static HTML + CSS, headless Chromium via `playwright-core` + `/usr/bin/google-chrome`). Capture script: `/tmp/capture-pr284.cjs` (reuses pattern from `/tmp/wl273verify/verify.cjs`).
+
+## Source recovery
+Copied verified shots without re-encoding (byte-identical to source) and renamed with a `pr284-` prefix so the new `pr-284-shots/` directory does not collide with git-tracked `docs/review/169..256`:
+
+- `pr284-table-*-top/stuck.png` (10 files) ← `/tmp/wl273verify/shot-*.png` (43K–144K)
+- `pr284-wl275-*.png` (6 files) ← `/tmp/opencode/wl275-shots/*.png` (53K–157K)
+
+Additional fixtures in this directory (`fixtures/fixture-*.html`) were rendered headlessly to cover filters / progress / chips.
+
+## Tables — Preview / Results / History at 1440 / 1024 / 782 / 375 + 200% zoom
+
+Fixture: `/tmp/wl273verify/fixture.html` (40 KB) — same fixture used by `/tmp/wl273verify/verify.cjs`, built by `/tmp/wl273verify/make-fixture.cjs`. It renders three tables on one page:
+
+- **Preview** — 5 columns (`Product | Regular before→after | Delta | Shoppers would pay after Apply | What will happen`), 40 rows including long-name wrapping, `Café 日本 '})], ...overrides};
+ return {status: 'OK', label: 'In progress', summary: '1 changed · 2 remaining', notice: '', undo_label: 'Undo unavailable', undo_summary: '0 restored', undo_notice: '', next_url: '/next', resume_available: true, undo_available: false, resume_nonce: 'bound-resume', undo_nonce: null, undo_button: 'Continue Undo', poll: true, progress_total: 2, progress_processed: 1, undo_counts: {eligible: 3, pending: 2, applying: 0, undone: 1, conflict: 0, failed: 0, needs_review: 0}, rows: [row(4, {name: ''})], ...overrides};
}
async function respond(h, index, data) { h.requests[index].resolve({ok: true, status: 200, json: async () => ({success: true, data})}); await flush(); }
(async () => {
@@ -107,8 +107,18 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st
await respond(h, 0, snapshot());
assert.equal(h.nodes['[data-progress-summary]'].textContent, '1 changed · 2 remaining');
assert.equal(h.body.children[0].children[0].textContent, '');
- assert.equal(h.body.children[0].children[2].textContent, 'Reload to read the current price');
+ assert.equal(h.body.children[0].children[2].textContent, 'Refresh to read the current price');
assert.match(h.nodes['[data-progress-connection]'].textContent, /Last successful refresh/);
+ // #276 slim bars: the two additive Apply ints drive width and aria-valuenow;
+ // Undo derives its totals from the existing undo_counts; text stays the truth.
+ assert.equal(h.nodes['[data-progress-bar]'].hidden, false);
+ assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuemax'), '2');
+ assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '1');
+ assert.equal(h.nodes['[data-progress-fill]'].style.width, '50%');
+ assert.equal(h.nodes['[data-progress-undo-bar]'].hidden, false);
+ assert.equal(h.nodes['[data-progress-undo-bar]'].getAttribute('aria-valuemax'), '3');
+ assert.equal(h.nodes['[data-progress-undo-bar]'].getAttribute('aria-valuenow'), '1');
+ assert.equal(h.nodes['[data-progress-undo-fill]'].style.width, '33%');
const button = h.nodes['[data-progress-resume]'].querySelector('button');
assert(button); assert.equal(button.type, 'submit');
const form = h.nodes['[data-progress-resume]'].children[0]; assert.equal(form.action, '/post');
@@ -126,6 +136,19 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st
assert.equal(h.body.children[0].children[4].className, 'writeleash-attention'); assert.equal([...h.timers.values()].filter(x => x.delay === 5000).length, 0);
assert.match(h.nodes['[data-progress-announcement]'].textContent, /1 uncertain/);
assert.match(h.nodes['[data-progress-connection]'].textContent, /Automatic updates stopped; refresh manually/);
+ // #276 bar lifecycle: aria-valuenow follows polling to finished, and
+ // unknown or zero totals hide the track instead of guessing.
+ h = harness(); await respond(h, 0, snapshot({progress_total: 4, progress_processed: 1}));
+ assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '1');
+ assert.equal(h.nodes['[data-progress-fill]'].style.width, '25%');
+ h.timer(5000); await respond(h, 1, snapshot({progress_total: 4, progress_processed: 4}));
+ assert.equal(h.nodes['[data-progress-bar]'].hidden, false);
+ assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuemax'), '4');
+ assert.equal(h.nodes['[data-progress-bar]'].getAttribute('aria-valuenow'), '4');
+ assert.equal(h.nodes['[data-progress-fill]'].style.width, '100%');
+ h.timer(5000); await respond(h, 2, snapshot({progress_total: 0, progress_processed: 0, undo_counts: {}}));
+ assert.equal(h.nodes['[data-progress-bar]'].hidden, true);
+ assert.equal(h.nodes['[data-progress-undo-bar]'].hidden, true);
// A focused outcome cell keeps its previous saved text and gets an explicit stale-detail message.
h = harness(); await respond(h, 0, snapshot());
const cell = h.body.children[0].children[4], focusedSummary = new Element('SUMMARY'); cell.appendChild(focusedSummary); h.document.activeElement = focusedSummary;
@@ -216,9 +239,9 @@ async function respond(h, index, data) { h.requests[index].resolve({ok: true, st
await respond(h, 1, snapshot({label: 'New observation'})); await respond(h, 0, snapshot({label: 'Old delayed observation'})); assert.equal(h.nodes['[data-progress-label]'].textContent, 'New observation');
// Session refusal stops polling; repeated transport errors back off then stop.
h = harness(); h.requests[0].resolve({ok: false, status: 403}); await flush();
- assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Reload and sign in/); assert.equal(h.timers.size, 0);
+ assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Sign in again/); assert.equal(h.timers.size, 0);
h = harness(); h.requests[0].resolve({ok: false, status: 401}); await flush();
- assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Reload and sign in/); assert.equal(h.timers.size, 0);
+ assert.match(h.nodes['[data-progress-connection]'].textContent, /expired.*Sign in again/); assert.equal(h.timers.size, 0);
h = harness();
for (let i = 0; i < 4; i++) { h.requests[i].reject(new Error('network failed')); await flush(); if (i === 0) { assert.match(h.nodes['[data-progress-connection]'].textContent, /retrying shortly/); } if (i < 3) { h.timer([10000, 20000, 40000][i]); } }
assert.match(h.nodes['[data-progress-connection]'].textContent, /stopped after repeated errors/); assert.equal(h.timers.size, 0);
diff --git a/wordpress/tests/admin/progress-integration.php b/wordpress/tests/admin/progress-integration.php
index 69b85d6..8e9db41 100644
--- a/wordpress/tests/admin/progress-integration.php
+++ b/wordpress/tests/admin/progress-integration.php
@@ -51,11 +51,17 @@ function wl204_read( array $post ): array {
$user = wp_get_current_user(); $user->add_cap( 'manage_woocommerce', false );
wl204_assert( 'FORBIDDEN' === wl204_read( $post )['status'], 'revoked capability refused' ); $user->remove_cap( 'manage_woocommerce' );
wl204_assert( 'OK' === Free_Admin::process_approve( array( 'job' => $job['public_id'], '_wpnonce' => wp_create_nonce( Free_Admin::ACTION_APPROVE . '_' . $job['plan_id'] ) ), 'POST' )['status'], 'exact approval accepted' );
+wl204_assert( str_contains( Free_Admin::reason_message( 'approved' ), 'Progress updates automatically' ), 'approved notice promises automatic updates' );
$before = wl204_read( wl204_post( $job ) );
Job_Worker::run( (int) $job['id'], array( 'max_items' => 1, 'budget_seconds' => 20 ), true );
$partial = wl204_read( wl204_post( $job ) );
wl204_assert( $partial['counts']['applied'] > $before['counts']['applied'] && $partial['counts']['pending'] > 0, 'Apply advances from real saved worker outcomes' );
wl204_assert( $partial['counts'] === Job_Repository::counts( (int) $job['id'] ), 'polled Apply counters equal durable rows' );
+// #276 slim bar: two additive ints mirror the durable counters; no existing field renamed.
+wl204_assert( is_int( $partial['progress_total'] ) && is_int( $partial['progress_processed'] ), 'bar ints are integers' );
+wl204_assert( $partial['progress_total'] === $partial['counts']['planned'], 'bar total mirrors durable planned' );
+wl204_assert( $partial['progress_processed'] === $partial['counts']['planned'] - $partial['counts']['pending'] - $partial['counts']['applying'], 'bar processed mirrors durable counters' );
+wl204_assert( $partial['progress_processed'] > 0 && $partial['progress_processed'] < $partial['progress_total'], 'mid-job bar is partial' );
// A newer supported edit is never described as success.
$product = wc_get_product( $ids[2] ); $product->set_regular_price( '99.00' ); $product->save();
for ( $n = 0; $n < 5; ++$n ) { Job_Worker::run( (int) $job['id'], array( 'max_items' => 3, 'budget_seconds' => 20 ), true ); }
@@ -64,6 +70,12 @@ function wl204_read( array $post ): array {
ob_start(); Free_Admin::render_view( 'job', $job['public_id'], 0 ); $html = ob_get_clean();
wl204_assert( str_contains( $html, 'Refresh saved progress' ) && str_contains( $html, 'Automatic updates require JavaScript' ), 'manual/no-JS refresh visible' );
wl204_assert( ! str_contains( $html, 'value="' . Free_Admin::ACTION_RESUME . '"' ), 'terminal no-JS view has no Resume form' );
+// #276 slim bars server-render from saved counters for Apply and Undo, with
+// value semantics for assistive technology; no Reload copy remains in view.
+wl204_assert( str_contains( $html, 'role="progressbar"' ) && str_contains( $html, 'data-progress-bar' ) && str_contains( $html, 'data-progress-undo-bar' ), 'Apply and Undo bars server-rendered' );
+wl204_assert( str_contains( $html, 'aria-valuemin="0"' ) && str_contains( $html, 'aria-valuenow' ) && str_contains( $html, 'aria-valuemax' ), 'bars carry value semantics' );
+wl204_assert( str_contains( $html, 'Apply progress' ) && str_contains( $html, 'Undo progress' ), 'bars carry labels' );
+wl204_assert( ! str_contains( $html, 'Reload' ), 'progress view carries no Reload copy' );
$started = Undo_Repository::initiate( (int) $job['id'], $actor );
$operation = Undo_Repository::read_operation_by_job( (int) $job['id'] );
$undo_before = wl204_read( wl204_post( $job ) );
@@ -73,6 +85,7 @@ function wl204_read( array $post ): array {
wl204_assert( $undo_partial['poll'], 'unfinished Undo continues polling after terminal Apply' );
for ( $n = 0; $n < 5; ++$n ) { Undo_Worker::run( (int) $operation['id'], array( 'max_items' => 3, 'budget_seconds' => 20 ), true ); }
$undo_terminal = wl204_read( wl204_post( $job ) ); wl204_assert( ! $undo_terminal['poll'] && ! $undo_terminal['undo_available'], 'terminal Undo stops polling and hides action' );
+wl204_assert( $undo_terminal['undo_counts']['eligible'] > 0 && 0 === $undo_terminal['undo_counts']['pending'] + $undo_terminal['undo_counts']['applying'], 'terminal Undo counters fully processed for the bar' );
// Missing item evidence must fail visibly without certifying successful completion.
global $wpdb;
$table = Job_Schema::items_table( $wpdb );
diff --git a/wordpress/tests/admin/progress-unit.php b/wordpress/tests/admin/progress-unit.php
index 06ddc94..67ee91f 100644
--- a/wordpress/tests/admin/progress-unit.php
+++ b/wordpress/tests/admin/progress-unit.php
@@ -108,6 +108,19 @@ function refused204() { return array( 'status' => 'FORBIDDEN', 'reason' => 'prog
$result = Admin::progress_snapshot( $post, 'POST' );
check204( $result['status'], 'OK', 'saved running fixture' ); check204( $result['poll'], true, 'running Apply polls' ); check204( $result['rows'][0]['apply_attention'], true, 'conflict not success' );
check204( $result['counts'], Jobs::$counts, 'durable counters authority' );
+ // #276 slim bar: exactly two additive ints ride the payload; every
+ // existing field keeps its name and meaning for older clients.
+ check204( array_key_exists( 'progress_total', $result ) && array_key_exists( 'progress_processed', $result ), true, 'two additive bar ints present' );
+ check204( is_int( $result['progress_total'] ) && is_int( $result['progress_processed'] ), true, 'bar ints are integers' );
+ check204( $result['progress_total'], 2, 'Apply planned total' );
+ check204( $result['progress_processed'], 1, 'Apply processed total' );
+ check204( Admin::progress_bar_values( array( 'planned' => 4, 'pending' => 1, 'applying' => 1 ) ), array( 4, 2 ), 'bar mid-job values' );
+ check204( Admin::progress_bar_values( array( 'planned' => 2, 'pending' => 0, 'applying' => 0 ) ), array( 2, 2 ), 'bar finished values' );
+ check204( Admin::progress_bar_values( array( 'planned' => 0 ) ), array( 0, 0 ), 'bar zero total hides' );
+ check204( Admin::progress_bar_values( array() ), array( 0, 0 ), 'bar unknown total hides' );
+ check204( Admin::undo_progress_bar_values( array( 'eligible' => 3, 'pending' => 2, 'applying' => 0, 'undone' => 1, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ) ), array( 3, 1 ), 'undo bar mid-job values' );
+ check204( Admin::undo_progress_bar_values( array( 'eligible' => 2, 'pending' => 0, 'applying' => 0, 'undone' => 2, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ) ), array( 2, 2 ), 'undo bar finished values' );
+ check204( Admin::undo_progress_bar_values( array() ), array( 0, 0 ), 'undo bar unknown total hides' );
// Round-4 defect: the polled row text must carry the exact conflict guidance
// the server-rendered table shows, never the short reason-message variant.
$long_apply = 'This product’s price or other conditions changed after you reviewed the preview. WriteLeash left the newer value unchanged.';
@@ -144,7 +157,7 @@ function refused204() { return array( 'status' => 'FORBIDDEN', 'reason' => 'prog
check204( Admin::progress_snapshot( array_merge( $post, array( 'offset' => '1000' ) ), 'POST' )['status'], 'OK', 'offset at the evidence bound accepted' );
Jobs::$stalled = true; $stalled = Admin::progress_snapshot( $post, 'POST' ); check204( $stalled['poll'], false, 'stalled stops' ); check204( str_contains( $stalled['notice'], 'stopped' ), true, 'stalled honest' );
Jobs::$stalled = false; Jobs::$state = 'COMPLETED'; Jobs::$counts['pending'] = 0; Jobs::$counts['applied'] = 2;
- $terminal = Admin::progress_snapshot( $post, 'POST' ); check204( $terminal['poll'], false, 'terminal Apply stops' ); check204( $terminal['resume_available'], false, 'terminal cannot resume' ); check204( $terminal['resume_nonce'], null, 'terminal cannot obtain Resume nonce' ); check204( $terminal['undo_available'], true, 'eligible Undo becomes available' );
+ $terminal = Admin::progress_snapshot( $post, 'POST' ); check204( $terminal['poll'], false, 'terminal Apply stops' ); check204( $terminal['resume_available'], false, 'terminal cannot resume' ); check204( $terminal['resume_nonce'], null, 'terminal cannot obtain Resume nonce' ); check204( $terminal['undo_available'], true, 'eligible Undo becomes available' ); check204( array( $terminal['progress_total'], $terminal['progress_processed'] ), array( 2, 2 ), 'terminal bar reads full' );
Undo::$state = 'UNDO_RUNNING'; $running = Admin::progress_snapshot( $post, 'POST' ); check204( $running['poll'], true, 'running Undo after Apply polls' ); check204( $running['undo_counts']['undone'], 1, 'Undo durable counts' );
Undo::$stalled = true; check204( Admin::progress_snapshot( $post, 'POST' )['poll'], false, 'stalled Undo stops' ); Undo::$stalled = false;
Jobs::$counts['planned'] = 1; $missing = Admin::progress_snapshot( $post, 'POST' ); check204( $missing['poll'], false, 'missing Apply evidence blocks Undo polling too' ); check204( $missing['undo_available'], false, 'missing blocks Undo action' ); check204( $missing['resume_nonce'], null, 'missing blocks Resume nonce' ); check204( $missing['undo_nonce'], null, 'missing blocks Undo nonce' ); check204( str_contains( $missing['summary'], 'unavailable' ), true, 'missing evidence visibly incomplete' );
diff --git a/wordpress/tests/admin/recovery-browser.cjs b/wordpress/tests/admin/recovery-browser.cjs
index e0d7a1b..6d5b623 100644
--- a/wordpress/tests/admin/recovery-browser.cjs
+++ b/wordpress/tests/admin/recovery-browser.cjs
@@ -71,9 +71,9 @@ function observe(mode = 'observe') {
const plan = JSON.parse(observed.jobs[1].plan_json);
eq(plan.items[0].planned_regular_price, '132.00', 'current price arithmetic'); eq(plan.source_job, f.job, 'provenance');
eq(plan.resolved_product_ids, [f.id], 'exact chosen population'); eq(plan.policy_snapshot.block_zero, false, 'edited safety setting frozen');
- eq(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count(), 1, 'normal explicit approval');
+ eq(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count(), 2, 'normal explicit approval');
if (process.env.WL167_EVIDENCE) await page.screenshot({ path: process.env.WL167_EVIDENCE + '/205-preview-' + js + '.png', fullPage: true });
- await Promise.all([page.waitForURL('**wl_view=job**'), page.getByRole('button', { name: 'Approve and apply', exact: true }).click()]);
+ await Promise.all([page.waitForURL('**wl_view=job**'), page.getByRole('button', { name: 'Approve and apply', exact: true }).first().click()]);
const applied = observe('run'); eq(applied.price, '132', 'normal Apply'); eq(applied.source, original.source, 'source unchanged after Apply');
await page.reload(); eq(await page.locator('tr[data-product-id="' + f.id + '"] td').nth(4).locator('strong').innerText(), 'Changed', 'result visible');
await context.close();
diff --git a/wordpress/tests/admin/regression-browser.cjs b/wordpress/tests/admin/regression-browser.cjs
index bd81116..77b6ae8 100644
--- a/wordpress/tests/admin/regression-browser.cjs
+++ b/wordpress/tests/admin/regression-browser.cjs
@@ -75,7 +75,9 @@ async function search(page, term) {
}
}
async function form(page, action) {
- return page.locator('form').filter({ has: page.locator('input[name="action"][value="' + action + '"]') }).evaluate(el => Object.fromEntries(new FormData(el)));
+ // #275 polish renders Approve top+bottom (two byte-identical forms, same POST);
+ // disambiguate to first — both carry the same action/job/nonce fields.
+ return page.locator('form').filter({ has: page.locator('input[name="action"][value="' + action + '"]') }).first().evaluate(el => Object.fromEntries(new FormData(el)));
}
async function scan(page, name) {
await page.addScriptTag({ path: require.resolve('axe-core/axe.min.js') });
@@ -240,7 +242,7 @@ async function responsive(page) {
await route.fetch({ maxRedirects: 0 }); // Real server approval commits; browser receives no response.
await route.abort('failed'); approvalForwarded();
});
- await tabTo(page, button(page, 'Approve and apply')); await page.keyboard.press('Enter');
+ await tabTo(page, button(page, 'Approve and apply').first()); await page.keyboard.press('Enter');
await forwarded;
await page.unroute('**/admin-post.php');
const jobURL = home + '&wl_view=job&wl_job=' + publicId;
diff --git a/wordpress/tests/admin/selection-browser.cjs b/wordpress/tests/admin/selection-browser.cjs
index b57bc5d..53db1d2 100644
--- a/wordpress/tests/admin/selection-browser.cjs
+++ b/wordpress/tests/admin/selection-browser.cjs
@@ -37,9 +37,10 @@ async function action(page, name) {
// Server-side form submission; the heavy multi-engine suite can queue the
// request behind other work, so allow a longer budget than the default.
try {
+ // #275 polish batch renders Approve top+bottom (both required no-JS); disambiguate to first when duplicated.
await Promise.all([
page.waitForEvent('framenavigated', { predicate: frame => frame === page.mainFrame(), timeout: 60000 }),
- page.getByRole('button', { name, exact: true }).click()
+ (name === 'Approve and apply' ? page.getByRole('button', { name, exact: true }).first() : page.getByRole('button', { name, exact: true })).click()
]);
} catch (error) {
await capture(page, 'action-timeout-' + name.replace(/[^a-z0-9]+/gi, '-').toLowerCase());
@@ -81,7 +82,7 @@ async function presets230(page, suffix) {
await action(page, 'Load preset');
await page.locator('#writeleash-free-amount').fill('70');
await action(page, 'Preview price changes');
- ok(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count() === 1, '#230 fresh Preview needs independent approval ' + suffix);
+ ok(await page.getByRole('button', { name: 'Approve and apply', exact: true }).count() === 2, '#230 fresh Preview needs independent approval (top + bottom) ' + suffix);
ok(observe().saves === before.saves, '#230 Preview still performs no Woo saves ' + suffix);
const preview = observe().jobs[0];
assert.deepEqual(JSON.parse(preview.plan_json).resolved_product_ids, expected, '#230 persisted range freezes exact current membership');
diff --git a/wordpress/tests/admin/selection-http.php b/wordpress/tests/admin/selection-http.php
index 9407a31..f6b10fc 100644
--- a/wordpress/tests/admin/selection-http.php
+++ b/wordpress/tests/admin/selection-http.php
@@ -37,8 +37,9 @@
$http_original_rows167 = $http_rows167( $http_saved167['body'] );
beq( count( $http_original_rows167 ), 2, 'saved picker preview renders two product rows' );
foreach ( $http_original_rows167 as $row ) {
- beq( $row[1], '$100.00 USD', 'saved expected price after prior Undo' );
- beq( $row[2], '$80.00 USD', 'saved absolute target' );
+ // #273 polish merges Preview before→after into one cell; Delta/Change % moved to the next cell.
+ beq( $row[1], '$100.00 USD→ $80.00 USD', 'saved before-to-after merged cell after prior Undo' );
+ bok( str_contains( $row[1], '$80.00 USD' ), 'saved absolute target in merged cell' );
}
login_session();
$http_recent167 = admin_get( $bulk_url );
diff --git a/wordpress/writeleash/includes/free/class-free-admin.php b/wordpress/writeleash/includes/free/class-free-admin.php
index eb28131..e845972 100644
--- a/wordpress/writeleash/includes/free/class-free-admin.php
+++ b/wordpress/writeleash/includes/free/class-free-admin.php
@@ -299,6 +299,7 @@ public static function progress_snapshot( array $post, string $method ): array {
$active = $complete && ! $observed['stalled'] && in_array( $effective, array( Job_State::READY, Job_State::QUEUED, Job_State::RUNNING ), true );
$undo_active = $complete && null !== $undo_observed && ! $undo_observed['stalled'] && Undo_State::can_auto_run( $undo['operation_status'] );
$undo_summary = self::undo_summary( $undo );
+ list( $progress_total, $progress_processed ) = self::progress_bar_values( $counts );
return array(
'status' => 'OK', 'counts' => $counts, 'undo_counts' => $undo,
'label' => $complete ? self::job_label( $effective ) : __( 'Saved product outcomes unavailable; needs checking', 'writeleash' ),
@@ -311,6 +312,7 @@ public static function progress_snapshot( array $post, string $method ): array {
'resume_nonce' => $complete && Job_State::can_manual_run( $job['status'] ) ? wp_create_nonce( self::ACTION_RESUME . '_' . $public_id ) : null,
'undo_nonce' => $complete && $history['undo_eligible'] && ( empty( $undo['operation_status'] ) || Undo_State::can_manual_run( $undo['operation_status'] ) ) ? wp_create_nonce( self::ACTION_UNDO . '_' . $public_id ) : null,
'undo_button' => empty( $undo['operation_status'] ) ? __( 'Restore eligible prices (Undo)', 'writeleash' ) : __( 'Continue Undo', 'writeleash' ),
+ 'progress_total' => $progress_total, 'progress_processed' => $progress_processed,
'poll' => $active || $undo_active, 'rows' => $rows, 'next_url' => null === $page['next_offset'] ? null : self::page_url( 'job', $public_id, (int) $page['next_offset'], $filter ),
);
} catch ( \Throwable $error ) { return array( 'status' => 'UNAVAILABLE', 'reason' => 'saved_progress_unavailable' ); }
@@ -1325,13 +1327,13 @@ public static function reason_message( string $reason, ?int $selected = null ):
'invalid_preset_name' => __( 'Use a nonempty preset name of at most 100 bytes without markup or control characters.', 'writeleash' ),
'preset_limit' => __( 'This store has reached its limit of 20 presets. Delete a preset before saving another.', 'writeleash' ),
'preset_unavailable' => __( 'This preset is unavailable to your account.', 'writeleash' ),
- 'preset_changed' => __( 'This preset changed during your request. Reload and try again.', 'writeleash' ),
+ 'preset_changed' => __( 'This preset changed during your request. Refresh and try again.', 'writeleash' ),
'preset_saved' => __( 'Preset saved. No prices changed; prepare a fresh Preview before applying.', 'writeleash' ),
'preset_loaded' => __( 'Preset loaded into the editable form. Check the selection, prepare a fresh Preview and approve independently. No prices changed.', 'writeleash' ),
'preset_references_changed' => __( 'Some saved product, SKU or category references are missing or unavailable. Exact saved inputs are retained. Review them before a fresh Preview, which checks current eligibility, permissions and limits. No prices changed.', 'writeleash' ),
'preset_updated' => __( 'Preset updated. Existing previews, jobs, History and Undo are unchanged.', 'writeleash' ),
'post_required' => __( 'This action requires an authenticated POST request.', 'writeleash' ),
- 'invalid_nonce' => __( 'The security token is missing or invalid. Reload the page and try again.', 'writeleash' ),
+ 'invalid_nonce' => __( 'The security token is missing or invalid. Refresh the page and try again.', 'writeleash' ),
'woocommerce_unavailable' => __( 'WooCommerce must be active and initialized before bulk-price planning. Install and activate WooCommerce, then reopen this page. No job was created and no product was changed.', 'writeleash' ),
'permission_denied' => __( 'You do not have permission to plan these product edits.', 'writeleash' ),
'permission_revoked' => __( 'The approved actor can no longer edit one or more products; nothing was executed.', 'writeleash' ),
@@ -1361,7 +1363,7 @@ public static function reason_message( string $reason, ?int $selected = null ):
'job_terminal' => __( 'This job has already finished.', 'writeleash' ),
'job_not_resumable' => __( 'This job cannot be continued from its current state.', 'writeleash' ),
'job_not_approvable' => __( 'This job cannot be approved in its current state.', 'writeleash' ),
- 'approved' => __( 'Approved. WriteLeash will apply the reviewed prices in the background. Reload this page to check progress.', 'writeleash' ),
+ 'approved' => __( 'Approved. WriteLeash will apply the reviewed prices in the background. Progress updates automatically.', 'writeleash' ),
'approved_scheduler_unavailable' => __( 'Approved, but background processing could not start. This job is paused; use Resume remaining products to continue it.', 'writeleash' ),
'resume_chunk' => __( 'Results updated after this step. Check changes, remaining products and anything needing attention below.', 'writeleash' ),
'undo_chunk' => __( 'Undo results updated. Check restorations, remaining products and anything needing attention below.', 'writeleash' ),
@@ -1500,6 +1502,27 @@ public static function undo_summary( array $undo ): string {
return implode( ' · ', $parts );
}
+ /** Presentation-only Apply bar values from saved counters; hidden on unknown/zero totals. */
+ public static function progress_bar_values( array $counts ): array {
+ $total = (int) ( $counts['planned'] ?? 0 );
+ $processed = $total - (int) ( $counts['pending'] ?? 0 ) - (int) ( $counts['applying'] ?? 0 );
+ if ( $processed < 0 ) { $processed = 0; }
+ if ( $processed > $total ) { $processed = $total; }
+ return array( $total, $processed );
+ }
+
+ /** Presentation-only Undo bar values from saved counters; hidden on unknown/zero totals. */
+ public static function undo_progress_bar_values( array $undo ): array {
+ $total = (int) ( $undo['eligible'] ?? 0 );
+ if ( $total < 1 ) {
+ $total = (int) ( $undo['undone'] ?? 0 ) + (int) ( $undo['pending'] ?? 0 ) + (int) ( $undo['applying'] ?? 0 ) + (int) ( $undo['conflict'] ?? 0 ) + (int) ( $undo['failed'] ?? 0 ) + (int) ( $undo['needs_review'] ?? 0 );
+ }
+ $processed = $total - (int) ( $undo['pending'] ?? 0 ) - (int) ( $undo['applying'] ?? 0 );
+ if ( $processed < 0 ) { $processed = 0; }
+ if ( $processed > $total ) { $processed = $total; }
+ return array( $total, $processed );
+ }
+
public static function selection_count_message( array $count ): string {
$range = ( isset( $count['range'] ) && is_array( $count['range'] ) && ! empty( $count['range']['enabled'] ) ) ? $count['range'] : null;
$excluded = (int) ( $count['excluded_by_range'] ?? 0 );
@@ -1518,7 +1541,8 @@ public static function selection_count_message( array $count ): string {
}
private static function support_details( string $text ): void {
- echo ' ' . esc_html( $text ) . ' ' . esc_html( $text ) . '' . esc_html__( 'Support details', 'writeleash' ) . '
' . esc_html__( 'Details', 'writeleash' ) . '
'; - echo esc_html( __( 'Preview bulk regular or sale prices before they happen. Run them safely. If the price or a checked setting changed after review, WriteLeash leaves the newer value alone. Undo changes that are still safe to restore.', 'writeleash' ) ); - echo '
'; - echo ''; - echo esc_html( __( 'Change the stored price of published simple products and variations of published variable products in the base store currency. Variations can be selected as a whole product or individually. Sale dates, stock, orders and subscriptions are not changed.', 'writeleash' ) ); - echo '
'; - echo ''; - - echo esc_html( sprintf( /* translators: %d: maximum selected products per new job. */ __( 'Up to %d selected products per new job in the tested configuration. Planned prices are not guaranteed shopper prices: taxes, currency settings, dynamic pricing and later edits still apply. You can close this page at any time; saved progress appears again when you return.', 'writeleash' ), Free_Support_Contract::MAX_JOB_PRODUCTS ) ); + echo esc_html( __( 'Preview bulk regular or sale prices safely. No prices change until you approve.', 'writeleash' ) ); echo '
'; + echo '' . esc_html( __( 'Your role can view this page but cannot preview or apply bulk price changes.', 'writeleash' ) ) . '
' . esc_html__( 'Check the selected products below. Preview checks their eligibility before any prices change.', 'writeleash' ) . '
'; echo '' . esc_html( __( 'History is unavailable right now. Reload the page to try again.', 'writeleash' ) ) . '
'; + echo '' . esc_html( __( 'History is unavailable right now. Refresh the page to try again.', 'writeleash' ) ) . '
'; return; } self::render_history_table( $page['jobs'] ); @@ -2218,6 +2247,20 @@ private static function load_job_for_view( string $public_id ): ?array { return $job; } + /** + * Single approve-form renderer for the Preview view. The top summary + * panel and the bottom approval block call this same renderer, so both + * forms carry the same action, job field and plan nonce. Presentation + * only; no contract change. + */ + private static function render_approve_form( array $job ): void { + echo ''; + } + private static function render_preview_view( string $public_id, int $offset ): void { if ( ! self::jobs_installed() ) { echo '' . esc_html( __( 'No preview is visible to your account for that identifier.', 'writeleash' ) ) . '
' . esc_html( sprintf( /* translators: 1: selected products, 2: translated outcome counts. */ _n( 'Selected %1$d product: %2$s.', 'Selected %1$d products: %2$s.', $summary['selected'], 'writeleash' ), $summary['selected'], implode( ' · ', array( self::count_copy( 'changing', $summary['changing'] ), self::count_copy( 'unchanged', $summary['unchanged'] ), self::count_copy( 'skipped', $summary['unsupported'] ) ) ) ) ) . '
'; $extra_counts = self::preview_extra_counts( $data['items'] ); - echo '' . esc_html( implode( ' · ', array( self::count_copy( 'large_increase', $extra_counts['large_increase'] ), self::count_copy( 'large_decrease', $extra_counts['large_decrease'] ), self::count_copy( 'zero_target', $extra_counts['zero_target'] ), self::count_copy( 'warning', $summary['warning_items'] ) ) ) . '.' ) . '
' . esc_html( implode( ' · ', array( self::count_copy( 'large_increase', $extra_counts['large_increase'] ), self::count_copy( 'large_decrease', $extra_counts['large_decrease'] ), self::count_copy( 'zero_target', $extra_counts['zero_target'] ), self::count_copy( 'warning', $summary['warning_items'] ) ) ) . '.' ) . '
'; + // Top approve summary: same task description and compact counts as + // above, plus the shopper-price caveat and the same approve form as + // the bottom block, so summary and approval share the first viewport + // without JavaScript. The bottom block stays unchanged. + $approvable = ! $blocked && Job_State::PLANNED === $job['status'] && self::can_mutate(); + if ( $approvable ) { + echo '' . esc_html__( 'Planned prices are not guaranteed shopper prices.', 'writeleash' ) . '
'; + self::render_approve_form( $job ); + } + echo ''; if ( $blocked ) { echo '' . esc_html( __( 'This plan cannot be executed.', 'writeleash' ) ) . ': ' . esc_html__( 'Your safety limits block every changing product. You cannot approve this preview; build a new one with different settings.', 'writeleash' ) . '
'; $identities = array_column( $data['items'], null, 'product_id' ); @@ -2312,7 +2365,7 @@ private static function render_preview_view( string $public_id, int $offset ): v $field_label = Price_Operation::label( $field ); echo '' . esc_html( ( Price_Operation::FIELD_SALE === $field ? sprintf( /* translators: %s: translated price-field name. */ __( 'Price to change: %s. The regular price is the sale baseline and is preserved.', 'writeleash' ), $field_label ) : sprintf( /* translators: %s: translated price-field name. */ __( 'Price to change: %s. The sale price and schedule are preserved.', 'writeleash' ), $field_label ) ) ) . '
'; - echo '| ' . esc_html( __( 'Product', 'writeleash' ) ) . ' | ' . esc_html( sprintf( /* translators: %s: translated price-field name. */ __( '%s before', 'writeleash' ), $field_label ) ) . ' | ' . esc_html( sprintf( /* translators: %s: translated price-field name. */ __( '%s after', 'writeleash' ), $field_label ) ) . ' | ' . esc_html( __( 'Delta', 'writeleash' ) ) . ' | ' . esc_html( __( 'Change %', 'writeleash' ) ) . ' | ' . esc_html( __( 'Shoppers would pay after Apply', 'writeleash' ) ) . ' | ' . esc_html( __( 'What will happen', 'writeleash' ) ) . ' |
|---|
| ' . esc_html( __( 'Product', 'writeleash' ) ) . ' | ' . esc_html( sprintf( /* translators: %s: translated price-field name. */ __( '%s before to after', 'writeleash' ), $field_label ) ) . ' | ' . esc_html( __( 'Delta', 'writeleash' ) ) . ' | ' . esc_html( __( 'Shoppers would pay after Apply', 'writeleash' ) ) . ' | ' . esc_html( __( 'What will happen', 'writeleash' ) ) . ' | |||||
|---|---|---|---|---|---|---|---|---|---|
| '; self::render_identity( $item ); echo ' | '; - echo '' . esc_html( self::money_display( $item['stored_price'], $data['store'], false, $field ) ) . ' | '; - echo '' . esc_html( self::money_display( $item['planned_regular_price'], $data['store'], false, $field ) ) . ' | '; - echo '' . esc_html( self::money_display( $item['absolute_delta'], $data['store'], true ) ) . ' | '; - echo '' . esc_html( $ratio_text ) . ' | '; - echo '' . esc_html( self::money_display( self::effective_shopper_price( $item, $field ), $data['store'] ) ) . ' | '; + echo '' . esc_html( self::money_display( $item['stored_price'], $data['store'], false, $field ) ) . ' ' . esc_html( '→ ' . self::money_display( $item['planned_regular_price'], $data['store'], false, $field ) ) . ' | ';
+ echo '' . esc_html( self::money_display( $item['absolute_delta'], $data['store'], true ) ) . ' ' . esc_html( $ratio_text ) . ' | ';
+ echo '' . esc_html( self::money_display( self::effective_shopper_price( $item, $field ), $data['store'] ) ) . ' | '; echo '' . esc_html( self::item_label( $state, $blocked ? Job_State::BLOCKED : Job_State::PLANNED ) ) . '';
foreach ( $detail as $reason ) { echo ' ' . esc_html( self::reason_message( $reason ) ) . ' '; } self::support_details( $state . ' · ' . implode( ', ', $detail ) ); @@ -2345,15 +2396,11 @@ private static function render_preview_view( string $public_id, int $offset ): v if ( ! isset( $data['source_job'] ) && self::can_mutate() ) { echo '' . esc_html__( 'Exclude individual products', 'writeleash' ) . ' '; } - echo '' . esc_html__( 'Create a new preview', 'writeleash' ) . ' ' . esc_html( __( 'Changing the selection or price settings creates a new preview; this saved preview stays unchanged.', 'writeleash' ) ) . ' '; - if ( ! $blocked && Job_State::PLANNED === $job['status'] && self::can_mutate() ) { + echo '' . esc_html__( 'Create a new preview', 'writeleash' ) . ' ' . esc_html( __( 'Changing settings builds a new preview.', 'writeleash' ) ) . ' '; + if ( $approvable ) { echo '' . esc_html( __( 'Approve this preview', 'writeleash' ) ) . ''; - echo '' . esc_html( __( 'Approving applies exactly the products and prices shown above. Planned prices are not guaranteed shopper prices. If you change any setting, build a new preview.', 'writeleash' ) ) . ' '; - echo ''; + echo '' . esc_html( __( 'Approving applies only the prices shown above. Planned prices are not guaranteed shopper prices.', 'writeleash' ) ) . ' '; + self::render_approve_form( $job ); } elseif ( ! $blocked ) { echo '' . esc_html( sprintf( /* translators: %s: translated job status. */ __( 'Current status: %s.', 'writeleash' ), self::job_label( $job['status'] ) ) . ' ' ) . '' . esc_html( __( 'View progress and results', 'writeleash' ) ) . ' '; @@ -2559,6 +2606,16 @@ private static function product_observation( int $product_id, string $field ): a } catch ( \Throwable $error ) { return $unavailable; } } + /** Slim static progress track. Text counters stay the source of truth; the live region announces them, never this bar. */ + private static function render_progress_bar( int $total, int $processed, string $label, string $bar_hook, string $fill_hook ): void { + if ( $processed < 0 ) { $processed = 0; } + if ( $processed > $total ) { $processed = $total; } + $percent = $total > 0 ? (int) round( $processed * 100 / $total ) : 0; + if ( $percent < 0 ) { $percent = 0; } + if ( $percent > 100 ) { $percent = 100; } + echo '';
+ }
+
private static function render_job_view( string $public_id, int $offset, string $filter = '' ): void {
if ( ! self::jobs_installed() ) {
echo ' ' . esc_html( __( 'No job is visible to your account for that identifier.', 'writeleash' ) ) . ' ' . esc_html( __( 'Saved progress and results are unavailable right now. Reload this page to try again; if it persists, ask an administrator to check the saved records.', 'writeleash' ) ) . ' ' . esc_html( __( 'Saved progress and results are unavailable right now. Refresh this page to try again; if it persists, ask an administrator to check the saved records.', 'writeleash' ) ) . ' ' . esc_html( $complete_evidence ? self::job_label( $effective ) : __( 'Saved product outcomes unavailable; needs checking', 'writeleash' ) ) . ' '; if ( $complete_evidence ) { echo '' . esc_html( self::result_summary( $counts, $effective, true ) ) . ' '; } else { echo '' . esc_html( sprintf( /* translators: %d: planned products. */ _n( 'Planned %d product. Some saved product evidence is unavailable. Check this job before taking further action.', 'Planned %d products. Some saved product evidence is unavailable. Check this job before taking further action.', $selected, 'writeleash' ), $selected ) ) . ' '; } + $apply_bar = $complete_evidence ? self::progress_bar_values( $counts ) : array( 0, 0 ); + self::render_progress_bar( $apply_bar[0], $apply_bar[1], __( 'Apply progress', 'writeleash' ), 'data-progress-bar', 'data-progress-fill' ); if ( in_array( $effective, array( Job_State::PAUSED, Job_State::NEEDS_REVIEW ), true ) ) { echo '' . esc_html( self::reason_message( $observed['effective_reason'] ) ) . ' '; } echo ''; self::support_details( $effective . ' · ' . $observed['effective_reason'] . ' · ' . $job['public_id'] . ' · pending ' . $counts['pending'] . ' · applied ' . $counts['applied'] ); echo ' ';
echo '';
@@ -2609,7 +2668,11 @@ private static function render_job_view( string $public_id, int $offset, string
echo '' . esc_html( self::reason_message( 'supported_job_limit_exceeded', $selected ) ) . ' ';
- if ( $complete_evidence && Job_State::can_manual_run( $job['status'] ) && self::can_mutate() ) {
+ // Single primary per view: Resume is the primary action while it is
+ // available; Undo drops to secondary then and returns to primary only
+ // when Resume is unavailable. Presentation only; same hooks/fields.
+ $resume_available = $complete_evidence && Job_State::can_manual_run( $job['status'] ) && self::can_mutate();
+ if ( $resume_available ) {
if ( in_array( $effective, array( Job_State::READY, Job_State::QUEUED ), true ) && ! $observed['stalled'] ) { echo ' ' . esc_html( __( 'Waiting for background processing. Use Resume remaining products to run the next step now.', 'writeleash' ) ) . ' '; } echo ' |