diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc5e126a..153b9d7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39048a1d..6270ccf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 + - name: Build admin-app env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CLAUDE.md b/CLAUDE.md index 38f36c36..46793ab7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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. diff --git a/SLASHED-for-WP/admin-app/scripts/sync-core.mjs b/SLASHED-for-WP/admin-app/scripts/sync-core.mjs index 4082e516..2f4c63d9 100644 --- a/SLASHED-for-WP/admin-app/scripts/sync-core.mjs +++ b/SLASHED-for-WP/admin-app/scripts/sync-core.mjs @@ -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) diff --git a/package.json b/package.json index 80c027e4..91c23d09 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/gen-class-hints.js b/scripts/gen-class-hints.js index a14124f7..2ad66d80 100644 --- a/scripts/gen-class-hints.js +++ b/scripts/gen-class-hints.js @@ -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))); +} + function generate() { const all = {}; for (const { file, category } of SOURCE_FILES) { @@ -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)`); diff --git a/tests-php/ColorMathTest.php b/tests-php/ColorMathTest.php new file mode 100644 index 00000000..a791dadd --- /dev/null +++ b/tests-php/ColorMathTest.php @@ -0,0 +1,96 @@ + + */ + 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 + */ + public function round_trip_cases() { + return array( + 'brand blue' => array( '#3b82f6' ), + 'black' => array( '#000000' ), + 'white' => array( '#ffffff' ), + 'mid grey' => array( '#808080' ), + 'orange' => array( '#ff8800' ), + ); + } +} diff --git a/tests-php/bootstrap.php b/tests-php/bootstrap.php index 693214f1..f707c845 100644 --- a/tests-php/bootstrap.php +++ b/tests-php/bootstrap.php @@ -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'; diff --git a/tests/color-model-copies-identical.test.js b/tests/color-model-copies-identical.test.js new file mode 100644 index 00000000..ac594d0b --- /dev/null +++ b/tests/color-model-copies-identical.test.js @@ -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.', + ); +}); diff --git a/tests/element-types-mirror.test.js b/tests/element-types-mirror.test.js new file mode 100644 index 00000000..9cac5b52 --- /dev/null +++ b/tests/element-types-mirror.test.js @@ -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).', + ); +});