feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support

Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -2,7 +2,7 @@
* Cascading multi-select — filters child options based on parent selection.
*/
import { getMultiSelectInstance } from './app-multiselect-init.js';
import { warnOnce } from '../core/app-dom.js';
import { warnOnce, resolveHost } from '../core/app-dom.js';
const parseValueList = (value) =>
String(value || '')
@@ -15,19 +15,6 @@ const setDisabledState = (option, disabled) => {
option.classList.toggle('is-disabled', disabled);
option.setAttribute('aria-disabled', disabled ? 'true' : 'false');
option.style.pointerEvents = disabled ? 'none' : '';
if (!option._cascadeGuard) {
option.addEventListener(
'click',
(event) => {
if (option.dataset.disabled === '1') {
event.preventDefault();
event.stopImmediatePropagation();
}
},
true
);
option._cascadeGuard = true;
}
};
const toggleSelectAll = (instance, disabled) => {
@@ -44,9 +31,11 @@ export const initMultiSelectCascade = ({
map = {},
mode = 'disable',
clearInvalid = true,
}) => {
const parentInput = typeof parent === 'string' ? document.querySelector(parent) : parent;
const childInput = typeof child === 'string' ? document.querySelector(child) : child;
root = document,
} = {}) => {
const host = resolveHost(root);
const parentInput = typeof parent === 'string' ? host.querySelector(parent) : parent;
const childInput = typeof child === 'string' ? host.querySelector(child) : child;
if (!parentInput || !childInput) {
if (!parentInput) {
warnOnce('UI_EL_MISSING', `Missing multiselect parent: ${parent}`, { module: 'multiselect-cascade' });
@@ -54,13 +43,13 @@ export const initMultiSelectCascade = ({
if (!childInput) {
warnOnce('UI_EL_MISSING', `Missing multiselect child: ${child}`, { module: 'multiselect-cascade' });
}
return;
return { destroy: () => {} };
}
const childInstance = getMultiSelectInstance(childInput);
const childInstance = getMultiSelectInstance(childInput, host);
if (!childInstance) {
warnOnce('UI_INIT_MISSING', 'Missing multiselect instance for child', { module: 'multiselect-cascade' });
return;
return { destroy: () => {} };
}
const apply = () => {
@@ -98,4 +87,13 @@ export const initMultiSelectCascade = ({
parentInput.addEventListener('change', apply);
apply();
return {
destroy: () => {
parentInput.removeEventListener('change', apply);
const options = childInstance.element.querySelectorAll('.multi-select-option');
options.forEach((option) => setDisabledState(option, false));
toggleSelectAll(childInstance, false);
},
};
};