From f50a817b26d8dfe84675369e0a0d221bd42d4566 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Wed, 18 Feb 2026 14:52:47 -0600 Subject: [PATCH 01/14] refactor: update example components and features to be more comprehensive - Note: this revealed some bugs in the implementation regarding the inheritance chain, so functionality is broken. A fix is in progress. --- index.css | 17 +- index.html | 6 +- readme.md | 24 +-- src/components/alert-box.ts | 133 ++++++++++++ src/components/custom-element.ts | 45 ---- src/components/feature-demo.ts | 135 ------------ src/components/message-base.ts | 126 +++++++++++ src/components/message-box.ts | 124 +++++++++++ src/components/notification-demo.ts | 263 +++++++++++++++++++++++ src/components/toast-notification.ts | 259 ++++++++++++++++++++++ src/features/counter-feature.ts | 41 ---- src/features/dismiss-feature.ts | 128 +++++++++++ src/features/focus-feature.ts | 61 ------ src/features/layout-feature.ts | 106 --------- src/features/lifecycle-logger-feature.ts | 23 -- src/features/status-feature.ts | 132 ++++++++++++ src/features/timer-feature.ts | 225 +++++++++++++++++++ src/features/visibility-feature.ts | 131 +++++++++++ src/root/decorators/provide.ts | 4 + src/root/lit-core.ts | 146 +++++++++++-- src/root/lit-feature.ts | 84 +++++--- src/root/services/feature-manager.ts | 108 ++++++---- src/root/types/feature-types.ts | 23 +- 23 files changed, 1808 insertions(+), 536 deletions(-) create mode 100644 src/components/alert-box.ts delete mode 100644 src/components/custom-element.ts delete mode 100644 src/components/feature-demo.ts create mode 100644 src/components/message-base.ts create mode 100644 src/components/message-box.ts create mode 100644 src/components/notification-demo.ts create mode 100644 src/components/toast-notification.ts delete mode 100644 src/features/counter-feature.ts create mode 100644 src/features/dismiss-feature.ts delete mode 100644 src/features/focus-feature.ts delete mode 100644 src/features/layout-feature.ts delete mode 100644 src/features/lifecycle-logger-feature.ts create mode 100644 src/features/status-feature.ts create mode 100644 src/features/timer-feature.ts create mode 100644 src/features/visibility-feature.ts diff --git a/index.css b/index.css index 94bafbf..8e00a50 100644 --- a/index.css +++ b/index.css @@ -1,11 +1,10 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; line-height: 1.5; font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color: #213547; + background-color: #f5f7fa; font-synthesis: none; text-rendering: optimizeLegibility; @@ -15,15 +14,9 @@ body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; + padding: 24px; + box-sizing: border-box; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } -} diff --git a/index.html b/index.html index bbc7758..1873e8d 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,11 @@ - Lit Composable Features + LitFeature - Notification System Demo - + - + diff --git a/readme.md b/readme.md index 9829231..c958972 100644 --- a/readme.md +++ b/readme.md @@ -36,12 +36,12 @@ Lit already has strong composition primitives (notably `ReactiveController`), bu Hosts declare features using **either** static getters or decorators (both are supported): **Static getter approach:** -- `static get provide()` — declares which features this class makes available to itself and subclasses -- `static get configure()` — configures (or disables) inherited/provided features for this class and below +- `static get provides()` — declares which features this class makes available to itself and subclasses +- `static get features()` — configures (or disables) inherited/provided features for this class and below **Decorator approach:** -- `@provide(name, definition)` — equivalent to adding an entry in `static get provide()` -- `@configure(name, options)` — equivalent to adding an entry in `static get configure()` +- `@provide(name, definition)` — equivalent to adding an entry in `static get provides()` +- `@configure(name, options)` — equivalent to adding an entry in `static get features()` This repo’s reference implementation is in `src/root`: @@ -53,7 +53,7 @@ This repo’s reference implementation is in `src/root`: ```ts import { LitCore } from "./src/root/lit-core.js"; -import { provide, configure } from "./src/root/decorators/index.js"; +import { provide, feature } from "./src/root/decorators/index.js"; // Base button provides styling with sensible defaults @provide('Style', { class: StyleFeature, config: { variant: 'outlined', size: 'medium' } }) @@ -77,7 +77,7 @@ At runtime, `PrimaryButton` instances have: ### 1) Providing a feature -Provide a feature by naming it in `static get provide()` or using the `@provide` decorator: +Provide a feature by naming it in `static get provides()` or using the `@provide` decorator: **Using static getter:** @@ -86,7 +86,7 @@ import { LitCore } from "./src/root/lit-core.js"; import { LayoutFeature } from "./src/features/layout-feature.js"; export class BaseElement extends LitCore { - static get provide() { + static get provides() { return { Layout: { class: LayoutFeature, @@ -113,13 +113,13 @@ export class BaseElement extends LitCore {} ### 2) Configuring a provided feature -Subclasses can override configuration via `static get configure()` or the `@configure` decorator: +Subclasses can override configuration via `static get features()` or the `@configure` decorator: **Using static getter:** ```js export class FancyElement extends BaseElement { - static get configure() { + static get features() { return { Layout: { config: { layout: "emphasized", shape: "rounded" } @@ -146,7 +146,7 @@ Config objects are deep-merged (this POC uses `lodash.merge`). ```js export class NoLayoutElement extends BaseElement { - static get configure() { + static get features() { return { Layout: "disable" }; @@ -171,7 +171,7 @@ Features can contribute reactive properties (via `static get properties()` on th ```js export class Element extends BaseElement { - static get configure() { + static get features() { return { Layout: { properties: { @@ -298,7 +298,7 @@ That means a “disabled-by-default, opt-in later” feature would not contribut 4) **API shape is intentionally minimal** -There is no formal typing, no "feature dependencies," no ordering controls, and no explicit "feature enabled" switch in `static get configure()` in the POC. +There is no formal typing, no “feature dependencies,” no ordering controls, and no explicit “feature enabled” switch in `static get features()` in the POC. ## Relationship to existing Lit concepts diff --git a/src/components/alert-box.ts b/src/components/alert-box.ts new file mode 100644 index 0000000..89ddcdb --- /dev/null +++ b/src/components/alert-box.ts @@ -0,0 +1,133 @@ +import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { provide, configure } from '../root/decorators/index.js'; +import { MessageBox } from './message-box.js'; +import { DismissFeature } from '../features/dismiss-feature.js'; + +/** + * AlertBox (Level 3) + * + * Extends MessageBox with dismissal functionality. + * Adds a close button and dismiss callbacks. + * + * HIERARCHY: MessageBase → MessageBox → AlertBox + * INHERITS: StatusFeature (from MessageBase), VisibilityFeature (from MessageBox) + * PROVIDES: DismissFeature + * CONFIGURES: StatusFeature, VisibilityFeature + * + * @element alert-box + * @attr {boolean} dismissible - Whether the alert can be dismissed + * @attr {boolean} dismissed - Whether the alert has been dismissed + * @fires dismissed - When the alert is dismissed + */ +@provide('Dismiss', { + class: DismissFeature, + config: { + dismissible: true, + dismissLabel: 'Close' + } +}) +@configure('Status', { + config: { + defaultStatus: 'warning' // Alerts are often warnings + } +}) +@configure('Visibility', { + config: { + transitionDuration: 200 // Slightly faster for alerts + } +}) +export class AlertBox extends MessageBox { + // Feature instance + declare Dismiss: DismissFeature; + + // Properties from DismissFeature + declare dismissible: boolean; + declare dismissed: boolean; + + static override styles: CSSResultGroup = [ + MessageBox.styles as CSSResultGroup, + css` + .alert-box { + position: relative; + padding-right: 40px; /* Make room for close button */ + } + + .dismiss-button { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + background: none; + border: none; + padding: 4px 8px; + cursor: pointer; + font-size: 18px; + line-height: 1; + opacity: 0.6; + transition: opacity 0.2s; + color: inherit; + } + + .dismiss-button:hover { + opacity: 1; + } + + .dismiss-button:focus { + outline: 2px solid currentColor; + outline-offset: 2px; + opacity: 1; + } + + /* Dismissed state */ + :host([dismissed]) .alert-box { + display: none; + } + ` + ]; + + /** + * Handle dismiss button click + */ + private _handleDismiss(): void { + this.Dismiss?.dismiss(); + } + + render(): TemplateResult { + if (this.dismissed) { + return html``; + } + + if (!this.visible && !this.transitioning) { + return html``; + } + + const statusClass = `status-${this.status || 'info'}`; + const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; + + return html` + + `; + } +} + +// Register the component +AlertBox.register('alert-box'); diff --git a/src/components/custom-element.ts b/src/components/custom-element.ts deleted file mode 100644 index 3603f94..0000000 --- a/src/components/custom-element.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { TemplateResult } from 'lit'; -import { LitCore } from '../root/lit-core.js'; -import { LayoutFeature, LayoutClasses } from '../features/layout-feature.js'; -import { provide } from '../root/decorators/index.js'; - -/** - * Using the @provide decorator to register the LayoutFeature. - * The first argument 'Layout' becomes the feature key, and the feature - * instance will be attached to the component as `this.Layout` at runtime. - */ -@provide('Layout', { class: LayoutFeature }) -export class CustomElement extends LitCore { - // Declare layout feature properties that will be available on the host - declare layout: string; - declare shape: string; - declare size: string; - declare layoutClasses: LayoutClasses; - declare Layout: LayoutFeature; - - /** - * Use this to define the layout of the component. - * This method should be overridden in extending classes to provide the specific layout. - */ - renderLayout(): TemplateResult | void {} - - /** - * Render the layout for the component. - * Do not override this method in extending classes. - */ - override render(): TemplateResult | void { - try { - return this.renderLayout(); - } catch (error) { - console.error('Failed to get the defined layout - using the default layout', error); - return this.getLayout('default'); - } - } - - /** - * Get a specific layout by name. Override in subclasses to provide layout implementations. - */ - protected getLayout(_name: string): TemplateResult | void { - return undefined; - } -} diff --git a/src/components/feature-demo.ts b/src/components/feature-demo.ts deleted file mode 100644 index 21b4cfc..0000000 --- a/src/components/feature-demo.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { html, TemplateResult } from 'lit'; -import { CustomElement } from './custom-element.js'; -import { FocusFeature } from '../features/focus-feature.js'; -import { CounterFeature } from '../features/counter-feature.js'; -import { LifecycleLoggerFeature } from '../features/lifecycle-logger-feature.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { provide, configure } from '../root/decorators/index.js'; - -/** - * - * - * This element demonstrates the full power of the feature system: - * - Multiple features (layout, focus, counter, lifecycle logger) - * - Feature configuration and property merging - * - Disabling features and properties - * - Lifecycle hooks - * - Living documentation via code and comments - * - * Usage: - * - */ -@provide('Focus', { class: FocusFeature, config: { makeHostFocusable: false } }) -@provide('Counter', { class: CounterFeature, config: { start: 5 } }) -@provide('LifecycleLogger', { class: LifecycleLoggerFeature }) -@configure('Layout', { - config: { layout: 'emphasized', shape: 'rounded', size: 'lg', onDark: false }, - properties: { onDark: 'disable' } -}) -@configure('Focus', { - config: { - onFocus: () => console.log('Demo: Focused!'), - onBlur: () => console.log('Demo: Blurred!'), - makeHostFocusable: true - } -}) -@configure('Counter', { config: { start: 10 } }) -export class FeatureDemoElement extends CustomElement { - // Declare feature instances and properties - declare Focus: FocusFeature; - declare Counter: CounterFeature; - declare LifecycleLogger: LifecycleLoggerFeature; - declare hasFocus: boolean; - declare count: number; - - private renderLayoutClassic(): TemplateResult { - return html` -
-

Classic Layout

-
- `; - } - - private renderLayoutEmphasized(): TemplateResult { - return html` -
-

Emphasized Layout

-
- `; - } - - private renderers: Record TemplateResult> = { - classic: this.renderLayoutClassic.bind(this), - emphasized: this.renderLayoutEmphasized.bind(this), - default: this.renderLayoutClassic.bind(this) - }; - - private get layoutRenderer(): () => TemplateResult { - return this.renderers[this.layout] || this.renderers['default']; - } - - updateLayout(): void { - if (this.layout) { - this.layout = this.layout === 'classic' ? 'emphasized' : 'classic'; - } - } - - updateSize(): void { - if (this.size) { - this.size = this.size === 'md' ? 'lg' : 'md'; - } - } - - updateShape(): void { - if (this.shape) { - this.shape = this.shape === 'pill' ? 'rounded' : 'pill'; - } - } - - increment(): void { - this.Counter?.increment(); - } - - decrement(): void { - this.Counter?.decrement(); - } - - override updated(changedProperties: Map): void { - super.updated(changedProperties); - if (changedProperties.has('hasFocus')) { - console.log(`Focus state changed: ${this.hasFocus}.`); - } - if (changedProperties.has('count')) { - console.log(`Counter changed: ${this.count}.`); - } - } - - override renderLayout(): TemplateResult { - return html` -

Feature System Demo

- - - - - - ${this.layoutRenderer()} -

- Shape: ${this.shape}
- Size: ${this.size}
- Layout: ${this.layout}
- Layout Classes: ${Object.keys(this.layoutClasses || {}).join(', ')} -

-

- Focus state: ${this.hasFocus ? 'Focused' : 'Not Focused'} -

-

- Counter: ${this.count} -

-

- Open the console to see lifecycle and feature logs. -

- `; - } -} - -FeatureDemoElement.register('feature-demo-element'); diff --git a/src/components/message-base.ts b/src/components/message-base.ts new file mode 100644 index 0000000..a8a5606 --- /dev/null +++ b/src/components/message-base.ts @@ -0,0 +1,126 @@ +import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { LitCore } from '../root/lit-core.js'; +import { provide } from '../root/decorators/index.js'; +import { StatusFeature, StatusType, StatusStyles } from '../features/status-feature.js'; + +/** + * MessageBase (Level 1) + * + * Base component for all message/notification types. + * Provides the StatusFeature which controls visual styling based on severity. + * + * HIERARCHY: This is the ROOT component + * PROVIDES: StatusFeature + * + * @element message-base + * @attr {string} status - Message severity: 'info', 'success', 'warning', 'error' + * @attr {boolean} show-icon - Whether to display status icon + */ +@provide('Status', { + class: StatusFeature, + config: { + defaultStatus: 'info', + showIcon: true + } +}) +export class MessageBase extends LitCore { + // Feature instance (injected by FeatureManager) + declare Status: StatusFeature; + + // Properties from StatusFeature (automatically added to host) + declare status: StatusType; + declare showIcon: boolean; + declare statusStyles: StatusStyles; + + static override styles: CSSResultGroup = css` + :host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + } + + .message { + padding: 12px 16px; + border-radius: 6px; + display: flex; + align-items: flex-start; + gap: 10px; + } + + .message-icon { + flex-shrink: 0; + font-size: 16px; + line-height: 1.5; + } + + .message-content { + flex: 1; + } + + /* Status color variants */ + .status-info { + background-color: #e3f2fd; + border: 1px solid #2196f3; + color: #1565c0; + } + + .status-success { + background-color: #e8f5e9; + border: 1px solid #4caf50; + color: #2e7d32; + } + + .status-warning { + background-color: #fff3e0; + border: 1px solid #ff9800; + color: #e65100; + } + + .status-error { + background-color: #ffebee; + border: 1px solid #f44336; + color: #c62828; + } + `; + + firstUpdated(_changedProperties?: Map): void { + super.firstUpdated(_changedProperties); + console.log('[MessageBase] firstUpdated with status:', this.status, 'showIcon:', this.showIcon); + } + + updated(changedProperties: Map): void { + super.updated(changedProperties); + console.log('[MessageBase] updated with status:', this.status, 'showIcon:', this.showIcon); + } + + connectedCallback(): void { + super.connectedCallback(); + console.log(`[MessageBase] Connected with status: ${this.status}, showIcon: ${this.showIcon}`); + } + + /** + * Render the message content (to be overridden by subclasses) + */ + protected renderContent(): TemplateResult { + return html``; + } + + override render(): TemplateResult { + const statusClass = `status-${this.status || 'info'}`; + + return html` +
+ ${this.showIcon ? html` + ${this.Status?.getStatusIcon()} + ` : null} +
+ ${this.renderContent()} +
+
+ `; + } +} + +// Register the component +MessageBase.register('message-base'); diff --git a/src/components/message-box.ts b/src/components/message-box.ts new file mode 100644 index 0000000..af85b3f --- /dev/null +++ b/src/components/message-box.ts @@ -0,0 +1,124 @@ +import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { provide, configure } from '../root/decorators/index.js'; +import { MessageBase } from './message-base.js'; +import { VisibilityFeature } from '../features/visibility-feature.js'; + +/** + * MessageBox (Level 2) + * + * Extends MessageBase with visibility/transition capabilities. + * Can be shown/hidden with smooth transitions. + * + * HIERARCHY: MessageBase → MessageBox + * INHERITS: StatusFeature (from MessageBase) + * PROVIDES: VisibilityFeature + * CONFIGURES: StatusFeature (changes default to 'success') + * + * @element message-box + * @attr {boolean} visible - Whether the message is visible + * @fires visibility-changed - When visibility state changes + */ +@provide('Visibility', { + class: VisibilityFeature, + config: { + initiallyVisible: true, + transitionDuration: 300 + } +}) +@configure('Status', { + config: { + defaultStatus: 'success' // Override parent's default + } +}) +export class MessageBox extends MessageBase { + // Feature instance + declare Visibility: VisibilityFeature; + + // Properties from VisibilityFeature + declare visible: boolean; + declare transitioning: boolean; + + static override styles: CSSResultGroup = [ + MessageBase.styles as CSSResultGroup, + css` + :host { + display: block; + } + + :host(:not([visible])) { + display: none; + } + + .message-box { + position: relative; + } + + .message-box.transitioning { + pointer-events: none; + } + + /* Slide-in animation */ + @keyframes slideIn { + from { + transform: translateY(-10px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + .message-box.animate-in { + animation: slideIn 0.3s ease-out; + } + ` + ]; + + /** + * Show the message box + */ + show(): void { + this.Visibility?.show(); + } + + /** + * Hide the message box + */ + hide(): void { + this.Visibility?.hide(); + } + + /** + * Toggle visibility + */ + toggle(): void { + this.Visibility?.toggle(); + } + + render(): TemplateResult { + if (!this.visible && !this.transitioning) { + return html``; + } + + const statusClass = `status-${this.status || 'info'}`; + const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; + + return html` +
+ ${this.showIcon ? html` + ${this.Status?.getStatusIcon()} + ` : null} +
+ ${this.renderContent()} +
+
+ `; + } +} + +// Register the component +MessageBox.register('message-box'); diff --git a/src/components/notification-demo.ts b/src/components/notification-demo.ts new file mode 100644 index 0000000..acee59f --- /dev/null +++ b/src/components/notification-demo.ts @@ -0,0 +1,263 @@ +import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { state } from 'lit/decorators.js'; +import { LitCore } from '../root/lit-core.js'; + +// Import all notification components +import './message-base.js'; +import './message-box.js'; +import './alert-box.js'; +import './toast-notification.js'; + +// Import types for declarations +import type { StatusType } from '../features/status-feature.js'; + +/** + * NotificationDemo + * + * Demonstrates the LitFeature notification system with all 4 levels: + * 1. message-base: Basic message with status styling + * 2. message-box: Message with show/hide transitions + * 3. alert-box: Dismissible alert with close button + * 4. toast-notification: Auto-dismissing toast with timer + * + * This demo showcases: + * - Feature inheritance across 4 levels + * - Feature configuration and overrides + * - Reactive properties from features + * - Lifecycle hooks in action + * - Feature-to-feature communication + */ +export class NotificationDemo extends LitCore { + private _toastCount: number = 0; + + @state() + private _toasts: Array<{ id: number; status: StatusType; message: string }> = []; + + static override styles: CSSResultGroup = css` + :host { + display: block; + max-width: 800px; + margin: 0 auto; + padding: 24px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + h1 { + font-size: 28px; + font-weight: 600; + margin: 0 0 8px; + color: #1a1a1a; + } + + h2 { + font-size: 20px; + font-weight: 600; + margin: 32px 0 16px; + color: #333; + border-bottom: 2px solid #e0e0e0; + padding-bottom: 8px; + } + + .subtitle { + color: #666; + margin: 0 0 32px; + } + + .section { + margin-bottom: 32px; + } + + .demo-grid { + display: grid; + gap: 16px; + } + + .demo-row { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + } + + .level-badge { + display: inline-block; + background: #333; + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-right: 8px; + } + + .code { + font-family: 'SF Mono', Monaco, monospace; + background: #f5f5f5; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + } + + button { + background: #333; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; + } + + button:hover { + background: #555; + } + + button.secondary { + background: #e0e0e0; + color: #333; + } + + button.secondary:hover { + background: #d0d0d0; + } + + .hierarchy-diagram { + background: #f9f9f9; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 16px; + font-family: 'SF Mono', Monaco, monospace; + font-size: 13px; + line-height: 1.8; + } + + .hierarchy-diagram .provides { + color: #2e7d32; + } + + .hierarchy-diagram .configures { + color: #1565c0; + } + + .toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column-reverse; + gap: 12px; + z-index: 1000; + } + + .feature-list { + margin: 8px 0; + padding-left: 20px; + } + + .feature-list li { + margin: 4px 0; + } + `; + + private _createToast(status: StatusType): void { + this._toastCount++; + const messages: Record = { + info: 'This is an informational toast notification.', + success: 'Operation completed successfully!', + warning: 'Please review before proceeding.', + error: 'An error occurred. Please try again.' + }; + + const toast = { + id: this._toastCount, + status, + message: messages[status] + }; + + this._toasts = [...this._toasts, toast]; + this.requestUpdate(); + + // Auto-remove from array after animation + setTimeout(() => { + this._toasts = this._toasts.filter(t => t.id !== toast.id); + this.requestUpdate(); + }, 6000); + } + + render(): TemplateResult { + return html` +

LitFeature Notification System

+

A demonstration of the composable feature architecture with 4 levels of inheritance

+ + + + +
+

Level 2 message-box

+

Extends message-base with VisibilityFeature - adds show/hide with transitions.

+
+
+ + Visibility.toggle() +
+ + This message can be shown/hidden with smooth transitions. + Click the button above to toggle! + +
+
+ + + + +
+

Features Demonstrated

+
    +
  • @provide decorator: Registers features at each component level
  • +
  • @configure decorator: Overrides feature config in descendant classes
  • +
  • Property inheritance: Feature properties automatically added to host
  • +
  • Lifecycle hooks: beforeConnectedCallback, afterConnectedCallback, updated, etc.
  • +
  • Feature communication: Timer → Dismiss, Dismiss → Visibility
  • +
  • Config merging: Descendant configs merge with ancestor defaults
  • +
+
+ + +
+ ${this._toasts.map(toast => html` + this._removeToast(toast.id)} + > + ${toast.message} + + `)} +
+ `; + } + + private _toggleMessageBox(): void { + const messageBox = this.renderRoot.querySelector('#demo-message-box') as { toggle?: () => void }; + messageBox?.toggle?.(); + } + + private _resetAlerts(): void { + // Re-create alerts by forcing a re-render + const alerts = this.renderRoot.querySelectorAll('alert-box'); + alerts.forEach(alert => { + (alert as { dismissed?: boolean; visible?: boolean }).dismissed = false; + (alert as { visible?: boolean }).visible = true; + }); + this.requestUpdate(); + } + + private _removeToast(id: number): void { + this._toasts = this._toasts.filter(t => t.id !== id); + this.requestUpdate(); + } +} + +// Register the demo component +NotificationDemo.register('notification-demo'); diff --git a/src/components/toast-notification.ts b/src/components/toast-notification.ts new file mode 100644 index 0000000..50bcd59 --- /dev/null +++ b/src/components/toast-notification.ts @@ -0,0 +1,259 @@ +import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { provide, configure } from '../root/decorators/index.js'; +import { AlertBox } from './alert-box.js'; +import { TimerFeature } from '../features/timer-feature.js'; +import { state } from 'lit/decorators.js'; + +/** + * ToastNotification (Level 4) + * + * Extends AlertBox with auto-dismiss timer functionality. + * Shows a countdown progress bar and auto-dismisses after duration. + * + * HIERARCHY: MessageBase → MessageBox → AlertBox → ToastNotification + * INHERITS: StatusFeature, VisibilityFeature, DismissFeature + * PROVIDES: TimerFeature + * CONFIGURES: All parent features + * + * This component demonstrates the full feature inheritance chain: + * - StatusFeature: Controls color/icon (configured to 'info') + * - VisibilityFeature: Handles show/hide transitions + * - DismissFeature: Provides manual close button + * - TimerFeature: Auto-dismisses after countdown + * + * @element toast-notification + * @attr {number} duration - Auto-dismiss duration in milliseconds + * @fires timer-complete - When the countdown finishes + * @fires dismissed - When the toast is dismissed (manual or auto) + */ +@provide('Timer', { + class: TimerFeature, + config: { + duration: 5000, + autoStart: true, + autoDismiss: true + } +}) +@configure('Status', { + config: { + defaultStatus: 'info', + showIcon: true + } +}) +@configure('Visibility', { + config: { + initiallyVisible: true, + transitionDuration: 300, + onVisibilityChange: (visible: boolean) => { + console.log(`[ToastNotification] Visibility: ${visible}`); + } + } +}) +@configure('Dismiss', { + config: { + dismissible: true, + onDismiss: () => { + console.log('[ToastNotification] Dismissed'); + } + } +}) +export class ToastNotification extends AlertBox { + // Feature instance + declare Timer: TimerFeature; + + // Properties from TimerFeature + declare duration: number; + declare remaining: number; + declare progress: number; + declare running: boolean; + declare paused: boolean; + + // Internal state for progress bar updates + @state() + private _progressPercent: number = 0; + + static override styles: CSSResultGroup = [ + AlertBox.styles as CSSResultGroup, + css` + :host { + --toast-max-width: 400px; + } + + .toast { + position: relative; + max-width: var(--toast-max-width); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + } + + /* Progress bar at bottom */ + .progress-bar { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + background-color: currentColor; + opacity: 0.4; + transition: width 0.1s linear; + } + + /* Timer controls */ + .timer-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + opacity: 0.8; + margin-top: 8px; + } + + .timer-remaining { + font-variant-numeric: tabular-nums; + } + + .timer-controls { + display: flex; + gap: 4px; + } + + .timer-button { + background: none; + border: 1px solid currentColor; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + color: inherit; + opacity: 0.7; + transition: opacity 0.2s; + } + + .timer-button:hover { + opacity: 1; + } + + /* Paused state visual */ + .toast.paused .progress-bar { + animation: pulse 1s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } + } + ` + ]; + + constructor() { + super(); + this._progressPercent = 0; + } + + /** + * Pause the auto-dismiss timer (e.g., on hover) + */ + pauseTimer(): void { + this.Timer?.pause(); + } + + /** + * Resume the auto-dismiss timer + */ + resumeTimer(): void { + this.Timer?.resume(); + } + + /** + * Reset and restart the timer + */ + resetTimer(): void { + this.Timer?.reset(); + this.Timer?.start(); + } + + connectedCallback(): void { + super.connectedCallback(); + // Update progress on each frame + this._startProgressTracking(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + private _startProgressTracking(): void { + const updateProgress = () => { + if (this.Timer) { + this._progressPercent = (1 - this.Timer.progress) * 100; + } + if (!this.dismissed) { + requestAnimationFrame(updateProgress); + } + }; + requestAnimationFrame(updateProgress); + } + + private _handleMouseEnter(): void { + this.pauseTimer(); + } + + private _handleMouseLeave(): void { + this.resumeTimer(); + } + + render(): TemplateResult { + if (this.dismissed) { + return html``; + } + + if (!this.visible && !this.transitioning) { + return html``; + } + + const statusClass = `status-${this.status || 'info'}`; + const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; + const progressWidth = `${this._progressPercent}%`; + + return html` + + `; + } +} + +// Register the component +ToastNotification.register('toast-notification'); diff --git a/src/features/counter-feature.ts b/src/features/counter-feature.ts deleted file mode 100644 index d14deb5..0000000 --- a/src/features/counter-feature.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; -import type { LitCore } from '../root/lit-core.js'; - -export interface CounterConfig extends FeatureConfig { - start?: number; -} - -/** - * CounterFeature - * Demonstrates a feature with state, methods, and events. - */ -export class CounterFeature extends LitFeature { - declare count: number; - - static override properties: FeatureProperties = { - count: { type: Number, attribute: 'count', reflect: true } - }; - - constructor(host: LitCore, config: CounterConfig) { - super(host, config); - this.count = config.start ?? 0; - } - - increment(): void { - this.count++; - this.host.dispatchEvent( - new CustomEvent('counter-incremented', { - detail: { count: this.count } - }) - ); - } - - decrement(): void { - this.count--; - this.host.dispatchEvent( - new CustomEvent('counter-decremented', { - detail: { count: this.count } - }) - ); - } -} diff --git a/src/features/dismiss-feature.ts b/src/features/dismiss-feature.ts new file mode 100644 index 0000000..31454c3 --- /dev/null +++ b/src/features/dismiss-feature.ts @@ -0,0 +1,128 @@ +import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; +import type { LitCore } from '../root/lit-core.js'; + +/** + * Configuration for the DismissFeature + */ +export interface DismissConfig extends FeatureConfig { + /** Whether dismissal is enabled */ + dismissible?: boolean; + /** Callback before dismiss (return false to prevent) */ + onBeforeDismiss?: () => boolean | void; + /** Callback after dismiss completes */ + onDismiss?: () => void; + /** Label for dismiss button (accessibility) */ + dismissLabel?: string; +} + +/** + * DismissFeature + * + * Provides dismissal functionality with callbacks and events. + * Works with VisibilityFeature when available. + * + * Features demonstrated: + * - Feature-to-feature communication + * - Preventable actions via callbacks + * - beforeDisconnectedCallback lifecycle + * - Custom events with composed: true + */ +export class DismissFeature extends LitFeature { + declare dismissible: boolean; + declare dismissed: boolean; + + private _onBeforeDismiss?: () => boolean | void; + private _onDismiss?: () => void; + private _dismissLabel: string; + + static properties: FeatureProperties = { + dismissible: { + type: Boolean, + attribute: 'dismissible', + reflect: true + }, + dismissed: { + type: Boolean, + attribute: 'dismissed', + reflect: true + } + }; + + constructor(host: LitCore, config: DismissConfig) { + super(host, config); + this.dismissible = config.dismissible ?? true; + this.dismissed = false; + this._onBeforeDismiss = config.onBeforeDismiss; + this._onDismiss = config.onDismiss; + this._dismissLabel = config.dismissLabel ?? 'Dismiss'; + } + + /** + * Get the dismiss button label + */ + getDismissLabel(): string { + return this._dismissLabel; + } + + /** + * Attempt to dismiss the component + * @returns true if dismissal was successful + */ + dismiss(): boolean { + if (!this.dismissible || this.dismissed) { + return false; + } + + // Call before callback - can prevent dismissal + const shouldProceed = this._onBeforeDismiss?.(); + if (shouldProceed === false) { + console.log('[DismissFeature] Dismiss prevented by onBeforeDismiss'); + return false; + } + + // Check if we have a Visibility feature to animate + const visibilityFeature = (this.host as unknown as { Visibility?: { hide: () => void } }).Visibility; + + if (visibilityFeature) { + // Use Visibility feature's hide for animation + visibilityFeature.hide(); + // Wait for animation then complete dismissal + setTimeout(() => this._completeDismiss(), 300); + } else { + this._completeDismiss(); + } + + return true; + } + + private _completeDismiss(): void { + this.dismissed = true; + this._onDismiss?.(); + + this.host.dispatchEvent( + new CustomEvent('dismissed', { + detail: { dismissed: true }, + bubbles: true, + composed: true + }) + ); + + console.log('[DismissFeature] Component dismissed'); + } + + /** + * Lifecycle: Before disconnect cleanup + */ + beforeDisconnectedCallback(): void { + if (!this.dismissed) { + console.log('[DismissFeature] Component removed before dismissed'); + } + } + + /** + * Lifecycle: Log feature connection + */ + connectedCallback(): void { + console.log(`[DismissFeature] Connected, dismissible: ${this.dismissible}`); + } +} diff --git a/src/features/focus-feature.ts b/src/features/focus-feature.ts deleted file mode 100644 index e847646..0000000 --- a/src/features/focus-feature.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; -import type { LitCore } from '../root/lit-core.js'; - -export interface FocusConfig extends FeatureConfig { - makeHostFocusable?: boolean; - onFocus?: () => void; - onBlur?: () => void; -} - -export class FocusFeature extends LitFeature { - declare hasFocus: boolean; - - static override properties: FeatureProperties = { - hasFocus: { type: Boolean, attribute: 'hasFocus', reflect: true } - }; - - constructor(host: LitCore, config: FocusConfig) { - super(host, config); - this._init(); - } - - private _init(): void { - this._setHostFocusable(); - this._setDefaults(); - this._addEventListeners(); - } - - private get _onFocusCallback(): () => void { - return typeof this.config.onFocus === 'function' - ? this.config.onFocus - : () => {}; - } - - private get _onBlurCallback(): () => void { - return typeof this.config.onBlur === 'function' - ? this.config.onBlur - : () => {}; - } - - private _setHostFocusable(): void { - if (this.config.makeHostFocusable) { - this.host.tabIndex = 0; - } - } - - private _addEventListeners(): void { - this.host.addEventListener('focus', () => { - this.hasFocus = true; - this._onFocusCallback(); - }); - - this.host.addEventListener('blur', () => { - this.hasFocus = false; - this._onBlurCallback(); - }); - } - - private _setDefaults(): void { - this.hasFocus = false; - } -} diff --git a/src/features/layout-feature.ts b/src/features/layout-feature.ts deleted file mode 100644 index a8181ba..0000000 --- a/src/features/layout-feature.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; -import type { LitCore } from '../root/lit-core.js'; - -export interface LayoutConfig extends FeatureConfig { - layout?: string; - shape?: string; - size?: string; - onDark?: boolean; -} - -export interface LayoutClasses { - [className: string]: boolean; -} - -export class LayoutFeature extends LitFeature { - declare layout: string; - declare shape: string; - declare size: string; - declare onDark: boolean; - declare layoutClasses: LayoutClasses; - - static override properties: FeatureProperties = { - layout: { - type: String, - attribute: 'layout', - reflect: true - }, - shape: { - type: String, - attribute: 'shape', - reflect: true - }, - size: { - type: String, - attribute: 'size', - reflect: true - }, - onDark: { - type: Boolean, - attribute: 'ondark', - reflect: true - }, - layoutClasses: { - type: Object, - attribute: false, - reflect: false - } - }; - - constructor(host: LitCore, config: LayoutConfig) { - super(host, config); - this.layout = config.layout ?? 'classic'; - this.shape = config.shape ?? 'pill'; - this.size = config.size ?? 'md'; - this.onDark = config.onDark ?? false; - this.layoutClasses = {}; - - this.updateComponentArchitecture(); - } - - updateShapeClasses(): void { - const updatedClasses: LayoutClasses = { ...this.layoutClasses }; - - Object.keys(updatedClasses).forEach(className => { - if (className.startsWith('shape-')) { - delete updatedClasses[className]; - } - }); - - if (this.shape && this.size) { - updatedClasses[`shape-${this.shape.toLowerCase()}-${this.size.toLowerCase()}`] = true; - } else { - updatedClasses['shape-none'] = true; - } - - this.layoutClasses = updatedClasses; - } - - updateLayoutClasses(): void { - if (this.layout) { - const updatedClasses: LayoutClasses = { ...this.layoutClasses }; - - Object.keys(updatedClasses).forEach(className => { - if (className.startsWith('layout-')) { - delete updatedClasses[className]; - } - }); - - updatedClasses[`layout-${this.layout.toLowerCase()}`] = true; - - this.layoutClasses = updatedClasses; - } - } - - updateComponentArchitecture(): void { - this.updateLayoutClasses(); - this.updateShapeClasses(); - } - - override updated(changedProperties: Map): void { - super.updated(changedProperties); - if (changedProperties.has('layout') || changedProperties.has('shape') || changedProperties.has('size')) { - this.updateComponentArchitecture(); - } - } -} diff --git a/src/features/lifecycle-logger-feature.ts b/src/features/lifecycle-logger-feature.ts deleted file mode 100644 index d3b2d1f..0000000 --- a/src/features/lifecycle-logger-feature.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { LitFeature, FeatureConfig } from '../root/lit-feature.js'; - -/** - * LifecycleLoggerFeature - * Logs lifecycle events for demonstration/documentation. - */ -export class LifecycleLoggerFeature extends LitFeature { - override connectedCallback(): void { - console.log(`[LifecycleLoggerFeature] connectedCallback on`, this.host); - } - - override disconnectedCallback(): void { - console.log(`[LifecycleLoggerFeature] disconnectedCallback on`, this.host); - } - - override firstUpdated(): void { - console.log(`[LifecycleLoggerFeature] firstUpdated on`, this.host); - } - - override updated(): void { - console.log(`[LifecycleLoggerFeature] updated on`, this.host); - } -} diff --git a/src/features/status-feature.ts b/src/features/status-feature.ts new file mode 100644 index 0000000..49a621b --- /dev/null +++ b/src/features/status-feature.ts @@ -0,0 +1,132 @@ +import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; +import type { LitCore } from '../root/lit-core.js'; + +/** + * Available message status types + */ +export type StatusType = 'info' | 'success' | 'warning' | 'error'; + +/** + * Configuration for the StatusFeature + */ +export interface StatusConfig extends FeatureConfig { + /** Default status when none is specified */ + defaultStatus?: StatusType; + /** Show status icon */ + showIcon?: boolean; +} + +/** + * CSS class map for styles + */ +export interface StatusStyles { + [className: string]: boolean; +} + +/** + * StatusFeature + * + * Manages the visual status/severity of a message component. + * Provides reactive properties for status type and computed style classes. + * + * Features demonstrated: + * - Reactive properties with reflection + * - Config-driven defaults + * - Computed property (statusStyles) + * - Lifecycle hooks (updated) + */ +export class StatusFeature extends LitFeature { + declare status: StatusType; + declare showIcon: boolean; + declare statusStyles: StatusStyles; + + /** Status icons as unicode characters (no dependencies) */ + private static readonly STATUS_ICONS: Record = { + info: 'ℹ️', + success: '✓', + warning: '⚠', + error: '✕' + }; + + static properties: FeatureProperties = { + status: { + type: String, + attribute: 'status', + reflect: true + }, + showIcon: { + type: Boolean, + attribute: 'show-icon', + reflect: true + }, + statusStyles: { + type: Object, + attribute: false + } + }; + + constructor(host: LitCore, config: StatusConfig) { + super(host, config); + console.log('[StatusFeature] constructor with config:', config); + console.log('[StatusFeature] initial properties:', this.status, 'showIcon:', this.showIcon); + // this.setInternalValue('status', config.defaultStatus ?? 'info'); + // this.setInternalValue('showIcon', config.showIcon ?? true); + this.statusStyles = {}; + this._updateStatusStyles(); + } + + /** + * Get the icon character for the current status + */ + getStatusIcon(): string { + return StatusFeature.STATUS_ICONS[this.status] || StatusFeature.STATUS_ICONS.info; + } + + /** + * Get CSS color variable for current status + */ + getStatusColor(): string { + const colors: Record = { + info: '#2196F3', + success: '#4CAF50', + warning: '#FF9800', + error: '#F44336' + }; + return colors[this.status] || colors.info; + } + + /** + * Update computed statusStyles whenever status changes + */ + private _updateStatusStyles(): void { + this.statusStyles = { + 'status-info': this.status === 'info', + 'status-success': this.status === 'success', + 'status-warning': this.status === 'warning', + 'status-error': this.status === 'error', + 'has-icon': this.showIcon + }; + } + + firstUpdated(_changedProperties?: Map): void { + super.firstUpdated(); + console.log('[StatusFeature] firstUpdated with status:', this.status, 'showIcon:', this.showIcon); + } + + /** + * Lifecycle: Update styles when properties change + */ + updated(changedProperties: Map): void { + super.updated(changedProperties); + if (changedProperties.has('status') || changedProperties.has('showIcon')) { + this._updateStatusStyles(); + } + } + + /** + * Lifecycle: Log when feature connects + */ + connectedCallback(): void { + console.log(`[StatusFeature] Connected with status: ${this.status}`); + } +} diff --git a/src/features/timer-feature.ts b/src/features/timer-feature.ts new file mode 100644 index 0000000..c04e88f --- /dev/null +++ b/src/features/timer-feature.ts @@ -0,0 +1,225 @@ +import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; +import type { LitCore } from '../root/lit-core.js'; + +/** + * Configuration for the TimerFeature + */ +export interface TimerConfig extends FeatureConfig { + /** Duration in milliseconds before auto-action */ + duration?: number; + /** Whether to start timer automatically */ + autoStart?: boolean; + /** Whether to auto-dismiss when timer completes */ + autoDismiss?: boolean; + /** Callback when timer completes */ + onTimerComplete?: () => void; + /** Callback for progress updates (0-1) */ + onProgress?: (progress: number) => void; +} + +/** + * TimerFeature + * + * Provides countdown timer functionality with progress tracking. + * Integrates with DismissFeature for auto-dismissal. + * + * Features demonstrated: + * - Complex state management (timer) + * - beforeConnectedCallback lifecycle + * - afterConnectedCallback for deferred initialization + * - Resource cleanup in disconnectedCallback + * - Feature-to-feature communication + */ +export class TimerFeature extends LitFeature { + declare duration: number; + declare remaining: number; + declare progress: number; + declare running: boolean; + declare paused: boolean; + + private _timerId: number | null = null; + private _startTime: number = 0; + private _pausedAt: number = 0; + private _autoStart: boolean; + private _autoDismiss: boolean; + private _onTimerComplete?: () => void; + private _onProgress?: (progress: number) => void; + + static properties: FeatureProperties = { + duration: { + type: Number, + attribute: 'duration', + reflect: true + }, + remaining: { + type: Number, + attribute: false + }, + progress: { + type: Number, + attribute: false + }, + running: { + type: Boolean, + attribute: false + }, + paused: { + type: Boolean, + attribute: false + } + }; + + constructor(host: LitCore, config: TimerConfig) { + super(host, config); + this.duration = config.duration ?? 5000; + this.remaining = this.duration; + this.progress = 0; + this.running = false; + this.paused = false; + this._autoStart = config.autoStart ?? true; + this._autoDismiss = config.autoDismiss ?? true; + this._onTimerComplete = config.onTimerComplete; + this._onProgress = config.onProgress; + } + + /** + * Start the countdown timer + */ + start(): void { + if (this.running && !this.paused) return; + + this.running = true; + this.paused = false; + this._startTime = Date.now() - (this.duration - this.remaining); + + this._tick(); + console.log(`[TimerFeature] Timer started, ${this.duration}ms`); + } + + /** + * Pause the timer + */ + pause(): void { + if (!this.running || this.paused) return; + + this.paused = true; + this._pausedAt = Date.now(); + + if (this._timerId !== null) { + cancelAnimationFrame(this._timerId); + this._timerId = null; + } + + console.log(`[TimerFeature] Timer paused at ${this.remaining}ms`); + } + + /** + * Resume a paused timer + */ + resume(): void { + if (!this.paused) return; + + const pauseDuration = Date.now() - this._pausedAt; + this._startTime += pauseDuration; + this.paused = false; + + this._tick(); + console.log('[TimerFeature] Timer resumed'); + } + + /** + * Reset the timer to initial state + */ + reset(): void { + this.stop(); + this.remaining = this.duration; + this.progress = 0; + console.log('[TimerFeature] Timer reset'); + } + + /** + * Stop the timer completely + */ + stop(): void { + this.running = false; + this.paused = false; + + if (this._timerId !== null) { + cancelAnimationFrame(this._timerId); + this._timerId = null; + } + } + + /** + * Get formatted time remaining (e.g., "3.2s") + */ + getFormattedRemaining(): string { + const seconds = Math.ceil(this.remaining / 1000); + return `${seconds}s`; + } + + private _tick(): void { + const elapsed = Date.now() - this._startTime; + this.remaining = Math.max(0, this.duration - elapsed); + this.progress = Math.min(1, elapsed / this.duration); + + this._onProgress?.(this.progress); + + if (this.remaining <= 0) { + this._complete(); + } else { + this._timerId = requestAnimationFrame(() => this._tick()); + } + } + + private _complete(): void { + this.running = false; + this.remaining = 0; + this.progress = 1; + + this._onTimerComplete?.(); + + this.host.dispatchEvent( + new CustomEvent('timer-complete', { + detail: { duration: this.duration }, + bubbles: true, + composed: true + }) + ); + + console.log('[TimerFeature] Timer complete'); + + // Auto-dismiss if configured + if (this._autoDismiss) { + const dismissFeature = (this.host as unknown as { Dismiss?: { dismiss: () => void } }).Dismiss; + dismissFeature?.dismiss(); + } + } + + /** + * Lifecycle: Prepare before connection + */ + beforeConnectedCallback(): void { + console.log('[TimerFeature] Preparing timer...'); + } + + /** + * Lifecycle: Auto-start after connection + */ + afterConnectedCallback(): void { + if (this._autoStart) { + // Defer start to allow other features to initialize + requestAnimationFrame(() => { + this.start(); + }); + } + } + + /** + * Lifecycle: Clean up resources + */ + disconnectedCallback(): void { + this.stop(); + console.log('[TimerFeature] Disconnected, timer cleaned up'); + } +} diff --git a/src/features/visibility-feature.ts b/src/features/visibility-feature.ts new file mode 100644 index 0000000..9bdd38f --- /dev/null +++ b/src/features/visibility-feature.ts @@ -0,0 +1,131 @@ +import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; +import type { LitCore } from '../root/lit-core.js'; + +/** + * Configuration for the VisibilityFeature + */ +export interface VisibilityConfig extends FeatureConfig { + /** Start visible or hidden */ + initiallyVisible?: boolean; + /** Transition duration in milliseconds */ + transitionDuration?: number; + /** Callback when visibility changes */ + onVisibilityChange?: (visible: boolean) => void; +} + +/** + * VisibilityFeature + * + * Manages show/hide state with optional transition effects. + * Demonstrates methods that mutate state and trigger re-renders. + * + * Features demonstrated: + * - Boolean reactive property with reflection + * - Public API methods (show, hide, toggle) + * - Config callbacks + * - Lifecycle hooks (afterFirstUpdated) + */ +export class VisibilityFeature extends LitFeature { + declare visible: boolean; + declare transitioning: boolean; + + private _transitionDuration: number; + private _onVisibilityChange?: (visible: boolean) => void; + + static properties: FeatureProperties = { + visible: { + type: Boolean, + attribute: 'visible', + reflect: true + }, + transitioning: { + type: Boolean, + attribute: false + } + }; + + constructor(host: LitCore, config: VisibilityConfig) { + super(host, config); + this.visible = config.initiallyVisible ?? true; + this.transitioning = false; + this._transitionDuration = config.transitionDuration ?? 300; + this._onVisibilityChange = config.onVisibilityChange; + } + + /** + * Show the component with optional animation + */ + show(): void { + if (this.visible) return; + + this.transitioning = true; + this.visible = true; + this._notifyVisibilityChange(); + + setTimeout(() => { + this.transitioning = false; + }, this._transitionDuration); + } + + /** + * Hide the component with optional animation + */ + hide(): void { + if (!this.visible) return; + + this.transitioning = true; + + setTimeout(() => { + this.visible = false; + this.transitioning = false; + this._notifyVisibilityChange(); + }, this._transitionDuration); + } + + /** + * Toggle visibility state + */ + toggle(): void { + if (this.visible) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Get inline styles for transition effects + */ + getTransitionStyles(): string { + return ` + transition: opacity ${this._transitionDuration}ms ease-in-out; + opacity: ${this.visible && !this.transitioning ? '1' : '0'}; + `; + } + + private _notifyVisibilityChange(): void { + this._onVisibilityChange?.(this.visible); + this.host.dispatchEvent( + new CustomEvent('visibility-changed', { + detail: { visible: this.visible }, + bubbles: true, + composed: true + }) + ); + } + + /** + * Lifecycle: Log initial visibility state after first render + */ + afterFirstUpdated(): void { + console.log(`[VisibilityFeature] Initial state: ${this.visible ? 'visible' : 'hidden'}`); + } + + /** + * Lifecycle: Cleanup on disconnect + */ + disconnectedCallback(): void { + console.log('[VisibilityFeature] Disconnected, cleaning up transitions'); + this.transitioning = false; + } +} diff --git a/src/root/decorators/provide.ts b/src/root/decorators/provide.ts index a6c15ef..e29747f 100644 --- a/src/root/decorators/provide.ts +++ b/src/root/decorators/provide.ts @@ -37,6 +37,7 @@ export function provide( definition: FeatureDefinition ) { return function (constructor: T): T { + console.log('[@provide]', 'called for', constructor.name, 'featureName:', featureName); const decorated = constructor as unknown as ProvidesDecorated; // Ensure we have our own registry (not inherited) @@ -49,6 +50,8 @@ export function provide( } decorated[PROVIDES_REGISTRY]![featureName] = definition as unknown as FeatureDefinition; + + console.log(`[@provide] ${constructor.name} now provides:`, Object.keys(decorated[PROVIDES_REGISTRY]!)); return constructor; }; @@ -71,6 +74,7 @@ export function getInheritedDecoratorProvides(constructor: Function): ProvidesRe let current: Function | null = constructor; while (current && current.name !== 'LitElement' && current.name !== 'LitCore') { const provides = getDecoratorProvides(current); + console.log(`[getInheritedDecoratorProvides] ${current.name} provides:`, Object.keys(provides)); // Child class features take precedence (don't override if already set) Object.entries(provides).forEach(([name, definition]) => { diff --git a/src/root/lit-core.ts b/src/root/lit-core.ts index 52165cd..8da1d7d 100644 --- a/src/root/lit-core.ts +++ b/src/root/lit-core.ts @@ -1,5 +1,42 @@ import { LitElement, PropertyDeclaration } from 'lit'; import { FeatureManager, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from './services/feature-manager.js'; +import { FeatureSnapshot } from './types/feature-types.js'; + +/** + * Global queue for deferred component registration + * This ensures all decorators are applied before any finalization happens + */ +const REGISTRATION_QUEUE: Array<{ ctor: LitCoreConstructor; name: string }> = []; +let REGISTRATION_SCHEDULED = false; + +/** + * Schedule deferred registration processing + */ +function scheduleRegistration() { + if (REGISTRATION_SCHEDULED) return; + REGISTRATION_SCHEDULED = true; + + // Use queueMicrotask to defer until after all current event loop tasks complete + // This gives all decorators time to run + queueMicrotask(() => { + processRegistrationQueue(); + }); +} + +/** + * Process all queued registrations in order + */ +function processRegistrationQueue() { + console.log(`[Registration] Processing ${REGISTRATION_QUEUE.length} queued components`); + + while (REGISTRATION_QUEUE.length > 0) { + const { ctor, name } = REGISTRATION_QUEUE.shift()!; + console.log(`[Registration] Finalizing and registering ${ctor.name} as <${name}>`); + + // Now finalize and register + LitCore._finalizeAndRegister(ctor, name); + } +} /** * Base class for web components with feature management capabilities. @@ -14,15 +51,15 @@ export class LitCore extends LitElement { featureManager: FeatureManager; /** - * Holds feature-defined properties. This static property is populated - * by the FeatureManager when features are prepared during component registration. + * Lit reactive properties for this class. + * We keep this in sync with feature snapshots in `finalize()`. */ - static _featureProperties: Record = {}; + static properties: Record = {}; /** - * Flag to track if features have been initialized for this class + * Immutable snapshot of features for this class. */ - static _featuresInitialized = false; + static _featureSnapshot?: FeatureSnapshot; constructor() { super(); @@ -30,42 +67,109 @@ export class LitCore extends LitElement { } /** - * Combined properties from both direct class definition and features. - * Base properties take precedence over feature properties when merged. + * Lit's class finalization hook. + * We override this to defer execution until after all decorators have run. */ - static get properties(): Record { - const baseProperties = (Object.getPrototypeOf(this) as typeof LitCore).properties || {}; - const featureProperties = this._featureProperties || {}; + static finalize(): void { + const ctor = this as unknown as LitCoreConstructor; + + // Only run if explicitly called via _finalizeAndRegister + // Prevent Lit from auto-finalizing before decorators complete + if (!(ctor as any)._explicitFinalize) { + console.log(`[LitCore.finalize] Skipping auto-finalize for ${ctor.name}, deferring to register()`); + return; + } + + console.log(`[LitCore.finalize] Running explicit finalization for ${ctor.name}`); + + // Ensure parent is finalized first + const parent = Object.getPrototypeOf(ctor) as LitCoreConstructor | null; + if (parent && parent !== LitElement && parent.name !== 'LitElement' && parent.name !== 'LitCore') { + if (!(parent as any)._explicitFinalize) { + (parent as any)._explicitFinalize = true; + parent.finalize(); + } + } - return { - ...featureProperties, - ...baseProperties + // Now prepare features for this class + FeatureManager.prepareFeatures(ctor); + + const snapshotProps = ctor._featureSnapshot?.properties ?? {}; + const baseProps = this.hasOwnProperty('properties') ? this.properties : {}; + + const merged: Record = { + ...snapshotProps, + ...baseProps, }; + + (this as any).properties = merged; + + console.log(`[LitCore.finalize] ${this.name} feature snapshot properties:`, Object.keys(snapshotProps)); + + // Call Lit's finalization + super.finalize(); } /** * Configuration for features this component wants to use. * Use 'disable' property to explicitly disable an inherited feature. */ - static get configure(): FeaturesRegistry { - return {}; - } + static configure: FeaturesRegistry; /** * Registry of features provided by this component/class. * Each feature should include a class reference and optional default configuration. */ - static get provide(): ProvidesRegistry { - return {}; + static provide: ProvidesRegistry; + + /** + * Internal method called after all decorators have completed + * Finalizes features and registers the custom element. + * + * This walks the ENTIRE inheritance chain and ensures each class + * has its finalize() called so that getInheritedDecoratorProvides + * and getInheritedDecoratorConfigurations run at EACH level. + */ + private static _finalizeAndRegister(ctor: LitCoreConstructor, name: string): void { + // Collect the full inheritance chain from base to current + const chain: LitCoreConstructor[] = []; + let current: LitCoreConstructor | null = ctor; + + while (current && current.name !== 'LitElement') { + chain.unshift(current); // Add to front (bottom-up) + current = Object.getPrototypeOf(current) as LitCoreConstructor | null; + } + + console.log(`[Registration] Finalization chain for ${ctor.name}:`, chain.map(c => c.name).join(' → ')); + + // Now finalize EACH class in the chain from base to derived + // This ensures getInheritedDecoratorProvides is called at each level + for (const cls of chain) { + if (!cls._featureSnapshot) { + console.log(`[Registration] Calling finalize() on ${cls.name}`); + (cls as any)._explicitFinalize = true; + cls.finalize(); + } + } + + // Register with browser + customElements.define(name, ctor as any); } /** * Registers the component as a custom element with the browser. - * Also ensures all features are prepared before the component is instantiated. + * Queues the registration to occur after all decorators have completed. */ static register(componentName: string): void { - FeatureManager.prepareFeatures(this as unknown as LitCoreConstructor); - customElements.define(componentName, this); + const ctor = this as unknown as LitCoreConstructor; + + console.log(`[Registration] Queuing ${ctor.name} as <${componentName}>`); + + // Add to queue + REGISTRATION_QUEUE.push({ ctor, name: componentName }); + + // Schedule processing of queue + scheduleRegistration(); } connectedCallback(): void { diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index db47f54..d2752d2 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -1,5 +1,6 @@ import type { LitCore } from './lit-core.ts'; import type { PropertyDeclaration } from 'lit'; +import type { ReactiveController } from 'lit'; /** * Base interface for feature configuration objects @@ -18,7 +19,7 @@ export interface FeatureProperties { * Base class for all features in the system. * Features extend this class to add functionality to LitCore components. */ -export class LitFeature { +export class LitFeature implements ReactiveController { host: LitCore; config: TConfig; @@ -34,6 +35,10 @@ export class LitFeature { constructor(host: LitCore, config: TConfig) { this.host = host; this.config = config; + + // Register as a controller so we get host lifecycle callbacks + (this.host as any).addController?.(this); + this._litFeatureInit(); } @@ -41,26 +46,36 @@ export class LitFeature { const properties = (this.constructor as typeof LitFeature).properties; if (!properties) return; - Object.entries(properties).forEach(([propertyName]) => { - // Create observer for the property + // At construction time we only define proxy accessors on the feature. + // Actual value reconciliation (host ↔ feature) happens in firstUpdated/updated + // when the host has finished its own setup. + Object.keys(properties).forEach(propertyName => { this._createPropertyObserver(propertyName); - - // Set the initial value from the feature or host - const featureValue = (this as unknown as Record)[propertyName]; - const hostValue = (this.host as unknown as Record)[propertyName]; - this.setInternalValue(propertyName, featureValue ?? hostValue); }); } private _createPropertyObserver(propertyName: string): void { const feature = this; Object.defineProperty(this, propertyName, { + configurable: true, + enumerable: true, get() { return feature.getInternalValue(propertyName); }, set(newValue: unknown) { - (feature.host as unknown as Record)[propertyName] = newValue; + const hostRecord = feature.host as unknown as Record; + const oldValue = hostRecord[propertyName]; + + // Feature → host: write to Lit reactive property + hostRecord[propertyName] = newValue; + + // Mirror into feature internal map feature.setInternalValue(propertyName, newValue); + + // Ensure Lit schedules an update + if (typeof (feature.host as any).requestUpdate === 'function') { + (feature.host as any).requestUpdate(propertyName, oldValue); + } } }); } @@ -79,37 +94,50 @@ export class LitFeature { return this._internalValues.get(propertyName); } + hostUpdated(): void { + // console.log("hostUpdated called on", this.constructor.name); + // Intentionally empty: timing hook only. + // Host → feature sync happens via LitCore.updated → FeatureManager.processLifecycle('updated', changedProperties) + // which calls each feature's `updated(changedProperties)`. + } + /** - * Called after the host element's first update cycle + * Called after the host element's first update cycle (legacy hook). + * Kept for compatibility; you can prefer `hostUpdated` for controller-style usage. */ firstUpdated(_changedProperties?: Map): void { - const properties = (this.constructor as typeof LitFeature).properties; - if (!properties) return; - Object.entries(properties).forEach(([propertyName]) => { - if (properties[propertyName]) { - this.setInternalValue( - propertyName, - (this.host as unknown as Record)[propertyName] - ); + const featureRecord = this as unknown as Record; + const hostRecord = this.host as unknown as Record; + + (_changedProperties || new Map()).forEach((oldValue, propertyName) => { + const hostValue = hostRecord[propertyName]; + const internalValue = this.getInternalValue(propertyName); + + if (hostValue !== undefined) { + // Host wins: copy host value into feature internal via proxy setter + (featureRecord as any)[propertyName] = hostValue; + } else if (internalValue !== undefined) { + // Feature default wins: push internal default out to host via proxy setter + (featureRecord as any)[propertyName] = internalValue; + } else { + // Nothing set anywhere; just mirror whatever host currently has (likely undefined) + this.setInternalValue(propertyName, hostValue); } }); } /** - * Called after the host element updates + * Called after the host element updates. + * Sync host → feature only for properties that actually changed. */ updated(changedProperties: Map): void { - const properties = (this.constructor as typeof LitFeature).properties; - if (!properties) return; + const hostRecord = this.host as unknown as Record; - Object.entries(properties).forEach(([propertyName]) => { - if (changedProperties.has(propertyName)) { - this.setInternalValue( - propertyName, - (this.host as unknown as Record)[propertyName] - ); - } + console.log(`updated called:`, '\n', `Feature: [${this.constructor.name}]`, '\n', `Host: ${this.host.constructor.name}`, '\n', `Changed Properties:`, changedProperties, '\n', `Instance:`, this.host); + + changedProperties.forEach((_oldValue, propertyName) => { + this.setInternalValue(propertyName as string, hostRecord[propertyName as string]); }); } diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index 1c4fe3d..dbc745b 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -4,7 +4,7 @@ import type { LitCore } from '../lit-core.js'; import type { PropertyDeclaration } from 'lit'; import { getInheritedDecoratorProvides, type ProvidesDecorated } from '../decorators/provide.js'; import { getInheritedDecoratorConfigurations, type ConfigureDecorated } from '../decorators/configure.js'; -import type { FeatureConfig } from '../types/feature-types.js'; +import type { FeatureConfig, FeatureSnapshot } from '../types/feature-types.js'; // Re-export types from feature-types for backward compatibility export type { @@ -68,6 +68,8 @@ export class FeatureManager { current = Object.getPrototypeOf(current) as LitCoreConstructor | null; } + + console.log(features); return features; } @@ -131,32 +133,50 @@ export class FeatureManager { * This needs to be called before the element is registered. */ static prepareFeatures(constructor: LitCoreConstructor): void { - if (constructor._featuresInitialized) { - return; + if (constructor._featureSnapshot) { + return; // already computed for this class } - if (!constructor._featureProperties) { - constructor._featureProperties = {}; - } - - const providedFeatures = this.getInheritedProvides(constructor); - const featureConfigs = this.getInheritedConfigs(constructor); + // Remove the recursive call - finalize() will handle the chain + const parent = Object.getPrototypeOf(constructor) as LitCoreConstructor | null; + + const parentSnapshot: FeatureSnapshot = parent?._featureSnapshot + ? { + properties: { ...parent._featureSnapshot.properties }, + provides: { ...parent._featureSnapshot.provides }, + configs: { ...parent._featureSnapshot.configs } + } + : { + properties: {}, + provides: {}, + configs: {} + }; - Object.entries(providedFeatures).forEach(([featureName, featureDef]) => { - const featureConfig = featureConfigs[featureName]; + const localProvides = this.getInheritedProvides(constructor); + const localConfigs = this.getInheritedConfigs(constructor); - if (featureConfig === 'disable') return; + const resolvedProperties = { ...parentSnapshot.properties }; - const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; + Object.entries(localProvides).forEach(([featureName, featureDef]) => { + const featureConfig = localConfigs[featureName]; - if (!enabled) return; + if (featureConfig === 'disable') { + return; + } + + const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; + if (!enabled) { + return; + } const finalConfig = !featureConfig ? defaultConfig : merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}); - // Merge properties: static + config properties, with 'disable' support - let mergedProperties: Record = { ...(FeatureClass.properties || {}) }; + let mergedProperties: Record = { + ...(FeatureClass.properties || {}) + }; + if (featureConfig && typeof featureConfig === 'object' && featureConfig.properties) { Object.entries(featureConfig.properties).forEach(([propName, propValue]) => { if (propValue === 'disable') { @@ -167,60 +187,54 @@ export class FeatureManager { }); } - // Add merged properties to the registry Object.entries(mergedProperties).forEach(([propName, propConfig]) => { - constructor._featureProperties![propName] = propConfig; + resolvedProperties[propName] = propConfig; }); }); - constructor._featuresInitialized = true; + const snapshot: FeatureSnapshot = { + properties: Object.freeze(resolvedProperties), + provides: Object.freeze(localProvides), + configs: Object.freeze(localConfigs) + }; + + Object.freeze(snapshot); + + constructor._featureSnapshot = snapshot; } + + /** * Initialize all features that this component has opted into */ private _initializeFeatures(): void { - const availableFeatures = FeatureManager.getInheritedProvides(this.hostConstructor); - const featureConfigs = FeatureManager.getInheritedConfigs(this.hostConstructor); + const snapshot = this.hostConstructor._featureSnapshot; + if (!snapshot) return; - Object.entries(availableFeatures).forEach(([featureName, featureDef]) => { - const featureConfig = featureConfigs[featureName]; + const { provides, configs } = snapshot; + Object.entries(provides).forEach(([featureName, featureDef]) => { + const featureConfig = configs[featureName]; if (featureConfig === 'disable') return; - + const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; - - if (!featureConfig && !enabled) return; - + if (!enabled) return; + const finalConfig = !featureConfig ? defaultConfig : merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}); - - // Create the feature instance + const featureInstance = new FeatureClass(this.host, finalConfig as FeatureConfig); - + this._featureInstances.set(featureName, featureInstance); - // Set the host reference on the feature instance - const hostRecord = this.host as unknown as Record; - if (hostRecord[featureName]) { - console.warn(`Feature Warning: Property ${featureName} is already defined on the host. Attaching with '_' prefix. \nRecommended: Change feature name via provides key to avoid conflicts.`); - } - const featureKey = hostRecord[featureName] ? `_${featureName}` : featureName; - hostRecord[featureKey] = featureInstance; - - // Register instance properties if provided - const instanceProps = (featureInstance as unknown as { properties?: Record }).properties; - if (instanceProps) { - Object.entries(instanceProps).forEach(([propName, propConfig]) => { - if (Object.prototype.hasOwnProperty.call(propConfig, 'value')) { - (this.host as unknown as Record)[propName] = propConfig.value; - } - }); - } + const hostRecord = this.host as Record; + hostRecord[featureName] = featureInstance; }); } + /** * Process lifecycle method for all registered features */ diff --git a/src/root/types/feature-types.ts b/src/root/types/feature-types.ts index 4443f90..d870bc4 100644 --- a/src/root/types/feature-types.ts +++ b/src/root/types/feature-types.ts @@ -41,6 +41,25 @@ export interface FeaturesRegistry { [featureName: string]: FeatureConfigEntry | 'disable'; } +/** + * Resolved feature instance with final configuration + */ +export interface ResolvedFeature { + name: string; + definition: FeatureDefinition; + config: FeatureConfig; + properties: Record; +} + +/** + * Immutable feature snapshot for a specific class + */ +export interface FeatureSnapshot { + properties: Record; + provides: ProvidesRegistry; + configs: FeaturesRegistry; +} + /** * Interface for LitCore constructor with static feature methods */ @@ -54,8 +73,8 @@ export interface LitCoreConstructor { /** @deprecated Use `configure` instead */ features?: FeaturesRegistry; properties?: Record; - _featureProperties?: Record; - _featuresInitialized?: boolean; + _featureSnapshot?: FeatureSnapshot; + _resolvedProperties?: Record; } // Re-export FeatureConfig for convenience From e14c01db9d448b65473d53d02d2387d631e5c049 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Wed, 18 Feb 2026 16:11:47 -0600 Subject: [PATCH 02/14] refactor: update example components and features to be more comprehensive - Updated demo to be more comprehensive with a message base > message box > alert box > toast notification progression - Refactored approach for gathering provide and configure options to be lazy and deterministic rather than aggressive during register call. --- PROPOSAL.md | 850 ++++++++++++++++++++++++ src/components/notification-demo.ts | 65 +- src/features/status-feature.ts | 20 +- src/root/decorators/configure.ts | 73 +- src/root/decorators/feature-meta.ts | 15 + src/root/decorators/feature-property.ts | 59 ++ src/root/decorators/index.ts | 10 +- src/root/decorators/provide.ts | 73 +- src/root/feature-resolver.ts | 153 +++++ src/root/lit-core.ts | 132 +--- src/root/lit-feature.ts | 11 +- src/root/services/feature-manager.ts | 183 +---- 12 files changed, 1230 insertions(+), 414 deletions(-) create mode 100644 PROPOSAL.md create mode 100644 src/root/decorators/feature-meta.ts create mode 100644 src/root/decorators/feature-property.ts create mode 100644 src/root/feature-resolver.ts diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..29a44d1 --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,850 @@ +# Composable Feature-Centric Controllers with Inheritance + +**A proposal for extending Lit with declarative, inheritable, composable features** + +## Executive Summary + +This proposal introduces **Composable Feature-Centric Controllers** — a pattern that extends Lit's `ReactiveController` concept to enable declarative composition of behaviors through inheritance, while maintaining full integration with Lit's reactive property system. + +The system allows component authors to: +- **Provide** reusable features (controllers) at any level in a class hierarchy +- **Configure** or **disable** inherited features in subclasses +- Define reactive properties within features that seamlessly merge into the host component +- Compose multiple concerns (status indicators, dismissal logic, timers, visibility management) without deep mixin chains + +**Integration Goal:** The architecture demonstrated in this POC (`LitCore`, `LitFeature`, decorators, and `FeatureManager`) would be integrated directly into `LitElement` and `ReactiveElement`, making features a native part of Lit's component model rather than a separate library. + +## Core Concept: Composable Feature-Centric Controllers + +### What is a Feature? + +A **Feature** is a specialized `ReactiveController` that: +1. **Implements `ReactiveController`** - participates in host lifecycle +2. **Declares reactive properties** - properties that merge into the host's property system +3. **Encapsulates single-responsibility behavior** - dismissal, timing, status management, etc. +4. **Is inheritable and configurable** - subclasses can reconfigure or disable features + +### Key Principles + +1. **Declarative Composition**: Features are declared via class decorators or static getters, not imperatively instantiated +2. **Inheritance-Aware**: Features flow down class hierarchies and can be reconfigured at each level +3. **Property Integration**: Feature properties become host properties automatically +4. **Single Responsibility**: Each feature manages one concern (status, visibility, timer, dismissal) +5. **Inter-Feature Communication**: Features can discover and communicate with other features on the same host + +## Features in the Codebase + +This POC implements four example features that demonstrate the pattern: + +### 1. StatusFeature +**Purpose:** Manages visual status/severity indicators + +**Properties:** +- `status: StatusType` - One of: 'info', 'success', 'warning', 'error' +- `showIcon: boolean` - Whether to display status icon +- `statusStyles: StatusStyles` - Computed CSS class map + +**Methods:** +- `getStatusIcon(): string` - Returns unicode icon for current status +- `getStatusColor(): string` - Returns CSS color variable + +**Demonstrates:** +- Reactive properties with reflection +- Config-driven defaults +- Computed properties +- Lifecycle hooks (`updated`) + +### 2. VisibilityFeature +**Purpose:** Manages show/hide state with transition effects + +**Properties:** +- `visible: boolean` - Current visibility state +- `transitioning: boolean` - Whether currently animating + +**Methods:** +- `show()` - Show component with animation +- `hide()` - Hide component with animation +- `toggle()` - Toggle visibility state +- `getTransitionStyles(): string` - Get inline styles for transitions + +**Demonstrates:** +- Boolean state management +- Public API methods +- Config callbacks (`onVisibilityChange`) +- Lifecycle hooks (`afterFirstUpdated`) + +### 3. DismissFeature +**Purpose:** Provides dismissal functionality with callbacks and events + +**Properties:** +- `dismissible: boolean` - Whether dismissal is enabled +- `dismissed: boolean` - Whether component has been dismissed + +**Methods:** +- `dismiss(): boolean` - Attempt to dismiss (returns success) +- `getDismissLabel(): string` - Get accessible label for dismiss button + +**Demonstrates:** +- Feature-to-feature communication (integrates with `VisibilityFeature`) +- Preventable actions via callbacks (`onBeforeDismiss`) +- Custom events with `composed: true` +- Lifecycle hooks (`beforeDisconnectedCallback`) + +### 4. TimerFeature +**Purpose:** Countdown timer with progress tracking and auto-actions + +**Properties:** +- `duration: number` - Total duration in milliseconds +- `remaining: number` - Time remaining +- `progress: number` - Progress ratio (0-1) +- `running: boolean` - Whether timer is active +- `paused: boolean` - Whether timer is paused + +**Methods:** +- `start()` - Start/resume timer +- `pause()` - Pause timer +- `stop()` - Stop and reset timer +- `reset()` - Reset to duration + +**Demonstrates:** +- Complex state management +- Resource cleanup in `disconnectedCallback` +- Feature-to-feature integration (auto-dismiss via `DismissFeature`) +- Lifecycle hooks (`beforeConnectedCallback`, `afterConnectedCallback`) +- Progress callbacks + +## How It Works + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ LitElement │ +│ │ │ +│ ↓ │ +│ LitCore │ +│ ┌──────────────────┴──────────────────┐ │ +│ │ │ │ +│ FeatureManager Feature Resolver │ +│ │ │ │ +│ ↓ ↓ │ +│ Feature Instances ←──────────── Feature Snapshot │ +│ │ (merged config) │ +│ ↓ │ +│ LitFeature (base) │ +│ │ │ +│ ├─── StatusFeature │ +│ ├─── VisibilityFeature │ +│ ├─── DismissFeature │ +│ └─── TimerFeature │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Core Components + +#### 1. **LitCore** (extends `LitElement`) +- Entry point for feature-enabled components +- Hosts a `FeatureManager` instance +- Overrides `finalize()` to merge feature properties into component properties +- Extends lifecycle methods to propagate to features + +**Key Methods:** +```typescript +static finalize(): void { + // Merge feature properties into host properties + const snapshot = resolveFeatureSnapshot(this); + this.properties = { + ...superProps, + ...snapshot.properties, // ← Feature properties merged here + ...ownProps + }; + super.finalize(); +} + +connectedCallback(): void { + this.featureManager.processLifecycle('beforeConnectedCallback'); + super.connectedCallback(); + this.featureManager.processLifecycle('connectedCallback'); + this.featureManager.processLifecycle('afterConnectedCallback'); +} +``` + +#### 2. **LitFeature** (implements `ReactiveController`) +- Base class for all features +- Creates property proxies that sync feature properties with host properties +- Implements `ReactiveController` interface +- Manages internal property storage and observation + +**Key Responsibilities:** +- Property proxy creation: Getting/setting feature properties reads/writes host properties +- Property observation: Tracks changes and triggers host updates +- Lifecycle participation: Receives lifecycle callbacks via `ReactiveController` + +**Property Synchronization:** +```typescript +private _createPropertyObserver(propertyName: string): void { + Object.defineProperty(this, propertyName, { + get() { + return feature.getInternalValue(propertyName); + }, + set(newValue: unknown) { + // Write to host's reactive property + hostRecord[propertyName] = newValue; + // Trigger Lit's update cycle + this.host.requestUpdate(propertyName, oldValue); + } + }); +} +``` + +#### 3. **FeatureManager** +- Service that manages feature lifecycle +- Instantiates features based on resolved snapshot +- Propagates host lifecycle events to all features +- Handles feature registration and cleanup + +**Initialization Flow:** +```typescript +private _initializeFeatures(): void { + const { provides, configs } = resolveFeatureSnapshot(this.hostConstructor); + + Object.entries(provides).forEach(([name, definition]) => { + if (configs[name] === 'disable') return; + + // Merge configs from inheritance chain + const finalConfig = merge({}, defaultConfig, userConfig); + const instance = new FeatureClass(this.host, finalConfig); + + // Store on host: this.FeatureName = instance + this.host[name] = instance; + }); +} +``` + +#### 4. **Feature Resolver** +- Resolves feature definitions across inheritance chains +- Merges configs from parent classes with child overrides +- Caches resolved snapshots for performance +- Handles property disabling and overrides + +**Resolution Algorithm:** +``` +1. Walk inheritance chain (bottom-up, excluding LitElement) +2. Collect all @provide decorators and static provide getters +3. Collect all @configure decorators and static configure getters +4. Merge configs using lodash.merge (deep merge) +5. Apply property overrides/disables +6. Create final snapshot with: + - provides: Map of feature name → definition + - configs: Map of feature name → merged config + - properties: Merged property declarations +7. Cache snapshot on constructor +``` + +### Decorators + +#### `@provide(name, definition)` +Declares that a class provides a feature: +```typescript +@provide('Status', { + class: StatusFeature, + config: { defaultStatus: 'info', showIcon: true } +}) +export class MessageBase extends LitCore {} +``` + +#### `@configure(name, options | 'disable')` +Configures or disables an inherited feature: +```typescript +@configure('Status', { + config: { defaultStatus: 'success' }, + properties: { showIcon: 'disable' } +}) +export class SuccessMessage extends MessageBase {} + +// Or disable entirely: +@configure('Status', 'disable') +export class NoStatusMessage extends MessageBase {} +``` + +#### `@property(options)` (on feature classes) +Declares reactive properties within features: +```typescript +export class StatusFeature extends LitFeature { + @property({ type: String, reflect: true }) + status: StatusType = 'info'; +} +``` + +### Example Usage + +**Inheritance Chain:** +```typescript +// Level 1: Base provides Status +@provide('Status', { class: StatusFeature }) +class MessageBase extends LitCore { + declare Status: StatusFeature; + declare status: string; // From StatusFeature +} + +// Level 2: Add Visibility +@provide('Visibility', { class: VisibilityFeature }) +class MessageBox extends MessageBase { + declare Visibility: VisibilityFeature; + declare visible: boolean; // From VisibilityFeature +} + +// Level 3: Add Dismiss, configure Status +@provide('Dismiss', { class: DismissFeature }) +@configure('Status', { config: { defaultStatus: 'error' } }) +class AlertBox extends MessageBox { + declare Dismiss: DismissFeature; + declare dismissible: boolean; // From DismissFeature + // status now defaults to 'error' instead of 'info' +} + +// Level 4: Add Timer, reconfigure all features +@provide('Timer', { class: TimerFeature }) +@configure('Status', { config: { defaultStatus: 'info' } }) +@configure('Dismiss', { config: { autoDismiss: true } }) +class ToastNotification extends AlertBox { + declare Timer: TimerFeature; + declare duration: number; // From TimerFeature + declare running: boolean; // From TimerFeature + // Now has all 4 features with custom configuration +} +``` + +**At runtime:** +```typescript +const toast = new ToastNotification(); +// All feature instances available: +toast.Status.getStatusIcon(); +toast.Visibility.toggle(); +toast.Dismiss.dismiss(); +toast.Timer.start(); + +// All feature properties available on host: +toast.status = 'success'; +toast.visible = true; +toast.dismissible = true; +toast.duration = 3000; +``` + +## Integration Plan for Lit Core + +The goal is to integrate this pattern directly into Lit's `ReactiveElement` and `LitElement`, making features a native capability rather than a separate framework. + +### Required Changes to Lit Core + +#### 1. **ReactiveElement Changes** + +**Add feature resolution to `finalize()`:** +```typescript +protected static finalize() { + // ... existing property resolution ... + + // NEW: Resolve and merge feature properties + if (this.hasOwnProperty('provide') || this.hasOwnProperty('configure')) { + const featureSnapshot = this._resolveFeatures(); + this._featureSnapshot = featureSnapshot; + + // Merge feature properties into element properties + Object.assign(this.elementProperties, featureSnapshot.properties); + } + + // ... rest of finalize ... +} +``` + +**Add feature instantiation to constructor:** +```typescript +constructor() { + super(); + // ... existing initialization ... + + // NEW: Initialize feature manager if features are present + if ((this.constructor as any)._featureSnapshot) { + this._featureManager = new FeatureManager(this); + } +} +``` + +**Extend lifecycle methods:** +```typescript +connectedCallback() { + this._featureManager?.processLifecycle('beforeConnectedCallback'); + // ... existing connectedCallback ... + this._featureManager?.processLifecycle('afterConnectedCallback'); +} + +// Similar for disconnectedCallback, updated, firstUpdated, etc. +``` + +#### 2. **Feature Base Class** + +Add `Feature` as a core export (similar to `ReactiveController`): +```typescript +// Part of lit/reactive-element +export class Feature implements ReactiveController { + host: ReactiveElement; + config: TConfig; + + static properties: PropertyDeclarations = {}; + + constructor(host: ReactiveElement, config: TConfig) { + this.host = host; + this.config = config; + host.addController(this); + this._initializeProperties(); + } + + // Property synchronization logic + // Lifecycle methods +} +``` + +#### 3. **Decorator Enhancements** + +The existing `@property()` decorator would detect context: +```typescript +export function property(options?: PropertyDeclaration) { + return (protoOrTarget: any, nameOrContext: string | ClassFieldDecoratorContext) => { + // Determine if we're decorating a Feature or ReactiveElement + const isFeature = protoOrTarget instanceof Feature || + protoOrTarget.prototype instanceof Feature; + + if (isFeature) { + // Store in feature metadata + return createFeatureProperty(options); + } else { + // Existing element property logic + return createElementProperty(options); + } + }; +} +``` + +#### 4. **New Decorators** + +Add `@provide` and `@configure` as core decorators: +```typescript +// Part of lit/decorators +export { provide, configure } from './feature-decorators.js'; +``` + +### Backward Compatibility + +All changes are **additive only**: +- Existing components without features work exactly as before +- Features are opt-in via `@provide` or `static provide` +- No breaking changes to existing APIs +- Feature system is isolated and only activates when used + +### Performance Considerations + +1. **Lazy Resolution**: Feature snapshots are resolved once and cached +2. **Conditional Overhead**: Feature manager only instantiates if features are present +3. **Property Optimization**: Property merging happens at finalize time (once per class) +4. **Lifecycle Delegation**: Minimal overhead in lifecycle methods (Map iteration) + +## Property Decorator Integration Strategy + +The `@property` decorator integration is key to making features feel native to Lit. + +### Current State (POC) + +Features use a separate `@property` decorator from `'./decorators/feature-property.js'`: +```typescript +export class StatusFeature extends LitFeature { + @property({ type: String, reflect: true }) + status: StatusType = 'info'; +} +``` + +### Proposed Integration + +**Single unified `@property` decorator** that intelligently detects context: + +```typescript +import { property } from 'lit/decorators.js'; + +// Works on LitElement +export class MyElement extends LitElement { + @property({ type: String }) + name: string = 'default'; +} + +// Works on Feature (automatically!) +export class MyFeature extends Feature { + @property({ type: String, reflect: true }) + status: string = 'active'; +} +``` + +### Implementation Approach + +The decorator inspects the class hierarchy to determine behavior: + +```typescript +export function property(options?: PropertyDeclaration) { + return (protoOrTarget: any, nameOrContext: PropertyKey | DecoratorContext) => { + const target = getTargetFromContext(protoOrTarget, nameOrContext); + const ctor = target.constructor; + + // Check if this is a Feature class + const isFeatureClass = isFeature(ctor); + + if (isFeatureClass) { + // Feature property behavior: + // 1. Store in feature's static properties + // 2. Register in feature metadata for host merging + // 3. Will be merged into host during finalize() + return handleFeatureProperty(ctor, nameOrContext, options); + } else { + // Standard ReactiveElement property behavior + return handleElementProperty(target, nameOrContext, options); + } + }; +} + +function isFeature(ctor: any): boolean { + // Walk prototype chain looking for Feature base class + let proto = ctor; + while (proto && proto !== Object) { + if (proto.name === 'Feature') return true; + proto = Object.getPrototypeOf(proto); + } + return false; +} +``` + +### Benefits + +1. **Single Import**: Users import from `'lit/decorators.js'` for both elements and features +2. **Consistent API**: Same decorator syntax regardless of context +3. **Type Safety**: TypeScript types work identically +4. **Zero Confusion**: No need to remember which decorator to use where + +### Migration Path + +For codebases using the POC: +```typescript +// Before (POC): +import { property } from './root/decorators/feature-property.js'; + +// After (integrated): +import { property } from 'lit/decorators.js'; +``` + +## Outstanding Questions & Discussion Points + +### 1. Naming Conventions + +**Question:** Should we use `provide`/`configure` or explore alternatives? + +**Alternatives considered:** +- `@feature()` / `@featureConfig()` - More verbose but clearer +- `@use()` / `@setup()` - Shorter but less semantic +- `@mixin()` / `@mixinConfig()` - Familiar but potentially confusing with actual mixins + +**Recommendation:** Keep `@provide` and `@configure` - they clearly express intent and avoid confusion with other patterns. + +### 2. Static Getters vs Decorators + +**Question:** Should we support both patterns or standardize on decorators? + +**Current POC:** Supports both +```typescript +// Decorators (modern) +@provide('Feature', definition) + +// Static getters (traditional) +static get provide() { return { Feature: definition }; } +``` + +**Considerations:** +- Decorators are more ergonomic for single features +- Static getters are better for defining many features at once +- Some users prefer avoiding decorators (stage 3 concern) +- Both patterns can coexist with minimal complexity + +**Recommendation:** Support both patterns in core, but document decorators as the primary/preferred approach. + +### 3. Feature Naming on Host + +**Question:** Should features be stored as `this.FeatureName` or in a namespace like `this.features.FeatureName`? + +**Current POC:** Direct properties (`this.Status`, `this.Timer`) + +**Alternatives:** +```typescript +// Option A: Direct (current) +this.Status.getStatusIcon(); + +// Option B: Namespaced +this.features.Status.getStatusIcon(); + +// Option C: Private with accessors +this._features.get('Status').getStatusIcon(); +``` + +**Trade-offs:** +- Direct: Cleaner API, potential naming conflicts +- Namespaced: Clearer separation, no conflicts, more verbose +- Private: Encapsulation, requires explicit accessors + +**Recommendation:** Start with **direct properties** (current approach) but reserve property names starting with capital letters for features only. This convention makes feature instances easily distinguishable from component properties. + +### 4. TypeScript Declare Support + +**Question:** How can we improve the DX for feature property types? + +**Current approach:** +```typescript +@provide('Status', { class: StatusFeature }) +class MyElement extends LitCore { + declare Status: StatusFeature; // Manual declaration + declare status: string; // Manual declaration of feature properties +} +``` + +**Desired:** Automatic type inference or code generation + +**Challenges:** +- TypeScript decorators don't modify class types +- No way to automatically add types from feature classes + +**Potential Solutions:** +1. **Vue-style defineComponent helper:** + ```typescript + export const MyElement = defineComponent(LitCore) + .withFeature('Status', StatusFeature) + .build(); + // Type inference from builder pattern + ``` + +2. **TypeScript transformer plugin** (build step) +3. **JSDoc with @typedef** for JavaScript users +4. **Accept manual `declare` statements** as acceptable DX (current approach) + +**Recommendation:** Start with manual `declare` statements and explore builder pattern or TS transformers in future iterations. + +### 5. Feature Lifecycle Granularity + +**Question:** Should we expose more granular lifecycle hooks? + +**Current hooks:** +```typescript +- beforeConnectedCallback / connectedCallback / afterConnectedCallback +- beforeDisconnectedCallback / disconnectedCallback / afterDisconnectedCallback +- beforeFirstUpdated / firstUpdated / afterFirstUpdated +- beforeUpdated / updated / afterUpdated +- beforeAttributeChangedCallback / afterAttributeChangedCallback +``` + +**Considerations:** +- More hooks = more power but more complexity +- Most features only need a few lifecycle points +- Performance impact of calling many no-op methods + +**Recommendation:** Keep current granularity for now. The before/after hooks provide flexibility for feature coordination without being overwhelming. Consider adding more hooks if clear use cases emerge. + +### 6. Feature-to-Feature Dependencies + +**Question:** Should we support explicit feature dependencies? + +**Current approach:** Features discover each other at runtime +```typescript +const otherFeature = this.host.OtherFeature; +if (otherFeature) { + otherFeature.doSomething(); +} +``` + +**Alternative:** Explicit dependency declaration +```typescript +@provide('Dismiss', { + class: DismissFeature, + requires: ['Visibility'] // ← Enforce at resolution time +}) +``` + +**Trade-offs:** +- Explicit: Fails fast, clearer contracts, more rigid +- Optional: More flexible, requires null checks, potential runtime errors + +**Recommendation:** Start with optional discovery (current approach). Add `requires` and `optional` dependency declarations in a future iteration if ecosystem adoption shows clear patterns. + +### 7. Disabling Features at Runtime + +**Question:** Should features be disable-able after instantiation? + +**Current:** Features can only be disabled at configuration time +```typescript +@configure('Timer', 'disable') +``` + +**Potential:** Runtime disabling +```typescript +this.featureManager.disableFeature('Timer'); +this.featureManager.enableFeature('Timer'); +``` + +**Use case:** Toggle features based on runtime conditions (permissions, feature flags, etc.) + +**Recommendation:** Keep static configuration for MVP. Runtime toggling adds significant complexity (property cleanup, lifecycle management) without clear must-have use cases yet. + +### 8. Feature Composition (Features of Features?) + +**Question:** Should features be able to compose other features? + +**Scenario:** +```typescript +// Can a feature itself use the @provide/@configure system? +class ComplexFeature extends Feature { + @provide('SubFeature', { class: SubFeature }) + static provide = { ... }; +} +``` + +**Considerations:** +- Powerful for complex behaviors +- Adds conceptual and implementation complexity +- Current pattern encourages flat composition + +**Recommendation:** Defer this. Current pattern of flat feature composition is simpler and covers most use cases. Nested features can be explored if adoption reveals a strong need. + +### 9. Feature Property Collision Handling + +**Question:** What happens when two features define the same property name? + +**Current behavior:** Last feature wins (merge order dependent) + +**Alternatives:** +1. Throw error at finalize time +2. Namespace properties automatically (e.g., `Timer_duration`, `Animation_duration`) +3. Require explicit resolution via `@configure` + +**Recommendation:** **Throw error at finalize time** (fail fast). Property collisions indicate poor feature design or naming. Developers should resolve by: +- Renaming properties in custom feature subclass +- Using `properties: { conflictName: 'disable' }` in `@configure` +- Choosing better feature names + +### 10. Integration with Reactive Controllers + +**Question:** How do features relate to existing `ReactiveController` usage? + +**Current:** Features ARE reactive controllers (implement the interface) + +**Key point:** Features and controllers coexist: +```typescript +class MyElement extends LitElement { + @provide('Timer', { class: TimerFeature }) + static provide = { ... }; + + // Also use traditional controllers + private _mouseController = new MouseController(this); + + // Timer feature available as this.Timer + // Mouse controller used traditionally +} +``` + +**Recommendation:** Position features as "reactive controllers with superpowers" (inheritance + property merging). They complement rather than replace existing controller patterns. Document migration path from controllers to features. + +## Next Steps + +### Phase 1: POC Refinement (Current) +- [x] Implement core architecture (LitCore, LitFeature, FeatureManager) +- [x] Create example features (Status, Visibility, Dismiss, Timer) +- [x] Demonstrate inheritance chain +- [x] Support both decorators and static getters +- [x] Create working demo components +- [ ] Write comprehensive test suite +- [ ] Document all APIs +- [ ] Create this proposal + +### Phase 2: POC Validation +- [ ] Share POC with Lit team for initial feedback +- [ ] Present at Lit community meeting +- [ ] Gather feedback on API design and use cases +- [ ] Iterate on decorator/property integration strategy +- [ ] Benchmark performance vs. mixin patterns +- [ ] Address outstanding questions based on feedback + +### Phase 3: Core Integration Prototype +- [ ] Create branch of Lit core with feature system integrated +- [ ] Implement unified `@property` decorator +- [ ] Add `Feature` base class to `reactive-element` +- [ ] Port feature resolution into `ReactiveElement.finalize()` +- [ ] Create comprehensive test suite for core integration +- [ ] Update Lit's TypeScript definitions +- [ ] Write migration guide from POC to core + +### Phase 4: Documentation & Examples +- [ ] Write tutorials for common feature patterns +- [ ] Document feature lifecycle in detail +- [ ] Create best practices guide +- [ ] Build reference features for common UI patterns: + - Focus management + - Keyboard navigation + - ARIA roles/attributes + - Animation coordination + - Form validation + - Responsive behavior +- [ ] Port existing Lit patterns to features (show equivalents) + +### Phase 5: Community Preview +- [ ] Release as experimental feature behind flag +- [ ] Create migration tools for existing codebases +- [ ] Gather real-world usage feedback +- [ ] Iterate on DX and performance +- [ ] Address TypeScript typing limitations +- [ ] Refine based on ecosystem patterns + +### Phase 6: Stable Release +- [ ] Finalize API based on preview feedback +- [ ] Complete documentation +- [ ] Release as stable feature in Lit +- [ ] Update lit.dev with feature system docs +- [ ] Create Lit labs packages for common features +- [ ] Present at web component conferences + +## Success Metrics + +**Developer Experience:** +- Reduced mixin depth in component libraries +- Improved code reusability across component hierarchies +- Clearer separation of concerns in components +- Faster onboarding for new team members + +**Technical:** +- No performance regression vs. mixin patterns +- Minimal bundle size increase +- Full TypeScript support +- Compatible with existing Lit decorators and patterns + +**Adoption:** +- Major design systems adopt feature patterns +- Community creates reusable feature packages +- Positive feedback from Lit ecosystem + +## Conclusion + +Composable Feature-Centric Controllers extend Lit's already excellent composition model with inheritance-aware, declarative feature management. This pattern: + +- **Simplifies** component inheritance by avoiding deep mixin chains +- **Enables** fine-grained control over inherited behaviors +- **Maintains** Lit's reactive property system and lifecycle model +- **Provides** clear mental model: features are "controllers with superpowers" +- **Integrates** naturally into Lit's existing architecture + +The POC demonstrates that this pattern is technically feasible, ergonomic, and powerful. With community feedback and refinement, it has the potential to become a core part of how developers build composable web components with Lit. + +--- + +**POC Repository:** LitFeature +**Primary Contact:** [Add contact info] +**Status:** Proposal / Proof of Concept +**Date:** February 18, 2026 diff --git a/src/components/notification-demo.ts b/src/components/notification-demo.ts index acee59f..dd576c0 100644 --- a/src/components/notification-demo.ts +++ b/src/components/notification-demo.ts @@ -190,7 +190,32 @@ export class NotificationDemo extends LitCore {

LitFeature Notification System

A demonstration of the composable feature architecture with 4 levels of inheritance

- + +
+

Component Hierarchy

+
+
Level 1: message-base → provides StatusFeature
+
+
Level 2: message-box → provides VisibilityFeature + configures Status
+
+
Level 3: alert-box → provides DismissFeature + configures Status, Visibility
+
+
Level 4: toast-notification → provides TimerFeature + configures all
+
+
+ + +
+

Level 1 message-base

+

Base component with StatusFeature - controls colors and icons based on severity.

+
+ This is an info message + This is a success message + This is a warning message + This is an error message + Message without icon (show-icon=false) +
+
@@ -208,7 +233,43 @@ export class NotificationDemo extends LitCore {
- + +
+

Level 3 alert-box

+

Extends message-box with DismissFeature - adds close button and callbacks.

+
+
+ +
+ + This is a dismissible alert. Click the × button to dismiss it. + + + This alert has dismissible=false, so no close button appears. + + + Try dismissing this alert. The Dismiss feature integrates with Visibility for smooth fade-out. + +
+
+ + +
+

Level 4 toast-notification

+

Extends alert-box with TimerFeature - adds auto-dismiss countdown.

+
    +
  • Auto-dismiss: Countdown timer that triggers dismiss
  • +
  • Progress bar: Visual countdown indicator
  • +
  • Pause on hover: Timer pauses when mouse enters
  • +
  • Controls: Pause/Resume and Reset buttons
  • +
+
+ + + + +
+
diff --git a/src/features/status-feature.ts b/src/features/status-feature.ts index 49a621b..5ec720f 100644 --- a/src/features/status-feature.ts +++ b/src/features/status-feature.ts @@ -1,5 +1,6 @@ import { LitFeature, FeatureProperties, FeatureConfig } from '../root/lit-feature.js'; import type { LitCore } from '../root/lit-core.js'; +import { property } from '../root/decorators/feature-property.js'; /** * Available message status types @@ -36,7 +37,6 @@ export interface StatusStyles { * - Lifecycle hooks (updated) */ export class StatusFeature extends LitFeature { - declare status: StatusType; declare showIcon: boolean; declare statusStyles: StatusStyles; @@ -48,12 +48,14 @@ export class StatusFeature extends LitFeature { error: '✕' }; + @property({ + type: String, + attribute: 'status', + reflect: true + }) + status: StatusType; + static properties: FeatureProperties = { - status: { - type: String, - attribute: 'status', - reflect: true - }, showIcon: { type: Boolean, attribute: 'show-icon', @@ -67,10 +69,8 @@ export class StatusFeature extends LitFeature { constructor(host: LitCore, config: StatusConfig) { super(host, config); - console.log('[StatusFeature] constructor with config:', config); - console.log('[StatusFeature] initial properties:', this.status, 'showIcon:', this.showIcon); - // this.setInternalValue('status', config.defaultStatus ?? 'info'); - // this.setInternalValue('showIcon', config.showIcon ?? true); + this.status = config.defaultStatus || 'info'; + this.showIcon = config.showIcon ?? true; this.statusStyles = {}; this._updateStatusStyles(); } diff --git a/src/root/decorators/configure.ts b/src/root/decorators/configure.ts index 5257b15..cc7608f 100644 --- a/src/root/decorators/configure.ts +++ b/src/root/decorators/configure.ts @@ -1,17 +1,6 @@ -import type { FeatureConfig, FeatureConfigEntry, FeaturesRegistry } from '../types/feature-types.js'; +import type { FeatureConfig, FeatureConfigEntry } from '../types/feature-types.js'; import type { PropertyDeclaration } from 'lit'; - -/** - * Symbol used to store decorator-configured features on a class - */ -export const CONFIGURE_REGISTRY = Symbol('configureRegistry'); - -/** - * Interface for classes decorated with @configure - */ -export interface ConfigureDecorated { - [CONFIGURE_REGISTRY]?: FeaturesRegistry; -} +import { FEATURE_META, type FeatureMetaEntry } from './feature-meta.js'; /** * Configuration options for the @configure decorator @@ -51,54 +40,18 @@ export function configure( options: ConfigureOptions | 'disable' ) { return function (constructor: T): T { - const decorated = constructor as unknown as ConfigureDecorated; - - // Ensure we have our own registry (not inherited) - if (!Object.prototype.hasOwnProperty.call(constructor, CONFIGURE_REGISTRY)) { - // Copy parent registry if it exists - const parent = Object.getPrototypeOf(constructor) as ConfigureDecorated; - decorated[CONFIGURE_REGISTRY] = parent[CONFIGURE_REGISTRY] - ? { ...parent[CONFIGURE_REGISTRY] } - : {}; - } - - if (options === 'disable') { - decorated[CONFIGURE_REGISTRY]![featureName] = 'disable'; - } else { - decorated[CONFIGURE_REGISTRY]![featureName] = options as FeatureConfigEntry; - } - - return constructor; - }; -} + const decorated = constructor as unknown as Record; -/** - * Gets the decorator-configured features registry from a class - */ -export function getDecoratorConfigurations(constructor: Function): FeaturesRegistry { - const decorated = constructor as ConfigureDecorated; - return decorated[CONFIGURE_REGISTRY] || {}; -} + if (!Object.prototype.hasOwnProperty.call(constructor, FEATURE_META)) { + decorated[FEATURE_META] = []; + } -/** - * Collects decorator-configured features from the entire inheritance chain - */ -export function getInheritedDecoratorConfigurations(constructor: Function): FeaturesRegistry { - const configs: FeaturesRegistry = {}; - - let current: Function | null = constructor; - while (current && current.name !== 'LitElement' && current.name !== 'LitCore') { - const features = getDecoratorConfigurations(current); - - // Child class configurations take precedence (don't override if already set) - Object.entries(features).forEach(([name, config]) => { - if (!configs[name]) { - configs[name] = config; - } + decorated[FEATURE_META].push({ + kind: 'configure', + name: featureName, + options: options === 'disable' ? 'disable' : (options as FeatureConfigEntry) }); - - current = Object.getPrototypeOf(current) as Function | null; - } - - return configs; + + return constructor; + }; } diff --git a/src/root/decorators/feature-meta.ts b/src/root/decorators/feature-meta.ts new file mode 100644 index 0000000..18cadfa --- /dev/null +++ b/src/root/decorators/feature-meta.ts @@ -0,0 +1,15 @@ +import type { FeatureConfigEntry, FeatureDefinition } from '../types/feature-types.js'; + +export const FEATURE_META = Symbol('litFeatureMeta'); + +export type FeatureMetaEntry = + | { + kind: 'provide'; + name: string; + definition: FeatureDefinition; + } + | { + kind: 'configure'; + name: string; + options: FeatureConfigEntry | 'disable'; + }; diff --git a/src/root/decorators/feature-property.ts b/src/root/decorators/feature-property.ts new file mode 100644 index 0000000..946c4e9 --- /dev/null +++ b/src/root/decorators/feature-property.ts @@ -0,0 +1,59 @@ +import type { PropertyDeclaration } from 'lit'; + +export const FEATURE_PROPERTIES_META = Symbol('featurePropertiesMeta'); + +export interface FeaturePropertyMeta { + [propertyName: string]: PropertyDeclaration; +} + +/** + * Decorator for defining reactive properties on feature classes. + * Works like Lit's @property but stores metadata for later resolution. + * + * @example + * ```typescript + * export class MyFeature extends LitFeature { + * @featureProperty({ type: String, reflect: true }) + * myProp = 'default'; + * } + * ``` + */ +export function property(options: PropertyDeclaration = {}) { + return function (target: any, propertyKey: string) { + // For field decorators, target is the prototype + const ctor = target.constructor; + + // 1. Store metadata for the resolver (used when merging into host properties) + if (!Object.prototype.hasOwnProperty.call(ctor, FEATURE_PROPERTIES_META)) { + Object.defineProperty(ctor, FEATURE_PROPERTIES_META, { + value: {}, + writable: true, + configurable: true, + enumerable: false + }); + } + (ctor as any)[FEATURE_PROPERTIES_META][propertyKey] = options; + + // 2. ALSO add to the feature class's own static properties + // This is needed for _litFeatureInit() to create the property proxy + if (!Object.prototype.hasOwnProperty.call(ctor, 'properties')) { + Object.defineProperty(ctor, 'properties', { + value: {}, + writable: true, + configurable: true, + enumerable: false + }); + } + + ctor.properties[propertyKey] = options; + + console.log(`[featureProperty] Registered "${propertyKey}" on ${ctor.name}:`, options, '\nCtor:', ctor); + }; +} + +/** + * Extract @featureProperty metadata from a feature class + */ +export function getFeaturePropertyMetadata(ctor: any): FeaturePropertyMeta { + return (ctor as any)[FEATURE_PROPERTIES_META] || {}; +} \ No newline at end of file diff --git a/src/root/decorators/index.ts b/src/root/decorators/index.ts index 820b951..1c576ff 100644 --- a/src/root/decorators/index.ts +++ b/src/root/decorators/index.ts @@ -1,8 +1,10 @@ /** * Decorators for the LitFeature system */ -export { provide, getDecoratorProvides, getInheritedDecoratorProvides, PROVIDES_REGISTRY } from './provide.js'; -export type { ProvideDefinition, ProvidesDecorated } from './provide.js'; +export { provide } from './provide.js'; +export type { ProvideDefinition } from './provide.js'; -export { configure, getDecoratorConfigurations, getInheritedDecoratorConfigurations, CONFIGURE_REGISTRY } from './configure.js'; -export type { ConfigureOptions, ConfigureDecorated } from './configure.js'; +export { configure } from './configure.js'; +export type { ConfigureOptions } from './configure.js'; + +export { FEATURE_META } from './feature-meta.js'; diff --git a/src/root/decorators/provide.ts b/src/root/decorators/provide.ts index e29747f..871dffd 100644 --- a/src/root/decorators/provide.ts +++ b/src/root/decorators/provide.ts @@ -1,16 +1,5 @@ -import type { FeatureConfig, FeatureDefinition, ProvidesRegistry } from '../types/feature-types.js'; - -/** - * Symbol used to store decorator-provided features on a class - */ -export const PROVIDES_REGISTRY = Symbol('providesRegistry'); - -/** - * Interface for classes decorated with @provide - */ -export interface ProvidesDecorated { - [PROVIDES_REGISTRY]?: ProvidesRegistry; -} +import type { FeatureConfig, FeatureDefinition } from '../types/feature-types.js'; +import { FEATURE_META, type FeatureMetaEntry } from './feature-meta.js'; /** * Type for the feature definition value passed to @provide decorator @@ -37,54 +26,20 @@ export function provide( definition: FeatureDefinition ) { return function (constructor: T): T { - console.log('[@provide]', 'called for', constructor.name, 'featureName:', featureName); - const decorated = constructor as unknown as ProvidesDecorated; - - // Ensure we have our own registry (not inherited) - if (!Object.prototype.hasOwnProperty.call(constructor, PROVIDES_REGISTRY)) { - // Copy parent registry if it exists - const parent = Object.getPrototypeOf(constructor) as ProvidesDecorated; - decorated[PROVIDES_REGISTRY] = parent[PROVIDES_REGISTRY] - ? { ...parent[PROVIDES_REGISTRY] } - : {}; + const decorated = constructor as unknown as Record; + + if (!Object.prototype.hasOwnProperty.call(constructor, FEATURE_META)) { + decorated[FEATURE_META] = []; } - - decorated[PROVIDES_REGISTRY]![featureName] = definition as unknown as FeatureDefinition; - console.log(`[@provide] ${constructor.name} now provides:`, Object.keys(decorated[PROVIDES_REGISTRY]!)); - - return constructor; - }; -} + decorated[FEATURE_META].push({ + kind: 'provide', + name: featureName, + definition: definition as unknown as FeatureDefinition + }); -/** - * Gets the decorator-provided features registry from a class - */ -export function getDecoratorProvides(constructor: Function): ProvidesRegistry { - const decorated = constructor as ProvidesDecorated; - return decorated[PROVIDES_REGISTRY] || {}; -} + // console.log(`[@provide] Registered feature "${featureName}" on ${constructor.name}:`, definition); -/** - * Collects decorator-provided features from the entire inheritance chain - */ -export function getInheritedDecoratorProvides(constructor: Function): ProvidesRegistry { - const features: ProvidesRegistry = {}; - - let current: Function | null = constructor; - while (current && current.name !== 'LitElement' && current.name !== 'LitCore') { - const provides = getDecoratorProvides(current); - console.log(`[getInheritedDecoratorProvides] ${current.name} provides:`, Object.keys(provides)); - - // Child class features take precedence (don't override if already set) - Object.entries(provides).forEach(([name, definition]) => { - if (!features[name]) { - features[name] = definition; - } - }); - - current = Object.getPrototypeOf(current) as Function | null; - } - - return features; + return constructor; + }; } diff --git a/src/root/feature-resolver.ts b/src/root/feature-resolver.ts new file mode 100644 index 0000000..e030a42 --- /dev/null +++ b/src/root/feature-resolver.ts @@ -0,0 +1,153 @@ +import merge from 'lodash.merge'; +import type { PropertyDeclaration } from 'lit'; +import type { + FeatureConfigEntry, + FeatureSnapshot, + FeaturesRegistry, + ProvidesRegistry, + LitCoreConstructor +} from './types/feature-types.js'; +import { FEATURE_META, type FeatureMetaEntry } from './decorators/feature-meta.js'; +import { getFeaturePropertyMetadata } from './decorators/feature-property.js'; + +const RESOLVED_SNAPSHOT = Symbol('litFeatureResolvedSnapshot'); + +function mergeConfigEntries( + existing: FeatureConfigEntry | 'disable' | undefined, + next: FeatureConfigEntry | 'disable' +): FeatureConfigEntry | 'disable' { + if (next === 'disable') { + return 'disable'; + } + + if (!existing || existing === 'disable') { + return { ...next }; + } + + const mergedConfig = merge({}, existing.config || {}, next.config || {}); + const mergedProps: Record = { + ...(existing.properties || {}) + }; + + Object.entries(next.properties || {}).forEach(([propName, propValue]) => { + if (propValue === 'disable') { + delete mergedProps[propName]; + } else { + mergedProps[propName] = propValue; + } + }); + + return { + config: mergedConfig, + properties: mergedProps + }; +} + +function getInheritanceChain(ctor: LitCoreConstructor): LitCoreConstructor[] { + const chain: LitCoreConstructor[] = []; + let current: LitCoreConstructor | null = ctor; + + while (current && current.name !== 'LitElement') { + chain.unshift(current); + current = Object.getPrototypeOf(current) as LitCoreConstructor | null; + } + + return chain; +} + +export function resolveFeatureSnapshot(ctor: LitCoreConstructor): FeatureSnapshot { + if (Object.prototype.hasOwnProperty.call(ctor, RESOLVED_SNAPSHOT)) { + return (ctor as unknown as Record)[RESOLVED_SNAPSHOT]; + } + + const provides = new Map(); + const configs = new Map(); + + const chain = getInheritanceChain(ctor); + + chain.forEach(current => { + const staticProvides = current.provide || current.provides || {}; + Object.entries(staticProvides).forEach(([name, definition]) => { + provides.set(name, definition); + }); + + const staticConfigs = current.configure || current.features || {}; + Object.entries(staticConfigs).forEach(([name, config]) => { + const nextConfig = config as FeatureConfigEntry | 'disable'; + const merged = mergeConfigEntries(configs.get(name) as FeatureConfigEntry | 'disable' | undefined, nextConfig); + configs.set(name, merged); + }); + + const metaEntries = (current as unknown as Record)[FEATURE_META] || []; + metaEntries.forEach(entry => { + if (entry.kind === 'provide') { + provides.set(entry.name, entry.definition); + return; + } + + const merged = mergeConfigEntries( + configs.get(entry.name) as FeatureConfigEntry | 'disable' | undefined, + entry.options + ); + configs.set(entry.name, merged); + }); + }); + + const resolvedProperties: Record = {}; + + provides.forEach((definition, name) => { + const featureConfig = configs.get(name); + if (featureConfig === 'disable') { + return; + } + + if (definition.enabled === false) { + return; + } + + let mergedProperties: Record = { + ...(definition.class.properties || {}) + }; + + const decoratorMeta = getFeaturePropertyMetadata(definition.class); + console.log(`[FeatureResolver] Decorator metadata for feature "${name}" on ${ctor.name}:`, decoratorMeta); + Object.assign(mergedProperties, decoratorMeta || {}); + + if (featureConfig && typeof featureConfig === 'object' && featureConfig.properties) { + Object.entries(featureConfig.properties).forEach(([propName, propValue]) => { + if (propValue === 'disable') { + delete mergedProperties[propName]; + } else { + mergedProperties[propName] = propValue; + } + }); + } + + console.log(`[FeatureResolver] Merged properties for feature "${name}" on ${ctor.name}:`, mergedProperties); + + Object.assign(resolvedProperties, mergedProperties); + }); + + const providesObject: ProvidesRegistry = {}; + provides.forEach((definition, name) => { + providesObject[name] = definition; + }); + + const configsObject: FeaturesRegistry = {}; + configs.forEach((config, name) => { + configsObject[name] = config; + }); + + const snapshot: FeatureSnapshot = { + properties: Object.freeze(resolvedProperties), + provides: Object.freeze(providesObject), + configs: Object.freeze(configsObject) + }; + + Object.freeze(snapshot); + + (ctor as unknown as Record)[RESOLVED_SNAPSHOT] = snapshot; + ctor._featureSnapshot = snapshot; + + return snapshot; +} diff --git a/src/root/lit-core.ts b/src/root/lit-core.ts index 8da1d7d..a32ff80 100644 --- a/src/root/lit-core.ts +++ b/src/root/lit-core.ts @@ -1,42 +1,6 @@ import { LitElement, PropertyDeclaration } from 'lit'; import { FeatureManager, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from './services/feature-manager.js'; -import { FeatureSnapshot } from './types/feature-types.js'; - -/** - * Global queue for deferred component registration - * This ensures all decorators are applied before any finalization happens - */ -const REGISTRATION_QUEUE: Array<{ ctor: LitCoreConstructor; name: string }> = []; -let REGISTRATION_SCHEDULED = false; - -/** - * Schedule deferred registration processing - */ -function scheduleRegistration() { - if (REGISTRATION_SCHEDULED) return; - REGISTRATION_SCHEDULED = true; - - // Use queueMicrotask to defer until after all current event loop tasks complete - // This gives all decorators time to run - queueMicrotask(() => { - processRegistrationQueue(); - }); -} - -/** - * Process all queued registrations in order - */ -function processRegistrationQueue() { - console.log(`[Registration] Processing ${REGISTRATION_QUEUE.length} queued components`); - - while (REGISTRATION_QUEUE.length > 0) { - const { ctor, name } = REGISTRATION_QUEUE.shift()!; - console.log(`[Registration] Finalizing and registering ${ctor.name} as <${name}>`); - - // Now finalize and register - LitCore._finalizeAndRegister(ctor, name); - } -} +import { resolveFeatureSnapshot } from './feature-resolver.js'; /** * Base class for web components with feature management capabilities. @@ -56,11 +20,6 @@ export class LitCore extends LitElement { */ static properties: Record = {}; - /** - * Immutable snapshot of features for this class. - */ - static _featureSnapshot?: FeatureSnapshot; - constructor() { super(); this.featureManager = new FeatureManager(this, this.constructor as LitCoreConstructor); @@ -68,48 +27,24 @@ export class LitCore extends LitElement { /** * Lit's class finalization hook. - * We override this to defer execution until after all decorators have run. + * We override this to merge feature properties into the class properties map. */ static finalize(): void { const ctor = this as unknown as LitCoreConstructor; - - // Only run if explicitly called via _finalizeAndRegister - // Prevent Lit from auto-finalizing before decorators complete - if (!(ctor as any)._explicitFinalize) { - console.log(`[LitCore.finalize] Skipping auto-finalize for ${ctor.name}, deferring to register()`); - return; - } - - console.log(`[LitCore.finalize] Running explicit finalization for ${ctor.name}`); - - // Ensure parent is finalized first - const parent = Object.getPrototypeOf(ctor) as LitCoreConstructor | null; - if (parent && parent !== LitElement && parent.name !== 'LitElement' && parent.name !== 'LitCore') { - if (!(parent as any)._explicitFinalize) { - (parent as any)._explicitFinalize = true; - parent.finalize(); - } - } - - // Now prepare features for this class - FeatureManager.prepareFeatures(ctor); - - const snapshotProps = ctor._featureSnapshot?.properties ?? {}; - const baseProps = this.hasOwnProperty('properties') ? this.properties : {}; - - const merged: Record = { - ...snapshotProps, - ...baseProps, + const snapshot = resolveFeatureSnapshot(ctor); + const superProps = (Object.getPrototypeOf(this) as typeof LitElement)?.properties || {}; + const ownProps = Object.prototype.hasOwnProperty.call(this, 'properties') ? this.properties : {}; + + (this as unknown as { properties: Record }).properties = { + ...superProps, + ...snapshot.properties, + ...ownProps }; - (this as any).properties = merged; - - console.log(`[LitCore.finalize] ${this.name} feature snapshot properties:`, Object.keys(snapshotProps)); - - // Call Lit's finalization super.finalize(); } + /** * Configuration for features this component wants to use. * Use 'disable' property to explicitly disable an inherited feature. @@ -122,54 +57,15 @@ export class LitCore extends LitElement { */ static provide: ProvidesRegistry; - /** - * Internal method called after all decorators have completed - * Finalizes features and registers the custom element. - * - * This walks the ENTIRE inheritance chain and ensures each class - * has its finalize() called so that getInheritedDecoratorProvides - * and getInheritedDecoratorConfigurations run at EACH level. - */ - private static _finalizeAndRegister(ctor: LitCoreConstructor, name: string): void { - // Collect the full inheritance chain from base to current - const chain: LitCoreConstructor[] = []; - let current: LitCoreConstructor | null = ctor; - - while (current && current.name !== 'LitElement') { - chain.unshift(current); // Add to front (bottom-up) - current = Object.getPrototypeOf(current) as LitCoreConstructor | null; - } - - console.log(`[Registration] Finalization chain for ${ctor.name}:`, chain.map(c => c.name).join(' → ')); - - // Now finalize EACH class in the chain from base to derived - // This ensures getInheritedDecoratorProvides is called at each level - for (const cls of chain) { - if (!cls._featureSnapshot) { - console.log(`[Registration] Calling finalize() on ${cls.name}`); - (cls as any)._explicitFinalize = true; - cls.finalize(); - } - } - - // Register with browser - customElements.define(name, ctor as any); - } - /** * Registers the component as a custom element with the browser. - * Queues the registration to occur after all decorators have completed. + * Also resolves feature metadata for the class. */ static register(componentName: string): void { const ctor = this as unknown as LitCoreConstructor; - console.log(`[Registration] Queuing ${ctor.name} as <${componentName}>`); - - // Add to queue - REGISTRATION_QUEUE.push({ ctor, name: componentName }); - - // Schedule processing of queue - scheduleRegistration(); + resolveFeatureSnapshot(ctor); + customElements.define(componentName, ctor as unknown as CustomElementConstructor); } connectedCallback(): void { diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index d2752d2..3d4f698 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -76,6 +76,8 @@ export class LitFeature implement if (typeof (feature.host as any).requestUpdate === 'function') { (feature.host as any).requestUpdate(propertyName, oldValue); } + + console.log(`[LitFeature] Property [${propertyName}] set on feature [${feature.constructor.name}]:`, newValue); } }); } @@ -134,7 +136,14 @@ export class LitFeature implement updated(changedProperties: Map): void { const hostRecord = this.host as unknown as Record; - console.log(`updated called:`, '\n', `Feature: [${this.constructor.name}]`, '\n', `Host: ${this.host.constructor.name}`, '\n', `Changed Properties:`, changedProperties, '\n', `Instance:`, this.host); + console.log( + `[LitFeature] updated called:`, + '\n', `Feature: [${this.constructor.name}]`, + '\n', `Host: ${this.host.constructor.name}`, + '\n', `Changed Properties:`, changedProperties, + '\n', `Current Host Values:`, Object.fromEntries(Array.from(changedProperties.keys()).map(key => [key, hostRecord[key as string]])), + '\n', `Instance:`, this.host + ); changedProperties.forEach((_oldValue, propertyName) => { this.setInternalValue(propertyName as string, hostRecord[propertyName as string]); diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index dbc745b..b58b854 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -1,10 +1,8 @@ import merge from 'lodash.merge'; -import type { LitFeature, FeatureProperties } from '../lit-feature.js'; +import type { LitFeature } from '../lit-feature.js'; import type { LitCore } from '../lit-core.js'; -import type { PropertyDeclaration } from 'lit'; -import { getInheritedDecoratorProvides, type ProvidesDecorated } from '../decorators/provide.js'; -import { getInheritedDecoratorConfigurations, type ConfigureDecorated } from '../decorators/configure.js'; -import type { FeatureConfig, FeatureSnapshot } from '../types/feature-types.js'; +import { resolveFeatureSnapshot } from '../feature-resolver.js'; +import type { FeatureConfig } from '../types/feature-types.js'; // Re-export types from feature-types for backward compatibility export type { @@ -17,14 +15,7 @@ export type { } from '../types/feature-types.js'; // Import types for local use -import type { - FeatureClass, - FeatureDefinition, - FeatureConfigEntry, - ProvidesRegistry, - FeaturesRegistry, - LitCoreConstructor -} from '../types/feature-types.js'; +import type { FeatureConfigEntry, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from '../types/feature-types.js'; /** * Compositional service responsible for managing features and their lifecycle hooks @@ -47,31 +38,7 @@ export class FeatureManager { * Includes both static provide() definitions and @provide decorated fields. */ static getInheritedProvides(constructor: LitCoreConstructor): ProvidesRegistry { - const features: ProvidesRegistry = {}; - - // First, collect decorator-provided features - const decoratorProvides = getInheritedDecoratorProvides(constructor as unknown as Function & ProvidesDecorated); - Object.entries(decoratorProvides).forEach(([name, definition]) => { - features[name] = definition; - }); - - // Then collect static provide/provides (these take precedence over decorator provides) - let current: LitCoreConstructor | null = constructor; - while (current && current.name !== 'LitElement') { - const provides = current.provide || current.provides || {}; - - Object.entries(provides).forEach(([name, definition]) => { - if (!features[name]) { - features[name] = definition; - } - }); - - current = Object.getPrototypeOf(current) as LitCoreConstructor | null; - } - - console.log(features); - - return features; + return resolveFeatureSnapshot(constructor).provides; } /** @@ -79,53 +46,7 @@ export class FeatureManager { * Includes both static configure() definitions and @configure decorated configurations. */ static getInheritedConfigs(constructor: LitCoreConstructor): FeaturesRegistry { - const configs: FeaturesRegistry = {}; - - // First, collect decorator-configured features - const decoratorConfigurations = getInheritedDecoratorConfigurations(constructor as unknown as Function & ConfigureDecorated); - Object.entries(decoratorConfigurations).forEach(([name, config]) => { - configs[name] = config; - }); - - // Then collect static configure/features (merge with decorator configs) - let current: LitCoreConstructor | null = constructor; - while (current && current.name !== 'LitElement') { - const features = current.configure || current.features || {}; - - Object.entries(features).forEach(([name, config]) => { - if (!configs[name]) { - configs[name] = config; - } else if (config === 'disable') { - configs[name] = 'disable'; - } else if (configs[name] !== 'disable') { - const existingConfig = configs[name] as FeatureConfigEntry; - const prevConfig = existingConfig.config || {}; - const nextConfig = (config as FeatureConfigEntry).config || {}; - const prevProps = existingConfig.properties || {}; - const nextProps = (config as FeatureConfigEntry).properties || {}; - - // Merge properties, with 'disable' support - const mergedProps: Record = { ...prevProps }; - Object.entries(nextProps).forEach(([propName, propValue]) => { - if (propValue === 'disable') { - delete mergedProps[propName]; - } else { - mergedProps[propName] = propValue; - } - }); - - configs[name] = { - ...existingConfig, - config: merge({}, prevConfig, nextConfig), - properties: mergedProps - }; - } - }); - - current = Object.getPrototypeOf(current) as LitCoreConstructor | null; - } - - return configs; + return resolveFeatureSnapshot(constructor).configs; } /** @@ -133,74 +54,7 @@ export class FeatureManager { * This needs to be called before the element is registered. */ static prepareFeatures(constructor: LitCoreConstructor): void { - if (constructor._featureSnapshot) { - return; // already computed for this class - } - - // Remove the recursive call - finalize() will handle the chain - const parent = Object.getPrototypeOf(constructor) as LitCoreConstructor | null; - - const parentSnapshot: FeatureSnapshot = parent?._featureSnapshot - ? { - properties: { ...parent._featureSnapshot.properties }, - provides: { ...parent._featureSnapshot.provides }, - configs: { ...parent._featureSnapshot.configs } - } - : { - properties: {}, - provides: {}, - configs: {} - }; - - const localProvides = this.getInheritedProvides(constructor); - const localConfigs = this.getInheritedConfigs(constructor); - - const resolvedProperties = { ...parentSnapshot.properties }; - - Object.entries(localProvides).forEach(([featureName, featureDef]) => { - const featureConfig = localConfigs[featureName]; - - if (featureConfig === 'disable') { - return; - } - - const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; - if (!enabled) { - return; - } - - const finalConfig = !featureConfig - ? defaultConfig - : merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}); - - let mergedProperties: Record = { - ...(FeatureClass.properties || {}) - }; - - if (featureConfig && typeof featureConfig === 'object' && featureConfig.properties) { - Object.entries(featureConfig.properties).forEach(([propName, propValue]) => { - if (propValue === 'disable') { - delete mergedProperties[propName]; - } else { - mergedProperties[propName] = propValue; - } - }); - } - - Object.entries(mergedProperties).forEach(([propName, propConfig]) => { - resolvedProperties[propName] = propConfig; - }); - }); - - const snapshot: FeatureSnapshot = { - properties: Object.freeze(resolvedProperties), - provides: Object.freeze(localProvides), - configs: Object.freeze(localConfigs) - }; - - Object.freeze(snapshot); - - constructor._featureSnapshot = snapshot; + resolveFeatureSnapshot(constructor); } @@ -209,8 +63,10 @@ export class FeatureManager { * Initialize all features that this component has opted into */ private _initializeFeatures(): void { - const snapshot = this.hostConstructor._featureSnapshot; - if (!snapshot) return; + const snapshot = this.hostConstructor._featureSnapshot || resolveFeatureSnapshot(this.hostConstructor); + if (!snapshot) { + return; + } const { provides, configs } = snapshot; @@ -221,16 +77,23 @@ export class FeatureManager { const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; if (!enabled) return; - const finalConfig = !featureConfig - ? defaultConfig - : merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}); + const finalConfig = featureConfig + ? merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}) + : defaultConfig; const featureInstance = new FeatureClass(this.host, finalConfig as FeatureConfig); this._featureInstances.set(featureName, featureInstance); - const hostRecord = this.host as Record; - hostRecord[featureName] = featureInstance; + const hostRecord = this.host as unknown as Record; + if (hostRecord.hasOwnProperty(featureName)) { + console.warn(`[Lit Feature] Host already has a property named "${featureName}". This may cause conflicts with the feature instance. +Features should not declare properties with names matching those in the host component. Please rename the feature or host property to avoid this conflict. +Feature will be assigned to _${featureName} to avoid overwriting the host property. It is not recommended to leave this conflict unresolved, as it may lead to unexpected behavior.`); + hostRecord[`_${featureName}`] = featureInstance; + } else { + hostRecord[featureName] = featureInstance; + } }); } From 4e2ee1d95dee8faffc7c8a18b11b89147231ef27 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 02:42:17 -0600 Subject: [PATCH 03/14] refactor: unify feature metadata handling and improve configuration resolution --- src/root/decorators/configure.ts | 18 +-- src/root/decorators/feature-meta.ts | 31 ++++- src/root/decorators/feature-property.ts | 40 +++--- src/root/decorators/provide.ts | 15 +-- src/root/feature-resolver.ts | 163 ++++++++++++++++-------- src/root/lit-core.ts | 25 +++- src/root/services/feature-manager.ts | 87 ++++--------- src/root/types/feature-types.ts | 57 ++++----- src/root/types/index.ts | 4 +- 9 files changed, 240 insertions(+), 200 deletions(-) diff --git a/src/root/decorators/configure.ts b/src/root/decorators/configure.ts index cc7608f..1fe3760 100644 --- a/src/root/decorators/configure.ts +++ b/src/root/decorators/configure.ts @@ -1,6 +1,6 @@ import type { FeatureConfig, FeatureConfigEntry } from '../types/feature-types.js'; import type { PropertyDeclaration } from 'lit'; -import { FEATURE_META, type FeatureMetaEntry } from './feature-meta.js'; +import { getOrCreateFeatureMeta } from './feature-meta.js'; /** * Configuration options for the @configure decorator @@ -40,17 +40,11 @@ export function configure( options: ConfigureOptions | 'disable' ) { return function (constructor: T): T { - const decorated = constructor as unknown as Record; - - if (!Object.prototype.hasOwnProperty.call(constructor, FEATURE_META)) { - decorated[FEATURE_META] = []; - } - - decorated[FEATURE_META].push({ - kind: 'configure', - name: featureName, - options: options === 'disable' ? 'disable' : (options as FeatureConfigEntry) - }); + const meta = getOrCreateFeatureMeta(constructor); + meta.configure!.set( + featureName, + options === 'disable' ? 'disable' : (options as FeatureConfigEntry) + ); return constructor; }; diff --git a/src/root/decorators/feature-meta.ts b/src/root/decorators/feature-meta.ts index 18cadfa..fb510db 100644 --- a/src/root/decorators/feature-meta.ts +++ b/src/root/decorators/feature-meta.ts @@ -1,7 +1,36 @@ -import type { FeatureConfigEntry, FeatureDefinition } from '../types/feature-types.js'; +import type { PropertyDeclaration } from 'lit'; +import type { FeatureConfigEntry, FeatureDefinition, FeatureMeta } from '../types/feature-types.js'; +/** + * Unified metadata symbol for all feature-related metadata. + * This single symbol holds provide, configure, and featureProperties. + */ export const FEATURE_META = Symbol('litFeatureMeta'); +/** + * Get or initialize the unified FeatureMeta for a constructor + */ +export function getOrCreateFeatureMeta(ctor: any): FeatureMeta { + if (!Object.prototype.hasOwnProperty.call(ctor, FEATURE_META)) { + Object.defineProperty(ctor, FEATURE_META, { + value: { + provide: new Map(), + configure: new Map(), + featureProperties: new Map() + }, + writable: false, + configurable: true, + enumerable: false + }); + } + return (ctor as any)[FEATURE_META]; +} + +// ============================================================================ +// Deprecated: Old array-based metadata (for backward compatibility) +// ============================================================================ + +/** @deprecated - Use unified FeatureMeta instead */ export type FeatureMetaEntry = | { kind: 'provide'; diff --git a/src/root/decorators/feature-property.ts b/src/root/decorators/feature-property.ts index 946c4e9..33af7de 100644 --- a/src/root/decorators/feature-property.ts +++ b/src/root/decorators/feature-property.ts @@ -1,14 +1,9 @@ import type { PropertyDeclaration } from 'lit'; - -export const FEATURE_PROPERTIES_META = Symbol('featurePropertiesMeta'); - -export interface FeaturePropertyMeta { - [propertyName: string]: PropertyDeclaration; -} +import { FEATURE_META, getOrCreateFeatureMeta } from './feature-meta.js'; /** * Decorator for defining reactive properties on feature classes. - * Works like Lit's @property but stores metadata for later resolution. + * Works like Lit's @property but stores metadata in the unified FEATURE_META symbol. * * @example * ```typescript @@ -23,16 +18,9 @@ export function property(options: PropertyDeclaration = {}) { // For field decorators, target is the prototype const ctor = target.constructor; - // 1. Store metadata for the resolver (used when merging into host properties) - if (!Object.prototype.hasOwnProperty.call(ctor, FEATURE_PROPERTIES_META)) { - Object.defineProperty(ctor, FEATURE_PROPERTIES_META, { - value: {}, - writable: true, - configurable: true, - enumerable: false - }); - } - (ctor as any)[FEATURE_PROPERTIES_META][propertyKey] = options; + // 1. Store in unified FEATURE_META for resolver + const meta = getOrCreateFeatureMeta(ctor); + meta.featureProperties!.set(propertyKey, options); // 2. ALSO add to the feature class's own static properties // This is needed for _litFeatureInit() to create the property proxy @@ -54,6 +42,18 @@ export function property(options: PropertyDeclaration = {}) { /** * Extract @featureProperty metadata from a feature class */ -export function getFeaturePropertyMetadata(ctor: any): FeaturePropertyMeta { - return (ctor as any)[FEATURE_PROPERTIES_META] || {}; -} \ No newline at end of file +export function getFeaturePropertyMetadata(ctor: any): Record { + const meta = (ctor as any)[FEATURE_META]; + if (!meta || !meta.featureProperties) { + return {}; + } + + const result: Record = {}; + meta.featureProperties.forEach((value: PropertyDeclaration, key: string) => { + result[key] = value; + }); + return result; +} + +/** @deprecated Use FEATURE_META instead */ +export const FEATURE_PROPERTIES_META = Symbol('featurePropertiesMeta'); \ No newline at end of file diff --git a/src/root/decorators/provide.ts b/src/root/decorators/provide.ts index 871dffd..6f368fe 100644 --- a/src/root/decorators/provide.ts +++ b/src/root/decorators/provide.ts @@ -1,5 +1,5 @@ import type { FeatureConfig, FeatureDefinition } from '../types/feature-types.js'; -import { FEATURE_META, type FeatureMetaEntry } from './feature-meta.js'; +import { getOrCreateFeatureMeta } from './feature-meta.js'; /** * Type for the feature definition value passed to @provide decorator @@ -26,17 +26,8 @@ export function provide( definition: FeatureDefinition ) { return function (constructor: T): T { - const decorated = constructor as unknown as Record; - - if (!Object.prototype.hasOwnProperty.call(constructor, FEATURE_META)) { - decorated[FEATURE_META] = []; - } - - decorated[FEATURE_META].push({ - kind: 'provide', - name: featureName, - definition: definition as unknown as FeatureDefinition - }); + const meta = getOrCreateFeatureMeta(constructor); + meta.provide!.set(featureName, definition as unknown as FeatureDefinition); // console.log(`[@provide] Registered feature "${featureName}" on ${constructor.name}:`, definition); diff --git a/src/root/feature-resolver.ts b/src/root/feature-resolver.ts index e030a42..48534dd 100644 --- a/src/root/feature-resolver.ts +++ b/src/root/feature-resolver.ts @@ -2,16 +2,50 @@ import merge from 'lodash.merge'; import type { PropertyDeclaration } from 'lit'; import type { FeatureConfigEntry, - FeatureSnapshot, - FeaturesRegistry, - ProvidesRegistry, - LitCoreConstructor + FeatureDefinition, + FeatureMeta, + ResolvedFeatures, + LitCoreConstructor, + FeatureConfig } from './types/feature-types.js'; -import { FEATURE_META, type FeatureMetaEntry } from './decorators/feature-meta.js'; +import type { LitFeature } from './lit-feature.js'; +import { LIT_CORE_MARKER } from './lit-core.js'; +import { FEATURE_META } from './decorators/feature-meta.js'; import { getFeaturePropertyMetadata } from './decorators/feature-property.js'; -const RESOLVED_SNAPSHOT = Symbol('litFeatureResolvedSnapshot'); +// ============================================================================ +// Pure Function Module: Feature Resolution +// ============================================================================ +// This module contains a single exported function that takes a constructor +// and returns the fully resolved feature state. All inheritance logic lives here. +// ============================================================================ +const RESOLVED_CACHE = Symbol('litFeatureResolvedCache'); + +// ---------------------------------------------------------------------------- +// Private Helpers +// ---------------------------------------------------------------------------- + +/** + * Get the full inheritance chain from LitCore up to the given constructor. + * Stops when it encounters a class without the LIT_CORE_MARKER. + */ +function getInheritanceChain(ctor: LitCoreConstructor): LitCoreConstructor[] { + const chain: LitCoreConstructor[] = []; + let current: LitCoreConstructor | null = ctor; + + // Walk up the prototype chain until we find a class without the marker + while (current && (current as any)[LIT_CORE_MARKER]) { + chain.unshift(current); + current = Object.getPrototypeOf(current) as LitCoreConstructor | null; + } + + return chain; +} + +/** + * Merge configuration entries with override semantics + */ function mergeConfigEntries( existing: FeatureConfigEntry | 'disable' | undefined, next: FeatureConfigEntry | 'disable' @@ -43,76 +77,92 @@ function mergeConfigEntries( }; } -function getInheritanceChain(ctor: LitCoreConstructor): LitCoreConstructor[] { - const chain: LitCoreConstructor[] = []; - let current: LitCoreConstructor | null = ctor; - - while (current && current.name !== 'LitElement') { - chain.unshift(current); - current = Object.getPrototypeOf(current) as LitCoreConstructor | null; - } - - return chain; -} - -export function resolveFeatureSnapshot(ctor: LitCoreConstructor): FeatureSnapshot { - if (Object.prototype.hasOwnProperty.call(ctor, RESOLVED_SNAPSHOT)) { - return (ctor as unknown as Record)[RESOLVED_SNAPSHOT]; +// ---------------------------------------------------------------------------- +// Main Export: Pure Resolution Function +// ---------------------------------------------------------------------------- + +/** + * Resolve all features for a component constructor. + * + * This function: + * 1. Walks the inheritance chain + * 2. Collects provides and configs from static getters and decorators + * 3. Merges configurations with proper override semantics + * 4. Returns final resolved state ready for instantiation + * + * @param ctor - The component constructor + * @returns Resolved features with properties and feature definitions + */ +export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { + // Check cache first + if (Object.prototype.hasOwnProperty.call(ctor, RESOLVED_CACHE)) { + return (ctor as unknown as Record)[RESOLVED_CACHE]; } - const provides = new Map(); - const configs = new Map(); + // Collect provides and configs from inheritance chain + const provides = new Map(); + const configs = new Map(); const chain = getInheritanceChain(ctor); chain.forEach(current => { + // Collect from static getters (provide/provides) const staticProvides = current.provide || current.provides || {}; Object.entries(staticProvides).forEach(([name, definition]) => { provides.set(name, definition); }); + // Collect from static getters (configure/features) const staticConfigs = current.configure || current.features || {}; Object.entries(staticConfigs).forEach(([name, config]) => { const nextConfig = config as FeatureConfigEntry | 'disable'; - const merged = mergeConfigEntries(configs.get(name) as FeatureConfigEntry | 'disable' | undefined, nextConfig); + const merged = mergeConfigEntries(configs.get(name), nextConfig); configs.set(name, merged); }); - const metaEntries = (current as unknown as Record)[FEATURE_META] || []; - metaEntries.forEach(entry => { - if (entry.kind === 'provide') { - provides.set(entry.name, entry.definition); - return; + // Collect from decorators using unified FEATURE_META + const meta = (current as unknown as Record)[FEATURE_META]; + if (meta) { + // Process provides from decorator metadata + if (meta.provide) { + meta.provide.forEach((definition, name) => { + provides.set(name, definition); + }); } - const merged = mergeConfigEntries( - configs.get(entry.name) as FeatureConfigEntry | 'disable' | undefined, - entry.options - ); - configs.set(entry.name, merged); - }); + // Process configs from decorator metadata + if (meta.configure) { + meta.configure.forEach((config, name) => { + const merged = mergeConfigEntries(configs.get(name), config); + configs.set(name, merged); + }); + } + } }); + // Build final resolved state const resolvedProperties: Record = {}; + const resolvedFeatures = new Map(); provides.forEach((definition, name) => { const featureConfig = configs.get(name); - if (featureConfig === 'disable') { - return; - } - if (definition.enabled === false) { + // Skip if disabled + if (featureConfig === 'disable' || definition.enabled === false) { return; } + // Merge feature properties let mergedProperties: Record = { ...(definition.class.properties || {}) }; + // Include decorator properties const decoratorMeta = getFeaturePropertyMetadata(definition.class); - console.log(`[FeatureResolver] Decorator metadata for feature "${name}" on ${ctor.name}:`, decoratorMeta); + console.log(`[resolveFeatures] Decorator metadata for feature "${name}" on ${ctor.name}:`, decoratorMeta); Object.assign(mergedProperties, decoratorMeta || {}); + // Apply property overrides from config if (featureConfig && typeof featureConfig === 'object' && featureConfig.properties) { Object.entries(featureConfig.properties).forEach(([propName, propValue]) => { if (propValue === 'disable') { @@ -123,31 +173,32 @@ export function resolveFeatureSnapshot(ctor: LitCoreConstructor): FeatureSnapsho }); } - console.log(`[FeatureResolver] Merged properties for feature "${name}" on ${ctor.name}:`, mergedProperties); + console.log(`[resolveFeatures] Merged properties for feature "${name}" on ${ctor.name}:`, mergedProperties); + // Add to resolved properties Object.assign(resolvedProperties, mergedProperties); - }); - const providesObject: ProvidesRegistry = {}; - provides.forEach((definition, name) => { - providesObject[name] = definition; - }); + // Compute final config + const finalConfig = featureConfig && typeof featureConfig === 'object' + ? merge({}, definition.config || {}, featureConfig.config || {}) + : (definition.config || {}); - const configsObject: FeaturesRegistry = {}; - configs.forEach((config, name) => { - configsObject[name] = config; + // Add to resolved features + resolvedFeatures.set(name, { + class: definition.class as typeof LitFeature, + config: finalConfig + }); }); - const snapshot: FeatureSnapshot = { + const resolved: ResolvedFeatures = { properties: Object.freeze(resolvedProperties), - provides: Object.freeze(providesObject), - configs: Object.freeze(configsObject) + features: resolvedFeatures }; - Object.freeze(snapshot); + Object.freeze(resolved); - (ctor as unknown as Record)[RESOLVED_SNAPSHOT] = snapshot; - ctor._featureSnapshot = snapshot; + // Cache and return + (ctor as unknown as Record)[RESOLVED_CACHE] = resolved; - return snapshot; + return resolved; } diff --git a/src/root/lit-core.ts b/src/root/lit-core.ts index a32ff80..c1e6f15 100644 --- a/src/root/lit-core.ts +++ b/src/root/lit-core.ts @@ -1,6 +1,12 @@ import { LitElement, PropertyDeclaration } from 'lit'; -import { FeatureManager, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from './services/feature-manager.js'; -import { resolveFeatureSnapshot } from './feature-resolver.js'; +import { FeatureConfigEntry, FeatureDefinition, FeatureManager, LitCoreConstructor } from './services/feature-manager.js'; +import { resolveFeatures } from './feature-resolver.js'; + +/** + * Symbol to mark classes that extend LitCore. + * Used by the resolver to detect when to stop walking the inheritance chain. + */ +export const LIT_CORE_MARKER = Symbol('litCore'); /** * Base class for web components with feature management capabilities. @@ -14,6 +20,11 @@ export class LitCore extends LitElement { */ featureManager: FeatureManager; + /** + * Marker to identify LitCore-based classes + */ + static readonly [LIT_CORE_MARKER] = true; + /** * Lit reactive properties for this class. * We keep this in sync with feature snapshots in `finalize()`. @@ -31,13 +42,13 @@ export class LitCore extends LitElement { */ static finalize(): void { const ctor = this as unknown as LitCoreConstructor; - const snapshot = resolveFeatureSnapshot(ctor); + const resolved = resolveFeatures(ctor); const superProps = (Object.getPrototypeOf(this) as typeof LitElement)?.properties || {}; const ownProps = Object.prototype.hasOwnProperty.call(this, 'properties') ? this.properties : {}; (this as unknown as { properties: Record }).properties = { ...superProps, - ...snapshot.properties, + ...resolved.properties, ...ownProps }; @@ -49,13 +60,13 @@ export class LitCore extends LitElement { * Configuration for features this component wants to use. * Use 'disable' property to explicitly disable an inherited feature. */ - static configure: FeaturesRegistry; + static configure: Record; /** * Registry of features provided by this component/class. * Each feature should include a class reference and optional default configuration. */ - static provide: ProvidesRegistry; + static provide: Record; /** * Registers the component as a custom element with the browser. @@ -64,7 +75,7 @@ export class LitCore extends LitElement { static register(componentName: string): void { const ctor = this as unknown as LitCoreConstructor; - resolveFeatureSnapshot(ctor); + resolveFeatures(ctor); customElements.define(componentName, ctor as unknown as CustomElementConstructor); } diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index b58b854..6d524d3 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -1,25 +1,28 @@ import merge from 'lodash.merge'; import type { LitFeature } from '../lit-feature.js'; import type { LitCore } from '../lit-core.js'; -import { resolveFeatureSnapshot } from '../feature-resolver.js'; -import type { FeatureConfig } from '../types/feature-types.js'; +import { resolveFeatures } from '../feature-resolver.js'; +import type { FeatureConfig, LitCoreConstructor, ResolvedFeatures } from '../types/feature-types.js'; -// Re-export types from feature-types for backward compatibility +// Re-export types for backward compatibility (deprecated) +/** @deprecated Import from types/feature-types.js instead */ export type { FeatureClass, FeatureDefinition, FeatureConfigEntry, - ProvidesRegistry, - FeaturesRegistry, LitCoreConstructor } from '../types/feature-types.js'; -// Import types for local use -import type { FeatureConfigEntry, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from '../types/feature-types.js'; - /** - * Compositional service responsible for managing features and their lifecycle hooks - * and connecting them to the host component. + * Compositional service responsible for managing feature instances and lifecycle. + * + * FeatureManager is an instance-only concern. It: + * 1. Resolves features using resolveFeatures() + * 2. Instantiates feature instances + * 3. Attaches them to the host + * 4. Dispatches lifecycle methods + * + * All class-level concerns (inheritance, metadata merging) live in the resolver. */ export class FeatureManager { host: LitCore; @@ -30,66 +33,29 @@ export class FeatureManager { this.host = host; this.hostConstructor = constructor; this._featureInstances = new Map(); - this._initializeFeatures(); - } - - /** - * Collects features (`static get provide`) from the entire inheritance chain. - * Includes both static provide() definitions and @provide decorated fields. - */ - static getInheritedProvides(constructor: LitCoreConstructor): ProvidesRegistry { - return resolveFeatureSnapshot(constructor).provides; + + // Resolve and initialize features + const resolved = resolveFeatures(constructor); + this._initializeFeatures(resolved); } /** - * Collects feature configurations (`static get configure`) from the inheritance chain. - * Includes both static configure() definitions and @configure decorated configurations. + * Initialize all features from resolved state */ - static getInheritedConfigs(constructor: LitCoreConstructor): FeaturesRegistry { - return resolveFeatureSnapshot(constructor).configs; - } - - /** - * Initialize features and collect their properties. - * This needs to be called before the element is registered. - */ - static prepareFeatures(constructor: LitCoreConstructor): void { - resolveFeatureSnapshot(constructor); - } - - - - /** - * Initialize all features that this component has opted into - */ - private _initializeFeatures(): void { - const snapshot = this.hostConstructor._featureSnapshot || resolveFeatureSnapshot(this.hostConstructor); - if (!snapshot) { - return; - } - - const { provides, configs } = snapshot; - - Object.entries(provides).forEach(([featureName, featureDef]) => { - const featureConfig = configs[featureName]; - if (featureConfig === 'disable') return; - - const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; - if (!enabled) return; - - const finalConfig = featureConfig - ? merge({}, defaultConfig, (featureConfig as FeatureConfigEntry).config || {}) - : defaultConfig; - - const featureInstance = new FeatureClass(this.host, finalConfig as FeatureConfig); + private _initializeFeatures(resolved: ResolvedFeatures): void { + resolved.features.forEach((feature, featureName) => { + const featureInstance = new (feature.class as any)(this.host, feature.config); this._featureInstances.set(featureName, featureInstance); + // Attach to host const hostRecord = this.host as unknown as Record; if (hostRecord.hasOwnProperty(featureName)) { - console.warn(`[Lit Feature] Host already has a property named "${featureName}". This may cause conflicts with the feature instance. + console.warn( + `[Lit Feature] Host already has a property named "${featureName}". This may cause conflicts with the feature instance. Features should not declare properties with names matching those in the host component. Please rename the feature or host property to avoid this conflict. -Feature will be assigned to _${featureName} to avoid overwriting the host property. It is not recommended to leave this conflict unresolved, as it may lead to unexpected behavior.`); +Feature will be assigned to _${featureName} to avoid overwriting the host property. It is not recommended to leave this conflict unresolved, as it may lead to unexpected behavior.` + ); hostRecord[`_${featureName}`] = featureInstance; } else { hostRecord[featureName] = featureInstance; @@ -97,7 +63,6 @@ Feature will be assigned to _${featureName} to avoid overwriting the host proper }); } - /** * Process lifecycle method for all registered features */ diff --git a/src/root/types/feature-types.ts b/src/root/types/feature-types.ts index d870bc4..0a4a961 100644 --- a/src/root/types/feature-types.ts +++ b/src/root/types/feature-types.ts @@ -11,7 +11,7 @@ export interface FeatureClass { } /** - * Feature definition as provided in `static get provide()` + * Feature definition as provided in `static get provide()` or decorators */ export interface FeatureDefinition { class: FeatureClass; @@ -20,44 +20,43 @@ export interface FeatureDefinition; } -/** - * Registry of provided features - */ -export interface ProvidesRegistry { - [featureName: string]: FeatureDefinition; -} +// ============================================================================ +// CORE CONCEPT #1: Class-Level Metadata (raw decorators) +// ============================================================================ /** - * Registry of feature configurations + * Class-level metadata attached to components via decorators or static getters. + * This is the raw, unresolved state before inheritance merging. */ -export interface FeaturesRegistry { - [featureName: string]: FeatureConfigEntry | 'disable'; +export interface FeatureMeta { + provide?: Map; + configure?: Map; + featureProperties?: Map; } -/** - * Resolved feature instance with final configuration - */ -export interface ResolvedFeature { - name: string; - definition: FeatureDefinition; - config: FeatureConfig; - properties: Record; -} +// ============================================================================ +// CORE CONCEPT #2: Resolved Snapshot (final merged state) +// ============================================================================ /** - * Immutable feature snapshot for a specific class + * Final resolved state after merging inheritance chain. + * This is the only thing FeatureManager needs. */ -export interface FeatureSnapshot { +export interface ResolvedFeatures { + /** All properties from all enabled features */ properties: Record; - provides: ProvidesRegistry; - configs: FeaturesRegistry; + /** All enabled features with their final config */ + features: Map; } /** @@ -66,14 +65,14 @@ export interface FeatureSnapshot { export interface LitCoreConstructor { new (): LitCore; name: string; - provide?: ProvidesRegistry; - configure?: FeaturesRegistry; + provide?: Record; + configure?: Record; /** @deprecated Use `provide` instead */ - provides?: ProvidesRegistry; + provides?: Record; /** @deprecated Use `configure` instead */ - features?: FeaturesRegistry; + features?: Record; properties?: Record; - _featureSnapshot?: FeatureSnapshot; + _resolvedFeatures?: ResolvedFeatures; _resolvedProperties?: Record; } diff --git a/src/root/types/index.ts b/src/root/types/index.ts index 439b43a..aef4da1 100644 --- a/src/root/types/index.ts +++ b/src/root/types/index.ts @@ -5,8 +5,8 @@ export type { FeatureClass, FeatureDefinition, FeatureConfigEntry, - ProvidesRegistry, - FeaturesRegistry, + FeatureMeta, + ResolvedFeatures, LitCoreConstructor, FeatureConfig } from './feature-types.js'; From 40620698ec0cc5375869099eca01d70ffd2c250a Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 03:20:53 -0600 Subject: [PATCH 04/14] refactor: enhance feature property decorator to support inherited properties --- PROPOSAL.md | 51 +++++++++++++++++++++++++ src/root/decorators/feature-property.ts | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/PROPOSAL.md b/PROPOSAL.md index 29a44d1..dcc6b92 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -23,6 +23,7 @@ A **Feature** is a specialized `ReactiveController` that: 2. **Declares reactive properties** - properties that merge into the host's property system 3. **Encapsulates single-responsibility behavior** - dismissal, timing, status management, etc. 4. **Is inheritable and configurable** - subclasses can reconfigure or disable features +5. **Supports standard JavaScript class inheritance** - features can extend other feature classes or base classes, inheriting properties and methods for more granular control ### Key Principles @@ -31,9 +32,59 @@ A **Feature** is a specialized `ReactiveController` that: 3. **Property Integration**: Feature properties become host properties automatically 4. **Single Responsibility**: Each feature manages one concern (status, visibility, timer, dismissal) 5. **Inter-Feature Communication**: Features can discover and communicate with other features on the same host +6. **Class-Based Extensibility**: Features leverage standard JavaScript class inheritance, allowing custom feature subclasses with specialized properties and methods for more granular control + +### Feature Class Inheritance + +Features support standard JavaScript class inheritance, enabling developers to create feature subclasses that extend base features with specialized properties and methods. This provides another vector for fine-grained control while maintaining all the benefits of the feature system. + +**Example: Extending StatusFeature** + +```typescript +// Base feature in library +export class StatusFeature extends LitFeature { + @property({ type: String, reflect: true }) + status: StatusType = 'info'; + + getStatusIcon(): string { /* ... */ } + getStatusColor(): string { /* ... */ } +} + +// Custom subclass in application code +export class ExtendedStatusFeature extends StatusFeature { + @property({ type: Boolean }) + showStatusLabel: boolean = false; + + // Extended method with custom behavior + getStatusIcon(): string { + const baseIcon = super.getStatusIcon(); + return this.showStatusLabel ? `${baseIcon} ${this.status}` : baseIcon; + } + + // New method specific to extended feature + getStatusHeight(): number { + return this.showStatusLabel ? 'large' : 'small'; + } +} + +// Use custom feature in component +@provide('Status', { class: ExtendedStatusFeature }) +class CustomMessageBox extends LitCore { + declare Status: ExtendedStatusFeature; + declare status: string; + declare showStatusLabel: boolean; +} +``` + +**Benefits of Feature Inheritance:** +- **Composition over duplication** - Subclass features reuse base feature logic rather than duplicating it +- **Specialized variants** - Create custom feature variants for specific use cases without modifying the base feature +- **Gradual enhancement** - Layer functionality by extending existing features incrementally +- **Maintains integration** - Feature properties and lifecycle management work seamlessly in subclasses ## Features in the Codebase + This POC implements four example features that demonstrate the pattern: ### 1. StatusFeature diff --git a/src/root/decorators/feature-property.ts b/src/root/decorators/feature-property.ts index 33af7de..91ee681 100644 --- a/src/root/decorators/feature-property.ts +++ b/src/root/decorators/feature-property.ts @@ -24,9 +24,10 @@ export function property(options: PropertyDeclaration = {}) { // 2. ALSO add to the feature class's own static properties // This is needed for _litFeatureInit() to create the property proxy + const parentProperties = ctor.properties || {}; // Get inherited or empty if (!Object.prototype.hasOwnProperty.call(ctor, 'properties')) { Object.defineProperty(ctor, 'properties', { - value: {}, + value: {...parentProperties}, writable: true, configurable: true, enumerable: false From 6e298c9ae46750a4919c1ee5e78f75483ad14748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephen=20Rios=20=F0=9F=A7=B8?= Date: Thu, 19 Feb 2026 10:53:22 -0600 Subject: [PATCH 05/14] Update src/components/toast-notification.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/toast-notification.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/toast-notification.ts b/src/components/toast-notification.ts index 50bcd59..743d1f1 100644 --- a/src/components/toast-notification.ts +++ b/src/components/toast-notification.ts @@ -211,7 +211,7 @@ export class ToastNotification extends AlertBox { } const statusClass = `status-${this.status || 'info'}`; - const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; + const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; const progressWidth = `${this._progressPercent}%`; return html` From 8c01c9f8cc504d94b0e4c5e93e3a3e7ddc97f253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephen=20Rios=20=F0=9F=A7=B8?= Date: Thu, 19 Feb 2026 10:54:25 -0600 Subject: [PATCH 06/14] Update PROPOSAL.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PROPOSAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PROPOSAL.md b/PROPOSAL.md index dcc6b92..d2f36ac 100644 --- a/PROPOSAL.md +++ b/PROPOSAL.md @@ -62,7 +62,7 @@ export class ExtendedStatusFeature extends StatusFeature { } // New method specific to extended feature - getStatusHeight(): number { + getStatusHeight(): 'large' | 'small' { return this.showStatusLabel ? 'large' : 'small'; } } From 4c9adb6591ea8e2c795bdc0c66c3c943ae6e1b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephen=20Rios=20=F0=9F=A7=B8?= Date: Thu, 19 Feb 2026 10:54:53 -0600 Subject: [PATCH 07/14] Update src/components/message-box.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/message-box.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/message-box.ts b/src/components/message-box.ts index af85b3f..afd62ff 100644 --- a/src/components/message-box.ts +++ b/src/components/message-box.ts @@ -102,7 +102,7 @@ export class MessageBox extends MessageBase { } const statusClass = `status-${this.status || 'info'}`; - const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; + const transitionStyle = this.Visibility?.visible ? this.Visibility.getTransitionStyles() || '' : ''; return html`
Date: Thu, 19 Feb 2026 11:33:59 -0600 Subject: [PATCH 08/14] refactor: update README and code to replace "provides" and "features" with "provide" and "configure" --- readme.md | 91 +++++++++++++--------------- src/root/feature-resolver.ts | 8 +-- src/root/lit-feature.ts | 2 +- src/root/services/feature-manager.ts | 2 - src/root/types/feature-types.ts | 6 -- 5 files changed, 47 insertions(+), 62 deletions(-) diff --git a/readme.md b/readme.md index c958972..ca906cc 100644 --- a/readme.md +++ b/readme.md @@ -33,15 +33,15 @@ Lit already has strong composition primitives (notably `ReactiveController`), bu ### High-level API -Hosts declare features using **either** static getters or decorators (both are supported): +Hosts declare features using **either** static properties or decorators (both are supported): -**Static getter approach:** -- `static get provides()` — declares which features this class makes available to itself and subclasses -- `static get features()` — configures (or disables) inherited/provided features for this class and below +**Static property approach:** +- `static provide` — object declaring which features this class makes available to itself and subclasses +- `static configure` — object configuring (or disabling) inherited/provided features for this class and below **Decorator approach:** -- `@provide(name, definition)` — equivalent to adding an entry in `static get provides()` -- `@configure(name, options)` — equivalent to adding an entry in `static get features()` +- `@provide(name, definition)` — equivalent to adding an entry in `static provide` +- `@configure(name, options)` — equivalent to adding an entry in `static configure` This repo’s reference implementation is in `src/root`: @@ -53,7 +53,7 @@ This repo’s reference implementation is in `src/root`: ```ts import { LitCore } from "./src/root/lit-core.js"; -import { provide, feature } from "./src/root/decorators/index.js"; +import { provide, configure } from "./src/root/decorators/index.js"; // Base button provides styling with sensible defaults @provide('Style', { class: StyleFeature, config: { variant: 'outlined', size: 'medium' } }) @@ -77,24 +77,21 @@ At runtime, `PrimaryButton` instances have: ### 1) Providing a feature -Provide a feature by naming it in `static get provides()` or using the `@provide` decorator: +Provide a feature using either static properties or decorators: -**Using static getter:** +**Using static property:** -```js +```ts import { LitCore } from "./src/root/lit-core.js"; import { LayoutFeature } from "./src/features/layout-feature.js"; export class BaseElement extends LitCore { - static get provides() { - return { - Layout: { - class: LayoutFeature, - config: { layout: "classic" }, // optional defaults - enabled: true // optional; defaults to true - } - }; - } + static provide = { + Layout: { + class: LayoutFeature, + config: { layout: "classic" } + } + }; } ``` @@ -111,21 +108,21 @@ export class BaseElement extends LitCore {} **Key behavior**: the feature name (e.g. `Layout`) becomes the property name on the host used to store the instance (e.g. `this.Layout`). +It is recommended to capitalize the first letter of this name to indicate throughout the code that this is a reference to an instance of a feature. + ### 2) Configuring a provided feature -Subclasses can override configuration via `static get features()` or the `@configure` decorator: +Subclasses can override configuration using either approach: -**Using static getter:** +**Using static property:** -```js +```ts export class FancyElement extends BaseElement { - static get features() { - return { - Layout: { - config: { layout: "emphasized", shape: "rounded" } - } - }; - } + static configure = { + Layout: { + config: { layout: "emphasized", shape: "rounded" } + } + }; } ``` @@ -142,15 +139,13 @@ Config objects are deep-merged (this POC uses `lodash.merge`). ### 3) Disabling a feature entirely -**Using static getter:** +**Using static property:** -```js +```ts export class NoLayoutElement extends BaseElement { - static get features() { - return { - Layout: "disable" - }; - } + static configure = { + Layout: 'disable' + }; } ``` @@ -167,20 +162,18 @@ export class NoLayoutElement extends BaseElement {} Features can contribute reactive properties (via `static get properties()` on the feature class). Hosts can optionally disable or override those property declarations: -**Using static getter:** +**Using static property:** -```js +```ts export class Element extends BaseElement { - static get features() { - return { - Layout: { - properties: { - onDark: "disable", // remove this property from the host - size: { type: String, reflect: true } // override metadata - } + static configure = { + Layout: { + properties: { + onDark: "disable", + size: { type: String, reflect: true } } - }; - } + } + }; } ``` @@ -288,7 +281,7 @@ The intent described by the API is “subclasses override ancestors.” The curr 2) **Disabled-by-default features and property preparation** -The POC prepares feature-contributed reactive properties at `register()` time (`FeatureManager.prepareFeatures()`), before instances exist. In the current implementation, properties are only prepared for features whose `provides` entry is `enabled: true`. +The POC prepares feature-contributed reactive properties at finalization time, before instances exist. In the current implementation, properties are only prepared for features that are provided (not disabled). That means a “disabled-by-default, opt-in later” feature would not contribute reactive properties unless the host class enables it up-front. @@ -298,7 +291,7 @@ That means a “disabled-by-default, opt-in later” feature would not contribut 4) **API shape is intentionally minimal** -There is no formal typing, no “feature dependencies,” no ordering controls, and no explicit “feature enabled” switch in `static get features()` in the POC. +There is no formal typing, no "feature dependencies," no ordering controls, and limited metadata available at runtime in this POC. ## Relationship to existing Lit concepts diff --git a/src/root/feature-resolver.ts b/src/root/feature-resolver.ts index 48534dd..683f4d4 100644 --- a/src/root/feature-resolver.ts +++ b/src/root/feature-resolver.ts @@ -106,14 +106,14 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { const chain = getInheritanceChain(ctor); chain.forEach(current => { - // Collect from static getters (provide/provides) - const staticProvides = current.provide || current.provides || {}; + // Collect from static getters (provide) + const staticProvides = current.provide || {}; Object.entries(staticProvides).forEach(([name, definition]) => { provides.set(name, definition); }); - // Collect from static getters (configure/features) - const staticConfigs = current.configure || current.features || {}; + // Collect from static getters (configure) + const staticConfigs = current.configure || {}; Object.entries(staticConfigs).forEach(([name, config]) => { const nextConfig = config as FeatureConfigEntry | 'disable'; const merged = mergeConfigEntries(configs.get(name), nextConfig); diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index 3d4f698..36c8d38 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -19,7 +19,7 @@ export interface FeatureProperties { * Base class for all features in the system. * Features extend this class to add functionality to LitCore components. */ -export class LitFeature implements ReactiveController { +export abstract class LitFeature implements ReactiveController { host: LitCore; config: TConfig; diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index 6d524d3..de08e71 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -26,12 +26,10 @@ export type { */ export class FeatureManager { host: LitCore; - hostConstructor: LitCoreConstructor; private _featureInstances: Map; constructor(host: LitCore, constructor: LitCoreConstructor) { this.host = host; - this.hostConstructor = constructor; this._featureInstances = new Map(); // Resolve and initialize features diff --git a/src/root/types/feature-types.ts b/src/root/types/feature-types.ts index 0a4a961..da69043 100644 --- a/src/root/types/feature-types.ts +++ b/src/root/types/feature-types.ts @@ -67,13 +67,7 @@ export interface LitCoreConstructor { name: string; provide?: Record; configure?: Record; - /** @deprecated Use `provide` instead */ - provides?: Record; - /** @deprecated Use `configure` instead */ - features?: Record; properties?: Record; - _resolvedFeatures?: ResolvedFeatures; - _resolvedProperties?: Record; } // Re-export FeatureConfig for convenience From eb5119a1dd2b945ca53b0fd955eb6f7b6bd84e6c Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 11:44:46 -0600 Subject: [PATCH 09/14] fix: handle undefined _changedProperties in firstUpdated method --- src/components/message-base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/message-base.ts b/src/components/message-base.ts index a8a5606..2a6bc18 100644 --- a/src/components/message-base.ts +++ b/src/components/message-base.ts @@ -85,7 +85,7 @@ export class MessageBase extends LitCore { `; firstUpdated(_changedProperties?: Map): void { - super.firstUpdated(_changedProperties); + super.firstUpdated(_changedProperties ?? new Map()); console.log('[MessageBase] firstUpdated with status:', this.status, 'showIcon:', this.showIcon); } From 5684bd9bd03715172705d244e791e15c73bd1bcf Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 12:41:21 -0600 Subject: [PATCH 10/14] feat: remove existing console logs and replace with new debug utils --- src/components/message-base.ts | 15 -- src/components/notification-demo.ts | 99 +---------- src/features/dismiss-feature.ts | 19 -- src/features/status-feature.ts | 12 -- src/features/timer-feature.ts | 15 -- src/features/visibility-feature.ts | 8 - src/root/debug-utils.ts | 222 ++++++++++++++++++++++++ src/root/decorators/feature-property.ts | 2 - src/root/decorators/provide.ts | 2 - src/root/feature-resolver.ts | 29 +++- src/root/lit-feature.ts | 77 +++++--- src/root/services/feature-manager.ts | 13 ++ 12 files changed, 321 insertions(+), 192 deletions(-) create mode 100644 src/root/debug-utils.ts diff --git a/src/components/message-base.ts b/src/components/message-base.ts index 2a6bc18..a491d7d 100644 --- a/src/components/message-base.ts +++ b/src/components/message-base.ts @@ -84,21 +84,6 @@ export class MessageBase extends LitCore { } `; - firstUpdated(_changedProperties?: Map): void { - super.firstUpdated(_changedProperties ?? new Map()); - console.log('[MessageBase] firstUpdated with status:', this.status, 'showIcon:', this.showIcon); - } - - updated(changedProperties: Map): void { - super.updated(changedProperties); - console.log('[MessageBase] updated with status:', this.status, 'showIcon:', this.showIcon); - } - - connectedCallback(): void { - super.connectedCallback(); - console.log(`[MessageBase] Connected with status: ${this.status}, showIcon: ${this.showIcon}`); - } - /** * Render the message content (to be overridden by subclasses) */ diff --git a/src/components/notification-demo.ts b/src/components/notification-demo.ts index dd576c0..3495a2c 100644 --- a/src/components/notification-demo.ts +++ b/src/components/notification-demo.ts @@ -1,12 +1,9 @@ -import { html, css, TemplateResult, CSSResultGroup } from 'lit'; +import { html, css, TemplateResult, CSSResultGroup, LitElement } from 'lit'; import { state } from 'lit/decorators.js'; import { LitCore } from '../root/lit-core.js'; // Import all notification components -import './message-base.js'; import './message-box.js'; -import './alert-box.js'; -import './toast-notification.js'; // Import types for declarations import type { StatusType } from '../features/status-feature.js'; @@ -27,7 +24,7 @@ import type { StatusType } from '../features/status-feature.js'; * - Lifecycle hooks in action * - Feature-to-feature communication */ -export class NotificationDemo extends LitCore { +export class NotificationDemo extends LitElement { private _toastCount: number = 0; @state() @@ -190,32 +187,6 @@ export class NotificationDemo extends LitCore {

LitFeature Notification System

A demonstration of the composable feature architecture with 4 levels of inheritance

- -
-

Component Hierarchy

-
-
Level 1: message-base → provides StatusFeature
-
-
Level 2: message-box → provides VisibilityFeature + configures Status
-
-
Level 3: alert-box → provides DismissFeature + configures Status, Visibility
-
-
Level 4: toast-notification → provides TimerFeature + configures all
-
-
- - -
-

Level 1 message-base

-

Base component with StatusFeature - controls colors and icons based on severity.

-
- This is an info message - This is a success message - This is a warning message - This is an error message - Message without icon (show-icon=false) -
-
@@ -233,69 +204,7 @@ export class NotificationDemo extends LitCore {
- -
-

Level 3 alert-box

-

Extends message-box with DismissFeature - adds close button and callbacks.

-
-
- -
- - This is a dismissible alert. Click the × button to dismiss it. - - - This alert has dismissible=false, so no close button appears. - - - Try dismissing this alert. The Dismiss feature integrates with Visibility for smooth fade-out. - -
-
- - -
-

Level 4 toast-notification

-

Extends alert-box with TimerFeature - adds auto-dismiss countdown.

-
    -
  • Auto-dismiss: Countdown timer that triggers dismiss
  • -
  • Progress bar: Visual countdown indicator
  • -
  • Pause on hover: Timer pauses when mouse enters
  • -
  • Controls: Pause/Resume and Reset buttons
  • -
-
- - - - -
-
- - -
-

Features Demonstrated

-
    -
  • @provide decorator: Registers features at each component level
  • -
  • @configure decorator: Overrides feature config in descendant classes
  • -
  • Property inheritance: Feature properties automatically added to host
  • -
  • Lifecycle hooks: beforeConnectedCallback, afterConnectedCallback, updated, etc.
  • -
  • Feature communication: Timer → Dismiss, Dismiss → Visibility
  • -
  • Config merging: Descendant configs merge with ancestor defaults
  • -
-
- - -
- ${this._toasts.map(toast => html` - this._removeToast(toast.id)} - > - ${toast.message} - - `)} -
+ `; } @@ -321,4 +230,4 @@ export class NotificationDemo extends LitCore { } // Register the demo component -NotificationDemo.register('notification-demo'); +customElements.define('notification-demo', NotificationDemo); diff --git a/src/features/dismiss-feature.ts b/src/features/dismiss-feature.ts index 31454c3..fc1f01e 100644 --- a/src/features/dismiss-feature.ts +++ b/src/features/dismiss-feature.ts @@ -76,7 +76,6 @@ export class DismissFeature extends LitFeature { // Call before callback - can prevent dismissal const shouldProceed = this._onBeforeDismiss?.(); if (shouldProceed === false) { - console.log('[DismissFeature] Dismiss prevented by onBeforeDismiss'); return false; } @@ -106,23 +105,5 @@ export class DismissFeature extends LitFeature { composed: true }) ); - - console.log('[DismissFeature] Component dismissed'); - } - - /** - * Lifecycle: Before disconnect cleanup - */ - beforeDisconnectedCallback(): void { - if (!this.dismissed) { - console.log('[DismissFeature] Component removed before dismissed'); - } - } - - /** - * Lifecycle: Log feature connection - */ - connectedCallback(): void { - console.log(`[DismissFeature] Connected, dismissible: ${this.dismissible}`); } } diff --git a/src/features/status-feature.ts b/src/features/status-feature.ts index 5ec720f..408aa11 100644 --- a/src/features/status-feature.ts +++ b/src/features/status-feature.ts @@ -108,11 +108,6 @@ export class StatusFeature extends LitFeature { }; } - firstUpdated(_changedProperties?: Map): void { - super.firstUpdated(); - console.log('[StatusFeature] firstUpdated with status:', this.status, 'showIcon:', this.showIcon); - } - /** * Lifecycle: Update styles when properties change */ @@ -122,11 +117,4 @@ export class StatusFeature extends LitFeature { this._updateStatusStyles(); } } - - /** - * Lifecycle: Log when feature connects - */ - connectedCallback(): void { - console.log(`[StatusFeature] Connected with status: ${this.status}`); - } } diff --git a/src/features/timer-feature.ts b/src/features/timer-feature.ts index c04e88f..9411e03 100644 --- a/src/features/timer-feature.ts +++ b/src/features/timer-feature.ts @@ -93,7 +93,6 @@ export class TimerFeature extends LitFeature { this._startTime = Date.now() - (this.duration - this.remaining); this._tick(); - console.log(`[TimerFeature] Timer started, ${this.duration}ms`); } /** @@ -109,8 +108,6 @@ export class TimerFeature extends LitFeature { cancelAnimationFrame(this._timerId); this._timerId = null; } - - console.log(`[TimerFeature] Timer paused at ${this.remaining}ms`); } /** @@ -124,7 +121,6 @@ export class TimerFeature extends LitFeature { this.paused = false; this._tick(); - console.log('[TimerFeature] Timer resumed'); } /** @@ -134,7 +130,6 @@ export class TimerFeature extends LitFeature { this.stop(); this.remaining = this.duration; this.progress = 0; - console.log('[TimerFeature] Timer reset'); } /** @@ -187,8 +182,6 @@ export class TimerFeature extends LitFeature { }) ); - console.log('[TimerFeature] Timer complete'); - // Auto-dismiss if configured if (this._autoDismiss) { const dismissFeature = (this.host as unknown as { Dismiss?: { dismiss: () => void } }).Dismiss; @@ -196,13 +189,6 @@ export class TimerFeature extends LitFeature { } } - /** - * Lifecycle: Prepare before connection - */ - beforeConnectedCallback(): void { - console.log('[TimerFeature] Preparing timer...'); - } - /** * Lifecycle: Auto-start after connection */ @@ -220,6 +206,5 @@ export class TimerFeature extends LitFeature { */ disconnectedCallback(): void { this.stop(); - console.log('[TimerFeature] Disconnected, timer cleaned up'); } } diff --git a/src/features/visibility-feature.ts b/src/features/visibility-feature.ts index 9bdd38f..475d45b 100644 --- a/src/features/visibility-feature.ts +++ b/src/features/visibility-feature.ts @@ -114,18 +114,10 @@ export class VisibilityFeature extends LitFeature { ); } - /** - * Lifecycle: Log initial visibility state after first render - */ - afterFirstUpdated(): void { - console.log(`[VisibilityFeature] Initial state: ${this.visible ? 'visible' : 'hidden'}`); - } - /** * Lifecycle: Cleanup on disconnect */ disconnectedCallback(): void { - console.log('[VisibilityFeature] Disconnected, cleaning up transitions'); this.transitioning = false; } } diff --git a/src/root/debug-utils.ts b/src/root/debug-utils.ts new file mode 100644 index 0000000..e4ab536 --- /dev/null +++ b/src/root/debug-utils.ts @@ -0,0 +1,222 @@ +/** + * Debug Utilities for LitFeature System + * + * Provides a centralized debugging system with flags for each phase of the + * feature lifecycle. Uses sessionStorage for persistence across page reloads, + * allowing developers to observe the full instantiation lifecycle on page load. + * + * Usage in browser console: + * window.__litFeatureDebug.enabled = true; + * window.__litFeatureDebug.meta = true; + * window.__litFeatureDebug.properties = true; + * window.__litFeatureDebug.wiring = true; + * + * Or set flags and reload: + * sessionStorage.setItem('__litFeatureDebug', JSON.stringify({ enabled: true })); + * location.reload(); + */ + +/** + * Global debug configuration object + */ +interface DebugConfig { + /** Master flag: enables/disables all debugging (takes precedence) */ + enabled: boolean; + + /** Debug meta/static gathering during definition phase */ + meta: boolean; + + /** Debug property resolution and wiring during instantiation phase */ + properties: boolean; + + /** Debug internal wiring between features and host */ + wiring: boolean; +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace globalThis { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + var __litFeatureDebug: DebugConfig | undefined; + } +} + +/** + * Default debug configuration - all flags off by default + */ +const DEFAULT_DEBUG_CONFIG: DebugConfig = { + enabled: false, + meta: false, + properties: false, + wiring: false +}; + +const SESSION_STORAGE_KEY = '__litFeatureDebug'; +let _initialized = false; + +/** + * Initialize debug configuration from sessionStorage or create new + */ +function initializeDebugConfig(): DebugConfig { + if (_initialized && typeof globalThis !== 'undefined' && globalThis.__litFeatureDebug) { + return globalThis.__litFeatureDebug; + } + + let config: DebugConfig = { ...DEFAULT_DEBUG_CONFIG }; + + // Try to load from sessionStorage + if (typeof globalThis !== 'undefined' && globalThis.sessionStorage) { + try { + const stored = globalThis.sessionStorage.getItem(SESSION_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + config = { ...DEFAULT_DEBUG_CONFIG, ...parsed }; + // Show that we loaded from storage + if (config.enabled || config.meta || config.properties || config.wiring) { + console.warn( + `[LitFeature Debug] Loaded configuration from sessionStorage. Flags enabled: ${ + [ + config.enabled && 'master', + config.meta && 'meta', + config.properties && 'properties', + config.wiring && 'wiring' + ] + .filter(Boolean) + .join(', ') + }` + ); + } + } + } catch (e) { + // Ignore parse errors + } + } + + if (typeof globalThis !== 'undefined') { + globalThis.__litFeatureDebug = config; + } + + // Show warning if no flags are enabled + if (!config.enabled && !config.meta && !config.properties && !config.wiring) { + console.warn( + `[LitFeature Debug] Debugging is available but currently disabled.\n` + + `To enable, set flags in your browser console:\n` + + ` window.__litFeatureDebug.enabled = true; // Master flag\n` + + ` window.__litFeatureDebug.meta = true; // Definition phase\n` + + ` window.__litFeatureDebug.properties = true; // Instantiation phase\n` + + ` window.__litFeatureDebug.wiring = true; // Host ↔ Feature sync\n\n` + + `Or set flags and reload to see the full lifecycle:\n` + + ` sessionStorage.setItem('__litFeatureDebug', JSON.stringify({ enabled: true }));\n` + + ` location.reload();` + ); + } + + _initialized = true; + return config; +} + +/** + * Get current debug configuration + */ +function getDebugConfig(): DebugConfig { + if (typeof globalThis === 'undefined' || !globalThis.__litFeatureDebug) { + return initializeDebugConfig(); + } + return globalThis.__litFeatureDebug; +} + +/** + * Save current debug configuration to sessionStorage + */ +function saveDebugConfig(config: DebugConfig): void { + if (typeof globalThis === 'undefined' || !globalThis.sessionStorage) { + return; + } + + try { + globalThis.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(config)); + } catch (e) { + // Ignore storage errors (e.g., quota exceeded) + } +} + +/** + * Update a debug flag and persist to sessionStorage + */ +function setDebugFlag(flag: keyof DebugConfig, value: boolean): void { + const config = getDebugConfig(); + config[flag] = value; + + if (typeof globalThis !== 'undefined') { + globalThis.__litFeatureDebug = config; + } + + saveDebugConfig(config); +} + +/** + * Check if a specific debug flag is enabled + * @param flag - The debug flag to check (meta, properties, wiring) + * @returns true if either the master 'enabled' flag or the specific flag is true + */ +function isDebugEnabled(flag: keyof Omit): boolean { + const config = getDebugConfig(); + // Master flag takes precedence - if enabled is true, all logging is on + return config.enabled === true || config[flag] === true; +} + +/** + * Log message for definition phase (meta/static gathering) + */ +function logMeta(phase: string, message: string, data?: unknown): void { + if (!isDebugEnabled('meta')) return; + const prefix = '[LitFeature Debug] [Definition Phase] [Meta]'; + if (data !== undefined) { + console.log(`${prefix} [${phase}] ${message}`, data); + } else { + console.log(`${prefix} [${phase}] ${message}`); + } +} + +/** + * Log message for instantiation phase (property resolution and wiring) + */ +function logProperties(phase: string, message: string, data?: unknown): void { + if (!isDebugEnabled('properties')) return; + const prefix = '[LitFeature Debug] [Instantiation Phase] [Properties]'; + if (data !== undefined) { + console.log(`${prefix} [${phase}] ${message}`, data); + } else { + console.log(`${prefix} [${phase}] ${message}`); + } +} + +/** + * Log message for internal wiring (feature ↔ host synchronization) + */ +function logWiring(phase: string, message: string, data?: unknown): void { + if (!isDebugEnabled('wiring')) return; + const prefix = '[LitFeature Debug] [Wiring Phase] [Internal Sync]'; + if (data !== undefined) { + console.log(`${prefix} [${phase}] ${message}`, data); + } else { + console.log(`${prefix} [${phase}] ${message}`); + } +} + +/** + * Export debug utilities + */ +export const DebugUtils = { + initializeDebugConfig, + getDebugConfig, + setDebugFlag, + saveDebugConfig, + isDebugEnabled, + logMeta, + logProperties, + logWiring +} as const; + +// Initialize on module load so configuration is ready from the start +initializeDebugConfig(); diff --git a/src/root/decorators/feature-property.ts b/src/root/decorators/feature-property.ts index 91ee681..249f660 100644 --- a/src/root/decorators/feature-property.ts +++ b/src/root/decorators/feature-property.ts @@ -35,8 +35,6 @@ export function property(options: PropertyDeclaration = {}) { } ctor.properties[propertyKey] = options; - - console.log(`[featureProperty] Registered "${propertyKey}" on ${ctor.name}:`, options, '\nCtor:', ctor); }; } diff --git a/src/root/decorators/provide.ts b/src/root/decorators/provide.ts index 6f368fe..7d00a55 100644 --- a/src/root/decorators/provide.ts +++ b/src/root/decorators/provide.ts @@ -29,8 +29,6 @@ export function provide( const meta = getOrCreateFeatureMeta(constructor); meta.provide!.set(featureName, definition as unknown as FeatureDefinition); - // console.log(`[@provide] Registered feature "${featureName}" on ${constructor.name}:`, definition); - return constructor; }; } diff --git a/src/root/feature-resolver.ts b/src/root/feature-resolver.ts index 683f4d4..3f2de0b 100644 --- a/src/root/feature-resolver.ts +++ b/src/root/feature-resolver.ts @@ -12,6 +12,7 @@ import type { LitFeature } from './lit-feature.js'; import { LIT_CORE_MARKER } from './lit-core.js'; import { FEATURE_META } from './decorators/feature-meta.js'; import { getFeaturePropertyMetadata } from './decorators/feature-property.js'; +import { DebugUtils } from './debug-utils.js'; // ============================================================================ // Pure Function Module: Feature Resolution @@ -94,8 +95,12 @@ function mergeConfigEntries( * @returns Resolved features with properties and feature definitions */ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { + const constructorName = ctor.name || 'Unknown'; + DebugUtils.logMeta('resolve-start', `Starting feature resolution for component: ${constructorName}`); + // Check cache first if (Object.prototype.hasOwnProperty.call(ctor, RESOLVED_CACHE)) { + DebugUtils.logMeta('resolve-cache', `Using cached resolution for: ${constructorName}`); return (ctor as unknown as Record)[RESOLVED_CACHE]; } @@ -104,11 +109,16 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { const configs = new Map(); const chain = getInheritanceChain(ctor); + DebugUtils.logMeta('resolve-chain', `Inheritance chain for ${constructorName}:`, chain.map(c => c.name)); chain.forEach(current => { + const className = current.name || 'Unknown'; + DebugUtils.logMeta('resolve-class', `Processing class: ${className}`); + // Collect from static getters (provide) const staticProvides = current.provide || {}; Object.entries(staticProvides).forEach(([name, definition]) => { + DebugUtils.logMeta('resolve-provide-static', ` → Collecting feature: ${name} from static provide`); provides.set(name, definition); }); @@ -117,6 +127,7 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { Object.entries(staticConfigs).forEach(([name, config]) => { const nextConfig = config as FeatureConfigEntry | 'disable'; const merged = mergeConfigEntries(configs.get(name), nextConfig); + DebugUtils.logMeta('resolve-configure-static', ` → Merging config for feature: ${name}`); configs.set(name, merged); }); @@ -126,6 +137,7 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { // Process provides from decorator metadata if (meta.provide) { meta.provide.forEach((definition, name) => { + DebugUtils.logMeta('resolve-provide-decorator', ` → Collecting feature: ${name} from decorator`); provides.set(name, definition); }); } @@ -134,6 +146,7 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { if (meta.configure) { meta.configure.forEach((config, name) => { const merged = mergeConfigEntries(configs.get(name), config); + DebugUtils.logMeta('resolve-configure-decorator', ` → Merging config for feature: ${name} from decorator`); configs.set(name, merged); }); } @@ -144,14 +157,19 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { const resolvedProperties: Record = {}; const resolvedFeatures = new Map(); + DebugUtils.logMeta('resolve-build', `Building resolved state for ${provides.size} provided features`); + provides.forEach((definition, name) => { const featureConfig = configs.get(name); // Skip if disabled if (featureConfig === 'disable' || definition.enabled === false) { + DebugUtils.logMeta('resolve-disabled', ` → Skipping disabled feature: ${name}`); return; } + DebugUtils.logMeta('resolve-feature', ` → Resolving feature: ${name}`); + // Merge feature properties let mergedProperties: Record = { ...(definition.class.properties || {}) @@ -159,24 +177,24 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { // Include decorator properties const decoratorMeta = getFeaturePropertyMetadata(definition.class); - console.log(`[resolveFeatures] Decorator metadata for feature "${name}" on ${ctor.name}:`, decoratorMeta); Object.assign(mergedProperties, decoratorMeta || {}); // Apply property overrides from config if (featureConfig && typeof featureConfig === 'object' && featureConfig.properties) { Object.entries(featureConfig.properties).forEach(([propName, propValue]) => { if (propValue === 'disable') { + DebugUtils.logMeta('resolve-property', ` → Disabling property: ${propName}`); delete mergedProperties[propName]; } else { + DebugUtils.logMeta('resolve-property', ` → Including property: ${propName}`); mergedProperties[propName] = propValue; } }); } - console.log(`[resolveFeatures] Merged properties for feature "${name}" on ${ctor.name}:`, mergedProperties); - // Add to resolved properties Object.assign(resolvedProperties, mergedProperties); + DebugUtils.logMeta('resolve-properties-count', ` → Total properties for ${name}: ${Object.keys(mergedProperties).length}`); // Compute final config const finalConfig = featureConfig && typeof featureConfig === 'object' @@ -197,6 +215,11 @@ export function resolveFeatures(ctor: LitCoreConstructor): ResolvedFeatures { Object.freeze(resolved); + DebugUtils.logMeta('resolve-complete', `Resolution complete for ${constructorName}`, { + featuresCount: resolvedFeatures.size, + propertiesCount: Object.keys(resolvedProperties).length + }); + // Cache and return (ctor as unknown as Record)[RESOLVED_CACHE] = resolved; diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index 36c8d38..7784d3c 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -1,6 +1,7 @@ import type { LitCore } from './lit-core.ts'; import type { PropertyDeclaration } from 'lit'; import type { ReactiveController } from 'lit'; +import { DebugUtils } from './debug-utils.js'; /** * Base interface for feature configuration objects @@ -33,6 +34,10 @@ export abstract class LitFeature static properties: FeatureProperties = {}; constructor(host: LitCore, config: TConfig) { + const featureName = this.constructor.name || 'UnnamedFeature'; + const hostName = host.constructor.name || 'UnknownHost'; + DebugUtils.logProperties('feature-constructor', `Constructing ${featureName} on host ${hostName}`); + this.host = host; this.config = config; @@ -43,41 +48,63 @@ export abstract class LitFeature } private _litFeatureInit(): void { + const featureName = this.constructor.name || 'UnnamedFeature'; + DebugUtils.logProperties('feature-init', `Initializing ${featureName} - setting up property observers`); + const properties = (this.constructor as typeof LitFeature).properties; - if (!properties) return; + if (!properties) { + DebugUtils.logProperties('feature-init-no-props', ` → ${featureName} has no properties to observe`); + return; + } + + const propNames = Object.keys(properties); + DebugUtils.logProperties('feature-init-props-count', ` → ${featureName} has ${propNames.length} properties`, propNames); // At construction time we only define proxy accessors on the feature. // Actual value reconciliation (host ↔ feature) happens in firstUpdated/updated // when the host has finished its own setup. Object.keys(properties).forEach(propertyName => { + DebugUtils.logProperties('feature-init-observer', ` → Creating property observer for: ${propertyName}`); this._createPropertyObserver(propertyName); }); } private _createPropertyObserver(propertyName: string): void { + const featureName = this.constructor.name || 'UnnamedFeature'; + DebugUtils.logProperties('property-observer-create', `Creating property descriptor for ${featureName}.${propertyName}`); + const feature = this; Object.defineProperty(this, propertyName, { configurable: true, enumerable: true, get() { - return feature.getInternalValue(propertyName); + const value = feature.getInternalValue(propertyName); + DebugUtils.logWiring('property-getter', `Getting ${featureName}.${propertyName}`, value); + return value; }, set(newValue: unknown) { const hostRecord = feature.host as unknown as Record; const oldValue = hostRecord[propertyName]; + DebugUtils.logWiring('property-setter', `Setting ${featureName}.${propertyName}`, { + oldValue, + newValue, + hostName: feature.host.constructor.name || 'UnknownHost' + }); + // Feature → host: write to Lit reactive property hostRecord[propertyName] = newValue; + DebugUtils.logWiring('property-to-host', ` → Synced to host property: ${propertyName}`, newValue); // Mirror into feature internal map feature.setInternalValue(propertyName, newValue); + DebugUtils.logWiring('property-to-internal', ` → Mirrored to internal storage: ${propertyName}`); // Ensure Lit schedules an update if (typeof (feature.host as any).requestUpdate === 'function') { (feature.host as any).requestUpdate(propertyName, oldValue); + DebugUtils.logWiring('property-request-update', ` → Requested update for: ${propertyName}`); } - - console.log(`[LitFeature] Property [${propertyName}] set on feature [${feature.constructor.name}]:`, newValue); } }); } @@ -96,18 +123,15 @@ export abstract class LitFeature return this._internalValues.get(propertyName); } - hostUpdated(): void { - // console.log("hostUpdated called on", this.constructor.name); - // Intentionally empty: timing hook only. - // Host → feature sync happens via LitCore.updated → FeatureManager.processLifecycle('updated', changedProperties) - // which calls each feature's `updated(changedProperties)`. - } - /** * Called after the host element's first update cycle (legacy hook). * Kept for compatibility; you can prefer `hostUpdated` for controller-style usage. */ firstUpdated(_changedProperties?: Map): void { + const featureName = this.constructor.name || 'UnnamedFeature'; + const hostName = this.host.constructor.name || 'UnknownHost'; + + DebugUtils.logWiring('first-updated-start', `First update phase for ${featureName} (host: ${hostName})`); const featureRecord = this as unknown as Record; const hostRecord = this.host as unknown as Record; @@ -116,17 +140,28 @@ export abstract class LitFeature const hostValue = hostRecord[propertyName]; const internalValue = this.getInternalValue(propertyName); + DebugUtils.logWiring('first-updated-reconcile', `Reconciling property: ${propertyName as string}`, { + hostValue, + internalValue, + oldValue + }); + if (hostValue !== undefined) { // Host wins: copy host value into feature internal via proxy setter + DebugUtils.logWiring('first-updated-host-wins', ` → Host value wins for ${propertyName as string}`); (featureRecord as any)[propertyName] = hostValue; } else if (internalValue !== undefined) { // Feature default wins: push internal default out to host via proxy setter + DebugUtils.logWiring('first-updated-feature-wins', ` → Feature default wins for ${propertyName as string}`, internalValue); (featureRecord as any)[propertyName] = internalValue; } else { // Nothing set anywhere; just mirror whatever host currently has (likely undefined) + DebugUtils.logWiring('first-updated-mirror', ` → No value set, mirroring undefined for ${propertyName as string}`); this.setInternalValue(propertyName, hostValue); } }); + + DebugUtils.logWiring('first-updated-complete', `First update phase complete for ${featureName}`); } /** @@ -134,20 +169,20 @@ export abstract class LitFeature * Sync host → feature only for properties that actually changed. */ updated(changedProperties: Map): void { - const hostRecord = this.host as unknown as Record; + const featureName = this.constructor.name || 'UnnamedFeature'; + const hostName = this.host.constructor.name || 'UnknownHost'; - console.log( - `[LitFeature] updated called:`, - '\n', `Feature: [${this.constructor.name}]`, - '\n', `Host: ${this.host.constructor.name}`, - '\n', `Changed Properties:`, changedProperties, - '\n', `Current Host Values:`, Object.fromEntries(Array.from(changedProperties.keys()).map(key => [key, hostRecord[key as string]])), - '\n', `Instance:`, this.host - ); + DebugUtils.logWiring('updated-start', `Update phase for ${featureName} (host: ${hostName})`); + + const hostRecord = this.host as unknown as Record; changedProperties.forEach((_oldValue, propertyName) => { - this.setInternalValue(propertyName as string, hostRecord[propertyName as string]); + const newValue = hostRecord[propertyName as string]; + DebugUtils.logWiring('updated-sync', `Syncing changed property: ${propertyName as string}`, newValue); + this.setInternalValue(propertyName as string, newValue); }); + + DebugUtils.logWiring('updated-complete', `Update phase complete for ${featureName}`); } // Lifecycle hooks that can be overridden by subclasses diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index de08e71..62c331a 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -2,6 +2,7 @@ import merge from 'lodash.merge'; import type { LitFeature } from '../lit-feature.js'; import type { LitCore } from '../lit-core.js'; import { resolveFeatures } from '../feature-resolver.js'; +import { DebugUtils } from '../debug-utils.js'; import type { FeatureConfig, LitCoreConstructor, ResolvedFeatures } from '../types/feature-types.js'; // Re-export types for backward compatibility (deprecated) @@ -41,8 +42,16 @@ export class FeatureManager { * Initialize all features from resolved state */ private _initializeFeatures(resolved: ResolvedFeatures): void { + const hostName = (this.host as any).constructor?.name || 'Unknown'; + DebugUtils.logProperties('init-start', `Starting feature instantiation for host: ${hostName}`, { + featureCount: resolved.features.size + }); + resolved.features.forEach((feature, featureName) => { + DebugUtils.logProperties('init-feature', `Instantiating feature: ${featureName}`); + const featureInstance = new (feature.class as any)(this.host, feature.config); + DebugUtils.logProperties('init-instance-created', ` → Instance created for: ${featureName}`); this._featureInstances.set(featureName, featureInstance); @@ -54,11 +63,15 @@ export class FeatureManager { Features should not declare properties with names matching those in the host component. Please rename the feature or host property to avoid this conflict. Feature will be assigned to _${featureName} to avoid overwriting the host property. It is not recommended to leave this conflict unresolved, as it may lead to unexpected behavior.` ); + DebugUtils.logProperties('init-attach-conflict', ` → Conflict detected, attaching to _${featureName}`); hostRecord[`_${featureName}`] = featureInstance; } else { + DebugUtils.logProperties('init-attach', ` → Attached to host as property: ${featureName}`); hostRecord[featureName] = featureInstance; } }); + + DebugUtils.logProperties('init-complete', `Feature instantiation complete for host: ${hostName}`); } /** From d255237d9ad0c3bc42ceeae48fe3b38d3d3df5bd Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 13:00:14 -0600 Subject: [PATCH 11/14] feat: implement property declaration tracking and update request suspension during feature initialization --- src/components/notification-demo.ts | 93 +++++++++++++++++++++++++++- src/root/lit-feature.ts | 86 ++++++++++++++++++++----- src/root/services/feature-manager.ts | 15 +++++ 3 files changed, 179 insertions(+), 15 deletions(-) diff --git a/src/components/notification-demo.ts b/src/components/notification-demo.ts index 3495a2c..1fcdab0 100644 --- a/src/components/notification-demo.ts +++ b/src/components/notification-demo.ts @@ -3,7 +3,10 @@ import { state } from 'lit/decorators.js'; import { LitCore } from '../root/lit-core.js'; // Import all notification components +import './message-base.js'; import './message-box.js'; +import './alert-box.js'; +import './toast-notification.js'; // Import types for declarations import type { StatusType } from '../features/status-feature.js'; @@ -187,6 +190,32 @@ export class NotificationDemo extends LitElement {

LitFeature Notification System

A demonstration of the composable feature architecture with 4 levels of inheritance

+ +
+

Component Hierarchy

+
+
Level 1: message-base → provides StatusFeature
+
+
Level 2: message-box → provides VisibilityFeature + configures Status
+
+
Level 3: alert-box → provides DismissFeature + configures Status, Visibility
+
+
Level 4: toast-notification → provides TimerFeature + configures all
+
+
+ + +
+

Level 1 message-base

+

Base component with StatusFeature - controls colors and icons based on severity.

+
+ This is an info message + This is a success message + This is a warning message + This is an error message + Message without icon (show-icon=false) +
+
@@ -204,7 +233,69 @@ export class NotificationDemo extends LitElement {
- + +
+

Level 3 alert-box

+

Extends message-box with DismissFeature - adds close button and callbacks.

+
+
+ +
+ + This is a dismissible alert. Click the × button to dismiss it. + + + This alert has dismissible=false, so no close button appears. + + + Try dismissing this alert. The Dismiss feature integrates with Visibility for smooth fade-out. + +
+
+ + +
+

Level 4 toast-notification

+

Extends alert-box with TimerFeature - adds auto-dismiss countdown.

+
    +
  • Auto-dismiss: Countdown timer that triggers dismiss
  • +
  • Progress bar: Visual countdown indicator
  • +
  • Pause on hover: Timer pauses when mouse enters
  • +
  • Controls: Pause/Resume and Reset buttons
  • +
+
+ + + + +
+
+ + +
+

Features Demonstrated

+
    +
  • @provide decorator: Registers features at each component level
  • +
  • @configure decorator: Overrides feature config in descendant classes
  • +
  • Property inheritance: Feature properties automatically added to host
  • +
  • Lifecycle hooks: beforeConnectedCallback, afterConnectedCallback, updated, etc.
  • +
  • Feature communication: Timer → Dismiss, Dismiss → Visibility
  • +
  • Config merging: Descendant configs merge with ancestor defaults
  • +
+
+ + +
+ ${this._toasts.map(toast => html` + this._removeToast(toast.id)} + > + ${toast.message} + + `)} +
`; } diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index 7784d3c..003aafc 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -26,6 +26,8 @@ export abstract class LitFeature private _propertyObservers: Map = new Map(); private _internalValues: Map = new Map(); + private _declaredProperties: Set = new Set(); + private _suspendUpdates: boolean = false; /** * Static property definitions for this feature. @@ -51,7 +53,7 @@ export abstract class LitFeature const featureName = this.constructor.name || 'UnnamedFeature'; DebugUtils.logProperties('feature-init', `Initializing ${featureName} - setting up property observers`); - const properties = (this.constructor as typeof LitFeature).properties; + const {properties} = (this.constructor as typeof LitFeature); if (!properties) { DebugUtils.logProperties('feature-init-no-props', ` → ${featureName} has no properties to observe`); return; @@ -60,6 +62,11 @@ export abstract class LitFeature const propNames = Object.keys(properties); DebugUtils.logProperties('feature-init-props-count', ` → ${featureName} has ${propNames.length} properties`, propNames); + // Track declared properties for this feature (used during reconciliation) + propNames.forEach(propName => { + this._declaredProperties.add(propName); + }); + // At construction time we only define proxy accessors on the feature. // Actual value reconciliation (host ↔ feature) happens in firstUpdated/updated // when the host has finished its own setup. @@ -85,6 +92,7 @@ export abstract class LitFeature set(newValue: unknown) { const hostRecord = feature.host as unknown as Record; const oldValue = hostRecord[propertyName]; + const internalValue = feature.getInternalValue(propertyName); DebugUtils.logWiring('property-setter', `Setting ${featureName}.${propertyName}`, { oldValue, @@ -92,6 +100,20 @@ export abstract class LitFeature hostName: feature.host.constructor.name || 'UnknownHost' }); + // Guard 1: Check if new value is identical to internal value (already set) + if (Object.is(internalValue, newValue)) { + DebugUtils.logWiring('property-setter-guard-internal', ` → Skipping: already equals internal value for ${propertyName}`); + return; + } + + // Guard 2: Check if new value is identical to host value (no change needed) + if (Object.is(oldValue, newValue)) { + DebugUtils.logWiring('property-setter-guard-host', ` → Skipping: already equals host value for ${propertyName}`); + // Still mirror to internal for consistency + feature.setInternalValue(propertyName, newValue); + return; + } + // Feature → host: write to Lit reactive property hostRecord[propertyName] = newValue; DebugUtils.logWiring('property-to-host', ` → Synced to host property: ${propertyName}`, newValue); @@ -100,10 +122,12 @@ export abstract class LitFeature feature.setInternalValue(propertyName, newValue); DebugUtils.logWiring('property-to-internal', ` → Mirrored to internal storage: ${propertyName}`); - // Ensure Lit schedules an update - if (typeof (feature.host as any).requestUpdate === 'function') { + // Guard 3: Only request update if not suspended + if (!feature._suspendUpdates && typeof (feature.host as any).requestUpdate === 'function') { (feature.host as any).requestUpdate(propertyName, oldValue); DebugUtils.logWiring('property-request-update', ` → Requested update for: ${propertyName}`); + } else if (feature._suspendUpdates) { + DebugUtils.logWiring('property-request-update-suspended', ` → Update suspended for: ${propertyName}`); } } }); @@ -123,6 +147,34 @@ export abstract class LitFeature return this._internalValues.get(propertyName); } + /** + * Suspend update requests (used during initialization batching) + */ + _suspendUpdateRequests(): void { + this._suspendUpdates = true; + } + + /** + * Resume update requests (used after initialization batching) + */ + _resumeUpdateRequests(): void { + this._suspendUpdates = false; + } + + /** + * Called when the host connects (ReactiveController lifecycle) + */ + hostConnected(): void { + // Subclasses can override this + } + + /** + * Called when the host disconnects (ReactiveController lifecycle) + */ + hostDisconnected(): void { + // Subclasses can override this + } + /** * Called after the host element's first update cycle (legacy hook). * Kept for compatibility; you can prefer `hostUpdated` for controller-style usage. @@ -136,27 +188,33 @@ export abstract class LitFeature const featureRecord = this as unknown as Record; const hostRecord = this.host as unknown as Record; - (_changedProperties || new Map()).forEach((oldValue, propertyName) => { + // Only reconcile properties declared by this feature (not all changed properties) + this._declaredProperties.forEach(propertyName => { const hostValue = hostRecord[propertyName]; const internalValue = this.getInternalValue(propertyName); - DebugUtils.logWiring('first-updated-reconcile', `Reconciling property: ${propertyName as string}`, { + DebugUtils.logWiring('first-updated-reconcile', `Reconciling property: ${propertyName}`, { hostValue, - internalValue, - oldValue + internalValue }); if (hostValue !== undefined) { - // Host wins: copy host value into feature internal via proxy setter - DebugUtils.logWiring('first-updated-host-wins', ` → Host value wins for ${propertyName as string}`); - (featureRecord as any)[propertyName] = hostValue; + // Host value exists - but only re-set if it differs from internal value + if (!Object.is(hostValue, internalValue)) { + DebugUtils.logWiring('first-updated-host-wins', ` → Host value wins for ${propertyName}`); + (featureRecord as any)[propertyName] = hostValue; + } else { + DebugUtils.logWiring('first-updated-host-match', ` → Host value already matches internal for ${propertyName}`); + // Just ensure internal is set + this.setInternalValue(propertyName, hostValue); + } } else if (internalValue !== undefined) { - // Feature default wins: push internal default out to host via proxy setter - DebugUtils.logWiring('first-updated-feature-wins', ` → Feature default wins for ${propertyName as string}`, internalValue); + // Feature default exists and host doesn't - push it out + DebugUtils.logWiring('first-updated-feature-wins', ` → Feature default wins for ${propertyName}`, internalValue); (featureRecord as any)[propertyName] = internalValue; } else { - // Nothing set anywhere; just mirror whatever host currently has (likely undefined) - DebugUtils.logWiring('first-updated-mirror', ` → No value set, mirroring undefined for ${propertyName as string}`); + // Nothing set anywhere; just mirror undefined for consistency + DebugUtils.logWiring('first-updated-mirror', ` → No value set, mirroring undefined for ${propertyName}`); this.setInternalValue(propertyName, hostValue); } }); diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index 62c331a..4570e7e 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -47,12 +47,16 @@ export class FeatureManager { featureCount: resolved.features.size }); + // Batch 1: Create instances with update requests suspended resolved.features.forEach((feature, featureName) => { DebugUtils.logProperties('init-feature', `Instantiating feature: ${featureName}`); const featureInstance = new (feature.class as any)(this.host, feature.config); DebugUtils.logProperties('init-instance-created', ` → Instance created for: ${featureName}`); + // Suspend update requests during initialization + featureInstance._suspendUpdateRequests(); + this._featureInstances.set(featureName, featureInstance); // Attach to host @@ -71,6 +75,17 @@ Feature will be assigned to _${featureName} to avoid overwriting the host proper } }); + // Batch 2: Resume updates and trigger single batch update if needed + this._featureInstances.forEach((featureInstance) => { + featureInstance._resumeUpdateRequests(); + }); + + // Trigger a single batch update on the host to handle any accumulated changes + if (typeof (this.host as any).requestUpdate === 'function') { + DebugUtils.logProperties('init-batch-update', `Triggering batch update after feature initialization for host: ${hostName}`); + (this.host as any).requestUpdate(); + } + DebugUtils.logProperties('init-complete', `Feature instantiation complete for host: ${hostName}`); } From 1ce87fb3aa7e16a5da720ef423fe21101969f7e4 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 13:53:44 -0600 Subject: [PATCH 12/14] feat: add performance monitoring and stress test components - Implemented a PerformanceMonitor to track and log performance metrics for component initialization and rendering. - Created StressTest component to generate and monitor 50+ components for performance bottlenecks. - Developed SuperStressTest component to silently generate 500 complex components for performance evaluation. - Added HTML files for both StressTest and SuperStressTest components to facilitate testing in a browser environment. - Enhanced existing LitCore and LitFeature classes to integrate performance monitoring during construction and feature initialization. --- index.html | 5 + src/components/stress-test.ts | 558 +++++++++++++++++++++++++++ src/components/super-stress-test.ts | 355 +++++++++++++++++ src/root/lit-core.ts | 11 + src/root/lit-feature.ts | 28 ++ src/root/performance-monitor.ts | 270 +++++++++++++ src/root/services/feature-manager.ts | 13 + stress-test.html | 18 + super-stress-test.html | 18 + 9 files changed, 1276 insertions(+) create mode 100644 src/components/stress-test.ts create mode 100644 src/components/super-stress-test.ts create mode 100644 src/root/performance-monitor.ts create mode 100644 stress-test.html create mode 100644 super-stress-test.html diff --git a/index.html b/index.html index 1873e8d..852e9a2 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,11 @@ + diff --git a/src/components/stress-test.ts b/src/components/stress-test.ts new file mode 100644 index 0000000..dafe99b --- /dev/null +++ b/src/components/stress-test.ts @@ -0,0 +1,558 @@ +import { html, css, TemplateResult, CSSResultGroup, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { performanceMonitor } from '../root/performance-monitor.js'; + +// Import all notification components +import './message-base.js'; +import './message-box.js'; +import './alert-box.js'; +import './toast-notification.js'; + +import type { StatusType } from '../features/status-feature.js'; + +/** + * Stress Test Component + * + * Creates 50+ components of varying complexity to stress-test the LitFeature system. + * Includes: + * - 20 simple message-base components + * - 15 message-box components (with VisibilityFeature) + * - 15 alert-box components (with multiple features) + * - Some with auto-dismiss timers + * + * Monitors performance throughout and logs only when there are potential + * slowdowns or issues, assuming the system could scale to 2500+ components. + * + * @element stress-test + */ +export class StressTest extends LitElement { + @state() + private _componentCount: number = 0; + + @state() + private _components: Array<{ + id: number; + type: 'simple' | 'medium' | 'complex'; + status: StatusType; + message: string; + visible?: boolean; + hasTimer?: boolean; + }> = []; + + @state() + private _initialized: boolean = false; + + @state() + private _performanceSummary = { + totalTime: 0, + componentCount: 0, + avgPerComponent: 0, + warnings: 0, + errors: 0 + }; + + static override styles: CSSResultGroup = css` + :host { + display: block; + width: 100%; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f8f9fa; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + .header { + background: white; + padding: 24px; + border-radius: 8px; + margin-bottom: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + h1 { + font-size: 28px; + font-weight: 600; + margin: 0 0 8px; + color: #1a1a1a; + } + + .subtitle { + color: #666; + margin: 0 0 16px; + font-size: 16px; + } + + .controls { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 16px; + } + + button { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .btn-primary { + background: #007bff; + color: white; + } + + .btn-primary:hover { + background: #0056b3; + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover { + background: #545b62; + } + + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-top: 16px; + } + + .stat { + background: #f0f0f0; + padding: 12px; + border-radius: 4px; + font-size: 14px; + } + + .stat-label { + color: #666; + font-size: 12px; + text-transform: uppercase; + margin-bottom: 4px; + } + + .stat-value { + font-size: 20px; + font-weight: 600; + color: #333; + } + + .stat-value.warning { + color: #ff9800; + } + + .stat-value.error { + color: #f44336; + } + + .components-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 12px; + margin-top: 24px; + } + + .section { + background: white; + padding: 24px; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + .section-title { + font-size: 16px; + font-weight: 600; + margin: 0 0 16px; + color: #333; + display: flex; + align-items: center; + gap: 8px; + } + + .component-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .badge-simple { + background: #e3f2fd; + color: #1565c0; + } + + .badge-medium { + background: #fff3e0; + color: #e65100; + } + + .badge-complex { + background: #f3e5f5; + color: #6a1b9a; + } + + .component-wrapper { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 12px; + background: #fafafa; + } + + .component-meta { + font-size: 12px; + color: #999; + margin-top: 8px; + font-family: monospace; + } + + .loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + color: #666; + } + + .spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 12px; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .warning-message { + background: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + padding: 12px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 14px; + } + + .error-message { + background: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 12px; + border-radius: 4px; + margin-bottom: 16px; + font-size: 14px; + } + `; + + override firstUpdated(): void { + performanceMonitor.mark('stress-test-init-start'); + } + + override render(): TemplateResult { + return html` +
+
+

🔥 LitFeature Stress Test

+

+ Rendering 50+ components with varying complexity to identify performance bottlenecks + and scaling limitations. Assuming potential scale to 2500+ components. +

+ + ${this._performanceSummary.warnings > 0 || this._performanceSummary.errors > 0 + ? html` +
+ ⚠️ Performance issues detected during initialization: + ${this._performanceSummary.warnings} warnings, + ${this._performanceSummary.errors} errors +
+ ` + : ''} + +
+ + + +
+ + ${this._initialized + ? html` +
+
+
Total Components
+
${this._performanceSummary.componentCount}
+
+
+
Total Init Time
+
${this._performanceSummary.totalTime.toFixed(2)}ms
+
+
+
Avg per Component
+
${this._performanceSummary.avgPerComponent.toFixed(3)}ms
+
+
+
Issues
+
+ ${this._performanceSummary.warnings + this._performanceSummary.errors} +
+
+
+ ` + : ''} +
+ + ${!this._initialized + ? html`
+
+ Click "Generate 50+ Components" to start stress test... +
` + : this._renderComponents()} +
+ `; + } + + private _renderComponents(): TemplateResult { + const groupedByType = { + simple: this._components.filter((c) => c.type === 'simple'), + medium: this._components.filter((c) => c.type === 'medium'), + complex: this._components.filter((c) => c.type === 'complex') + }; + + return html` +
+ ${this._renderComponentGroup('Simple Components (Message Base)', 'simple', groupedByType.simple)} +
+ +
+ ${this._renderComponentGroup('Medium Complexity (Message Box + Visibility)', 'medium', groupedByType.medium)} +
+ +
+ ${this._renderComponentGroup('High Complexity (Alert Box + Multiple Features)', 'complex', groupedByType.complex)} +
+ `; + } + + private _renderComponentGroup( + title: string, + type: 'simple' | 'medium' | 'complex', + components: typeof this._components + ): TemplateResult { + const badgeClass = + type === 'simple' ? 'badge-simple' : type === 'medium' ? 'badge-medium' : 'badge-complex'; + + return html` +
+ ${type} + ${title} (${components.length}) +
+ +
+ ${components.map((comp) => this._renderComponentInstance(comp))} +
+ `; + } + + private _renderComponentInstance(comp: typeof this._components[0]): TemplateResult { + const messagePrefix = `[${comp.type}]`; + + if (comp.type === 'simple') { + return html` +
+ ${messagePrefix} ${comp.message} +
ID: ${comp.id} | Type: message-base
+
+ `; + } else if (comp.type === 'medium') { + return html` +
+ + ${messagePrefix} ${comp.message} + +
ID: ${comp.id} | Type: message-box | Visible: ${comp.visible !== false ? 'yes' : 'no'}
+
+ `; + } else { + // complex + return html` +
+ + ${messagePrefix} ${comp.message} + +
+ ID: ${comp.id} | Type: alert-box | Timer: ${comp.hasTimer ? 'yes' : 'no'} +
+
+ `; + } + } + + private _handleGenerateComponents(): void { + performanceMonitor.mark('component-generation-start'); + + const components: typeof this._components = []; + const statuses: StatusType[] = ['info', 'success', 'warning', 'error']; + + // Generate 20 simple components + performanceMonitor.mark('generate-simple-start'); + for (let i = 0; i < 20; i++) { + components.push({ + id: i, + type: 'simple', + status: statuses[i % statuses.length], + message: `Simple message ${i + 1}: Basic notification component` + }); + } + const simpleTime = performanceMonitor.measure('generate-simple', { + markStart: 'generate-simple-start', + threshold: 0.5, + context: { count: 20 } + }); + + // Generate 15 medium complexity components + performanceMonitor.mark('generate-medium-start'); + for (let i = 0; i < 15; i++) { + components.push({ + id: 20 + i, + type: 'medium', + status: statuses[i % statuses.length], + message: `Medium message ${i + 1}: Component with visibility feature`, + visible: i % 3 !== 0 // Some hidden + }); + } + const mediumTime = performanceMonitor.measure('generate-medium', { + markStart: 'generate-medium-start', + threshold: 0.5, + context: { count: 15 } + }); + + // Generate 15 complex components + performanceMonitor.mark('generate-complex-start'); + for (let i = 0; i < 15; i++) { + components.push({ + id: 35 + i, + type: 'complex', + status: statuses[i % statuses.length], + message: `Complex message ${i + 1}: Dismiss and visibility features`, + visible: true, + hasTimer: i % 2 === 0 // Half have timers + }); + } + const complexTime = performanceMonitor.measure('generate-complex', { + markStart: 'generate-complex-start', + threshold: 0.5, + context: { count: 15 } + }); + + // Update component list and stats + this._components = components; + this._componentCount = components.length; + + // Measure total generation time + performanceMonitor.mark('component-render-start'); + this.requestUpdate(); + + // Use microtask to measure render completion + Promise.resolve().then(() => { + const totalTime = performanceMonitor.measure('component-generation-total', { + markStart: 'component-generation-start', + threshold: 10, + context: { componentCount: components.length }, + alwaysLog: true + }); + + // Calculate performance metrics + const avg = totalTime / components.length; + const metrics = performanceMonitor.getSummary(); + + this._performanceSummary = { + totalTime, + componentCount: components.length, + avgPerComponent: avg, + warnings: metrics.warnings, + errors: metrics.errors + }; + + this._initialized = true; + + // Check if averages indicate scaling issues + const scaledTo2500 = avg * 2500; + if (scaledTo2500 > 500) { + console.warn( + `⚠️ Scaling concern: Average ${avg.toFixed(3)}ms per component would result in ${scaledTo2500.toFixed(0)}ms for 2500 components` + ); + } + + console.log( + `✅ Generated ${components.length} components in ${totalTime.toFixed(2)}ms (${avg.toFixed(3)}ms each)` + ); + console.log( + ` - Simple (20): ${simpleTime.toFixed(2)}ms (${(simpleTime / 20).toFixed(3)}ms each)` + ); + console.log( + ` - Medium (15): ${mediumTime.toFixed(2)}ms (${(mediumTime / 15).toFixed(3)}ms each)` + ); + console.log( + ` - Complex (15): ${complexTime.toFixed(2)}ms (${(complexTime / 15).toFixed(3)}ms each)` + ); + }); + } + + private _handleClear(): void { + this._components = []; + this._initialized = false; + this._componentCount = 0; + this._performanceSummary = { + totalTime: 0, + componentCount: 0, + avgPerComponent: 0, + warnings: 0, + errors: 0 + }; + performanceMonitor.clearMetrics(); + } + + private _handleLogMetrics(): void { + performanceMonitor.logSummary(); + } +} + +customElements.define('stress-test', StressTest); + +declare global { + interface HTMLElementTagNameMap { + 'stress-test': StressTest; + } +} diff --git a/src/components/super-stress-test.ts b/src/components/super-stress-test.ts new file mode 100644 index 0000000..4ce5401 --- /dev/null +++ b/src/components/super-stress-test.ts @@ -0,0 +1,355 @@ +import { html, css, TemplateResult, CSSResultGroup, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { performanceMonitor } from '../root/performance-monitor.js'; + +// Import all notification components +import './message-base.js'; +import './message-box.js'; +import './alert-box.js'; +import './toast-notification.js'; + +import type { StatusType } from '../features/status-feature.js'; + +/** + * Super Stress Test Component + * + * Silently generates 500 of the most complex components (AlertBox) to test performance. + * Designed for quick standup demos - no console spam, only final timing. + * + * @element super-stress-test + */ +export class SuperStressTest extends LitElement { + @state() + private _isRunning: boolean = false; + + @state() + private _isComplete: boolean = false; + + @state() + private _totalTime: number = 0; + + @state() + private _componentCount: number = 0; + + @state() + private _components: Array<{ + id: number; + status: StatusType; + message: string; + }> = []; + + static override styles: CSSResultGroup = css` + :host { + display: block; + width: 100%; + height: 100vh; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + } + + .container { + text-align: center; + backdrop-filter: blur(10px); + background: rgba(255, 255, 255, 0.95); + padding: 60px 40px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 600px; + } + + h1 { + font-size: 36px; + font-weight: 700; + margin: 0 0 16px; + color: #1a1a1a; + } + + .subtitle { + font-size: 16px; + color: #666; + margin: 0 0 40px; + } + + .status { + font-size: 18px; + color: #667eea; + font-weight: 600; + margin-bottom: 24px; + height: 24px; + } + + .spinner { + display: inline-block; + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 24px; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .results { + display: none; + } + + .results.show { + display: block; + } + + .time-display { + font-size: 64px; + font-weight: 700; + color: #667eea; + margin: 32px 0; + font-family: 'Monaco', 'Courier New', monospace; + } + + .unit { + font-size: 24px; + color: #999; + } + + .details { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin-top: 24px; + text-align: left; + font-size: 14px; + font-family: monospace; + color: #333; + } + + .detail-row { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #e0e0e0; + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + color: #666; + } + + .detail-value { + color: #1a1a1a; + font-weight: 600; + } + + .rating { + margin-top: 24px; + font-size: 32px; + } + + button { + margin-top: 24px; + padding: 12px 32px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + background: #667eea; + color: white; + transition: all 0.2s; + } + + button:hover { + background: #764ba2; + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + } + + button:active { + transform: translateY(0); + } + + .hidden { + display: none; + } + + .components-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; + opacity: 0.3; + z-index: -1; + } + + .component-item { + position: absolute; + width: 300px; + opacity: 0.5; + } + `; + + override firstUpdated(): void { + performanceMonitor.disable(); + } + + override render(): TemplateResult { + return html` +
+ ${!this._isComplete + ? html` +

⚡ Super Stress Test

+

Silently rendering 500 complex components...

+ + ${this._isRunning + ? html` +
+
Generating ${this._componentCount}/500...
+ ` + : html` +
+ + `} + ` + : html` +

✅ Test Complete!

+ +
+
+ ${this._totalTime.toFixed(0)}ms +
+ +
+
+ Components Generated: + ${this._componentCount} +
+
+ Total Time: + ${this._totalTime.toFixed(2)}ms +
+
+ Time per Component: + ${(this._totalTime / this._componentCount).toFixed(3)}ms +
+
+ Projected 2500: + ${((this._totalTime / this._componentCount) * 2500).toFixed(0)}ms +
+
+ +
+ ${this._getRating()} +
+ + +
+ `} +
+ +
+ ${this._components.map((comp) => this._renderHiddenComponent(comp))} +
+ `; + } + + private _getRating(): string { + const avg = this._totalTime / this._componentCount; + + if (avg < 0.4) return '🚀 Blazing fast!'; + if (avg < 0.6) return '⚡ Very fast'; + if (avg < 0.8) return '✅ Fast'; + if (avg < 1.0) return '👍 Acceptable'; + if (avg < 1.5) return '⚠️ Slow'; + return '❌ Very slow'; + } + + private _renderHiddenComponent(comp: (typeof this._components)[0]): TemplateResult { + return html` +
+ + ${comp.message} + +
+ `; + } + + private _handleRun(): void { + this._isRunning = true; + this._isComplete = false; + this._components = []; + this._componentCount = 0; + this._totalTime = 0; + + // Use requestAnimationFrame to break up work and allow renders between batches + const startTime = performance.now(); + const statuses: StatusType[] = ['info', 'success', 'warning', 'error']; + const componentsToGenerate = 500; + const batchSize = 50; // Process in batches of 50 + + const generateBatch = (batchIndex: number) => { + const start = batchIndex * batchSize; + const end = Math.min(start + batchSize, componentsToGenerate); + + for (let i = start; i < end; i++) { + this._components.push({ + id: i, + status: statuses[i % statuses.length], + message: `Component ${i + 1}` + }); + } + + this._componentCount = end; + this.requestUpdate(); + + if (end < componentsToGenerate) { + requestAnimationFrame(() => generateBatch(batchIndex + 1)); + } else { + const endTime = performance.now(); + this._totalTime = endTime - startTime; + this._isRunning = false; + this._isComplete = true; + + // Log only the final result + console.log(`✅ Super Stress Test Complete`); + console.log(`📊 Generated ${this._componentCount} complex components in ${this._totalTime.toFixed(2)}ms`); + console.log(`⏱️ Average per component: ${(this._totalTime / this._componentCount).toFixed(3)}ms`); + console.log(`📈 Projected at 2500 components: ${((this._totalTime / this._componentCount) * 2500).toFixed(0)}ms`); + } + }; + + generateBatch(0); + } + + private _handleReset(): void { + this._isComplete = false; + this._isRunning = false; + this._components = []; + this._componentCount = 0; + this._totalTime = 0; + } +} + +customElements.define('super-stress-test', SuperStressTest); + +declare global { + interface HTMLElementTagNameMap { + 'super-stress-test': SuperStressTest; + } +} diff --git a/src/root/lit-core.ts b/src/root/lit-core.ts index c1e6f15..4dc6d7d 100644 --- a/src/root/lit-core.ts +++ b/src/root/lit-core.ts @@ -1,6 +1,7 @@ import { LitElement, PropertyDeclaration } from 'lit'; import { FeatureConfigEntry, FeatureDefinition, FeatureManager, LitCoreConstructor } from './services/feature-manager.js'; import { resolveFeatures } from './feature-resolver.js'; +import { performanceMonitor } from './performance-monitor.js'; /** * Symbol to mark classes that extend LitCore. @@ -32,8 +33,18 @@ export class LitCore extends LitElement { static properties: Record = {}; constructor() { + const markName = `lt-core-constructor-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); + super(); this.featureManager = new FeatureManager(this, this.constructor as LitCoreConstructor); + + const componentName = this.constructor.name || 'UnknownComponent'; + performanceMonitor.measure(`component-creation-${componentName}`, { + markStart: markName, + threshold: 0.5, + context: { component: componentName } + }); } /** diff --git a/src/root/lit-feature.ts b/src/root/lit-feature.ts index 003aafc..3eb9244 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -2,6 +2,7 @@ import type { LitCore } from './lit-core.ts'; import type { PropertyDeclaration } from 'lit'; import type { ReactiveController } from 'lit'; import { DebugUtils } from './debug-utils.js'; +import { performanceMonitor } from './performance-monitor.js'; /** * Base interface for feature configuration objects @@ -36,6 +37,9 @@ export abstract class LitFeature static properties: FeatureProperties = {}; constructor(host: LitCore, config: TConfig) { + const markName = `feature-constructor-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); + const featureName = this.constructor.name || 'UnnamedFeature'; const hostName = host.constructor.name || 'UnknownHost'; DebugUtils.logProperties('feature-constructor', `Constructing ${featureName} on host ${hostName}`); @@ -47,6 +51,12 @@ export abstract class LitFeature (this.host as any).addController?.(this); this._litFeatureInit(); + + performanceMonitor.measure(`feature-init-${featureName}`, { + markStart: markName, + threshold: 0.1, + context: { feature: featureName, host: hostName } + }); } private _litFeatureInit(): void { @@ -80,6 +90,9 @@ export abstract class LitFeature const featureName = this.constructor.name || 'UnnamedFeature'; DebugUtils.logProperties('property-observer-create', `Creating property descriptor for ${featureName}.${propertyName}`); + const markName = `property-observer-${featureName}-${propertyName}-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); + const feature = this; Object.defineProperty(this, propertyName, { configurable: true, @@ -90,6 +103,9 @@ export abstract class LitFeature return value; }, set(newValue: unknown) { + const setMarkName = `property-set-${featureName}-${propertyName}-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(setMarkName); + const hostRecord = feature.host as unknown as Record; const oldValue = hostRecord[propertyName]; const internalValue = feature.getInternalValue(propertyName); @@ -129,8 +145,20 @@ export abstract class LitFeature } else if (feature._suspendUpdates) { DebugUtils.logWiring('property-request-update-suspended', ` → Update suspended for: ${propertyName}`); } + + performanceMonitor.measure(`property-set-${featureName}`, { + markStart: setMarkName, + threshold: 0.1, + context: { property: propertyName } + }); } }); + + performanceMonitor.measure(`property-observer-create-${featureName}`, { + markStart: markName, + threshold: 0.05, + context: { property: propertyName } + }); } /** diff --git a/src/root/performance-monitor.ts b/src/root/performance-monitor.ts new file mode 100644 index 0000000..d461f3d --- /dev/null +++ b/src/root/performance-monitor.ts @@ -0,0 +1,270 @@ +/** + * Performance Monitor + * + * Tracks performance metrics for the component system and logs only when + * potential issues or slowdowns are detected. Uses harsh thresholds to + * identify bottlenecks early, assuming the system could scale to 2500+ + * components with multiple features each. + * + * Philosophy: Only log when there's a problem, but be aggressive about + * what constitutes a "problem" to catch scaling issues early. + */ + +interface PerformanceMetrics { + name: string; + startTime: number; + endTime?: number; + duration?: number; + severity: 'info' | 'warning' | 'error'; + context?: Record; +} + +interface PerformanceThresholds { + /** Component creation time threshold (ms) - assume 2500 components each taking X ms = total init time */ + componentCreation: number; + /** Feature initialization time threshold (ms) - features add up across multiple instances */ + featureInit: number; + /** Render batch time threshold (ms) - rendering many components at once */ + renderBatch: number; + /** Property reconciliation threshold (ms) - feature property syncing */ + propertyReconciliation: number; + /** Total initialization time for a component (ms) */ + totalInit: number; +} + +class PerformanceMonitor { + private static readonly instance = new PerformanceMonitor(); + private metrics: PerformanceMetrics[] = []; + private enabled: boolean = true; + private verbose: boolean = false; + + // Thresholds - being VERY harsh to catch scaling issues early + // If we have 2500 components and each takes X ms, total is 2500X + private thresholds: PerformanceThresholds = { + componentCreation: 0.5, // > 0.5ms per component = worry + featureInit: 0.1, // > 0.1ms per feature = worry + renderBatch: 5, // > 5ms for rendering batch + propertyReconciliation: 0.2, // > 0.2ms for property syncing + totalInit: 3 // > 3ms total to initialize one component + }; + + private marks: Map = new Map(); + + static getInstance(): PerformanceMonitor { + return PerformanceMonitor.instance; + } + + enable(): void { + this.enabled = true; + } + + disable(): void { + this.enabled = false; + } + + setVerbose(verbose: boolean): void { + this.verbose = verbose; + } + + setThresholds(thresholds: Partial): void { + this.thresholds = { ...this.thresholds, ...thresholds }; + } + + /** + * Mark the start of a measured operation + */ + mark(name: string): void { + this.marks.set(name, performance.now()); + } + + /** + * Measure from a previous mark and optionally log if threshold exceeded + */ + measure( + name: string, + options?: { + markStart?: string; + threshold?: number; + context?: Record; + alwaysLog?: boolean; + } + ): number { + if (!this.enabled) return 0; + + const markStart = options?.markStart || name; + const startTime = this.marks.get(markStart); + + if (!startTime) { + this.logWarning(`No start mark found for "${markStart}"`); + return 0; + } + + const endTime = performance.now(); + const duration = endTime - startTime; + const threshold = options?.threshold; + + // Determine if we should log based on threshold and severity + const shouldLog = + options?.alwaysLog || + (threshold !== undefined && duration > threshold) || + this._determineSeverity(name, duration) !== 'info'; + + if (shouldLog) { + const severity = this._determineSeverity(name, duration); + this._logMetric({ + name, + startTime, + endTime, + duration, + severity, + context: options?.context + }); + } + + // Clean up mark + this.marks.delete(markStart); + + return duration; + } + + /** + * Measure synchronous code execution + */ + time( + name: string, + fn: () => T, + options?: { + threshold?: number; + context?: Record; + alwaysLog?: boolean; + } + ): { result: T; duration: number } { + this.mark(name); + const result = fn(); + const duration = this.measure(name, options); + return { result, duration }; + } + + /** + * Get all recorded metrics + */ + getMetrics(): PerformanceMetrics[] { + return [...this.metrics]; + } + + /** + * Clear all metrics + */ + clearMetrics(): void { + this.metrics = []; + } + + /** + * Get metrics summary + */ + getSummary(): { + total: number; + warnings: number; + errors: number; + avgDuration: number; + totalDuration: number; + } { + const summary = { + total: this.metrics.length, + warnings: this.metrics.filter((m) => m.severity === 'warning').length, + errors: this.metrics.filter((m) => m.severity === 'error').length, + avgDuration: 0, + totalDuration: 0 + }; + + if (this.metrics.length > 0) { + summary.totalDuration = this.metrics.reduce((sum, m) => sum + (m.duration || 0), 0); + summary.avgDuration = summary.totalDuration / this.metrics.length; + } + + return summary; + } + + /** + * Log performance summary to console + */ + logSummary(): void { + if (!this.enabled) return; + + const summary = this.getSummary(); + console.group('📊 Performance Summary'); + console.log(`Total Measurements: ${summary.total}`); + console.log(`⚠️ Warnings: ${summary.warnings}`); + console.log(`❌ Errors: ${summary.errors}`); + console.log(`Average Duration: ${summary.avgDuration.toFixed(3)}ms`); + console.log(`Total Duration: ${summary.totalDuration.toFixed(3)}ms`); + + if (summary.warnings > 0 || summary.errors > 0) { + console.group('Issues Found:'); + const issues = this.metrics.filter((m) => m.severity !== 'info'); + issues.forEach((metric) => { + const icon = metric.severity === 'error' ? '❌' : '⚠️'; + console.log( + `${icon} ${metric.name}: ${metric.duration?.toFixed(3)}ms`, + metric.context || '' + ); + }); + console.groupEnd(); + } + + console.groupEnd(); + } + + /** + * Determine severity based on metric type and duration + */ + private _determineSeverity(name: string, duration: number): 'info' | 'warning' | 'error' { + let threshold = 0; + let errorMultiplier = 2; + + if (name.includes('component-creation')) { + threshold = this.thresholds.componentCreation; + } else if (name.includes('feature-init')) { + threshold = this.thresholds.featureInit; + } else if (name.includes('render')) { + threshold = this.thresholds.renderBatch; + } else if (name.includes('property') || name.includes('reconciliation')) { + threshold = this.thresholds.propertyReconciliation; + } else if (name.includes('total-init') || name.includes('total')) { + threshold = this.thresholds.totalInit; + } else { + return 'info'; + } + + if (duration > threshold * errorMultiplier) { + return 'error'; + } else if (duration > threshold) { + return 'warning'; + } + + return 'info'; + } + + /** + * Internal metric logging + */ + private _logMetric(metric: PerformanceMetrics): void { + this.metrics.push(metric); + + if (this.verbose || metric.severity !== 'info') { + const icon = metric.severity === 'error' ? '❌' : metric.severity === 'warning' ? '⚠️' : 'ℹ️'; + const contextStr = metric.context + ? ` ${JSON.stringify(metric.context)}` + : ''; + console.log( + `${icon} [${metric.duration?.toFixed(3)}ms] ${metric.name}${contextStr}` + ); + } + } + + private logWarning(message: string): void { + console.warn(`⚠️ PerformanceMonitor: ${message}`); + } +} + +export const performanceMonitor = PerformanceMonitor.getInstance(); diff --git a/src/root/services/feature-manager.ts b/src/root/services/feature-manager.ts index 4570e7e..70b7c64 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -3,6 +3,7 @@ import type { LitFeature } from '../lit-feature.js'; import type { LitCore } from '../lit-core.js'; import { resolveFeatures } from '../feature-resolver.js'; import { DebugUtils } from '../debug-utils.js'; +import { performanceMonitor } from '../performance-monitor.js'; import type { FeatureConfig, LitCoreConstructor, ResolvedFeatures } from '../types/feature-types.js'; // Re-export types for backward compatibility (deprecated) @@ -42,6 +43,9 @@ export class FeatureManager { * Initialize all features from resolved state */ private _initializeFeatures(resolved: ResolvedFeatures): void { + const markName = `feature-manager-init-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); + const hostName = (this.host as any).constructor?.name || 'Unknown'; DebugUtils.logProperties('init-start', `Starting feature instantiation for host: ${hostName}`, { featureCount: resolved.features.size @@ -87,6 +91,15 @@ Feature will be assigned to _${featureName} to avoid overwriting the host proper } DebugUtils.logProperties('init-complete', `Feature instantiation complete for host: ${hostName}`); + + performanceMonitor.measure(`feature-manager-init-${hostName}`, { + markStart: markName, + threshold: 0.5, + context: { + component: hostName, + featureCount: resolved.features.size + } + }); } /** diff --git a/stress-test.html b/stress-test.html new file mode 100644 index 0000000..f9550f5 --- /dev/null +++ b/stress-test.html @@ -0,0 +1,18 @@ + + + + + + LitFeature - Stress Test + + + + + + + + diff --git a/super-stress-test.html b/super-stress-test.html new file mode 100644 index 0000000..8574025 --- /dev/null +++ b/super-stress-test.html @@ -0,0 +1,18 @@ + + + + + + LitFeature - Super Stress Test + + + + + + + + From 94853300a35b0a879accdce330a6875b1e209108 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 14:19:15 -0600 Subject: [PATCH 13/14] refactor: update site layout and styles to be more user-friendly and match Lit branding --- index.css | 332 ++++++++++++++++++++++++- index.html | 9 +- src/components/alert-box.ts | 17 +- src/components/app-router.ts | 107 +++++++++ src/components/demo-page.ts | 19 ++ src/components/home-page.ts | 346 +++++++++++++++++++++++++++ src/components/message-base.ts | 37 +-- src/components/nav-bar.ts | 170 +++++++++++++ src/components/notification-demo.ts | 140 ++++++----- src/components/stress-test.ts | 206 +++++++++------- src/components/super-stress-test.ts | 83 ++++--- src/components/toast-notification.ts | 10 +- stress-test.html | 14 +- super-stress-test.html | 14 +- 14 files changed, 1271 insertions(+), 233 deletions(-) create mode 100644 src/components/app-router.ts create mode 100644 src/components/demo-page.ts create mode 100644 src/components/home-page.ts create mode 100644 src/components/nav-bar.ts diff --git a/index.css b/index.css index 8e00a50..c0dcb23 100644 --- a/index.css +++ b/index.css @@ -1,22 +1,338 @@ -:root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - line-height: 1.5; - font-weight: 400; +/* ======================================== */ +/* LitFeature - Dark Mode Styling */ +/* Inspired by lit.dev design system */ +/* ======================================== */ - color: #213547; - background-color: #f5f7fa; +:root { + /* Colors */ + --color-bg-strong: #030303; + --color-bg-content: #1f1f1f; + --color-bg-secondary: #2a2a2a; + --color-bg-tertiary: #353535; + --color-accent-primary: #4d64ff; + --color-accent-secondary: #90ffff; + --color-text-primary: #e0e0e0; + --color-text-secondary: #a0a0a0; + --color-border: #404040; + /* Typography */ + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 16px; + line-height: 1.6; + font-weight: 400; + + /* Rendering optimization */ font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* Default text and background */ + color: var(--color-text-primary); + background-color: var(--color-bg-strong); } -body { +* { + box-sizing: border-box; +} + +html, body { margin: 0; + padding: 0; + height: 100%; +} + +body { min-width: 320px; min-height: 100vh; + background: var(--color-bg-strong); + color: var(--color-text-primary); +} + +/* ======================================== */ +/* Typography */ +/* ======================================== */ + +h1 { + font-size: 42px; + font-weight: 700; + letter-spacing: -1px; + margin: 0 0 16px 0; + background: linear-gradient(135deg, var(--color-accent-primary) 0%, var(--color-accent-secondary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +h2 { + font-size: 32px; + font-weight: 700; + margin: 32px 0 16px 0; + color: var(--color-text-primary); +} + +h3 { + font-size: 24px; + font-weight: 600; + margin: 24px 0 12px 0; + color: var(--color-text-primary); +} + +h4 { + font-size: 18px; + font-weight: 600; + margin: 16px 0 8px 0; + color: var(--color-text-primary); +} + +p { + margin: 0 0 16px 0; + color: var(--color-text-secondary); + line-height: 1.8; +} + +a { + color: var(--color-accent-secondary); + text-decoration: none; + transition: all 0.2s ease; + border-bottom: 1px solid transparent; +} + +a:hover { + color: var(--color-accent-primary); + border-bottom-color: var(--color-accent-primary); +} + +/* ======================================== */ +/* Buttons */ +/* ======================================== */ + +button { + padding: 10px 20px; + border: 2px solid var(--color-accent-primary); + background: linear-gradient(135deg, var(--color-accent-primary) 0%, rgba(77, 100, 255, 0.8) 100%); + color: #fff; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(77, 100, 255, 0.2); +} + +button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(77, 100, 255, 0.4); +} + +button:active { + transform: translateY(0); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* ======================================== */ +/* Forms */ +/* ======================================== */ + +input, textarea, select { + padding: 10px 12px; + border: 1px solid var(--color-border); + background: var(--color-bg-content); + color: var(--color-text-primary); + border-radius: 6px; + font-size: 16px; + font-family: inherit; + transition: all 0.2s ease; +} + +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--color-accent-primary); + box-shadow: 0 0 0 3px rgba(77, 100, 255, 0.1); +} + +/* ======================================== */ +/* Code and Pre */ +/* ======================================== */ + +code { + background: var(--color-bg-content); + color: var(--color-accent-secondary); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + font-size: 14px; +} + +pre { + background: var(--color-bg-content); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + overflow-x: auto; + margin: 16px 0; +} + +pre code { + background: none; + color: var(--color-text-primary); + padding: 0; +} + +/* ======================================== */ +/* Utility Classes */ +/* ======================================== */ + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 32px; +} + +.grid { + display: grid; + gap: 24px; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.card { + background: var(--color-bg-content); + border: 1px solid var(--color-border); + border-radius: 12px; padding: 24px; - box-sizing: border-box; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.card:hover { + border-color: var(--color-accent-primary); + box-shadow: 0 8px 24px rgba(77, 100, 255, 0.15); + transform: translateY(-2px); +} + +.section { + background: var(--color-bg-content); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 32px; + margin: 24px 0; +} + +.badge { + display: inline-block; + padding: 4px 12px; + background: rgba(77, 100, 255, 0.2); + color: var(--color-accent-secondary); + border-radius: 20px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--color-accent-primary); +} + +/* ======================================== */ +/* Scrollbar Styling */ +/* ======================================== */ + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg-content); +} + +::-webkit-scrollbar-thumb { + background: var(--color-accent-primary); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-accent-secondary); +} + +/* ======================================== */ +/* Responsive Design */ +/* ======================================== */ + +@media (max-width: 768px) { + :root { + font-size: 14px; + } + + h1 { + font-size: 32px; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 20px; + } + + .container { + padding: 0 16px; + } + + .section { + padding: 16px; + } + + .grid { + grid-template-columns: 1fr; + } +} + +/* ======================================== */ +/* Animations */ +/* ======================================== */ + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } } diff --git a/index.html b/index.html index 852e9a2..e9f2398 100644 --- a/index.html +++ b/index.html @@ -5,14 +5,9 @@ LitFeature - Notification System Demo - + - - + diff --git a/src/components/alert-box.ts b/src/components/alert-box.ts index 89ddcdb..52f9f79 100644 --- a/src/components/alert-box.ts +++ b/src/components/alert-box.ts @@ -49,27 +49,34 @@ export class AlertBox extends MessageBox { css` .alert-box { position: relative; - padding-right: 40px; /* Make room for close button */ + padding-right: 44px; } .dismiss-button { position: absolute; top: 50%; - right: 8px; + right: 12px; transform: translateY(-50%); - background: none; + background: rgba(255, 255, 255, 0.08); border: none; padding: 4px 8px; cursor: pointer; - font-size: 18px; + font-size: 24px; line-height: 1; - opacity: 0.6; + opacity: 0.7; transition: opacity 0.2s; color: inherit; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; } .dismiss-button:hover { opacity: 1; + background: rgba(255, 255, 255, 0.15); } .dismiss-button:focus { diff --git a/src/components/app-router.ts b/src/components/app-router.ts new file mode 100644 index 0000000..7255b13 --- /dev/null +++ b/src/components/app-router.ts @@ -0,0 +1,107 @@ +import { html, css, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import './nav-bar.js'; +import './home-page.js'; +import './notification-demo.js'; +import './stress-test.js'; +import './super-stress-test.js'; + +/** + * AppRouter Component + * + * Main application routing layer that handles: + * - Navigation between pages + * - Hash-based routing + * - Dynamic page loading + * - Shared navigation bar + */ +@customElement('app-router') +export class AppRouter extends LitElement { + @state() + private currentPage: 'home' | 'demo' | 'stress-test' | 'super-stress-test' = 'home'; + + static override styles = css` + :host { + display: block; + min-height: 100vh; + background: #030303; + color: #e0e0e0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; + } + + .page-container { + max-width: 1200px; + margin: 0 auto; + padding: 48px 32px; + animation: fadeIn 0.3s ease-in; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (max-width: 768px) { + .page-container { + padding: 24px 16px; + } + } + `; + + constructor() { + super(); + this.handleHashChange(); + window.addEventListener('hashchange', () => this.handleHashChange()); + } + + private handleHashChange() { + const hash = window.location.hash.slice(1) || 'home'; + const validPages: Record = { + home: 'home', + demo: 'demo', + 'stress-test': 'stress-test', + 'super-stress-test': 'super-stress-test', + }; + this.currentPage = validPages[hash] || 'home'; + } + + private handleNavigate(e: Event) { + const event = e as CustomEvent<{ page: string }>; + const page = event.detail.page as 'home' | 'demo' | 'stress-test' | 'super-stress-test'; + this.currentPage = page; + window.location.hash = page === 'home' ? '' : page; + } + + override render() { + return html` + this.handleNavigate(e)} + > +
+ ${this.renderPage()} +
+ `; + } + + private renderPage() { + switch (this.currentPage) { + case 'home': + return html``; + case 'demo': + return html``; + case 'stress-test': + return html``; + case 'super-stress-test': + return html``; + default: + return html``; + } + } +} diff --git a/src/components/demo-page.ts b/src/components/demo-page.ts new file mode 100644 index 0000000..8bf46ce --- /dev/null +++ b/src/components/demo-page.ts @@ -0,0 +1,19 @@ +import { html, TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import './notification-demo.js'; + +/** + * DemoPage Component + * + * Wrapper for the notification demo + */ +@customElement('demo-page') +export class DemoPage extends HTMLElement { + override connectedCallback(): void { + this.innerHTML = ''; + } + + override render(): TemplateResult { + return html``; + } +} diff --git a/src/components/home-page.ts b/src/components/home-page.ts new file mode 100644 index 0000000..4f4ca17 --- /dev/null +++ b/src/components/home-page.ts @@ -0,0 +1,346 @@ +import { html, css, TemplateResult, CSSResultGroup, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +/** + * HomePage Component + * + * Explains the LitFeature system with content from README and PROPOSAL + */ +@customElement('home-page') +export class HomePage extends LitElement { + static override styles: CSSResultGroup = css` + :host { + display: block; + width: 100%; + } + + .hero { + text-align: center; + margin-bottom: 64px; + } + + .hero h1 { + font-size: 56px; + font-weight: 700; + margin: 0 0 20px 0; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -2px; + } + + .hero p { + font-size: 20px; + color: #a0a0a0; + max-width: 600px; + margin: 0 auto 32px; + line-height: 1.8; + } + + .cta-button { + display: inline-block; + padding: 14px 40px; + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.8) 100%); + color: white; + border: 2px solid #4d64ff; + border-radius: 10px; + font-size: 16px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 4px 12px rgba(77, 100, 255, 0.2); + } + + .cta-button:hover { + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + box-shadow: 0 6px 20px rgba(77, 100, 255, 0.4); + transform: translateY(-2px); + } + + .content { + max-width: 900px; + margin: 0 auto; + } + + .section { + margin-bottom: 48px; + } + + .section h2 { + font-size: 32px; + font-weight: 700; + margin: 0 0 24px 0; + color: #e0e0e0; + } + + .section p { + color: #a0a0a0; + font-size: 16px; + line-height: 1.8; + margin: 0 0 20px 0; + } + + .section ul { + list-style: none; + padding: 0; + margin: 0; + } + + .section li { + padding: 12px 0; + color: #a0a0a0; + font-size: 16px; + line-height: 1.8; + border-bottom: 1px solid #404040; + } + + .section li:last-child { + border-bottom: none; + } + + .section strong { + color: #e0e0e0; + } + + .feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 24px; + margin: 32px 0; + } + + .feature-card { + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 10px; + padding: 28px; + transition: all 0.3s ease; + } + + .feature-card:hover { + border-color: #4d64ff; + background: #323232; + } + + .feature-card h3 { + font-size: 20px; + font-weight: 700; + margin: 0 0 12px 0; + color: #90ffff; + } + + .feature-card p { + margin: 0; + font-size: 14px; + } + + .code-block { + background: #1a1a1a; + border: 1px solid #404040; + border-radius: 8px; + padding: 20px; + overflow-x: auto; + margin: 20px 0; + font-family: 'Courier New', monospace; + font-size: 13px; + color: #90ffff; + line-height: 1.6; + } + + .highlight { + color: #4d64ff; + font-weight: 600; + } + + .byline { + font-size: 13px; + color: #808080; + margin: 24px 0 12px 0; + font-style: italic; + } + + .footer { + margin-top: 80px; + padding-top: 40px; + border-top: 1px solid #404040; + text-align: center; + font-size: 13px; + color: #808080; + } + + .footer a { + color: #4d64ff; + } + + .footer a:hover { + color: #90ffff; + } + `; + + override render(): TemplateResult { + return html` +
+

Composable Features for Lit

+

+ A proof-of-concept exploring an ergonomics-focused alternative to deep mixin stacks when building + component libraries and design systems with Lit. +

+ + +
+ +
+ +
+

What is LitFeature?

+

+ Large design systems need to compose multiple independent behaviors (status indicators, visibility management, + dismissal logic, timers) while enabling/disabling them per component or per subtree. Traditional approaches often + lead to unnecessary complexity and brittle deep mixin stacks. +

+

+ LitFeature introduces a cleaner model: features are small, + single-responsibility units of behavior that can be provided by base classes and + configured by subclasses, while participating fully in Lit's reactive property + system and component lifecycle. +

+
+ + +
+

Core Concepts

+
    +
  • + Features: Specialized controllers that encapsulate single-responsibility behavior like status + management, visibility, dismissal, or timers. +
  • +
  • + Provide: Base classes declare which features they make available to themselves and subclasses. +
  • +
  • + Configure: Subclasses override or disable inherited features, with configs that deep-merge down + the inheritance chain. +
  • +
  • + Property Integration: Feature properties automatically become host properties in Lit's reactive + system. +
  • +
  • + Lifecycle Participation: Features hook into host lifecycle events and can communicate with each + other. +
  • +
+
+ + +
+

Built-in Features

+
+
+

StatusFeature

+

+ Manages visual status indicators (info, success, warning, error) with icons and semantic colors. +

+
+
+

VisibilityFeature

+

+ Handles show/hide state with smooth transitions and animation callbacks. +

+
+
+

DismissFeature

+

+ Provides dismissal functionality with close buttons and dismiss callbacks. +

+
+
+

TimerFeature

+

+ Countdown timer with progress tracking, pause/resume controls, and auto-dismissal. +

+
+
+
+ + +
+

Hierarchical Composition

+

+ The demo shows a 4-level inheritance hierarchy where each level adds a new feature while maintaining and + configuring all inherited features: +

+
+
Level 1: message-base → provides StatusFeature
+
+
Level 2: message-box → provides VisibilityFeature + configures Status
+
+
Level 3: alert-box → provides DismissFeature + configures Status & Visibility
+
+
Level 4: toast-notification → provides TimerFeature + configures all
+
+

+ This demonstrates how features cleanly layer without mixin complexity, and how subclasses can reconfigure + inherited feature behavior. +

+
+ + +
+

Why This Matters

+
    +
  • + Scalability: Add new behaviors to design systems by composing features, not by creating new + mixin chains. +
  • +
  • + Maintainability: Each feature is self-contained. Changes to one don't cascade through mixins. +
  • +
  • + Flexibility: Enable/disable behaviors per component or per use-case using configuration. +
  • +
  • + Clarity: Decorators and static getters make feature composition explicit and easy to follow. +
  • +
  • + Inheritance-Aware: Leverage JavaScript's class inheritance system with first-class support for + features. +
  • +
+
+ + +
+

Integration Goal

+

+ This proof-of-concept demonstrates the desired developer experience. The architecture (LitCore, LitFeature, + decorators, and FeatureManager) would ideally be integrated directly into LitElement and ReactiveElement, making + features a native part of Lit's component model rather than a separate library. +

+
+ + +
+

Composable Features for Lit © 2026 by Stephen Rios. Open source under Apache 2.0

+
+
+ `; + } + + private _goToDemo(): void { + window.location.hash = 'demo'; + this.dispatchEvent( + new CustomEvent('navigate', { + detail: { page: 'demo' }, + bubbles: true, + composed: true, + }) + ); + } +} diff --git a/src/components/message-base.ts b/src/components/message-base.ts index a491d7d..d616dfa 100644 --- a/src/components/message-base.ts +++ b/src/components/message-base.ts @@ -37,50 +37,51 @@ export class MessageBase extends LitCore { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; - line-height: 1.5; + line-height: 1.6; } .message { - padding: 12px 16px; - border-radius: 6px; + padding: 14px 18px; + border-radius: 8px; display: flex; align-items: flex-start; - gap: 10px; + gap: 12px; } .message-icon { flex-shrink: 0; - font-size: 16px; - line-height: 1.5; + font-size: 18px; + line-height: 1.4; } .message-content { flex: 1; + font-weight: 500; } /* Status color variants */ .status-info { - background-color: #e3f2fd; - border: 1px solid #2196f3; - color: #1565c0; + background: rgba(77, 100, 255, 0.08); + border: 2px solid #4d64ff; + color: #90ffff; } .status-success { - background-color: #e8f5e9; - border: 1px solid #4caf50; - color: #2e7d32; + background: rgba(144, 255, 255, 0.08); + border: 2px solid #90ffff; + color: #90ffff; } .status-warning { - background-color: #fff3e0; - border: 1px solid #ff9800; - color: #e65100; + background: rgba(255, 193, 7, 0.08); + border: 2px solid #ffc107; + color: #ffeb3b; } .status-error { - background-color: #ffebee; - border: 1px solid #f44336; - color: #c62828; + background: rgba(244, 67, 54, 0.08); + border: 2px solid #f44336; + color: #ff6b6b; } `; diff --git a/src/components/nav-bar.ts b/src/components/nav-bar.ts new file mode 100644 index 0000000..52ac59a --- /dev/null +++ b/src/components/nav-bar.ts @@ -0,0 +1,170 @@ +import { html, css, LitElement } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +/** + * NavBar Component + * + * Shared navigation bar used across all pages + * Dispatches navigation events that can be handled by a router + */ +@customElement('nav-bar') +export class NavBar extends LitElement { + @property() + currentPage: 'home' | 'demo' | 'stress-test' | 'super-stress-test' = 'home'; + + static override styles = css` + :host { + display: block; + background: linear-gradient(135deg, #030303 0%, #1a1a1a 100%); + border-bottom: 2px solid #4d64ff; + box-shadow: 0 4px 20px rgba(77, 100, 255, 0.1); + } + + nav { + max-width: 1200px; + margin: 0 auto; + padding: 24px 32px; + display: flex; + align-items: center; + gap: 32px; + } + + .logo { + font-size: 24px; + font-weight: 700; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + cursor: pointer; + transition: transform 0.2s ease; + margin-right: 16px; + } + + .logo:hover { + transform: scale(1.05); + } + + .nav-links { + display: flex; + gap: 24px; + margin-left: auto; + list-style: none; + margin: 0; + padding: 0; + } + + a { + color: #90ffff; + text-decoration: none; + font-size: 16px; + font-weight: 500; + transition: all 0.3s ease; + padding: 8px 16px; + border-radius: 6px; + position: relative; + } + + a:hover { + color: #4d64ff; + background: rgba(77, 100, 255, 0.1); + transform: translateY(-2px); + } + + a.active { + color: #4d64ff; + background: rgba(77, 100, 255, 0.15); + border-bottom: 2px solid #4d64ff; + padding-bottom: 6px; + } + + @media (max-width: 768px) { + nav { + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 16px 24px; + } + + .nav-links { + margin-left: 0; + gap: 16px; + } + + .logo { + margin-right: 0; + } + } + `; + + private handleNavigation(page: 'home' | 'demo' | 'stress-test' | 'super-stress-test') { + this.currentPage = page; + this.dispatchEvent( + new CustomEvent('navigate', { + detail: { page }, + bubbles: true, + composed: true, + }) + ); + } + + override render() { + return html` + + `; + } +} diff --git a/src/components/notification-demo.ts b/src/components/notification-demo.ts index 1fcdab0..d5b8537 100644 --- a/src/components/notification-demo.ts +++ b/src/components/notification-demo.ts @@ -36,108 +36,126 @@ export class NotificationDemo extends LitElement { static override styles: CSSResultGroup = css` :host { display: block; - max-width: 800px; - margin: 0 auto; - padding: 24px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + width: 100%; } h1 { - font-size: 28px; - font-weight: 600; - margin: 0 0 8px; - color: #1a1a1a; + font-size: 42px; + font-weight: 700; + margin: 0 0 12px; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -1px; } h2 { - font-size: 20px; - font-weight: 600; - margin: 32px 0 16px; - color: #333; - border-bottom: 2px solid #e0e0e0; - padding-bottom: 8px; + font-size: 28px; + font-weight: 700; + margin: 40px 0 20px; + color: #e0e0e0; + display: flex; + align-items: center; + gap: 12px; } .subtitle { - color: #666; - margin: 0 0 32px; + color: #a0a0a0; + margin: 0 0 40px; + font-size: 18px; } .section { - margin-bottom: 32px; + margin-bottom: 40px; + background: #1f1f1f; + border: 1px solid #404040; + border-radius: 12px; + padding: 32px; } .demo-grid { display: grid; - gap: 16px; + gap: 20px; } .demo-row { display: flex; - gap: 12px; + gap: 16px; flex-wrap: wrap; align-items: center; } .level-badge { - display: inline-block; - background: #333; - color: white; - padding: 2px 8px; - border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.6) 100%); + color: #fff; + padding: 4px 12px; + border-radius: 6px; font-size: 12px; - font-weight: 600; - margin-right: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; } .code { - font-family: 'SF Mono', Monaco, monospace; - background: #f5f5f5; - padding: 2px 6px; - border-radius: 4px; + font-family: 'Courier New', monospace; + background: rgba(77, 100, 255, 0.1); + color: #90ffff; + padding: 4px 10px; + border-radius: 6px; font-size: 13px; + border: 1px solid rgba(144, 255, 255, 0.2); } button { - background: #333; - color: white; - border: none; - padding: 8px 16px; - border-radius: 6px; + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.8) 100%); + color: #fff; + border: 2px solid #4d64ff; + padding: 10px 20px; + border-radius: 8px; cursor: pointer; font-size: 14px; - transition: background 0.2s; + font-weight: 600; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; } button:hover { - background: #555; - } - - button.secondary { - background: #e0e0e0; - color: #333; + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); } - button.secondary:hover { - background: #d0d0d0; + button:active { + transform: translateY(0); } .hierarchy-diagram { - background: #f9f9f9; - border: 1px solid #e0e0e0; + background: #2a2a2a; + border: 1px solid #404040; border-radius: 8px; - padding: 16px; - font-family: 'SF Mono', Monaco, monospace; - font-size: 13px; - line-height: 1.8; + padding: 20px; + font-family: 'Courier New', monospace; + font-size: 14px; + line-height: 2; + color: #e0e0e0; } .hierarchy-diagram .provides { - color: #2e7d32; + color: #90ffff; + font-weight: 600; } .hierarchy-diagram .configures { - color: #1565c0; + color: #4d64ff; + font-weight: 600; + } + + .hierarchy-diagram strong { + color: #fff; } .toast-container { @@ -151,12 +169,24 @@ export class NotificationDemo extends LitElement { } .feature-list { - margin: 8px 0; - padding-left: 20px; + margin: 16px 0; + padding-left: 24px; } .feature-list li { - margin: 4px 0; + margin: 12px 0; + color: #a0a0a0; + line-height: 1.8; + } + + .feature-list strong { + color: #e0e0e0; + } + + p { + color: #a0a0a0; + line-height: 1.8; + margin: 0 0 20px 0; } `; diff --git a/src/components/stress-test.ts b/src/components/stress-test.ts index dafe99b..1e2f7a0 100644 --- a/src/components/stress-test.ts +++ b/src/components/stress-test.ts @@ -55,9 +55,6 @@ export class StressTest extends LitElement { :host { display: block; width: 100%; - padding: 20px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f8f9fa; } .container { @@ -66,175 +63,220 @@ export class StressTest extends LitElement { } .header { - background: white; - padding: 24px; - border-radius: 8px; - margin-bottom: 24px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + background: #1f1f1f; + padding: 32px; + border-radius: 12px; + margin-bottom: 32px; + border: 1px solid #404040; } h1 { - font-size: 28px; - font-weight: 600; - margin: 0 0 8px; - color: #1a1a1a; + font-size: 42px; + font-weight: 700; + margin: 0 0 12px; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -1px; } .subtitle { - color: #666; - margin: 0 0 16px; - font-size: 16px; + color: #a0a0a0; + margin: 0 0 20px; + font-size: 18px; } .controls { display: flex; gap: 12px; flex-wrap: wrap; - margin-top: 16px; + margin-top: 20px; } button { - padding: 8px 16px; - border: none; - border-radius: 4px; + padding: 10px 20px; + border-radius: 8px; font-size: 14px; - font-weight: 500; + font-weight: 600; cursor: pointer; transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; } .btn-primary { - background: #007bff; + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.8) 100%); color: white; + border: 2px solid #4d64ff; } .btn-primary:hover { - background: #0056b3; + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); } .btn-secondary { - background: #6c757d; - color: white; + background: #2a2a2a; + color: #90ffff; + border: 2px solid #90ffff; } .btn-secondary:hover { - background: #545b62; + background: #353535; + transform: translateY(-1px); } .stats { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 12px; - margin-top: 16px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-top: 20px; } .stat { - background: #f0f0f0; - padding: 12px; - border-radius: 4px; + background: #2a2a2a; + padding: 16px; + border-radius: 8px; + border: 1px solid #404040; font-size: 14px; } + .stat:hover { + border-color: #4d64ff; + } + .stat-label { - color: #666; + color: #a0a0a0; font-size: 12px; text-transform: uppercase; - margin-bottom: 4px; + margin-bottom: 8px; + letter-spacing: 0.5px; + font-weight: 600; } .stat-value { - font-size: 20px; - font-weight: 600; - color: #333; + font-size: 28px; + font-weight: 700; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } .stat-value.warning { - color: #ff9800; + background: linear-gradient(135deg, #ffc107 0%, #ffeb3b 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } .stat-value.error { - color: #f44336; + background: linear-gradient(135deg, #f44336 0%, #ff6b6b 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } .components-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 12px; - margin-top: 24px; + gap: 16px; + margin-top: 32px; } .section { - background: white; + background: #1f1f1f; padding: 24px; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-radius: 12px; + border: 1px solid #404040; + } + + .section:hover { + border-color: #4d64ff; } .section-title { font-size: 16px; - font-weight: 600; + font-weight: 700; margin: 0 0 16px; - color: #333; + color: #e0e0e0; display: flex; align-items: center; - gap: 8px; + gap: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; } .component-type-badge { display: inline-block; - padding: 2px 8px; - border-radius: 3px; + padding: 4px 10px; + border-radius: 6px; font-size: 11px; - font-weight: 600; + font-weight: 700; text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid; } .badge-simple { - background: #e3f2fd; - color: #1565c0; + background: rgba(77, 100, 255, 0.15); + color: #90ffff; + border-color: #4d64ff; } .badge-medium { - background: #fff3e0; - color: #e65100; + background: rgba(255, 193, 7, 0.15); + color: #ffeb3b; + border-color: #ffc107; } .badge-complex { - background: #f3e5f5; - color: #6a1b9a; + background: rgba(244, 67, 54, 0.15); + color: #ff6b6b; + border-color: #f44336; } .component-wrapper { - border: 1px solid #e0e0e0; - border-radius: 4px; - padding: 12px; - background: #fafafa; + border: 2px solid #404040; + border-radius: 8px; + padding: 16px; + background: rgba(77, 100, 255, 0.05); + } + + .component-wrapper:hover { + border-color: #4d64ff; + background: rgba(77, 100, 255, 0.1); } .component-meta { font-size: 12px; - color: #999; - margin-top: 8px; - font-family: monospace; + color: #808080; + margin-top: 12px; + font-family: 'Courier New', monospace; + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 4px; } .loading { display: flex; align-items: center; justify-content: center; - padding: 40px; - color: #666; + padding: 60px 20px; + color: #a0a0a0; + font-size: 18px; } .spinner { display: inline-block; - width: 20px; - height: 20px; - border: 3px solid #f3f3f3; - border-top: 3px solid #007bff; + width: 24px; + height: 24px; + border: 3px solid rgba(77, 100, 255, 0.3); + border-top: 3px solid #4d64ff; border-radius: 50%; animation: spin 1s linear infinite; - margin-right: 12px; + margin-right: 16px; } @keyframes spin { @@ -247,23 +289,25 @@ export class StressTest extends LitElement { } .warning-message { - background: #fff3cd; - border: 1px solid #ffc107; - color: #856404; - padding: 12px; - border-radius: 4px; - margin-bottom: 16px; + background: rgba(255, 193, 7, 0.1); + border: 2px solid #ffc107; + color: #ffeb3b; + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; font-size: 14px; + font-weight: 500; } .error-message { - background: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; - padding: 12px; - border-radius: 4px; - margin-bottom: 16px; + background: rgba(244, 67, 54, 0.1); + border: 2px solid #f44336; + color: #ff6b6b; + padding: 16px; + border-radius: 8px; + margin-bottom: 20px; font-size: 14px; + font-weight: 500; } `; diff --git a/src/components/super-stress-test.ts b/src/components/super-stress-test.ts index 4ce5401..57f4eb6 100644 --- a/src/components/super-stress-test.ts +++ b/src/components/super-stress-test.ts @@ -40,54 +40,57 @@ export class SuperStressTest extends LitElement { static override styles: CSSResultGroup = css` :host { - display: block; - width: 100%; - height: 100vh; - padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; + width: 100%; + min-height: 100vh; + background: #030303; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .container { text-align: center; - backdrop-filter: blur(10px); - background: rgba(255, 255, 255, 0.95); + background: linear-gradient(135deg, #1f1f1f 0%, #2a2a2a 100%); + border: 2px solid #4d64ff; padding: 60px 40px; border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-width: 600px; } h1 { - font-size: 36px; + font-size: 42px; font-weight: 700; - margin: 0 0 16px; - color: #1a1a1a; + margin: 0 0 20px; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -1px; } .subtitle { - font-size: 16px; - color: #666; + font-size: 18px; + color: #a0a0a0; margin: 0 0 40px; } .status { font-size: 18px; - color: #667eea; + color: #90ffff; font-weight: 600; margin-bottom: 24px; height: 24px; + text-transform: uppercase; + letter-spacing: 1px; } .spinner { display: inline-block; width: 40px; height: 40px; - border: 4px solid #f3f3f3; - border-top: 4px solid #667eea; + border: 4px solid rgba(77, 100, 255, 0.3); + border-top: 4px solid #4d64ff; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 24px; @@ -111,34 +114,39 @@ export class SuperStressTest extends LitElement { } .time-display { - font-size: 64px; + font-size: 72px; font-weight: 700; - color: #667eea; + background: linear-gradient(135deg, #4d64ff 0%, #90ffff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; margin: 32px 0; - font-family: 'Monaco', 'Courier New', monospace; + font-family: 'Courier New', monospace; + letter-spacing: -2px; } .unit { font-size: 24px; - color: #999; + color: #808080; } .details { - background: #f8f9fa; + background: rgba(77, 100, 255, 0.08); + border: 1px solid #4d64ff; padding: 20px; border-radius: 8px; margin-top: 24px; text-align: left; font-size: 14px; - font-family: monospace; - color: #333; + font-family: 'Courier New', monospace; + color: #e0e0e0; } .detail-row { display: flex; justify-content: space-between; - padding: 8px 0; - border-bottom: 1px solid #e0e0e0; + padding: 12px 0; + border-bottom: 1px solid rgba(77, 100, 255, 0.2); } .detail-row:last-child { @@ -146,11 +154,11 @@ export class SuperStressTest extends LitElement { } .detail-label { - color: #666; + color: #a0a0a0; } .detail-value { - color: #1a1a1a; + color: #90ffff; font-weight: 600; } @@ -162,20 +170,21 @@ export class SuperStressTest extends LitElement { button { margin-top: 24px; padding: 12px 32px; - border: none; + border: 2px solid #90ffff; border-radius: 8px; font-size: 16px; - font-weight: 600; + font-weight: 700; cursor: pointer; - background: #667eea; + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.8) 100%); color: white; transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; } button:hover { - background: #764ba2; - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); } button:active { @@ -194,7 +203,7 @@ export class SuperStressTest extends LitElement { height: 100%; pointer-events: none; overflow: hidden; - opacity: 0.3; + opacity: 0.15; z-index: -1; } @@ -283,9 +292,9 @@ export class SuperStressTest extends LitElement { private _renderHiddenComponent(comp: (typeof this._components)[0]): TemplateResult { return html`
- + ${comp.message} - +
`; } diff --git a/src/components/toast-notification.ts b/src/components/toast-notification.ts index 743d1f1..19fda1f 100644 --- a/src/components/toast-notification.ts +++ b/src/components/toast-notification.ts @@ -82,7 +82,6 @@ export class ToastNotification extends AlertBox { .toast { position: relative; max-width: var(--toast-max-width); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); overflow: hidden; } @@ -93,7 +92,7 @@ export class ToastNotification extends AlertBox { left: 0; height: 3px; background-color: currentColor; - opacity: 0.4; + opacity: 0.5; transition: width 0.1s linear; } @@ -134,12 +133,7 @@ export class ToastNotification extends AlertBox { /* Paused state visual */ .toast.paused .progress-bar { - animation: pulse 1s ease-in-out infinite; - } - - @keyframes pulse { - 0%, 100% { opacity: 0.4; } - 50% { opacity: 0.8; } + opacity: 0.3; } ` ]; diff --git a/stress-test.html b/stress-test.html index f9550f5..6b97a9b 100644 --- a/stress-test.html +++ b/stress-test.html @@ -5,14 +5,14 @@ LitFeature - Stress Test - + - - + + diff --git a/super-stress-test.html b/super-stress-test.html index 8574025..a1378ce 100644 --- a/super-stress-test.html +++ b/super-stress-test.html @@ -5,14 +5,14 @@ LitFeature - Super Stress Test - + - - + + From 4fce0b4f6927580cf4c7d47c96e7e92bbbd957c0 Mon Sep 17 00:00:00 2001 From: Stephen Rios Date: Thu, 19 Feb 2026 14:25:34 -0600 Subject: [PATCH 14/14] refactor: remove unnecessary override keywords from DemoPage methods --- .github/workflows/deploy.yml | 49 ++++++++++++++++++++++++++++++++++++ src/components/demo-page.ts | 4 +-- vite.config.ts | 9 +++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 vite.config.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a287423 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + + - name: Type check + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + - name: Deploy to GitHub Pages + id: deployment + if: github.ref == 'refs/heads/main' + uses: actions/deploy-pages@v3 diff --git a/src/components/demo-page.ts b/src/components/demo-page.ts index 8bf46ce..4665d45 100644 --- a/src/components/demo-page.ts +++ b/src/components/demo-page.ts @@ -9,11 +9,11 @@ import './notification-demo.js'; */ @customElement('demo-page') export class DemoPage extends HTMLElement { - override connectedCallback(): void { + connectedCallback(): void { this.innerHTML = ''; } - override render(): TemplateResult { + render(): TemplateResult { return html``; } } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b8df40e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + // For user/org pages (yourusername.github.io), use '/': + // base: '/', + + // For project pages (yourusername.github.io/repo-name), use: '/repo-name/' + base: '/LitFeature/', +})