feat: consolidate testdrive-jsui to capabilities and implement plugin self-declaration

## Major Changes
- Moved all testdrive-jsui assets from root to capabilities/testdrive-jsui/
- Consolidated directory structure: js/, static/css/, static/images/, static/templates/
- Implemented plugin self-declaration (get_plugin_source_dir, get_asset_paths)
- Removed hardcoded plugin discovery from rendering.py
- Updated all asset paths to be relative to capability root

## Architecture Improvements
- Single source of truth for all testdrive-jsui assets
- Plugin declares its own location (no hardcoded paths)
- Generic plugin discovery using hasattr check
- Clean separation: all JS in .js files, no code mixing
- Standalone capability ready for independent use

## Files Changed
- markitect/plugins/testdrive_jsui.py: Added self-declaration methods
- markitect/plugins/rendering.py: Removed hardcoded discovery
- capabilities/testdrive-jsui/README.md: Added standalone usage documentation
- Moved 17 asset files to consolidated structure
- Deleted obsolete /testdrive-jsui/ root directory

## Testing
- All 17 assets verified and working
- Tested via CLI: markitect md-render --engine testdrive-jsui
- Full document rendering successful

Prepares testdrive-jsui to become a git submodule with proper dependency management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-15 23:42:54 +01:00
parent d0a1c91b8e
commit ab3f0db86f
41 changed files with 223 additions and 6073 deletions

View File

@@ -0,0 +1,215 @@
/**
* UI Widget Base Class
*
* Extends Widget with DOM manipulation and visual functionality.
* Base for all widgets that render UI elements.
*/
import { Widget } from './Widget.js';
export class UIWidget extends Widget {
constructor(options = {}) {
super(options);
// UI properties
this.element = null;
this.isVisible = false;
this.isRendered = false;
this.theme = options.theme || 'default';
this.cssClasses = new Set(['markitect-widget']);
// Animation support
this.animationDuration = options.animationDuration || 300;
this.enableAnimations = options.enableAnimations !== false;
}
/**
* Render the widget to DOM (abstract method)
*/
async render() {
throw new Error('render() method must be implemented by subclass');
}
/**
* Show the widget
*/
async show(options = {}) {
if (!this.isRendered) {
await this.render();
}
if (this.isVisible) {
return this;
}
this.isVisible = true;
if (this.element) {
if (this.enableAnimations && !options.immediate) {
await this.animateShow();
} else {
this.element.style.display = '';
}
}
this.emit('shown');
return this;
}
/**
* Hide the widget
*/
async hide(options = {}) {
if (!this.isVisible) {
return this;
}
this.isVisible = false;
if (this.element) {
if (this.enableAnimations && !options.immediate) {
await this.animateHide();
} else {
this.element.style.display = 'none';
}
}
this.emit('hidden');
return this;
}
/**
* Toggle visibility
*/
async toggle(options = {}) {
return this.isVisible ? this.hide(options) : this.show(options);
}
/**
* Show animation (override for custom animations)
*/
async animateShow() {
if (!this.element) return;
return new Promise(resolve => {
this.element.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
this.element.style.opacity = '0';
this.element.style.display = '';
// Force reflow
this.element.offsetHeight;
this.element.style.opacity = '1';
setTimeout(() => {
this.element.style.transition = '';
resolve();
}, this.animationDuration);
});
}
/**
* Hide animation (override for custom animations)
*/
async animateHide() {
if (!this.element) return;
return new Promise(resolve => {
this.element.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
this.element.style.opacity = '0';
setTimeout(() => {
this.element.style.display = 'none';
this.element.style.transition = '';
this.element.style.opacity = '';
resolve();
}, this.animationDuration);
});
}
/**
* CSS class management
*/
addClass(className) {
this.cssClasses.add(className);
if (this.element) {
this.element.classList.add(className);
}
return this;
}
removeClass(className) {
this.cssClasses.delete(className);
if (this.element) {
this.element.classList.remove(className);
}
return this;
}
hasClass(className) {
return this.cssClasses.has(className);
}
/**
* Apply theme styling
*/
applyTheme(themeName) {
const oldTheme = this.theme;
this.theme = themeName;
this.removeClass(`theme-${oldTheme}`);
this.addClass(`theme-${themeName}`);
this.emit('theme-changed', { oldTheme, newTheme: themeName });
return this;
}
/**
* Find child element by selector
*/
findElement(selector) {
return this.element ? this.element.querySelector(selector) : null;
}
/**
* Find all child elements by selector
*/
findElements(selector) {
return this.element ? this.element.querySelectorAll(selector) : [];
}
/**
* Override destroy to clean up DOM
*/
async destroy() {
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
this.element = null;
this.isRendered = false;
this.isVisible = false;
await super.destroy();
}
/**
* Apply all CSS classes to element
*/
applyCSSClasses(element = this.element) {
if (element) {
element.className = Array.from(this.cssClasses).join(' ');
}
}
/**
* Default configuration for UI widgets
*/
getDefaultConfig() {
return {
...super.getDefaultConfig(),
theme: 'default',
animationDuration: 300,
enableAnimations: true
};
}
}

View File

@@ -0,0 +1,141 @@
/**
* Base Widget Class
*
* Foundation class for all Markitect UI widgets following the plugin architecture.
* Provides core functionality for event handling, state management, and lifecycle.
*/
export class Widget extends EventTarget {
constructor(options = {}) {
super();
// Core properties
this.id = options.id || `widget-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
this.container = options.container || document.body;
this.config = { ...this.getDefaultConfig(), ...options };
// State management
this.state = new Map();
this.isInitialized = false;
this.isDestroyed = false;
// Mixin support
this.mixins = [];
// Lifecycle hooks
this.onInitialize = options.onInitialize || (() => {});
this.onDestroy = options.onDestroy || (() => {});
}
/**
* Initialize the widget
*/
async initialize() {
if (this.isInitialized || this.isDestroyed) {
return this;
}
try {
await this.onInitialize(this);
this.isInitialized = true;
this.emit('initialized');
return this;
} catch (error) {
this.emit('error', { phase: 'initialize', error });
throw error;
}
}
/**
* Destroy the widget and clean up resources
*/
async destroy() {
if (this.isDestroyed) {
return;
}
try {
await this.onDestroy(this);
this.isDestroyed = true;
this.emit('destroyed');
} catch (error) {
this.emit('error', { phase: 'destroy', error });
throw error;
}
}
/**
* State management
*/
setState(key, value) {
const oldValue = this.state.get(key);
this.state.set(key, value);
this.emit('state-changed', { key, value, oldValue });
}
getState(key, defaultValue = null) {
return this.state.get(key) ?? defaultValue;
}
/**
* Event emission wrapper
*/
emit(eventType, data = {}) {
const event = new CustomEvent(eventType, {
detail: { widget: this, ...data }
});
this.dispatchEvent(event);
}
/**
* Apply mixin functionality
*/
applyMixin(mixin) {
if (typeof mixin === 'object') {
Object.assign(this, mixin);
this.mixins.push(mixin);
}
return this;
}
/**
* Default configuration (override in subclasses)
*/
getDefaultConfig() {
return {};
}
/**
* Utility method for creating DOM elements with styling
*/
createElement(tag, options = {}) {
const element = document.createElement(tag);
if (options.className) {
element.className = options.className;
}
if (options.textContent) {
element.textContent = options.textContent;
}
if (options.innerHTML) {
element.innerHTML = options.innerHTML;
}
if (options.style) {
if (typeof options.style === 'string') {
element.style.cssText = options.style;
} else {
Object.assign(element.style, options.style);
}
}
if (options.attributes) {
Object.entries(options.attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
return element;
}
}