Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions SLASHED-for-WP/includes/class-css-generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand Down Expand Up @@ -61,18 +65,57 @@ 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 );

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.
Expand Down
79 changes: 79 additions & 0 deletions SLASHED-for-WP/includes/class-css-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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] );
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 . '}'
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 . '}'
);
}
}
Expand Down
77 changes: 77 additions & 0 deletions docs/troubleshooting-token-overrides.md
Original file line number Diff line number Diff line change
@@ -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 `<style id="slashed-framework-inline-css">` exists in view-source. |
| Section 4 says the ladder matches, and the knobs show your values | The tokens *are* live on the page. | The element you are looking at does not consume them — e.g. the builder sets padding in px. Check the element's own computed padding in DevTools, not the token. |
| Section 1 flags a `.flat.` bundle | Flat bundles carry no `@layer`. The emitter follows this automatically (`Slashed_CSS_Loader::layers_enabled()`), but a *third-party* optimizer that strips `@layer` from the bundle recreates the same mismatch from outside, and the plugin cannot detect that. | Disable the optimizer's layer stripping, or switch the plugin to flat mode deliberately so both sides agree. |

## 3. Is a concrete value stored in the override map?

This is the cause that has actually been observed in the field, so check it
early — the diagnostic's section 5 calls it out by name.

```bash
wp option get slashed_overrides --format=json
```

An explicit output token in the map (say
`--sf-space-m: clamp(1.00rem, 0.85rem + 0.75vw, 1.50rem)`) **beats the scale
knob by design** — fine-tuning wins over the knob that generated it, in both the
JS preview and the PHP emitter (`array_merge($derived, $overrides)`), because the
emitted `@layer slashed.overrides` block lands after the framework's generative
rules. A full per-step ladder stored this way makes the base *and* ratio knobs
completely inert while both still read back correctly at `:root`, which is the
confusing part: nothing looks broken, the values are simply unused.

Such entries arrive from an older settings page, the All-tokens tab, or an
imported theme. The fix is to drop the `--sf-space-*` **step** keys (`2xs`, `xs`,
`s`, `m`, `l`, `xl`, `2xl`, `3xl`, `4xl`) and keep the base/ratio/scale knobs —
reset them from the configurator's All-tokens tab, or rewrite the option. Note
the same applies to `--sf-text-*` steps and the typography scale.

## What is already known-good (don't re-audit these)

- The configurator writes only live token names — every `--sf-*` literal in
`configurator/src` resolves against `data/inventory.json`.
- The vendored `admin-app/src/` and `framework-css/` match the framework at the
pinned `SLASHED_CSS_REF`; `npm run check` is the gate for that.
- The live preview injects **unlayered** `:root` CSS including pre-computed
derived tokens (`persistence.ts:injectLivePreview`), which is why the preview
can look right while the saved page is wrong. A preview/page mismatch is a
symptom of a cascade conflict on the page, not of a broken control.
Loading