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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ jobs:
- name: Build
run: npm --prefix SLASHED-for-WP/integrations/bricks/editor-app run build

# The built editor-app bundle (assets/editor-app/app.js + app.css) is a
# COMMITTED artifact loaded at runtime. Fail the PR if a source change
# wasn't rebuilt & committed — otherwise the build here is green while the
# shipped/committed bundle is stale. (.map files are gitignored, so a
# clean tree here means the committed app.js/app.css match this build.)
- name: Fail if committed editor-app bundle is stale
run: |
changes="$(git status --porcelain -- SLASHED-for-WP/integrations/bricks/assets/editor-app)"
if [ -n "$changes" ]; then
echo "::error::Committed editor-app bundle is out of date. Run 'npm run build:editor-app' and commit the result."
echo "$changes"
git --no-pager diff -- SLASHED-for-WP/integrations/bricks/assets/editor-app
exit 1
fi

build-admin-app:
name: Build admin-app
runs-on: ubuntu-latest
Expand Down Expand Up @@ -88,6 +103,22 @@ jobs:
SLASHED_SKIP_SYNC: '1'
run: npm run build

# The built admin SPA (assets/admin-app/app.js + app.css) is a COMMITTED
# artifact loaded at runtime by the token page and the frontend overlay.
# Fail the PR if a source (or vendored-configurator) change wasn't rebuilt
# & committed — otherwise CI is green while the shipped bundle is stale.
# (.map files are gitignored, so a clean tree here means the committed
# app.js/app.css match this build.)
- name: Fail if committed admin-app bundle is stale
run: |
changes="$(git status --porcelain -- SLASHED-for-WP/assets/admin-app)"
if [ -n "$changes" ]; then
echo "::error::Committed admin-app bundle is out of date. Run 'npm run build:admin-app' and commit the result."
echo "$changes"
git --no-pager diff -- SLASHED-for-WP/assets/admin-app
exit 1
fi

quality:
name: Lint, test, version & drift checks
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ jobs:
- name: Update CHANGELOG and readme.txt for this release
run: node scripts/changelog-release.js --from-tag

# Rebuild BOTH bundled Svelte apps so the release ships freshly-built
# artifacts rather than whatever was last committed. The admin-app build
# re-vendors the configurator from the framework (needs GITHUB_TOKEN);
# the editor-app build is self-contained.
- name: Build editor-app
run: npm run build:editor-app

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
- name: Build admin-app
env:
GITHUB_TOKEN: ${{ github.token }}
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ SLASHED-for-WP/ WordPress plugin
src/ ⚠️ VENDORED — see below
framework-css/ Vendored framework CSS (chrome layers + full bundle)
assets/ Built SPA output (admin-app/, editor-app/ — committed build artifacts)
data/ Generated inventories/hints (class-hints.json, variables-hints.json…)
data/ Generated inventories/hints (classes-hints.json, variables-hints.json…)
integrations/
bricks/editor-app/ Bricks Builder panel — independent, NOT vendored
gutenberg/ Gutenberg integration
Expand Down Expand Up @@ -97,7 +97,7 @@ npm run update-framework -- --version=0.6.0
```

Downloads release CSS bundles, shallow-clones framework source, regenerates
`data/inventory.json`, `data/class-hints.json` and `data/variables-hints.json`,
`data/inventory.json`, `data/classes-hints.json` and `data/variables-hints.json`,
re-vendors the admin-app configurator core from the clone, and stamps PHP
constants — so one run moves every framework-derived artifact to the same
release.
Expand Down
14 changes: 9 additions & 5 deletions SLASHED-for-WP/admin-app/scripts/sync-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
* 2. GitHub API https://api.github.com/repos/codeslash-dev/slashed
* Set GITHUB_TOKEN env var to avoid public rate limits.
*
* The configurator is now embeddable on its own (it auto-detects WordPress via
* window.slashedApp and persists through the REST API), so NOTHING in src/ needs
* to diverge — `.syncignore` is empty by default. Plugin build wiring lives
* outside src/ (vite.config.js, package.json, svelte.config.js, tsconfig.json)
* and is never touched by this script.
* The configurator is embeddable on its own (it auto-detects WordPress via
* window.slashedApp and persists through the REST API), so almost nothing in
* src/ needs to diverge from upstream. The few files that DO — the plugin's
* frontend overlay entry points — are listed in `.syncignore` (currently
* src/plugin-main.ts and src/AppOverlay.svelte) and are preserved across every
* sync. Any other file you edit under src/ is overwritten on the next sync;
* `npm run check` fails CI when a non-syncignored src/ file drifts from
* upstream. Plugin build wiring lives outside src/ (vite.config.js,
* package.json, svelte.config.js, tsconfig.json) and is never touched here.
*
* --check / --dry-run: reports drift against the framework source (files that
* would change, be added, or are vendored locally but no longer exist upstream)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"update-framework": "node scripts/update-framework.js",
"build:data": "node scripts/gen-bricks-inventory.js && node scripts/gen-class-hints.js && node scripts/gen-variables-hints.js",
"sync-dist": "node scripts/sync-plugin-dist.js",
"build:editor-app": "npm --prefix SLASHED-for-WP/integrations/bricks/editor-app run build",
"build:editor-app": "(cd SLASHED-for-WP/integrations/bricks/editor-app && npm ci && npm run build)",
"build:admin-app": "(cd SLASHED-for-WP/admin-app && npm ci && npm run build)",
"build:apps": "npm run build:editor-app && npm run build:admin-app",
"build:zip": "node scripts/zip-plugin.js",
Expand Down
33 changes: 33 additions & 0 deletions scripts/gen-class-hints.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,24 @@ function parseFile(rel, category) {
return hints;
}

/**
* The framework CSS source files (relative to FRAMEWORK) that are NOT present
* on disk. parseFile() silently returns {} for a missing source, so ANY absent
* file would drop that whole category's hints; generate() would then produce a
* partial map (down to just the hardcoded MANUAL_HINTS + OVERRIDE_HINTS when
* the checkout is missing entirely). Writing that would clobber the committed
* classes-hints.json, so the writer below refuses unless EVERY source resolves
* — a mispointed or partial checkout is rejected, not silently truncated.
* Mirrors gen-bricks-inventory.js, which likewise fails on any missing source.
* (--check mode still runs generate() and reports the resulting drift loudly,
* which is safe.)
*/
function missingFrameworkSources() {
return SOURCE_FILES
.map(({ file }) => file)
.filter((file) => !fs.existsSync(path.join(FRAMEWORK, file)));
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

function generate() {
const all = {};
for (const { file, category } of SOURCE_FILES) {
Expand Down Expand Up @@ -204,6 +222,21 @@ if (process.argv.includes('--check')) {
}
console.log(`[gen-class-hints] OK — ${Object.keys(hints).length} class hints`);
} else {
// Refuse to overwrite the committed file with a truncated result: every
// framework CSS source must resolve, or the generated map would silently
// drop the categories whose sources are missing.
const missing = missingFrameworkSources();
if (missing.length > 0) {
console.error(
`[gen-class-hints] framework CSS source incomplete — refusing to overwrite ${OUT_REL} ` +
`with a truncated hints map.\n` +
` Missing ${missing.length}/${SOURCE_FILES.length} source file(s) under FRAMEWORK=${FRAMEWORK}:\n` +
missing.map((f) => ` - ${f}`).join('\n') + '\n' +
` Point SLASHED_FRAMEWORK_DIR at a complete SLASHED checkout, add a ./.framework clone, ` +
`or place a sibling ../SLASHED checkout, then re-run.`,
);
process.exit(1);
}
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, json);
console.log(`[gen-class-hints] → ${OUT_REL} (${Object.keys(hints).length} class hints)`);
Expand Down
96 changes: 96 additions & 0 deletions tests-php/ColorMathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* Golden-value regression tests for Slashed_Color_Math — the dependency-free
* OKLCH ⇄ hex color-space math that powers PHP-side swatch previews.
*
* The same conversion also exists in JS (the vendored OklchColorDesk /
* WcagPanel components drive the interactive picker). These are hand-maintained
* parallel implementations with no build-time link, so they can silently
* diverge — a preview swatch computed in PHP would then stop matching the
* colour the picker shows. These golden values lock the PHP output so any
* future change to the conversion math is caught here rather than shipping as a
* silent visual drift. If the framework's canonical algorithm intentionally
* changes, update these constants deliberately (and mirror the change in JS).
*
* Pure math, no WordPress runtime — see class doc comment.
*
* @package SLASHED
*/

use PHPUnit\Framework\TestCase;

final class ColorMathTest extends TestCase {

/**
* @return array<string, array{0: float, 1: float, 2: float, 3: string}>
*/
public function oklch_to_hex_cases() {
return array(
// [ L, C, H, expected hex ]
'blue (primary source)' => array( 0.45, 0.20, 264.0, '#1745c2' ),
'green' => array( 0.70, 0.15, 145.0, '#5bb661' ),
'warm red' => array( 0.60, 0.12, 29.0, '#bd6255' ),
'achromatic grey' => array( 0.50, 0.0, 0.0, '#636363' ),
'pale yellow' => array( 0.90, 0.05, 90.0, '#ebddb9' ),
'deep violet' => array( 0.20, 0.10, 300.0, '#1e023b' ),
);
}

/**
* @dataProvider oklch_to_hex_cases
*/
public function test_oklch_to_hex_matches_golden( $l, $c, $h, $expected ) {
$this->assertSame( $expected, Slashed_Color_Math::oklch_to_hex( $l, $c, $h ) );
}

public function test_parse_oklch_reads_components() {
$this->assertSame( array( 0.45, 0.2, 264.0 ), Slashed_Color_Math::parse_oklch( 'oklch(0.45 0.2 264)' ) );
$this->assertSame( array( 0.45, 0.2, 264.0 ), Slashed_Color_Math::parse_oklch( 'oklch(0.45 0.2 264deg)' ) );
}

public function test_parse_oklch_rejects_garbage() {
$this->assertNull( Slashed_Color_Math::parse_oklch( 'rgb(1,2,3)' ) );
$this->assertNull( Slashed_Color_Math::parse_oklch( 'not a color' ) );
}

public function test_hex_to_oklch_matches_golden() {
$oklch = Slashed_Color_Math::hex_to_oklch( '#3b82f6' );
$this->assertNotNull( $oklch );
// Tolerance keeps the assertion stable against last-decimal float noise
// while still pinning the conversion to the current algorithm.
$this->assertEqualsWithDelta( 0.6231, $oklch[0], 0.0005 );
$this->assertEqualsWithDelta( 0.1880, $oklch[1], 0.0005 );
$this->assertEqualsWithDelta( 259.8145, $oklch[2], 0.001 );
}

public function test_hex_to_oklch_rejects_non_hex() {
$this->assertNull( Slashed_Color_Math::hex_to_oklch( 'oklch(0.5 0.1 200)' ) );
$this->assertNull( Slashed_Color_Math::hex_to_oklch( 'nope' ) );
}

/**
* hex → oklch → hex must return the original colour (the two directions are
* inverse transforms). Guards against a matrix/gamma change breaking one
* direction without the other.
*
* @dataProvider round_trip_cases
*/
public function test_hex_oklch_round_trip( $hex ) {
$oklch = Slashed_Color_Math::hex_to_oklch( $hex );
$this->assertNotNull( $oklch );
$this->assertSame( $hex, Slashed_Color_Math::oklch_to_hex( $oklch[0], $oklch[1], $oklch[2] ) );
}

/**
* @return array<string, array{0: string}>
*/
public function round_trip_cases() {
return array(
'brand blue' => array( '#3b82f6' ),
'black' => array( '#000000' ),
'white' => array( '#ffffff' ),
'mid grey' => array( '#808080' ),
'orange' => array( '#ff8800' ),
);
}
}
1 change: 1 addition & 0 deletions tests-php/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ function sanitize_key( $key ) {
require_once $includes . 'class-css-parser.php';
require_once $includes . 'class-css-generator.php';
require_once $includes . 'class-rest-controller.php';
require_once $includes . 'class-color-math.php';
43 changes: 43 additions & 0 deletions tests/color-model-copies-identical.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* color-model.js is maintained as two byte-identical copies — one for the
* Bricks editor-app, one for the Gutenberg editor bundle — because the two
* builders ship separate bundles with no shared module between them. The
* cross-impl test (color-model-cross-impl.test.js) only pins the *behaviour*
* of classifyVar(); every other export (buildColorModel, swatchHex,
* filterModel, FAMILY_INFO, …) is unguarded and could silently diverge if
* someone edits one copy and forgets the other.
*
* This test asserts the two files are identical byte-for-byte, so any edit to
* one that isn't mirrored in the other fails CI. When you change one copy,
* copy it verbatim to the other (they are intentionally the same file).
*
* Run: node --test tests/color-model-copies-identical.test.js
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

const BRICKS_COPY = path.join(
ROOT,
'SLASHED-for-WP/integrations/bricks/editor-app/src/lib/color-model.js',
);
const GUTENBERG_COPY = path.join(
ROOT,
'SLASHED-for-WP/integrations/gutenberg/assets/editor/color-model.js',
);

test('Bricks and Gutenberg color-model.js copies are byte-identical', () => {
const bricks = readFileSync(BRICKS_COPY, 'utf8');
const gutenberg = readFileSync(GUTENBERG_COPY, 'utf8');
assert.equal(
bricks,
gutenberg,
'color-model.js has diverged between the Bricks and Gutenberg copies — ' +
'they are intentionally identical. Copy your change to both files.',
);
});
60 changes: 60 additions & 0 deletions tests/element-types-mirror.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* ELEMENT_TYPE_LABEL_MAP (JS, editor-app) and Slashed_Bricks_Settings_Page::
* BUILTIN_DEFAULTS (PHP) are two hand-maintained copies of the same element-
* type → default-BEM-name table. The editor is the source of truth at
* pre-fill time; the PHP copy only feeds the settings-page placeholders. The
* PHP class carries a "KEEP IN SYNC" comment but nothing enforced it, so the
* two could drift and show users different defaults than they actually get.
*
* This test parses the PHP const array and asserts it equals the JS map
* exactly. If you change one, change the other.
*
* Run: node --test tests/element-types-mirror.test.js
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ELEMENT_TYPE_LABEL_MAP } from '../SLASHED-for-WP/integrations/bricks/editor-app/src/lib/element-types.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PHP_FILE = path.resolve(
__dirname,
'..',
'SLASHED-for-WP/includes/class-bricks-settings-page.php',
);

/**
* Extract the `const BUILTIN_DEFAULTS = array( ... );` block from the PHP file
* and parse its `'key' => 'value'` pairs into a plain object.
*/
function parsePhpBuiltinDefaults(src) {
const start = src.indexOf('const BUILTIN_DEFAULTS = array(');
assert.notEqual(start, -1, 'BUILTIN_DEFAULTS array not found in PHP source');
const body = src.slice(start);
const end = body.indexOf(');');
assert.notEqual(end, -1, 'end of BUILTIN_DEFAULTS array not found');
const inner = body.slice(0, end);

const pairRe = /'([^']+)'\s*=>\s*'([^']+)'/g;
const map = {};
let m;
while ((m = pairRe.exec(inner)) !== null) {
map[m[1]] = m[2];
}
return map;
}

test('PHP BUILTIN_DEFAULTS mirrors JS ELEMENT_TYPE_LABEL_MAP exactly', () => {
const phpMap = parsePhpBuiltinDefaults(readFileSync(PHP_FILE, 'utf8'));
const jsMap = { ...ELEMENT_TYPE_LABEL_MAP };

assert.ok(Object.keys(phpMap).length > 0, 'parsed PHP map is unexpectedly empty');
assert.deepEqual(
phpMap,
jsMap,
'PHP BUILTIN_DEFAULTS has drifted from JS ELEMENT_TYPE_LABEL_MAP — ' +
'update both (class-bricks-settings-page.php and element-types.js).',
);
});