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/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..d2f36ac --- /dev/null +++ b/PROPOSAL.md @@ -0,0 +1,901 @@ +# 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 +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 + +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 +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(): 'large' | 'small' { + 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 +**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/index.css b/index.css index 94bafbf..c0dcb23 100644 --- a/index.css +++ b/index.css @@ -1,29 +1,338 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +/* ======================================== */ +/* LitFeature - Dark Mode Styling */ +/* Inspired by lit.dev design system */ +/* ======================================== */ - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +: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; - display: flex; - place-items: center; + 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; } -@media (prefers-color-scheme: light) { +.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; + 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 { - color: #213547; - background-color: #ffffff; + 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 bbc7758..e9f2398 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..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 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 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 provide()` -- `@configure(name, options)` — equivalent to adding an entry in `static get configure()` +- `@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`: @@ -77,24 +77,21 @@ 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 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 provide() { - 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 configure()` 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 configure() { - 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 configure() { - 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 configure() { - 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 configure()` 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/components/alert-box.ts b/src/components/alert-box.ts new file mode 100644 index 0000000..52f9f79 --- /dev/null +++ b/src/components/alert-box.ts @@ -0,0 +1,140 @@ +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: 44px; + } + + .dismiss-button { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + background: rgba(255, 255, 255, 0.08); + border: none; + padding: 4px 8px; + cursor: pointer; + font-size: 24px; + line-height: 1; + 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 { + 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/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/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/demo-page.ts b/src/components/demo-page.ts new file mode 100644 index 0000000..4665d45 --- /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 { + connectedCallback(): void { + this.innerHTML = ''; + } + + render(): TemplateResult { + return html``; + } +} 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/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 new file mode 100644 index 0000000..d616dfa --- /dev/null +++ b/src/components/message-base.ts @@ -0,0 +1,112 @@ +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.6; + } + + .message { + padding: 14px 18px; + border-radius: 8px; + display: flex; + align-items: flex-start; + gap: 12px; + } + + .message-icon { + flex-shrink: 0; + font-size: 18px; + line-height: 1.4; + } + + .message-content { + flex: 1; + font-weight: 500; + } + + /* Status color variants */ + .status-info { + background: rgba(77, 100, 255, 0.08); + border: 2px solid #4d64ff; + color: #90ffff; + } + + .status-success { + background: rgba(144, 255, 255, 0.08); + border: 2px solid #90ffff; + color: #90ffff; + } + + .status-warning { + background: rgba(255, 193, 7, 0.08); + border: 2px solid #ffc107; + color: #ffeb3b; + } + + .status-error { + background: rgba(244, 67, 54, 0.08); + border: 2px solid #f44336; + color: #ff6b6b; + } + `; + + /** + * 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..afd62ff --- /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/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 new file mode 100644 index 0000000..d5b8537 --- /dev/null +++ b/src/components/notification-demo.ts @@ -0,0 +1,354 @@ +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'; + +/** + * 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 LitElement { + private _toastCount: number = 0; + + @state() + private _toasts: Array<{ id: number; status: StatusType; message: string }> = []; + + static override styles: CSSResultGroup = css` + :host { + display: block; + width: 100%; + } + + h1 { + 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: 28px; + font-weight: 700; + margin: 40px 0 20px; + color: #e0e0e0; + display: flex; + align-items: center; + gap: 12px; + } + + .subtitle { + color: #a0a0a0; + margin: 0 0 40px; + font-size: 18px; + } + + .section { + margin-bottom: 40px; + background: #1f1f1f; + border: 1px solid #404040; + border-radius: 12px; + padding: 32px; + } + + .demo-grid { + display: grid; + gap: 20px; + } + + .demo-row { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + } + + .level-badge { + 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: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .code { + 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: 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; + font-weight: 600; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + button:hover { + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); + } + + button:active { + transform: translateY(0); + } + + .hierarchy-diagram { + background: #2a2a2a; + border: 1px solid #404040; + border-radius: 8px; + padding: 20px; + font-family: 'Courier New', monospace; + font-size: 14px; + line-height: 2; + color: #e0e0e0; + } + + .hierarchy-diagram .provides { + color: #90ffff; + font-weight: 600; + } + + .hierarchy-diagram .configures { + color: #4d64ff; + font-weight: 600; + } + + .hierarchy-diagram strong { + color: #fff; + } + + .toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column-reverse; + gap: 12px; + z-index: 1000; + } + + .feature-list { + margin: 16px 0; + padding-left: 24px; + } + + .feature-list li { + 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; + } + `; + + 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

+ + +
+

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) +
+
+ + +
+

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! + +
+
+ + +
+

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} + + `)} +
+ `; + } + + 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 +customElements.define('notification-demo', NotificationDemo); diff --git a/src/components/stress-test.ts b/src/components/stress-test.ts new file mode 100644 index 0000000..1e2f7a0 --- /dev/null +++ b/src/components/stress-test.ts @@ -0,0 +1,602 @@ +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%; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + .header { + background: #1f1f1f; + padding: 32px; + border-radius: 12px; + margin-bottom: 32px; + border: 1px solid #404040; + } + + h1 { + 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: #a0a0a0; + margin: 0 0 20px; + font-size: 18px; + } + + .controls { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 20px; + } + + button { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .btn-primary { + background: linear-gradient(135deg, #4d64ff 0%, rgba(77, 100, 255, 0.8) 100%); + color: white; + border: 2px solid #4d64ff; + } + + .btn-primary:hover { + background: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); + } + + .btn-secondary { + background: #2a2a2a; + color: #90ffff; + border: 2px solid #90ffff; + } + + .btn-secondary:hover { + background: #353535; + transform: translateY(-1px); + } + + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-top: 20px; + } + + .stat { + background: #2a2a2a; + padding: 16px; + border-radius: 8px; + border: 1px solid #404040; + font-size: 14px; + } + + .stat:hover { + border-color: #4d64ff; + } + + .stat-label { + color: #a0a0a0; + font-size: 12px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 0.5px; + font-weight: 600; + } + + .stat-value { + 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 { + background: linear-gradient(135deg, #ffc107 0%, #ffeb3b 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .stat-value.error { + 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: 16px; + margin-top: 32px; + } + + .section { + background: #1f1f1f; + padding: 24px; + border-radius: 12px; + border: 1px solid #404040; + } + + .section:hover { + border-color: #4d64ff; + } + + .section-title { + font-size: 16px; + font-weight: 700; + margin: 0 0 16px; + color: #e0e0e0; + display: flex; + align-items: center; + gap: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .component-type-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid; + } + + .badge-simple { + background: rgba(77, 100, 255, 0.15); + color: #90ffff; + border-color: #4d64ff; + } + + .badge-medium { + background: rgba(255, 193, 7, 0.15); + color: #ffeb3b; + border-color: #ffc107; + } + + .badge-complex { + background: rgba(244, 67, 54, 0.15); + color: #ff6b6b; + border-color: #f44336; + } + + .component-wrapper { + 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: #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: 60px 20px; + color: #a0a0a0; + font-size: 18px; + } + + .spinner { + display: inline-block; + 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: 16px; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .warning-message { + 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: 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; + } + `; + + 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..57f4eb6 --- /dev/null +++ b/src/components/super-stress-test.ts @@ -0,0 +1,364 @@ +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: 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; + background: linear-gradient(135deg, #1f1f1f 0%, #2a2a2a 100%); + border: 2px solid #4d64ff; + padding: 60px 40px; + border-radius: 16px; + max-width: 600px; + } + + h1 { + font-size: 42px; + font-weight: 700; + 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: 18px; + color: #a0a0a0; + margin: 0 0 40px; + } + + .status { + font-size: 18px; + 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 rgba(77, 100, 255, 0.3); + border-top: 4px solid #4d64ff; + 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: 72px; + font-weight: 700; + 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: 'Courier New', monospace; + letter-spacing: -2px; + } + + .unit { + font-size: 24px; + color: #808080; + } + + .details { + 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: 'Courier New', monospace; + color: #e0e0e0; + } + + .detail-row { + display: flex; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid rgba(77, 100, 255, 0.2); + } + + .detail-row:last-child { + border-bottom: none; + } + + .detail-label { + color: #a0a0a0; + } + + .detail-value { + color: #90ffff; + font-weight: 600; + } + + .rating { + margin-top: 24px; + font-size: 32px; + } + + button { + margin-top: 24px; + padding: 12px 32px; + border: 2px solid #90ffff; + border-radius: 8px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + 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: linear-gradient(135deg, #5d74ff 0%, rgba(77, 100, 255, 0.9) 100%); + transform: translateY(-1px); + } + + 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.15; + 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/components/toast-notification.ts b/src/components/toast-notification.ts new file mode 100644 index 0000000..19fda1f --- /dev/null +++ b/src/components/toast-notification.ts @@ -0,0 +1,253 @@ +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); + overflow: hidden; + } + + /* Progress bar at bottom */ + .progress-bar { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + background-color: currentColor; + opacity: 0.5; + 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 { + opacity: 0.3; + } + ` + ]; + + 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..fc1f01e --- /dev/null +++ b/src/features/dismiss-feature.ts @@ -0,0 +1,109 @@ +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) { + 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 + }) + ); + } +} 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..408aa11 --- /dev/null +++ b/src/features/status-feature.ts @@ -0,0 +1,120 @@ +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 + */ +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 showIcon: boolean; + declare statusStyles: StatusStyles; + + /** Status icons as unicode characters (no dependencies) */ + private static readonly STATUS_ICONS: Record = { + info: 'ℹ️', + success: '✓', + warning: '⚠', + error: '✕' + }; + + @property({ + type: String, + attribute: 'status', + reflect: true + }) + status: StatusType; + + static properties: FeatureProperties = { + showIcon: { + type: Boolean, + attribute: 'show-icon', + reflect: true + }, + statusStyles: { + type: Object, + attribute: false + } + }; + + constructor(host: LitCore, config: StatusConfig) { + super(host, config); + this.status = config.defaultStatus || 'info'; + this.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 + }; + } + + /** + * Lifecycle: Update styles when properties change + */ + updated(changedProperties: Map): void { + super.updated(changedProperties); + if (changedProperties.has('status') || changedProperties.has('showIcon')) { + this._updateStatusStyles(); + } + } +} diff --git a/src/features/timer-feature.ts b/src/features/timer-feature.ts new file mode 100644 index 0000000..9411e03 --- /dev/null +++ b/src/features/timer-feature.ts @@ -0,0 +1,210 @@ +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(); + } + + /** + * 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; + } + } + + /** + * Resume a paused timer + */ + resume(): void { + if (!this.paused) return; + + const pauseDuration = Date.now() - this._pausedAt; + this._startTime += pauseDuration; + this.paused = false; + + this._tick(); + } + + /** + * Reset the timer to initial state + */ + reset(): void { + this.stop(); + this.remaining = this.duration; + this.progress = 0; + } + + /** + * 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 + }) + ); + + // Auto-dismiss if configured + if (this._autoDismiss) { + const dismissFeature = (this.host as unknown as { Dismiss?: { dismiss: () => void } }).Dismiss; + dismissFeature?.dismiss(); + } + } + + /** + * 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(); + } +} diff --git a/src/features/visibility-feature.ts b/src/features/visibility-feature.ts new file mode 100644 index 0000000..475d45b --- /dev/null +++ b/src/features/visibility-feature.ts @@ -0,0 +1,123 @@ +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: Cleanup on disconnect + */ + disconnectedCallback(): void { + 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/configure.ts b/src/root/decorators/configure.ts index 5257b15..1fe3760 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 { getOrCreateFeatureMeta } from './feature-meta.js'; /** * Configuration options for the @configure decorator @@ -51,54 +40,12 @@ 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; - } - + const meta = getOrCreateFeatureMeta(constructor); + meta.configure!.set( + featureName, + options === 'disable' ? 'disable' : (options as FeatureConfigEntry) + ); + return constructor; }; } - -/** - * Gets the decorator-configured features registry from a class - */ -export function getDecoratorConfigurations(constructor: Function): FeaturesRegistry { - const decorated = constructor as ConfigureDecorated; - return decorated[CONFIGURE_REGISTRY] || {}; -} - -/** - * 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; - } - }); - - current = Object.getPrototypeOf(current) as Function | null; - } - - return configs; -} diff --git a/src/root/decorators/feature-meta.ts b/src/root/decorators/feature-meta.ts new file mode 100644 index 0000000..fb510db --- /dev/null +++ b/src/root/decorators/feature-meta.ts @@ -0,0 +1,44 @@ +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'; + 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..249f660 --- /dev/null +++ b/src/root/decorators/feature-property.ts @@ -0,0 +1,58 @@ +import type { PropertyDeclaration } from 'lit'; +import { FEATURE_META, getOrCreateFeatureMeta } from './feature-meta.js'; + +/** + * Decorator for defining reactive properties on feature classes. + * Works like Lit's @property but stores metadata in the unified FEATURE_META symbol. + * + * @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 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 + const parentProperties = ctor.properties || {}; // Get inherited or empty + if (!Object.prototype.hasOwnProperty.call(ctor, 'properties')) { + Object.defineProperty(ctor, 'properties', { + value: {...parentProperties}, + writable: true, + configurable: true, + enumerable: false + }); + } + + ctor.properties[propertyKey] = options; + }; +} + +/** + * Extract @featureProperty metadata from a feature class + */ +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/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 a6c15ef..7d00a55 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 { getOrCreateFeatureMeta } from './feature-meta.js'; /** * Type for the feature definition value passed to @provide decorator @@ -37,50 +26,9 @@ export function provide( definition: FeatureDefinition ) { return function (constructor: T): T { - 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] } - : {}; - } - - decorated[PROVIDES_REGISTRY]![featureName] = definition as unknown as FeatureDefinition; - + const meta = getOrCreateFeatureMeta(constructor); + meta.provide!.set(featureName, definition as unknown as FeatureDefinition); + return constructor; }; } - -/** - * Gets the decorator-provided features registry from a class - */ -export function getDecoratorProvides(constructor: Function): ProvidesRegistry { - const decorated = constructor as ProvidesDecorated; - return decorated[PROVIDES_REGISTRY] || {}; -} - -/** - * 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); - - // 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; -} diff --git a/src/root/feature-resolver.ts b/src/root/feature-resolver.ts new file mode 100644 index 0000000..3f2de0b --- /dev/null +++ b/src/root/feature-resolver.ts @@ -0,0 +1,227 @@ +import merge from 'lodash.merge'; +import type { PropertyDeclaration } from 'lit'; +import type { + FeatureConfigEntry, + FeatureDefinition, + FeatureMeta, + ResolvedFeatures, + LitCoreConstructor, + FeatureConfig +} from './types/feature-types.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'; +import { DebugUtils } from './debug-utils.js'; + +// ============================================================================ +// 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' +): 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 + }; +} + +// ---------------------------------------------------------------------------- +// 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 { + 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]; + } + + // Collect provides and configs from inheritance chain + const provides = new Map(); + 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); + }); + + // 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); + DebugUtils.logMeta('resolve-configure-static', ` → Merging config for feature: ${name}`); + configs.set(name, merged); + }); + + // 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) => { + DebugUtils.logMeta('resolve-provide-decorator', ` → Collecting feature: ${name} from decorator`); + provides.set(name, definition); + }); + } + + // Process configs from decorator metadata + 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); + }); + } + } + }); + + // Build final resolved state + 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 || {}) + }; + + // Include decorator properties + const decoratorMeta = getFeaturePropertyMetadata(definition.class); + 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; + } + }); + } + + // 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' + ? merge({}, definition.config || {}, featureConfig.config || {}) + : (definition.config || {}); + + // Add to resolved features + resolvedFeatures.set(name, { + class: definition.class as typeof LitFeature, + config: finalConfig + }); + }); + + const resolved: ResolvedFeatures = { + properties: Object.freeze(resolvedProperties), + features: 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; + + return resolved; +} diff --git a/src/root/lit-core.ts b/src/root/lit-core.ts index 52165cd..4dc6d7d 100644 --- a/src/root/lit-core.ts +++ b/src/root/lit-core.ts @@ -1,5 +1,13 @@ import { LitElement, PropertyDeclaration } from 'lit'; -import { FeatureManager, ProvidesRegistry, FeaturesRegistry, LitCoreConstructor } from './services/feature-manager.js'; +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. + * 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,58 +22,72 @@ 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. + * Marker to identify LitCore-based classes */ - static _featureProperties: Record = {}; + static readonly [LIT_CORE_MARKER] = true; /** - * Flag to track if features have been initialized for this class + * Lit reactive properties for this class. + * We keep this in sync with feature snapshots in `finalize()`. */ - static _featuresInitialized = false; + 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 } + }); } /** - * 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 merge feature properties into the class properties map. */ - 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; + const resolved = resolveFeatures(ctor); + const superProps = (Object.getPrototypeOf(this) as typeof LitElement)?.properties || {}; + const ownProps = Object.prototype.hasOwnProperty.call(this, 'properties') ? this.properties : {}; - return { - ...featureProperties, - ...baseProperties + (this as unknown as { properties: Record }).properties = { + ...superProps, + ...resolved.properties, + ...ownProps }; + + 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: Record; /** * 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: Record; /** * Registers the component as a custom element with the browser. - * Also ensures all features are prepared before the component is instantiated. + * Also resolves feature metadata for the class. */ static register(componentName: string): void { - FeatureManager.prepareFeatures(this as unknown as LitCoreConstructor); - customElements.define(componentName, this); + const ctor = this as unknown as LitCoreConstructor; + + resolveFeatures(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 db47f54..3eb9244 100644 --- a/src/root/lit-feature.ts +++ b/src/root/lit-feature.ts @@ -1,5 +1,8 @@ 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 @@ -18,12 +21,14 @@ 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 abstract class LitFeature implements ReactiveController { host: LitCore; config: TConfig; 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. @@ -32,37 +37,128 @@ export 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}`); + this.host = host; this.config = config; + + // Register as a controller so we get host lifecycle callbacks + (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 { - const properties = (this.constructor as typeof LitFeature).properties; - if (!properties) return; + const featureName = this.constructor.name || 'UnnamedFeature'; + DebugUtils.logProperties('feature-init', `Initializing ${featureName} - setting up property observers`); - Object.entries(properties).forEach(([propertyName]) => { - // Create observer for the property - this._createPropertyObserver(propertyName); + const {properties} = (this.constructor as typeof LitFeature); + 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); - // 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); + // 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. + 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 markName = `property-observer-${featureName}-${propertyName}-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); + 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) { - (feature.host as unknown as Record)[propertyName] = newValue; + 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); + + DebugUtils.logWiring('property-setter', `Setting ${featureName}.${propertyName}`, { + oldValue, + newValue, + 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); + + // Mirror into feature internal map feature.setInternalValue(propertyName, newValue); + DebugUtils.logWiring('property-to-internal', ` → Mirrored to internal storage: ${propertyName}`); + + // 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}`); + } + + 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 } + }); } /** @@ -80,37 +176,99 @@ export class LitFeature { } /** - * Called after the host element's first update cycle + * 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. */ 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 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; + + // 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}`, { + hostValue, + internalValue + }); + + if (hostValue !== undefined) { + // 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 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 undefined for consistency + DebugUtils.logWiring('first-updated-mirror', ` → No value set, mirroring undefined for ${propertyName}`); + this.setInternalValue(propertyName, hostValue); } }); + + DebugUtils.logWiring('first-updated-complete', `First update phase complete for ${featureName}`); } /** - * 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; - - Object.entries(properties).forEach(([propertyName]) => { - if (changedProperties.has(propertyName)) { - this.setInternalValue( - propertyName, - (this.host as unknown as Record)[propertyName] - ); - } + const featureName = this.constructor.name || 'UnnamedFeature'; + const hostName = this.host.constructor.name || 'UnknownHost'; + + DebugUtils.logWiring('updated-start', `Update phase for ${featureName} (host: ${hostName})`); + + const hostRecord = this.host as unknown as Record; + + changedProperties.forEach((_oldValue, propertyName) => { + 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/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 1c4fe3d..70b7c64 100644 --- a/src/root/services/feature-manager.ts +++ b/src/root/services/feature-manager.ts @@ -1,222 +1,103 @@ 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 } from '../types/feature-types.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 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 { - FeatureClass, - FeatureDefinition, - 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; - hostConstructor: LitCoreConstructor; private _featureInstances: Map; constructor(host: LitCore, constructor: LitCoreConstructor) { 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 { - 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; - } - return features; + // 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 { - const configs: FeaturesRegistry = {}; + private _initializeFeatures(resolved: ResolvedFeatures): void { + const markName = `feature-manager-init-${Date.now()}-${Math.random()}`; + performanceMonitor.mark(markName); - // First, collect decorator-configured features - const decoratorConfigurations = getInheritedDecoratorConfigurations(constructor as unknown as Function & ConfigureDecorated); - Object.entries(decoratorConfigurations).forEach(([name, config]) => { - configs[name] = config; + const hostName = (this.host as any).constructor?.name || 'Unknown'; + DebugUtils.logProperties('init-start', `Starting feature instantiation for host: ${hostName}`, { + featureCount: resolved.features.size }); - // 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 || {}; + // Batch 1: Create instances with update requests suspended + resolved.features.forEach((feature, featureName) => { + DebugUtils.logProperties('init-feature', `Instantiating feature: ${featureName}`); - 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 || {}; + const featureInstance = new (feature.class as any)(this.host, feature.config); + DebugUtils.logProperties('init-instance-created', ` → Instance created for: ${featureName}`); - // 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; - } + // Suspend update requests during initialization + featureInstance._suspendUpdateRequests(); - return configs; - } - - /** - * Initialize features and collect their properties. - * This needs to be called before the element is registered. - */ - static prepareFeatures(constructor: LitCoreConstructor): void { - if (constructor._featuresInitialized) { - return; - } - - if (!constructor._featureProperties) { - constructor._featureProperties = {}; - } - - const providedFeatures = this.getInheritedProvides(constructor); - const featureConfigs = this.getInheritedConfigs(constructor); - - Object.entries(providedFeatures).forEach(([featureName, featureDef]) => { - const featureConfig = featureConfigs[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 || {}); + this._featureInstances.set(featureName, featureInstance); - // Merge properties: static + config properties, with 'disable' support - 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; - } - }); + // 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. +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; } - - // Add merged properties to the registry - Object.entries(mergedProperties).forEach(([propName, propConfig]) => { - constructor._featureProperties![propName] = propConfig; - }); }); - constructor._featuresInitialized = true; - } - - /** - * Initialize all features that this component has opted into - */ - private _initializeFeatures(): void { - const availableFeatures = FeatureManager.getInheritedProvides(this.hostConstructor); - const featureConfigs = FeatureManager.getInheritedConfigs(this.hostConstructor); - - Object.entries(availableFeatures).forEach(([featureName, featureDef]) => { - const featureConfig = featureConfigs[featureName]; + // Batch 2: Resume updates and trigger single batch update if needed + this._featureInstances.forEach((featureInstance) => { + featureInstance._resumeUpdateRequests(); + }); - if (featureConfig === 'disable') return; - - const { class: FeatureClass, config: defaultConfig = {}, enabled = true } = featureDef; - - if (!featureConfig && !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); + // 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(); + } - // 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; + DebugUtils.logProperties('init-complete', `Feature instantiation complete for host: ${hostName}`); - // 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; - } - }); + performanceMonitor.measure(`feature-manager-init-${hostName}`, { + markStart: markName, + threshold: 0.5, + context: { + component: hostName, + featureCount: resolved.features.size } }); } diff --git a/src/root/types/feature-types.ts b/src/root/types/feature-types.ts index 4443f90..da69043 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,25 +20,43 @@ export interface FeatureDefinition; } +// ============================================================================ +// CORE CONCEPT #1: Class-Level Metadata (raw decorators) +// ============================================================================ + /** - * Registry of provided features + * Class-level metadata attached to components via decorators or static getters. + * This is the raw, unresolved state before inheritance merging. */ -export interface ProvidesRegistry { - [featureName: string]: FeatureDefinition; +export interface FeatureMeta { + provide?: Map; + configure?: Map; + featureProperties?: Map; } +// ============================================================================ +// CORE CONCEPT #2: Resolved Snapshot (final merged state) +// ============================================================================ + /** - * Registry of feature configurations + * Final resolved state after merging inheritance chain. + * This is the only thing FeatureManager needs. */ -export interface FeaturesRegistry { - [featureName: string]: FeatureConfigEntry | 'disable'; +export interface ResolvedFeatures { + /** All properties from all enabled features */ + properties: Record; + /** All enabled features with their final config */ + features: Map; } /** @@ -47,15 +65,9 @@ export interface FeaturesRegistry { export interface LitCoreConstructor { new (): LitCore; name: string; - provide?: ProvidesRegistry; - configure?: FeaturesRegistry; - /** @deprecated Use `provide` instead */ - provides?: ProvidesRegistry; - /** @deprecated Use `configure` instead */ - features?: FeaturesRegistry; + provide?: Record; + configure?: Record; properties?: Record; - _featureProperties?: Record; - _featuresInitialized?: boolean; } // Re-export FeatureConfig for convenience 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'; diff --git a/stress-test.html b/stress-test.html new file mode 100644 index 0000000..6b97a9b --- /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..a1378ce --- /dev/null +++ b/super-stress-test.html @@ -0,0 +1,18 @@ + + + + + + LitFeature - Super Stress Test + + + + + + + + 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/', +})