From 87d3c526e0530cdb5d76ffb7f9ae3fadd8545402 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:11:12 +0000 Subject: [PATCH 1/5] fix: emit token overrides unlayered when serving the flat CSS bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated override CSS was always wrapped in @layer slashed.overrides. That is correct against the layered bundles, where the framework declares every token inside @layer slashed.tokens and reserves slashed.overrides as the last layer — but the flat bundles are the same rules with every @layer stripped, and an unlayered declaration beats any layered one regardless of specificity or source order. With the flat variant enabled, the whole override block was therefore silently inert: every configurator control — colours, gap/gutter, the spacing and typography modular scales, every scale knob — saved fine and changed nothing on the page, while the SPA's own live preview (which injects unlayered :root CSS) kept showing the change. Wrap the block only when the served bundle has layers, and route the same decision through Slashed_CSS_Loader so the Bricks and Gutenberg dark-mode bridges — layered inline CSS with the same defect — follow the bundle too. Verified in a headless browser against the committed dist bundles: before this change all 18 probed control groups came out DEAD on the flat bundle and OK on the layered one; after it, both modes are OK. That measurement is now a committed tool, tests/override-effect-probe.mjs, which asks the real PHP emitter for the CSS a site would serve and diffs every live --sf-* token against the un-overridden page. It carries a must-be-DEAD control case so a noisy measurement can't make the run pass vacuously. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DftXg4tjRkey2c3ybxAWjN --- CLAUDE.md | 24 ++- .../includes/class-css-generator.php | 49 ++++- SLASHED-for-WP/includes/class-css-loader.php | 29 +++ .../bricks/includes/class-enqueue.php | 7 +- .../gutenberg/includes/class-enqueue.php | 7 +- tests-php/CssGeneratorFlatBundleTest.php | 93 +++++++++ tests/override-effect-probe.mjs | 187 ++++++++++++++++++ tests/php-harness/emit-override-css.php | 74 +++++++ 8 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 tests-php/CssGeneratorFlatBundleTest.php create mode 100644 tests/override-effect-probe.mjs create mode 100644 tests/php-harness/emit-override-css.php diff --git a/CLAUDE.md b/CLAUDE.md index 46793ab7..97fa99bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,12 +115,24 @@ release. | `npm run check` | Verify generated artifacts (class hints, variables hints, vendored admin-app core) aren't stale — exits non-zero on drift, never writes. Needs the framework source (sibling checkout, `.framework`, or `SLASHED_FRAMEWORK_DIR`); CI clones it at the pinned `SLASHED_CSS_REF` and runs this as the per-PR drift gate | | `composer phpunit` | Run the PHP unit suite (`tests-php/`) | -`tests/` is `node --test` specs, run automatically by `npm test`, with one -exception: `tests/playwright-admin.js` is a manual, local-only dev/QA tool — -it walks the admin SPA and saves screenshots for a human to review, has no -pass/fail assertions, and isn't wired into `npm test` or CI (no committed -HTML fixture, needs a locally-running dev server). Run it directly with -`node tests/playwright-admin.js`; see the file header for prerequisites. +`tests/` is `node --test` specs, run automatically by `npm test`, with two +exceptions — both manual, local-only dev/QA tools that need a Playwright +browser (not an npm dependency of this repo) and so are wired into neither +`npm test` nor CI. See each file's header for prerequisites. + +- `tests/playwright-admin.js` — walks the admin SPA and saves screenshots for + a human to review. No pass/fail assertions (also needs a locally-running dev + server serving an uncommitted `test-admin.html`). Run: + `node tests/playwright-admin.js`. +- `tests/override-effect-probe.mjs` — answers "which configurator controls + actually change anything on the page?". For each control group it asks the + real PHP emitter (`tests/php-harness/emit-override-css.php`) for the CSS a + site would serve, then diffs every live `--sf-*` token in a headless browser + against the un-overridden page, for both the layered and the flat bundle. A + control group that changes nothing prints `DEAD` and the run exits non-zero. + Reach for this first whenever a configurator control appears to do nothing in + WordPress but works on the standalone configurator. Run: + `node tests/override-effect-probe.mjs`. `tests-php/` is a plain PHPUnit suite (`composer phpunit`, wired into CI's `quality` job) covering pure/near-pure PHP logic that needs no WordPress diff --git a/SLASHED-for-WP/includes/class-css-generator.php b/SLASHED-for-WP/includes/class-css-generator.php index 78cc9b78..6f90a77e 100644 --- a/SLASHED-for-WP/includes/class-css-generator.php +++ b/SLASHED-for-WP/includes/class-css-generator.php @@ -7,6 +7,10 @@ * @layer slashed.overrides { :root { ... } } containing only validated, * non-empty values. Framework defaults are untouched when no override is set. * + * The wrapper follows the bundle actually served: the flat bundles carry no + * @layer at all, so against those the overrides must be emitted unlayered — + * see get_override_css(). + * * @package SLASHED */ @@ -61,11 +65,21 @@ public static function get_override_css() { return self::$cache; } - $css = "@layer slashed.overrides {\n\t:root {\n"; + if ( self::use_cascade_layer() ) { + $open = "@layer slashed.overrides {\n\t:root {\n"; + $indent = "\t\t"; + $close = "\t}\n}"; + } else { + $open = ":root {\n"; + $indent = "\t"; + $close = '}'; + } + + $css = $open; foreach ( $declarations as $declaration ) { - $css .= "\t\t" . $declaration . "\n"; + $css .= $indent . $declaration . "\n"; } - $css .= "\t}\n}"; + $css .= $close; /** @filter slashed/override_css The generated token override CSS string. */ self::$cache = apply_filters( 'slashed/override_css', $css ); @@ -73,6 +87,35 @@ public static function get_override_css() { return self::$cache; } + /** + * Whether the override block should be wrapped in @layer slashed.overrides. + * + * The framework's layered bundles declare every token inside + * @layer slashed.tokens and reserve slashed.overrides as the last layer, so + * wrapping is what lets these declarations win — and keeps them from also + * beating the framework's @media-scoped rules (prefers-reduced-motion + * clamps, colour-scheme defaults), which an unlayered block would. + * + * The flat bundles are the same rules with every @layer stripped. Against + * those, an unlayered framework declaration beats ANY layered one no matter + * the source order, so a wrapped block is silently inert: every token + * override — colours, spacing, the modular scales — stops reaching the page. + * Emit unlayered in that case, matching the bundle Slashed_CSS_Loader + * actually serves. + * + * Slashed_CSS_Loader is absent when an integration plugin runs standalone + * (without slashed.php); it can't serve a flat bundle either, so the + * layered wrapper is the correct default there. + * + * @return bool + */ + private static function use_cascade_layer() { + if ( ! class_exists( 'Slashed_CSS_Loader' ) ) { + return true; + } + return Slashed_CSS_Loader::layers_enabled(); + } + /** * Build declarations from the flat { "--name": "value" } override map the * in-WordPress configurator saves via POST /tokens/overrides. diff --git a/SLASHED-for-WP/includes/class-css-loader.php b/SLASHED-for-WP/includes/class-css-loader.php index c9adb67a..0afa61e4 100644 --- a/SLASHED-for-WP/includes/class-css-loader.php +++ b/SLASHED-for-WP/includes/class-css-loader.php @@ -71,6 +71,35 @@ public static function get_url() { return apply_filters( 'slashed/css_bundle_url', $url ); } + /** + * Whether the served bundle carries @layer, so inline CSS added on top of + * the `slashed-framework` handle should be wrapped in a framework layer. + * + * False when the flat variant is enabled: those bundles are the same rules + * with every @layer stripped, and an unlayered declaration beats ANY layered + * one regardless of specificity or source order. Inline CSS that keeps its + * @layer wrapper is therefore silently inert against a flat bundle — which + * is how token overrides and the builder dark-mode bridges stopped reaching + * the page whenever flat mode was switched on. + * + * @return bool + */ + public static function layers_enabled() { + return ! Slashed_Settings::get_css_flat(); + } + + /** + * Wrap inline CSS in a framework cascade layer, or return it unlayered when + * the flat bundle is being served (see layers_enabled()). + * + * @param string $layer Layer name, e.g. 'slashed.themes'. + * @param string $css Rules to wrap. + * @return string + */ + public static function wrap_layer( $layer, $css ) { + return self::layers_enabled() ? '@layer ' . $layer . '{' . $css . '}' : $css; + } + /** * Derive a cache-busting version string for a resolved CSS URL. * diff --git a/SLASHED-for-WP/integrations/bricks/includes/class-enqueue.php b/SLASHED-for-WP/integrations/bricks/includes/class-enqueue.php index 23782b2f..577db921 100644 --- a/SLASHED-for-WP/integrations/bricks/includes/class-enqueue.php +++ b/SLASHED-for-WP/integrations/bricks/includes/class-enqueue.php @@ -37,9 +37,14 @@ public function enqueue_frontend_styles() { } // Bridge Bricks' dark mode toggle (data-brx-theme attribute) to SLASHED's theme system. + // Layered only when the served bundle has layers — against a flat bundle a + // layered rule can never win, which would leave the bridge inert. + $bridge = '[data-brx-theme="light"]{color-scheme:light;--sf-is-dark:0}[data-brx-theme="dark"]{color-scheme:dark;--sf-is-dark:1}'; wp_add_inline_style( 'slashed-framework', - '@layer slashed.themes{[data-brx-theme="light"]{color-scheme:light;--sf-is-dark:0}[data-brx-theme="dark"]{color-scheme:dark;--sf-is-dark:1}}' + class_exists( 'Slashed_CSS_Loader' ) + ? Slashed_CSS_Loader::wrap_layer( 'slashed.themes', $bridge ) + : '@layer slashed.themes{' . $bridge . '}' ); } diff --git a/SLASHED-for-WP/integrations/gutenberg/includes/class-enqueue.php b/SLASHED-for-WP/integrations/gutenberg/includes/class-enqueue.php index 4cc7d6b4..ed0fd2f9 100644 --- a/SLASHED-for-WP/integrations/gutenberg/includes/class-enqueue.php +++ b/SLASHED-for-WP/integrations/gutenberg/includes/class-enqueue.php @@ -63,9 +63,14 @@ public function enqueue_editor_styles() { // Bridge the Gutenberg dark-mode toggle to SLASHED's theme system. // Attaches to whichever code enqueued the shared handle. if ( wp_style_is( 'slashed-framework', 'enqueued' ) ) { + // Layered only when the served bundle has layers — against a flat bundle + // a layered rule can never win, which would leave the bridge inert. + $bridge = 'html[data-wp-dark-mode-active]{color-scheme:dark;--sf-is-dark:1}'; wp_add_inline_style( 'slashed-framework', - '@layer slashed.themes{html[data-wp-dark-mode-active]{color-scheme:dark;--sf-is-dark:1}}' + class_exists( 'Slashed_CSS_Loader' ) + ? Slashed_CSS_Loader::wrap_layer( 'slashed.themes', $bridge ) + : '@layer slashed.themes{' . $bridge . '}' ); } } diff --git a/tests-php/CssGeneratorFlatBundleTest.php b/tests-php/CssGeneratorFlatBundleTest.php new file mode 100644 index 00000000..a2630f1e --- /dev/null +++ b/tests-php/CssGeneratorFlatBundleTest.php @@ -0,0 +1,93 @@ + $flat ) ); + } + + public function test_layered_bundle_keeps_the_overrides_layer_wrapper() { + $this->set_flat( false ); + Slashed_Token_Store::update_overrides( array( '--sf-color-primary' => '#ff0000' ) ); + + $this->assertSame( + "@layer slashed.overrides {\n\t:root {\n\t\t--sf-color-primary: #ff0000;\n\t}\n}", + Slashed_CSS_Generator::get_override_css() + ); + } + + public function test_flat_bundle_emits_the_same_declarations_unlayered() { + $this->set_flat( true ); + Slashed_Token_Store::update_overrides( array( '--sf-color-primary' => '#ff0000' ) ); + + $this->assertSame( + ":root {\n\t--sf-color-primary: #ff0000;\n}", + Slashed_CSS_Generator::get_override_css() + ); + } + + public function test_flat_bundle_carries_every_declaration_including_derived_ones() { + $this->set_flat( true ); + // --sf-space-ratio-min is a source knob the framework's generative + // clamp()s read; --sf-radius-scale additionally expands to derived + // output tokens. Both must survive the unlayered path intact. + Slashed_Token_Store::update_overrides( + array( + '--sf-space-ratio-min' => '1.618', + '--sf-radius-scale' => '2', + ) + ); + + $css = Slashed_CSS_Generator::get_override_css(); + $this->assertStringNotContainsString( '@layer', $css ); + $this->assertStringContainsString( '--sf-space-ratio-min: 1.618;', $css ); + $this->assertStringContainsString( '--sf-radius-m: 16px;', $css ); + $this->assertStringContainsString( '--sf-radius-scale: 2;', $css ); + } + + public function test_wrap_layer_follows_the_same_switch() { + $this->set_flat( false ); + $this->assertTrue( Slashed_CSS_Loader::layers_enabled() ); + $this->assertSame( + '@layer slashed.themes{:root{--sf-is-dark:1}}', + Slashed_CSS_Loader::wrap_layer( 'slashed.themes', ':root{--sf-is-dark:1}' ) + ); + + $this->set_flat( true ); + $this->assertFalse( Slashed_CSS_Loader::layers_enabled() ); + $this->assertSame( + ':root{--sf-is-dark:1}', + Slashed_CSS_Loader::wrap_layer( 'slashed.themes', ':root{--sf-is-dark:1}' ) + ); + } +} diff --git a/tests/override-effect-probe.mjs b/tests/override-effect-probe.mjs new file mode 100644 index 00000000..25482819 --- /dev/null +++ b/tests/override-effect-probe.mjs @@ -0,0 +1,187 @@ +/** + * Manual, local-only dev/QA tool — NOT part of `npm test` or CI (needs a + * Playwright browser, which this repo doesn't depend on). + * + * Answers the question "which configurator controls actually change anything on + * the page?" mechanically, instead of by clicking through the UI and squinting. + * + * For every case below it: + * 1. asks the real PHP emitter what CSS a site would serve for that override + * map (tests/php-harness/emit-override-css.php — same validation, derived + * token expansion and @layer/unlayered wrapper as production), then + * 2. loads a probe page with a bundle from SLASHED-for-WP/dist/ plus that CSS + * and diffs every live --sf-* token (and a handful of computed properties + * on a real element) against the same page without the override. + * + * A case that changes nothing is reported DEAD: either the control writes a + * token the framework no longer reads, or the emitted CSS can't win the cascade. + * The whole matrix is run against both the layered and the flat bundle, because + * a wrapper/bundle mismatch takes out every control at once — that is how the + * flat-mode regression (layered overrides are inert against a flat bundle) was + * found, and re-running this is how you'd catch it coming back. + * + * Prerequisites (not automated by this script): + * 1. `playwright` installed with a Chromium available — it is not an npm + * dependency of this repo: + * npm install --no-save playwright && npx playwright install chromium + * 2. `php` on PATH. + * 3. SLASHED-for-WP/dist/ populated (it is committed; `npm run sync-dist` + * refreshes it). + * + * Run: node tests/override-effect-probe.mjs + * Exit code is 1 when any case is DEAD, so it can be used as an ad-hoc gate. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; +import { execFileSync } from 'node:child_process'; + +let chromium; +try { + ({ chromium } = await import('playwright')); +} catch { + console.error( + 'playwright not found. This is a manual dev tool (not an npm dependency of\n' + + 'this repo) — install it yourself first, e.g.:\n' + + ' npm install --no-save playwright && npx playwright install chromium' + ); + process.exit(1); +} + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DIST = path.join(ROOT, 'SLASHED-for-WP', 'dist'); +const HARNESS = path.join(import.meta.dirname, 'php-harness', 'emit-override-css.php'); + +// Self-check: a token the framework does not read must come out DEAD. If this +// one ever reports OK the measurement below has gone noisy and every other +// verdict in the run is worthless. +const CONTROL_CASE = 'control: unknown token (must be DEAD)'; + +// One case per configurator control group that has a distinct route to the +// page. Values are deliberately far from the defaults so any real effect shows +// up as a computed-value change. +const CASES = { + [CONTROL_CASE]: { '--sf-definitely-not-a-framework-token': '99px' }, + 'spacing: modular scale (ratio)': { '--sf-space-ratio-min': '1.618', '--sf-space-ratio-max': '1.618' }, + 'spacing: modular scale (base)': { '--sf-space-base-min': '1.5', '--sf-space-base-max': '3' }, + 'spacing: space scale knob': { '--sf-space-scale': '2' }, + 'spacing: gap / gutter': { '--sf-gap': '3rem', '--sf-gutter': '4rem' }, + 'spacing: section scale': { '--sf-section-scale': '2' }, + 'typography: modular scale (ratio)': { '--sf-text-ratio-min': '1.5', '--sf-text-ratio-max': '1.5' }, + 'typography: modular scale (base)': { '--sf-text-base-min': '1.4', '--sf-text-base-max': '1.8' }, + 'typography: text scale knob': { '--sf-text-scale': '1.5' }, + 'typography: display scale': { '--sf-text-display-base-min': '3.5', '--sf-text-display-base-max': '5' }, + 'typography: body font': { '--sf-font-body': 'Georgia, serif' }, + 'fluid: viewport endpoints': { '--sf-fluid-min-vw': '30', '--sf-fluid-max-vw': '70' }, + 'colors: brand source': { '--sf-color-primary-source-light': 'oklch(0.7 0.2 30)' }, + 'contrast: bias': { '--sf-contrast-bias': '0.3' }, + 'borders: radius scale': { '--sf-radius-scale': '3' }, + 'borders: width scale': { '--sf-border-scale': '3' }, + 'shadows: strength': { '--sf-shadow-strength': '2' }, + 'motion: scale': { '--sf-motion-scale': '3' }, + 'components: density': { '--sf-density': '0.5' }, +}; + +const TOKENS = JSON.parse( + fs.readFileSync(path.join(ROOT, 'SLASHED-for-WP', 'data', 'inventory.json'), 'utf8'), +).variables; + +const PROBE_PROPS = [ + 'paddingTop', 'marginTop', 'gap', 'fontSize', 'fontFamily', 'lineHeight', + 'borderRadius', 'borderTopWidth', 'transitionDuration', 'boxShadow', + 'color', 'backgroundColor', 'minHeight', +]; + +function emit(cases, { flat }) { + const args = [HARNESS, ...(flat ? ['--flat'] : [])]; + return JSON.parse(execFileSync('php', args, { input: JSON.stringify(cases), encoding: 'utf8' })); +} + +const server = http + .createServer((req, res) => { + const file = path.join(DIST, path.basename(req.url.split('?')[0])); + try { + res.writeHead(200, { 'Content-Type': 'text/css' }); + res.end(fs.readFileSync(file)); + } catch { + res.writeHead(404); + res.end(); + } + }) + .listen(0); +const { port } = server.address(); + +const browser = await chromium.launch(); +let failures = 0; + +async function measure(page, bundle, overrideCSS) { + await page.setContent( + `` + + (overrideCSS ? `` : '') + + '
', + ); + await page.waitForLoadState('load'); + return page.evaluate( + ({ tokens, props }) => { + const rootStyle = getComputedStyle(document.documentElement); + const out = {}; + for (const token of tokens) out[token] = rootStyle.getPropertyValue(token).trim(); + const probeStyle = getComputedStyle(document.getElementById('probe')); + for (const prop of props) out[`@probe.${prop}`] = probeStyle[prop]; + return out; + }, + { tokens: TOKENS, props: PROBE_PROPS }, + ); +} + +for (const flat of [false, true]) { + const bundle = `slashed.optimal${flat ? '.flat' : ''}.min.css`; + if (!fs.existsSync(path.join(DIST, bundle))) { + console.error(`missing bundle: SLASHED-for-WP/dist/${bundle} — run \`npm run sync-dist\``); + process.exit(1); + } + + const css = emit(CASES, { flat }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + // The framework's transitions would otherwise still be interpolating when the + // probe element is measured (the stylesheet lands after first paint), making + // colour/size readings differ run to run. Reduced motion collapses them. + await page.emulateMedia({ reducedMotion: 'reduce' }); + // Throwaway pass: the very first load fetches the bundle cold, which lands + // after first paint and skews the probe element's computed values. Every + // later pass reads it from cache, so take the baseline from a warm page. + await measure(page, bundle, null); + const baseline = await measure(page, bundle, null); + + console.log(`\n===== ${bundle}${flat ? ' (flat mode: css_flat = true)' : ''}`); + for (const [label, overrideCSS] of Object.entries(css)) { + const now = await measure(page, bundle, overrideCSS); + const changed = Object.keys(baseline).filter((k) => baseline[k] !== now[k]); + const probes = changed.filter((k) => k.startsWith('@probe.')); + // The verdict is the live-token diff: it is exact. The computed properties + // are reported as a hint about what a user would actually see move. + const tokenCount = changed.length - probes.length; + const isControl = label === CONTROL_CASE; + const ok = isControl ? tokenCount === 0 : tokenCount > 0; + if (!ok) failures += 1; + console.log( + `${tokenCount === 0 ? 'DEAD' : 'OK '}${ok ? ' ' : '!'}${label.padEnd(38)}` + + ` tokens=${String(tokenCount).padStart(3)}` + + ` probe=${probes.map((p) => p.slice(7)).join(',') || '-'}`, + ); + } + await page.close(); +} + +server.close(); +await browser.close(); + +if (failures > 0) { + console.error( + `\n${failures} case(s) marked "!" did not behave as expected — a DEAD control` + + ' group means those configurator controls change nothing on the page.', + ); + process.exit(1); +} +console.log('\nEvery control group reached the page in both bundle modes.'); diff --git a/tests/php-harness/emit-override-css.php b/tests/php-harness/emit-override-css.php new file mode 100644 index 00000000..a67d20d9 --- /dev/null +++ b/tests/php-harness/emit-override-css.php @@ -0,0 +1,74 @@ +setAccessible( true ); + +$out = array(); +foreach ( $cases as $label => $overrides ) { + $GLOBALS['slashed_test_options'] = array( Slashed_Settings::OPTION_KEY => array( 'css_flat' => $flat ) ); + $cache->setValue( null, null ); + Slashed_Token_Store::update_overrides( is_array( $overrides ) ? $overrides : array() ); + $out[ $label ] = Slashed_CSS_Generator::get_override_css(); +} + +echo json_encode( $out, JSON_UNESCAPED_SLASHES ); From 0f595c1162b677c3c6515db31d3b49492bf5d2a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:18:46 +0000 Subject: [PATCH 2/5] docs: add an on-page diagnostic for dead configurator controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The override-effect probe proves whether the emitted CSS can move the page at all. When it reports every control group OK and a control still does nothing on a real site, the conflict is in that page's CSS environment — which no amount of reading plugin code will find. Add scripts/diagnose-page-tokens.js, a console snippet that lists every stylesheet, every rule declaring a watched token together with the cascade layer it sits in, and the computed source-knob vs derived-output values at :root. An unlayered concrete --sf-space-* declaration beats every @layer, so it shadows the modular-scale knobs while the knob itself reads back correctly — which is exactly what "the control saves but nothing changes" looks like. Verified against a page with that interference injected: the snippet names the shadowing rule. docs/troubleshooting-token-overrides.md turns the snippet's output into a decision table, and records what is already measured-good so it doesn't get re-audited: the configurator writes only live token names, the vendored tree matches the pinned framework ref, and the live preview injects unlayered CSS (which is why a preview/page mismatch indicates a cascade conflict rather than a broken control). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DftXg4tjRkey2c3ybxAWjN --- CLAUDE.md | 5 + docs/troubleshooting-token-overrides.md | 65 ++++++++++++ scripts/diagnose-page-tokens.js | 132 ++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 docs/troubleshooting-token-overrides.md create mode 100644 scripts/diagnose-page-tokens.js diff --git a/CLAUDE.md b/CLAUDE.md index 97fa99bc..a940d73c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,11 @@ browser (not an npm dependency of this repo) and so are wired into neither WordPress but works on the standalone configurator. Run: `node tests/override-effect-probe.mjs`. +When that probe says every control group is `OK` but a control still does nothing +on a real site, the conflict is in that page's CSS environment: paste +`scripts/diagnose-page-tokens.js` into the browser console there and read the +result with `docs/troubleshooting-token-overrides.md`. + `tests-php/` is a plain PHPUnit suite (`composer phpunit`, wired into CI's `quality` job) covering pure/near-pure PHP logic that needs no WordPress runtime — CSS parsing, override-value validation, and REST input diff --git a/docs/troubleshooting-token-overrides.md b/docs/troubleshooting-token-overrides.md new file mode 100644 index 00000000..60f3b7c8 --- /dev/null +++ b/docs/troubleshooting-token-overrides.md @@ -0,0 +1,65 @@ +# Troubleshooting: a configurator control changes nothing on the page + +Symptom: a control works in the standalone configurator (and in the plugin's own +live preview) but changes nothing on the WordPress page. The spacing/typography +**modular scale** is the usual reporter, because it works indirectly — it moves +a *source knob* that the framework's generative `clamp()`s read, rather than +writing the concrete value. + +Work through it in this order; each step is a measurement, not a guess. + +## 1. Is the emitted CSS itself capable of moving the page? + +```bash +node tests/override-effect-probe.mjs +``` + +This asks the real PHP emitter for the CSS a site would serve for each control +group, then diffs every live `--sf-*` token in a headless browser against the +un-overridden page — for both the layered and the flat bundle. `DEAD` means the +control cannot work anywhere; `OK` means the problem is specific to the site. +See the file header for prerequisites (`playwright`, `php`). + +If everything is `OK` here, the defect is in the page's CSS environment, and no +amount of reading plugin code will find it. Go to step 2. + +## 2. What does the actual page say? + +Open the page where the change should be visible (the front end, or the Bricks +canvas iframe — pick the iframe as the console context, not the builder panel), +and paste the whole of [`scripts/diagnose-page-tokens.js`](../scripts/diagnose-page-tokens.js) +into DevTools. + +Read the output against this table. "Source knob" = `--sf-space-ratio-min` et al; +"derived output" = `--sf-space-m`, `--sf-space-4xl`, … + +| What section 3 shows | What it means | Fix | +|---|---|---| +| Source knob = your value, derived outputs = defaults | Something declares the concrete output tokens and shadows the knob. Section 2 names it — look for `layer=(unlayered …)`, which beats every `@layer`, or a declaration in a layer after `slashed.overrides`. | Remove that source. If it is another SLASHED copy loaded by a theme/optimizer, stop the duplicate load. | +| Source knob = default value | The override never reached the page. Section 2 will show no `slashed.overrides` declaration. | Page cache or CSS optimizer serving HTML from before the save — purge it. Confirm `