diff --git a/.gitattributes b/.gitattributes index 0b7c43d..e1e241f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,29 @@ # Auto detect text files and perform LF normalization * text=auto +*.php text eol=lf +*.yml text eol=lf + +# Development tools and temporary output do not belong in module distributions. +/.github export-ignore +/.build export-ignore +/.qodana export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.codeclimate.yml export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/phpstan.neon export-ignore +/phpunit.xml.dist export-ignore +/qodana.yaml export-ignore +/CONTRIBUTING.md export-ignore +/vendor export-ignore +/node_modules export-ignore +/composer.json export-ignore +/composer.lock export-ignore +/package-lock.json export-ignore +/coverage export-ignore +/reports export-ignore +/data/workbench/*.json export-ignore # Custom for Visual Studio *.cs diff=csharp diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b92f31a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcee763 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + workflow_call: + push: + branches: [master, main, 'release/**'] + pull_request: + +permissions: + contents: read + +jobs: + qa: + name: QA (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, mysqli, xml + coverage: none + tools: composer:v2 + - name: Install shared runner tools + run: | + mkdir -p "$RUNNER_TEMP/xoops_lib" + echo "XOOPS_VENDOR_PATH=$RUNNER_TEMP/xoops_lib/vendor" >> "$GITHUB_ENV" + composer --working-dir="$RUNNER_TEMP/xoops_lib" init --name=xoops/ci-tools --no-interaction + composer --working-dir="$RUNNER_TEMP/xoops_lib" require --dev --no-interaction --no-progress \ + 'phpunit/phpunit:^11.5' 'phpstan/phpstan:^2.0' + - name: PHP syntax and isolated regression suite + run: | + git ls-files -z '*.php' | xargs -0 -n1 php -l + php tests/run.php + - name: Scoped static analysis + run: php "$XOOPS_VENDOR_PATH/bin/phpstan" analyse --configuration=phpstan.neon --no-progress --memory-limit=512M + - name: JavaScript syntax + run: for file in assets/js/*.js tests/*.cjs; do node --check "$file"; done + - name: Install shared browser runner + if: matrix.php == '8.4' + run: | + npm install --prefix "$RUNNER_TEMP/xoops-browser" --no-save --package-lock=false playwright@1.60.0 + "$RUNNER_TEMP/xoops-browser/node_modules/.bin/playwright" install --with-deps chromium + echo "PLAYWRIGHT_MODULE=$RUNNER_TEMP/xoops-browser/node_modules/playwright" >> "$GITHUB_ENV" + - name: Browser regressions and real endpoint roundtrip + if: matrix.php == '8.4' + run: | + export CHROME_PATH="$(node -e 'console.log(require(process.env.PLAYWRIGHT_MODULE).chromium.executablePath())')" + php tests/run.php --browser diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000..29d86fa --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,44 @@ +# Optional Qodana analysis; shared PHP tooling remains configured separately. +name: Qodana + +on: + workflow_dispatch: + pull_request: + push: + branches: [master, main, 'releases/*'] + +permissions: {} + +concurrency: + group: qodana-${{ github.ref }} + cancel-in-progress: true + +jobs: + qodana: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + checks: write + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + steps: + - name: Explain skipped scan + if: env.QODANA_TOKEN == '' + run: echo "::notice::QODANA_TOKEN is not available; configure this repository's Qodana project token to enable analysis." + + - name: Checkout + if: env.QODANA_TOKEN != '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Qodana scan + if: env.QODANA_TOKEN != '' + uses: JetBrains/qodana-action@3e8d76ff0e0241618abd808682f426afe84bab3f # v2026.2.2 + with: + upload-result: true + post-pr-comment: false + push-fixes: none diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4bb4f1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + - '[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*' + - '[0-9]+.[0-9]+.[0-9]+-[0-9A-Za-z]*' + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + qa: + uses: ./.github/workflows/ci.yml + release: + needs: qa + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build clean module archive + id: archive + run: | + NOTES="docs/RELEASE-${GITHUB_REF_NAME#v}.md" + if [ ! -f "$NOTES" ]; then + echo "::error::Release notes missing: $NOTES" + exit 1 + fi + echo "notes=$NOTES" >> "$GITHUB_OUTPUT" + ZIP="cssholmes-${GITHUB_REF_NAME}.zip" + git archive --format=zip --prefix=cssholmes/ -o "$ZIP" HEAD + echo "zip=$ZIP" >> "$GITHUB_OUTPUT" + - name: Create or update GitHub Release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + with: + files: ${{ steps.archive.outputs.zip }} + overwrite_files: false + fail_on_unmatched_files: true + generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} + body_path: ${{ steps.archive.outputs.notes }} diff --git a/.gitignore b/.gitignore index 85d2647..1e2a988 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,25 @@ \#* *.bak .idea/ +/.vscode/ +/vendor/ +/node_modules/ +/composer.lock +/package-lock.json +/.build/ +/.qodana/ +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/coverage/ +/reports/ +/data/workbench/*.json +/templates_c/ +.env +.env.* +!.env.example +*.log +*.orig +*.rej +.DS_Store +Thumbs.db diff --git a/.scrutinizer.yml b/.scrutinizer.yml index d758d13..96ddc69 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,5 +1,7 @@ # language: php build: + environment: + php: 8.4 nodes: tests: true analysis: @@ -13,9 +15,9 @@ build: override: true tests: override: - - php-scrutinizer-run --enable-security-analysis + - php-scrutinizer-run --enable-security-analysis filter: dependency_paths: - - XoopsCore25/* - - tag/* + - XoopsCore25/* + - tag/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5438ec0..ae73b7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,20 +1,104 @@ - -# Contributing to [XOOPS CMS](https://xoops.org) -[](https://xoops.org) -[](https://www.gnu.org/licenses/gpl-2.0.html) - -Contributions are **welcome** and will be fully **credited**. - -We accept contributions via Pull Requests on [Github](https://github.com/XoopsModules25x/cssholmes). - -## Pull Requests - -- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). -- **Add tests!** - We encourage providing tests for your contributions. -- **Document any change in behavior** - Make sure the `/docs/changelog.txt` and any other relevant documentation are up-to-date. -- **Consider our release cycle** - We try to follow [Semantic Versioning v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. -- **Create feature branches** - Don't ask us to pull from your master branch. -- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. -- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please squash them before submitting. - -**Happy coding, and _May the Source be with You_!** +# Contributing to cssHolmes + +Submit focused pull requests to [mambax7/cssholmes](https://github.com/mambax7/cssholmes). +Use PSR-12, preserve public APIs and existing request/CSRF validation, and add a +regression test beside the affected behavior. Describe the problem, behavior +change, compatibility impact and commands actually run. Include screenshots for +visible changes. Use XOOPS Conventional Commits, for example +`fix(designer): preserve preview selection`. + +## Shared tools + +PHP 8.4 is the module minimum. Keep Composer dependencies in the site's shared +`xoops_lib/vendor`, never under this module. Existing shared PHPUnit and PHPStan +are sufficient; do not run `composer install` in the module. Node/Playwright +also come from an existing shared installation. + +From `htdocs/modules/cssholmes`: + +```sh +php tests/run.php +``` + +For an independent checkout (PowerShell example): + +```powershell +$env:XOOPS_VENDOR_PATH = 'C:/path/to/htdocs/xoops_lib/vendor' +php tests/run.php +``` + +The runner stops on the first failed subprocess. It runs storage, sets, access, +preview-context and HTTP endpoint regressions in temporary storage, followed by +`phpunit.xml.dist` (Analyzer, Cli and Diagnostics). Its isolated namespace +bootstrap also supports a checkout whose folder is not named `cssholmes`. +It does not load a configured site's `mainfile.php`, session or database. + +To include browser regressions and the HTTP roundtrip: + +```powershell +$env:PLAYWRIGHT_MODULE = 'C:/path/to/shared/node_modules/playwright-core' +$env:CHROME_PATH = 'C:/Program Files/Google/Chrome/Application/chrome.exe' +php tests/run.php --browser +``` + +All independent `tests/*.cjs` run automatically. `designer-roundtrip.cjs` runs +through the temporary HTTP server. `designer-specimens.cjs` additionally needs +installed theme fixtures and is a separate opt-in command: +`php tests/designer-http.php --browser --specimens`. +The XMF/XTF package contract check is separately opt-in: +`php tests/xtf-contract.php /path/to/htdocs/xoops_lib/vendor/xoops`. + +## Static analysis + +`phpstan.neon` uses standard PHPStan level 5 for framework-independent +Analyzer, Cli, Diagnostics and Designer production classes. It has no baseline +or error ignores. PHPDoc types remain uncertain because manifest/token values +are validated at runtime. Core-dependent entrypoints, preloads, Workbench and +legacy Common helpers are outside this initial analysis scope; the gate does +not claim whole-module static coverage. Increase scope/level with targeted +fixes, not suppression files. + +With a shared tool installation that has no automatic PHPStan extensions: + +```powershell +php "$env:XOOPS_VENDOR_PATH/bin/phpstan" analyse --configuration=phpstan.neon +``` + +If the shared installation enables unrelated strict/deprecation extensions, +run its existing self-contained PHAR from a temporary location. This avoids +changing shared Composer settings or copying dependencies into the module: + +```powershell +$analysisPhar = Join-Path ([IO.Path]::GetTempPath()) 'xoops-phpstan.phar' +Copy-Item "$env:XOOPS_VENDOR_PATH/phpstan/phpstan/phpstan.phar" $analysisPhar +try { + php $analysisPhar analyse --configuration=phpstan.neon + if ($LASTEXITCODE -ne 0) { throw 'PHPStan failed' } +} finally { + Remove-Item -LiteralPath $analysisPhar +} +``` + +Keep the temporary PHAR location stable across runs because PHPStan caches +paths to its bundled stubs. CI installs only the required tools in the runner's +shared temporary `xoops_lib` directory and invokes them directly. + +## CI, releases and limitations + +CI runs syntax checks, the isolated regression suite and scoped static analysis +on PHP 8.4 and 8.5, plus Chromium regressions on PHP 8.4. It does not install or +modify a real XOOPS database. Qodana uses PHP 8.4 and requires the repository's +`QODANA_TOKEN`; missing tokens produce an explicit skip notice. No scan fixes or +PR comments are pushed automatically. + +Release tags run CI, then `git archive` creates a ZIP rooted at `cssholmes/`. +`.gitattributes` excludes development tooling and tests while preserving module +assets and runtime data. Existing release assets are not overwritten. Review +the ZIP before installing it on a site. Action updates are proposed weekly by +Dependabot; auto-merge is not enabled. + +The historical full `tests/Unit` tree is not the standalone suite. It contains +incomplete Common tests, obsolete `getMockForTrait()` calls, a preload test with +a filename/class mismatch, and Helper tests requiring a real XOOPS runtime. +See [the recorded results](docs/compatibility.md); do not equate the focused +suite's pass with a clean full legacy suite. diff --git a/README.md b/README.md index 3c8871b..bf56589 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,42 @@ - -## cssHolmes module for [XOOPS CMS 2.5.10+](https://xoops.org) -[](https://xoops.org) -[](http://www.gnu.org/licenses/gpl-2.0.html) +# cssHolmes for XOOPS -[](https://scrutinizer-ci.com/g/XoopsModules25x/cssholmes/?branch=master) -[](https://www.codacy.com/app/XoopsModules25x/cssholmes) -[](https://codeclimate.com/github/XoopsModules25x/cssholmes) -[](https://github.com/XoopsModules25x/cssholmes/tags/) -[](https://github.com/XoopsModules25x/cssholmes/releases/) +cssHolmes combines HTML/CSS diagnostics, a visual theme designer, and a library +of portable theme configuration sets. Preview changes, inspect tokens and +contrast, and publish validated overrides without rewriting theme source files. -**cssHolmes** module for [XOOPS CMS](https://xoops.org) is a "CSS Markup Detective", with diagnostic CSS stylesheet that can highlight potentially invalid, inaccessible or erroneous HTML(5) markup by adding one class. +Requires **PHP 8.4+** and an installed XOOPS site. XOOPS 2.7 and 2.8 are the +current integration targets; see the [compatibility and verification record](docs/compatibility.md) +for what has actually been tested. XMF/XTF contract checks do not establish a +native XOOPS 4 port. -[](https://xoops.gitbook.io/cssholmes-tutorial/) Tutorial: see [GitBook](https://xoops.gitbook.io/cssholmes-tutorial/). -To contribute to the Tutorial, [fork it on GitHub](https://github.com/XoopsDocs/cssholmes-tutorial) +- [Visual designer](docs/visual-designer.md): selection, preview and saving. +- [Theme sets](docs/theme-sets.md): library, palettes, import/export and reset. +- [Tutorial](docs/TUTORIAL-cssholmes-XTF-XMF-v2.md). +- [XMF/XTF migration boundaries](docs/xoops4-migration.md). +- [Development and testing](CONTRIBUTING.md). -[](https://www.transifex.com/xoops) +Install the module as `htdocs/modules/cssholmes` and use the XOOPS module +administration screen to install or update it. Keep the site's shared dependencies +in `xoops_lib`; this module has no separate Composer installation or vendor tree. +Back up the site's files and database before an installed-module upgrade. -Please visit us on https://xoops.org +## Development checks -Current and upcoming "next generation" versions of XOOPS CMS are crafted on GitHub at: https://github.com/XOOPS +Use a complete source checkout, including the testing/tooling files supplied by +[the final QA change](https://github.com/XoopsModules25x/cssholmes/pull/9). +Documentation-only review branches are incomplete; release ZIPs intentionally +exclude `tests/`, PHPUnit configuration and other development files. If +`tests/run.php` is absent, obtain the complete source before running these commands. + +From that source directory, `php tests/run.php` runs the isolated PHP +regressions plus the analyzer, CLI and diagnostic PHPUnit tests using shared +`xoops_lib/vendor/bin/phpunit`. For a separate checkout, set `XOOPS_VENDOR_PATH` +to that shared vendor directory. `php tests/run.php --browser` also runs the +browser regressions with an existing Playwright/Chromium installation. + +Source and issues: [XoopsModules25x/cssholmes](https://github.com/XoopsModules25x/cssholmes). +The repository's CI checks PHP 8.4/8.5; release tags run CI before creating a +clean module ZIP. Qodana is optional and explicitly skips its scan when +`QODANA_TOKEN` is unavailable. A skipped scan is not a quality result. + +Licensed under the [GNU GPL, version 2 or later](docs/license.txt). diff --git a/admin/about.php b/admin/about.php index 622db00..a870211 100644 --- a/admin/about.php +++ b/admin/about.php @@ -1,4 +1,4 @@ -displayNavigation(basename(__FILE__)); -$adminObject::setPaypal('xoopsfoundation@gmail.com'); +Admin::setPaypal('xoopsfoundation@gmail.com'); $adminObject->displayAbout(false); -require __DIR__ . '/admin_footer.php'; +require_once __DIR__ . '/admin_footer.php'; diff --git a/admin/admin_footer.php b/admin/admin_footer.php index c902e45..6274dbb 100644 --- a/admin/admin_footer.php +++ b/admin/admin_footer.php @@ -1,4 +1,4 @@ -\n" . "
\n" . ' ' . _AM_MODULEADMIN_ADMIN_FOOTER . "\n" . ''; diff --git a/admin/admin_header.php b/admin/admin_header.php index fc9ec3d..6b4737b 100644 --- a/admin/admin_header.php +++ b/admin/admin_header.php @@ -1,4 +1,5 @@ - + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) + * @author krisOverlay diagnostics are opt-in for groups with the Use Overlay permission (Webmasters by default) on both the site and admin theme side. You can activate them from the query string, then keep working with the floating toolbar without re-adding the parameter on every page.
'; +echo '' . htmlspecialchars($queryKey, ENT_QUOTES) . '=html5,xtf-theme,xtf-widget,a11y,layout
The toolbar now persists by scope in the browser, so site and admin overlays can keep different profile sets while still supporting shareable preview URLs.
'; +echo 'The first VisBug-style tools are also live in the toolbar: inspect, measure, element locking on click, selector copy, keyboard shortcuts I, M, T, P and U (only while the toolbar is open, never together with Ctrl, Cmd or Alt, and never while you type in a form field) plus Esc to leave inspect and measure mode, plus token-aware color hints when the current XTF theme exposes matching values in theme.json. The toolbar can now preview local token edits on the selected element, adjust core typography properties, jump to the nearest widget or slot wrapper, make quick text edits on simple leaf text elements, copy readable or JSON inspection snapshots, keep a small undoable local change log, and export sel as a structured workbench payload with widget and slot context.
If no XTF manifest is detected, cssHolmes falls back to a generic XOOPS mode. In that mode the HTML5, accessibility, layout, inspection, measurement, copy, and widget HTML scan tools still work, while token-aware theme analysis and token editing stay disabled.
'; +echo 'Paste either a cssHolmes toolbar review export or an XTF Marketplace theme config export. cssHolmes will detect the payload type and route it to the right review flow.
'; +foreach ($importErrors as $importError) { + echo 'Payload: ' . htmlspecialchars((string)($importSummary['payload_label'] ?? 'Export'), ENT_QUOTES) . '
'; + echo 'Scope: ' . htmlspecialchars((string)$importSummary['scope'], ENT_QUOTES) + . ' Theme: ' . htmlspecialchars((string)$importSummary['theme'], ENT_QUOTES) + . ' Generated: ' . htmlspecialchars((string)$importSummary['generated_at'], ENT_QUOTES) . '
'; + if ('theme_config_export' === (string)($importSummary['payload_type'] ?? '')) { + $configSummary = is_array($importSummary['config_summary'] ?? null) ? $importSummary['config_summary'] : []; + $slotCounts = is_array($configSummary['slot_counts'] ?? null) ? $configSummary['slot_counts'] : []; + $preferenceKeys = is_array($configSummary['preference_keys'] ?? null) ? $configSummary['preference_keys'] : []; + echo 'This payload describes widget placements, preferences, and token overrides. It is not a cssHolmes review-change export, so there are no per-change rows to accept or apply.
'; + echo 'Theme Name: ' . htmlspecialchars((string)($configSummary['theme_name'] ?? ''), ENT_QUOTES) + . ' Version: ' . htmlspecialchars((string)($configSummary['version'] ?? ''), ENT_QUOTES) + . ' Schema: ' . htmlspecialchars((string)($configSummary['schema_version'] ?? ''), ENT_QUOTES) + . '
'; + echo 'Placements: ' . (int)($configSummary['placement_count'] ?? 0) + . ' Token Overrides: ' . (int)($configSummary['token_override_count'] ?? 0) + . '
'; + if ([] !== $slotCounts) { + echo '| Slot | Placements |
|---|---|
| ' . htmlspecialchars((string)$slotName, ENT_QUOTES) . ' | ' . (int)$slotCount . ' |
Preferences: ' . htmlspecialchars(implode(', ', array_map('strval', $preferenceKeys)), ENT_QUOTES) . '
'; + } + } elseif ([] !== $importSummary['kind_counts']) { + echo ''; + foreach ($importSummary['kind_counts'] as $kind => $count) { + echo '' + . htmlspecialchars((string)$kind, ENT_QUOTES) + . ': ' + . (int)$count + . ''; + } + echo '
'; + } + if ('theme_config_export' === (string)($importSummary['payload_type'] ?? '')) { + // Theme config exports are summarized above. + } elseif ([] === $importSummary['changes']) { + echo 'No changes were present in the payload.
'; + } else { + echo '| Kind | ' + . 'Selector | ' + . 'Widget | ' + . 'Slot | ' + . 'Summary | ' + . 'Before | ' + . 'After | ' + . '
|---|---|---|---|---|---|---|
| ' . htmlspecialchars((string)$change['kind'], ENT_QUOTES) . ' | ' + . '' . htmlspecialchars((string)$change['selector'], ENT_QUOTES) . ' | '
+ . '' . htmlspecialchars((string)$change['widget'], ENT_QUOTES) . ' | ' + . '' . htmlspecialchars((string)$change['slot'], ENT_QUOTES) . ' | ' + . '' . nl2br(htmlspecialchars($inspectionSummary, ENT_QUOTES)) . ' | ' + . '' . nl2br(htmlspecialchars((string)$change['before'], ENT_QUOTES)) . ' | ' + . '' . nl2br(htmlspecialchars((string)$change['after'], ENT_QUOTES)) . ' | ' + . '
' + . htmlspecialchars((string)($draft['content'] ?? ''), ENT_QUOTES) + . ''; + echo '
No saved imports yet.
'; +} else { + echo '' + . 'Imports: ' . (int)$historyTotals['imports'] . '' + . 'Changes: ' . (int)$historyTotals['changes'] . '' + . 'Pending: ' . (int)$historyTotals['pending'] . '' + . 'Accepted: ' . (int)$historyTotals['accepted'] . '' + . 'Rejected: ' . (int)$historyTotals['rejected'] . '' + . '
'; + + echo '' . htmlspecialchars((string)($savedImport['theme'] ?? ''), ENT_QUOTES) . '
'; + echo '' . htmlspecialchars((string)($savedImport['payload_label'] ?? 'Export'), ENT_QUOTES) . '
'; + echo '' . htmlspecialchars((string)($savedImport['id'] ?? ''), ENT_QUOTES) . '
Saved ' . htmlspecialchars((string)($savedImport['created_at'] ?? ''), ENT_QUOTES) . '
'; + if ('theme_config_export' === $savedPayloadType) { + echo '' + . 'Placements: ' . (int)($savedConfigSummary['placement_count'] ?? 0) . '' + . 'Slots: ' . count(is_array($savedConfigSummary['slot_counts'] ?? null) ? $savedConfigSummary['slot_counts'] : []) . '' + . 'Token Overrides: ' . (int)($savedConfigSummary['token_override_count'] ?? 0) . '' + . '
'; + } else { + echo '' + . 'Total: ' . count($savedChanges) . '' + . 'P: ' . (int)$savedCounts['pending'] . '' + . 'A: ' . (int)$savedCounts['accepted'] . '' + . 'R: ' . (int)$savedCounts['rejected'] . '' + . '
'; + } + echo ''; + echo 'Select a saved import to review its changes.
'; + } else { + $selectedPayloadType = is_string($selectedHistoryImport['payload_type'] ?? null) ? (string)$selectedHistoryImport['payload_type'] : 'review_export'; + $selectedConfigSummary = is_array($selectedHistoryImport['config_summary'] ?? null) ? $selectedHistoryImport['config_summary'] : []; + echo 'ID: ' . htmlspecialchars((string)$selectedHistoryImport['id'], ENT_QUOTES) . ''
+ . ' Theme: ' . htmlspecialchars((string)($selectedHistoryImport['theme'] ?? ''), ENT_QUOTES)
+ . ' Scope: ' . htmlspecialchars((string)($selectedHistoryImport['scope'] ?? ''), ENT_QUOTES)
+ . '
Payload: ' . htmlspecialchars((string)($selectedHistoryImport['payload_label'] ?? 'Export'), ENT_QUOTES) . '
'; + if ('theme_config_export' === $selectedPayloadType) { + $selectedSlotCounts = is_array($selectedConfigSummary['slot_counts'] ?? null) ? $selectedConfigSummary['slot_counts'] : []; + $selectedPreferenceKeys = is_array($selectedConfigSummary['preference_keys'] ?? null) ? $selectedConfigSummary['preference_keys'] : []; + echo 'This import is an XTF theme config export. It captures Marketplace placements and theme preferences, not cssHolmes change rows.
'; + echo 'Theme Name: ' . htmlspecialchars((string)($selectedConfigSummary['theme_name'] ?? ''), ENT_QUOTES) + . ' Version: ' . htmlspecialchars((string)($selectedConfigSummary['version'] ?? ''), ENT_QUOTES) + . ' Placements: ' . (int)($selectedConfigSummary['placement_count'] ?? 0) + . '
'; + if ([] !== $selectedSlotCounts) { + echo '| Slot | Placements |
|---|---|
| ' . htmlspecialchars((string)$slotName, ENT_QUOTES) . ' | ' . (int)$slotCount . ' |
Preferences: ' . htmlspecialchars(implode(', ', array_map('strval', $selectedPreferenceKeys)), ENT_QUOTES) . '
'; + } + echo 'Use Marketplace import/export for applying this payload. cssHolmes stores it here so you can inspect the structure and compare it with review exports.
'; + } else { + echo '' + . 'Pending: ' . (int)$selectedHistoryCounts['pending'] . '' + . 'Accepted: ' . (int)$selectedHistoryCounts['accepted'] . '' + . 'Rejected: ' . (int)$selectedHistoryCounts['rejected'] . '' + . '
'; + echo ''; + } + if ('theme_config_export' !== $selectedPayloadType && [] === $filteredHistoryChanges) { + echo 'No changes match the current filter.
'; + } elseif ('theme_config_export' !== $selectedPayloadType) { + echo '| Kind | ' + . 'Selector | ' + . 'Widget | ' + . 'Slot | ' + . 'Summary | ' + . 'Before | ' + . 'After | ' + . 'Status | ' + . 'Action | ' + . '
|---|---|---|---|---|---|---|---|---|
| ' . htmlspecialchars((string)($savedChange['kind'] ?? ''), ENT_QUOTES) . ' | ' + . '' . htmlspecialchars((string)($savedChange['selector'] ?? ''), ENT_QUOTES) . ' | '
+ . '' . htmlspecialchars((string)($savedChange['widget'] ?? ''), ENT_QUOTES) . ' | ' + . '' . htmlspecialchars((string)($savedChange['slot'] ?? ''), ENT_QUOTES) . ' | ' + . '' . nl2br(htmlspecialchars($inspectionSummary, ENT_QUOTES)) . ' | ' + . '' . nl2br(htmlspecialchars((string)($savedChange['before'] ?? ''), ENT_QUOTES)) . ' | ' + . '' . nl2br(htmlspecialchars((string)($savedChange['after'] ?? ''), ENT_QUOTES)) . ' | ' + . '' . htmlspecialchars($status, ENT_QUOTES) . ' | ' + . ''
+ . ''
+ . (is_array($applyPreview) && true === ($applyPreview['supported'] ?? false)
+ ? ' '
+ . ' '
+ : '')
+ . ($canApplyNow
+ ? ''
+ : '')
+ . ($canApply && !$canApplyNow
+ ? 'Preview Apply' + . ''
+ . htmlspecialchars((string)($applyPreview['message'] ?? ''), ENT_QUOTES)
+ . ('' !== trim((string)($applyPreview['target'] ?? ''))
+ ? ' '
+ . 'Target: ' . htmlspecialchars((string)$applyPreview['target'], ENT_QUOTES) . ''
+ : '')
+ . ''
+ . ' '
+ . 'Current '
+ . '' + . htmlspecialchars((string)($applyPreview['current'] ?? ''), ENT_QUOTES) + . '' + . ' '
+ . ' '
+ . 'Proposed '
+ . '' + . htmlspecialchars((string)($applyPreview['proposed'] ?? ''), ENT_QUOTES) + . '' + . ' Apply is disabled until the preview reports a safe, unambiguous target. '
+ : '')
+ . ('' !== $appliedAt
+ ? ''
+ : '')
+ . ('' !== $appliedAt
+ ? 'Applied '
+ . htmlspecialchars($appliedAt, ENT_QUOTES)
+ . ('' !== $appliedTarget ? ' '
+ : '')
+ . ('' !== $rolledBackAt
+ ? '' . htmlspecialchars($appliedTarget, ENT_QUOTES) . '' : '')
+ . ('' !== $appliedMessage ? '' . htmlspecialchars($appliedMessage, ENT_QUOTES) : '') + . ' Rolled back '
+ . htmlspecialchars($rolledBackAt, ENT_QUOTES)
+ . ('' !== $rolledBackMessage ? ' '
+ : '')
+ . '' . htmlspecialchars($rolledBackMessage, ENT_QUOTES) : '') + . ' | '
+ . '
' + . htmlspecialchars((string)$selectedHistoryImport['raw_payload'], ENT_QUOTES) + . ''; + echo '
' + . htmlspecialchars((string)($historyDraft['content'] ?? ''), ENT_QUOTES) + . ''; + echo '
The server-side analyzer workbench runs its checks against frontend and admin themes: manifest validation, design token usage, template structure, and accessibility-oriented review. XTF themes (with theme.json) get every check; normal XOOPS themes are listed as generic and get the template, accessibility and custom-property checks. The analyzers understand Smarty tags and follow <{include file=...}> inside the theme. The widget scan below applies widget-output rules directly to pasted HTML fragments.
The same checks run from the command line, for example as a release check: php modules/' . htmlspecialchars($moduleDirName, ENT_QUOTES) . '/bin/holmes-scan.php themes/mytheme
No themes found: no folder with a theme.tpl, theme.html or theme.json was found for this scope.
The overlay still supports HTML5, accessibility, layout, inspect, measure, copy, export, and widget HTML scan workflows.
'; + echo 'Selected: ' . htmlspecialchars($selectedTheme->label, ENT_QUOTES) . '
';
+ echo 'Path: ' . htmlspecialchars($selectedTheme->path, ENT_QUOTES) . '
' + . 'Errors: ' . (int)$findingCounts[Severity::Error->value] . '' + . 'Warnings: ' . (int)$findingCounts[Severity::Warning->value] . '' + . 'Info: ' . (int)$findingCounts[Severity::Info->value] . '' + . '
'; + if ($selectedTheme->generic) { + echo 'Generic XOOPS theme: no theme.json, so the XTF manifest checks are skipped and the token checks use the theme\'s own custom-property prefix.
Previous scan (' . htmlspecialchars((string)($previousScan['created_at'] ?? ''), ENT_QUOTES) . '): ' . implode(', ', $deltaParts) . '
'; + } + + if ([] === $findings) { + echo 'No analyzer findings for this theme.
'; + } else { + foreach ($analyzerResults as $analyzerLabel => $analyzerFindings) { + if ([] === $analyzerFindings) { + continue; + } + + echo '| Severity | ' + . 'Rule | ' + . 'Message | ' + . 'Target | ' + . 'Suggestion | ' + . '
|---|---|---|---|---|
| ' . htmlspecialchars(strtoupper($finding->severity->value), ENT_QUOTES) . ' | ' + . '' . htmlspecialchars($finding->ruleId, ENT_QUOTES) . ' | '
+ . '' . htmlspecialchars($finding->message, ENT_QUOTES) . ' | ' + . '' . $target . ' | '
+ . '' . htmlspecialchars((string)$finding->suggestion, ENT_QUOTES) . ' | ' + . '
Paste rendered widget HTML to run the widget-output analyzer without waiting for a full page scan.
'; +echo ''; +if ([] !== $widgetFindings) { + echo '' + . 'Errors: ' . (int)$widgetFindingCounts[Severity::Error->value] . '' + . 'Warnings: ' . (int)$widgetFindingCounts[Severity::Warning->value] . '' + . 'Info: ' . (int)$widgetFindingCounts[Severity::Info->value] . '' + . '
'; + echo '| Severity | ' + . 'Rule | ' + . 'Message | ' + . 'Suggestion | ' + . '
|---|---|---|---|
| ' . htmlspecialchars(strtoupper($widgetFinding->severity->value), ENT_QUOTES) . ' | ' + . '' . htmlspecialchars($widgetFinding->ruleId, ENT_QUOTES) . ' | '
+ . '' . htmlspecialchars($widgetFinding->message, ENT_QUOTES) . ' | ' + . '' . htmlspecialchars((string)$widgetFinding->suggestion, ENT_QUOTES) . ' | ' + . '
No widget-output findings for this HTML snippet.
'; +} +echo '' . htmlspecialchars($currentTab['desc'], ENT_QUOTES) . '
'; + +// Render the permission form +$permForm->display(); + +require_once __DIR__ . '/admin_footer.php'; diff --git a/admin/theme_sets.php b/admin/theme_sets.php new file mode 100644 index 0000000..3c65e88 --- /dev/null +++ b/admin/theme_sets.php @@ -0,0 +1,34 @@ +displayNavigation(basename(__FILE__)); + +$labels = []; +$prefix = '_AM_CSSHOLMES_SETS_'; +foreach (get_defined_constants() as $key => $value) { + if (str_starts_with($key, $prefix) && is_string($value)) { + $labels[strtolower(substr($key, strlen($prefix)))] = $value; + } +} +$baseUrl = XOOPS_URL . '/modules/cssholmes'; +$config = [ + 'libraryEndpoint' => $baseUrl . '/sets.php', + 'designerEndpoint' => $baseUrl . '/designer.php', + 'previewUrl' => XOOPS_URL . '/modules/xstyles/', + 'token' => '', + 'labels' => $labels, +]; +echo ''; +echo ''; +echo ''; +echo ''; + +require_once __DIR__ . '/admin_footer.php'; diff --git a/assets/css/admin.css b/assets/css/admin.css index b0845b7..93eab7e 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -9,8 +9,8 @@ * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * - * @copyright XOOPS Project (https://xoops.org) - * @license http://www.fsf.org/copyleft/gpl.html GNU public license + * @copyright 2000-2026 XOOPS Project (https://xoops.org) + * @license GNU GPL 2.0 or later (https://www.gnu.org/licenses/gpl-2.0.html) * @package Cssholmes * @since 2.3.0 * @author krisI. Measure: M. Click an element to lock it.';
+ if (genericMode) {
+ details.innerHTML += '${escapeHtml(selectorPath(target))}${escapeHtml(token.key)}`).join('')}${escapeHtml(change.selector)}${escapeHtml(change.summary)}${escapeHtml(exportPayload())}`;
+ }
+ }
+ }
+
+ function toggleProfile(profile) {
+ if (genericMode && XTF_ONLY_PROFILES.includes(profile)) {
+ return;
+ }
+ activeProfiles = activeProfiles.includes(profile)
+ ? activeProfiles.filter((candidate) => candidate !== profile)
+ : [...activeProfiles, profile];
+ markCountsDirty();
+ persistProfiles(activeProfiles);
+ syncRootState();
+ ensureStylesheets();
+ updateUrl();
+ window.setTimeout(refreshToolbar, 50);
+ }
+
+ function clearInspector() {
+ inspectEnabled = false;
+ measureEnabled = false;
+ hovered = null;
+ selected = null;
+ tokenEditorOpen = false;
+ typographyEditorOpen = false;
+ // Clear diagnostic overlays
+ if (overflowOverlays.length > 0) {
+ overflowOverlays.forEach((el) => el.remove());
+ overflowOverlays = [];
+ document.querySelectorAll('[data-holmes-overflow]').forEach((el) => {
+ el.style.removeProperty('outline');
+ delete el.dataset.holmesOverflow;
+ });
+ }
+ if (zIndexOverlays.length > 0) {
+ zIndexOverlays.forEach((el) => el.remove());
+ zIndexOverlays = [];
+ }
+ if (gridOverlays.length > 0) {
+ gridOverlays.forEach((el) => el.remove());
+ gridOverlays = [];
+ }
+ if (colorBlindMode !== '') {
+ colorBlindMode = '';
+ root.style.removeProperty('filter');
+ }
+ syncRootState();
+ draw();
+ }
+
+ function copySelector() {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const feedback = flashButton(copyTextButton, 'copied');
+ writeClipboard(inspectionSnapshotText(target))
+ .then(() => feedback?.success())
+ .catch(() => feedback?.failure());
+ }
+
+ function copyInspectionJson() {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const feedback = flashButton(copyJsonButton, 'json copied');
+ writeClipboard(inspectionSnapshotJson(target))
+ .then(() => feedback?.success())
+ .catch(() => feedback?.failure());
+ }
+
+ function exportSelected() {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const button = toolbar?.querySelector('[data-action="export-selected"]');
+ const feedback = flashButton(button, 'exported');
+ writeClipboard(selectedExportPayload(target))
+ .then(() => feedback?.success())
+ .catch(() => feedback?.failure());
+ }
+
+ function editToken() {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const bindings = tokenBindingsFor(target);
+ if (bindings.length === 0) {
+ return;
+ }
+ typographyEditorOpen = false;
+ tokenEditorOpen = !tokenEditorOpen;
+ refreshToolbar();
+ }
+
+ function editTypography() {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ tokenEditorOpen = false;
+ typographyEditorOpen = !typographyEditorOpen;
+ refreshToolbar();
+ }
+
+ function applyTokenEdit(tokenKey, nextValue) {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const binding = tokenBindingsFor(target).find((candidate) => candidate.key === tokenKey);
+ if (!binding) {
+ return;
+ }
+
+ const trimmedValue = String(nextValue || '').trim();
+ const normalizedValue = normalizeColor(trimmedValue);
+ if (trimmedValue === '' || normalizedValue === '') {
+ return;
+ }
+
+ const tokenIndex = themeTokens.findIndex((token) => token.key === binding.key);
+ if (tokenIndex < 0) {
+ return;
+ }
+
+ const previousToken = {
+ value: themeTokens[tokenIndex].value,
+ normalized: themeTokens[tokenIndex].normalized,
+ };
+ const previousInlineStyles = {};
+ binding.properties.forEach((property) => {
+ previousInlineStyles[property] = target.style[property] || '';
+ target.style[property] = trimmedValue;
+ });
+
+ themeTokens[tokenIndex] = {
+ ...themeTokens[tokenIndex],
+ value: trimmedValue,
+ normalized: normalizedValue,
+ };
+
+ recordChange({
+ kind: 'token',
+ element: target,
+ selector: selectorPath(target),
+ widget: widgetTargetFor(target)?.dataset.widget || shortSelector(widgetTargetFor(target)),
+ slot: slotTargetFor(target)?.dataset.slot || shortSelector(slotTargetFor(target)),
+ before: previousToken.value,
+ after: trimmedValue,
+ summary: `${binding.key}: ${previousToken.value} -> ${trimmedValue}`,
+ tokenKey: binding.key,
+ tokenProperties: binding.properties,
+ previousToken,
+ previousInlineStyles,
+ inspection: inspectionSnapshot(target),
+ });
+ draw();
+ }
+
+ function applyTypographyEdit(styleKey, nextValue) {
+ const target = currentTarget();
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const binding = typographyBindingsFor(target).find((candidate) => candidate.key === styleKey);
+ if (!binding) {
+ return;
+ }
+
+ const trimmedValue = String(nextValue || '').trim();
+ if (trimmedValue === '') {
+ return;
+ }
+
+ const previousInlineStyle = target.style.getPropertyValue(binding.cssProperty);
+ target.style.setProperty(binding.cssProperty, trimmedValue);
+
+ recordChange({
+ kind: 'style',
+ element: target,
+ selector: selectorPath(target),
+ widget: widgetTargetFor(target)?.dataset.widget || shortSelector(widgetTargetFor(target)),
+ slot: slotTargetFor(target)?.dataset.slot || shortSelector(slotTargetFor(target)),
+ before: binding.value,
+ after: trimmedValue,
+ summary: `${binding.label}: ${binding.value} -> ${trimmedValue}`,
+ styleProperty: binding.cssProperty,
+ previousInlineStyle,
+ inspection: inspectionSnapshot(target),
+ });
+ draw();
+ }
+
+ function recordChange(change) {
+ markCountsDirty();
+ changeLog.unshift(change);
+ if (changeLog.length > 20) {
+ changeLog = changeLog.slice(0, 20);
+ }
+ refreshToolbar();
+ }
+
+ function undoLastChange() {
+ markCountsDirty();
+ const change = changeLog.shift();
+ if (!change || !(change.element instanceof HTMLElement) || !document.contains(change.element)) {
+ refreshToolbar();
+ return;
+ }
+
+ if (change.kind === 'text') {
+ change.element.innerText = change.before;
+ selected = change.element;
+ draw();
+ return;
+ }
+
+ if (change.kind === 'token') {
+ if (change.previousInlineStyles && typeof change.previousInlineStyles === 'object') {
+ Object.entries(change.previousInlineStyles).forEach(([property, value]) => {
+ change.element.style[property] = String(value);
+ });
+ }
+
+ if (change.tokenKey && change.previousToken && typeof change.previousToken === 'object') {
+ const tokenIndex = themeTokens.findIndex((token) => token.key === change.tokenKey);
+ if (tokenIndex >= 0) {
+ themeTokens[tokenIndex] = {
+ ...themeTokens[tokenIndex],
+ value: String(change.previousToken.value || ''),
+ normalized: String(change.previousToken.normalized || ''),
+ };
+ }
+ }
+
+ selected = change.element;
+ draw();
+ return;
+ }
+
+ if (change.kind === 'style') {
+ const styleProperty = String(change.styleProperty || '');
+ if (styleProperty !== '') {
+ if (String(change.previousInlineStyle || '') === '') {
+ change.element.style.removeProperty(styleProperty);
+ } else {
+ change.element.style.setProperty(styleProperty, String(change.previousInlineStyle));
+ }
+ }
+
+ selected = change.element;
+ draw();
+ return;
+ }
+
+ refreshToolbar();
+ }
+
+ function copyExport() {
+ if (changeLog.length === 0) {
+ return;
+ }
+
+ writeClipboard(exportPayload()).then(() => {
+ const button = toolbar?.querySelector('[data-action="copy-export"]');
+ if (!(button instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ const label = button.textContent;
+ button.textContent = 'exported';
+ window.setTimeout(() => {
+ button.textContent = label;
+ }, 900);
+ }).catch(() => {
+ const button = toolbar?.querySelector('[data-action="copy-export"]');
+ if (!(button instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ const label = button.textContent;
+ button.textContent = 'export failed';
+ window.setTimeout(() => {
+ button.textContent = label;
+ }, 1200);
+ });
+ }
+
+ function editText() {
+ const target = editableTextTarget(currentTarget());
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ const beforeText = target.innerText;
+ const nextText = window.prompt('Edit text content', beforeText);
+ if (nextText === null) {
+ return;
+ }
+ target.innerText = nextText;
+ recordChange({
+ kind: 'text',
+ element: target,
+ selector: selectorPath(target),
+ widget: widgetTargetFor(target)?.dataset.widget || shortSelector(widgetTargetFor(target)),
+ slot: slotTargetFor(target)?.dataset.slot || shortSelector(slotTargetFor(target)),
+ before: beforeText,
+ after: nextText,
+ summary: `${summarizeText(beforeText)} -> ${summarizeText(nextText)}`,
+ });
+ draw();
+ }
+
+ /**
+ * Shortcuts only work while the toolbar is open. When it is minimised,
+ * cssHolmes stays out of the way and every key reaches the page.
+ */
+ function shortcutsActive() {
+ return toolbar instanceof HTMLElement && !toolbar.classList.contains('cssholmes--minimized');
+ }
+
+ function isTypingTarget(event) {
+ const candidates = [event.target, document.activeElement];
+ if (typeof event.composedPath === 'function') {
+ candidates.unshift(event.composedPath()[0]);
+ }
+ return candidates.some((node) => node instanceof HTMLElement
+ && (['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName) || node.isContentEditable));
+ }
+
+ /**
+ * Turn off inspect and measure, drop the selection and close the editors.
+ * Diagnostic overlays (overflow, z-index, grid, colour simulation) stay;
+ * the reset button clears those. Returns true when anything changed.
+ */
+ function leaveInspectionModes() {
+ const active = inspectEnabled || measureEnabled || selected !== null || tokenEditorOpen || typographyEditorOpen;
+ if (!active) {
+ return false;
+ }
+ inspectEnabled = false;
+ measureEnabled = false;
+ hovered = null;
+ selected = null;
+ tokenEditorOpen = false;
+ typographyEditorOpen = false;
+ syncRootState();
+ draw();
+ return true;
+ }
+
+ function attachListeners() {
+ document.addEventListener('mousemove', (event) => {
+ pointerX = event.clientX;
+ pointerY = event.clientY;
+ if (inspectEnabled) {
+ // Freeze hover when an editor panel is open — moving the mouse
+ // to interact with the color picker or typography controls must
+ // not re-inspect the element under the cursor.
+ if (tokenEditorOpen || typographyEditorOpen) {
+ return;
+ }
+ hovered = inspectableAt(pointerX, pointerY);
+ draw();
+ }
+ }, true);
+
+ document.addEventListener('click', (event) => {
+ if (!inspectEnabled) {
+ return;
+ }
+ if (isInternal(event.target)) {
+ return;
+ }
+ const target = inspectableAt(event.clientX, event.clientY);
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ selected = target;
+ draw();
+ }, true);
+
+ document.addEventListener('keydown', (event) => {
+ // Escape leaves inspect/measure mode and closes the editors. It is
+ // only consumed when one of them is active, so page dialogs keep
+ // their own Escape handling otherwise.
+ if (event.key === 'Escape') {
+ if (leaveInspectionModes()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ return;
+ }
+ if (!shortcutsActive() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing
+ || isTypingTarget(event)) {
+ return;
+ }
+ const key = String(event.key || '').toLowerCase();
+ if (!SHORTCUT_KEYS.includes(key)) {
+ return;
+ }
+ event.preventDefault();
+ if (key === 'i') {
+ inspectEnabled = !inspectEnabled;
+ hovered = inspectEnabled ? inspectableAt(pointerX, pointerY) : null;
+ syncRootState();
+ draw();
+ } else if (key === 'm') {
+ measureEnabled = !measureEnabled;
+ syncRootState();
+ draw();
+ } else if (key === 'u') {
+ undoLastChange();
+ } else if (key === 't') {
+ editToken();
+ } else if (key === 'p') {
+ editTypography();
+ }
+ }, true);
+
+ window.addEventListener('resize', draw);
+ window.addEventListener('scroll', draw, true);
+ }
+
+ function loadThemeTokens() {
+ if (!themeManifestUrl || !window.fetch) {
+ return;
+ }
+
+ window.fetch(themeManifestUrl, { credentials: 'same-origin' })
+ .then((response) => response.ok ? response.json() : null)
+ .then((manifest) => {
+ if (!manifest || typeof manifest !== 'object' || !manifest.tokens || typeof manifest.tokens !== 'object') {
+ return;
+ }
+
+ themeTokens = Object.entries(manifest.tokens)
+ .filter((entry) => typeof entry[0] === 'string' && typeof entry[1] === 'string')
+ .map(([key, value]) => ({
+ key,
+ value,
+ normalized: normalizeColor(value),
+ }))
+ .filter((token) => token.normalized !== '');
+
+ draw();
+ })
+ .catch(() => {});
+ }
+
+ // --- Color Blindness Simulation ---
+
+ const COLOR_BLIND_FILTERS = {
+ protanopia: '0.567,0.433,0,0,0, 0.558,0.442,0,0,0, 0,0.242,0.758,0,0, 0,0,0,1,0',
+ deuteranopia: '0.625,0.375,0,0,0, 0.7,0.3,0,0,0, 0,0.3,0.7,0,0, 0,0,0,1,0',
+ tritanopia: '0.95,0.05,0,0,0, 0,0.433,0.567,0,0, 0,0.475,0.525,0,0, 0,0,0,1,0',
+ achromatopsia: '0.299,0.587,0.114,0,0, 0.299,0.587,0.114,0,0, 0.299,0.587,0.114,0,0, 0,0,0,1,0',
+ };
+
+ function toggleColorBlind(mode) {
+ const svgId = 'cssholmes-cvd-filters';
+ if (colorBlindMode === mode) {
+ colorBlindMode = '';
+ root.style.removeProperty('filter');
+ const existing = document.getElementById(svgId);
+ if (existing) existing.remove();
+ return;
+ }
+ colorBlindMode = mode;
+ let svg = document.getElementById(svgId);
+ if (!svg) {
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.id = svgId;
+ svg.setAttribute('style', 'position:absolute;width:0;height:0;pointer-events:none');
+ svg.innerHTML = Object.entries(COLOR_BLIND_FILTERS).map(([name, matrix]) =>
+ `