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 @@ -![alt XOOPS CMS](https://xoops.org/images/logoXoops4GithubRepository.png) -# Contributing to [XOOPS CMS](https://xoops.org) -[![XOOPS CMS Module](https://img.shields.io/badge/XOOPS%20CMS-Module-blue.svg)](https://xoops.org) -[![Software License](https://img.shields.io/badge/license-GPL-brightgreen.svg?style=flat)](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 @@ -![alt XOOPS CMS](https://xoops.org/images/logoXoops4GithubRepository.png) -## cssHolmes module for [XOOPS CMS 2.5.10+](https://xoops.org) -[![XOOPS CMS Module](https://img.shields.io/badge/XOOPS%20CMS-Module-blue.svg)](https://xoops.org) -[![Software License](https://img.shields.io/badge/license-GPL-brightgreen.svg?style=flat)](http://www.gnu.org/licenses/gpl-2.0.html) +# cssHolmes for XOOPS -[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/XoopsModules25x/cssholmes.svg?style=flat)](https://scrutinizer-ci.com/g/XoopsModules25x/cssholmes/?branch=master) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/95b12220e0ac4056b9af52af708379c9)](https://www.codacy.com/app/XoopsModules25x/cssholmes) -[![Code Climate](https://img.shields.io/codeclimate/github/XoopsModules25x/cssholmes.svg?style=flat)](https://codeclimate.com/github/XoopsModules25x/cssholmes) -[![Latest Pre-Release](https://img.shields.io/github/tag/XoopsModules25x/cssholmes.svg?style=flat)](https://github.com/XoopsModules25x/cssholmes/tags/) -[![Latest Version](https://img.shields.io/github/release/XoopsModules25x/cssholmes.svg?style=flat)](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. -[![Tutorial Available](https://xoops.org/images/tutorial-available-blue.svg)](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). -[![Translations on Transifex](https://xoops.org/images/translations-transifex-blue.svg)](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" . " XOOPS\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 kris **/ use Xmf\Module\Admin; @@ -21,12 +20,12 @@ Helper }; -require dirname(__DIR__) . '/preloads/autoloader.php'; +require \dirname(__DIR__) . '/preloads/autoloader.php'; -require dirname(__DIR__, 3) . '/include/cp_header.php'; -require dirname(__DIR__) . '/include/common.php'; +require \dirname(__DIR__, 3) . '/include/cp_header.php'; +require \dirname(__DIR__) . '/include/common.php'; -$moduleDirName = basename(dirname(__DIR__)); +$moduleDirName = \basename(\dirname(__DIR__)); /** @varHelper $helper */ $helper = Helper::getInstance(); @@ -42,5 +41,3 @@ $helper->loadLanguage('admin'); $helper->loadLanguage('modinfo'); $helper->loadLanguage('common'); - - diff --git a/admin/footer.php b/admin/footer.php deleted file mode 100644 index c091c22..0000000 --- a/admin/footer.php +++ /dev/null @@ -1,4 +0,0 @@ - - **/ -require_once dirname(dirname(dirname(__DIR__))) . '/include/cp_header.php'; -$myts = \MyTextSanitizer::getInstance(); - -if ($xoopsUser) { - $xoopsModule = XoopsModule::getByDirname('cssholmes'); - if (!$xoopsUser->isAdmin($xoopsModule->mid())) { - redirect_header(XOOPS_URL . '/', 3, _NOPERM); - } -} else { - redirect_header(XOOPS_URL . '/', 3, _NOPERM); -} - -require_once XOOPS_ROOT_PATH . '/class/template.php'; - -if (!isset($xoopsTpl)) { - $xoopsTpl = new \XoopsTpl(); -} -$xoopsTpl->caching = 0; - -xoops_cp_header(); - -// Define Stylesheet and JScript -$xoTheme->addStylesheet(XOOPS_URL . '/modules/' . $xoopsModule->getVar('dirname') . '/assets/css/admin.css'); diff --git a/admin/index.html b/admin/index.html deleted file mode 100644 index 2c5cdd3..0000000 --- a/admin/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/admin/index.php b/admin/index.php index e24a332..91a0c7e 100644 --- a/admin/index.php +++ b/admin/index.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 kris , XOOPS Development Team **/ -use XoopsModules\Cssholmes\Common; +use Xmf\Module\Admin; +use Xmf\Request; +use XoopsModules\Cssholmes\Analyzer\AccessibilityAnalyzer; +use XoopsModules\Cssholmes\Analyzer\AnalysisContext; +use XoopsModules\Cssholmes\Analyzer\DesignTokenAnalyzer; +use XoopsModules\Cssholmes\Analyzer\Severity; +use XoopsModules\Cssholmes\Analyzer\TemplateAnalyzer; +use XoopsModules\Cssholmes\Analyzer\ThemeCatalog; +use XoopsModules\Cssholmes\Analyzer\ThemeManifestAnalyzer; +use XoopsModules\Cssholmes\Analyzer\WidgetOutputAnalyzer; +use XoopsModules\Cssholmes\Workbench\PatchDraftBuilder; +use XoopsModules\Cssholmes\Workbench\PatchApplier; +use XoopsModules\Cssholmes\Workbench\ImportStore; +use XoopsModules\Cssholmes\Workbench\PatchSuggester; +use XoopsModules\Cssholmes\Workbench\ScanStore; require_once __DIR__ . '/admin_header.php'; xoops_cp_header(); -$adminObject = \Xmf\Module\Admin::getInstance(); +$adminObject = Admin::getInstance(); $adminObject->displayNavigation(basename(__FILE__)); +echo \XoopsModules\Cssholmes\Utility::registeredUserToolNotice($moduleDirName, (int)$xoopsModule->getVar('mid')); $adminObject->displayIndex(); +$queryKey = (string)$helper->getConfig('holmes_query_key'); +if ('' === trim($queryKey)) { + $queryKey = 'holmes'; +} + +$themeCatalog = new ThemeCatalog(); +$patchApplier = new PatchApplier(); +$patchDraftBuilder = new PatchDraftBuilder(); +$patchSuggester = new PatchSuggester(); +$importStore = new ImportStore(\dirname(__DIR__)); +$importErrors = []; +$importPayload = ''; +$importSummary = null; +$importSuggestions = []; +$importDrafts = []; +$workbenchNotice = ''; +$widgetScanName = ''; +$widgetScanHtml = ''; +$widgetFindings = []; +$widgetFindingCounts = [ + Severity::Error->value => 0, + Severity::Warning->value => 0, + Severity::Info->value => 0, +]; +$selectedHistoryId = trim(Request::getString('history_import', '', 'GET')); +$historyStatusFilter = strtolower(trim(Request::getString('history_status', 'all', 'GET'))); +if (!in_array($historyStatusFilter, ['all', 'pending', 'accepted', 'rejected'], true)) { + $historyStatusFilter = 'all'; +} +$normalizeReviewChanges = static function (array $changes): array { + $normalizedChanges = []; + foreach ($changes as $change) { + if (!is_array($change)) { + continue; + } + + $normalizedChanges[] = [ + 'kind' => is_string($change['kind'] ?? null) ? trim((string)$change['kind']) : 'unknown', + 'selector' => is_string($change['selector'] ?? null) ? trim((string)$change['selector']) : '', + 'widget' => is_string($change['widget'] ?? null) ? trim((string)$change['widget']) : '', + 'slot' => is_string($change['slot'] ?? null) ? trim((string)$change['slot']) : '', + 'before' => is_string($change['before'] ?? null) ? (string)$change['before'] : '', + 'after' => is_string($change['after'] ?? null) ? (string)$change['after'] : '', + 'summary' => is_string($change['summary'] ?? null) ? trim((string)$change['summary']) : '', + 'style_property' => is_string($change['style_property'] ?? null) ? trim((string)$change['style_property']) : '', + 'token_key' => is_string($change['token_key'] ?? null) ? trim((string)$change['token_key']) : '', + 'token_properties' => is_array($change['token_properties'] ?? null) ? array_values(array_filter($change['token_properties'], 'is_string')) : [], + 'inspection' => is_array($change['inspection'] ?? null) ? $change['inspection'] : [], + 'status' => 'pending', + ]; + } + + return $normalizedChanges; +}; +$buildKindCounts = static function (array $changes): array { + $kindCounts = []; + foreach ($changes as $change) { + $kind = is_string($change['kind'] ?? null) ? trim((string)$change['kind']) : 'unknown'; + $kindCounts[$kind] = ($kindCounts[$kind] ?? 0) + 1; + } + + return $kindCounts; +}; +$detectThemeScope = static function (ThemeCatalog $catalog, string $themeId): string { + foreach (['site', 'admin'] as $scope) { + if (null !== $catalog->find($scope, $themeId)) { + return $scope; + } + } + + return ''; +}; +$buildThemeConfigSummary = static function (array $decoded, ThemeCatalog $catalog) use ($detectThemeScope): array { + $placements = is_array($decoded['placements'] ?? null) ? $decoded['placements'] : []; + $preferences = is_array($decoded['preferences'] ?? null) ? $decoded['preferences'] : []; + $tokenOverrides = is_array($decoded['token_overrides'] ?? null) ? $decoded['token_overrides'] : []; + $slotCounts = []; + $placementCount = 0; + foreach ($placements as $slotName => $slotPlacements) { + if (!is_string($slotName) || !is_array($slotPlacements)) { + continue; + } + + $validPlacements = array_values(array_filter($slotPlacements, 'is_array')); + $slotCounts[$slotName] = count($validPlacements); + $placementCount += count($validPlacements); + } + + $themeId = is_string($decoded['theme_id'] ?? null) ? trim((string)$decoded['theme_id']) : ''; + + return [ + 'payload_type' => 'theme_config_export', + 'payload_label' => 'XTF theme config export', + 'scope' => $detectThemeScope($catalog, $themeId), + 'theme' => $themeId, + 'generated_at' => is_string($decoded['exported_at'] ?? null) ? trim((string)$decoded['exported_at']) : '', + 'changes' => [], + 'kind_counts' => [], + 'config_summary' => [ + 'theme_id' => $themeId, + 'theme_name' => is_string($decoded['name'] ?? null) ? trim((string)$decoded['name']) : '', + 'version' => is_string($decoded['version'] ?? null) ? trim((string)$decoded['version']) : '', + 'schema_version' => is_scalar($decoded['schema_version'] ?? null) ? (string)$decoded['schema_version'] : '', + 'placement_count' => $placementCount, + 'slot_counts' => $slotCounts, + 'preference_keys' => array_values(array_filter(array_map('strval', array_keys($preferences)), static fn (string $key): bool => '' !== trim($key))), + 'token_override_count' => count(array_values(array_filter($tokenOverrides, 'is_array'))), + ], + ]; +}; +if ('POST' === strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'))) { + $operation = Request::getString('op', '', 'POST'); + if (!$GLOBALS['xoopsSecurity']->check()) { + $importErrors[] = implode(' ', $GLOBALS['xoopsSecurity']->getErrors()); + } elseif ('import_export' === $operation) { + $importPayload = trim(Request::getText('export_payload', '', 'POST')); + if ('' === $importPayload) { + $importErrors[] = 'Export payload is empty.'; + } else { + try { + $decoded = json_decode($importPayload, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded)) { + throw new \RuntimeException('Payload must decode to an object.'); + } + + if (is_array($decoded['changes'] ?? null)) { + $normalizedChanges = $normalizeReviewChanges($decoded['changes']); + $importSummary = [ + 'payload_type' => 'review_export', + 'payload_label' => 'cssHolmes review export', + 'scope' => is_string($decoded['scope'] ?? null) ? trim((string)$decoded['scope']) : '', + 'theme' => is_string($decoded['theme'] ?? null) ? trim((string)$decoded['theme']) : '', + 'generated_at' => is_string($decoded['generated_at'] ?? null) ? trim((string)$decoded['generated_at']) : '', + 'changes' => $normalizedChanges, + 'kind_counts' => $buildKindCounts($normalizedChanges), + 'config_summary' => [], + ]; + } elseif (isset($decoded['schema_version'], $decoded['theme_id']) && is_array($decoded['placements'] ?? null)) { + $importSummary = $buildThemeConfigSummary($decoded, $themeCatalog); + } else { + throw new \RuntimeException('Unsupported payload. Use a cssHolmes review export or an XTF theme config export.'); + } + + $savedImport = $importStore->saveImport($importSummary, $importPayload); + $selectedHistoryId = (string)($savedImport['id'] ?? ''); + if ('theme_config_export' === (string)($importSummary['payload_type'] ?? '')) { + $workbenchNotice = 'Theme config export stored in the cssHolmes workbench history.'; + $importSuggestions = []; + $importDrafts = []; + } else { + $workbenchNotice = 'Import stored in the cssHolmes workbench history.'; + $importSuggestions = $patchSuggester->suggest( + $themeCatalog->find((string)$importSummary['scope'], (string)$importSummary['theme']), + is_array($importSummary['changes'] ?? null) ? $importSummary['changes'] : [] + ); + $importDrafts = $patchDraftBuilder->build( + $themeCatalog->find((string)$importSummary['scope'], (string)$importSummary['theme']), + is_array($importSummary['changes'] ?? null) ? $importSummary['changes'] : [], + $importSuggestions + ); + } + } catch (\Throwable $throwable) { + $importErrors[] = 'Invalid export payload: ' . $throwable->getMessage(); + } + } + } elseif ('change_status' === $operation) { + $selectedHistoryId = Request::getString('import_id', '', 'POST'); + $updated = $importStore->updateChangeStatus( + $selectedHistoryId, + (int)Request::getString('change_index', '0', 'POST'), + Request::getString('status', 'pending', 'POST') + ); + $workbenchNotice = $updated ? 'Change status updated.' : 'Unable to update change status.'; + } elseif ('apply_change' === $operation) { + $selectedHistoryId = Request::getString('import_id', '', 'POST'); + $changeIndex = (int)Request::getString('change_index', '0', 'POST'); + $confirmedApply = '1' === Request::getString('confirm_apply', '0', 'POST'); + $selectedImport = $importStore->find($selectedHistoryId); + + if (!$confirmedApply) { + $importErrors[] = 'Apply confirmation is required before writing files.'; + } elseif (null === $selectedImport) { + $importErrors[] = 'Unable to find the selected import.'; + } else { + $selectedChanges = is_array($selectedImport['changes'] ?? null) ? $selectedImport['changes'] : []; + $selectedChange = is_array($selectedChanges[$changeIndex] ?? null) ? $selectedChanges[$changeIndex] : null; + + if (null === $selectedChange) { + $importErrors[] = 'Unable to find the selected change.'; + } else { + $status = is_string($selectedChange['status'] ?? null) ? strtolower(trim((string)$selectedChange['status'])) : 'pending'; + if ('accepted' !== $status) { + $importErrors[] = 'Only accepted changes can be applied directly.'; + } else { + $theme = $themeCatalog->find( + is_string($selectedImport['scope'] ?? null) ? (string)$selectedImport['scope'] : '', + is_string($selectedImport['theme'] ?? null) ? (string)$selectedImport['theme'] : '' + ); + $suggestions = $patchSuggester->suggest($theme, [$selectedChange]); + $result = $patchApplier->apply($theme, $selectedChange, is_array($suggestions[0] ?? null) ? $suggestions[0] : []); + + if ($result['success']) { + try { + $recorded = $importStore->markChangeApplied( + $selectedHistoryId, + $changeIndex, + (string)$result['target'], + (string)$result['message'], + is_array($result['rollback'] ?? null) ? $result['rollback'] : [] + ); + if (!$recorded) { + throw new \RuntimeException('Import no longer exists.'); + } + $workbenchNotice = (string)$result['message']; + } catch (\Throwable $error) { + $recovery = $patchApplier->rollback($theme, $selectedChange, $result['rollback']); + $importErrors[] = $recovery['success'] + ? 'History could not be saved; the patch was rolled back.' + : 'History could not be saved and automatic rollback failed. Restore the target from your backup before continuing.'; + } + } else { + $importErrors[] = (string)$result['message']; + } + } + } + } + } elseif ('rollback_change' === $operation) { + $selectedHistoryId = Request::getString('import_id', '', 'POST'); + $changeIndex = (int)Request::getString('change_index', '0', 'POST'); + $confirmedRollback = '1' === Request::getString('confirm_rollback', '0', 'POST'); + $selectedImport = $importStore->find($selectedHistoryId); + + if (!$confirmedRollback) { + $importErrors[] = 'Rollback confirmation is required before restoring a previous file state.'; + } elseif (null === $selectedImport) { + $importErrors[] = 'Unable to find the selected import.'; + } else { + $selectedChanges = is_array($selectedImport['changes'] ?? null) ? $selectedImport['changes'] : []; + $selectedChange = is_array($selectedChanges[$changeIndex] ?? null) ? $selectedChanges[$changeIndex] : null; + + if (null === $selectedChange) { + $importErrors[] = 'Unable to find the selected change.'; + } else { + $appliedMeta = is_array($selectedChange['applied_meta'] ?? null) ? $selectedChange['applied_meta'] : []; + if ([] === $appliedMeta) { + $importErrors[] = 'Rollback metadata is missing for this change.'; + } else { + $theme = $themeCatalog->find( + is_string($selectedImport['scope'] ?? null) ? (string)$selectedImport['scope'] : '', + is_string($selectedImport['theme'] ?? null) ? (string)$selectedImport['theme'] : '' + ); + $result = $patchApplier->rollback($theme, $selectedChange, $appliedMeta); + + if ($result['success']) { + $importStore->markChangeRolledBack($selectedHistoryId, $changeIndex, (string)$result['message']); + $workbenchNotice = (string)$result['message']; + } else { + $importErrors[] = (string)$result['message']; + } + } + } + } + } elseif ('scan_widget_html' === $operation) { + $widgetScanName = trim(Request::getString('widget_name', 'sample-widget', 'POST')); + $widgetScanHtml = trim(Request::getText('widget_html', '', 'POST')); + + if ('' === $widgetScanName) { + $widgetScanName = 'sample-widget'; + } + + if ('' === $widgetScanHtml) { + $importErrors[] = 'Widget HTML is empty.'; + } else { + $widgetAnalyzer = new WidgetOutputAnalyzer(); + $widgetFindings = $widgetAnalyzer->analyze(new AnalysisContext($widgetScanHtml, null, $widgetScanName)); + foreach ($widgetFindings as $widgetFinding) { + $widgetFindingCounts[$widgetFinding->severity->value]++; + } + } + } +} + +$savedImports = $importStore->all(); +$statusCountsForChanges = static function (array $changes): array { + $counts = [ + 'pending' => 0, + 'accepted' => 0, + 'rejected' => 0, + ]; + + foreach ($changes as $change) { + if (!is_array($change)) { + continue; + } + + $status = is_string($change['status'] ?? null) ? strtolower(trim((string)$change['status'])) : 'pending'; + if (!isset($counts[$status])) { + $status = 'pending'; + } + + $counts[$status]++; + } + + return $counts; +}; +$inspectionSummaryForChange = static function (array $change): string { + $summary = is_string($change['summary'] ?? null) ? trim((string)$change['summary']) : ''; + $inspection = is_array($change['inspection'] ?? null) ? $change['inspection'] : []; + $details = []; + + foreach (['size' => 'Size', 'position' => 'Pos', 'margin' => 'Margin', 'padding' => 'Padding', 'font' => 'Font', 'color' => 'Color'] as $key => $label) { + if (is_string($inspection[$key] ?? null) && '' !== trim((string)$inspection[$key])) { + $details[] = $label . ': ' . trim((string)$inspection[$key]); + } + } + if (is_array($inspection['tokens'] ?? null) && [] !== $inspection['tokens']) { + $details[] = 'Tokens: ' . implode(', ', array_map('strval', $inspection['tokens'])); + } + + $detailText = implode(' | ', $details); + if ('' !== $summary && '' !== $detailText) { + return $summary . "\n" . $detailText; + } + + return '' !== $summary ? $summary : $detailText; +}; +$historyTotals = [ + 'imports' => count($savedImports), + 'changes' => 0, + 'pending' => 0, + 'accepted' => 0, + 'rejected' => 0, +]; +foreach ($savedImports as $savedImport) { + $savedChanges = is_array($savedImport['changes'] ?? null) ? $savedImport['changes'] : []; + $historyTotals['changes'] += count($savedChanges); + $statusCounts = $statusCountsForChanges($savedChanges); + $historyTotals['pending'] += $statusCounts['pending']; + $historyTotals['accepted'] += $statusCounts['accepted']; + $historyTotals['rejected'] += $statusCounts['rejected']; +} + +$selectedHistoryImport = '' !== $selectedHistoryId ? $importStore->find($selectedHistoryId) : null; +if (null === $selectedHistoryImport && [] !== $savedImports) { + $selectedHistoryImport = $savedImports[0]; + $selectedHistoryId = (string)($selectedHistoryImport['id'] ?? ''); +} + +$selectedHistoryChanges = is_array($selectedHistoryImport['changes'] ?? null) ? $selectedHistoryImport['changes'] : []; +$selectedHistoryCounts = $statusCountsForChanges($selectedHistoryChanges); +$selectedHistoryTheme = null !== $selectedHistoryImport + ? $themeCatalog->find( + is_string($selectedHistoryImport['scope'] ?? null) ? (string)$selectedHistoryImport['scope'] : '', + is_string($selectedHistoryImport['theme'] ?? null) ? (string)$selectedHistoryImport['theme'] : '' + ) + : null; +$selectedHistorySuggestions = null !== $selectedHistoryImport + ? $patchSuggester->suggest( + $selectedHistoryTheme, + $selectedHistoryChanges + ) + : []; +$selectedHistoryDrafts = null !== $selectedHistoryImport + ? $patchDraftBuilder->build( + $selectedHistoryTheme, + $selectedHistoryChanges, + $selectedHistorySuggestions + ) + : []; +$filteredHistoryChanges = array_values(array_filter( + $selectedHistoryChanges, + static function ($change) use ($historyStatusFilter): bool { + if ('all' === $historyStatusFilter) { + return true; + } + + if (!is_array($change)) { + return false; + } + + $status = is_string($change['status'] ?? null) ? strtolower(trim((string)$change['status'])) : 'pending'; + + return $status === $historyStatusFilter; + } +)); + +$scanScope = strtolower(trim(Request::getString('scan_scope', 'admin', 'GET'))); +if (!in_array($scanScope, ['admin', 'site'], true)) { + $scanScope = 'admin'; +} + +$selectedThemeKey = trim(Request::getString('scan_theme', '', 'GET')); +$scopeThemes = $themeCatalog->allForScope($scanScope); + +$currentThemeGuess = ''; +if ('admin' === $scanScope && isset($GLOBALS['xoTheme']) && is_object($GLOBALS['xoTheme']) && isset($GLOBALS['xoTheme']->folderName)) { + $currentThemeGuess = trim((string)$GLOBALS['xoTheme']->folderName); +} +if ('site' === $scanScope && isset($GLOBALS['xoopsConfig']['theme_set']) && is_string($GLOBALS['xoopsConfig']['theme_set'])) { + $currentThemeGuess = trim($GLOBALS['xoopsConfig']['theme_set']); +} + +if ('' === $selectedThemeKey) { + $selectedThemeKey = $currentThemeGuess; +} + +$selectedTheme = $themeCatalog->find($scanScope, $selectedThemeKey); +if (null === $selectedTheme && [] !== $scopeThemes) { + $selectedTheme = $scopeThemes[0]; +} + +$findings = []; +$analyzerResults = []; +$previousScan = null; +$findingCounts = [ + Severity::Error->value => 0, + Severity::Warning->value => 0, + Severity::Info->value => 0, +]; + +if (null !== $selectedTheme) { + $analyzers = [ + new ThemeManifestAnalyzer(), + new DesignTokenAnalyzer(), + new TemplateAnalyzer(), + new AccessibilityAnalyzer(), + ]; + $context = new AnalysisContext($selectedTheme->path, $selectedTheme->key); + + foreach ($analyzers as $analyzer) { + $result = $analyzer->analyze($context); + $analyzerResults[$analyzer->label()] = $result; + foreach ($result as $finding) { + $findings[] = $finding; + } + } + + foreach ($findings as $finding) { + $findingCounts[$finding->severity->value]++; + } + + // Keep a short scan history when the scan was requested explicitly, so the + // workbench can show what changed since the previous scan of this theme. + if ('' !== trim(Request::getString('scan_theme', '', 'GET'))) { + $scanStore = new ScanStore(\dirname(__DIR__)); + $previousScan = $scanStore->latestForTheme($selectedTheme->scope, $selectedTheme->key); + $scanStore->saveScan($selectedTheme, $findingCounts, $analyzerResults); + } +} + +$adminOverlayUrl = XOOPS_URL . '/modules/' . $moduleDirName . '/admin/index.php?' . $queryKey . '=xtf-theme,xtf-widget'; +$siteOverlayUrl = XOOPS_URL . '/?' . $queryKey . '=xtf-theme,xtf-widget'; + +$examples = [ + XOOPS_URL . '/?' . $queryKey . '=html5', + $siteOverlayUrl, + $adminOverlayUrl, + XOOPS_URL . '/?' . $queryKey . '=all', +]; + +echo '
'; +echo '

cssHolmes Overlay Activation

'; +echo '

Overlay 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

'; +echo '
    '; +foreach ($examples as $exampleUrl) { + $safeUrl = htmlspecialchars($exampleUrl, ENT_QUOTES); + echo '
  • ' . $safeUrl . '
  • '; +} +echo '
'; +echo '

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.

'; +echo '

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 '
'; + +echo '
'; +echo '

Export Import

'; +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 '
' . htmlspecialchars($importError, ENT_QUOTES) . '
'; +} +if ('' !== $workbenchNotice && [] === $importErrors) { + echo '
' . htmlspecialchars($workbenchNotice, ENT_QUOTES) . '
'; +} +if (is_array($importSummary)) { + if ('theme_config_export' === (string)($importSummary['payload_type'] ?? '')) { + $configSummary = is_array($importSummary['config_summary'] ?? null) ? $importSummary['config_summary'] : []; + echo '
Detected an XTF theme config export for ' + . htmlspecialchars((string)($configSummary['theme_name'] ?? $importSummary['theme']), ENT_QUOTES) + . '.
'; + } else { + echo '
Imported ' + . (int)count($importSummary['changes']) + . ' change(s) for ' + . htmlspecialchars((string)($importSummary['theme'] !== '' ? $importSummary['theme'] : $importSummary['scope']), ENT_QUOTES) + . '.
'; + } +} +echo '
'; +echo $GLOBALS['xoopsSecurity']->getTokenHTML(); +echo ''; +echo ''; +echo '

'; +echo '
'; +if (is_array($importSummary)) { + echo '
'; + 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 '
'; + echo ''; + echo ''; + foreach ($slotCounts as $slotName => $slotCount) { + echo ''; + } + echo '
SlotPlacements
' . htmlspecialchars((string)$slotName, ENT_QUOTES) . '' . (int)$slotCount . '
'; + echo '
'; + } + if ([] !== $preferenceKeys) { + echo '

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 '
'; + echo ''; + echo '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + foreach ($importSummary['changes'] as $change) { + $inspectionSummary = $inspectionSummaryForChange($change); + echo '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + echo '
KindSelectorWidgetSlotSummaryBeforeAfter
' . 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)) . '
'; + echo '
'; + } + if ([] !== $importSuggestions) { + echo '
'; + echo '

Patch Suggestions

'; + echo '
    '; + foreach ($importSuggestions as $suggestion) { + echo '
  • ' . htmlspecialchars((string)$suggestion['title'], ENT_QUOTES) . ': ' + . htmlspecialchars((string)$suggestion['detail'], ENT_QUOTES); + if ([] !== ($suggestion['targets'] ?? [])) { + echo '
    Likely files: ' + . htmlspecialchars(implode(', ', array_map('strval', $suggestion['targets'])), ENT_QUOTES) + . ''; + } + echo '
  • '; + } + echo '
'; + echo '
'; + } + if ([] !== $importDrafts) { + echo '
'; + echo '

Patch Drafts

'; + foreach ($importDrafts as $draft) { + echo '
'; + echo '' . htmlspecialchars((string)($draft['title'] ?? 'Draft'), ENT_QUOTES) . ''; + if ('' !== trim((string)($draft['target'] ?? ''))) { + echo ' [' . htmlspecialchars((string)$draft['target'], ENT_QUOTES) . ']'; + } + echo ''; + echo '
'
+                . htmlspecialchars((string)($draft['content'] ?? ''), ENT_QUOTES)
+                . '
'; + echo '
'; + } + echo '
'; + } + echo '
'; +} +echo '
'; + +echo '
'; +echo '

Workbench History

'; +if ([] === $savedImports) { + 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 '
'; + echo '
'; + echo '

Saved Imports

'; + foreach (array_slice($savedImports, 0, 10) as $savedImport) { + $savedChanges = is_array($savedImport['changes'] ?? null) ? $savedImport['changes'] : []; + $savedCounts = $statusCountsForChanges($savedChanges); + $isCurrent = (string)($savedImport['id'] ?? '') === $selectedHistoryId; + $savedPayloadType = is_string($savedImport['payload_type'] ?? null) ? (string)$savedImport['payload_type'] : 'review_export'; + $savedConfigSummary = is_array($savedImport['config_summary'] ?? null) ? $savedImport['config_summary'] : []; + echo '
'; + echo '

' . htmlspecialchars((string)($savedImport['theme'] ?? ''), ENT_QUOTES) . '

'; + echo '

' . htmlspecialchars((string)($savedImport['payload_label'] ?? 'Export'), ENT_QUOTES) . '

'; + echo '

' . htmlspecialchars((string)($savedImport['id'] ?? ''), ENT_QUOTES) . '

'; + echo '

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 '

Open review

'; + echo '
'; + } + echo '
'; + + echo '
'; + if (null === $selectedHistoryImport) { + 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 '
'; + echo '

Import Review

'; + echo '

ID: ' . htmlspecialchars((string)$selectedHistoryImport['id'], ENT_QUOTES) . '' + . ' Theme: ' . htmlspecialchars((string)($selectedHistoryImport['theme'] ?? ''), ENT_QUOTES) + . ' Scope: ' . htmlspecialchars((string)($selectedHistoryImport['scope'] ?? ''), ENT_QUOTES) + . '

'; + echo '

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 '
'; + echo ''; + echo ''; + foreach ($selectedSlotCounts as $slotName => $slotCount) { + echo ''; + } + echo '
SlotPlacements
' . htmlspecialchars((string)$slotName, ENT_QUOTES) . '' . (int)$slotCount . '
'; + echo '
'; + } + if ([] !== $selectedPreferenceKeys) { + echo '

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 '
'; + echo ''; + echo '

'; + echo '
'; + echo '
'; + echo '
'; + } + if ('theme_config_export' !== $selectedPayloadType && [] === $filteredHistoryChanges) { + echo '

No changes match the current filter.

'; + } elseif ('theme_config_export' !== $selectedPayloadType) { + echo '
'; + echo ''; + echo '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + foreach ($selectedHistoryChanges as $changeIndex => $savedChange) { + $status = is_string($savedChange['status'] ?? null) ? strtolower((string)$savedChange['status']) : 'pending'; + if ('all' !== $historyStatusFilter && $status !== $historyStatusFilter) { + continue; + } + + $statusColor = match ($status) { + 'accepted' => '#166534', + 'rejected' => '#b91c1c', + default => '#b45309', + }; + $appliedAt = is_string($savedChange['applied_at'] ?? null) ? trim((string)$savedChange['applied_at']) : ''; + $appliedTarget = is_string($savedChange['applied_target'] ?? null) ? trim((string)$savedChange['applied_target']) : ''; + $appliedMessage = is_string($savedChange['applied_message'] ?? null) ? trim((string)$savedChange['applied_message']) : ''; + $rolledBackAt = is_string($savedChange['rolled_back_at'] ?? null) ? trim((string)$savedChange['rolled_back_at']) : ''; + $rolledBackMessage = is_string($savedChange['rolled_back_message'] ?? null) ? trim((string)$savedChange['rolled_back_message']) : ''; + $changeKind = is_string($savedChange['kind'] ?? null) ? strtolower(trim((string)$savedChange['kind'])) : ''; + $canApply = 'accepted' === $status && in_array($changeKind, ['text', 'token', 'color', 'style', 'layout', 'measure'], true); + $changeSuggestion = is_array($selectedHistorySuggestions[$changeIndex] ?? null) ? $selectedHistorySuggestions[$changeIndex] : []; + $applyPreview = $canApply ? $patchApplier->preview($selectedHistoryTheme, $savedChange, $changeSuggestion) : null; + $canApplyNow = $canApply && is_array($applyPreview) && true === ($applyPreview['can_apply'] ?? false); + $inspectionSummary = $inspectionSummaryForChange($savedChange); + echo '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + echo '
KindSelectorWidgetSlotSummaryBeforeAfterStatusAction
' . 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) . '' + . '
' + . $GLOBALS['xoopsSecurity']->getTokenHTML() + . '' + . '' + . '' + . '' + . '' + . '' + . '
' + . (is_array($applyPreview) && true === ($applyPreview['supported'] ?? false) + ? '
' + . '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)
+                        . '
' + . '
' + . '
' + : '') + . ($canApplyNow + ? '
' + . $GLOBALS['xoopsSecurity']->getTokenHTML() + . '' + . '' + . '' + . '' + . '' + . '
' + : '') + . ($canApply && !$canApplyNow + ? '
Apply is disabled until the preview reports a safe, unambiguous target.
' + : '') + . ('' !== $appliedAt + ? '
' + . $GLOBALS['xoopsSecurity']->getTokenHTML() + . '' + . '' + . '' + . '' + . '' + . '
' + : '') + . ('' !== $appliedAt + ? '
Applied ' + . htmlspecialchars($appliedAt, ENT_QUOTES) + . ('' !== $appliedTarget ? '
' . htmlspecialchars($appliedTarget, ENT_QUOTES) . '' : '') + . ('' !== $appliedMessage ? '
' . htmlspecialchars($appliedMessage, ENT_QUOTES) : '') + . '
' + : '') + . ('' !== $rolledBackAt + ? '
Rolled back ' + . htmlspecialchars($rolledBackAt, ENT_QUOTES) + . ('' !== $rolledBackMessage ? '
' . htmlspecialchars($rolledBackMessage, ENT_QUOTES) : '') + . '
' + : '') + . '
'; + echo '
'; + } + + if (is_string($selectedHistoryImport['raw_payload'] ?? null) && '' !== trim((string)$selectedHistoryImport['raw_payload'])) { + echo '
'; + echo 'Raw Export JSON'; + echo '
'
+                . htmlspecialchars((string)$selectedHistoryImport['raw_payload'], ENT_QUOTES)
+                . '
'; + echo '
'; + } + if ('theme_config_export' !== $selectedPayloadType && [] !== $selectedHistorySuggestions) { + echo '
'; + echo '
Likely Patch Targets
'; + echo '
    '; + foreach ($selectedHistorySuggestions as $historySuggestion) { + echo '
  • ' . htmlspecialchars((string)$historySuggestion['title'], ENT_QUOTES) . ': ' + . htmlspecialchars((string)$historySuggestion['detail'], ENT_QUOTES); + if ([] !== ($historySuggestion['targets'] ?? [])) { + echo '
    Likely files: ' + . htmlspecialchars(implode(', ', array_map('strval', $historySuggestion['targets'])), ENT_QUOTES) + . ''; + } + echo '
  • '; + } + echo '
'; + echo '
'; + } + if ('theme_config_export' !== $selectedPayloadType && [] !== $selectedHistoryDrafts) { + echo '
'; + echo '
Patch Drafts
'; + foreach ($selectedHistoryDrafts as $historyDraft) { + echo '
'; + echo '' . htmlspecialchars((string)($historyDraft['title'] ?? 'Draft'), ENT_QUOTES) . ''; + if ('' !== trim((string)($historyDraft['target'] ?? ''))) { + echo ' [' . htmlspecialchars((string)$historyDraft['target'], ENT_QUOTES) . ']'; + } + echo ''; + echo '
'
+                    . htmlspecialchars((string)($historyDraft['content'] ?? ''), ENT_QUOTES)
+                    . '
'; + echo '
'; + } + echo '
'; + } + echo '
'; + } + echo '
'; + echo '
'; +} +echo '
'; + +echo '
'; +echo '

Phase 2 Workbench

'; +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.

'; +echo '

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

'; +echo '
'; +echo '

'; +echo '
'; + +echo '

'; +echo '
'; +echo '
'; +echo '
'; + +if (null === $selectedTheme) { + echo '
'; + echo '

No themes found: no folder with a theme.tpl, theme.html or theme.json was found for this scope.

'; + echo '

The overlay still supports HTML5, accessibility, layout, inspect, measure, copy, export, and widget HTML scan workflows.

'; + echo '
'; +} else { + echo '
'; + echo '

Selected: ' . htmlspecialchars($selectedTheme->label, ENT_QUOTES) . '
'; + echo 'Path: ' . htmlspecialchars($selectedTheme->path, ENT_QUOTES) . '

'; + echo '

' + . '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.

'; + } + if (is_array($previousScan)) { + $previousCounts = is_array($previousScan['counts'] ?? null) ? $previousScan['counts'] : []; + $deltaParts = []; + foreach (['error' => 'errors', 'warning' => 'warnings', 'info' => 'info'] as $severityKey => $severityLabel) { + $deltaParts[] = $severityLabel . ' ' . (int)($previousCounts[$severityKey] ?? 0) . ' → ' . (int)$findingCounts[$severityKey]; + } + echo '

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 '
'; + echo '

' . htmlspecialchars($analyzerLabel, ENT_QUOTES) . '

'; + echo '
'; + echo ''; + echo '' + . '' + . '' + . '' + . '' + . '' + . ''; + + foreach ($analyzerFindings as $finding) { + $severityColor = match ($finding->severity) { + Severity::Error => '#b91c1c', + Severity::Warning => '#b45309', + Severity::Info => '#1d4ed8', + }; + + $target = htmlspecialchars($finding->target, ENT_QUOTES); + if (null !== $finding->line) { + $target .= ':' . $finding->line; + } + + echo '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + echo '
SeverityRuleMessageTargetSuggestion
' . htmlspecialchars(strtoupper($finding->severity->value), ENT_QUOTES) . '' . htmlspecialchars($finding->ruleId, ENT_QUOTES) . '' . htmlspecialchars($finding->message, ENT_QUOTES) . '' . $target . '' . htmlspecialchars((string)$finding->suggestion, ENT_QUOTES) . '
'; + echo '
'; + echo '
'; + } + } + echo '
'; +} +echo '
'; + +echo '
'; +echo '

Widget Output Scan

'; +echo '

Paste rendered widget HTML to run the widget-output analyzer without waiting for a full page scan.

'; +echo '
'; +echo $GLOBALS['xoopsSecurity']->getTokenHTML(); +echo ''; +echo '


'; +echo '

'; +echo '


'; +echo '

'; +echo '

'; +echo '
'; +if ([] !== $widgetFindings) { + echo '

' + . 'Errors: ' . (int)$widgetFindingCounts[Severity::Error->value] . '' + . 'Warnings: ' . (int)$widgetFindingCounts[Severity::Warning->value] . '' + . 'Info: ' . (int)$widgetFindingCounts[Severity::Info->value] . '' + . '

'; + echo '
'; + echo ''; + echo '' + . '' + . '' + . '' + . '' + . ''; + foreach ($widgetFindings as $widgetFinding) { + $severityColor = match ($widgetFinding->severity) { + Severity::Error => '#b91c1c', + Severity::Warning => '#b45309', + Severity::Info => '#1d4ed8', + }; + echo '' + . '' + . '' + . '' + . '' + . ''; + } + echo '
SeverityRuleMessageSuggestion
' . htmlspecialchars(strtoupper($widgetFinding->severity->value), ENT_QUOTES) . '' . htmlspecialchars($widgetFinding->ruleId, ENT_QUOTES) . '' . htmlspecialchars($widgetFinding->message, ENT_QUOTES) . '' . htmlspecialchars((string)$widgetFinding->suggestion, ENT_QUOTES) . '
'; + echo '
'; +} elseif ('' !== $widgetScanHtml && [] === $importErrors) { + echo '

No widget-output findings for this HTML snippet.

'; +} +echo '
'; + //require_once XOOPS_ROOT_PATH . '/modules/' . $xoopsModule->getVar('dirname') . '/class/menu.php'; // //$menu = new \XoopsModules\Cssholmes\Menu(); @@ -33,7 +1090,7 @@ // //$admin = new \XoopsModules\Cssholmes\Menu(); //$admin->addItem('update', _AM_CSSHOLMES_MANAGER_UPDATE, '../../system/admin.php?fct=modulesadmin&op=update&module=cssholmes'); -//$admin->addItem('xoofoo', _AM_CSSHOLMES_MANAGER_PREFERENCES, 'http://www.xoofoo.org'); +//$admin->addItem('xoofoo', _AM_CSSHOLMES_MANAGER_PREFERENCES, 'https://www.xoofoo.org'); //$xoopsTpl->assign($xoopsModule->getVar('dirname') . '_admin', $admin->_items); // //$xoopsTpl->assign('module_dirname', $xoopsModule->getVar('dirname')); diff --git a/admin/menu.php b/admin/menu.php index f391f56..ef1f21c 100644 --- a/admin/menu.php +++ b/admin/menu.php @@ -1,4 +1,4 @@ - + * @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 kris , XOOPS Development Team **/ // admin menu title and link @@ -23,31 +21,45 @@ Helper }; -require dirname(__DIR__) . '/preloads/autoloader.php'; +require \dirname(__DIR__) . '/preloads/autoloader.php'; -$moduleDirName = basename(dirname(__DIR__)); -$moduleDirNameUpper = mb_strtoupper($moduleDirName); +$moduleDirName = \basename(\dirname(__DIR__)); +$moduleDirNameUpper = \mb_strtoupper($moduleDirName); /** @var Helper $helper */ $helper = Helper::getInstance(); $helper->loadLanguage('common'); $helper->loadLanguage('feedback'); -$pathIcon32 = Admin::menuIconPath(''); -if (is_object($helper->getModule())) { - $pathModIcon32 = $helper->getModule()->getInfo('modicons32'); +$pathIcon32 = Admin::menuIconPath(''); +$pathModIcon32 = XOOPS_URL . '/modules/' . $moduleDirName . '/assets/images/icons/32/'; +if (is_object($helper->getModule()) && false !== $helper->getModule()->getInfo('modicons32')) { + $pathModIcon32 = $helper->url($helper->getModule()->getInfo('modicons32')); } $adminmenu[] = [ 'title' => _MI_CSSHOLMES_MANAGER_INDEX, 'link' => 'admin/index.php', 'desc' => _MI_CSSHOLMES_MANAGER_INDEX_DESC, - 'icon' => $pathIcon32 . '/home.png', + 'icon' => $pathIcon32 . '/home.png', +]; + +$adminmenu[] = [ + 'title' => _MI_CSSHOLMES_MANAGER_THEME_SETS, + 'link' => 'admin/theme_sets.php', + 'desc' => _MI_CSSHOLMES_MANAGER_THEME_SETS_DESC, + 'icon' => $pathIcon32 . '/view.png', +]; + +$adminmenu[] = [ + 'title' => \constant('CO_' . $moduleDirNameUpper . '_PERMISSIONS'), + 'link' => 'admin/permissions.php', + 'icon' => $pathIcon32 . '/permissions.png', ]; $adminmenu[] = [ 'title' => _MI_CSSHOLMES_MANAGER_ABOUT, 'link' => 'admin/about.php', 'desc' => _MI_CSSHOLMES_MANAGER_ABOUT_DESC, - 'icon' => $pathIcon32 . '/about.png', + 'icon' => $pathIcon32 . '/about.png', ]; diff --git a/admin/permissions.php b/admin/permissions.php new file mode 100644 index 0000000..c851d7c --- /dev/null +++ b/admin/permissions.php @@ -0,0 +1,112 @@ +displayNavigation(basename(__FILE__)); + +$moduleDirName = basename(dirname(__DIR__)); +$moduleId = $xoopsModule->getVar('mid'); + +// Permission tabs — each becomes a separate XoopsGroupPermForm +$tabs = [ + 'overlay' => [ + 'permName' => $moduleDirName . '_overlay', + 'title' => _AM_CSSHOLMES_TAB_OVERLAY, + 'desc' => _AM_CSSHOLMES_PERM_OVERLAY_DESC, + 'itemLabel' => _AM_CSSHOLMES_PERM_OVERLAY, + 'anonymous' => false, + ], + 'diagnose' => [ + 'permName' => $moduleDirName . '_diagnose', + 'title' => _AM_CSSHOLMES_TAB_DIAGNOSE, + 'desc' => _AM_CSSHOLMES_PERM_DIAGNOSE_DESC, + 'itemLabel' => _AM_CSSHOLMES_PERM_DIAGNOSE, + 'anonymous' => false, + ], + 'edit' => [ + 'permName' => $moduleDirName . '_edit', + 'title' => _AM_CSSHOLMES_TAB_EDIT, + 'desc' => _AM_CSSHOLMES_PERM_EDIT_DESC, + 'itemLabel' => _AM_CSSHOLMES_PERM_EDIT, + 'anonymous' => false, + ], + 'export' => [ + 'permName' => $moduleDirName . '_export', + 'title' => _AM_CSSHOLMES_TAB_EXPORT, + 'desc' => _AM_CSSHOLMES_PERM_EXPORT_DESC, + 'itemLabel' => _AM_CSSHOLMES_PERM_EXPORT, + 'anonymous' => false, + ], + 'workbench' => [ + 'permName' => $moduleDirName . '_workbench', + 'title' => _AM_CSSHOLMES_TAB_WORKBENCH, + 'desc' => _AM_CSSHOLMES_PERM_WORKBENCH_DESC, + 'itemLabel' => _AM_CSSHOLMES_PERM_WORKBENCH, + 'anonymous' => false, + ], +]; + +// Current tab from URL parameter +$permTab = \Xmf\Request::getString('tab', 'overlay', 'GET'); +if (!isset($tabs[$permTab])) { + $permTab = 'overlay'; +} +$currentTab = $tabs[$permTab]; + +require_once XOOPS_ROOT_PATH . '/class/xoopsform/grouppermform.php'; + +$permForm = new \XoopsGroupPermForm( + $currentTab['title'], + $moduleId, + $currentTab['permName'], + $currentTab['desc'], + 'admin/permissions.php?tab=' . $permTab, + $currentTab['anonymous'], +); + +$permForm->addItem(1, $currentTab['itemLabel']); + +// Render heading +echo '

' . _AM_CSSHOLMES_PERMISSIONS . '

'; + +// Tab navigation +echo '
'; +foreach ($tabs as $key => $tab) { + $isActive = ($permTab === $key); + $style = $isActive + ? 'display:inline-block;padding:8px 16px;border-bottom:2px solid #2563eb;margin-bottom:-2px;font-weight:600;color:#2563eb;text-decoration:none;' + : 'display:inline-block;padding:8px 16px;color:#64748b;text-decoration:none;'; + echo '' + . htmlspecialchars($tab['title'], ENT_QUOTES) . ''; +} +echo '
'; + +echo \XoopsModules\Cssholmes\Utility::registeredUserToolNotice($moduleDirName, (int)$moduleId); + +// Description +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 kris diff --git a/assets/css/designer.css b/assets/css/designer.css new file mode 100644 index 0000000..9c3f464 --- /dev/null +++ b/assets/css/designer.css @@ -0,0 +1,88 @@ +:host { all: initial; color-scheme: light; font: 13px/1.45 system-ui, sans-serif; color: #172033; } +*, *::before, *::after { box-sizing: border-box; } +[hidden] { display: none !important; } +button, input, select { font: inherit; color: inherit; } +button { min-block-size: 34px; padding: 7px 10px; border: 1px solid #cbd2df; border-radius: 5px; background: #fff; cursor: pointer; line-height: 1.3; } +button:hover:not(:disabled) { background: #eef2fa; border-color: #8999b8; } +button:disabled { opacity: .45; cursor: default; } +:focus-visible { outline: 3px solid #8cb5ff; outline-offset: 2px; } +.launcher { position: fixed; inset-inline-end: 18px; inset-block-end: 18px; z-index: 2; background: #263e75; border-color: #263e75; color: #fff; padding: 11px 16px; box-shadow: 0 4px 18px #17203333; } +.launcher:hover:not(:disabled) { background: #1e315c; color: #fff; } +.panel { position: fixed; inset-block: 0; inset-inline-end: 0; inline-size: min(370px, 100vw); display: flex; flex-direction: column; background: #fafbfd; border-inline-start: 1px solid #cbd2df; box-shadow: -8px 0 32px #17203322; } +.panel-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 18px 14px; background: #fff; } +h2 { font-size: 18px; line-height: 1.2; font-weight: 650; margin: 0 0 5px; letter-spacing: -.3px; } +h3 { font-size: 15px; line-height: 1.4; margin: 16px 0 12px; overflow-wrap: anywhere; } +p { margin: 0; } +.theme-name { font-size: 12px; color: #5f6a7d; overflow-wrap: anywhere; } +.quiet { padding: 5px 8px; min-block-size: 30px; background: transparent; } +.action-bar { display: flex; gap: 6px; padding: 0 18px 14px; background: #fff; border-block-end: 1px solid #e1e5ed; } +.action-bar button:first-child { margin-inline-end: auto; } +.panel-content { flex: 1 1 auto; min-block-size: 0; overflow-y: auto; overscroll-behavior: contain; padding: 14px 18px 20px; } +.status { color: #536176; margin-block-end: 12px; font-size: 12px; } +.error, .notice { border-inline-start: 3px solid #b03b36; padding: 9px 10px; background: #fff0ef; color: #8b2420; margin-block: 10px; overflow-wrap: anywhere; } +.notice { border-color: #92713a; background: #fff9ec; color: #705521; } +.target-fields { border: 0; padding: 0; margin: 0; min-inline-size: 0; } +.target-fields:disabled { opacity: .55; } +.field { display: block; font-size: 12px; font-weight: 550; margin-block-end: 10px; min-inline-size: 0; } +input:not([type=color]), select { display: block; inline-size: 100%; min-inline-size: 0; min-block-size: 34px; padding: 7px 8px; margin-block-start: 5px; border: 1px solid #cbd2df; border-radius: 4px; background: #fff; font-weight: 400; } +input::placeholder { color: #788297; opacity: 1; } +input[aria-invalid=true] { border-color: #b03b36; background: #fff5f4; } +.match-count { color: #284f87; font-size: 12px; font-weight: 600; margin-block: 7px; } +.help { font-size: 11px; color: #657084; margin-block: 6px 14px; } +.library { border-block-end: 1px solid #dce2ec; margin-block-end: 14px; padding-block-end: 10px; } +.library > summary { padding-block: 0 8px; } +.library-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-block: 8px; } +.library-actions button { font-size: 11px; } +.library-secondary > summary { font-size: 12px; padding-block: 8px; color: #536176; } +.library-info { overflow-wrap: anywhere; } +.library .notice { white-space: pre-line; font-size: 12px; } +.palette-swatches { display: flex; flex-wrap: wrap; gap: 4px; } +.palette-swatches span { inline-size: 26px; block-size: 20px; border: 1px solid #b9c2d1; border-radius: 3px; } +@supports (appearance: base-select) { + .library > .palette-swatches { display: none; } + .palette-select, .palette-select::picker(select) { appearance: base-select; } + .palette-select { display: flex; align-items: center; } + .palette-select > button { display: flex; flex: 1; min-inline-size: 0; min-block-size: 0; padding: 0; border: 0; background: transparent; text-align: start; } + .palette-select selectedcontent { display: flex; align-items: center; gap: 8px; inline-size: 100%; min-inline-size: 0; } + .palette-select::picker(select) { border: 1px solid #aab5c7; border-radius: 5px; padding: 4px; margin-block: 4px; max-block-size: min(420px, 60vh); overflow: auto; background: #fff; color: #172033; box-shadow: 0 8px 24px #17203326; } + .palette-select optgroup { font: inherit; font-weight: 650; padding-block: 4px; } + .palette-select option { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 3px; font-weight: 400; } + .palette-select option:checked { background: #e8effc; } + .palette-select option:hover, .palette-select option:focus { background: #263e75; color: #fff; } + .palette-select option::checkmark { order: 2; } + .palette-select .palette-option-name { flex: 1; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .palette-select .palette-option-swatches { display: inline-flex; flex: none; gap: 4px; margin-inline-start: auto; } + .palette-select .palette-option-swatches > span { display: inline-block; inline-size: 20px; block-size: 18px; border: 1px solid #99a5b8; border-radius: 3px; } +} +.compare-original { display: flex; align-items: center; gap: 7px; margin-block: 8px; font-size: 12px; } +.compare-original input { inline-size: auto; min-block-size: auto; margin: 0; } +.contexts { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; margin-block-start: 14px; } +.diagnostics { border-block: 1px solid #dce2ec; margin-block-end: 14px; padding-block-end: 10px; } +.diagnostics > .field { margin-block-start: 12px; } +.diagnostics [data-contrast] { font-size: 12px; overflow-wrap: anywhere; } +.token-list { max-block-size: 260px; overflow: auto; margin-block: 8px; font-size: 11px; } +.token-list dt { font-weight: 650; overflow-wrap: anywhere; } +.token-list dd { margin: 3px 0 10px; overflow-wrap: anywhere; } +.token-swatch { display: inline-block; inline-size: 18px; block-size: 14px; border: 1px solid #8999b8; vertical-align: middle; margin-inline-end: 6px; } +.contexts .field:last-child { grid-column: 1 / -1; } +.property-group { border-block-start: 1px solid #dce2ec; } +summary { cursor: pointer; font-size: 13px; font-weight: 600; padding-block: 12px; } +.property-fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 12px; padding-block: 0 6px; } +.property:has([data-property=font-family]) { grid-column: 1 / -1; } +.value-row { display: flex; align-items: center; gap: 5px; } +.value-row input:not([type=color]) { flex: 1 1 auto; } +.computed { display: block; font-size: 10px; font-weight: 400; color: #657084; margin-block-start: 4px; overflow-wrap: anywhere; } +.color-picker { flex: 0 0 30px; inline-size: 30px; block-size: 30px; padding: 2px; border: 1px solid #cbd2df; border-radius: 4px; margin-block-start: 5px; background: #fff; cursor: pointer; } +.reset { margin-block-start: 12px; } +.panel-footer { flex: 0 0 auto; border-block-start: 1px solid #dce2ec; padding: 12px 18px; background: #fff; } +.rule-count { font-size: 11px; color: #657084; margin-block-end: 8px; } +.save-row { display: flex; gap: 7px; flex-wrap: wrap; } +.primary { background: #263e75; color: #fff; border-color: #263e75; flex: 1 1 auto; } +.primary:hover:not(:disabled) { background: #1e315c; color: #fff; } +.more summary { font-size: 11px; font-weight: 500; padding-block: 9px 0; color: #536176; } +.more-actions { display: flex; gap: 6px; flex-wrap: wrap; padding-block-start: 9px; } +.more-actions button { font-size: 11px; } +.outline { position: fixed; inset-block-start: 0; inset-inline-start: 0; direction: ltr; pointer-events: none; border: 2px solid #4379ef; background: #4379ef12; z-index: 1; } +.pick-hint { position: fixed; inset-block-start: 12px; inset-inline: 12px; inline-size: fit-content; max-inline-size: calc(100vw - 24px); margin-inline: auto; padding: 10px 14px; background: #172033; color: #fff; border-radius: 5px; box-shadow: 0 4px 18px #17203333; pointer-events: none; } +.sr-only { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@media (max-width: 380px) { .panel-header, .panel-content, .panel-footer { padding-inline: 12px; } .action-bar { padding-inline: 12px; } } diff --git a/assets/css/gallery-designer.css b/assets/css/gallery-designer.css new file mode 100644 index 0000000..da942ed --- /dev/null +++ b/assets/css/gallery-designer.css @@ -0,0 +1,16 @@ +/* Only cssHolmes workspace chrome; sample elements retain the theme's styles. */ +#xs-gallery [data-cssholmes-preview] { margin-block: 1rem; border-radius: var(--xs-ui-radius, 8px); font: 13px/1.5 system-ui, sans-serif; } +#xs-gallery [data-cssholmes-preview] [hidden] { display: none; } +#xs-gallery .cssholmes-preview-toolbar { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-block: 14px; } +#xs-gallery .cssholmes-preview-toolbar label { display: grid; gap: 5px; font-weight: 600; margin: 0; } +#xs-gallery .cssholmes-preview-toolbar :is(button, select) { font: inherit; border: 1px solid #cbd2df; border-radius: 8px; padding: 7px 12px; min-block-size: 36px; background: #fff; color: #263e75; } +#xs-gallery .cssholmes-preview-toolbar :is(button, select):focus-visible { outline: 3px solid #518ce9; outline-offset: 3px; } +#xs-gallery .cssholmes-preview-panes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +#xs-gallery .cssholmes-preview-panes[data-layout=responsive] { grid-template-columns: repeat(3, minmax(0, 1fr)); } +#xs-gallery .cssholmes-preview-panes figure { margin: 0; min-inline-size: 0; } +#xs-gallery .cssholmes-preview-panes figcaption { font-weight: 650; margin-block: 6px; } +#xs-gallery .cssholmes-frame-status { font-size: 12px; } +#xs-gallery .cssholmes-preview-scroll { overflow: auto; border: 1px solid #cbd2df; border-radius: 8px; background: #fff; } +#xs-gallery .cssholmes-preview-scroll iframe { display: block; max-inline-size: none; border: 0; } +@media (max-width: 700px) { #xs-gallery .cssholmes-preview-panes, #xs-gallery .cssholmes-preview-panes[data-layout=responsive] { grid-template-columns: minmax(0, 1fr); } } +@media (min-width: 1100px) { #xs-gallery.cssholmes-editor-visible { box-sizing: border-box; padding-inline-end: 380px; } } diff --git a/assets/css/holmes.css b/assets/css/holmes.css index 83d37f4..aba4ccb 100644 --- a/assets/css/holmes.css +++ b/assets/css/holmes.css @@ -16,7 +16,7 @@ * Copyright (C) 2012 Luke Williams & RedRoot All Rights Reserved. * holmes.css HTML5 CSS Accessibility Validation * Licenced under GPL license -* http://www.gnu.org/licenses/gpl.html +* https://www.gnu.org/licenses/gpl.html * * ------------------------ * @@ -47,6 +47,14 @@ * the style, then the content * */ + +/* Severity marker read by the toolbar counters (E/W): every rule that flags + an element also sets --holmes-html5 to error, warning or info. The reset + below has zero specificity, so the marker never inherits to children. */ +:where(.holmes-debug *) { + --holmes-html5: none; +} + /* ====================================================== */ /* = OUTLINES / BORDERS ================================= */ /* ====================================================== */ @@ -61,12 +69,14 @@ .holmes-debug a:not([href]), .holmes-debug a[href=""] { outline: 2px solid red; + --holmes-html5: error; } .holmes-debug a[href="#"], .holmes-debug a[href^="javascript"], .holmes-debug a[title=""]{ outline: 2px solid #fd0; + --holmes-html5: warning; } @@ -77,10 +87,12 @@ .holmes-debug img:not([alt]){ outline: 2px solid red; + --holmes-html5: error; } .holmes-debug img[alt=""] { outline: 2px solid #fd0; + --holmes-html5: warning; } /************************* MISC ATTRIBUTES @@ -102,11 +114,9 @@ .holmes-debug abbr[title=""], .holmes-debug [class=""], -.holmes-debug [id=""], - -.holmes-debug table:not([summary]), -.holmes-debug table[summary=""]{ +.holmes-debug [id=""] { outline: 2px solid red; + --holmes-html5: error; } /* Warnings here, for inline styles and event calls, @@ -123,7 +133,8 @@ .holmes-debug [onunload] */{ outline: 2px solid #fd0; - } + --holmes-html5: warning; +} /************************************************************************* DEPRECATED & NON-W3C CONTENT ELEMENTS (#a9a9a9) @@ -142,23 +153,27 @@ .holmes-debug plaintext, .holmes-debug xmp { border: 2px solid #a9a9a9; + --holmes-html5: warning; } -body.holmes-debug[background], -body.holmes-debug[bgproperties], -body.holmes-debug[topmargin], -body.holmes-debug[rightmargin], -body.holmes-debug[bottommargin], -body.holmes-debug[leftmargin] { +body.holmes-debug[background], .holmes-debug body[background], +body.holmes-debug[bgproperties], .holmes-debug body[bgproperties], +body.holmes-debug[topmargin], .holmes-debug body[topmargin], +body.holmes-debug[rightmargin], .holmes-debug body[rightmargin], +body.holmes-debug[bottommargin], .holmes-debug body[bottommargin], +body.holmes-debug[leftmargin], .holmes-debug body[leftmargin] { outline: 2px solid #a9a9a9; + --holmes-html5: warning; } .holmes-debug *[bordercolor], .holmes-debug *[bordercolordark], .holmes-debug *[bordercolorlight], -.holmes-debug table[frame] { +.holmes-debug table[frame], +.holmes-debug table[summary] { border: 2px solid #a9a9a9; + --holmes-html5: warning; } .holmes-debug *[align], @@ -167,6 +182,7 @@ body.holmes-debug[leftmargin] { .holmes-debug *[clear], .holmes-debug ul[type], .holmes-debug li[type] { border: 2px solid #a9a9a9; + --holmes-html5: warning; } /********************* @@ -180,6 +196,7 @@ body.holmes-debug[leftmargin] { .holmes-debug td:empty, .holmes-debug th:empty { border: 2px solid #fd0; + --holmes-html5: warning; } @@ -248,10 +265,7 @@ body.holmes-debug[leftmargin] { .holmes-debug abbr[title=""]:hover::after, .holmes-debug [class=""]:hover::after, -.holmes-debug [id=""]:hover::after, - -.holmes-debug table:not([summary]):hover::after, -.holmes-debug table[summary=""]:hover::after { +.holmes-debug [id=""]:hover::after { border-radius: 0.5em; display:block; padding: 1em; @@ -286,6 +300,7 @@ body.holmes-debug[leftmargin] { .holmes-debug *[bordercolorlight]:hover::after, .holmes-debug table[frame]:hover::after, +.holmes-debug table[summary]:hover::after, .holmes-debug *[align]:hover::after, .holmes-debug *[valign]:hover::after, .holmes-debug *[bgcolor]:hover::after, @@ -309,12 +324,12 @@ body.holmes-debug[leftmargin] { /* = Deprecated body style Styles = */ /* ================================ */ -body.holmes-debug[background]:hover::after, -body.holmes-debug[bgproperties]:hover::after, -body.holmes-debug[topmargin]:hover::after, -body.holmes-debug[rightmargin]:hover::after, -body.holmes-debug[bottommargin]:hover::after, -body.holmes-debug[leftmargin]:hover::after { +body.holmes-debug[background]:hover::after, .holmes-debug body[background]:hover::after, +body.holmes-debug[bgproperties]:hover::after, .holmes-debug body[bgproperties]:hover::after, +body.holmes-debug[topmargin]:hover::after, .holmes-debug body[topmargin]:hover::after, +body.holmes-debug[rightmargin]:hover::after, .holmes-debug body[rightmargin]:hover::after, +body.holmes-debug[bottommargin]:hover::after, .holmes-debug body[bottommargin]:hover::after, +body.holmes-debug[leftmargin]:hover::after, .holmes-debug body[leftmargin]:hover::after { border-radius: 0.5em; display:block; padding: 1em; @@ -406,10 +421,7 @@ body.holmes-debug[leftmargin]:hover::after { content:'Blank id attribute'; } -.holmes-debug table:not([summary]):hover::after, -.holmes-debug table[summary=""]:hover::after { - content:'Missing summary attribute'; -} +/* summary is obsolete in HTML5: flagged with the deprecated attributes below */ .holmes-debug [style]:hover::after { content: 'Element has inline styles'; @@ -439,18 +451,18 @@ body.holmes-debug[leftmargin]:hover::after { Based largely on http://www.w3.org/TR/html5/obsolete.html#obsolete ************************************************************************/ -body.holmes-debug[background]:hover::after { +body.holmes-debug[background]:hover::after, .holmes-debug body[background]:hover::after { content:'Deprecated or Non-W3C body attribute background'; } -body.holmes-debug[bgproperties]:hover::after { +body.holmes-debug[bgproperties]:hover::after, .holmes-debug body[bgproperties]:hover::after { content:'Deprecated or Non-W3C body attribute bgproperties'; } -body.holmes-debug[topmargin]:hover::after, -body.holmes-debug[rightmargin]:hover::after, -body.holmes-debug[bottommargin]:hover::after, -body.holmes-debug[leftmargin]:hover::after { +body.holmes-debug[topmargin]:hover::after, .holmes-debug body[topmargin]:hover::after, +body.holmes-debug[rightmargin]:hover::after, .holmes-debug body[rightmargin]:hover::after, +body.holmes-debug[bottommargin]:hover::after, .holmes-debug body[bottommargin]:hover::after, +body.holmes-debug[leftmargin]:hover::after, .holmes-debug body[leftmargin]:hover::after { content:'Deprecated or Non-W3C body attribute *margin'; } @@ -460,6 +472,10 @@ body.holmes-debug[leftmargin]:hover::after { content:'Deprecated or Non-W3C attribute bordercolor'; } +.holmes-debug table[summary]:hover::after { + content:'Obsolete table attribute summary: use a caption or aria-describedby'; +} + .holmes-debug table[frame]:hover::after { content:'Deprecated or Non-W3C table attribute frame'; } @@ -493,4 +509,4 @@ body.holmes-debug[leftmargin]:hover::after { .holmes-debug td:empty:hover::after, .holmes-debug th:empty:hover::after { content:'Empty element!'; -} \ No newline at end of file +} diff --git a/assets/css/index.html b/assets/css/index.html deleted file mode 100644 index 74b6f45..0000000 --- a/assets/css/index.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/css/index.php b/assets/css/index.php new file mode 100644 index 0000000..e5ee072 --- /dev/null +++ b/assets/css/index.php @@ -0,0 +1,2 @@ + **/ diff --git a/assets/css/profiles/a11y.css b/assets/css/profiles/a11y.css new file mode 100644 index 0000000..9cc051b --- /dev/null +++ b/assets/css/profiles/a11y.css @@ -0,0 +1,159 @@ +/** + * cssHolmes v2 — Accessibility Profile + * + * Detects common accessibility problems: + * - Missing lang on html + * - No main landmark + * - Empty links and buttons without accessible names + * - Images without alt + * - Form controls without labels + * - Positive tabindex values + * + * Color legend: + * Red (#e74c3c) = error + * Purple (#9b59b6) = accessibility concern + */ + +/* Severity marker read by the toolbar counters (E/W): every rule that flags + an element also sets --holmes-a11y to error, warning or info. The reset + below has zero specificity, so the marker never inherits to children. */ +:where(.holmes-debug *) { + --holmes-a11y: none; +} + +/* ========================================= */ +/* = MISSING LANG (error) = */ +/* ========================================= */ + +.holmes-debug:not([lang]) { + border: 5px solid #9b59b6 !important; + --holmes-a11y: warning; +} + +.holmes-debug:not([lang])::before { + content: "Holmes: has no lang attribute"; + position: fixed; + top: 0; + left: 0; + padding: 8px 12px; + background: #9b59b6; + color: #fff; + font-size: 13px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 99998; + pointer-events: none; +} + +/* ========================================= */ +/* = NO MAIN LANDMARK (warning) = */ +/* ========================================= */ + +.holmes-debug body:not(:has(main)):not(:has([role="main"])) { + border-bottom: 5px solid #9b59b6 !important; + --holmes-a11y: warning; +} + +/* ========================================= */ +/* = EMPTY LINKS (error) = */ +/* ========================================= */ + +.holmes-debug a:empty:not([aria-label]):not([aria-labelledby]):not(:has(img[alt])) { + outline: 3px solid #e74c3c !important; + --holmes-a11y: error; +} + +.holmes-debug a:empty:not([aria-label]):not([aria-labelledby]):hover::after { + content: "Holmes: Empty link with no accessible name"; + position: fixed; + top: 0; + left: 0; + padding: 8px 12px; + background: #e74c3c; + color: #fff; + font-size: 13px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 99998; + pointer-events: none; + white-space: nowrap; +} + +/* ========================================= */ +/* = EMPTY BUTTONS (error) = */ +/* ========================================= */ + +.holmes-debug button:empty:not([aria-label]):not([aria-labelledby]) { + outline: 3px solid #e74c3c !important; + --holmes-a11y: error; +} + +.holmes-debug button:empty:not([aria-label]):not([aria-labelledby]):hover::after { + content: "Holmes: Empty button with no accessible name"; + position: fixed; + top: 0; + left: 0; + padding: 8px 12px; + background: #e74c3c; + color: #fff; + font-size: 13px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 99998; + pointer-events: none; + white-space: nowrap; +} + +/* ========================================= */ +/* = IMAGES WITHOUT ALT (error) = */ +/* ========================================= */ + +.holmes-debug img:not([alt]) { + outline: 3px solid #e74c3c !important; + --holmes-a11y: error; +} + +/* ========================================= */ +/* = FORM CONTROLS WITHOUT LABELS (warning)= */ +/* ========================================= */ + +.holmes-debug [data-holmes-unlabelled] { + outline: 3px solid #9b59b6 !important; + --holmes-a11y: warning; +} + +.holmes-debug [data-holmes-unlabelled]:hover::after { + content: "Holmes: Form control without label or aria-label"; + position: fixed; + top: 0; + left: 0; + padding: 8px 12px; + background: #9b59b6; + color: #fff; + font-size: 13px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 99998; + pointer-events: none; + white-space: nowrap; +} + +/* ========================================= */ +/* = POSITIVE TABINDEX (warning) = */ +/* ========================================= */ + +.holmes-debug [tabindex]:not([tabindex="0"]):not([tabindex="-1"]):not([tabindex=""]) { + outline: 3px solid #9b59b6 !important; + --holmes-a11y: warning; +} + +.holmes-debug [tabindex]:not([tabindex="0"]):not([tabindex="-1"]):not([tabindex=""]):hover::after { + content: "Holmes: Positive tabindex disrupts natural tab order"; + position: fixed; + top: 0; + left: 0; + padding: 8px 12px; + background: #9b59b6; + color: #fff; + font-size: 13px; + font-family: system-ui, -apple-system, sans-serif; + z-index: 99998; + pointer-events: none; + white-space: nowrap; +} diff --git a/assets/css/profiles/html5.css b/assets/css/profiles/html5.css new file mode 100644 index 0000000..aba4ccb --- /dev/null +++ b/assets/css/profiles/html5.css @@ -0,0 +1,512 @@ +/** +* O +* \ HOLMES +* +* ------------------------ +* +* Holmes is stand-alone diagnostic CSS stylesheet that will highlight +* potentially invalid or erroneous HTML(5) markup +* +* @author Luke Williams +* @contributors Anthony Mann (hover based debug info) +* @author_url http://www.red-root.com +* @project_url http://github.com/redroot/holmes/ +* @version 1.0.45(06/08/2012) +* +* Copyright (C) 2012 Luke Williams & RedRoot All Rights Reserved. +* holmes.css HTML5 CSS Accessibility Validation +* Licenced under GPL license +* https://www.gnu.org/licenses/gpl.html +* +* ------------------------ +* +* How does it work? +* +* Adding the class 'holmes-debug' to any element, ideally the body or html tag, +* will set debug styles for any elements within that element. +* +* e.g.: +* +* +* ------------------------ +* +* Debug colours and what they mean +* +* - RED denotes an error, invalid markup or a missing attribute +* - YELLOW denotes a warning, so potentionally erroneous markup or bad practice +* - DARK GREY denotes a deprecated element or element with deprecated or Non-W3C attributes +* +* ------------------------- +* +* Notes +* +* - I have chosen to keep all separate types of diagnostis separate, despite sharing +* common CSS styles, to maintain readability +* +* - Getting pretty long so I've split it into outlines/borders and then Anthony's debug info, first +* the style, then the content +* +*/ + +/* Severity marker read by the toolbar counters (E/W): every rule that flags + an element also sets --holmes-html5 to error, warning or info. The reset + below has zero specificity, so the marker never inherits to children. */ +:where(.holmes-debug *) { + --holmes-html5: none; +} + +/* ====================================================== */ +/* = OUTLINES / BORDERS ================================= */ +/* ====================================================== */ + + +/************** + LINKS +**************/ + +/* Errors */ + +.holmes-debug a:not([href]), +.holmes-debug a[href=""] { + outline: 2px solid red; + --holmes-html5: error; +} + +.holmes-debug a[href="#"], +.holmes-debug a[href^="javascript"], +.holmes-debug a[title=""]{ + outline: 2px solid #fd0; + --holmes-html5: warning; +} + + + +/************** + IMAGES +**************/ + +.holmes-debug img:not([alt]){ + outline: 2px solid red; + --holmes-html5: error; +} + +.holmes-debug img[alt=""] { + outline: 2px solid #fd0; + --holmes-html5: warning; +} +/************************* + MISC ATTRIBUTES +**************************/ + +.holmes-debug label:not([for]), +.holmes-debug label[for=""], + +.holmes-debug input:not([name]), +.holmes-debug input[name=""], + +.holmes-debug select:not([name]), +.holmes-debug select[name=""], + +.holmes-debug textarea:not([name]), +.holmes-debug textarea[name=""], + +.holmes-debug abbr:not([title]), +.holmes-debug abbr[title=""], + +.holmes-debug [class=""], +.holmes-debug [id=""] { + outline: 2px solid red; + --holmes-html5: error; +} +/* + Warnings here, for inline styles and event calls, + ideally they should be external (CSS) and JS applied by JS scripts, + keep markup purely presentational! + (not sure about the js yet) +*/ +.holmes-debug [style]/*, +.holmes-debug [onclick], +.holmes-debug [onblur], +.holmes-debug [onfocus], +.holmes-debug [onselect], +.holmes-debug [onload], +.holmes-debug [onunload] + */{ + outline: 2px solid #fd0; + --holmes-html5: warning; +} + +/************************************************************************* + DEPRECATED & NON-W3C CONTENT ELEMENTS (#a9a9a9) + Based largely on http://www.w3.org/TR/html5/obsolete.html#obsolete +*************************************************************************/ + +.holmes-debug applet, +.holmes-debug acronym, +.holmes-debug center, +.holmes-debug dir, +.holmes-debug font, +.holmes-debug strike, +.holmes-debug big, +.holmes-debug tt, +.holmes-debug marquee, /* i've left this is because its naff bascially */ +.holmes-debug plaintext, +.holmes-debug xmp { + border: 2px solid #a9a9a9; + --holmes-html5: warning; +} + +body.holmes-debug[background], .holmes-debug body[background], +body.holmes-debug[bgproperties], .holmes-debug body[bgproperties], +body.holmes-debug[topmargin], .holmes-debug body[topmargin], +body.holmes-debug[rightmargin], .holmes-debug body[rightmargin], +body.holmes-debug[bottommargin], .holmes-debug body[bottommargin], +body.holmes-debug[leftmargin], .holmes-debug body[leftmargin] { + outline: 2px solid #a9a9a9; + --holmes-html5: warning; +} + + +.holmes-debug *[bordercolor], +.holmes-debug *[bordercolordark], +.holmes-debug *[bordercolorlight], +.holmes-debug table[frame], +.holmes-debug table[summary] { + border: 2px solid #a9a9a9; + --holmes-html5: warning; +} + +.holmes-debug *[align], +.holmes-debug *[valign], +.holmes-debug *[bgcolor], +.holmes-debug *[clear], +.holmes-debug ul[type], .holmes-debug li[type] { + border: 2px solid #a9a9a9; + --holmes-html5: warning; +} + +/********************* + EMPTY ELEMENTS (warning level for now) +**********************/ + +.holmes-debug div:empty, +.holmes-debug span:empty, +.holmes-debug li:empty, +.holmes-debug p:empty, +.holmes-debug td:empty, +.holmes-debug th:empty { + border: 2px solid #fd0; + --holmes-html5: warning; +} + + +/* ====================================================== */ +/* = MESSAGES STYLES ==================================== */ +/* ====================================================== */ + +/* ================== */ +/* = Warning Style = */ +/* ================== */ + +/* - LINKS - */ +.holmes-debug a[href="#"]:hover::after, +.holmes-debug a[href^="javascript"]:hover::after, +.holmes-debug a[title=""]:hover::after, +.holmes-debug img[alt=""]:hover::after, + +/* - EMPTY ELEMENTS - */ +.holmes-debug div:empty:hover::after, +.holmes-debug span:empty:hover::after, +.holmes-debug li:empty:hover::after, +.holmes-debug p:empty:hover::after, +.holmes-debug td:empty:hover::after, +.holmes-debug th:empty:hover::after, + +/* - MISC ATTRIBUTES - */ +.holmes-debug [style]:hover::after { + border-radius: 0.5em; + display:block; + padding: 1em; + margin: 1em; + background: #fd0; + position: fixed; + color: #000; + left: 0px; + top: 0px; + z-index: 9999; +} + + +/* =============== */ +/* = Error Style = */ +/* =============== */ + +/* - LINKS - */ +.holmes-debug a:not([href]):hover::after, +.holmes-debug a[href=""]:hover::after, + +/* - IMAGES - */ +.holmes-debug img:not([alt]):hover:after, + +/* MISC ATTRIBUTES */ +.holmes-debug label:not([for]):hover::after, +.holmes-debug label[for=""]:hover::after, + +.holmes-debug input:not([name]):hover::after, +.holmes-debug input[name=""]:hover::after, + +.holmes-debug select:not([name]):hover::after, +.holmes-debug select[name=""]:hover::after, + +.holmes-debug textarea:not([name]):hover::after, +.holmes-debug textarea[name=""]:hover::after, + +.holmes-debug abbr:not([title]):hover::after, +.holmes-debug abbr[title=""]:hover::after, + +.holmes-debug [class=""]:hover::after, +.holmes-debug [id=""]:hover::after { + border-radius: 0.5em; + display:block; + padding: 1em; + margin: 1em; + background: red; + position: fixed; + color: #000; + left: 0px; + top: 0px; + z-index: 9999; + text-decoration: none; +} + +/* ===================== */ +/* = Deprecated Styles = */ +/* ===================== */ + +/* - DEPRECATED & NON-W3C CONTENT ELEMENTS - */ +.holmes-debug applet:hover::after, +.holmes-debug acronym:hover::after, +.holmes-debug center:hover::after, +.holmes-debug dir:hover::after, +.holmes-debug font:hover::after, +.holmes-debug strike:hover::after, +.holmes-debug big:hover::after, +.holmes-debug tt:hover::after, +.holmes-debug marquee:hover::after, +.holmes-debug plaintext:hover::after, +.holmes-debug xmp:hover::after, +.holmes-debug *[bordercolor]:hover::after, +.holmes-debug *[bordercolordark]:hover::after, +.holmes-debug *[bordercolorlight]:hover::after, + +.holmes-debug table[frame]:hover::after, +.holmes-debug table[summary]:hover::after, +.holmes-debug *[align]:hover::after, +.holmes-debug *[valign]:hover::after, +.holmes-debug *[bgcolor]:hover::after, +.holmes-debug *[clear]:hover::after, +.holmes-debug ul[type]:hover::after, +.holmes-debug li[type]:hover::after { + border-radius: 0.5em; + display:block; + padding: 1em; + margin: 1em; + background: red; + position: fixed; + background: #a9a9a9; + color: #000; + left: 0px; + top: 0px; + z-index: 9999; +} + +/* ================================ */ +/* = Deprecated body style Styles = */ +/* ================================ */ + +body.holmes-debug[background]:hover::after, .holmes-debug body[background]:hover::after, +body.holmes-debug[bgproperties]:hover::after, .holmes-debug body[bgproperties]:hover::after, +body.holmes-debug[topmargin]:hover::after, .holmes-debug body[topmargin]:hover::after, +body.holmes-debug[rightmargin]:hover::after, .holmes-debug body[rightmargin]:hover::after, +body.holmes-debug[bottommargin]:hover::after, .holmes-debug body[bottommargin]:hover::after, +body.holmes-debug[leftmargin]:hover::after, .holmes-debug body[leftmargin]:hover::after { + border-radius: 0.5em; + display:block; + padding: 1em; + margin: 1em; + background: red; + position: fixed; + background: #a9a9a9; + color: #000; + bottom: 0px; + right: 0px; + z-index: 9999; + + /* Bottom right due to whole body coverage */ +} + +/* Fixes the case where a table may contain both errors and warnings - potentially other cases of this elsewhere */ + +.holmes-debug td:empty:hover::after, +.holmes-debug th:empty:hover::after { + top: 4em; +} + +/* ====================================================== */ +/* = MESSAGES CONTENT =================================== */ +/* ====================================================== */ + +/************** + LINKS +**************/ + +.holmes-debug a:not([href]):hover::after, +.holmes-debug a[href=""]:hover::after { + content:'Missing href attribute'; +} + +.holmes-debug a[href="#"]:hover:after{ + content:"href='#'"; +} + +.holmes-debug a[href^="javascript"]:hover:after{ + content:'Href has javascript'; +} + +.holmes-debug a[title=""]:hover::after { + content:'Empty title attribute'; +} + + +/************** + IMAGES +**************/ + +.holmes-debug img:not([alt]):hover::after { + content:'Missing alt attribute'; +} +.holmes-debug img[alt=""]:hover::after { + content:'No text in alt attribute'; +} + + +/************************* + MISC ATTRIBUTES +**************************/ + +.holmes-debug label:not([for]):hover::after, +.holmes-debug label[for=""]:hover::after{ + content:'Missing for attribute'; +} + +.holmes-debug input:not([name]):hover::after, +.holmes-debug input[name=""]:hover::after, +.holmes-debug select:not([name]):hover::after, +.holmes-debug select[name=""]:hover::after, +.holmes-debug textarea:not([name]):hover::after, +.holmes-debug textarea[name=""]:hover::after { + content:'Missing name attribute'; +} + +.holmes-debug abbr:not([title]):hover::after, +.holmes-debug abbr[title=""]:hover::after { + content:'Missing title attribute'; +} + +.holmes-debug [class=""]:hover::after { + content:'Blank class attribute'; +} + +.holmes-debug [id=""]:hover::after { + content:'Blank id attribute'; +} + +/* summary is obsolete in HTML5: flagged with the deprecated attributes below */ + +.holmes-debug [style]:hover::after { + content: 'Element has inline styles'; +} + +/************************************************************************* + DEPRECATED & NON-W3C CONTENT ELEMENTS (#a9a9a9) + Based largely on http://www.w3.org/TR/html5/obsolete.html#obsolete +*************************************************************************/ + +.holmes-debug applet:hover::after, +.holmes-debug acronym:hover::after, +.holmes-debug center:hover::after, +.holmes-debug dir:hover::after, +.holmes-debug font:hover::after, +.holmes-debug strike:hover::after, +.holmes-debug big:hover::after, +.holmes-debug tt:hover::after, +.holmes-debug marquee:hover::after, /* i've left this is because its naff bascially */ +.holmes-debug plaintext:hover::after, +.holmes-debug xmp:hover::after { + content:'Deprecated or Non-W3C element'; +} + +/************************************************************************ + DEPRECATED NON-W3C ATTRIBUTES (#a9a9a9) + Based largely on http://www.w3.org/TR/html5/obsolete.html#obsolete +************************************************************************/ + +body.holmes-debug[background]:hover::after, .holmes-debug body[background]:hover::after { + content:'Deprecated or Non-W3C body attribute background'; +} + +body.holmes-debug[bgproperties]:hover::after, .holmes-debug body[bgproperties]:hover::after { + content:'Deprecated or Non-W3C body attribute bgproperties'; +} + +body.holmes-debug[topmargin]:hover::after, .holmes-debug body[topmargin]:hover::after, +body.holmes-debug[rightmargin]:hover::after, .holmes-debug body[rightmargin]:hover::after, +body.holmes-debug[bottommargin]:hover::after, .holmes-debug body[bottommargin]:hover::after, +body.holmes-debug[leftmargin]:hover::after, .holmes-debug body[leftmargin]:hover::after { + content:'Deprecated or Non-W3C body attribute *margin'; +} + +.holmes-debug *[bordercolor]:hover::after, +.holmes-debug *[bordercolordark]:hover::after, +.holmes-debug *[bordercolorlight]:hover::after { + content:'Deprecated or Non-W3C attribute bordercolor'; +} + +.holmes-debug table[summary]:hover::after { + content:'Obsolete table attribute summary: use a caption or aria-describedby'; +} + +.holmes-debug table[frame]:hover::after { + content:'Deprecated or Non-W3C table attribute frame'; +} + +.holmes-debug *[align]:hover::after, +.holmes-debug *[valign]:hover::after { + content:'Deprecated or Non-W3C attribute *align'; +} + +.holmes-debug *[bgcolor]:hover::after { + content:'Deprecated or Non-W3C attribute bgcolor'; +} + +.holmes-debug *[clear]:hover::after { + content:'Deprecated or Non-W3C attribute clear'; +} + +.holmes-debug ul[type]:hover::after, +.holmes-debug li[type]:hover::after { + content:'Deprecated or Non-W3C list attribute type'; +} + +/********************* + EMPTY ELEMENTS (warning level for now) +**********************/ + +.holmes-debug div:empty:hover::after, +.holmes-debug span:empty:hover::after, +.holmes-debug li:empty:hover::after, +.holmes-debug p:empty:hover::after, +.holmes-debug td:empty:hover::after, +.holmes-debug th:empty:hover::after { + content:'Empty element!'; +} diff --git a/assets/css/profiles/index.php b/assets/css/profiles/index.php new file mode 100644 index 0000000..e8c6483 --- /dev/null +++ b/assets/css/profiles/index.php @@ -0,0 +1,3 @@ + diff --git a/assets/css/theme-sets-admin.css b/assets/css/theme-sets-admin.css new file mode 100644 index 0000000..ce68606 --- /dev/null +++ b/assets/css/theme-sets-admin.css @@ -0,0 +1,45 @@ +#cssholmes-theme-sets { color: var(--text-primary, #202936); max-inline-size: 100%; font: inherit; line-height: 1.35; } +#cssholmes-theme-sets * { box-sizing: border-box; } +#cssholmes-theme-sets h1 { font-size: 1.75rem; margin-block: .8rem .4rem; } +#cssholmes-theme-sets h2 { font-size: 1.25rem; margin-block: 0 .5rem; } +#cssholmes-theme-sets h3 { font-size: 1rem; margin-block: 0 .6rem; } +#cssholmes-theme-sets button, #cssholmes-theme-sets select, #cssholmes-theme-sets input { font: inherit; color: #202936; background: #fff; border: 1px solid #a3adbb; border-radius: .25rem; padding: .4rem .6rem; max-inline-size: 100%; } +#cssholmes-theme-sets button, #cssholmes-theme-sets a { cursor: pointer; } +#cssholmes-theme-sets button:hover { background: #eef3f9; } +#cssholmes-theme-sets :focus-visible { outline: 3px solid #245bd1; outline-offset: 2px; } +#cssholmes-theme-sets a { color: #164ba8; text-decoration: underline; } +#cssholmes-theme-sets [hidden] { display: none !important; } +#cssholmes-theme-sets .chs-status { min-block-size: 2.2rem; padding: .5rem .7rem; border-inline-start: 3px solid #245bd1; background: var(--bg-tertiary, #f1f5fa); overflow-wrap: anywhere; } +#cssholmes-theme-sets .chs-controls { display: flex; flex-wrap: wrap; gap: .8rem; align-items: end; margin-block: 1rem; } +#cssholmes-theme-sets .chs-field { display: inline-flex; flex-direction: column; gap: .25rem; max-inline-size: 100%; margin-inline-end: .6rem; margin-block-end: .6rem; } +#cssholmes-theme-sets .chs-field > span { font-weight: 600; } +#cssholmes-theme-sets .chs-table-wrap { overflow-x: auto; max-inline-size: 100%; } +#cssholmes-theme-sets table { inline-size: 100%; min-inline-size: 780px; border-collapse: collapse; background: var(--bg-secondary, #fff); margin: 0; } +#cssholmes-theme-sets caption { text-align: start; padding: .45rem .5rem; font-weight: 600; color: inherit; } +#cssholmes-theme-sets td, #cssholmes-theme-sets th { text-align: start; padding: 4px 8px; border-block-start: 1px solid var(--border-light, #ddd); vertical-align: middle; overflow-wrap: anywhere; } +#cssholmes-theme-sets thead th { white-space: nowrap; } +#cssholmes-theme-sets tbody th[scope="row"] { min-inline-size: 10rem; max-inline-size: 22rem; } +#cssholmes-theme-sets tbody th[scope="row"] { background: transparent; color: inherit; font-weight: 600; } +#cssholmes-theme-sets .chs-category th { background: var(--bg-tertiary, #e4e4e4); color: var(--text-primary, #202936); } +#cssholmes-theme-sets .chs-category button { margin-inline-start: 12px; } +#cssholmes-theme-sets table button, #cssholmes-theme-sets .chs-actions a { display: inline-block; padding: 3px 6px; font: inherit; line-height: 1.25; white-space: nowrap; } +#cssholmes-theme-sets .chs-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 5px; min-inline-size: 21rem; } +#cssholmes-theme-sets .chs-date { white-space: nowrap; } +#cssholmes-theme-sets .chs-sample-toggle { display: inline-flex; align-items: center; gap: 5px; margin-block: 0 .6rem; } +#cssholmes-theme-sets input[type="checkbox"] { inline-size: auto; margin: 0; } +#cssholmes-theme-sets .chs-sample-note { color: var(--text-secondary, #555); margin-block: .4rem; } +#cssholmes-theme-sets .chs-sample-badge { display: inline-block; margin-inline-start: 6px; font-size: .9em; font-weight: 400; color: var(--text-secondary, #555); } +#cssholmes-theme-sets .chs-like-count { display: inline-block; min-inline-size: 1.6rem; margin-inline-end: .5rem; } +#cssholmes-theme-sets .chs-palettes, #cssholmes-theme-sets .chs-import { margin-block-start: 2rem; border-block-start: 1px solid #c8d0db; padding-block-start: 1.2rem; } +#cssholmes-theme-sets .chs-palette-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 1rem; margin-block-start: .7rem; } +#cssholmes-theme-sets .chs-palette { border: 1px solid #c8d0db; background: var(--bg-secondary, #fff); padding: 1rem; border-radius: .3rem; } +#cssholmes-theme-sets .chs-palette ul { padding-inline-start: 1.2rem; font-size: .85rem; } +#cssholmes-theme-sets .chs-swatches { display: flex; flex-wrap: wrap; gap: .5rem; } +#cssholmes-theme-sets .chs-swatches span { font-size: .75rem; text-align: center; } +#cssholmes-theme-sets .chs-swatches span::before { content: ''; display: block; block-size: 2rem; border: 1px solid #a3adbb; border-radius: .2rem; background: var(--chs-swatch); } +#cssholmes-theme-sets .chs-import-review { margin-block: .8rem; } +#cssholmes-theme-sets .chs-import-review ul { border-inline-start: 3px solid #a06600; background: var(--bg-tertiary, #fff8e5); padding: .7rem 1.7rem; } +@media (max-width: 600px) { + #cssholmes-theme-sets .chs-controls { display: grid; grid-template-columns: minmax(0, 1fr); } + #cssholmes-theme-sets .chs-field { display: flex; margin-inline-end: 0; } +} diff --git a/assets/images/admin/about.png b/assets/images/admin/about.png deleted file mode 100644 index f2fd86e..0000000 Binary files a/assets/images/admin/about.png and /dev/null differ diff --git a/assets/images/admin/index.html b/assets/images/admin/index.html deleted file mode 100644 index 2c5cdd3..0000000 --- a/assets/images/admin/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/images/admin/index.php b/assets/images/admin/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/assets/images/admin/index.php @@ -0,0 +1,3 @@ +history.go(-1); \ No newline at end of file diff --git a/assets/images/icons/16/index.php b/assets/images/icons/16/index.php new file mode 100644 index 0000000..2ea9b7d --- /dev/null +++ b/assets/images/icons/16/index.php @@ -0,0 +1,3 @@ +history.go(-1); \ No newline at end of file diff --git a/assets/images/icons/32/index.php b/assets/images/icons/32/index.php new file mode 100644 index 0000000..2ea9b7d --- /dev/null +++ b/assets/images/icons/32/index.php @@ -0,0 +1,3 @@ +history.go(-1); diff --git a/assets/images/icons/index.php b/assets/images/icons/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/assets/images/icons/index.php @@ -0,0 +1,3 @@ +history.go(-1); diff --git a/assets/images/index.php b/assets/images/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/assets/images/index.php @@ -0,0 +1,3 @@ +history.go(-1); \ No newline at end of file diff --git a/assets/index.php b/assets/index.php new file mode 100644 index 0000000..e5ee072 --- /dev/null +++ b/assets/index.php @@ -0,0 +1,2 @@ + { + 'use strict'; + let cache = new WeakMap(); + function declarations(style, names) { + for (const name of style || []) if (name.startsWith('--')) names.add(name); + } + function discover(document) { + const sheets = [...document.styleSheets, ...(document.adoptedStyleSheets || [])]; + const previous = cache.get(document); + if (previous && sheets.length === previous.sheets.length && sheets.every((sheet, index) => sheet === previous.sheets[index])) return previous; + const result = { sheets, names: new Set(), incomplete: false }; + const visited = new Set(); + function rules(sheet) { + if (visited.has(sheet)) return; + visited.add(sheet); + try { walk(sheet.cssRules); } catch (_) { result.incomplete = true; } + } + function walk(list) { + for (const rule of list || []) { + declarations(rule.style, result.names); + if (rule.styleSheet) rules(rule.styleSheet); + if (rule.cssRules) walk(rule.cssRules); + } + } + sheets.forEach(rules); + cache.set(document, result); + return result; + } + function color(value) { + const match = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/); + if (!match) return null; + const rgba = match.slice(1, 4).map(Number); + rgba.push(match[4] === undefined ? 1 : Number(match[4])); + return rgba.every(Number.isFinite) && rgba.slice(0, 3).every(value => value >= 0 && value <= 255) && rgba[3] >= 0 && rgba[3] <= 1 ? rgba : null; + } + function over(front, back) { + const alpha = front[3] + back[3] * (1 - front[3]); + return [...front.slice(0, 3).map((value, index) => alpha ? (value * front[3] + back[index] * back[3] * (1 - front[3])) / alpha : 0), alpha]; + } + function luminance(rgb) { + const linear = rgb.slice(0, 3).map(value => { value /= 255; return value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4; }); + return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722; + } + const unknown = reason => ({ status: 'unknown', reason }); + const rgb = values => `rgb(${values.slice(0, 3).map(value => Math.round(value * 100) / 100).join(', ')})`; + function contrast(element, computed) { + const view = element.ownerDocument.defaultView; + if (!element.getClientRects().length || computed.visibility !== 'visible') return unknown('hidden'); + if (!element.textContent.trim() || element.children.length || /^(INPUT|TEXTAREA|SELECT|BUTTON|IMG|VIDEO|CANVAS|SVG)$/.test(element.tagName)) return unknown('text'); + const foreground = color(computed.color); + if (!foreground) return unknown('color'); + let background = [0, 0, 0, 0]; + const ancestors = new Set(); + for (let current = element; current; current = current.parentElement) { + ancestors.add(current); + const style = view.getComputedStyle(current); + if (Number(style.opacity) !== 1 || style.filter !== 'none' || style.mixBlendMode !== 'normal' + || (style.backdropFilter && style.backdropFilter !== 'none') || (style.maskImage && style.maskImage !== 'none')) return unknown('effects'); + if (style.textShadow !== 'none' || (style.webkitTextFillColor && style.webkitTextFillColor !== style.color) + || parseFloat(style.webkitTextStrokeWidth || '0') !== 0) return unknown('effects'); + for (const pseudo of ['::before', '::after']) { + const generated = view.getComputedStyle(current, pseudo); + if (generated.display !== 'none' && !['none', 'normal'].includes(generated.content)) return unknown('layers'); + } + if (background[3] < 1) { + if (style.backgroundImage !== 'none' || style.backgroundClip.includes('text') || /\binset\b/.test(style.boxShadow)) return unknown('background'); + const layer = color(style.backgroundColor); + if (!layer) return unknown('color'); + background = over(background, layer); + } + } + if (background[3] !== 1) return unknown('surface'); + // shortcut: hit tests cannot prove absence of every painted overlap; keep the estimate disclaimer and reject detected foreign layers. + const rect = element.getBoundingClientRect(); + const x = Math.max(0, Math.min(view.innerWidth - 1, rect.left + rect.width / 2)); + const y = Math.max(0, Math.min(view.innerHeight - 1, rect.top + rect.height / 2)); + if (rect.bottom > 0 && rect.top < view.innerHeight && rect.right > 0 && rect.left < view.innerWidth) { + for (const layer of element.ownerDocument.elementsFromPoint(x, y)) { + if (layer.id === 'cssholmes-designer') continue; + if (!ancestors.has(layer) && !element.contains(layer)) return unknown('layers'); + if (ancestors.has(layer) && color(view.getComputedStyle(layer).backgroundColor)?.[3] === 1) break; + } + } + const painted = over(foreground, background); + const first = luminance(painted), second = luminance(background); + return { status: 'measured', ratio: (Math.max(first, second) + .05) / (Math.min(first, second) + .05), foreground: rgb(painted), background: rgb(background) }; + } + function inspect(element) { + if (!element?.isConnected || !element.ownerDocument?.defaultView) return { tokens: [], discoveryIncomplete: false, contrast: unknown('selection') }; + const computed = element.ownerDocument.defaultView.getComputedStyle(element); + const discovered = discover(element.ownerDocument); + const names = new Set(discovered.names); + declarations(computed, names); + for (let current = element; current; current = current.parentElement) declarations(current.style, names); + const tokens = [...names].sort().map(name => ({ name, value: computed.getPropertyValue(name).trim() })).filter(token => token.value); + return { tokens, discoveryIncomplete: discovered.incomplete, contrast: contrast(element, computed) }; + } + window.CssHolmesDiagnostics = { inspect, refresh: () => { cache = new WeakMap(); } }; +})(); diff --git a/assets/js/designer.js b/assets/js/designer.js new file mode 100644 index 0000000..4384a7f --- /dev/null +++ b/assets/js/designer.js @@ -0,0 +1,961 @@ +/* Theme styles only: editor state stays in memory until an explicit save. */ +(() => { + 'use strict'; + const boot = document.getElementById('cssholmes-designer-config'); + if (!boot || window.CssHolmesDesigner) return; + let config; + try { config = JSON.parse(boot.textContent); } catch (_) { return; } + if (!config.theme || !config.endpoint || !config.stylesheetUrl) return; + + const english = { + title: 'Theme designer', open: 'Design theme', close: 'Close', theme: 'Theme', + pick: 'Pick element', cancel_pick: 'Cancel picking', pick_hint: 'Click an element, or focus it and press Enter. Escape cancels.', + empty: 'Pick an element to edit its theme styles.', selector: 'Selector', selected: 'Selected element', + matches: '{count} matching elements', scope_note: 'Changes apply to every matching element in this theme.', + invalid_selector: 'Use up to four tag, class or ID selectors joined by spaces or >. Gallery controls cannot be selected.', + no_matches: 'This selector has no editable matches on this page.', invalid_value: 'Enter a supported CSS value, or leave blank to remove the override.', + invalid_controls: 'Correct the highlighted value before continuing.', rule_list: 'Saved and draft rules', choose_rule: 'Choose a rule', + mode: 'Color mode', breakpoint: 'Screen size', state: 'Element state', all: 'All', light: 'Light', dark: 'Dark', + mobile: 'Mobile (up to 575px)', tablet: 'Tablet (576–991px)', desktop: 'Desktop (992px and up)', + normal: 'Normal', hover: 'Hover', focus: 'Keyboard focus', preview_note: 'Preview follows the page color mode, current screen size and actual hover or focus state.', + typography: 'Typography', colors: 'Colors', spacing: 'Spacing', size: 'Size', border: 'Border', layout: 'Layout', + color: 'Text color', background_color: 'Background color', border_color: 'Border color', + font_size: 'Font size', line_height: 'Line height', letter_spacing: 'Letter spacing', font_weight: 'Font weight', + font_family: 'Font family', text_align: 'Text alignment', text_transform: 'Text case', text_decoration: 'Text decoration', font_style: 'Font style', + width: 'Width', height: 'Height', max_width: 'Maximum width', min_width: 'Minimum width', + margin_top: 'Top margin', margin_right: 'Right margin', margin_bottom: 'Bottom margin', margin_left: 'Left margin', + margin_inline_start: 'Start margin', margin_inline_end: 'End margin', + margin_block_start: 'Block start margin', margin_block_end: 'Block end margin', + padding_top: 'Top padding', padding_right: 'Right padding', padding_bottom: 'Bottom padding', padding_left: 'Left padding', + padding_inline_start: 'Start padding', padding_inline_end: 'End padding', + padding_block_start: 'Block start padding', padding_block_end: 'Block end padding', + gap: 'Gap', border_width: 'Border width', border_radius: 'Corner radius', border_style: 'Border style', + display: 'Display', align_items: 'Align items', justify_content: 'Justify content', + default_value: 'Theme default', computed: 'Current: {value}', color_picker: '{label} picker', + undo: 'Undo', redo: 'Redo', reset: 'Reset selected', save: 'Save to theme', discard: 'Discard draft', + restore: 'Restore previous save', restore_confirm: 'Restore the previous saved theme configuration? This replaces the current saved styles and any draft.', + more_actions: 'More actions', reload: 'Reload saved settings', reload_confirm: 'Discard this draft and load the latest saved theme settings?', + export_css: 'Export CSS', export_json: 'Export JSON', + loading: 'Loading theme styles…', ready: 'Ready. Changes preview here until you save.', + unsaved: 'Unsaved theme changes.', saved: 'Saved to theme.', discarded: 'Draft discarded.', restored: 'Previous save restored.', + saving: 'Saving…', restoring: 'Restoring…', request_failed: 'Could not load or save theme styles. Your draft is retained.', + preview_only: 'Preview only. Saving requires workbench permission.', changes: '{count} style rules', + unsupported: 'This browser cannot open the theme designer.', target_comment_titles: 'Comment titles', + target_comment_body: 'Comment text', target_article_titles: 'Article titles', target_article_body: 'Article text', + target_button: 'Buttons', target_input: 'Form fields', target_label: 'Form labels', target_table: 'Tables', + target_table_header: 'Table headings', target_message: 'Messages', target_quote: 'Quotations', target_link: 'Links', + library: 'Library', style_sets: 'Style sets', choose_set: 'Choose a set or palette', current_theme_sets: 'Current theme', + other_theme_sets: 'Other themes', starter_palettes: 'Starter palettes', set_name: 'Set name', save_new_set: 'Save as new set', + update_set: 'Update selected set', preview_set: 'Preview selected', like: 'Like', unlike: 'Unlike', + set_details: '{author} · {count} likes', library_note: 'Library saves do not change the theme. Preview first, then use Save to theme to publish.', + set_saved: 'Set saved in library. Theme unchanged.', set_previewed: 'Set previewed. Use Save to theme to publish.', + replace_draft_confirm: 'Discard the current draft, including invalid values, and preview this selection?', + transfer_sets: 'Import / export', import_file: 'Import set JSON', preview_import: 'Preview import', save_import: 'Save imported set', + export_portable: 'Export portable set', import_size: 'Choose a JSON file no larger than 128 KiB.', import_invalid: 'Choose a file containing valid JSON.', + import_previewed: 'Import previewed. Save imported set adds it to the library only.', set_name_required: 'Enter a set name.', + original_theme: 'Original theme', compare_original: 'Compare original', preview_original: 'Preview original defaults', + reset_original: 'Reset theme to original', reset_original_confirm: 'Remove all published designer overrides and discard this draft? This restores the untouched theme. Library sets are kept.', + original_previewed: 'Original theme previewed. Use Save to theme to publish.', original_reset: 'Theme reset to original. Library sets kept.', + palette_contrast: '{name}: {ratio}:1 · {result}', contrast_pass: 'Pass', contrast_fail: 'Fail', + diagnostics: 'Tokens and contrast', diagnostics_refresh: 'Refresh diagnostics', diagnostics_search: 'Search tokens', + diagnostics_sample: 'Inspecting the first of {count} matching elements.', diagnostics_empty: 'Select an element to inspect.', + diagnostics_note: 'Text contrast estimate only, not a full accessibility check. Overlapping and pseudo-element paint may not be fully detected.', + diagnostics_incomplete: 'Token discovery may be incomplete: some stylesheets cannot be read. Refresh after stylesheet changes.', + diagnostics_tokens_empty: 'No matching tokens.', diagnostics_unknown: 'Cannot determine automatically: {reason}', + diagnostics_ratio: '{ratio}:1 · Normal text AA (4.5:1): {normal} · Large text AA (3:1): {large}', + diagnostics_pair: 'Rendered text {foreground} on {background}', + diagnostics_selection: 'no connected element', diagnostics_hidden: 'the element is hidden', + diagnostics_text: 'select a plain text element; nested text and native controls need manual review', + diagnostics_color: 'unsupported color space', diagnostics_effects: 'opacity, filters, blending or text effects', + diagnostics_layers: 'generated or overlapping layers', diagnostics_background: 'image, gradient or complex background', + diagnostics_surface: 'no known opaque background', + diagnostics_repair_color: 'Adjust text color', diagnostics_repair_background: 'Adjust background color' + }; + const labels = { ...english, ...(config.labels || {}) }; + const t = (key, replacements = {}) => Object.entries(replacements).reduce((value, [name, item]) => value.replaceAll(`{${name}}`, String(item)), labels[key] || key); + const groups = { + typography: ['font-size', 'line-height', 'letter-spacing', 'font-weight', 'font-family', 'text-align', 'text-transform', 'text-decoration', 'font-style'], + colors: ['color', 'background-color', 'border-color'], + spacing: ['margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'margin-inline-start', 'margin-inline-end', 'margin-block-start', 'margin-block-end', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'padding-inline-start', 'padding-inline-end', 'padding-block-start', 'padding-block-end', 'gap'], + size: ['width', 'height', 'max-width', 'min-width'], + border: ['border-width', 'border-radius', 'border-style'], + layout: ['display', 'align-items', 'justify-content'] + }; + const supported = new Set(Object.values(groups).flat()); + const propertyNames = Object.keys(config.properties || {}).filter(name => supported.has(name)); + const permitted = new Set(propertyNames.length ? propertyNames : supported); + const enums = { + 'font-weight': ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], + 'text-align': ['start', 'end', 'left', 'right', 'center', 'justify'], + 'text-transform': ['none', 'uppercase', 'lowercase', 'capitalize'], + 'text-decoration': ['none', 'underline', 'overline', 'line-through'], + 'font-style': ['normal', 'italic', 'oblique'], 'border-style': ['none', 'solid', 'dashed', 'dotted', 'double'], + display: ['block', 'inline', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'none'], + 'align-items': ['normal', 'stretch', 'start', 'end', 'center', 'flex-start', 'flex-end', 'baseline'], + 'justify-content': ['normal', 'start', 'end', 'center', 'flex-start', 'flex-end', 'space-between', 'space-around', 'space-evenly'] + }; + const stableTargets = [ + ['.xoops-comment-template .xoops-comment-text > h4', 'target_comment_titles'], ['.comTitle', 'target_comment_titles'], + ['.xoops-comment-text .message-text', 'target_comment_body'], ['.comText', 'target_comment_body'], + ['.itemTitle', 'target_article_titles'], ['.itemText', 'target_article_body'], ['.itemBody', 'target_article_body'], + ['.errorMsg', 'target_message'], ['.confirmMsg', 'target_message'], ['.resultMsg', 'target_message'], + ['.xoops-alert', 'target_message'], ['.head', 'target_table_header'], ['.outer', 'target_table'], + ['.xoops-button', 'target_button'], ['button', 'target_button'], ['input', 'target_input'], + ['textarea', 'target_input'], ['select', 'target_input'], ['label', 'target_label'], ['blockquote', 'target_quote'] + ]; + const host = document.createElement('div'); + host.id = 'cssholmes-designer'; + host.style.cssText = 'all:initial!important;font:13px/1.45 system-ui,sans-serif!important;color:#172033!important;color-scheme:light!important;position:fixed!important;inset:0 auto auto 0!important;inline-size:0!important;block-size:0!important;z-index:2147483647!important;'; + const shadow = host.attachShadow({ mode: 'open' }); + const sheet = document.createElement('link'); + sheet.rel = 'stylesheet'; + sheet.href = config.stylesheetUrl; + shadow.append(sheet); + document.body.append(host); + const node = (tag, attrs = {}, text = null, parent = shadow) => { + const item = document.createElement(tag); + Object.entries(attrs).forEach(([name, value]) => item.setAttribute(name, String(value))); + if (text !== null) item.textContent = text; + parent.append(item); + return item; + }; + const button = (key, parent, action, attrs = {}) => { + const item = node('button', { type: 'button', ...attrs }, t(key), parent); + item.addEventListener('click', action); + return item; + }; + const launcher = button('open', shadow, () => picking ? stopPicking() : open(), { class: 'launcher', 'aria-expanded': 'false', 'aria-controls': 'designer-panel' }); + const panel = node('section', { id: 'designer-panel', 'data-panel': '', class: 'panel', hidden: '', 'aria-label': t('title') }); + const header = node('header', { class: 'panel-header' }, null, panel); + const heading = node('div', {}, null, header); + node('h2', {}, t('title'), heading); + node('p', { class: 'theme-name' }, `${t('theme')}: ${config.theme}`, heading); + button('close', header, close, { class: 'quiet' }); + const actions = node('div', { class: 'action-bar' }, null, panel); + const pickButton = button('pick', actions, startPicking); + const undoButton = button('undo', actions, () => travel(-1)); + const redoButton = button('redo', actions, () => travel(1)); + const scroll = node('div', { class: 'panel-content' }, null, panel); + const status = node('p', { 'data-status': '', role: 'status', 'aria-live': 'polite', class: 'status' }, t('loading'), scroll); + const error = node('p', { role: 'alert', hidden: '', class: 'error' }, '', scroll); + let libraryControls = null; + if (config.libraryEndpoint) { + const library = node('details', { class: 'library', open: '' }, null, scroll); + node('summary', {}, t('library'), library); + node('p', { class: 'help' }, t('library_note'), library); + const selectLabel = node('label', { class: 'field' }, t('style_sets'), library); + const select = node('select', { class: 'palette-select', 'aria-label': t('style_sets') }, null, selectLabel); + const info = node('p', { class: 'help library-info' }, '', library); + const swatches = node('div', { class: 'palette-swatches', 'aria-hidden': 'true' }, null, library); + const contrast = node('p', { class: 'help' }, '', library); + const nameLabel = node('label', { class: 'field' }, t('set_name'), library); + const name = node('input', { type: 'text', maxlength: '100', autocomplete: 'off' }, null, nameLabel); + const row = node('div', { class: 'library-actions' }, null, library); + const previewSet = button('preview_set', row, previewSelected); + const like = button('like', row, () => libraryTask(async () => { + const selected = selectedSet(); + if (selected) await libraryRequest('like', { id: selected.id, liked: !selected.liked }); + })); + const saveRow = node('div', { class: 'library-actions' }, null, library); + const saveNew = button('save_new_set', saveRow, () => saveSet(false)); + const update = button('update_set', saveRow, () => saveSet(true)); + const warnings = node('p', { 'data-library-warnings': '', role: 'status', class: 'notice', hidden: '' }, '', library); + const transfer = node('details', { 'data-library-transfer': '', class: 'library-secondary' }, null, library); + node('summary', {}, t('transfer_sets'), transfer); + const fileLabel = node('label', { class: 'field' }, t('import_file'), transfer); + const file = node('input', { type: 'file', accept: '.json,application/json' }, null, fileLabel); + const transferRow = node('div', { class: 'library-actions' }, null, transfer); + const importPreview = button('preview_import', transferRow, previewImport); + const importSave = button('save_import', transferRow, () => libraryTask(async () => { + if (!importDocument || !canSave) return; + await libraryRequest('importSet', { document: importDocument, ...(name.value.trim() ? { name: name.value.trim() } : {}) }); + importDocument = null; + status.textContent = t('set_saved'); + })); + const exportSet = button('export_portable', transferRow, () => libraryTask(async () => { + const data = await libraryRequest('exportCurrent', { name: name.value.trim(), rules }); + download('json', JSON.stringify(data.document || data.exported, null, 2), 'application/json'); + })); + const original = node('details', { 'data-library-original': '', class: 'library-secondary' }, null, library); + node('summary', {}, t('original_theme'), original); + const compareLabel = node('label', { class: 'compare-original' }, null, library); + const compare = node('input', { type: 'checkbox' }, null, compareLabel); + compareLabel.append(document.createTextNode(t('compare_original'))); + compare.addEventListener('change', () => { applyPreview(); }); + const originalRow = node('div', { class: 'library-actions' }, null, original); + const defaults = button('preview_original', originalRow, () => { + if (!canReplaceDraft()) return; + replaceDraft([]); + status.textContent = t('original_previewed'); + }); + const reset = button('reset_original', originalRow, () => { + if (!window.confirm(t('reset_original_confirm'))) return; + libraryTask(async () => { + accept(await libraryRequest('reset', { revision })); + refreshPersisted(); + status.textContent = t('original_reset'); + }); + }); + libraryControls = { select, info, swatches, contrast, name, previewSet, like, saveNew, update, warnings, file, importPreview, importSave, exportSet, compare, defaults, reset }; + select.addEventListener('change', () => { showSelectedSet(true); }); + name.addEventListener('input', updateActions); + file.addEventListener('change', () => { importDocument = null; updateActions(); }); + } + if (!config.canSave) node('p', { class: 'notice' }, t('preview_only'), scroll); + const ruleLabel = node('label', { class: 'field' }, t('rule_list'), scroll); + const ruleList = node('select', {}, null, ruleLabel); + ruleList.addEventListener('change', () => { + if (ruleList.value === '') return; + const rule = rules[Number(ruleList.value)]; + if (!rule || !canLeaveControls()) return; + selection = { selector: rule.selector, label: rule.label }; + context = { mode: rule.mode, breakpoint: rule.breakpoint, state: rule.state }; + syncFields(); + }); + const selectedTitle = node('h3', { class: 'selected-title' }, t('empty'), scroll); + const diagnostics = node('details', { class: 'diagnostics', 'data-diagnostics': '', hidden: '' }, null, scroll); + node('summary', {}, t('diagnostics'), diagnostics); + const diagnosticsSample = node('p', { class: 'help', 'data-diagnostics-sample': '' }, '', diagnostics); + const contrastStatus = node('p', { 'data-contrast': '', role: 'status', 'aria-live': 'polite' }, '', diagnostics); + const contrastPair = node('p', { class: 'help' }, '', diagnostics); + const contrastActions = node('div', { class: 'library-actions', 'data-contrast-actions': '', hidden: '' }, null, diagnostics); + button('diagnostics_repair_color', contrastActions, () => focusProperty('color')); + button('diagnostics_repair_background', contrastActions, () => focusProperty('background-color')); + node('p', { class: 'help' }, t('diagnostics_note'), diagnostics); + button('diagnostics_refresh', diagnostics, () => { window.CssHolmesDiagnostics?.refresh(); refreshDiagnostics(); }); + const tokenLabel = node('label', { class: 'field' }, t('diagnostics_search'), diagnostics); + const tokenSearch = node('input', { type: 'search', autocomplete: 'off' }, null, tokenLabel); + const tokenWarning = node('p', { class: 'notice', hidden: '' }, t('diagnostics_incomplete'), diagnostics); + const tokenList = node('dl', { class: 'token-list', 'data-token-list': '' }, null, diagnostics); + tokenSearch.addEventListener('input', renderTokens); + const targetArea = node('fieldset', { disabled: '', class: 'target-fields' }, null, scroll); + node('legend', { class: 'sr-only' }, t('selected'), targetArea); + const selectorLabel = node('label', { class: 'field' }, t('selector'), targetArea); + const selectorInput = node('input', { type: 'text', spellcheck: 'false', autocomplete: 'off' }, null, selectorLabel); + const count = node('p', { 'data-count': '', class: 'match-count' }, '', targetArea); + node('p', { class: 'help' }, t('scope_note'), targetArea); + selectorInput.addEventListener('input', () => { + const result = validateSelector(selectorInput.value.trim()); + selectorInput.setAttribute('aria-invalid', String(!result.ok)); + selectorInput.setCustomValidity(result.ok ? '' : t('invalid_selector')); + count.textContent = result.ok ? t('matches', { count: result.elements.length }) : t('invalid_selector'); + updateActions(); + }); + selectorInput.addEventListener('change', () => { + const selector = selectorInput.value.trim().replace(/\s+/g, ' ').replace(/\s*>\s*/g, ' > '); + const result = validateSelector(selector); + if (!result.ok || !canLeaveControls()) return; + selection = { selector, label: selector.slice(0, 160) }; + syncFields(); + }); + const contextControls = {}; + const contexts = node('div', { class: 'contexts' }, null, targetArea); + Object.entries({ mode: ['all', 'light', 'dark'], breakpoint: ['all', 'mobile', 'tablet', 'desktop'], state: ['normal', 'hover', 'focus'] }).forEach(([name, values]) => { + const label = node('label', { class: 'field' }, t(name), contexts); + const select = node('select', { 'data-context': name }, null, label); + values.forEach(value => node('option', { value }, t(value), select)); + contextControls[name] = select; + select.addEventListener('change', () => { + if (!canLeaveControls()) { select.value = context[name]; return; } + context[name] = select.value; + syncFields(); + }); + }); + node('p', { class: 'help' }, t('preview_note'), targetArea); + const propertyFields = new Map(); + Object.entries(groups).forEach(([key, names]) => { + const available = names.filter(name => permitted.has(name)); + if (!available.length) return; + const section = node('details', { class: 'property-group', ...(key === 'typography' || key === 'colors' ? { open: '' } : {}) }, null, targetArea); + node('summary', {}, t(key), section); + const fields = node('div', { class: 'property-fields' }, null, section); + available.forEach(name => { + const labelText = t(name.replaceAll('-', '_')); + const label = node('label', { class: 'field property' }, labelText, fields); + const row = node('span', { class: 'value-row' }, null, label); + const options = config.properties?.[name]?.options || enums[name]; + const input = node(options ? 'select' : 'input', { + 'data-property': name, 'aria-label': labelText, + ...(options ? {} : { type: 'text', placeholder: t('default_value'), spellcheck: 'false', autocomplete: 'off' }) + }, null, row); + if (options) { + node('option', { value: '' }, t('default_value'), input); + options.forEach(value => node('option', { value }, value, input)); + } + const hint = node('small', { class: 'computed' }, '', label); + let picker = null; + if (key === 'colors') { + picker = node('input', { type: 'color', class: 'color-picker', 'aria-label': t('color_picker', { label: labelText }) }, null, row); + picker.addEventListener('input', () => { input.value = picker.value; changeProperty(name, input); }); + } + input.addEventListener(options ? 'change' : 'input', () => changeProperty(name, input)); + propertyFields.set(name, { input, hint, picker }); + }); + }); + const resetButton = button('reset', targetArea, () => { + if (!selection) return; + const index = currentIndex(); + if (index !== -1) commit(rules.filter((_, position) => position !== index)); + syncFields(); + }, { class: 'reset' }); + const footer = node('footer', { class: 'panel-footer' }, null, panel); + footer.append(error); + const changes = node('p', { class: 'rule-count' }, '', footer); + const saveRow = node('div', { class: 'save-row' }, null, footer); + const saveButton = button('save', saveRow, () => persist('save'), { class: 'primary' }); + const discardButton = button('discard', saveRow, discard); + const more = node('details', { class: 'more' }, null, footer); + node('summary', {}, t('more_actions'), more); + const moreActions = node('div', { class: 'more-actions' }, null, more); + const restoreButton = button('restore', moreActions, () => { + if (window.confirm(t('restore_confirm'))) persist('restore'); + }); + const reloadButton = button('reload', moreActions, () => { + const invalid = !selectorInput.checkValidity() || [...propertyFields.values()].some(({ input }) => !input.checkValidity()); + if ((!dirty() && !invalid) || window.confirm(t('reload_confirm'))) load(); + }); + const exportCssButton = button('export_css', moreActions, () => download('css', compile(rules), 'text/css')); + const exportJsonButton = button('export_json', moreActions, () => { + if (libraryControls) libraryControls.exportSet.click(); + else download('json', JSON.stringify({ theme: config.theme, rules }, null, 2), 'application/json'); + }); + const outline = node('div', { class: 'outline', hidden: '', 'aria-hidden': 'true' }); + const pickingHint = node('p', { class: 'pick-hint', hidden: '', role: 'status' }, t('pick_hint')); + const preview = document.createElement('style'); + preview.id = 'cssholmes-draft-style'; + let rules = [], baseline = [], history = [[]], historyIndex = 0; + let revision = 0, token = config.token, canSave = Boolean(config.canSave), canExport = Boolean(config.canExport), canRestore = false; + let loaded = false, busy = false, visible = false, picking = false, selection = null, hovered = null; + let context = { mode: 'all', breakpoint: 'all', state: 'normal' }; + let loadPromise = null; + let previousFocus = null; + let persistedDisabled = false; + let library = { sets: [], palettes: [] }, libraryLoaded = false, importDocument = null; + const setRevisions = new Map(); + let diagnosticTokens = [], diagnosticsTimer = null; + const colorPreference = window.matchMedia('(prefers-color-scheme: dark)'); + const diagnosticsObserver = new MutationObserver(records => { + const relevant = records.filter(record => record.target !== host && !host.contains(record.target)); + if (!relevant.length) return; + if (relevant.some(record => { + const target = record.target.nodeType === 1 ? record.target : record.target.parentElement; + if (target?.closest('#cssholmes-draft-style')) return false; + return target?.matches('style, link') || [...record.addedNodes, ...record.removedNodes].some(item => item.nodeType === 1 && item.id !== 'cssholmes-draft-style' && (item.matches('style, link') || item.querySelector('style, link'))); + })) window.CssHolmesDiagnostics?.refresh(); + scheduleDiagnostics(); + }); + function scheduleDiagnostics() { + if (!visible) return; + clearTimeout(diagnosticsTimer); + diagnosticsTimer = setTimeout(refreshDiagnostics, 100); + } + function stylesheetLoaded(event) { + if (event.target.matches?.('link[rel=stylesheet]')) { window.CssHolmesDiagnostics?.refresh(); scheduleDiagnostics(); } + } + function watchDiagnostics(active) { + diagnosticsObserver.disconnect(); + clearTimeout(diagnosticsTimer); + window.removeEventListener('resize', scheduleDiagnostics); + colorPreference.removeEventListener('change', scheduleDiagnostics); + document.removeEventListener('load', stylesheetLoaded, true); + if (!active || !window.CssHolmesDiagnostics) return; + window.CssHolmesDiagnostics?.refresh(); + diagnosticsObserver.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-bs-theme', 'href', 'media', 'disabled', 'hidden'] }); + window.addEventListener('resize', scheduleDiagnostics); + colorPreference.addEventListener('change', scheduleDiagnostics); + document.addEventListener('load', stylesheetLoaded, true); + scheduleDiagnostics(); + } + function refreshDiagnostics() { + if (!visible) return; + const inspector = window.CssHolmesDiagnostics; + diagnostics.hidden = !inspector; + if (!inspector) return; + const elements = selection ? validateSelector(selection.selector).elements : []; + diagnosticsSample.textContent = elements.length ? t('diagnostics_sample', { count: elements.length }) : t('diagnostics_empty'); + const result = inspector.inspect(elements[0]); + diagnosticTokens = result.tokens; + tokenWarning.hidden = !result.discoveryIncomplete; + const contrast = result.contrast; + contrastStatus.textContent = contrast.status === 'measured' + ? t('diagnostics_ratio', { ratio: contrast.ratio.toFixed(2), normal: t(contrast.ratio >= 4.5 ? 'contrast_pass' : 'contrast_fail'), large: t(contrast.ratio >= 3 ? 'contrast_pass' : 'contrast_fail') }) + : t('diagnostics_unknown', { reason: t(`diagnostics_${contrast.reason}`) }); + contrastPair.textContent = contrast.status === 'measured' ? t('diagnostics_pair', contrast) : ''; + contrastActions.hidden = contrast.status !== 'measured' || contrast.ratio >= 4.5; + renderTokens(); + } + function renderTokens() { + const query = tokenSearch.value.trim().toLowerCase(); + tokenList.replaceChildren(); + const tokens = diagnosticTokens.filter(token => `${token.name} ${token.value}`.toLowerCase().includes(query)); + tokens.forEach(token => { + node('dt', {}, token.name, tokenList); + const value = node('dd', {}, null, tokenList); + if (CSS.supports('color', token.value) && !/\b(?:currentcolor|inherit|initial|unset|revert|var|env)\b/i.test(token.value)) { + const swatch = node('span', { class: 'token-swatch', 'aria-hidden': 'true' }, null, value); + swatch.style.backgroundColor = token.value; + } + value.append(document.createTextNode(token.value)); + }); + if (!tokens.length) node('dd', {}, t('diagnostics_tokens_empty'), tokenList); + } + const clone = value => JSON.parse(JSON.stringify(value)); + const dirty = () => JSON.stringify(rules) !== JSON.stringify(baseline); + const persisted = () => document.getElementById('cssholmes-theme-style'); + const currentIndex = () => selection ? rules.findIndex(rule => rule.selector === selection.selector && rule.mode === context.mode && rule.breakpoint === context.breakpoint && rule.state === context.state) : -1; + function setError(message = '') { error.textContent = message; error.hidden = !message; } + function isEditable(element) { + if (!(element instanceof Element) || element === host || element.closest('#cssholmes-designer, #cssholmes-toolbar, #cssholmes-inspector, [data-cssholmes-designer], script, style, link, meta')) return false; + // Gallery chrome is not a theme specimen; real pages keep their normal content scope. + if (element.closest('#xs-gallery, .xs-gallery') && !element.closest('[data-xs-specimen]')) return false; + return !['HTML', 'HEAD', 'BODY', 'SCRIPT', 'STYLE', 'LINK', 'META'].includes(element.tagName); + } + function validateSelector(selector) { + if (!selector || selector.length > 240 || /(?:^|[.#])(?:xs-|cssholmes-|xstyles|commentxs-fixture)|#(?:comment[_-]|comment\d)/i.test(selector)) return { ok: false, elements: [] }; + const suffix = '[.#][a-zA-Z_][a-zA-Z0-9_-]*'; + const compound = `(?:[a-zA-Z][a-zA-Z0-9-]*(?:${suffix})*|(?:${suffix})+)`; + if (!new RegExp(`^${compound}(?:\\s*>\\s*${compound}|\\s+${compound}){0,3}$`).test(selector) || /[>\s]$/.test(selector)) return { ok: false, elements: [] }; + try { + const matches = [...document.querySelectorAll(selector)]; + return { ok: matches.every(isEditable), elements: matches }; + } catch (_) { return { ok: false, elements: [] }; } + } + function canLeaveControls() { + if ([...propertyFields.values()].some(({ input }) => !input.checkValidity())) { setError(t('invalid_controls')); return false; } + return true; + } + function validValue(name, value) { + if (value === '') return true; + if (value.length > 128 || /[;{}@!\\/<>\x00-\x1f\x7f]/.test(value) || !CSS.supports(name, value)) return false; + const options = config.properties?.[name]?.options || enums[name]; + if (options) return options.includes(value); + if (name === 'font-family') return value.split(',').every(family => /^(?:[A-Za-z][A-Za-z0-9 -]*|"[A-Za-z][A-Za-z0-9 -]*"|'[A-Za-z][A-Za-z0-9 -]*')$/.test(family.trim())); + if (['color', 'background-color', 'border-color'].includes(name)) { + if (value === 'transparent' || /^#(?:[a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/i.test(value)) return true; + const match = value.match(/^(rgb|rgba)\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?|\.\d+))?\s*\)$/); + return Boolean(match && match.slice(2, 5).every(part => Number(part) <= 255) && (match[1] === 'rgba') === Boolean(match[5])); + } + if (value === 'normal' && ['line-height', 'letter-spacing'].includes(name)) return true; + if (value === 'auto' && (name.startsWith('margin-') || ['width', 'height', 'min-width'].includes(name))) return true; + if (value === 'none' && name === 'max-width') return true; + const negative = name.startsWith('margin-') || name === 'letter-spacing' ? '-?' : ''; + const units = name === 'line-height' ? '(?:px|rem|em|%|vh|vw)?' : '(?:px|rem|em|%|vh|vw)'; + return value === '0' || new RegExp(`^${negative}(?:\\d{1,4}(?:\\.\\d{1,4})?|\\.\\d{1,4})${units}$`).test(value); + } + function changeProperty(name, input) { + const value = input.value.trim(); + const valid = validValue(name, value); + input.setCustomValidity(valid ? '' : t('invalid_value')); + input.setAttribute('aria-invalid', String(!valid)); + if (!valid) { setError(t('invalid_value')); updateActions(); return; } + if (!selection || !validateSelector(selection.selector).ok) return; + const next = clone(rules); + const index = currentIndex(); + const rule = index === -1 ? { ...selection, ...context, declarations: {} } : next[index]; + if (value === '') delete rule.declarations[name]; else rule.declarations[name] = value; + if (index === -1 && Object.keys(rule.declarations).length) next.push(rule); + else if (index !== -1 && !Object.keys(rule.declarations).length) next.splice(index, 1); + commit(next); + const picker = propertyFields.get(name).picker; + if (picker && /^#[0-9a-f]{6}$/i.test(value)) picker.value = value; + } + function commit(next) { + importDocument = null; + if (JSON.stringify(next) !== JSON.stringify(rules)) { + rules = next; + history = history.slice(0, historyIndex + 1); + history.push(clone(rules)); + if (history.length > 100) history.shift(); + historyIndex = history.length - 1; + } + setError(); + status.textContent = dirty() ? t('unsaved') : t('ready'); + applyPreview(); + updateActions(); + syncRuleList(); + } + function travel(step) { + if (busy || !loaded) return; + const nextIndex = historyIndex + step; + if (nextIndex < 0 || nextIndex >= history.length) return; + importDocument = null; + historyIndex = nextIndex; + rules = clone(history[historyIndex]); + setError(); + status.textContent = dirty() ? t('unsaved') : t('ready'); + applyPreview(); + syncFields(); + } + function compile(items) { + const breakpoints = ['all', 'mobile', 'tablet', 'desktop']; + const modes = ['all', 'light', 'dark']; + const media = { mobile: '(max-width: 575px)', tablet: '(min-width: 576px) and (max-width: 991px)', desktop: '(min-width: 992px)' }; + return [...items].sort((a, b) => breakpoints.indexOf(a.breakpoint) - breakpoints.indexOf(b.breakpoint) || modes.indexOf(a.mode) - modes.indexOf(b.mode)).map(rule => { + const state = rule.state === 'hover' ? ':hover' : rule.state === 'focus' ? ':focus-visible' : ''; + const selector = rule.selector + state; + const declarations = Object.entries(rule.declarations).filter(([name, value]) => permitted.has(name) && validValue(name, value)).map(([name, value]) => `${name}: ${value} !important;`).join(' '); + if (!declarations) return ''; + let css = `${selector} { ${declarations} }`; + if (rule.mode !== 'all') { + const scope = `:root:is([data-bs-theme="${rule.mode}"], :not([data-bs-theme="light"]):not([data-bs-theme="dark"])[data-theme="${rule.mode}"])`; + const fallback = ':root:not([data-bs-theme="light"]):not([data-bs-theme="dark"]):not([data-theme="light"]):not([data-theme="dark"])'; + css = `${scope} :is(${selector}), ${scope}:is(${selector}) { ${declarations} }\n@media (prefers-color-scheme: ${rule.mode}) { ${fallback} :is(${selector}), ${fallback}:is(${selector}) { ${declarations} } }`; + } + return rule.breakpoint === 'all' ? css : `@media ${media[rule.breakpoint]} { ${css} }`; + }).join('\n'); + } + function applyPreview() { + if (!visible || !loaded) return; + const link = persisted(); + if (link) link.disabled = true; + preview.textContent = libraryControls?.compare.checked ? '' : compile(rules); + document.head.append(preview); + scheduleDiagnostics(); + window.dispatchEvent(new Event('cssholmes:draft-changed')); + } + function syncRuleList() { + const chosen = currentIndex(); + ruleList.replaceChildren(); + node('option', { value: '' }, t('choose_rule'), ruleList); + rules.forEach((rule, index) => node('option', { value: index }, `${rule.label || rule.selector} · ${t(rule.mode)} / ${t(rule.breakpoint)} / ${t(rule.state)}`, ruleList)); + ruleList.value = chosen === -1 ? '' : String(chosen); + } + function syncFields() { + targetArea.disabled = busy || !loaded || !selection; + selectedTitle.textContent = selection ? selection.label : t('empty'); + selectorInput.value = selection ? selection.selector : ''; + selectorInput.setCustomValidity(''); + selectorInput.setAttribute('aria-invalid', 'false'); + const result = selection ? validateSelector(selection.selector) : { elements: [] }; + count.textContent = t('matches', { count: result.elements.length }); + if (selection && !result.elements.length) count.textContent += ` · ${t('no_matches')}`; + Object.entries(contextControls).forEach(([name, select]) => { select.value = context[name]; }); + const rule = rules[currentIndex()]; + const computed = result.elements[0] ? getComputedStyle(result.elements[0]) : null; + propertyFields.forEach(({ input, hint, picker }, name) => { + input.value = rule?.declarations[name] || ''; + input.setCustomValidity(''); + input.setAttribute('aria-invalid', 'false'); + const value = computed ? computed.getPropertyValue(name).trim() : ''; + hint.textContent = value ? t('computed', { value }) : ''; + if (picker) { + const rgb = value.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); + if (rgb) picker.value = '#' + rgb.slice(1).map(part => Number(part).toString(16).padStart(2, '0')).join(''); + else if (/^#[0-9a-f]{6}$/i.test(input.value)) picker.value = input.value; + } + }); + syncRuleList(); + updateActions(); + scheduleDiagnostics(); + } + function updateActions() { + const invalid = !selectorInput.checkValidity() || [...propertyFields.values()].some(({ input }) => !input.checkValidity()); + saveButton.disabled = busy || !loaded || !canSave || !dirty() || invalid; + discardButton.disabled = busy || !loaded || (!dirty() && !invalid); + undoButton.disabled = busy || !loaded || historyIndex === 0; + redoButton.disabled = busy || !loaded || historyIndex >= history.length - 1; + restoreButton.disabled = busy || !loaded || !canSave || !canRestore; + reloadButton.disabled = busy; + exportCssButton.disabled = exportJsonButton.disabled = busy || !loaded || !canExport || invalid; + pickButton.disabled = busy || !loaded; + resetButton.disabled = busy || !selection || currentIndex() === -1; + ruleList.disabled = busy || !loaded; + targetArea.disabled = busy || !loaded || !selection; + changes.textContent = t('changes', { count: rules.length }); + if (libraryControls) { + const controls = libraryControls, selected = selectedSet(); + controls.select.disabled = busy || !libraryLoaded; + controls.name.disabled = busy; + controls.previewSet.disabled = busy || !loaded || !controls.select.value; + controls.saveNew.disabled = busy || !loaded || !canSave || invalid || !controls.name.value.trim(); + controls.update.disabled = busy || !loaded || !canSave || invalid || !selected?.editable || !controls.name.value.trim(); + controls.like.disabled = busy || !loaded || !selected; + controls.importPreview.disabled = busy || !loaded || !controls.file.files.length; + controls.importSave.disabled = busy || !loaded || !canSave || !importDocument; + controls.exportSet.disabled = busy || !loaded || !canExport || invalid; + controls.defaults.disabled = controls.compare.disabled = busy || !loaded; + controls.reset.disabled = busy || !loaded || !canSave; + controls.file.disabled = busy; + } + } + async function request(op) { + const url = new URL(config.endpoint, location.href); + url.searchParams.set('theme', config.theme); + const options = { credentials: 'same-origin', headers: { Accept: 'application/json' } }; + if (op) { + // Renew authorization without accepting server state over the local draft. + await request(); + options.method = 'POST'; + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify({ theme: config.theme, op, revision, rules, token }); + } + const response = await fetch(url, options); + let data; + try { data = await response.json(); } catch (_) { throw new Error(t('request_failed')); } + if (data.token) token = data.token; + if (!response.ok || !data.ok || !data.state || !Array.isArray(data.state.rules)) throw new Error(data.error || t('request_failed')); + return data; + } + function accept(data) { + importDocument = null; + rules = clone(data.state.rules); + baseline = clone(rules); + revision = data.state.revision; + canRestore = Boolean(data.state.canRestore); + canSave = Boolean(data.canSave); + canExport = Boolean(data.canExport); + history = [clone(rules)]; + historyIndex = 0; + loaded = true; + applyPreview(); + syncFields(); + } + async function load() { + busy = true; + updateActions(); + setError(); + status.textContent = t('loading'); + try { + accept(await request()); status.textContent = t('ready'); + if (libraryControls) { + setRevisions.clear(); + await libraryRequest(); + if (!libraryLoaded && config.initialSetId) { + if (config.initialSetId === 'original') { + replaceDraft([]); + libraryControls.name.value = t('original_theme'); + status.textContent = t('original_previewed'); + } else { + libraryControls.select.value = config.initialSetId; + showSelectedSet(true); + if (libraryControls.select.value) { + const data = await libraryRequest(null, { action: 'preview', id: libraryControls.select.value }); + replaceDraft(data.preview.rules, data.preview.warnings, data.preview.set); + status.textContent = t('set_previewed'); + } + } + } + libraryLoaded = true; + } + } + catch (failure) { setError(failure.message); status.textContent = dirty() ? t('unsaved') : loaded ? t('ready') : ''; } + finally { busy = false; syncFields(); loadPromise = null; } + } + async function persist(op) { + if (busy || !loaded || !canSave || (op === 'save' && !canLeaveControls())) return; + busy = true; + setError(); + status.textContent = t(op === 'restore' ? 'restoring' : 'saving'); + updateActions(); + try { + accept(await request(op)); + refreshPersisted(); + status.textContent = t(op === 'restore' ? 'restored' : 'saved'); + } catch (failure) { setError(failure.message); status.textContent = dirty() ? t('unsaved') : t('ready'); } + finally { busy = false; syncFields(); } + } + function discard() { + if (busy || !loaded) return; + importDocument = null; + rules = clone(baseline); + history = [clone(rules)]; + historyIndex = 0; + setError(); + status.textContent = t('discarded'); + applyPreview(); + syncFields(); + } + function refreshPersisted() { + let link = persisted(); + if (!link && config.styleUrl) { + link = document.createElement('link'); + link.id = 'cssholmes-theme-style'; + link.rel = 'stylesheet'; + link.disabled = visible; + document.head.append(link); + } + if (link) { + const url = new URL(config.styleUrl || link.href, location.href); + url.searchParams.set('revision', String(revision)); + link.href = url.href; + } + window.dispatchEvent(new Event('cssholmes:published')); + } + function selectedSet() { return library.sets.find(item => item.id === libraryControls?.select.value); } + function showSelectedSet(rename = false) { + const controls = libraryControls, selected = selectedSet(); + // Some Chromium builds leave selectedcontent empty after rebuilding the options. + const selectedContent = controls.select.querySelector('selectedcontent'); + const selectedOption = controls.select.selectedOptions[0]; + if (selectedContent) selectedContent.replaceChildren(...[...(selectedOption?.childNodes || [])].map(child => child.cloneNode(true))); + const palette = library.palettes.find(item => item.id === controls.select.value); + controls.info.textContent = selected ? t('set_details', { author: selected.author.name, count: selected.likes }) : ''; + controls.like.textContent = t(selected?.liked ? 'unlike' : 'like'); + controls.like.setAttribute('aria-pressed', String(Boolean(selected?.liked))); + if (rename) controls.name.value = selected?.name || palette?.name || ''; + controls.swatches.replaceChildren(); + (palette?.colors || []).forEach(color => { + if (/^#[a-f0-9]{6}$/i.test(color)) node('span', { style: `background-color:${color}`, title: color }, null, controls.swatches); + }); + controls.contrast.textContent = (palette?.contrast || []).map(item => t('palette_contrast', { name: item.name, ratio: item.ratio, result: t(item.pass ? 'contrast_pass' : 'contrast_fail') })).join(' · '); + updateActions(); + } + async function libraryRequest(op, values = {}) { + const url = new URL(config.libraryEndpoint, location.href); + url.searchParams.set('theme', config.theme); + const options = { credentials: 'same-origin', headers: { Accept: 'application/json' } }; + if (op) { + await request(); + options.method = 'POST'; + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify({ theme: config.theme, op, token, ...values }); + } else Object.entries(values).forEach(([name, value]) => url.searchParams.set(name, value)); + const response = await fetch(url, options); + let data; + try { data = await response.json(); } catch (_) { throw new Error(t('request_failed')); } + if (data.token) token = data.token; + if (!response.ok || !data.ok) throw new Error(data.error || t('request_failed')); + if (typeof data.canSave === 'boolean') canSave = data.canSave; + if (typeof data.canExport === 'boolean') canExport = data.canExport; + if (data.library) { + library = data.library; + // Likes and other metadata refreshes must not renew an older draft's revision. + library.sets.forEach(set => { if (!setRevisions.has(set.id)) setRevisions.set(set.id, set.revision); }); + const selected = libraryControls.select.value; + libraryControls.select.replaceChildren(); + const richOptions = CSS.supports('appearance', 'base-select'); + if (richOptions) { + const selectedButton = node('button', { type: 'button' }, null, libraryControls.select); + node('selectedcontent', {}, null, selectedButton); + } + node('option', { value: '' }, t('choose_set'), libraryControls.select); + ['current_theme_sets', 'other_theme_sets', 'starter_palettes'].forEach(key => { + const group = node('optgroup', { label: t(key) }, null, libraryControls.select); + const items = key === 'starter_palettes' ? library.palettes : library.sets.filter(item => (item.theme === config.theme) === (key === 'current_theme_sets')); + items.forEach(item => { + const name = key === 'other_theme_sets' ? `${item.theme} · ${item.name}` : item.name; + const option = node('option', { value: item.id }, null, group); + node('span', { class: 'palette-option-name' }, name, option); + if (key === 'starter_palettes') { + const swatches = node('span', { class: 'palette-option-swatches', 'aria-hidden': 'true' }, null, option); + (item.colors || []).forEach(color => { + if (/^#[a-f0-9]{6}$/i.test(color)) node('span', { style: `background-color:${color}` }, null, swatches); + }); + } + }); + }); + libraryControls.select.value = selected; + showSelectedSet(); + } + return data; + } + async function libraryTask(action) { + if (busy || !loaded) return; + busy = true; setError(); updateActions(); + try { await action(); } + catch (failure) { setError(failure.message); } + finally { busy = false; updateActions(); } + } + function canReplaceDraft() { + const invalid = !selectorInput.checkValidity() || [...propertyFields.values()].some(({ input }) => !input.checkValidity()); + return (!dirty() && !invalid) || window.confirm(t('replace_draft_confirm')); + } + function replaceDraft(next, warnings = [], sourceSet = null) { + libraryControls.compare.checked = false; + importDocument = null; + commit(clone(next)); + if (sourceSet) setRevisions.set(sourceSet.id, sourceSet.revision); + syncFields(); + libraryControls.warnings.textContent = warnings.join('\n'); + libraryControls.warnings.hidden = !warnings.length; + } + function previewSelected() { + if (!canReplaceDraft()) return; + libraryTask(async () => { + const data = await libraryRequest(null, { action: 'preview', id: libraryControls.select.value }); + replaceDraft(data.preview.rules, data.preview.warnings, data.preview.set); + status.textContent = t('set_previewed'); + }); + } + function saveSet(update) { + if (!canSave || !canLeaveControls()) return; + const name = libraryControls.name.value.trim(), selected = selectedSet(); + if (!name) { setError(t('set_name_required')); return; } + libraryTask(async () => { + const data = await libraryRequest(update ? 'updateSet' : 'saveSet', { name, rules, ...(update ? { id: selected.id, setRevision: setRevisions.get(selected.id) } : {}) }); + setRevisions.set(data.set.id, data.set.revision); + status.textContent = t('set_saved'); + }); + } + function previewImport() { + libraryTask(async () => { + const file = libraryControls.file.files[0]; + if (!file || file.size > 128 * 1024) throw new Error(t('import_size')); + let document; + try { document = JSON.parse(await file.text()); } catch (_) { throw new Error(t('import_invalid')); } + const data = await libraryRequest('previewImport', { document }); + if (!canReplaceDraft()) return; + replaceDraft(data.preview.rules, data.preview.warnings); + importDocument = document; + status.textContent = t('import_previewed'); + }); + } + function open() { + if (!visible) { + previousFocus = document.activeElement; + persistedDisabled = persisted()?.disabled || false; + } + visible = true; + watchDiagnostics(true); + window.dispatchEvent(new CustomEvent('cssholmes:designer-opened')); + panel.hidden = false; + launcher.hidden = true; + launcher.setAttribute('aria-expanded', 'true'); + if (!loaded && !loadPromise) loadPromise = load(); + else applyPreview(); + return loadPromise || Promise.resolve(); + } + function close() { + stopPicking(false); + visible = false; + watchDiagnostics(false); + panel.hidden = true; + launcher.hidden = false; + launcher.setAttribute('aria-expanded', 'false'); + preview.remove(); + if (persisted()) persisted().disabled = persistedDisabled; + window.dispatchEvent(new Event('cssholmes:designer-closed')); + if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }); + } + async function select(element, options = {}) { + if (!isEditable(element) || !canLeaveControls()) return; + await open(); + if (!loaded) return; + let found = null; + for (const [selector, label] of stableTargets) { + const ancestor = element.closest(selector); + if (ancestor && isEditable(ancestor) && validateSelector(selector).ok) { found = { selector, label: t(label) }; break; } + } + if (!found) { + const parts = []; + let current = element; + for (let depth = 0; current && depth < 4 && isEditable(current); depth++, current = current.parentElement) { + const classes = [...current.classList].filter(name => /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name) && !/^(?:xs-|cssholmes-|xstyles)/i.test(name)).slice(0, 3); + let part = current.tagName.toLowerCase() + classes.map(name => `.${name}`).join(''); + const id = current.id; + if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(id) && !/^(?:xs-|cssholmes-|xstyles|comment[_-]|comment\d|commentxs-fixture)/i.test(id)) part = `#${id}`; + parts.unshift(part); + if (classes.length || part.startsWith('#')) break; + } + const selector = parts.join(' > '); + if (validateSelector(selector).ok) found = { selector, label: selector.slice(0, 160) }; + } + if (!found) { setError(t('invalid_selector')); return; } + selection = found; + stopPicking(false); + setError(); + syncFields(); + if (options.diagnostics) { diagnostics.open = true; refreshDiagnostics(); } + if (options.property && propertyFields.has(options.property)) focusProperty(options.property); + else selectorInput.focus({ preventScroll: true }); + if (options.highlight) flashSelection(element); + return true; + } + let selectionTimer; + function flashSelection(element) { + cancelAnimationFrame(selectionTimer); + const until = performance.now() + 1800; + const draw = () => { + if (!visible || !element.isConnected || picking || performance.now() >= until) { + if (!picking) outline.hidden = true; + return; + } + highlight(element); + selectionTimer = requestAnimationFrame(draw); + }; + draw(); + } + function focusProperty(property) { + const input = propertyFields.get(property)?.input; + if (!input || input.disabled) return; + input.closest('details').open = true; + input.focus(); + const element = selection && validateSelector(selection.selector).elements[0]; + if (element) flashSelection(element); + } + function startPicking() { + if (!canLeaveControls()) return; + picking = true; + panel.hidden = true; + launcher.hidden = false; + launcher.textContent = t('cancel_pick'); + pickingHint.hidden = false; + shadow.activeElement?.blur(); + } + function stopPicking(focus = true) { + picking = false; + hovered = null; + outline.hidden = true; + pickingHint.hidden = true; + launcher.textContent = t('open'); + if (visible) { panel.hidden = false; launcher.hidden = true; if (focus) pickButton.focus(); } + } + function highlight(element) { + hovered = isEditable(element) ? element : null; + outline.hidden = !hovered; + if (!hovered) return; + const rect = hovered.getBoundingClientRect(); + outline.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + outline.style.width = `${rect.width}px`; + outline.style.height = `${rect.height}px`; + } + document.addEventListener('pointermove', event => { if (picking && !event.composedPath().includes(host)) highlight(event.target); }, true); + document.addEventListener('focusin', event => { if (picking && !event.composedPath().includes(host)) highlight(event.target); }, true); + document.addEventListener('click', event => { + if (event.composedPath().includes(host)) return; + const opener = event.target.closest?.('[data-cssholmes-designer]'); + if (opener) { event.preventDefault(); open(); return; } + if (!picking) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (!isEditable(event.target)) { stopPicking(); setError(t('invalid_selector')); return; } + select(event.target); + }, true); + document.addEventListener('keydown', event => { + if (!picking) return; + if (event.key === 'Escape') { event.preventDefault(); stopPicking(); } + if (event.key === 'Enter' && !event.composedPath().includes(host)) { + event.preventDefault(); + event.stopImmediatePropagation(); + if (isEditable(document.activeElement)) select(document.activeElement); + else { stopPicking(); setError(t('invalid_selector')); } + } + }, true); + window.addEventListener('scroll', () => { if (picking && hovered) highlight(hovered); }, { capture: true, passive: true }); + window.addEventListener('resize', () => { if (picking && hovered) highlight(hovered); }); + window.addEventListener('beforeunload', event => { + if (!dirty()) return; + event.preventDefault(); event.returnValue = ''; + }); + const designEvent = event => { + if (event.currentTarget === window && event.target !== window) return; + event.detail?.target ? select(event.detail.target, { property: event.detail.property, diagnostics: event.detail.diagnostics, highlight: true }) : open(); + }; + window.addEventListener('cssholmes:design', designEvent); + document.addEventListener('cssholmes:design', designEvent); + function download(extension, content, type) { + if (!canExport) return; + const url = URL.createObjectURL(new Blob([content], { type })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${config.theme}-styles.${extension}`; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + window.CssHolmesDesigner = { + open, close, select, + draft: () => ({ theme: config.theme, loaded, visible, css: loaded ? compile(rules) : '', revision, dirty: dirty() }) + }; + updateActions(); + if (config.autoOpen) open(); +})(); diff --git a/assets/js/gallery-designer.js b/assets/js/gallery-designer.js new file mode 100644 index 0000000..df219a3 --- /dev/null +++ b/assets/js/gallery-designer.js @@ -0,0 +1,160 @@ +/* Private gallery previews: CSS stays in this tab and its owned same-origin frames. */ +(() => { + 'use strict'; + const gallery = document.querySelector('#xs-gallery[data-xs-workspace]'); + const designer = window.CssHolmesDesigner; + if (!gallery || typeof designer?.draft !== 'function' || /\/admin\//.test(location.pathname)) return; + let config, base; + try { + config = JSON.parse(document.getElementById('cssholmes-designer-config').textContent); + base = new URL(gallery.dataset.xsPreviewUrl, location.href); + } catch { return; } + if (base.origin !== location.origin || !/\/xstyles\/index\.php$/.test(base.pathname) + || base.searchParams.get('preview_frame') !== '1' || gallery.dataset.xsTheme !== config.theme) return; + const defaults = { + gallery_title: 'Preview your changes', gallery_note: 'Draft previews stay in this tab. Only Save to theme publishes changes for visitors.', + gallery_view: 'Preview', gallery_compare: 'Published vs draft', gallery_responsive: 'Draft at three screen sizes', + gallery_width: 'Viewport width', gallery_refresh: 'Refresh previews', gallery_published: 'Published', gallery_draft: 'Unsaved draft', + gallery_ready: 'Preview ready', gallery_loading: 'Loading preview…', gallery_failed: 'Preview unavailable. Refresh to try again.', + gallery_no_target: 'Choose an element with Pick element in the designer.', gallery_open: 'Open theme designer', + gallery_current: 'Current draft', gallery_wait: 'Open the designer to load your draft.' + }; + const text = key => config.labels?.[key] || defaults[key]; + const node = (tag, parent, value, attributes = {}) => { + const item = document.createElement(tag); + if (value) item.textContent = value; + Object.entries(attributes).forEach(([name, value]) => item.setAttribute(name, value)); + parent.append(item); + return item; + }; + const panel = document.createElement('details'); + panel.className = 'xs-controls'; + panel.dataset.cssholmesPreview = ''; + node('summary', panel, text('gallery_title')); + node('p', panel, text('gallery_note')); + const toolbar = node('div', panel, '', { class: 'cssholmes-preview-toolbar' }); + const viewLabel = node('label', toolbar, text('gallery_view')); + const view = node('select', viewLabel, '', { 'data-draft-view': '' }); + node('option', view, text('gallery_compare'), { value: 'compare' }); + node('option', view, text('gallery_responsive'), { value: 'responsive' }); + const widthLabel = node('label', toolbar, text('gallery_width')); + const width = node('select', widthLabel, '', { 'data-draft-width': '' }); + [360, 768, 1280].forEach(pixels => node('option', width, `${pixels} px`, { value: pixels })); + width.value = '768'; + const refresh = node('button', toolbar, text('gallery_refresh'), { type: 'button' }); + const open = node('button', toolbar, text('gallery_open'), { type: 'button' }); + const status = node('p', panel, '', { role: 'status', 'aria-live': 'polite', 'data-draft-status': '' }); + const tray = node('div', panel, '', { class: 'cssholmes-preview-panes' }); + const launcher = gallery.querySelector('[data-cssholmes-designer]'); + if (!launcher) return; + launcher.closest('p').after(panel); + gallery.classList.toggle('cssholmes-editor-visible', designer.draft().visible); + const frames = new Map(), timers = new Set(); + let generation = 0, targetId = '', pending = false; + function clear() { + generation++; + timers.forEach(clearTimeout); + timers.clear(); + frames.clear(); + tray.replaceChildren(); + } + function apply(frame, record) { + const state = designer.draft(); + const child = frame.contentDocument; + if (!state.loaded || state.theme !== config.theme || !frame.isConnected + || child?.getElementById('xs-gallery')?.dataset.xsTheme !== state.theme + || child.querySelector('[data-xs-workspace], #cssholmes-designer-config')) return false; + if (record.draft) { + const published = child.getElementById('cssholmes-theme-style'); + if (published) published.disabled = true; + let style = child.getElementById('cssholmes-frame-draft'); + if (!style) { style = child.createElement('style'); style.id = 'cssholmes-frame-draft'; child.head.append(style); } + style.textContent = state.css; + record.label.textContent = `${text(state.dirty ? 'gallery_draft' : 'gallery_current')} · ${record.pixels} px`; + } + return true; + } + function update() { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + frames.forEach((record, frame) => { if (record.ready) apply(frame, record); }); + }); + } + async function render() { + clear(); + if (!panel.open) return; + const current = generation; + widthLabel.hidden = view.value === 'responsive'; + if (!designer.draft().loaded) await designer.open(); + if (current !== generation || !panel.open) return; + if (!designer.draft().loaded) { status.textContent = text('gallery_wait'); return; } + status.textContent = `${text('gallery_ready')} · ${config.theme}`; + const example = gallery.querySelector('.xs-example:not([hidden])'); + if (example) targetId = example.id; + const url = new URL(base); + url.searchParams.set('xs_theme', config.theme); + if (example?.dataset.xsSection) url.searchParams.set('section', example.dataset.xsSection); + url.hash = targetId; + const panes = view.value === 'responsive' + ? [360, 768, 1280].map(pixels => ({ pixels, draft: true })) + : [{ pixels: Number(width.value), draft: false }, { pixels: Number(width.value), draft: true }]; + tray.dataset.layout = view.value; + panes.forEach(record => { + if (![360, 768, 1280].includes(record.pixels)) return; + const figure = node('figure', tray); + record.label = node('figcaption', figure, `${text(record.draft ? 'gallery_draft' : 'gallery_published')} · ${record.pixels} px`); + const state = node('p', figure, text('gallery_loading'), { role: 'status', class: 'cssholmes-frame-status' }); + const scroll = node('div', figure, '', { class: 'cssholmes-preview-scroll' }); + const frame = node('iframe', scroll, '', { title: record.label.textContent, width: record.pixels, height: 460 }); + frame.style.width = `${record.pixels}px`; + frame.style.minWidth = `${record.pixels}px`; + frames.set(frame, record); + const timeout = setTimeout(() => { if (current === generation) state.textContent = text('gallery_failed'); }, 15000); + timers.add(timeout); + frame.addEventListener('load', () => { + if (current !== generation) return; + clearTimeout(timeout); timers.delete(timeout); + try { + if (frame.contentWindow.location.origin !== location.origin) throw new Error('Origin mismatch'); + record.ready = apply(frame, record); + state.textContent = text(record.ready ? 'gallery_ready' : 'gallery_failed'); + if (record.ready) { + const element = frame.contentDocument.getElementById(targetId); + if (element) frame.contentWindow.scrollTo(0, frame.contentWindow.scrollY + element.getBoundingClientRect().top - 20); + } + } catch { record.ready = false; state.textContent = text('gallery_failed'); } + }); + frame.src = url.href; + }); + } + panel.addEventListener('toggle', render); + view.addEventListener('change', render); + width.addEventListener('change', render); + refresh.addEventListener('click', render); + open.addEventListener('click', () => designer.open()); + window.addEventListener('cssholmes:draft-changed', update); + window.addEventListener('cssholmes:published', render); + window.addEventListener('cssholmes:designer-opened', () => gallery.classList.add('cssholmes-editor-visible')); + window.addEventListener('cssholmes:designer-closed', () => gallery.classList.remove('cssholmes-editor-visible')); + window.addEventListener('pagehide', clear); + window.addEventListener('pageshow', event => { if (event.persisted) render(); }); + gallery.querySelectorAll('.xs-designer-actions').forEach(item => { item.hidden = false; }); + gallery.addEventListener('click', async event => { + const action = event.target.closest('[data-xs-edit], [data-xs-inspect]'); + if (!action) return; + const example = action.closest('.xs-example'); + const sample = example?.querySelector('[data-xs-specimen]'); + const target = sample?.querySelector('h1,h2,h3,h4,h5,h6,p,button,input:not([type=hidden]),textarea,select,table,blockquote,a,img') || sample?.firstElementChild; + targetId = example?.id || ''; + if (!target || !await designer.select(target, { diagnostics: action.hasAttribute('data-xs-inspect'), highlight: true })) { + status.textContent = text('gallery_no_target'); + await designer.open(); + } + if (panel.open) render(); + }); + gallery.addEventListener('click', event => { + if (panel.open && event.target.closest('[data-xs-example-link]')) setTimeout(render, 0); + }); +})(); diff --git a/assets/js/holmes.js b/assets/js/holmes.js index df613bb..417b6b8 100644 --- a/assets/js/holmes.js +++ b/assets/js/holmes.js @@ -1 +1,1800 @@ -document.getElementsByTagName('html')[0].className = 'holmes-debug'; \ No newline at end of file +(() => { + const PROFILE_ORDER = ['html5', 'xtf-theme', 'xtf-widget', 'a11y', 'layout']; + const XTF_ONLY_PROFILES = ['xtf-theme', 'xtf-widget']; + const PROFILE_ALIASES = { theme: 'xtf-theme', widget: 'xtf-widget', accessibility: 'a11y', html: 'html5' }; + const TOOLBAR_ID = 'cssholmes-toolbar'; + const INSPECTOR_ID = 'cssholmes-inspector'; + // Each profile stylesheet marks the elements it flags with a custom + // property named after the profile, for example + // `--holmes-html5: error` or `--holmes-a11y: warning`. The counters read + // these markers instead of guessing severity from outline colours. + const SEVERITY_PROPERTY_PREFIX = '--holmes-'; + // Keys handled by the keyboard shortcuts (I, M, T, P, U). + const SHORTCUT_KEYS = ['i', 'm', 't', 'p', 'u']; + + const script = document.currentScript || Array.from(document.scripts).find((item) => (item.src || '').includes('/assets/js/holmes.js')) || null; + const scriptUrl = script ? new URL(script.src, window.location.href) : new URL(window.location.href); + if (!['http:', 'https:'].includes(scriptUrl.protocol) || scriptUrl.origin !== window.location.origin) return; + const queryKey = scriptUrl.searchParams.get('queryKey') || 'holmes'; + const pageScope = scriptUrl.searchParams.get('scope') || 'site'; + const themeKey = scriptUrl.searchParams.get('themeKey') || ''; + const themeManifestUrl = scriptUrl.searchParams.get('themeManifestUrl') || ''; + const genericMode = themeManifestUrl === ''; + const storageKey = `cssholmes.activeProfiles.${pageScope}`; + const permsList = (scriptUrl.searchParams.get('perms') || 'overlay').split(','); + const canEdit = permsList.includes('edit'); + const canDiagnose = permsList.includes('diagnose'); + const canExport = permsList.includes('export'); + const root = document.documentElement; + + let activeProfiles = []; + let inspectEnabled = false; + let measureEnabled = false; + let hovered = null; + let selected = null; + let pointerX = 0; + let pointerY = 0; + let toolbar = null; + let details = null; + let copyTextButton = null; + let copyJsonButton = null; + let themeTokens = []; + let changeLog = []; + let tokenEditorOpen = false; + let typographyEditorOpen = false; + let colorBlindMode = ''; + let overflowOverlays = []; + let zIndexOverlays = []; + let gridOverlays = []; + let cachedCounts = null; + + function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); + } + + function normalizeProfiles(value) { + const raw = String(value || '').trim(); + if (raw === '') { + return []; + } + const normalized = raw.toLowerCase(); + if (['0', 'false', 'no', 'off'].includes(normalized)) { + return []; + } + if (['1', 'true', 'yes', 'on', 'html'].includes(normalized)) { + return ['html5']; + } + if (normalized === 'all') { + return [...PROFILE_ORDER]; + } + const profiles = []; + raw.split(/[\s,]+/).forEach((candidate) => { + const profile = PROFILE_ALIASES[candidate.toLowerCase().trim()] || candidate.toLowerCase().trim(); + if (PROFILE_ORDER.includes(profile) && !profiles.includes(profile)) { + profiles.push(profile); + } + }); + return profiles; + } + + function supportedProfiles(profiles) { + return genericMode ? profiles.filter((profile) => !XTF_ONLY_PROFILES.includes(profile)) : profiles; + } + + function resolveProfiles() { + const pageUrl = new URL(window.location.href); + if (pageUrl.searchParams.has(queryKey)) { + const requested = supportedProfiles(normalizeProfiles(pageUrl.searchParams.get(queryKey) || '')); + persistProfiles(requested); + return requested; + } + try { + const stored = supportedProfiles(normalizeProfiles(window.localStorage.getItem(storageKey) || '')); + if (stored.length > 0) { + return stored; + } + } catch (error) {} + return supportedProfiles(normalizeProfiles(scriptUrl.searchParams.get('initialProfiles') || '')); + } + + function persistProfiles(profiles) { + const supported = supportedProfiles(profiles); + try { + if (supported.length === 0) { + window.localStorage.removeItem(storageKey); + } else { + window.localStorage.setItem(storageKey, supported.join(',')); + } + } catch (error) {} + } + + function syncRootState() { + root.setAttribute('data-cssholmes-scope', pageScope); + root.setAttribute('data-cssholmes-mode', genericMode ? 'generic' : 'xtf'); + root.toggleAttribute('data-cssholmes-inspect', inspectEnabled); + root.toggleAttribute('data-cssholmes-measure', measureEnabled); + if (activeProfiles.length > 0) { + root.classList.add('holmes-debug'); + root.setAttribute('data-cssholmes-profiles', activeProfiles.join(',')); + } else { + root.classList.remove('holmes-debug'); + root.removeAttribute('data-cssholmes-profiles'); + } + } + + function ensureStylesheets() { + PROFILE_ORDER.forEach((profile) => { + const id = `cssholmes-profile-${profile}`; + const existing = document.getElementById(id); + const enabled = activeProfiles.includes(profile); + if (enabled && !existing) { + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = new URL(`../css/profiles/${profile}.css`, scriptUrl).href; + link.addEventListener('load', () => { + markCountsDirty(); + refreshToolbar(); + }); + document.head.appendChild(link); + } + if (!enabled && existing) { + existing.remove(); + } + }); + } + + function updateUrl() { + const pageUrl = new URL(window.location.href); + if (activeProfiles.length > 0) { + pageUrl.searchParams.set(queryKey, activeProfiles.join(',')); + } else { + pageUrl.searchParams.delete(queryKey); + } + window.history.replaceState({}, document.title, pageUrl.toString()); + } + + function markCountsDirty() { + cachedCounts = null; + } + + function severityOf(style) { + let severity = ''; + for (const profile of activeProfiles) { + const value = style.getPropertyValue(`${SEVERITY_PROPERTY_PREFIX}${profile}`).trim(); + if (value === 'error') { + return 'error'; + } + if (value === 'warning') { + severity = 'warning'; + } + } + return severity; + } + + function markUnlabelledControls() { + document.querySelectorAll('input, select, textarea').forEach(control => { + if (isInternal(control) || control.closest('[data-holmes-ui]')) return; + const selfNamed = control instanceof HTMLInputElement && ['hidden', 'button', 'submit', 'reset', 'image'].includes(control.type); + const labelled = Array.from(control.labels || []).some(label => label.textContent.trim()) + || (control.getAttribute('aria-label') || '').trim() + || (control.getAttribute('aria-labelledby') || '').split(/\s+/).some(id => document.getElementById(id)?.textContent.trim()) + || (control.getAttribute('title') || '').trim(); + control.toggleAttribute('data-holmes-unlabelled', activeProfiles.includes('a11y') && !selfNamed && !labelled); + }); + } + + function countFindings() { + if (cachedCounts !== null) { + return cachedCounts; + } + markUnlabelledControls(); + let errors = 0; + let warnings = 0; + if (activeProfiles.length > 0 && document.body) { + const elements = [document.documentElement, document.body, ...document.body.querySelectorAll('*')]; + elements.forEach((element) => { + if (isInternal(element) || element.closest('[data-holmes-ui]')) { + return; + } + const severity = severityOf(window.getComputedStyle(element)); + if (severity === 'error') { + errors++; + } else if (severity === 'warning') { + warnings++; + } + }); + } + cachedCounts = { errors, warnings }; + return cachedCounts; + } + + function currentTarget() { + if (selected instanceof HTMLElement && document.contains(selected)) { + return selected; + } + if (inspectEnabled && hovered instanceof HTMLElement && document.contains(hovered)) { + return hovered; + } + return null; + } + + function shortSelector(element) { + if (!(element instanceof HTMLElement)) { + return ''; + } + if (element.id) { + return `${element.tagName.toLowerCase()}#${element.id}`; + } + const classes = Array.from(element.classList).slice(0, 2); + return `${element.tagName.toLowerCase()}${classes.length ? '.' + classes.join('.') : ''}`; + } + + function selectorPath(element) { + if (!(element instanceof HTMLElement)) { + return ''; + } + if (element.id) { + return `#${element.id}`; + } + const parts = []; + let current = element; + while (current instanceof HTMLElement && current !== document.body && parts.length < 4) { + let part = current.tagName.toLowerCase(); + const classes = Array.from(current.classList).slice(0, 2); + if (classes.length > 0) { + part += `.${classes.join('.')}`; + } + parts.unshift(part); + current = current.parentElement; + } + return parts.join(' > '); + } + + function widgetTargetFor(element) { + let current = element instanceof HTMLElement ? element : null; + while (current instanceof HTMLElement && current !== document.body) { + if ( + current.matches('.xtf-slot-widget,[data-widget],[data-widget-name],.xtf-mkt-card,.xmf-widget-lazy,.xmf-widget-loaded,.xmf-widget-loading') + || Array.from(current.classList).some((className) => className.startsWith('xmf-') && !className.includes('__')) + ) { + return current; + } + current = current.parentElement; + } + return null; + } + + function slotTargetFor(element) { + return element instanceof HTMLElement ? element.closest('[data-slot]') : null; + } + + function editableTextTarget(element) { + if (!(element instanceof HTMLElement)) { + return null; + } + if (['SCRIPT', 'STYLE'].includes(element.tagName) || element.children.length > 0) { + return null; + } + return element.innerText.trim() !== '' ? element : null; + } + + function normalizeColor(value) { + const probe = document.createElement('span'); + probe.style.color = ''; + probe.style.color = value; + if (!probe.style.color) { + return ''; + } + document.body.appendChild(probe); + const normalized = window.getComputedStyle(probe).color; + probe.remove(); + return normalized; + } + + function tokenMatchesFor(style) { + if (themeTokens.length === 0) { + return []; + } + + const matches = []; + const currentColors = [style.color, style.backgroundColor].filter(Boolean); + themeTokens.forEach((token) => { + if (token.normalized && currentColors.includes(token.normalized)) { + matches.push(token); + } + }); + + return matches.slice(0, 4); + } + + function tokenBindingsFor(target = currentTarget()) { + if (!(target instanceof HTMLElement)) { + return []; + } + + const style = window.getComputedStyle(target); + + return tokenMatchesFor(style) + .map((token) => { + const properties = []; + if (style.color === token.normalized) { + properties.push('color'); + } + if (style.backgroundColor === token.normalized) { + properties.push('backgroundColor'); + } + + return { + ...token, + properties, + }; + }) + .filter((binding) => binding.properties.length > 0); + } + + function summarizeText(value) { + const normalized = String(value).replace(/\s+/g, ' ').trim(); + return normalized.length > 48 ? `${normalized.slice(0, 45)}...` : normalized; + } + + function typographyBindingsFor(target = currentTarget()) { + if (!(target instanceof HTMLElement)) { + return []; + } + + const style = window.getComputedStyle(target); + + return [ + { key: 'fontSize', cssProperty: 'font-size', label: 'Size', value: style.fontSize, input: 'text' }, + { key: 'lineHeight', cssProperty: 'line-height', label: 'Line Height', value: style.lineHeight, input: 'text' }, + { key: 'letterSpacing', cssProperty: 'letter-spacing', label: 'Letter Spacing', value: style.letterSpacing, input: 'text' }, + { key: 'fontWeight', cssProperty: 'font-weight', label: 'Weight', value: style.fontWeight, input: 'select', options: ['300', '400', '500', '600', '700', '800'] }, + { key: 'textAlign', cssProperty: 'text-align', label: 'Align', value: style.textAlign, input: 'select', options: ['left', 'center', 'right', 'justify'] }, + ]; + } + + function inspectionSnapshot(target = currentTarget()) { + if (!(target instanceof HTMLElement)) { + return null; + } + + const style = window.getComputedStyle(target); + const rect = target.getBoundingClientRect(); + const widgetTarget = widgetTargetFor(target); + const slotTarget = slotTargetFor(target); + const tokens = tokenMatchesFor(style).map((token) => token.key); + + return { + shortSelector: shortSelector(target), + selector: selectorPath(target), + size: `${Math.round(rect.width)} x ${Math.round(rect.height)}`, + position: `${Math.round(rect.left)} / ${Math.round(rect.top)}`, + margin: `${style.marginTop} ${style.marginRight} ${style.marginBottom} ${style.marginLeft}`, + padding: `${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft}`, + font: `${style.fontSize} / ${style.lineHeight}`, + color: style.color, + theme: themeKey || pageScope, + mode: genericMode ? 'generic-xoops' : 'xtf', + widget: widgetTarget ? (widgetTarget.dataset.widget || shortSelector(widgetTarget)) : '', + slot: slotTarget ? (slotTarget.dataset.slot || shortSelector(slotTarget)) : '', + tokens, + }; + } + + function inspectionSnapshotText(target = currentTarget()) { + const snapshot = inspectionSnapshot(target); + if (!snapshot) { + return ''; + } + + const lines = [ + `Element: ${snapshot.shortSelector}`, + `Selector: ${snapshot.selector}`, + `Size: ${snapshot.size}`, + `Pos: ${snapshot.position}`, + `Margin: ${snapshot.margin}`, + `Padding: ${snapshot.padding}`, + `Font: ${snapshot.font}`, + `Color: ${snapshot.color}`, + `Mode: ${snapshot.mode}`, + `Theme: ${snapshot.theme}`, + ]; + + if (snapshot.widget) { + lines.push(`Widget: ${snapshot.widget}`); + } + if (snapshot.slot) { + lines.push(`Slot: ${snapshot.slot}`); + } + if (snapshot.tokens.length > 0) { + lines.push(`Tokens: ${snapshot.tokens.join(', ')}`); + } + + return lines.join('\n'); + } + + function inspectionSnapshotJson(target = currentTarget()) { + const snapshot = inspectionSnapshot(target); + if (!snapshot) { + return ''; + } + + return JSON.stringify(snapshot, null, 2); + } + + function selectedExportPayload(target = currentTarget()) { + const snapshot = inspectionSnapshot(target); + if (!snapshot) { + return ''; + } + + return JSON.stringify({ + scope: pageScope, + theme: themeKey || null, + generated_at: new Date().toISOString(), + changes: [ + { + kind: 'inspect', + selector: snapshot.selector, + widget: snapshot.widget || '', + slot: snapshot.slot || '', + before: '', + after: '', + summary: `Inspect ${snapshot.shortSelector} (${snapshot.size})`, + inspection: snapshot, + }, + ], + }, null, 2); + } + + function writeClipboard(text) { + if (!text) { + return Promise.reject(new Error('No text to copy')); + } + + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + + return new Promise((resolve, reject) => { + const field = document.createElement('textarea'); + field.value = text; + field.setAttribute('readonly', 'readonly'); + field.style.position = 'fixed'; + field.style.top = '-9999px'; + field.style.left = '-9999px'; + document.body.appendChild(field); + field.focus(); + field.select(); + + try { + if (!document.execCommand('copy')) { + throw new Error('Copy command was rejected'); + } + resolve(); + } catch (error) { + reject(error); + } finally { + field.remove(); + } + }); + } + + function flashButton(button, successText, failureText = 'copy failed') { + if (!(button instanceof HTMLButtonElement)) { + return; + } + + const label = button.textContent; + + return { + success() { + button.textContent = successText; + window.setTimeout(() => { + button.textContent = label; + }, 900); + }, + failure() { + button.textContent = failureText; + window.setTimeout(() => { + button.textContent = label; + }, 1200); + }, + }; + } + + function exportPayload() { + return JSON.stringify({ + scope: pageScope, + theme: themeKey || null, + generated_at: new Date().toISOString(), + changes: changeLog.map((change) => ({ + kind: change.kind, + selector: change.selector, + widget: change.widget || '', + slot: change.slot || '', + before: change.before, + after: change.after, + summary: change.summary, + style_property: change.styleProperty || '', + token_key: change.tokenKey || '', + token_properties: change.tokenProperties || [], + inspection: change.inspection || null, + })), + }, null, 2); + } + + function isInternal(element) { + return !!(element instanceof HTMLElement && element.closest(`#${TOOLBAR_ID}, #${INSPECTOR_ID}`)); + } + + function inspectableAt(x, y) { + return document.elementsFromPoint(x, y).find((element) => element instanceof HTMLElement && !isInternal(element)) || null; + } + + function setNode(node, visible, styles = {}, text = null) { + if (!(node instanceof HTMLElement)) { + return; + } + node.style.display = visible ? 'block' : 'none'; + if (!visible) { + return; + } + Object.assign(node.style, styles); + if (text !== null) { + node.textContent = text; + } + } + + function draw() { + const layer = document.getElementById(INSPECTOR_ID); + if (!(layer instanceof HTMLElement)) { + return; + } + + const hoverBox = layer.querySelector('[data-role="hover-box"]'); + const selectBox = layer.querySelector('[data-role="selected-box"]'); + const label = layer.querySelector('[data-role="focus-label"]'); + const lines = Array.from(layer.querySelectorAll('[data-role^="line-"]')); + const badges = Array.from(layer.querySelectorAll('[data-role^="badge-"]')); + const target = currentTarget(); + + const drawBox = (element, node, border) => { + if (!(element instanceof HTMLElement) || !document.contains(element)) { + setNode(node, false); + return; + } + const rect = element.getBoundingClientRect(); + setNode(node, rect.width > 0 && rect.height > 0, { + left: `${rect.left + window.scrollX}px`, + top: `${rect.top + window.scrollY}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + border, + }); + }; + + drawBox(inspectEnabled ? hovered : null, hoverBox, '1px dashed #38bdf8'); + drawBox(selected, selectBox, '2px solid #f59e0b'); + + if (target instanceof HTMLElement) { + const rect = target.getBoundingClientRect(); + setNode(label, true, { + left: `${Math.max(6, rect.left + window.scrollX)}px`, + top: `${Math.max(6, rect.top + window.scrollY - 28)}px`, + }, `${shortSelector(target)} ${Math.round(rect.width)} x ${Math.round(rect.height)}`); + if (label.getBoundingClientRect().top < 6) { + label.style.top = `${rect.bottom + window.scrollY + 6}px`; + } + } else { + setNode(label, false); + } + + if (measureEnabled && target instanceof HTMLElement) { + const rect = target.getBoundingClientRect(); + const cx = rect.left + rect.width / 2 + window.scrollX; + const cy = rect.top + rect.height / 2 + window.scrollY; + const values = [ + { node: lines[0], style: { left: `${cx}px`, top: `${window.scrollY}px`, width: '1px', height: `${Math.max(0, rect.top)}px` }, badge: badges[0], bx: cx + 8, by: window.scrollY + Math.max(6, rect.top / 2), text: `${Math.round(rect.top)}px` }, + { node: lines[1], style: { left: `${rect.right + window.scrollX}px`, top: `${cy}px`, width: `${Math.max(0, window.innerWidth - rect.right)}px`, height: '1px' }, badge: badges[1], bx: rect.right + window.scrollX + 8, by: cy + 8, text: `${Math.round(window.innerWidth - rect.right)}px` }, + { node: lines[2], style: { left: `${cx}px`, top: `${rect.bottom + window.scrollY}px`, width: '1px', height: `${Math.max(0, window.innerHeight - rect.bottom)}px` }, badge: badges[2], bx: cx + 8, by: rect.bottom + window.scrollY + 8, text: `${Math.round(window.innerHeight - rect.bottom)}px` }, + { node: lines[3], style: { left: `${window.scrollX}px`, top: `${cy}px`, width: `${Math.max(0, rect.left)}px`, height: '1px' }, badge: badges[3], bx: window.scrollX + 8, by: cy + 8, text: `${Math.round(rect.left)}px` }, + ]; + values.forEach((entry) => { + setNode(entry.node, true, entry.style); + setNode(entry.badge, true, { left: `${entry.bx}px`, top: `${entry.by}px` }, entry.text); + }); + } else { + lines.forEach((node) => setNode(node, false)); + badges.forEach((node) => setNode(node, false)); + } + + if (details instanceof HTMLElement) { + if (!(target instanceof HTMLElement)) { + details.innerHTML = 'Inspect: I. Measure: M. Click an element to lock it.'; + if (genericMode) { + details.innerHTML += '
Generic XOOPS mode: token-aware XTF tools are limited because no theme.json manifest was detected.'; + } + if (copyTextButton instanceof HTMLButtonElement) { + copyTextButton.disabled = true; + } + if (copyJsonButton instanceof HTMLButtonElement) { + copyJsonButton.disabled = true; + } + } else { + const style = window.getComputedStyle(target); + const rect = target.getBoundingClientRect(); + const tokenMatches = tokenMatchesFor(style); + const widgetTarget = widgetTargetFor(target); + const slotTarget = slotTargetFor(target); + details.innerHTML = ` +
${escapeHtml(shortSelector(target))}
+
${escapeHtml(selectorPath(target))}
+
+ Size${Math.round(rect.width)} x ${Math.round(rect.height)} + Pos${Math.round(rect.left)} / ${Math.round(rect.top)} + Margin${escapeHtml(style.marginTop)} ${escapeHtml(style.marginRight)} ${escapeHtml(style.marginBottom)} ${escapeHtml(style.marginLeft)} + Padding${escapeHtml(style.paddingTop)} ${escapeHtml(style.paddingRight)} ${escapeHtml(style.paddingBottom)} ${escapeHtml(style.paddingLeft)} + Font${escapeHtml(style.fontSize)} / ${escapeHtml(style.lineHeight)} + Color${escapeHtml(style.color)} + Mode${genericMode ? 'generic-xoops' : 'xtf'} + Theme${escapeHtml(themeKey || pageScope)} + ${widgetTarget ? `Widget${escapeHtml(widgetTarget.dataset.widget || shortSelector(widgetTarget))}` : ''} + ${slotTarget ? `Slot${escapeHtml(slotTarget.dataset.slot || shortSelector(slotTarget))}` : ''} +
+ ${tokenMatches.length > 0 ? `
Tokens${tokenMatches.map((token) => `${escapeHtml(token.key)}`).join('')}
` : ''}`; + if (copyTextButton instanceof HTMLButtonElement) { + copyTextButton.disabled = false; + } + if (copyJsonButton instanceof HTMLButtonElement) { + copyJsonButton.disabled = false; + } + } + } + + refreshToolbar(); + } + + function refreshToolbar() { + toolbar = document.getElementById(TOOLBAR_ID); + if (!(toolbar instanceof HTMLElement)) { + return; + } + const counts = countFindings(); + const errorNode = toolbar.querySelector('[data-role="errors"]'); + const warningNode = toolbar.querySelector('[data-role="warnings"]'); + if (errorNode) { + errorNode.textContent = String(counts.errors); + } + if (warningNode) { + warningNode.textContent = String(counts.warnings); + } + PROFILE_ORDER.forEach((profile) => { + const button = toolbar.querySelector(`[data-profile="${profile}"]`); + if (button instanceof HTMLButtonElement) { + button.className = activeProfiles.includes(profile) ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn'; + button.disabled = genericMode && XTF_ONLY_PROFILES.includes(profile); + button.title = genericMode && XTF_ONLY_PROFILES.includes(profile) ? 'This profile requires an XTF theme.json manifest.' : ''; + } + }); + ['inspect', 'measure'].forEach((action) => { + const button = toolbar.querySelector(`[data-action="${action}"]`); + const enabled = action === 'inspect' ? inspectEnabled : measureEnabled; + if (button instanceof HTMLElement) { + button.className = enabled ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn'; + } + }); + const target = currentTarget(); + const editable = editableTextTarget(target); + const widgetTarget = widgetTargetFor(target); + const slotTarget = slotTargetFor(target); + const tokenBindings = tokenBindingsFor(target); + const typographyBindings = typographyBindingsFor(target); + const tokenButton = toolbar.querySelector('[data-action="edit-token"]'); + const typographyButton = toolbar.querySelector('[data-action="edit-typography"]'); + const textButton = toolbar.querySelector('[data-action="edit-text"]'); + const widgetButton = toolbar.querySelector('[data-action="select-widget"]'); + const slotButton = toolbar.querySelector('[data-action="select-slot"]'); + const exportSelectedButton = toolbar.querySelector('[data-action="export-selected"]'); + if (tokenButton instanceof HTMLButtonElement) { + tokenButton.disabled = genericMode || tokenBindings.length === 0; + tokenButton.className = tokenEditorOpen ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + tokenButton.title = genericMode ? 'Token editor requires a detected XTF theme.json manifest.' : ''; + } + if (typographyButton instanceof HTMLButtonElement) { + typographyButton.disabled = !(target instanceof HTMLElement); + typographyButton.className = typographyEditorOpen ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + } + if (textButton instanceof HTMLButtonElement) { + textButton.disabled = !(editable instanceof HTMLElement); + } + if (widgetButton instanceof HTMLButtonElement) { + widgetButton.disabled = !(widgetTarget instanceof HTMLElement); + } + if (slotButton instanceof HTMLButtonElement) { + slotButton.disabled = !(slotTarget instanceof HTMLElement); + } + if (exportSelectedButton instanceof HTMLButtonElement) { + exportSelectedButton.disabled = !(target instanceof HTMLElement); + } + const undoButton = toolbar.querySelector('[data-action="undo"]'); + if (undoButton instanceof HTMLButtonElement) { + undoButton.disabled = changeLog.length === 0; + } + const editorNode = toolbar.querySelector('[data-role="editor"]'); + if (editorNode instanceof HTMLElement) { + if (tokenEditorOpen && tokenBindings.length > 0) { + editorNode.style.display = 'block'; + editorNode.innerHTML = ` +
+ Token Editor + +
+
+ ${tokenBindings.map((binding) => ` +
+
+ ${escapeHtml(binding.key)} + ${escapeHtml(binding.properties.join(' + '))} +
+
+ + + +
+
+ `).join('')} +
`; + } else if (typographyEditorOpen && typographyBindings.length > 0) { + editorNode.style.display = 'block'; + editorNode.innerHTML = ` +
+ Typography Editor + +
+
+ ${typographyBindings.map((binding) => ` +
+ +
+ ${binding.input === 'select' + ? `` + : ``} + +
+
+ `).join('')} +
`; + } else { + editorNode.innerHTML = ''; + editorNode.style.display = 'none'; + } + } + const changesNode = toolbar.querySelector('[data-role="changes"]'); + if (changesNode instanceof HTMLElement) { + if (changeLog.length === 0) { + changesNode.innerHTML = '
No local edits yet.
'; + } else { + changesNode.innerHTML = ` +
+ Local Changes + +
+
+ ${changeLog + .slice(0, 5) + .map((change, index) => `
#${index + 1}${escapeHtml(change.kind)}${escapeHtml(change.selector)}${escapeHtml(change.summary)}
`) + .join('')} +
+
${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]) => + `` + ).join(''); + document.body.appendChild(svg); + } + root.style.setProperty('filter', `url(#cvd-${mode})`, 'important'); + } + + // --- Overflow Detection --- + + function toggleOverflowDetection() { + if (overflowOverlays.length > 0) { + overflowOverlays.forEach((el) => el.remove()); + overflowOverlays = []; + return 0; + } + let count = 0; + const all = document.querySelectorAll('*'); + for (const el of all) { + if (el.closest(`#${TOOLBAR_ID},#${INSPECTOR_ID}`)) continue; + const overX = el.scrollWidth > el.clientWidth + 1; + const overY = el.scrollHeight > el.clientHeight + 1; + if (!overX && !overY) continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + const label = document.createElement('div'); + label.className = 'cssholmes-overflow-label'; + label.setAttribute('data-holmes-ui', 'overflow'); + const dirs = [overX ? 'x' : '', overY ? 'y' : ''].filter(Boolean).join('+'); + label.textContent = `overflow ${dirs}`; + label.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY - 18}px;` + + `padding:2px 6px;background:#dc2626;color:#fff;font:600 11px/1.2 system-ui,sans-serif;border-radius:3px;` + + `z-index:2147483645;pointer-events:none;white-space:nowrap`; + el.style.outline = '2px solid #dc2626'; + el.dataset.holmesOverflow = 'true'; + document.body.appendChild(label); + overflowOverlays.push(label); + count++; + } + return count; + } + + // --- Z-Index Overlay --- + + function toggleZIndexOverlay() { + if (zIndexOverlays.length > 0) { + zIndexOverlays.forEach((el) => el.remove()); + zIndexOverlays = []; + return 0; + } + let count = 0; + const all = document.querySelectorAll('*'); + for (const el of all) { + if (el.closest(`#${TOOLBAR_ID},#${INSPECTOR_ID}`)) continue; + const z = getComputedStyle(el).zIndex; + if (z === 'auto' || z === '0') continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + const label = document.createElement('div'); + label.className = 'cssholmes-zindex-label'; + label.setAttribute('data-holmes-ui', 'zindex'); + label.textContent = `z:${z}`; + const hue = (Math.abs(parseInt(z, 10)) * 37) % 360; + label.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY}px;` + + `padding:2px 6px;background:hsl(${hue},70%,35%);color:#fff;font:700 11px/1.2 system-ui,sans-serif;` + + `border-radius:3px;z-index:2147483645;pointer-events:none;white-space:nowrap;opacity:.9`; + document.body.appendChild(label); + zIndexOverlays.push(label); + count++; + } + return count; + } + + // --- CSS Grid / Flex Overlay --- + + function toggleGridOverlay() { + if (gridOverlays.length > 0) { + gridOverlays.forEach((el) => el.remove()); + gridOverlays = []; + return 0; + } + let count = 0; + const all = document.querySelectorAll('*'); + for (const el of all) { + if (el.closest(`#${TOOLBAR_ID},#${INSPECTOR_ID}`)) continue; + const cs = getComputedStyle(el); + const isGrid = cs.display === 'grid' || cs.display === 'inline-grid'; + const isFlex = cs.display === 'flex' || cs.display === 'inline-flex'; + if (!isGrid && !isFlex) continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + + // Label + const label = document.createElement('div'); + label.setAttribute('data-holmes-ui', 'grid'); + const kind = isGrid ? 'grid' : 'flex'; + const dir = isFlex ? ` ${cs.flexDirection}` : ''; + const cols = isGrid ? cs.gridTemplateColumns : ''; + const colCount = isGrid && cols !== 'none' ? cols.split(/\s+/).length : 0; + const labelText = colCount > 0 ? `${kind} ${colCount}-col` : `${kind}${dir}`; + label.textContent = labelText; + const hue = isGrid ? 280 : 170; + label.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY - 18}px;` + + `padding:2px 6px;background:hsl(${hue},60%,40%);color:#fff;font:600 11px/1.2 system-ui,sans-serif;` + + `border-radius:3px;z-index:2147483645;pointer-events:none;white-space:nowrap`; + document.body.appendChild(label); + gridOverlays.push(label); + + // Outline + const outline = document.createElement('div'); + outline.setAttribute('data-holmes-ui', 'grid'); + outline.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${rect.top + window.scrollY}px;` + + `width:${rect.width}px;height:${rect.height}px;` + + `border:1px dashed hsl(${hue},60%,50%);pointer-events:none;z-index:2147483644;` + + `box-sizing:border-box;border-radius:2px`; + document.body.appendChild(outline); + gridOverlays.push(outline); + + // Grid lines for actual CSS Grid containers + if (isGrid && cols !== 'none') { + const colWidths = cols.split(/\s+/); + let xOffset = rect.left + window.scrollX; + for (let i = 0; i < colWidths.length - 1; i++) { + const colPx = parseFloat(colWidths[i]); + if (isNaN(colPx) || colPx <= 0) continue; + xOffset += colPx; + const gap = parseFloat(cs.columnGap) || 0; + const line = document.createElement('div'); + line.setAttribute('data-holmes-ui', 'grid'); + line.style.cssText = `position:absolute;left:${xOffset + gap * i}px;top:${rect.top + window.scrollY}px;` + + `width:1px;height:${rect.height}px;background:hsl(${hue},60%,50%,0.4);` + + `pointer-events:none;z-index:2147483644`; + document.body.appendChild(line); + gridOverlays.push(line); + } + + const rows = cs.gridTemplateRows; + if (rows !== 'none') { + const rowHeights = rows.split(/\s+/); + let yOffset = rect.top + window.scrollY; + const rowGap = parseFloat(cs.rowGap) || 0; + for (let i = 0; i < rowHeights.length - 1; i++) { + const rowPx = parseFloat(rowHeights[i]); + if (isNaN(rowPx) || rowPx <= 0) continue; + yOffset += rowPx; + const line = document.createElement('div'); + line.setAttribute('data-holmes-ui', 'grid'); + line.style.cssText = `position:absolute;left:${rect.left + window.scrollX}px;top:${yOffset + rowGap * i}px;` + + `width:${rect.width}px;height:1px;background:hsl(${hue},60%,50%,0.4);` + + `pointer-events:none;z-index:2147483644`; + document.body.appendChild(line); + gridOverlays.push(line); + } + } + } + + count++; + } + return count; + } + + function renderToolbar() { + if (!document.body || document.getElementById(TOOLBAR_ID)) { + return; + } + + const layer = document.createElement('div'); + layer.id = INSPECTOR_ID; + layer.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
`; + document.body.appendChild(layer); + + const style = document.createElement('style'); + style.textContent = ` + #${TOOLBAR_ID}, #${INSPECTOR_ID} { font-family:system-ui,-apple-system,sans-serif; } + #${TOOLBAR_ID} * , #${INSPECTOR_ID} * { box-sizing:border-box; } + #${TOOLBAR_ID}{position:fixed;right:1rem;bottom:1rem;z-index:2147483647;max-width:min(34rem,calc(100vw - 2rem));padding:.55rem .7rem;border-radius:10px;background:#111827;color:#f9fafb;box-shadow:0 10px 30px rgba(0,0,0,.3);font-size:13px;text-align:left;transition:all .2s ease} + #${TOOLBAR_ID}.cssholmes--minimized{padding:0;border-radius:50%;width:36px;height:36px;display:flex;align-items:center;justify-content:center;cursor:pointer;max-width:36px;box-shadow:0 4px 12px rgba(0,0,0,.4)} + #${TOOLBAR_ID}.cssholmes--minimized .cssholmes-shell{display:none} + #${TOOLBAR_ID}.cssholmes--minimized .cssholmes-mini-icon{display:flex} + #${TOOLBAR_ID} .cssholmes-mini-icon{display:none;align-items:center;justify-content:center;font-weight:900;font-size:16px;color:#60a5fa;line-height:1} + #${TOOLBAR_ID} .cssholmes-shell{display:flex;flex-direction:column;gap:.45rem} + #${TOOLBAR_ID} .cssholmes-row,#${TOOLBAR_ID} .cssholmes-profiles,#${TOOLBAR_ID} .cssholmes-tools,#${TOOLBAR_ID} .cssholmes-counts{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap} + #${TOOLBAR_ID} .cssholmes-logo{font-weight:700;color:#60a5fa} + #${TOOLBAR_ID} .cssholmes-scope,#${TOOLBAR_ID} .cssholmes-mode{padding:.1rem .45rem;border-radius:999px;background:#1e293b;color:#94a3b8;font-size:11px;text-transform:uppercase} + #${TOOLBAR_ID} .cssholmes-mode{background:${genericMode ? '#7c2d12' : '#14532d'};color:${genericMode ? '#fed7aa' : '#bbf7d0'}} + #${TOOLBAR_ID} .cssholmes-btn{border:1px solid #475569;background:#334155;color:#cbd5e1;border-radius:999px;padding:.2rem .55rem;cursor:pointer;font:inherit;line-height:1.2} + #${TOOLBAR_ID} .cssholmes-btn:hover{background:#475569;color:#fff} + #${TOOLBAR_ID} .cssholmes-btn:disabled{opacity:.45;cursor:default} + #${TOOLBAR_ID} .cssholmes-btn--active{background:#2563eb;border-color:#3b82f6;color:#fff} + #${TOOLBAR_ID} .cssholmes-btn--ghost{background:transparent} + #${TOOLBAR_ID} .cssholmes-count{padding:.1rem .35rem;border-radius:999px;background:#1f2937} + #${TOOLBAR_ID} .cssholmes-count--error{color:#f87171} + #${TOOLBAR_ID} .cssholmes-count--warning{color:#fbbf24} + #${TOOLBAR_ID} .cssholmes-detail{min-height:3.25rem;padding:.5rem .6rem;border:1px solid #334155;border-radius:.75rem;background:#0f172a;color:#94a3b8;line-height:1.4;text-align:left} + #${TOOLBAR_ID} .cssholmes-detail-name{font-weight:700;color:#e2e8f0;margin-bottom:.2rem} + #${TOOLBAR_ID} .cssholmes-detail-path{margin-bottom:.35rem;color:#93c5fd;overflow-wrap:anywhere} + #${TOOLBAR_ID} .cssholmes-detail-grid{display:grid;grid-template-columns:auto 1fr;gap:.2rem .55rem} + #${TOOLBAR_ID} .cssholmes-detail-grid span{color:#94a3b8} + #${TOOLBAR_ID} .cssholmes-detail-grid strong{font-size:12px;color:#e2e8f0;overflow-wrap:anywhere} + #${TOOLBAR_ID} .cssholmes-token-list{margin-top:.45rem;display:flex;gap:.35rem;align-items:center;flex-wrap:wrap} + #${TOOLBAR_ID} .cssholmes-token-list span{color:#94a3b8} + #${TOOLBAR_ID} .cssholmes-token-list code{padding:.12rem .35rem;border-radius:.4rem;background:#1e293b;color:#86efac} + #${TOOLBAR_ID} .cssholmes-editor{padding:.45rem .6rem;border:1px solid #334155;border-radius:.75rem;background:#0b1220} + #${TOOLBAR_ID} .cssholmes-editor-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.45rem} + #${TOOLBAR_ID} .cssholmes-editor-head strong{color:#e2e8f0;font-size:12px} + #${TOOLBAR_ID} .cssholmes-token-editor-list{display:flex;flex-direction:column;gap:.5rem} + #${TOOLBAR_ID} .cssholmes-token-editor-row{padding:.45rem;border-radius:.65rem;background:#0f172a;border:1px solid #1e293b} + #${TOOLBAR_ID} .cssholmes-token-editor-meta{display:flex;justify-content:space-between;gap:.5rem;flex-wrap:wrap;margin-bottom:.4rem} + #${TOOLBAR_ID} .cssholmes-token-editor-meta code{color:#86efac} + #${TOOLBAR_ID} .cssholmes-token-editor-meta span{color:#94a3b8;font-size:11px} + #${TOOLBAR_ID} .cssholmes-token-editor-controls{display:grid;grid-template-columns:3rem minmax(0,1fr) auto;gap:.45rem;align-items:center} + #${TOOLBAR_ID} .cssholmes-token-editor-controls input[type="color"]{width:100%;height:2rem;padding:0;border:1px solid #475569;border-radius:.45rem;background:#111827} + #${TOOLBAR_ID} .cssholmes-token-editor-controls input[type="text"]{width:100%;padding:.35rem .45rem;border:1px solid #475569;border-radius:.45rem;background:#111827;color:#e2e8f0;font:inherit} + #${TOOLBAR_ID} .cssholmes-typography-editor-list{display:flex;flex-direction:column;gap:.5rem} + #${TOOLBAR_ID} .cssholmes-typography-editor-row{padding:.45rem;border-radius:.65rem;background:#0f172a;border:1px solid #1e293b} + #${TOOLBAR_ID} .cssholmes-typography-editor-meta{display:flex;justify-content:space-between;gap:.5rem;flex-wrap:wrap;margin-bottom:.4rem} + #${TOOLBAR_ID} .cssholmes-typography-editor-meta span{color:#e2e8f0;font-size:12px} + #${TOOLBAR_ID} .cssholmes-typography-editor-meta code{color:#93c5fd} + #${TOOLBAR_ID} .cssholmes-typography-editor-controls{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center} + #${TOOLBAR_ID} .cssholmes-typography-editor-controls input[type="text"],#${TOOLBAR_ID} .cssholmes-typography-editor-controls select{width:100%;padding:.35rem .45rem;border:1px solid #475569;border-radius:.45rem;background:#111827;color:#e2e8f0;font:inherit} + #${TOOLBAR_ID} .cssholmes-changes{padding:.45rem .6rem;border:1px solid #334155;border-radius:.75rem;background:#0b1220} + #${TOOLBAR_ID} .cssholmes-changes-empty{color:#64748b} + #${TOOLBAR_ID} .cssholmes-changes-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.35rem} + #${TOOLBAR_ID} .cssholmes-changes-head strong{color:#e2e8f0;font-size:12px} + #${TOOLBAR_ID} .cssholmes-change-list{display:flex;flex-direction:column;gap:.1rem} + #${TOOLBAR_ID} .cssholmes-change-row{display:grid;grid-template-columns:auto auto 1fr 1fr;gap:.35rem;align-items:start;font-size:12px;padding:.15rem 0} + #${TOOLBAR_ID} .cssholmes-change-row span{color:#64748b} + #${TOOLBAR_ID} .cssholmes-change-row strong{color:#f8fafc;font-weight:600;text-transform:uppercase;font-size:11px} + #${TOOLBAR_ID} .cssholmes-change-row code{color:#93c5fd;overflow-wrap:anywhere} + #${TOOLBAR_ID} .cssholmes-change-row em{color:#cbd5e1;font-style:normal;overflow-wrap:anywhere} + #${TOOLBAR_ID} .cssholmes-export{margin:.45rem 0 0;padding:.45rem;border-radius:.55rem;background:#020617;color:#cbd5e1;font-size:11px;line-height:1.35;max-height:9rem;overflow:auto;white-space:pre-wrap} + #${INSPECTOR_ID}{position:absolute;inset:0;pointer-events:none;z-index:2147483646} + #${INSPECTOR_ID} .cssholmes-box,#${INSPECTOR_ID} .cssholmes-label,#${INSPECTOR_ID} .cssholmes-line,#${INSPECTOR_ID} .cssholmes-badge{position:absolute;display:none} + #${INSPECTOR_ID} .cssholmes-box{background:rgba(56,189,248,.08)} + #${INSPECTOR_ID} [data-role="selected-box"]{background:rgba(245,158,11,.08)} + #${INSPECTOR_ID} .cssholmes-label,#${INSPECTOR_ID} .cssholmes-badge{padding:.2rem .45rem;border-radius:.45rem;font:600 12px/1.2 system-ui,-apple-system,sans-serif;color:#fff;white-space:nowrap} + #${INSPECTOR_ID} .cssholmes-label{background:#0284c7} + #${INSPECTOR_ID} .cssholmes-line{background:#34d399} + #${INSPECTOR_ID} .cssholmes-badge{background:#065f46;color:#d1fae5}`; + document.head.appendChild(style); + + const element = document.createElement('div'); + element.id = TOOLBAR_ID; + element.innerHTML = ` +
H
+
+
+ + ${escapeHtml(pageScope)} + ${genericMode ? 'generic' : 'xtf'} + ${PROFILE_ORDER.map((profile) => ``).join('')} + +
+
+ + + + ${canEdit ? ` + + ${pageScope === 'site' && document.getElementById('cssholmes-designer-config') ? '' : ''} + + ` : ''} + + + + ${canExport ? ` + ` : ''} + + + + E:0 + W:0 + + +
+ ${canDiagnose ? `
+ + + + + + + + + +
` : ''} +
+ +
+
`; + + element.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + // Expand from minimized state — click anywhere on the mini icon + if (element.classList.contains('cssholmes--minimized')) { + element.classList.remove('cssholmes--minimized'); + return; + } + + const profile = target.getAttribute('data-profile'); + if (profile) { + toggleProfile(profile); + return; + } + switch (target.getAttribute('data-action')) { + case 'minimize': + // A minimised toolbar must not keep capturing clicks. + leaveInspectionModes(); + element.classList.add('cssholmes--minimized'); + break; + case 'clear': + activeProfiles = []; + markCountsDirty(); + persistProfiles(activeProfiles); + syncRootState(); + ensureStylesheets(); + updateUrl(); + refreshToolbar(); + break; + case 'inspect': + inspectEnabled = !inspectEnabled; + hovered = inspectEnabled ? inspectableAt(pointerX, pointerY) : null; + syncRootState(); + draw(); + break; + case 'measure': + measureEnabled = !measureEnabled; + syncRootState(); + draw(); + break; + case 'copy-selector': + copySelector(); + break; + case 'copy-json': + copyInspectionJson(); + break; + case 'export-selected': + exportSelected(); + break; + case 'edit-token': + editToken(); + break; + case 'edit-typography': + editTypography(); + break; + case 'theme-designer': { + const selected = currentTarget(); + leaveInspectionModes(); + syncRootState(); + draw(); + window.dispatchEvent(new CustomEvent('cssholmes:design', { detail: { target: selected } })); + break; + } + case 'close-token-editor': + tokenEditorOpen = false; + refreshToolbar(); + break; + case 'close-typography-editor': + typographyEditorOpen = false; + refreshToolbar(); + break; + case 'apply-token': { + const tokenKey = target.getAttribute('data-token-key') || ''; + const input = toolbar?.querySelector(`[data-role="token-value"][data-token-key="${CSS.escape(tokenKey)}"]`); + if (input instanceof HTMLInputElement) { + applyTokenEdit(tokenKey, input.value); + } + break; + } + case 'apply-typography': { + const styleKey = target.getAttribute('data-style-key') || ''; + const input = toolbar?.querySelector(`[data-role="typography-value"][data-style-key="${CSS.escape(styleKey)}"]`); + if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement) { + applyTypographyEdit(styleKey, input.value); + } + break; + } + case 'edit-text': + editText(); + break; + case 'undo': + undoLastChange(); + break; + case 'copy-export': + copyExport(); + break; + case 'select-widget': + selected = widgetTargetFor(currentTarget()); + if (selected) { + draw(); + } + break; + case 'select-slot': + selected = slotTargetFor(currentTarget()); + if (selected) { + draw(); + } + break; + case 'reset-inspector': + clearInspector(); + break; + case 'detect-overflow': { + const count = toggleOverflowDetection(); + target.className = overflowOverlays.length > 0 + ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + if (count > 0) target.textContent = `overflow (${count})`; + else target.textContent = 'overflow'; + break; + } + case 'show-zindex': { + const zCount = toggleZIndexOverlay(); + target.className = zIndexOverlays.length > 0 + ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + if (zCount > 0) target.textContent = `z-index (${zCount})`; + else target.textContent = 'z-index'; + break; + } + case 'show-grid': { + const gCount = toggleGridOverlay(); + target.className = gridOverlays.length > 0 + ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + if (gCount > 0) target.textContent = `grid (${gCount})`; + else target.textContent = 'grid'; + break; + } + case 'cvd-protanopia': + case 'cvd-deuteranopia': + case 'cvd-tritanopia': + case 'cvd-achromatopsia': { + const mode = target.getAttribute('data-action').replace('cvd-', ''); + toggleColorBlind(mode); + // Update all CVD button states + element.querySelectorAll('[data-action^="cvd-"]').forEach((btn) => { + const btnMode = btn.getAttribute('data-action').replace('cvd-', ''); + btn.className = (colorBlindMode === btnMode) + ? 'cssholmes-btn cssholmes-btn--active' : 'cssholmes-btn cssholmes-btn--ghost'; + }); + break; + } + default: + break; + } + }); + + // Start minimized — toolbar opens only when user clicks the icon + element.classList.add('cssholmes--minimized'); + + document.body.appendChild(element); + toolbar = element; + details = element.querySelector('[data-role="details"]'); + copyTextButton = element.querySelector('[data-action="copy-selector"]'); + copyJsonButton = element.querySelector('[data-action="copy-json"]'); + element.addEventListener('input', (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + if ('token-color' === target.getAttribute('data-role')) { + const tokenKey = target.getAttribute('data-token-key') || ''; + const valueInput = toolbar?.querySelector(`[data-role="token-value"][data-token-key="${CSS.escape(tokenKey)}"]`); + if (valueInput instanceof HTMLInputElement) { + valueInput.value = target.value; + } + } + }); + attachListeners(); + let labelRefreshPending = false; + new MutationObserver(records => { + if (!activeProfiles.includes('a11y') || labelRefreshPending || !records.some(record => { + const element = record.target instanceof Element ? record.target : record.target.parentElement; + return element && !isInternal(element) && !element.closest('[data-holmes-ui]'); + })) return; + labelRefreshPending = true; + window.requestAnimationFrame(() => { + labelRefreshPending = false; + markCountsDirty(); + refreshToolbar(); + }); + }).observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, + attributeFilter: ['id', 'for', 'aria-label', 'aria-labelledby', 'title', 'type'] }); + window.addEventListener('load', () => { + markCountsDirty(); + refreshToolbar(); + }, { once: true }); + loadThemeTokens(); + draw(); + window.setTimeout(refreshToolbar, 120); + } + + window.addEventListener('cssholmes:designer-opened', () => { + leaveInspectionModes(); + syncRootState(); + draw(); + }); + + activeProfiles = supportedProfiles(resolveProfiles()); + syncRootState(); + ensureStylesheets(); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', renderToolbar, { once: true }); + } else { + renderToolbar(); + } +})(); diff --git a/assets/js/index.html b/assets/js/index.html deleted file mode 100644 index 74b6f45..0000000 --- a/assets/js/index.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/js/index.php b/assets/js/index.php new file mode 100644 index 0000000..e5ee072 --- /dev/null +++ b/assets/js/index.php @@ -0,0 +1,2 @@ + { + 'use strict'; + const root = document.getElementById('cssholmes-theme-sets'); + const boot = document.getElementById('cssholmes-theme-sets-config'); + if (!root || !boot) return; + let config; + try { config = JSON.parse(boot.textContent); } catch (_) { return; } + if (!config.libraryEndpoint || !config.previewUrl) return; + const t = (key, values = {}) => Object.entries(values).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), config.labels?.[key] || key); + const el = (tag, text, attrs = {}) => { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value); + return node; + }; + const button = (text, action) => { + const node = el('button', text, { type: 'button' }); + node.addEventListener('click', () => run(action)); + return node; + }; + const field = (label, node) => { + const wrap = el('label', undefined, { class: 'chs-field' }); + wrap.append(el('span', label), node); + return wrap; + }; + let library = { themes: [], sets: [], palettes: [] }; + let canSave = false; + let busy = false; + let pendingImport = null; + let importGeneration = 0; + const status = el('p', t('loading'), { role: 'status', 'aria-live': 'polite', class: 'chs-status' }); + const controls = el('div', undefined, { class: 'chs-controls' }); + const themeFilter = el('select', undefined, { 'aria-label': t('theme') }); + const nameFilter = el('input', undefined, { type: 'search', placeholder: t('filter_hint') }); + const sort = el('select', undefined, { 'aria-label': t('sort') }); + for (const [value, key] of [['name', 'sort_name'], ['modified', 'sort_modified'], ['likes', 'sort_likes']]) sort.append(el('option', t(key), { value })); + const table = el('table', undefined, { class: 'outer' }); + const showSamples = el('input', undefined, { type: 'checkbox' }); + showSamples.checked = true; + const sampleLabel = el('label', undefined, { class: 'chs-sample-toggle' }); + sampleLabel.append(showSamples, document.createTextNode(t('show_samples'))); + const sampleNote = el('p', t('sample_note'), { class: 'chs-sample-note' }); + const head = el('thead'); + const header = el('tr'); + for (const key of ['name', 'author', 'created', 'modified', 'likes', 'actions']) header.append(el('th', t(key), { scope: 'col' })); + head.append(header); + table.append(el('caption', t('table_caption')), head); + const tableWrap = el('div', undefined, { class: 'chs-table-wrap', tabindex: '0', role: 'region', 'aria-label': t('table_caption') }); + tableWrap.append(table); + const palettes = el('section', undefined, { class: 'chs-palettes', 'aria-label': t('palettes') }); + const paletteTheme = el('select', undefined, { 'aria-label': t('palette_theme') }); + const paletteList = el('div', undefined, { class: 'chs-palette-list' }); + palettes.append(el('h2', t('palettes')), el('p', t('palette_note')), field(t('palette_theme'), paletteTheme), paletteList); + const importBox = el('section', undefined, { class: 'chs-import', 'aria-label': t('import_title') }); + const importTheme = el('select'); + const importName = el('input', undefined, { type: 'text', maxlength: '80' }); + const file = el('input', undefined, { type: 'file', accept: '.json,application/json' }); + const review = el('div', undefined, { class: 'chs-import-review' }); + const importConfirm = button(t('import_confirm'), async () => { + if (!pendingImport || pendingImport.theme !== importTheme.value) return; + await mutate(importTheme.value, 'importSet', { document: pendingImport.document, name: importName.value.trim() }); + clearImport(); + file.value = ''; + await reload(); + status.textContent = t('imported'); + }); + importConfirm.hidden = true; + importBox.append(el('h2', t('import_title')), el('p', t('import_note')), field(t('import_theme'), importTheme), field(t('import_name'), importName), field(t('json_file'), file), button(t('review_import'), async () => { + clearImport(); + const selected = file.files[0]; + if (!selected || !importTheme.value) throw new Error(t('choose_import')); + if (selected.size > 131072) throw new Error(t('too_large')); + const theme = importTheme.value; + const generation = importGeneration; + let document; + try { document = JSON.parse(await selected.text()); } catch (_) { throw new Error(t('invalid_json')); } + if (generation !== importGeneration) return; + const response = await mutate(theme, 'previewImport', { document }); + if (generation !== importGeneration) return; + const preview = response.preview; + if (!preview?.document || !Array.isArray(preview.rules)) throw new Error(t('request_failed')); + pendingImport = { theme, document: preview.document }; + review.append(el('p', t('rule_count', { count: preview.rules.length }))); + if (preview.warnings?.length) { + const warnings = el('ul'); + for (const warning of preview.warnings) warnings.append(el('li', warning)); + review.append(warnings); + } + importConfirm.hidden = false; + status.textContent = t('review_ready'); + }), review, importConfirm); + controls.append(field(t('theme'), themeFilter), field(t('filter'), nameFilter), field(t('sort'), sort), button(t('reload'), async () => { await reload(); status.textContent = t('ready'); }), sampleLabel); + root.replaceChildren(el('h1', t('title')), el('p', t('intro')), status, controls, sampleNote, tableWrap, palettes, importBox); + + function clearImport() { + importGeneration += 1; + pendingImport = null; + review.replaceChildren(); + importConfirm.hidden = true; + } + file.addEventListener('change', clearImport); + importTheme.addEventListener('change', clearImport); + for (const control of [themeFilter, sort]) control.addEventListener('change', renderTable); + nameFilter.addEventListener('input', renderTable); + showSamples.addEventListener('change', () => { sampleNote.hidden = !showSamples.checked; renderTable(); }); + paletteTheme.addEventListener('change', renderPalettes); + + async function request(query = {}, data = null) { + const url = new URL(config.libraryEndpoint, window.location.href); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + const response = await fetch(url, { + method: data ? 'POST' : 'GET', credentials: 'same-origin', cache: 'no-store', + headers: data ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' }, + body: data ? JSON.stringify(data) : undefined + }); + let result; + try { result = await response.json(); } catch (_) { throw new Error(t('request_failed')); } + if (response.status === 409) throw new Error(t('conflict')); + if (!response.ok || !result.ok) throw new Error(result.error || t('request_failed')); + return result; + } + async function mutate(theme, op, data, confirmKey) { + const fresh = await request({ theme }); + if (confirmKey && !window.confirm(t(confirmKey, { theme, name: data.name || '' }))) return null; + return request({}, { ...data, theme, op, token: fresh.token, ...(fresh.state ? { revision: fresh.state.revision } : {}) }); + } + async function run(action) { + if (busy) return; + busy = true; + root.setAttribute('aria-busy', 'true'); + status.textContent = t('loading'); + try { await action(); } catch (error) { status.textContent = error.message || t('request_failed'); } + finally { busy = false; root.setAttribute('aria-busy', 'false'); } + } + async function reload() { + const response = await request(); + library = response.library; + if (!Array.isArray(library?.themes) || !Array.isArray(library.sets)) throw new Error(t('request_failed')); + canSave = response.canSave === true; + for (const [select, includeAll] of [[themeFilter, true], [paletteTheme, false], [importTheme, false]]) { + const previous = select.value; + select.replaceChildren(); + if (includeAll) select.append(el('option', t('all_themes'), { value: '' })); + for (const theme of library.themes) select.append(el('option', theme, { value: theme })); + if ([...select.options].some(option => option.value === previous)) select.value = previous; + } + importBox.hidden = !canSave; + renderTable(); + renderPalettes(); + } + function previewLink(theme, id, label = 'preview') { + const url = new URL(config.previewUrl, window.location.href); + for (const [key, value] of Object.entries({ section: 'all', designer: '1', xoops_theme_select: theme, theme_set: id })) url.searchParams.set(key, value); + return el('a', t(label), { href: url.href, target: '_blank', rel: 'noopener', title: t(label === 'edit' ? 'edit_new_tab' : 'preview_new_tab') }); + } + function timestamp(value) { + return new Date(typeof value === 'number' ? value * 1000 : value).getTime(); + } + function date(value) { + const parsed = new Date(timestamp(value)); + if (!Number.isFinite(parsed.getTime())) return '—'; + return parsed.toLocaleString(document.documentElement.lang || undefined); + } + function renderTable() { + for (const body of [...table.tBodies]) body.remove(); + const filter = nameFilter.value.toLocaleLowerCase(); + const themes = library.themes.filter(theme => !themeFilter.value || themeFilter.value === theme); + for (const theme of themes) { + const body = el('tbody'); + const category = el('tr', undefined, { class: 'chs-category' }); + const categoryCell = el('th', undefined, { scope: 'rowgroup', colspan: '6' }); + categoryCell.append(el('span', theme)); + if (canSave) categoryCell.append(button(t('reset'), async () => { + const response = await mutate(theme, 'reset', {}, 'reset_confirm'); + if (!response) { status.textContent = t('cancelled'); return; } + await reload(); status.textContent = t('reset_done'); + })); + category.append(categoryCell); + body.append(category); + const sets = library.sets.filter(set => set.theme === theme && set.name.toLocaleLowerCase().includes(filter)); + if (showSamples.checked && !library.sets.some(set => set.theme === theme) && t('sample_name').toLocaleLowerCase().includes(filter)) { + sets.push({ id: 'original', theme, name: t('sample_name'), author: { name: 'Admin' }, created: '2026-10-01T12:00:00Z', modified: '2026-10-20T15:30:00Z', likes: 5 + [...theme].reduce((sum, letter) => sum + letter.codePointAt(0), 0) % 80, liked: false, sample: true }); + } + sets.sort((a, b) => { + if (sort.value === 'likes') return b.likes - a.likes || a.name.localeCompare(b.name); + if (sort.value === 'modified') return timestamp(b.modified) - timestamp(a.modified) || a.name.localeCompare(b.name); + return a.name.localeCompare(b.name); + }); + for (const set of sets) { + const row = el('tr', undefined, { class: body.querySelectorAll('.chs-set-row').length % 2 ? 'chs-set-row even' : 'chs-set-row odd', ...(set.sample ? { 'data-sample': '' } : {}) }); + const name = el('th', set.name, { scope: 'row' }); + if (set.sample) name.append(el('small', t('sample'), { class: 'chs-sample-badge' })); + row.append(name, el('td', set.author?.name || '—'), el('td', date(set.created), { class: 'chs-date' }), el('td', date(set.modified), { class: 'chs-date' })); + const likes = el('td'); + const like = button(t(set.liked ? 'unlike' : 'like'), async () => { + await mutate(theme, 'like', { id: set.id, liked: !set.liked }); + await reload(); status.textContent = t('liked'); + }); + like.setAttribute('aria-pressed', String(set.liked === true)); + if (set.sample) { like.disabled = true; like.title = t('sample_readonly'); } + likes.append(el('span', String(set.likes), { class: 'chs-like-count' }), like); + const actionsCell = el('td'); + const actions = el('div', undefined, { class: 'chs-actions' }); + if (canSave && (set.editable || set.sample)) actions.append(previewLink(theme, set.id, 'edit')); + actions.append(previewLink(theme, set.id), button(t('export'), async () => { + const response = await request({ theme, action: 'export', id: set.id }); + const url = URL.createObjectURL(new Blob([JSON.stringify(response.exported, null, 2)], { type: 'application/json' })); + const link = el('a', undefined, { href: url, download: `${theme}-${set.id}.json` }); + root.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + status.textContent = t('exported'); + })); + if (canSave) actions.append(button(t('clone'), async () => { + const name = window.prompt(t('clone_prompt'), t('copy_name', { name: [...set.name].slice(0, 70).join('') })); + if (name === null) { status.textContent = t('cancelled'); return; } + if (!name.trim()) throw new Error(t('name_required')); + await mutate(theme, 'cloneSet', { id: set.id, name: name.trim() }); + await reload(); status.textContent = t('cloned'); + })); + if (canSave && !set.sample) actions.append(button(t('activate'), async () => { + const response = await mutate(theme, 'activate', { id: set.id, name: set.name }, 'activate_confirm'); + if (!response) { status.textContent = t('cancelled'); return; } + await reload(); status.textContent = t('activated'); + })); + if (canSave && (set.editable || set.sample)) { + const remove = button(t('delete'), async () => { + if (set.sample) return; + const response = await mutate(theme, 'deleteSet', { id: set.id, setRevision: set.revision, name: set.name }, 'delete_confirm'); + if (!response) { status.textContent = t('cancelled'); return; } + await reload(); status.textContent = t('deleted'); + }); + remove.disabled = set.sample === true; + if (set.sample) remove.title = t('sample_readonly'); + actions.append(remove); + } + actionsCell.append(actions); + row.append(likes, actionsCell); + body.append(row); + } + if (!sets.length) { + const row = el('tr'); + row.append(el('td', t(filter ? 'no_matches' : 'empty'), { colspan: '6' })); + body.append(row); + } + table.append(body); + } + if (!themes.length) { + const body = el('tbody'); + const row = el('tr'); + row.append(el('td', t('no_themes'), { colspan: '6' })); + body.append(row); table.append(body); + } + } + function renderPalettes() { + paletteList.replaceChildren(); + for (const palette of library.palettes || []) { + const article = el('article', undefined, { class: 'chs-palette' }); + article.append(el('h3', palette.name)); + const swatches = el('div', undefined, { class: 'chs-swatches' }); + for (const color of palette.colors || []) { + if (!/^#[\da-f]{6}$/i.test(color)) continue; + const swatch = el('span', color); + swatch.style.setProperty('--chs-swatch', color); + swatches.append(swatch); + } + article.append(swatches); + const pairs = el('ul'); + for (const pair of palette.contrast || []) pairs.append(el('li', `${pair.name}: ${Number(pair.ratio).toFixed(2)}:1 — ${t(pair.pass ? 'pair_pass' : 'pair_fail')}`)); + article.append(pairs); + if (paletteTheme.value) article.append(previewLink(paletteTheme.value, palette.id)); + paletteList.append(article); + } + } + run(async () => { await reload(); status.textContent = t('ready'); }); +})(); diff --git a/bin/holmes-scan.php b/bin/holmes-scan.php new file mode 100644 index 0000000..99892d9 --- /dev/null +++ b/bin/holmes-scan.php @@ -0,0 +1,43 @@ + + */ + +if ('cli' !== PHP_SAPI) { + header('HTTP/1.0 404 Not Found'); + exit; +} + +// The module's classes only; nothing from XOOPS core is loaded. +spl_autoload_register( + static function (string $class): void { + $prefix = 'XoopsModules\\Cssholmes\\'; + if (!str_starts_with($class, $prefix)) { + return; + } + + $file = dirname(__DIR__) . '/class/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file)) { + require_once $file; + } + } +); + +exit((new XoopsModules\Cssholmes\Cli\ScanCommand())->run(array_slice($argv, 1))); diff --git a/bin/index.php b/bin/index.php new file mode 100644 index 0000000..2ea9b7d --- /dev/null +++ b/bin/index.php @@ -0,0 +1,3 @@ + + */ + +final class AccessibilityAnalyzer implements AnalyzerInterface +{ + public function name(): string + { + return 'accessibility'; + } + + public function label(): string + { + return 'Accessibility Analyzer'; + } + + /** + * Page-level checks (lang, main landmark, h1, heading order, skip link) + * run on theme.tpl with its includes inlined. Element checks (empty links + * and buttons, img alt, form labels, tabindex) run on every template file + * so findings carry a file and line. Smarty tags are neutralised first. + * + * @return Finding[] + */ + public function analyze(AnalysisContext $context): array + { + $findings = []; + + if (str_starts_with(trim($context->themePath), '<')) { + $html = SmartySource::htmlView(trim($context->themePath)); + $this->runPageChecks($html, 'html', $findings); + $this->runElementChecks($html, 'html', $findings); + + return $findings; + } + + if (!$context->isThemeFolder()) { + return []; + } + + $resolver = new TemplateIncludeResolver($context->themePath); + $page = $resolver->expandedPage(); + if ('' !== $page) { + $pageTemplate = $this->pageTemplateName($context->themePath); + $this->runPageChecks(SmartySource::htmlView($page), $pageTemplate, $findings); + } + + foreach (ThemeFiles::templates($context->themePath) as $relativePath => $fullPath) { + $content = file_get_contents($fullPath); + if (false === $content) { + continue; + } + + $this->runElementChecks(SmartySource::htmlView($content), $relativePath, $findings); + } + + $this->checkContrastTokenPairs($context, $findings); + + return $findings; + } + + private function pageTemplateName(string $themePath): string + { + return is_file($themePath . '/theme.tpl') ? 'theme.tpl' : 'theme.html'; + } + + /** @param Finding[] $findings */ + private function runPageChecks(string $html, string $target, array &$findings): void + { + $this->checkLang($html, $target, $findings); + $this->checkMainLandmark($html, $target, $findings); + $this->checkH1($html, $target, $findings); + $this->checkHeadingSkip($html, $target, $findings); + $this->checkSkipLink($html, $target, $findings); + } + + /** @param Finding[] $findings */ + private function runElementChecks(string $html, string $target, array &$findings): void + { + $this->checkEmptyElements($html, 'a', 'Empty link with no accessible name', $target, $findings); + $this->checkEmptyElements($html, 'button', 'Empty button with no accessible name', $target, $findings); + $this->checkImgAlt($html, $target, $findings); + $this->checkFormLabels($html, $target, $findings); + $this->checkTabindex($html, $target, $findings); + } + + /** @param Finding[] $findings */ + private function checkLang(string $html, string $target, array &$findings): void + { + if (preg_match('/]*\blang\s*=/i', $html)) { + $findings[] = new Finding( + Severity::Error, + 'a11y/no-lang', + 'Page has no lang attribute on ', + $target, + suggestion: 'Add lang="<{$xoops_langcode}>" to .' + ); + } + } + + /** @param Finding[] $findings */ + private function checkMainLandmark(string $html, string $target, array &$findings): void + { + if (!preg_match('/ landmark', + $target, + suggestion: 'Wrap <{$xoops_contents}> in
.' + ); + } + } + + /** @param Finding[] $findings */ + private function checkH1(string $html, string $target, array &$findings): void + { + if (!preg_match('/]/i', $html)) { + $findings[] = new Finding( + Severity::Info, + 'a11y/no-h1', + 'The page template has no

; each page then depends on module content for its main heading', + $target, + suggestion: 'Check that module pages print an

, or give the theme one.' + ); + } + } + + /** @param Finding[] $findings */ + private function checkHeadingSkip(string $html, string $target, array &$findings): void + { + if (!preg_match_all('/]/i', $html, $matches)) { + return; + } + + $levels = array_map('intval', $matches[1]); + $previous = 0; + foreach ($levels as $level) { + if ($previous > 0 && $level > $previous + 1) { + $findings[] = new Finding( + Severity::Warning, + 'a11y/heading-skip', + "Heading level skips from h{$previous} to h{$level}", + $target, + suggestion: 'Add intermediate heading levels.' + ); + break; + } + $previous = $level; + } + } + + /** @param Finding[] $findings */ + private function checkEmptyElements( + string $html, + string $tag, + string $message, + string $target, + array &$findings + ): void { + $ruleId = 'a' === $tag ? 'a11y/empty-link' : 'a11y/empty-' . $tag; + $pattern = '/<' . $tag . '\b([^>]*)>\s*<\/' . $tag . '>/i'; + if (!preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + return; + } + + foreach ($matches as $match) { + $attributes = (string)$match[1][0]; + if ('a' === $tag && !preg_match('/\bhref\s*=/i', $attributes)) { + // is a fragment target, not a link. + continue; + } + + $hasLabel = (bool)preg_match('/aria-label\s*=\s*["\'][^"\']+["\']/', $attributes); + $hasLabelledBy = (bool)preg_match('/aria-labelledby\s*=/', $attributes); + $hasTitle = (bool)preg_match('/\btitle\s*=\s*["\'][^"\']+["\']/', $attributes); + if ($hasLabel || $hasLabelledBy || $hasTitle || SmartySource::hasDynamicAttributes($attributes)) { + continue; + } + + $findings[] = new Finding( + Severity::Error, + $ruleId, + $message, + $target, + SmartySource::lineAt($html, (int)$match[0][1]), + 'Add text content or aria-label.' + ); + } + } + + /** @param Finding[] $findings */ + private function checkImgAlt(string $html, string $target, array &$findings): void + { + if (!preg_match_all('/]*)>/i', $html, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + return; + } + + foreach ($matches as $match) { + $attributes = (string)$match[1][0]; + if (preg_match('/\balt\s*=/i', $attributes) || SmartySource::hasDynamicAttributes($attributes)) { + continue; + } + + $findings[] = new Finding( + Severity::Error, + 'a11y/img-no-alt', + 'Image without alt attribute', + $target, + SmartySource::lineAt($html, (int)$match[0][1]), + 'Add alt text or alt="" for decorative images.' + ); + } + } + + /** @param Finding[] $findings */ + private function checkFormLabels(string $html, string $target, array &$findings): void + { + $pattern = '/<(input|select|textarea)\b([^>]*)>/i'; + if (!preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) { + return; + } + + foreach ($matches as $match) { + $tag = strtolower((string)$match[1][0]); + $attributes = (string)$match[2][0]; + $unlabelledType = '/type\s*=\s*["\'](hidden|submit|button|reset|image)["\']/i'; + if ('input' === $tag && preg_match($unlabelledType, $attributes)) { + continue; + } + + $hasId = (bool)preg_match('/\bid\s*=\s*["\'][^"\']+["\']/', $attributes); + $hasAriaLabel = (bool)preg_match('/aria-label\s*=\s*["\'][^"\']+["\']/', $attributes); + $hasAriaLabelledBy = (bool)preg_match('/aria-labelledby\s*=/', $attributes); + if ( + $hasId || $hasAriaLabel || $hasAriaLabelledBy || SmartySource::hasDynamicAttributes($attributes) + || $this->insideLabel($html, (int)$match[0][1]) + ) { + continue; + } + + $findings[] = new Finding( + Severity::Warning, + 'a11y/form-label', + "Form <{$tag}> without label: no id, aria-label, or aria-labelledby", + $target, + SmartySource::lineAt($html, (int)$match[0][1]), + 'Add a


-

Holmes is stand-alone diagnostic CSS stylesheet that can highlight potentially invalid, inaccessible or erroneous HTML(5) markup by adding one class.

-

holmes.css is useful for checking the quality of your code (up to W3C HTML5 standards), nitpicking over ensuring markup is valid and semantic and accessibility guidelines are met, and when you are tasked to fix up and debug an old, OLD website. - It has a simple implementation and a mostly unobtrusive effect on your page. Not recommended for live environments.

-

Remember too that these are just guidelines: if something is flagged but you can't change it for a good reason, don't worry about it :) Also use a validator if you want to be 100% sure.

-

Inspired by InuitCss, Eric Meyer, NealGrossKopf and procrastination from coursework!



-

+
+

cssHolmes is now a XOOPS design and diagnostics workbench, not just a wrapper around holmes.css.

+

It combines two complementary tools:

+
    +
  • a live in-browser overlay for inspection, measurement, quick local edits, and export
  • +
  • an admin-side workbench for theme scans, widget scans, import review, patch drafts, preview/apply, and rollback
  • +
+

It is especially useful for:

+
    +
  • creating new XTF themes
  • +
  • editing or repairing existing XTF themes
  • +
  • building and validating XMF widgets
  • +
  • debugging admin-theme UI, spacing, semantics, and accessibility
  • +
+

On plain XOOPS installs without XTF manifests, cssHolmes still works in a reduced generic XOOPS mode for HTML5, accessibility, layout, inspect, measure, copy, export, and widget HTML diagnostics.

+

INSTALL/UNINSTALL

@@ -21,20 +31,70 @@

OPERATING INSTRUCTIONS


-

- This module and its operations are very simple.

- Detailed instructions on configuring the access rights for user groups are available in the - Chapter 2.8 of our XOOPS Operations Manual

+
+

1. Enable the overlay

+

The overlay is opt-in and is activated by query string for groups with the Use Overlay permission (Webmasters by default). Typical examples:

+
    +
  • ?holmes=html5
  • +
  • ?holmes=xtf-theme,a11y,layout
  • +
  • ?holmes=xtf-theme,xtf-widget
  • +
  • ?holmes=all
  • +
+

Available profiles:

+
    +
  • html5
  • +
  • xtf-theme
  • +
  • xtf-widget
  • +
  • a11y
  • +
  • layout
  • +
+ +

2. Use the live toolbar

+

The toolbar supports the main day-to-day visual workflow:

+
    +
  • inspect and measure
  • +
  • token editing for token-aware color preview when an XTF theme.json manifest is available
  • +
  • typo editing for font size, line height, letter spacing, weight, and alignment
  • +
  • text for quick local text experiments
  • +
  • widget and slot jumps to the nearest wrapper
  • +
  • copy text, copy json, and export sel
  • +
  • undo for local changes
  • +
+

Keyboard shortcuts I, M, T, P and U work while the toolbar is open, never together with Ctrl, Cmd or Alt, and never while you type in a form field. Esc leaves inspect and measure mode; minimising the toolbar does the same.

+ +

3. Review results in the admin workbench

+

The admin side supports:

+
    +
  • theme scans for XTF themes with theme.json and for normal (generic) XOOPS themes; the same scan runs on the command line with bin/holmes-scan.php
  • +
  • widget HTML scans
  • +
  • importing toolbar JSON exports
  • +
  • reviewing imported changes and marking them accepted or rejected
  • +
  • file suggestions, patch drafts, preview/apply, and rollback
  • +
  • saved workbench history
  • +
+ +

4. Workflows where cssHolmes is most useful

+
    +
  • finding which token is driving a rendered color in a XTF theme
  • +
  • jumping from a nested DOM node to the real widget or slot wrapper
  • +
  • testing typography and spacing ideas before touching templates or CSS
  • +
  • reviewing widget HTML for accessibility and wrapper issues
  • +
  • turning designer experiments into structured developer handoff data
  • +
+ +

Detailed instructions on configuring the access rights for user groups are available in the + Chapter 2.8 of our XOOPS Operations Manual.

+

TUTORIAL


-

- Tutorial has been started, but we might need your help! Please check out the status of the tutorial here . -

To contribute to this Tutorial, please fork it on GitHub. -
This document describes our Documentation Process and it will help you to understand how to contribute. -

- There are more XOOPS Tutorials, so check them out in our XOOPS Tutorial Repository on GitBook. -

+
+

The current local tutorial for designers and developers is:

+

docs/TUTORIAL-cssholmes-XTF-XMF-v2.md

+

It focuses on XTF themes, XMF widgets, admin themes, inspection/export workflow, and the cases where cssHolmes becomes indispensable.

+

Additional strategic planning material is also available locally in:

+

docs/PROPOSAL-cssholmes-v2.md

+

TRANSLATIONS


@@ -47,11 +107,12 @@

DEVELOPMENT


- This module is Open Source and we would love your help in making it better! You can fork this module on GitHub

+ This module is Open Source and we would love your help in making it better. You can fork this module on GitHub

But there is more happening on GitHub:

- XOOPS Core
- XOOPS Modules
- XOOPS Themes

+ cssHolmes is particularly interested in contributions around XTF theme analysis, widget diagnostics, admin-theme coverage, documentation, and tests.

Go check it out, and GET INVOLVED

diff --git a/language/english/help/helpheader.tpl b/language/english/help/helpheader.tpl index 6aed9f8..0fd5fae 100644 --- a/language/english/help/helpheader.tpl +++ b/language/english/help/helpheader.tpl @@ -1,6 +1,6 @@

Help: <{$smarty.const._MI_CSSHOLMES_NAME}> - <{$smarty.const._MI_CSSHOLMES_BACK_2_ADMIN}> <{$smarty.const._MI_CSSHOLMES_NAME}> + <{$smarty.const._MI_CSSHOLMES_BACK_2_ADMIN}> <{$smarty.const._MI_CSSHOLMES_NAME}>

diff --git a/language/english/help/index.html b/language/english/help/index.html deleted file mode 100644 index 990cbd6..0000000 --- a/language/english/help/index.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/language/english/help/index.php b/language/english/help/index.php new file mode 100644 index 0000000..2ea9b7d --- /dev/null +++ b/language/english/help/index.php @@ -0,0 +1,3 @@ +history.go(-1); diff --git a/language/english/index.php b/language/english/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/language/english/index.php @@ -0,0 +1,3 @@ + + * @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 kris + * @author XOOPS Development Team, Mamba **/ + +// Guided theme designer. +define('_MD_CSSHOLMES_DESIGNER_TITLE', 'Theme designer'); +define('_MD_CSSHOLMES_DESIGNER_OPEN', 'Design theme'); +define('_MD_CSSHOLMES_DESIGNER_CLOSE', 'Close'); +define('_MD_CSSHOLMES_DESIGNER_THEME', 'Theme'); +define('_MD_CSSHOLMES_DESIGNER_PICK', 'Pick element'); +define('_MD_CSSHOLMES_DESIGNER_CANCEL_PICK', 'Cancel picking'); +define('_MD_CSSHOLMES_DESIGNER_PICK_HINT', 'Click an element, or focus it and press Enter. Escape cancels.'); +define('_MD_CSSHOLMES_DESIGNER_EMPTY', 'Pick an element to edit its theme styles.'); +define('_MD_CSSHOLMES_DESIGNER_SELECTOR', 'Selector'); +define('_MD_CSSHOLMES_DESIGNER_SELECTED', 'Selected element'); +define('_MD_CSSHOLMES_DESIGNER_MATCHES', '{count} matching elements'); +define('_MD_CSSHOLMES_DESIGNER_SCOPE_NOTE', 'Changes apply to every matching element in this theme.'); +define('_MD_CSSHOLMES_DESIGNER_INVALID_SELECTOR', 'Use up to four tag, class or ID selectors joined by spaces or >. Gallery controls cannot be selected.'); +define('_MD_CSSHOLMES_DESIGNER_NO_MATCHES', 'This selector has no editable matches on this page.'); +define('_MD_CSSHOLMES_DESIGNER_INVALID_VALUE', 'Enter a supported CSS value, or leave blank to remove the override.'); +define('_MD_CSSHOLMES_DESIGNER_INVALID_CONTROLS', 'Correct the highlighted value before continuing.'); +define('_MD_CSSHOLMES_DESIGNER_RULE_LIST', 'Saved and draft rules'); +define('_MD_CSSHOLMES_DESIGNER_CHOOSE_RULE', 'Choose a rule'); +define('_MD_CSSHOLMES_DESIGNER_MODE', 'Color mode'); +define('_MD_CSSHOLMES_DESIGNER_BREAKPOINT', 'Screen size'); +define('_MD_CSSHOLMES_DESIGNER_STATE', 'Element state'); +define('_MD_CSSHOLMES_DESIGNER_ALL', 'All'); +define('_MD_CSSHOLMES_DESIGNER_LIGHT', 'Light'); +define('_MD_CSSHOLMES_DESIGNER_DARK', 'Dark'); +define('_MD_CSSHOLMES_DESIGNER_MOBILE', 'Mobile (up to 575px)'); +define('_MD_CSSHOLMES_DESIGNER_TABLET', 'Tablet (576–991px)'); +define('_MD_CSSHOLMES_DESIGNER_DESKTOP', 'Desktop (992px and up)'); +define('_MD_CSSHOLMES_DESIGNER_NORMAL', 'Normal'); +define('_MD_CSSHOLMES_DESIGNER_HOVER', 'Hover'); +define('_MD_CSSHOLMES_DESIGNER_FOCUS', 'Keyboard focus'); +define('_MD_CSSHOLMES_DESIGNER_PREVIEW_NOTE', 'Preview follows the page color mode, current screen size and actual hover or focus state.'); +define('_MD_CSSHOLMES_DESIGNER_TYPOGRAPHY', 'Typography'); +define('_MD_CSSHOLMES_DESIGNER_COLORS', 'Colors'); +define('_MD_CSSHOLMES_DESIGNER_SPACING', 'Spacing'); +define('_MD_CSSHOLMES_DESIGNER_SIZE', 'Size'); +define('_MD_CSSHOLMES_DESIGNER_BORDER', 'Border'); +define('_MD_CSSHOLMES_DESIGNER_LAYOUT', 'Layout'); +define('_MD_CSSHOLMES_DESIGNER_COLOR', 'Text color'); +define('_MD_CSSHOLMES_DESIGNER_BACKGROUND_COLOR', 'Background color'); +define('_MD_CSSHOLMES_DESIGNER_BORDER_COLOR', 'Border color'); +define('_MD_CSSHOLMES_DESIGNER_FONT_SIZE', 'Font size'); +define('_MD_CSSHOLMES_DESIGNER_LINE_HEIGHT', 'Line height'); +define('_MD_CSSHOLMES_DESIGNER_LETTER_SPACING', 'Letter spacing'); +define('_MD_CSSHOLMES_DESIGNER_FONT_WEIGHT', 'Font weight'); +define('_MD_CSSHOLMES_DESIGNER_FONT_FAMILY', 'Font family'); +define('_MD_CSSHOLMES_DESIGNER_TEXT_ALIGN', 'Text alignment'); +define('_MD_CSSHOLMES_DESIGNER_TEXT_TRANSFORM', 'Text case'); +define('_MD_CSSHOLMES_DESIGNER_TEXT_DECORATION', 'Text decoration'); +define('_MD_CSSHOLMES_DESIGNER_FONT_STYLE', 'Font style'); +define('_MD_CSSHOLMES_DESIGNER_WIDTH', 'Width'); +define('_MD_CSSHOLMES_DESIGNER_HEIGHT', 'Height'); +define('_MD_CSSHOLMES_DESIGNER_MAX_WIDTH', 'Maximum width'); +define('_MD_CSSHOLMES_DESIGNER_MIN_WIDTH', 'Minimum width'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_TOP', 'Top margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_RIGHT', 'Right margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_BOTTOM', 'Bottom margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_LEFT', 'Left margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_INLINE_START', 'Start margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_INLINE_END', 'End margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_BLOCK_START', 'Block start margin'); +define('_MD_CSSHOLMES_DESIGNER_MARGIN_BLOCK_END', 'Block end margin'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_TOP', 'Top padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_RIGHT', 'Right padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_BOTTOM', 'Bottom padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_LEFT', 'Left padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_INLINE_START', 'Start padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_INLINE_END', 'End padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_BLOCK_START', 'Block start padding'); +define('_MD_CSSHOLMES_DESIGNER_PADDING_BLOCK_END', 'Block end padding'); +define('_MD_CSSHOLMES_DESIGNER_GAP', 'Gap'); +define('_MD_CSSHOLMES_DESIGNER_BORDER_WIDTH', 'Border width'); +define('_MD_CSSHOLMES_DESIGNER_BORDER_RADIUS', 'Corner radius'); +define('_MD_CSSHOLMES_DESIGNER_BORDER_STYLE', 'Border style'); +define('_MD_CSSHOLMES_DESIGNER_DISPLAY', 'Display'); +define('_MD_CSSHOLMES_DESIGNER_ALIGN_ITEMS', 'Align items'); +define('_MD_CSSHOLMES_DESIGNER_JUSTIFY_CONTENT', 'Justify content'); +define('_MD_CSSHOLMES_DESIGNER_DEFAULT_VALUE', 'Theme default'); +define('_MD_CSSHOLMES_DESIGNER_COMPUTED', 'Current: {value}'); +define('_MD_CSSHOLMES_DESIGNER_COLOR_PICKER', '{label} picker'); +define('_MD_CSSHOLMES_DESIGNER_UNDO', 'Undo'); +define('_MD_CSSHOLMES_DESIGNER_REDO', 'Redo'); +define('_MD_CSSHOLMES_DESIGNER_RESET', 'Reset selected'); +define('_MD_CSSHOLMES_DESIGNER_SAVE', 'Save to theme'); +define('_MD_CSSHOLMES_DESIGNER_DISCARD', 'Discard draft'); +define('_MD_CSSHOLMES_DESIGNER_RESTORE', 'Restore previous save'); +define('_MD_CSSHOLMES_DESIGNER_RESTORE_CONFIRM', 'Restore the previous saved theme configuration? This replaces the current saved styles and any draft.'); +define('_MD_CSSHOLMES_DESIGNER_MORE_ACTIONS', 'More actions'); +define('_MD_CSSHOLMES_DESIGNER_RELOAD', 'Reload saved settings'); +define('_MD_CSSHOLMES_DESIGNER_RELOAD_CONFIRM', 'Discard this draft and load the latest saved theme settings?'); +define('_MD_CSSHOLMES_DESIGNER_EXPORT_CSS', 'Export CSS'); +define('_MD_CSSHOLMES_DESIGNER_EXPORT_JSON', 'Export JSON'); +define('_MD_CSSHOLMES_DESIGNER_LOADING', 'Loading theme styles…'); +define('_MD_CSSHOLMES_DESIGNER_READY', 'Ready. Changes preview here until you save.'); +define('_MD_CSSHOLMES_DESIGNER_UNSAVED', 'Unsaved theme changes.'); +define('_MD_CSSHOLMES_DESIGNER_SAVED', 'Saved to theme.'); +define('_MD_CSSHOLMES_DESIGNER_DISCARDED', 'Draft discarded.'); +define('_MD_CSSHOLMES_DESIGNER_RESTORED', 'Previous save restored.'); +define('_MD_CSSHOLMES_DESIGNER_SAVING', 'Saving…'); +define('_MD_CSSHOLMES_DESIGNER_RESTORING', 'Restoring…'); +define('_MD_CSSHOLMES_DESIGNER_REQUEST_FAILED', 'Could not load or save theme styles. Your draft is retained.'); +define('_MD_CSSHOLMES_DESIGNER_PREVIEW_ONLY', 'Preview only. Saving requires workbench permission.'); +define('_MD_CSSHOLMES_DESIGNER_CHANGES', '{count} style rules'); +define('_MD_CSSHOLMES_DESIGNER_UNSUPPORTED', 'This browser cannot open the theme designer.'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_COMMENT_TITLES', 'Comment titles'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_COMMENT_BODY', 'Comment text'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_ARTICLE_TITLES', 'Article titles'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_ARTICLE_BODY', 'Article text'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_BUTTON', 'Buttons'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_INPUT', 'Form fields'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_LABEL', 'Form labels'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_TABLE', 'Tables'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_TABLE_HEADER', 'Table headings'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_MESSAGE', 'Messages'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_QUOTE', 'Quotations'); +define('_MD_CSSHOLMES_DESIGNER_TARGET_LINK', 'Links'); +define('_MD_CSSHOLMES_DESIGNER_LIBRARY', 'Library'); +define('_MD_CSSHOLMES_DESIGNER_STYLE_SETS', 'Style sets'); +define('_MD_CSSHOLMES_DESIGNER_CHOOSE_SET', 'Choose a set or palette'); +define('_MD_CSSHOLMES_DESIGNER_CURRENT_THEME_SETS', 'Current theme'); +define('_MD_CSSHOLMES_DESIGNER_OTHER_THEME_SETS', 'Other themes'); +define('_MD_CSSHOLMES_DESIGNER_STARTER_PALETTES', 'Starter palettes'); +define('_MD_CSSHOLMES_DESIGNER_SET_NAME', 'Set name'); +define('_MD_CSSHOLMES_DESIGNER_SAVE_NEW_SET', 'Save as new set'); +define('_MD_CSSHOLMES_DESIGNER_UPDATE_SET', 'Update selected set'); +define('_MD_CSSHOLMES_DESIGNER_PREVIEW_SET', 'Preview selected'); +define('_MD_CSSHOLMES_DESIGNER_LIKE', 'Like'); +define('_MD_CSSHOLMES_DESIGNER_UNLIKE', 'Unlike'); +define('_MD_CSSHOLMES_DESIGNER_SET_DETAILS', '{author} · {count} likes'); +define('_MD_CSSHOLMES_DESIGNER_LIBRARY_NOTE', 'Library saves do not change the theme. Preview first, then use Save to theme to publish.'); +define('_MD_CSSHOLMES_DESIGNER_SET_SAVED', 'Set saved in library. Theme unchanged.'); +define('_MD_CSSHOLMES_DESIGNER_SET_PREVIEWED', 'Set previewed. Use Save to theme to publish.'); +define('_MD_CSSHOLMES_DESIGNER_REPLACE_DRAFT_CONFIRM', 'Discard the current draft, including invalid values, and preview this selection?'); +define('_MD_CSSHOLMES_DESIGNER_TRANSFER_SETS', 'Import / export'); +define('_MD_CSSHOLMES_DESIGNER_IMPORT_FILE', 'Import set JSON'); +define('_MD_CSSHOLMES_DESIGNER_PREVIEW_IMPORT', 'Preview import'); +define('_MD_CSSHOLMES_DESIGNER_SAVE_IMPORT', 'Save imported set'); +define('_MD_CSSHOLMES_DESIGNER_EXPORT_PORTABLE', 'Export portable set'); +define('_MD_CSSHOLMES_DESIGNER_IMPORT_SIZE', 'Choose a JSON file no larger than 128 KiB.'); +define('_MD_CSSHOLMES_DESIGNER_IMPORT_INVALID', 'Choose a file containing valid JSON.'); +define('_MD_CSSHOLMES_DESIGNER_IMPORT_PREVIEWED', 'Import previewed. Save imported set adds it to the library only.'); +define('_MD_CSSHOLMES_DESIGNER_SET_NAME_REQUIRED', 'Enter a set name.'); +define('_MD_CSSHOLMES_DESIGNER_ORIGINAL_THEME', 'Original theme'); +define('_MD_CSSHOLMES_DESIGNER_COMPARE_ORIGINAL', 'Compare original'); +define('_MD_CSSHOLMES_DESIGNER_PREVIEW_ORIGINAL', 'Preview original defaults'); +define('_MD_CSSHOLMES_DESIGNER_RESET_ORIGINAL', 'Reset theme to original'); +define('_MD_CSSHOLMES_DESIGNER_RESET_ORIGINAL_CONFIRM', 'Remove all published designer overrides and discard this draft? This restores the untouched theme. Library sets are kept.'); +define('_MD_CSSHOLMES_DESIGNER_ORIGINAL_PREVIEWED', 'Original theme previewed. Use Save to theme to publish.'); +define('_MD_CSSHOLMES_DESIGNER_ORIGINAL_RESET', 'Theme reset to original. Library sets kept.'); +define('_MD_CSSHOLMES_DESIGNER_PALETTE_CONTRAST', '{name}: {ratio}:1 · {result}'); +define('_MD_CSSHOLMES_DESIGNER_CONTRAST_PASS', 'Pass'); +define('_MD_CSSHOLMES_DESIGNER_CONTRAST_FAIL', 'Fail'); + +// xStyles draft previews. +define('_MD_CSSHOLMES_DESIGNER_GALLERY_TITLE', 'Preview your changes'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_NOTE', 'Draft previews stay in this tab. Only Save to theme publishes changes for visitors.'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_VIEW', 'Preview'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_COMPARE', 'Published vs draft'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_RESPONSIVE', 'Draft at three screen sizes'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_WIDTH', 'Viewport width'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_REFRESH', 'Refresh previews'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_PUBLISHED', 'Published'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_DRAFT', 'Unsaved draft'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_READY', 'Preview ready'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_LOADING', 'Loading preview...'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_FAILED', 'Preview unavailable. Refresh to try again.'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_NO_TARGET', 'Choose an element with Pick element in the designer.'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_OPEN', 'Open theme designer'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_CURRENT', 'Current draft'); +define('_MD_CSSHOLMES_DESIGNER_GALLERY_WAIT', 'Open the designer to load your draft.'); diff --git a/language/english/modinfo.php b/language/english/modinfo.php index 16f3ff2..78e0b90 100644 --- a/language/english/modinfo.php +++ b/language/english/modinfo.php @@ -1,4 +1,4 @@ - + * @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 kris + * @author XOOPS Development Team, Mamba **/ // Module Info // The name of this module -define('_MI_CSSHOLMES_NAME', 'Css Holmes'); +define('_MI_CSSHOLMES_NAME', 'cssHolmes'); // A brief description of this module -define('_MI_CSSHOLMES_DESC', 'CssHolmes is stand-alone diagnostic CSS stylesheet that can highlight potentially invalid or erroneous HTML(5) markup by adding one class'); +define('_MI_CSSHOLMES_DESC', 'cssHolmes is an opt-in XTF theme and widget diagnostics module with profile-based frontend overlays'); // Admin menu links define('_MI_CSSHOLMES_MANAGER_INDEX', 'Index'); @@ -35,16 +34,21 @@ define('MI_CSSHOLMES_EDITOR_ADMIN_DESC', 'Select the Editor to use by the Admin'); define('MI_CSSHOLMES_EDITOR_USER', 'Editor: User'); define('MI_CSSHOLMES_EDITOR_USER_DESC', 'Select the Editor to use by the User'); +define('_MI_CSSHOLMES_QUERY_KEY', 'Overlay query key'); +define('_MI_CSSHOLMES_QUERY_KEY_DESC', 'Query-string key that activates the cssHolmes overlay, for example ?holmes=html5. It only works for groups with the Use Overlay permission (Webmasters by default).'); //Help -define('_MI_CSSHOLMES_DIRNAME', basename(dirname(dirname(__DIR__)))); +define('_MI_CSSHOLMES_DIRNAME', basename(dirname(__DIR__, 2))); define('_MI_CSSHOLMES_HELP_HEADER', __DIR__ . '/help/helpheader.tpl'); define('_MI_CSSHOLMES_BACK_2_ADMIN', 'Back to Administration of '); define('_MI_CSSHOLMES_OVERVIEW', 'Overview'); //define('_MI_CSSHOLMES_HELP_DIR', __DIR__); -//help multi-page +//help multipage define('_MI_CSSHOLMES_DISCLAIMER', 'Disclaimer'); define('_MI_CSSHOLMES_LICENSE', 'License'); define('_MI_CSSHOLMES_SUPPORT', 'Support'); + +define('_MI_CSSHOLMES_MANAGER_THEME_SETS', 'Theme sets'); +define('_MI_CSSHOLMES_MANAGER_THEME_SETS_DESC', 'Browse, preview, share and activate named theme sets'); diff --git a/language/french/admin.php b/language/french/admin.php index a233a54..08c43cc 100644 --- a/language/french/admin.php +++ b/language/french/admin.php @@ -1,4 +1,4 @@ - + * @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 kris **/ define('_AM_CSSHOLMES_MANAGER_INDEX', 'Index'); define('_AM_CSSHOLMES_MANAGER_ABOUT', 'A propos'); define('_AM_CSSHOLMES_MANAGER_PREFERENCES', 'XooFoo.org'); define('_AM_CSSHOLMES_MANAGER_UPDATE', 'Mise à jour'); -define('_AM_CSSHOLMES_MANAGER_FOOTER', "
Cssholmes est maintenu par Labs.XooFoo.org
"); +define('_AM_CSSHOLMES_MANAGER_FOOTER', "
Cssholmes est maintenu par Labs.XooFoo.org
"); // Index.php define( '_AM_CSSHOLMES_MANAGER_INDEX_TXT1', - "

Holmes est une feuille de style CSS autonome de diagnostic qui peut mettre en évidence des éléments HTML potentiellement incorrects, inaccessible ou erronée.

holmes.css est utile pour vérifier la qualité de votre code (aux standards W3C HTML5), il analyse par balayage le code qui est valide et sémantique et que les lignes directrices d'accessibilité soient remplies. Très utile quand vous êtes chargé de fixer et déboguer votre site. Il possède une implémentation simple et un effet généralement discret sur vos pages. Il n'est pas recommandé pour les environnements de production, bien que les messages ne soient accessibles qu'aux administrateurs.

Rappelez-vous aussi que ce ne sont que des lignes directrices: si quelque chose est indiqué, mais que vous ne pouvez pas le changer pour une bonne raison, ne vous inquiétez pas ! De plus, utilisez un validateur, si vous voulez être sûr à 100%.

Inspiré par InuitCss, Eric Meyer, NealGrossKopf et les altermoiements de travaux de cours!

" + "

Holmes est une feuille de style CSS autonome de diagnostic qui peut mettre en évidence des éléments HTML potentiellement incorrects, inaccessible ou erronée.

holmes.css est utile pour vérifier la qualité de votre code (aux standards W3C HTML5), il analyse par balayage le code qui est valide et sémantique et que les lignes directrices d'accessibilité soient remplies. Très utile quand vous êtes chargé de fixer et déboguer votre site. Il possède une implémentation simple et un effet généralement discret sur vos pages. Il n'est pas recommandé pour les environnements de production, bien que les messages ne soient accessibles qu'aux administrateurs.

Rappelez-vous aussi que ce ne sont que des lignes directrices: si quelque chose est indiqué, mais que vous ne pouvez pas le changer pour une bonne raison, ne vous inquiétez pas ! De plus, utilisez un validateur, si vous voulez être sûr à 100%.

Inspiré par InuitCss, Eric Meyer, NealGrossKopf et les altermoiements de travaux de cours!

" ); // About.php @@ -41,7 +39,6 @@ define('_AM_CSSHOLMES_ABOUT_MODULE_INFO', 'Information sur le module'); define('_AM_CSSHOLMES_ABOUT_AUTHOR_INFO', "Informations sur l'auteur"); - define('_AM_CSSHOLMES_UPGRADEFAILED0', "Update failed - couldn't rename field '%s'"); define('_AM_CSSHOLMES_UPGRADEFAILED1', "Update failed - couldn't add new fields"); define('_AM_CSSHOLMES_UPGRADEFAILED2', "Update failed - couldn't rename table '%s'"); @@ -57,3 +54,5 @@ define('_AM_CSSHOLMES_ERROR_BAD_REMOVE', 'Could not delete %s'); define('_AM_CSSHOLMES_ERROR_NO_PLUGIN', 'Could not load plugin'); +// Permissions +define('_AM_CSSHOLMES_PERM_USERS_NOTICE', "Les utilisateurs enregistrés peuvent encore utiliser l'overlay ou les diagnostics de cssHolmes. Les versions précédentes accordaient ce droit à l'installation ; depuis la 3.0.0 Alpha 2, une nouvelle installation réserve tous les outils aux webmestres et une mise à jour ne modifie pas les permissions existantes. Retirez les coches des utilisateurs enregistrés dans Permissions, sauf si vous voulez que les membres voient la barre d'outils."); diff --git a/language/french/help/help.tpl b/language/french/help/help.tpl index 2631328..84cd9e3 100644 --- a/language/french/help/help.tpl +++ b/language/french/help/help.tpl @@ -7,7 +7,7 @@

holmes.css is useful for checking the quality of your code (up to W3C HTML5 standards), nitpicking over ensuring markup is valid and semantic and accessibility guidelines are met, and when you are tasked to fix up and debug an old, OLD website. It has a simple implementation and a mostly unobtrusive effect on your page. Not recommended for live environments.

Remember too that these are just guidelines: if something is flagged but you can't change it for a good reason, don't worry about it :) Also use a validator if you want to be 100% sure.

-

Inspired by InuitCss, Eric Meyer, NealGrossKopf and procrastination from coursework!



+

Inspired by InuitCss, Eric Meyer, NealGrossKopf and procrastination from coursework!



INSTALL/UNINSTALL

diff --git a/language/french/help/helpheader.tpl b/language/french/help/helpheader.tpl index 6aed9f8..0fd5fae 100644 --- a/language/french/help/helpheader.tpl +++ b/language/french/help/helpheader.tpl @@ -1,6 +1,6 @@

Help: <{$smarty.const._MI_CSSHOLMES_NAME}> - <{$smarty.const._MI_CSSHOLMES_BACK_2_ADMIN}> <{$smarty.const._MI_CSSHOLMES_NAME}> + <{$smarty.const._MI_CSSHOLMES_BACK_2_ADMIN}> <{$smarty.const._MI_CSSHOLMES_NAME}>

diff --git a/language/french/help/index.html b/language/french/help/index.html deleted file mode 100644 index 990cbd6..0000000 --- a/language/french/help/index.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/language/french/help/index.php b/language/french/help/index.php new file mode 100644 index 0000000..2ea9b7d --- /dev/null +++ b/language/french/help/index.php @@ -0,0 +1,3 @@ +history.go(-1); diff --git a/language/french/index.php b/language/french/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/language/french/index.php @@ -0,0 +1,3 @@ + + * @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 kris **/ diff --git a/language/french/modinfo.php b/language/french/modinfo.php index 3d5e4ea..b3c6616 100644 --- a/language/french/modinfo.php +++ b/language/french/modinfo.php @@ -1,4 +1,4 @@ - + * @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 kris **/ // Module Info // The name of this module -define('_MI_CSSHOLMES_NAME', 'Css Holmes'); +define('_MI_CSSHOLMES_NAME', 'cssHolmes'); // A brief description of this module -define('_MI_CSSHOLMES_DESC', 'Css Holmes est une feuille de style CSS autonome de diagnostic qui peut mettre en évidence des élements du code HTML potentiellement incorrects ou erronés.'); +define('_MI_CSSHOLMES_DESC', "cssHolmes est un module de diagnostic XTF a activation explicite avec superpositions frontend par profil."); // Admin menu links define('_MI_CSSHOLMES_MANAGER_INDEX', 'Index'); @@ -35,16 +33,18 @@ define('MI_CSSHOLMES_EDITOR_ADMIN_DESC', 'Select the Editor to use by the Admin'); define('MI_CSSHOLMES_EDITOR_USER', 'Editor: User'); define('MI_CSSHOLMES_EDITOR_USER_DESC', 'Select the Editor to use by the User'); +define('_MI_CSSHOLMES_QUERY_KEY', "Cle de requete d'overlay"); +define('_MI_CSSHOLMES_QUERY_KEY_DESC', "Cle d'URL qui active l'overlay cssHolmes, par exemple ?holmes=html5. Elle ne fonctionne que pour les groupes ayant la permission Utiliser l'overlay (les webmestres par defaut)."); //Help -define('_MI_CSSHOLMES_DIRNAME', basename(dirname(dirname(__DIR__)))); +define('_MI_CSSHOLMES_DIRNAME', basename(dirname(__DIR__, 2))); define('_MI_CSSHOLMES_HELP_HEADER', __DIR__ . '/help/helpheader.tpl'); define('_MI_CSSHOLMES_BACK_2_ADMIN', 'Back to Administration of '); define('_MI_CSSHOLMES_OVERVIEW', 'Overview'); //define('_MI_CSSHOLMES_HELP_DIR', __DIR__); -//help multi-page +//help multipage define('_MI_CSSHOLMES_DISCLAIMER', 'Disclaimer'); define('_MI_CSSHOLMES_LICENSE', 'License'); define('_MI_CSSHOLMES_SUPPORT', 'Support'); diff --git a/language/index.html b/language/index.html deleted file mode 100644 index 2c5cdd3..0000000 --- a/language/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/language/index.php b/language/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/language/index.php @@ -0,0 +1,3 @@ + + + + + tests/Unit/class/Analyzer + tests/Unit/class/Cli + tests/Unit/class/Diagnostics + + + diff --git a/preloads/autoloader.php b/preloads/autoloader.php index b0c8e17..f8de47d 100644 --- a/preloads/autoloader.php +++ b/preloads/autoloader.php @@ -1,15 +1,15 @@ - + * @author kris + * @author XOOPS Development Team, Mamba **/ -class CssHolmesCorePreload extends \XoopsPreloadItem +class CssholmesCorePreload extends \XoopsPreloadItem { // to add PSR-4 autoloader - /** - * @param $args + * @param array $args */ - public static function eventCoreIncludeCommonEnd($args) + public static function eventCoreIncludeCommonEnd(array $args): void { require_once __DIR__ . '/autoloader.php'; } - public static function eventCoreHeaderAddmeta() + public static function eventCoreHeaderAddmeta(): void + { + self::injectAssets('site'); + } + + public static function eventSystemClassGuiHeader(mixed $args): void { + self::injectAssets('admin'); + } + + /** + * Check if the current user has a specific cssHolmes permission. + */ + private static function hasPermission(string $permName, int $moduleId): bool + { + global $xoopsUser; + + if (!is_object($xoopsUser)) { + return false; + } + + // Module admins always have all permissions + if ($xoopsUser->isAdmin($moduleId)) { + return true; + } + + /** @var \XoopsGroupPermHandler $grouppermHandler */ + $grouppermHandler = xoops_getHandler('groupperm'); + $userGroups = $xoopsUser->getGroups(); + + return $grouppermHandler->checkRight($permName, 1, $userGroups, $moduleId); + } + + private static function injectAssets(string $scope): void + { + if (defined('XSTYLES_PREVIEW_FRAME') && XSTYLES_PREVIEW_FRAME === true) { + return; + } global $xoTheme, $xoopsUser; - $xoopsModule = XoopsModule::getByDirname('cssholmes'); - // Add scripts and Css if only User is xoopsAdmin - if (!is_object($xoopsUser) || !is_object($xoopsModule) || !$xoopsUser->isAdmin($xoopsModule->mid())) { - $xoTheme->addStylesheet(XOOPS_URL . '/modules/cssholmes/assets/css/style.css'); - } else { - $xoTheme->addStylesheet(XOOPS_URL . '/modules/cssholmes/assets/css/holmes.css'); - $xoTheme->addScript(XOOPS_URL . '/modules/cssholmes/assets/js/holmes.js'); + + if (!is_object($xoTheme) || !is_object($xoopsUser)) { + return; } + + static $injectedScopes = []; + if (isset($injectedScopes[$scope])) { + return; + } + + $moduleDirName = basename(\dirname(__DIR__)); + $moduleId = null; + if (\function_exists('xoops_getHandler')) { + $moduleHandler = xoops_getHandler('module'); + if (is_object($moduleHandler) && method_exists($moduleHandler, 'getByDirname')) { + $xoopsModule = $moduleHandler->getByDirname($moduleDirName); + if (is_object($xoopsModule) && method_exists($xoopsModule, 'getVar')) { + $moduleId = (int)$xoopsModule->getVar('mid'); + } + } + } + + if ($moduleId === null) { + return; + } + + // Must have at least overlay permission to see anything + if (!self::hasPermission($moduleDirName . '_overlay', $moduleId)) { + return; + } + + // Build permission flags to pass to JS + $perms = [ + 'overlay' => true, + 'edit' => self::hasPermission($moduleDirName . '_edit', $moduleId), + 'diagnose' => self::hasPermission($moduleDirName . '_diagnose', $moduleId), + 'export' => self::hasPermission($moduleDirName . '_export', $moduleId), + 'workbench' => self::hasPermission($moduleDirName . '_workbench', $moduleId), + ]; + + $helper = \XoopsModules\Cssholmes\Helper::getInstance(); + $queryKey = (string)$helper->getConfig('holmes_query_key'); + if ('' === trim($queryKey)) { + $queryKey = 'holmes'; + } + + $moduleUrl = XOOPS_URL . '/modules/' . $moduleDirName; + $themeKey = ''; + $themeManifestPath = ''; + $themeManifestUrl = ''; + if ('admin' === $scope && isset($xoTheme->folderName) && is_string($xoTheme->folderName) && '' !== trim($xoTheme->folderName)) { + $themeKey = trim($xoTheme->folderName); + $themeManifestPath = XOOPS_ADMINTHEME_PATH . '/' . $themeKey . '/theme.json'; + $themeManifestUrl = XOOPS_ADMINTHEME_URL . '/' . $themeKey . '/theme.json'; + } elseif ('site' === $scope) { + $themeKey = trim((string)($GLOBALS['xoopsConfig']['theme_set'] ?? ($xoTheme->folderName ?? ''))); + if ('' !== $themeKey) { + $themeManifestPath = XOOPS_THEME_PATH . '/' . $themeKey . '/theme.json'; + $themeManifestUrl = XOOPS_THEME_URL . '/' . $themeKey . '/theme.json'; + } + } + $hasThemeManifest = '' !== $themeManifestPath && is_file($themeManifestPath); + if (!$hasThemeManifest) { + $themeManifestUrl = ''; + } + + $overlay = \XoopsModules\Cssholmes\Diagnostics\OverlayConfig::fromRequest( + \Xmf\Request::getString($queryKey, '', 'GET') + ); + $initialProfiles = $overlay->profiles(); + if (!$hasThemeManifest) { + $initialProfiles = array_values(array_filter( + $initialProfiles, + static fn (string $profile): bool => !in_array($profile, ['xtf-theme', 'xtf-widget'], true) + )); + } + + if ([] !== $initialProfiles) { + foreach ($initialProfiles as $profile) { + $stylesheetPath = 'assets/css/profiles/' . $profile . '.css'; + $xoTheme->addStylesheet($moduleUrl . '/' . $stylesheetPath); + } + } + + $permString = implode(',', array_keys(array_filter($perms))); + + $xoTheme->addScript( + $moduleUrl + . '/assets/js/holmes.js?queryKey=' . rawurlencode($queryKey) + . '&initialProfiles=' . rawurlencode(implode(',', $initialProfiles)) + . '&moduleUrl=' . rawurlencode($moduleUrl) + . '&scope=' . rawurlencode($scope) + . '&themeKey=' . rawurlencode($themeKey) + . '&themeManifestUrl=' . rawurlencode($themeManifestUrl) + . '&perms=' . rawurlencode($permString) + ); + + $injectedScopes[$scope] = true; } } diff --git a/preloads/designer.php b/preloads/designer.php new file mode 100644 index 0000000..2966892 --- /dev/null +++ b/preloads/designer.php @@ -0,0 +1,57 @@ +folderName; + try { + $store = cssholmes_designer_store(); + $state = $store->load($active); + $styleUrl = $moduleUrl . '/theme-style.php?' . http_build_query(['theme' => $active]); + if ($state['rules'] !== []) { + $theme->addStylesheet($styleUrl . '&v=' . rawurlencode($state['revision']), ['id' => 'cssholmes-theme-style']); + } + if (defined('XSTYLES_PREVIEW_FRAME') && XSTYLES_PREVIEW_FRAME === true) { + $theme->template->assign('cssholmes_designer_available', false); + return; + } + $access = cssholmes_designer_access(); + $theme->template->assign('cssholmes_designer_available', $access['edit']); + if (!$access['edit']) { + return; + } + $config = [ + 'theme' => $active, 'endpoint' => $moduleUrl . '/designer.php', + 'styleUrl' => $styleUrl, 'stylesheetUrl' => $moduleUrl . '/assets/css/designer.css', + 'token' => '', 'canSave' => $access['save'], 'canExport' => $access['export'], 'labels' => cssholmes_designer_labels(), + 'autoOpen' => \Xmf\Request::getString('designer', '0', 'GET') === '1', + 'properties' => $store::properties(), + 'libraryEndpoint' => $moduleUrl . '/sets.php', + 'initialSetId' => \Xmf\Request::getString('theme_set', '', 'GET'), + ]; + // addScript wraps inline content in JavaScript comments, which are invalid JSON. + $theme->htmlHeadStrings[] = ''; + $theme->addScript($moduleUrl . '/assets/js/designer-diagnostics.js', ['defer' => 'defer']); + $theme->addScript($moduleUrl . '/assets/js/designer.js', ['defer' => 'defer']); + $theme->addScript($moduleUrl . '/assets/js/gallery-designer.js', ['defer' => 'defer']); + $theme->addStylesheet($moduleUrl . '/assets/css/gallery-designer.css', ['id' => 'cssholmes-gallery-style']); + } catch (\Throwable $error) { + // A bad configuration must not take the public site offline. + $GLOBALS['xoopsLogger']->addExtra('Theme Designer', 'Saved theme configuration could not be loaded.'); + } + } +} diff --git a/preloads/index.html b/preloads/index.html index 2c5cdd3..990cbd6 100644 --- a/preloads/index.html +++ b/preloads/index.html @@ -1 +1 @@ - + \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..ad7484c --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,16 @@ +version: '1.0' +image: jetbrains/qodana-php:2026.2 +profile: + name: qodana.recommended +php: + version: '8.4' +exclude: + - name: All + paths: + - vendor + - node_modules + - .build + - .qodana + - coverage + - templates_c + - uploads diff --git a/sets.php b/sets.php new file mode 100644 index 0000000..f75caa8 --- /dev/null +++ b/sets.php @@ -0,0 +1,226 @@ +activated = false; + $GLOBALS['xoopsLogger']->renderingEnabled = false; +} +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: no-store'); +header('X-Content-Type-Options: nosniff'); +$reply = static function (int $status, array $data): never { + http_response_code($status); + echo json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); + exit; +}; +$access = cssholmes_designer_access(); +if (!$access['edit']) { + $signedIn = is_object($GLOBALS['xoopsUser'] ?? null); + $reply(403, [ + 'ok' => false, 'code' => $signedIn ? 'permission_denied' : 'auth_required', + 'error' => $signedIn ? 'Theme designer access is required.' : 'Sign in again in another tab, then retry this action. Your draft is kept.', + ]); +} +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +if (!in_array($method, ['GET', 'POST'], true)) { + header('Allow: GET, POST'); + $reply(405, ['ok' => false, 'error' => 'Use GET or POST.']); +} + +try { + $actor = ['id' => (int) $GLOBALS['xoopsUser']->getVar('uid'), 'name' => (string) $GLOBALS['xoopsUser']->getVar('uname', 'n')]; + if ($actor['id'] < 1) { + $reply(403, ['ok' => false, 'error' => 'A registered designer account is required.']); + } + $module = xoops_getHandler('module')->getByDirname('cssholmes'); + $admin = $GLOBALS['xoopsUser']->isAdmin((int) $module->getVar('mid')); + $store = cssholmes_designer_store(); + $format = new SetFormat($store); + $library = new SetLibrary(XOOPS_VAR_PATH . '/configs/cssholmes/designer'); + $palettes = $format->builtins(); + $extra = []; + $data = []; + if ($method === 'POST') { + if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 131072) { + $reply(413, ['ok' => false, 'error' => 'Configuration sets must be smaller than 128 KiB.']); + } + $input = fopen('php://input', 'rb'); + $raw = $input === false ? false : stream_get_contents($input, 131073); + if (is_resource($input)) { + fclose($input); + } + if ($raw === false || strlen($raw) > 131072) { + $reply(413, ['ok' => false, 'error' => 'Configuration sets must be smaller than 128 KiB.']); + } + $data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($data) || array_is_list($data)) { + throw new InvalidArgumentException('A JSON object is required.'); + } + $token = $data['token'] ?? null; + if (!is_string($token) || !$GLOBALS['xoopsSecurity']->check(false, $token, 'CSSHOLMES_DESIGNER')) { + $reply(403, [ + 'ok' => false, 'code' => 'csrf_expired', + 'error' => 'Your editing token was renewed. Try the action again. Your draft is kept.', + 'token' => $GLOBALS['xoopsSecurity']->createToken(1800, 'CSSHOLMES_DESIGNER'), + ]); + } + } + $theme = $method === 'GET' ? \Xmf\Request::getString('theme', (string) ($GLOBALS['xoopsConfig']['theme_set'] ?? 'default'), 'GET') : ($data['theme'] ?? null); + if (!is_string($theme)) { + throw new InvalidArgumentException('A theme is required.'); + } + $state = $store->load($theme); + $resolve = static function (string $id) use ($library, $palettes, $format, $theme): array { + if ($id === 'original') { + return ['rules' => [], 'warnings' => [], 'document' => $format->fromRules($theme, 'Original defaults', [])]; + } + foreach ($palettes as $palette) { + if ($palette['id'] === $id) { + return $format->resolve($palette['payload'], $theme) + ['document' => $palette['payload']]; + } + } + $record = $library->get($id); + return $format->resolve($record['payload'], $theme) + ['document' => $record['payload'], + 'set' => ['id' => $record['id'], 'revision' => $record['revision']]]; + }; + if ($method === 'GET') { + $action = \Xmf\Request::getString('action', 'list', 'GET'); + if ($action === 'export' && !$access['export']) { + $reply(403, ['ok' => false, 'error' => 'Export permission is required.']); + } + if (in_array($action, ['preview', 'export'], true)) { + $preview = $resolve(\Xmf\Request::getString('id', '', 'GET')); + $extra[$action === 'export' ? 'exported' : 'preview'] = $action === 'export' ? $preview['document'] : $preview; + } elseif ($action !== 'list') { + throw new InvalidArgumentException('Unknown library action.'); + } + } else { + $operation = $data['op'] ?? null; + if (!is_string($operation)) { + throw new InvalidArgumentException('An operation is required.'); + } + if ($operation === 'exportCurrent' && !$access['export']) { + $reply(403, ['ok' => false, 'error' => 'Export permission is required.']); + } + if (!$access['save'] && !in_array($operation, ['like', 'exportCurrent', 'previewImport'], true)) { + $reply(403, ['ok' => false, 'error' => 'Workbench permission is required to change saved sets or the active theme.']); + } + $string = static function (string $key, string $default = '') use ($data): string { + $value = $data[$key] ?? $default; + if (!is_string($value)) { + throw new InvalidArgumentException('Invalid ' . $key . '.'); + } + return $value; + }; + $document = static function () use ($data, $format): array { + if (!is_array($data['document'] ?? null)) { + throw new InvalidArgumentException('A configuration document is required.'); + } + $document = $data['document']; + if (array_diff(array_keys($document), ['theme', 'rules']) === [] + && is_string($document['theme'] ?? null) && is_array($document['rules'] ?? null)) { + return $format->fromRules($document['theme'], 'Imported theme set', $document['rules']); + } + return $format->normalize($document); + }; + if (in_array($operation, ['saveSet', 'updateSet', 'exportCurrent'], true)) { + if (!is_array($data['rules'] ?? null)) { + throw new InvalidArgumentException('A rules list is required.'); + } + $name = $string('name', 'My theme set'); + if ($operation === 'exportCurrent' && trim($name) === '') { + $name = 'Theme configuration'; + } + $payload = $format->fromRules($theme, $name, $data['rules']); + if ($operation === 'exportCurrent') { + $extra['document'] = $payload; + } elseif ($operation === 'saveSet') { + $extra['set'] = $library->create($theme, $payload['name'], $payload, $actor); + } else { + $existing = $library->get($string('id')); + if (!$admin && $existing['author']['id'] !== $actor['id']) { + $reply(403, ['ok' => false, 'error' => 'Only the author or a module administrator can update this set.']); + } + if ($existing['theme'] !== $theme) { + throw new InvalidArgumentException('Save a new set to keep an adapted copy in another theme.'); + } + $extra['set'] = $library->update($existing['id'], $payload['name'], $payload, $actor, $admin, $string('setRevision')); + } + } elseif ($operation === 'importSet' || $operation === 'previewImport') { + $payload = $document(); + if (isset($data['name']) && $string('name') !== '') { + $payload['name'] = $string('name'); + $payload = $format->normalize($payload); + } + $extra['preview'] = $format->resolve($payload, $theme) + ['document' => $payload]; + if ($operation === 'importSet') { + $extra['set'] = $library->create($theme, $payload['name'], $payload, $actor); + } + } elseif ($operation === 'cloneSet') { + $payload = $resolve($string('id'))['document']; + $payload['name'] = $string('name'); + $payload = $format->normalize($payload); + $extra['set'] = $library->create($theme, $payload['name'], $payload, $actor); + } elseif ($operation === 'like') { + if (!is_bool($data['liked'] ?? null)) { + throw new InvalidArgumentException('The liked value must be true or false.'); + } + $extra['set'] = $library->like($string('id'), $actor['id'], $data['liked']); + } elseif ($operation === 'deleteSet') { + $existing = $library->get($string('id')); + if (!$admin && $existing['author']['id'] !== $actor['id']) { + $reply(403, ['ok' => false, 'error' => 'Only the author or a module administrator can delete this set.']); + } + $library->delete($existing['id'], $actor, $admin, $string('setRevision')); + } elseif ($operation === 'activate' || $operation === 'reset') { + $rules = $operation === 'reset' ? [] : $resolve($string('id'))['rules']; + $state = $store->save($theme, $rules, $string('revision')); + } else { + throw new InvalidArgumentException('Unknown library operation.'); + } + } + $summary = static function (array $record) use ($actor, $admin): array { + $record['likes'] = count($record['likedBy']); + $record['liked'] = in_array($actor['id'], $record['likedBy'], true); + $record['editable'] = $admin || $record['author']['id'] === $actor['id']; + unset($record['payload'], $record['likedBy']); + return $record; + }; + if (isset($extra['set'])) { + $extra['set'] = $summary($extra['set']); + } + $themes = []; + foreach (new DirectoryIterator(XOOPS_ROOT_PATH . '/themes') as $folder) { + if (!$folder->isDot() && $folder->isDir() && !$folder->isLink() + && preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,79}$/D', $folder->getFilename()) + && (is_file($folder->getPathname() . '/theme.tpl') || is_file($folder->getPathname() . '/theme.html'))) { + $themes[] = $folder->getFilename(); + } + } + natcasesort($themes); + $reply(200, $extra + [ + 'ok' => true, 'state' => $state, 'canSave' => $access['save'], 'canExport' => $access['export'], 'isAdmin' => $admin, + 'token' => $GLOBALS['xoopsSecurity']->createToken(1800, 'CSSHOLMES_DESIGNER'), + 'library' => [ + 'themes' => array_values($themes), 'sets' => array_map($summary, $library->all()), + 'palettes' => array_map(static function (array $palette): array { + unset($palette['payload']); + return $palette; + }, $palettes), + ], + ]); +} catch (ConflictException $error) { + $reply(409, ['ok' => false, 'error' => 'These settings changed elsewhere. Reload the library before saving again.']); +} catch (InvalidArgumentException | JsonException $error) { + $reply(422, ['ok' => false, 'error' => $error instanceof JsonException ? 'Invalid JSON configuration.' : $error->getMessage()]); +} catch (Throwable $error) { + $reply(500, ['ok' => false, 'error' => 'The theme-set library could not be read or saved. Check its private storage permissions.']); +} diff --git a/templates/admin/index.html b/templates/admin/index.html deleted file mode 100644 index 2c5cdd3..0000000 --- a/templates/admin/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/admin/index.php b/templates/admin/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/templates/admin/index.php @@ -0,0 +1,3 @@ +*}> +
+
+

cssHolmes Test Suites

+

+ cssHolmes now has two complementary reference pages: the legacy HTML5 markup suite and a modern XTF/XMF theme and widget suite. + Use the first for baseline markup diagnostics and the second for XTF tokens, slots, widgets, admin-style cards, and export-oriented inspection work. +

+

+ On plain XOOPS installs without XTF manifests, cssHolmes still runs in a reduced generic mode for inspect, measure, HTML5, accessibility, layout, and widget HTML diagnostics. +

+
-<{include file="db:cssholmes_testsuite.tpl"}> +
+
+

Legacy HTML5 Suite

+

+ Original markup-oriented checks for links, forms, deprecated elements, and baseline HTML smells. +

+

+ Open HTML5 Suite +

+
-<{*<{include file="db:cssholmes_footer.tpl"}>*}> +
+

Modern XTF/XMF Suite

+

+ Practical samples for tokens, slots, widgets, admin-style cards, accessibility hints, and export-friendly inspection. +

+

+ Open XTF/XMF Suite +

+
+ +
+

Admin Workbench

+

+ Theme scans, widget scans, import review, patch drafts, preview/apply/rollback flow, and workbench history. +

+

+ Open Workbench +

+
+
+
diff --git a/templates/cssholmes_testsuite.tpl b/templates/cssholmes_testsuite.tpl index 7d7c852..588f15a 100644 --- a/templates/cssholmes_testsuite.tpl +++ b/templates/cssholmes_testsuite.tpl @@ -1,14 +1,14 @@ - holmes.css - Test Suite (v 1.0) - - + cssHolmes - HTML5 Profile Test Suite + + - +
@@ -16,15 +16,15 @@
  • Normal Link

    - redroot + redroot
  • Link with no title

    - redroot + redroot
  • Link with an empty title

    - redroot + redroot
  • Link with no href

    @@ -49,15 +49,15 @@
    • Normal Image

      - Alternative Text + Alternative Text
    • Image with no alt

      - +
    • Image with an empty alt

      - +
@@ -123,15 +123,15 @@
  • Normal Input

    - +
  • Input with no name

    - +
  • Input with empty name

    - +
  • Normal Select

    diff --git a/templates/cssholmes_xtf_testsuite.tpl b/templates/cssholmes_xtf_testsuite.tpl new file mode 100644 index 0000000..d610f72 --- /dev/null +++ b/templates/cssholmes_xtf_testsuite.tpl @@ -0,0 +1,551 @@ + + cssHolmes - XTF/XMF Test Suite + + + + + + + + +
    +
    +

    cssHolmes Modern Test Suite

    +

    XTF Themes, XMF Widgets, Slots, Tokens, and Admin-Like UI

    +

    + Use this page with the cssHolmes toolbar to exercise token matching, slot and widget jumps, typography edits, + layout checks, widget diagnostics, accessibility hints, and export-ready inspection context. +

    +
    + +
    +
    +

    Suggested Flow

    +
      +
    1. Inspect a card or widget root.
    2. +
    3. Jump to the nearest widget or slot wrapper.
    4. +
    5. Try token or typography changes locally.
    6. +
    7. Export the useful result to the workbench.
    8. +
    +
    +
    +

    Toolbar Targets

    +

    Cards below include token-backed accents, nested widgets, deliberate spacing differences, and common admin-style UI elements.

    +
    +
    + Expected Behavior +

    Most cards are intended to be clean for `xtf-theme`. The `Known Problems` cards and `Problem Form` intentionally trigger warnings.

    +
    +
    + +
    +
    +
    +
    +
    +

    Feature Card

    +

    Token-aware theme surface

    +

    This widget uses token-like values, content spacing, and nested text so you can test inspect, token matching, typo edits, and export flow in one place.

    +
    + +
    +
      +
    • Stable wrapper class for widget selection
    • +
    • Nested text for text replacement preview
    • +
    • Visible accent color for token inspection
    • +
    +
    + +
    +
    +

    Stats

    +

    128

    +

    Published widget instances

    +
    +
    +

    Warning Card

    +

    Useful for checking contrast, spacing, and message hierarchy.

    +
    +
    + +
    +

    Admin-like Module List

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ModuleStatusOwner
    MenusHealthyCore
    BlocksAdminReviewAdmin UI
    WidgetsNewXMF
    +
    + +
    +
    +

    Known Problems

    +

    Accessibility and Content Smells

    + + + Ambiguous link +

    This card intentionally mixes an empty button, missing image alt, and placeholder link target.

    +
    + +
    +

    Known Problems

    +

    Layout and Widget Edge Cases

    +
    + + + + SVG without width/height attributes +
    +
    +
    +
    +
    Deep wrapper nesting for layout inspection
    +
    +
    +
    +

    Useful for widget SVG checks, wrapper depth checks, and spacing inspection.

    +
    +
    +
    + + +
    +
    + diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 2c5cdd3..0000000 --- a/templates/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/templates/index.php b/templates/index.php new file mode 100644 index 0000000..b295ab9 --- /dev/null +++ b/templates/index.php @@ -0,0 +1,3 @@ +" alt="<{$block.title}>">'); + + $this->assertSame('SMARTY', $html); + $this->assertSame(1, preg_match('/]*)>/', $html, $match)); + $this->assertStringContainsString('alt=', $match[1]); + } + + public function testCommentsAreRemovedAndLineBreaksKept(): void + { + $source = "a<{* one\n two *}>b\nc"; + + $this->assertSame("a\nb\nc", SmartySource::stripComments($source)); + $this->assertSame("a\nb\nc", SmartySource::neutralise($source)); + } + + public function testControlTagsBecomeSpacesAndOutputTagsPlaceholders(): void + { + $source = ' class="on"<{/if}>><{$smarty.const.LABEL}>'; + + $this->assertSame('SMARTY', SmartySource::neutralise($source)); + } + + public function testQuotedStringsMayContainTheClosingDelimiter(): void + { + $source = '

    b"}>">t

    '; + + $this->assertSame('

    t

    ', SmartySource::neutralise($source)); + } + + public function testLiteralBlocksKeepTheirContent(): void + { + $source = '<{literal}><{/literal}><{$x}>'; + + $this->assertSame('SMARTY', SmartySource::neutralise($source)); + } + + public function testHtmlViewBlanksScriptsStylesAndComments(): void + { + $source = ""; + + $this->assertSame("", SmartySource::htmlView($source)); + } + + public function testDynamicAttributesAreDetectedOnlyOutsideQuotes(): void + { + $this->assertTrue(SmartySource::hasDynamicAttributes(' src="x" SMARTY')); + $this->assertFalse(SmartySource::hasDynamicAttributes(' src="SMARTY" alt="SMARTY"')); + } + + public function testLineNumbersSurviveNeutralising(): void + { + $source = "<{if \$a\n && \$b}>\n"; + $view = SmartySource::htmlView($source); + $offset = (int)strpos($view, 'assertSame(3, SmartySource::lineAt($view, $offset)); + } +} diff --git a/tests/Unit/class/Analyzer/TemplateIncludeResolverTest.php b/tests/Unit/class/Analyzer/TemplateIncludeResolverTest.php new file mode 100644 index 0000000..93fa6bf --- /dev/null +++ b/tests/Unit/class/Analyzer/TemplateIncludeResolverTest.php @@ -0,0 +1,91 @@ +resolve('"$theme_name/tpl/header.tpl"'); + + $this->assertSame(TemplateIncludeResolver::STATUS_THEME, $result['status']); + $this->assertSame('tpl/header.tpl', $result['path']); + $this->assertTrue($result['exists']); + } + + public function testCustomVariableAssignedFromFolderNameIsLearnt(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/smartytheme'); + + $this->assertContains('tname', $resolver->themeVariables()); + $this->assertSame('tpl/header.tpl', $resolver->resolve('"$tname/tpl/header.tpl"')['path']); + $this->assertSame('tpl/header.tpl', $resolver->resolve('"{$tname}/tpl/header.tpl"')['path']); + } + + public function testFolderNamePatternsAreResolved(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/cleantheme'); + + $arguments = [ + '"`$xoTheme->folderName`/tpl/header.tpl"', + '"$xoTheme->folderName/tpl/header.tpl"', + "\$xoTheme->folderName|cat:'/tpl/header.tpl'", + "\$theme_name|cat:'/tpl/'|cat:'header.tpl'", + ]; + foreach ($arguments as $argument) { + $result = $resolver->resolve($argument); + $this->assertSame('tpl/header.tpl', $result['path'], $argument); + $this->assertTrue($result['exists'], $argument); + } + } + + public function testPlainRelativeNamesResolveInsideTheTheme(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/cleantheme'); + + $this->assertTrue($resolver->resolve('"tpl/header.tpl"')['exists']); + $this->assertTrue($resolver->resolve('"header.tpl"', 'tpl/other.tpl')['exists']); + $this->assertSame('cleantheme/tpl/header.tpl', $resolver->themeName() . '/' . $resolver->resolve("'cleantheme/tpl/header.tpl'")['path']); + } + + public function testModuleAndDynamicIncludesAreNeverMissing(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/cleantheme'); + + $this->assertSame(TemplateIncludeResolver::STATUS_MODULE, $resolver->resolve("'db:system_block_user.tpl'")['status']); + $this->assertSame(TemplateIncludeResolver::STATUS_DYNAMIC, $resolver->resolve('"db:system_block_$block.template"')['status']); + $this->assertSame(TemplateIncludeResolver::STATUS_DYNAMIC, $resolver->resolve('$block.template')['status']); + $this->assertSame(TemplateIncludeResolver::STATUS_EXTERNAL, $resolver->resolve('"file:/var/www/x.tpl"')['status']); + } + + public function testIncludesInReportsLineAndStatus(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/smartytheme'); + $source = (string)file_get_contents(self::FIXTURES . '/smartytheme/theme.tpl'); + $includes = $resolver->includesIn($source, 'theme.tpl'); + + $this->assertCount(3, $includes); + $this->assertSame([8, true], [$includes[0]['line'], $includes[0]['exists']]); + $this->assertSame([9, false, 'tpl/missing.tpl'], [$includes[1]['line'], $includes[1]['exists'], $includes[1]['path']]); + $this->assertSame(TemplateIncludeResolver::STATUS_MODULE, $includes[2]['status']); + } + + public function testExpandedPageInlinesThePartials(): void + { + $resolver = new TemplateIncludeResolver(self::FIXTURES . '/cleantheme'); + $page = $resolver->expandedPage(); + + $this->assertStringContainsString('