diff --git a/CLAUDE.md b/CLAUDE.md index 46793ab7..a940d73c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,12 +115,29 @@ 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`. + +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 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..36bfc934 100644 --- a/SLASHED-for-WP/includes/class-css-loader.php +++ b/SLASHED-for-WP/includes/class-css-loader.php @@ -52,6 +52,17 @@ public static function get_bundle() { * @return string */ public static function get_url() { + // SLASHED_PATH / SLASHED_URL are defined by slashed.php, i.e. only in + // unified mode. An integration plugin running standalone loads this class + // but resolves its own bundle URL (slashed_{builder}_get_css_url()), so + // there is no local bundle to find here — and dereferencing the constants + // would be a fatal error. Fall through to the filter with an empty URL, + // exactly as a missing bundle file does. + if ( ! defined( 'SLASHED_PATH' ) || ! defined( 'SLASHED_URL' ) ) { + /** This filter is documented below. */ + return apply_filters( 'slashed/css_bundle_url', '' ); + } + $bundle = self::get_bundle(); $flat = Slashed_Settings::get_css_flat(); $debug = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG; @@ -71,6 +82,74 @@ 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. + * + * Layer support is a property of the bundle actually served, not of the + * setting: `slashed/css_bundle_url` can serve a bundle the css_flat setting + * does not describe, and getting that backwards reintroduces the very bug + * this method exists to prevent (layered overrides over a flat bundle, or + * unlayered rules outranking the framework's @media-scoped rules over a + * layered one). So the resolved URL decides whenever it is recognisably one + * of SLASHED's own bundles, and the setting is only the fallback for a URL + * this cannot read — a CDN path with an arbitrary name, say. Hosts serving + * such a bundle should state its layer mode via `slashed/css_layers_enabled` + * rather than rely on the fallback. + * + * @return bool + */ + public static function layers_enabled() { + $url = self::get_url(); + $mode = '' === $url ? null : self::url_layer_mode( $url ); + $enabled = null === $mode ? ! Slashed_Settings::get_css_flat() : $mode; + + /** + * Filter whether the served bundle supports @layer. + * + * @param bool $enabled Whether inline framework CSS should be layered. + * @param string $url The resolved bundle URL this was derived from. + */ + return (bool) apply_filters( 'slashed/css_layers_enabled', $enabled, $url ); + } + + /** + * Layer mode implied by a bundle URL's filename. + * + * Pure and side-effect free so the naming contract can be tested directly. + * + * @param string $url Resolved bundle URL. + * @return bool|null True when the name is a layered SLASHED bundle, false for + * a flat one, null when the name is not one this recognises. + */ + public static function url_layer_mode( $url ) { + $path = wp_parse_url( (string) $url, PHP_URL_PATH ); + $name = is_string( $path ) ? basename( $path ) : ''; + if ( ! preg_match( '/^slashed\.[a-z0-9-]+(\.flat)?(\.min)?\.css$/i', $name, $m ) ) { + return null; + } + return empty( $m[1] ); + } + + /** + * 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/docs/troubleshooting-token-overrides.md b/docs/troubleshooting-token-overrides.md new file mode 100644 index 00000000..9e6885d6 --- /dev/null +++ b/docs/troubleshooting-token-overrides.md @@ -0,0 +1,77 @@ +# 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 the output shows | What it means | Fix | +|---|---|---| +| Section 4 says the ladder does NOT match the knobs | The decisive case. The concrete output tokens are declared by something that is not the framework's generative CSS, so the knobs feed a formula whose result is discarded — they read back correctly at `:root` and change nothing. Note this does **not** require the outputs to sit at their defaults: an unrelated hand-set ladder reads as "not default" and still proves the knobs are inert. Section 2 names the declaring rule. | Remove that source. If it is another SLASHED copy loaded by a theme/optimizer, stop the duplicate load. | +| Source knob = your value, derived outputs = defaults | Same shadowing, in its most obvious form. Section 2 names it — look for `layer=(unlayered …)`, which beats every `@layer`, or a declaration in a layer after `slashed.overrides`. | As above. | +| 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 `` : '') + + '
', + ); + 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 }, + ); +} + +try { + 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\``); + await shutdown(); + 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(); + } +} finally { + await shutdown(); +} + +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..6976535a --- /dev/null +++ b/tests/php-harness/emit-override-css.php @@ -0,0 +1,94 @@ +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 );