diff --git a/README.md b/README.md index f34734c..7cb524e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ applications. **Key features:** - Simple attribute-based API for product configurators +- Dynamic mapping support for dependent product options - Fire-and-forget messaging with automatic state synchronization - Zero external dependencies (all bundled) - Works with any frontend framework or vanilla JavaScript @@ -94,43 +95,11 @@ graph LR architecture - **Event-driven**: Loosely coupled components communicate via events -## Product options - -### When do you need mapping - -Before diving in, it's important to understand when you need attribute mapping: - -**No mapping needed:** - -- Static 3D models that always look the same -- Product showcases without options -- Architectural visualizations -- Art pieces or sculptures -- Single-configuration products - -**Mapping required:** - -- Products where users can change colors (different textures/materials per option) -- Products with size options (different 3D meshes per size) -- Modular products (parts that can be added/removed) -- Any product where different options show different 3D elements +## Product configuration ### Attribute mapping -The mapping system bridges your business logic with the 3D visualization. It -serves multiple purposes: - -**Why mapping is essential:** - -- The 3D server is generic - it doesn't know your specific product options -- Your business rules (stock, pricing, combinations) change independently from - the 3D model -- You control exactly which parts of the model are shown for each option -- You can group multiple 3D nodes (meshes, materials) into logical product - choices - -**How it works:** The mapping connects your product options to specific parts of -the 3D model: +For configurable products, the mapping system connects your product options to parts of the 3D model: ```typescript client.setMapping({ @@ -138,69 +107,33 @@ client.setMapping({ { name: 'Color', values: [ - { - value: 'Red', - nodeIds: ['mat_red_sole', 'mat_red_laces', 'mat_red_logo'], - isSelected: true, - }, - { - value: 'Blue', - nodeIds: ['mat_blue_sole', 'mat_blue_laces', 'mat_blue_logo'], - }, - ], - }, - { - name: 'Material', - values: [ - { value: 'Leather', nodeIds: ['material_leather_upper'] }, - { - value: 'Canvas', - nodeIds: ['material_canvas_upper'], - isSelected: true, - }, + { value: 'Red', nodeIds: ['sole_red', 'laces_red'], isSelected: true }, + { value: 'Blue', nodeIds: ['sole_blue', 'laces_blue'] }, ], }, ], }); ``` -**Structure explained:** +**Key concepts:** -- `attribute` - A product feature like Color, Size, or Material -- `value` - A specific option like Red, Blue, Small, or Large +- `attribute` - A product feature (Color, Size, Material) +- `value` - A specific option (Red, Blue, Small, Large) - `nodeIds` - The 3D model parts that represent this option -- `isSelected` - Whether this option is selected by default - -**Example benefits:** - -```typescript -// Group related 3D parts into one logical choice -{ - value: 'Red', - nodeIds: [ - 'mesh_sole_red', // Red sole mesh - 'material_laces_red', // Red laces material - 'texture_logo_red', // Red logo texture - 'mesh_stitching_red' // Red stitching details - ] -} - -// The server automatically handles showing Red parts -// and hiding Blue/Green parts when Red is selected -``` +- `isSelected` - Default selection state -**Key advantages:** +**When you need mapping:** -- Full control over what's visible without modifying the 3D model -- Business logic stays in your application, not in the 3D server -- Easy to update when products or availability changes -- Group complex 3D structures into simple user choices +- Products with color/material variations +- Products with size options that affect the 3D model +- Modular products with optional parts +- Any product where options change what's visible in 3D -**Need help with mapping?** +**Learn more:** -- We help you connect your product catalog to your 3D models -- Visual mapping tool coming soon to simplify this process -- Contact for mapping assistance +- [Understanding attribute mapping](./docs/mapping-concept.md) - Core concepts +- [Static mapping guide](./docs/static-mapping.md) - Simple configurations +- [Dynamic mapping guide](./docs/dynamic-mapping.md) - Advanced dependencies ### Complete flow diff --git a/README.npm.md b/README.npm.md index 317a301..cc143a8 100644 --- a/README.npm.md +++ b/README.npm.md @@ -1,14 +1,6 @@ # Virtualdisplay client -TypeScript library for embedding interactive 3D product models in web -applications. - -**Key features:** - -- Simple attribute-based API for product configurators -- Fire-and-forget messaging with automatic state synchronization -- Zero external dependencies (all bundled) -- Works with any frontend framework or vanilla JavaScript +TypeScript library for embedding interactive 3D product models in web applications. ## Installation @@ -20,14 +12,6 @@ pnpm add @virtualdisplay.io/client yarn add @virtualdisplay.io/client ``` -## What's new in v3.1 - -- Simplified message handling with unified event system -- JSON Schema validation using AJV -- Cleaner, more maintainable codebase -- Improved TypeScript types -- Better error messages with specific error codes - ## Quick start ### Simple product (no options) @@ -70,414 +54,25 @@ client.setMapping({ // Control via product options client.getAttribute('Color')?.select('Red'); - -// Or store the attribute for multiple operations -const sizeAttribute = client.getAttribute('Size'); -if (sizeAttribute) { - sizeAttribute.select('Large'); -} -``` - -## How it works - -The 3D server hosts the viewer in an iframe, keeping WebGL complexity isolated -from your application. The client library handles all communication via -postMessage and manages state locally using an attribute mapping system. - -**Key principles:** - -- **Iframe architecture**: 3D server loads the viewer independently -- **Fire-and-forget**: Send messages without waiting for confirmation -- **Attribute mapping**: Connect your product options to 3D model parts -- **State synchronization**: Client and 3D server stay in sync automatically -- **Domain-driven design**: Clean separation of concerns with simplified - architecture -- **Event-driven**: Loosely coupled components communicate via events - -## Product options - -### When do you need mapping - -Before diving in, it's important to understand when you need attribute mapping: - -**No mapping needed:** - -- Static 3D models that always look the same -- Product showcases without options -- Architectural visualizations -- Art pieces or sculptures -- Single-configuration products - -**Mapping required:** - -- Products where users can change colors (different textures/materials per option) -- Products with size options (different 3D meshes per size) -- Modular products (parts that can be added/removed) -- Any product where different options show different 3D elements - -### Attribute mapping - -The mapping system bridges your business logic with the 3D visualization. It -serves multiple purposes: - -**Why mapping is essential:** - -- The 3D server is generic - it doesn't know your specific product options -- Your business rules (stock, pricing, combinations) change independently from - the 3D model -- You control exactly which parts of the model are shown for each option -- You can group multiple 3D nodes (meshes, materials) into logical product - choices - -**How it works:** The mapping connects your product options to specific parts of -the 3D model: - -```typescript -client.setMapping({ - attributes: [ - { - name: 'Color', - values: [ - { - value: 'Red', - nodeIds: ['mat_red_sole', 'mat_red_laces', 'mat_red_logo'], - isSelected: true, - }, - { - value: 'Blue', - nodeIds: ['mat_blue_sole', 'mat_blue_laces', 'mat_blue_logo'], - }, - ], - }, - { - name: 'Material', - values: [ - { value: 'Leather', nodeIds: ['material_leather_upper'] }, - { - value: 'Canvas', - nodeIds: ['material_canvas_upper'], - isSelected: true, - }, - ], - }, - ], -}); -``` - -**Structure explained:** - -- `attribute` - A product feature like Color, Size, or Material -- `value` - A specific option like Red, Blue, Small, or Large -- `nodeIds` - The 3D model parts that represent this option -- `isSelected` - Whether this option is selected by default - -**Example benefits:** - -```typescript -// Group related 3D parts into one logical choice -{ - value: 'Red', - nodeIds: [ - 'mesh_sole_red', // Red sole mesh - 'material_laces_red', // Red laces material - 'texture_logo_red', // Red logo texture - 'mesh_stitching_red' // Red stitching details - ] -} - -// The server automatically handles showing Red parts -// and hiding Blue/Green parts when Red is selected -``` - -**Key advantages:** - -- Full control over what's visible without modifying the 3D model -- Business logic stays in your application, not in the 3D server -- Easy to update when products or availability changes -- Group complex 3D structures into simple user choices - -**Need help with mapping?** - -- We help you connect your product catalog to your 3D models -- Visual mapping tool coming soon to simplify this process -- Contact for mapping assistance - -### Complete flow - -Here's how the client, mapping system, and 3D server work together: - -**Phase by phase breakdown:** - -#### Phase 0: Initialization (always happens) - -- Client creates iframe and loads 3D server -- Server initializes the 3D model with all nodes visible -- Server sends complete node state to client -- Client now knows about all available nodes in the model - -**Note:** For simple models without options, this is all you need! - -#### Phase 1: Configuration (only for configurable products) - -- You call `setMapping()` with your product structure -- Client stores the attribute mapping locally -- Client sends mutations for all `isSelected: true` values -- Server applies these changes and hides non-selected options -- Server sends back confirmed state -- Local state updates based on server response -- onChange callbacks fire for initial UI synchronization - -#### Phase 2: User interaction (only for configurable products) - -- User selects a different option in your UI -- You call `getAttribute('Color').select('Blue')` -- Client sends mutation request to server -- Your code continues immediately (doesn't wait for server response) -- Server updates the 3D model -- Server sends back the confirmed state -- Local state updates based on server response -- onChange callbacks fire with the actual state -- UI stays perfectly synchronized with 3D model - -**Important:** The pattern is identical for both initial mapping and user -interactions. In both cases: - -1. Client sends mutations to server -2. Server applies changes to 3D model -3. Server sends back confirmed state -4. Client updates local state based on server response -5. onChange callbacks fire with the actual state - -This ensures the server remains the single source of truth for all state -changes. - -## Advanced usage - -### Live state synchronization - -Keep your UI in sync with the 3D server state using onChange callbacks: - -```typescript -const colorAttribute = client.getAttribute('Color'); - -colorAttribute?.getValues().forEach((value) => { - value.onChange = () => { - // Update UI when state changes - updateButton(value.value, value.isSelected); - }; -}); -``` - -### Mapping validation - -Validate your mapping configuration during development: - -```typescript -import { mappingSchema } from '@virtualdisplay.io/client'; -import Ajv from 'ajv'; - -const validate = new Ajv().compile(mappingSchema); -if (!validate(myMapping)) { - console.error('Invalid mapping:', validate.errors); -} -``` - -## Real-world integration examples - -### CMS integration - -The Virtualdisplay client is CMS-agnostic. Store the mapping configuration in -your CMS alongside your product data: - -```typescript -// Fetch product with 3D mapping from your CMS -const product = await fetch('/api/products/sneaker-pro').then((r) => r.json()); - -// Use the stored mapping directly -client.setMapping(product.server3dMapping); -``` - -**Best practices:** - -- Store the complete mapping configuration with each product -- Update mappings when product options or availability changes -- Let your CMS handle filtering of unavailable combinations -- Version your mappings when 3D models are updated - -## Example - -See the [color configurator example](./examples/color-configurator/) for a -complete working implementation that demonstrates: - -- Setting up the client -- Defining attribute mappings -- Binding UI controls to attributes -- Real-time state synchronization - -## API reference - -### ClientOptions - -```typescript -interface ClientOptions { - parent: string | HTMLElement; // Container element or selector - license: string; // Your license key - model: string; // Model ID to load - debug?: boolean; // Enable debug logging (default: false) - language?: string; // Language for UI inside the iframe (default: 'nl', supported: 'nl', 'en', 'de') -} -``` - -### Methods - -#### `setMapping(configuration: MappingConfiguration): void` - -Configure attribute-to-node mapping for product variants. This is the primary -method for setting up your product configurator. Call this once with your -complete configuration - the client efficiently handles all updates. - -```typescript -client.setMapping({ - attributes: [ - { - name: 'Color', - values: [ - { value: 'Red', nodeIds: ['node1', 'node2'], isSelected: true }, - { value: 'Blue', nodeIds: ['node3', 'node4'] }, - ], - }, - ], -}); -``` - -#### `getAttribute(name: string): AttributeSelector | undefined` - -Get an attribute selector for changing values. Returns undefined if the -attribute doesn't exist. - -```typescript -const colorAttr = client.getAttribute('Color'); -if (colorAttr) { - colorAttr.select('Blue'); -} -``` - -#### `destroy(): void` - -Clean up and remove the client connection. Always call this when unmounting your -component. - -```typescript -// React example -useEffect(() => { - const client = new VirtualdisplayClient({ ... }); - return () => client.destroy(); -}, []); ``` -### Types - -```typescript -interface MappingConfiguration { - attributes: AttributeConfig[]; -} - -interface AttributeConfig { - name: string; // Attribute name (e.g., 'Color') - values: AttributeValueConfig[]; // Possible values -} - -interface AttributeValueConfig { - value: string; // Value name (e.g., 'Red') - nodeIds: string[]; // 3D node IDs for this value - isSelected?: boolean; // Default selection state -} - -class AttributeSelector { - // Properties - name: string; // Attribute name (getter) - currentValue: string | undefined; // Current selected value (getter) - availableValues: string[]; // All possible values (getter) - - // Methods - select(value: string): AttributeSelector; // Select a value (chainable) - onChange(callback: () => void): AttributeSelector; // Register change callback (chainable) - getValues(): AttributeValue[]; // Get all AttributeValue objects - getValue(value: string): AttributeValue | undefined; // Get specific AttributeValue -} - -class AttributeValue { - // Properties - value: string; // The value name (e.g., 'Red') - getter - nodeList: string[]; // Associated 3D node IDs - getter - isSelected: boolean; // Current selection state - getter - onChange?: () => void; // Callback when selection changes - property -} -``` - -#### NodeSelector - -The client exports `NodeSelector` for direct node manipulation. This is intended -for specialized tools like mapping editors and inspectors, not for typical -product configurators. For product configurators, use the attribute-based API. - -### Error handling - -The client throws `VirtualdisplayError` for known error conditions: - -```typescript -import { - VirtualdisplayClient, - VirtualdisplayError, - ERROR_CODES, -} from '@virtualdisplay.io/client'; - -try { - client.getAttribute('NonExistent').select('Value'); -} catch (error) { - if (error instanceof VirtualdisplayError) { - switch (error.code) { - case ERROR_CODES.ATTRIBUTE_NOT_FOUND: - console.error('Attribute does not exist'); - break; - case ERROR_CODES.NO_MAPPING: - console.error('Call setMapping() first'); - break; - // Handle other error codes - } - } -} -``` - -**Error codes:** - -- `NO_MAPPING` - No mapping configuration set -- `ATTRIBUTE_NOT_FOUND` - Requested attribute doesn't exist -- `VALUE_NOT_FOUND` - Requested value doesn't exist for attribute -- `INVALID_MAPPING` - Mapping configuration is invalid -- `PARENT_NOT_FOUND` - Parent element for iframe not found - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and guidelines. - -## Troubleshooting - -### Common issues - -**Iframe not loading:** +## Key features -- Check that the parent element exists in the DOM -- Verify your license key is valid -- Ensure the model ID matches your license +- **Simple API**: Attribute-based product configuration +- **Fire-and-forget**: Send commands without waiting for responses +- **State sync**: Automatic synchronization between client and 3D viewer +- **Framework-agnostic**: Works with React, Vue, Angular, or vanilla JS +- **TypeScript first**: Full type safety and IntelliSense support +- **Minimal dependencies**: Only essential validation and logging included -**Attributes not working:** +## Documentation -- Make sure you call `setMapping()` before using `getAttribute()` -- Verify attribute names match exactly (case-sensitive) -- Check that node IDs in your mapping exist in the 3D model +For complete documentation including: -## Changelog +- API reference +- Advanced examples +- Integration guides +- Architecture diagrams +- Troubleshooting -See the [GitHub releases](https://github.com/virtualdisplay-io/client/releases) -for release notes and version history. +Visit our [GitHub repository](https://github.com/virtualdisplay-io/client) diff --git a/docs/dynamic-mapping.md b/docs/dynamic-mapping.md new file mode 100644 index 0000000..51b8e66 --- /dev/null +++ b/docs/dynamic-mapping.md @@ -0,0 +1,381 @@ +# Dynamic mapping guide + +Dynamic mapping allows attribute values to change based on other selections, enabling complex product +configurations with dependencies. + +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with +> [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. +> +> **Haven't read about static mapping yet?** Start with [Static mapping guide](./static-mapping.md) first. +> This guide builds on those concepts using the same city bicycle example. + +## What makes mapping "dynamic" + +In dynamic mapping, any property can be computed at runtime using a function: + +```typescript +{ + name: 'Wheel Size', + // Dynamic values based on frame size + values: (context) => { + const frameSize = context.getValue('Frame Size'); + if (frameSize === 'small') { + return [{ value: '26-inch', nodeIds: ['wheels_26'], isSelected: true }]; + } + return [{ value: '28-inch', nodeIds: ['wheels_28'], isSelected: true }]; + } +} +``` + +## City bicycle with dependencies + +Let's enhance our city bicycle from the static example. The key insight: frame size affects almost +everything else on the bicycle. This is where dynamic mapping shines - without it, you'd need +separate configurations for each frame size. + +### Frame size as the central driver + +When you select a frame size, it cascades through the entire configuration: + +**Small frame (150-165cm riders)**: + +- Uses 26" wheels positioned for smaller geometry +- Components positioned closer together (brakes, gears, lights) +- Basket mounted at lower position +- Limited gear options suitable for smaller frame + +**Medium frame (165-180cm riders)**: + +- Uses 28" wheels with standard positioning +- Components at standard positions +- Full range of gear and accessory options + +**Large frame (180-195cm riders)**: + +- Uses 28" wheels with extended positioning +- Components spread wider (matching frame geometry) +- Accessories positioned for larger frame +- May include frame-specific components + +**The key insight**: Components don't just change type - they also move to match the frame geometry. +The basket is always "basket" from the user's perspective, but the 3D model shows `basket_small`, +`basket_medium`, or `basket_large` nodes positioned appropriately for each frame. + +Without dynamic mapping, you'd need either 3 separate product configurations or build complex workarounds in your +application to manage all these dependencies - defeating the purpose of a clean mapping system. + +### Implementation + +```typescript +// Helper functions for business logic +// Tire types - same options but positioned for different wheel sizes +function getTireOptions(frameSize: string) { + const wheelSize = frameSize === 'small' ? '26' : '28'; + + const options = [ + { value: 'city', nodeIds: [`tires_${wheelSize}_city`], isSelected: true }, + { value: 'comfort', nodeIds: [`tires_${wheelSize}_comfort`] }, + ]; + + // Sport tires only available for larger wheels + if (frameSize !== 'small') { + options.push({ value: 'sport', nodeIds: [`tires_${wheelSize}_sport`] }); + } + + return options; +} + +// Brake types - positioned for different wheel sizes +function getBrakeOptions(frameSize: string) { + const wheelSize = frameSize === 'small' ? '26' : '28'; + const defaultBrake = frameSize === 'small' ? 'rim' : 'disc'; + + return [ + { + value: 'rim', + nodeIds: [`brakes_rim_${wheelSize}`], + isSelected: defaultBrake === 'rim', + }, + { + value: 'disc', + nodeIds: [`brakes_disc_${wheelSize}`], + isSelected: defaultBrake === 'disc', + }, + ]; +} + +function getBasketOptions(frameSize: string) { + // User always sees same options, but nodeIds change based on frame size + return [ + { value: 'none', nodeIds: [], isSelected: true }, + { + value: 'basket', + nodeIds: [`basket_${frameSize}`], // basket_small, basket_medium, basket_large + isSelected: false, + }, + ]; +} + +function getGripOptions(saddleType: string) { + // Sport saddle requires sport grips + if (saddleType === 'sport') { + return [ + { + value: 'sport-black', + nodeIds: ['grips_sport_black'], + isSelected: true, + }, + ]; + } + + // Comfort saddle allows choice of grips + return [ + { value: 'brown', nodeIds: ['grips_brown'], isSelected: true }, + { value: 'black', nodeIds: ['grips_black'] }, + ]; +} + +// The complete dynamic mapping +client.setMapping({ + attributes: [ + { + // Frame size is our primary driver - it affects almost everything else + name: 'Frame Size', + values: [ + { + value: 'small', + nodeIds: ['frame_small', 'wheels_26'], + isSelected: false, + }, + { + value: 'medium', + nodeIds: ['frame_medium', 'wheels_28'], + isSelected: true, + }, + { + value: 'large', + nodeIds: ['frame_large', 'wheels_28'], + isSelected: false, + }, + ], + }, + { + name: 'Frame Color', + // Even colors can depend on frame size! + values: (context) => { + const frameSize = context.getValue('Frame Size'); + const colors = [ + { value: 'blue', nodeIds: ['paint_blue'], isSelected: true }, + { value: 'black', nodeIds: ['paint_black'] }, + ]; + + // Red is a premium color only available on medium/large frames + if (frameSize !== 'small') { + colors.push({ value: 'red', nodeIds: ['paint_red'] }); + } + + return colors; + }, + }, + { + name: 'Tires', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getTireOptions(frameSize); + }, + }, + { + name: 'Brakes', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getBrakeOptions(frameSize); + }, + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Grips', + values: (context) => { + const saddleType = context.getValue('Saddle Type'); + return getGripOptions(saddleType); + }, + }, + { + name: 'Basket', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + return getBasketOptions(frameSize); + }, + }, + { + name: 'Gear System', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + + // Small frames get simpler gearing + if (frameSize === 'small') { + return [ + { value: '3-speed', nodeIds: ['gears_3speed'], isSelected: true }, + { value: '7-speed', nodeIds: ['gears_7speed'] }, + ]; + } + + // Larger frames can handle more complex gearing + return [ + { value: '7-speed', nodeIds: ['gears_7speed'], isSelected: true }, + { value: '21-speed', nodeIds: ['gears_21speed'] }, + ]; + }, + }, + { + name: 'Lights', + values: (context) => { + const frameSize = context.getValue('Frame Size'); + const wheelSize = frameSize === 'small' ? '26' : '28'; + + return [ + { value: 'none', nodeIds: [], isSelected: true }, + { + value: 'standard', + nodeIds: [`lights_front_${frameSize}`, `lights_rear_${frameSize}`], + isSelected: false, + }, + { + value: 'premium', + nodeIds: [ + `lights_premium_front_${frameSize}`, + `lights_premium_rear_${frameSize}`, + `dynamo_${wheelSize}`, + ], + isSelected: false, + }, + ]; + }, + }, + ], +}); +``` + +### What happens during re-evaluation + +Look at the cascade effect when changing frame size: + +**User selects "small" frame size**: + +1. Frame and wheels change to small geometry: `frame_small`, `wheels_26` +2. Frame Color re-evaluates → red option disappears (not available for small frames) +3. Tires re-evaluates → 26" tire positioning, sport option disappears +4. Brakes re-evaluates → positioned for 26" wheels, rim brakes default +5. Basket re-evaluates → positioned for small frame geometry +6. Gear System re-evaluates → positioned for small frame, 21-speed disappears +7. Lights re-evaluates → positioned for small frame, uses 26" dynamo + +**Key insight**: The user still sees the same options ("basket", "disc brakes", "standard lights") +but the 3D model automatically uses the correct nodes for the frame size (`basket_small` vs +`basket_large`, `brakes_disc_26` vs `brakes_disc_28`). + +That's 7 attributes affected by one change! With static mapping, you'd need: + +- 3 frame sizes × all color options × all tire options × all brake options × all gear options = + massive configuration complexity + +**The exponential advantage**: + +- Static mapping: 3 frame sizes × 3 colors × 2 tire types × 2 brake types × 2 gear systems × + 2 light options = 144 separate configurations to define +- Dynamic mapping: Just define the positioning rules once, system handles all valid combinations + automatically + +## Testing dynamic mappings + +Extract the logic functions and test them independently: + +```typescript +// bicycle-config-logic.test.ts +import { describe, it, expect } from 'vitest'; +import { + getWheelSize, + getBasketOptions, + getGripOptions, +} from './bicycle-config-logic'; + +describe('Bicycle Configuration Logic', () => { + describe('Wheel size dependencies', () => { + it('should assign 26-inch wheels to small frames', () => { + const wheels = getWheelSize('small'); + expect(wheels).toHaveLength(1); + expect(wheels[0].value).toBe('26-inch'); + }); + + it('should assign 28-inch wheels to medium and large frames', () => { + expect(getWheelSize('medium')[0].value).toBe('28-inch'); + expect(getWheelSize('large')[0].value).toBe('28-inch'); + }); + }); + + describe('Saddle and grip compatibility', () => { + it('should limit sport saddle to sport grips only', () => { + const grips = getGripOptions('sport'); + expect(grips).toHaveLength(1); + expect(grips[0].value).toBe('sport-black'); + }); + + it('should allow grip choice with comfort saddle', () => { + const grips = getGripOptions('comfort'); + expect(grips).toHaveLength(2); + expect(grips.map((g) => g.value)).toContain('brown'); + expect(grips.map((g) => g.value)).toContain('black'); + }); + }); + + describe('Frame-specific accessories', () => { + it('should offer size-appropriate baskets', () => { + const smallBasket = getBasketOptions('small'); + const largeBasket = getBasketOptions('large'); + + expect(smallBasket.find((b) => b.value === 'basket')?.nodeIds).toContain( + 'basket_small' + ); + expect(largeBasket.find((b) => b.value === 'basket')?.nodeIds).toContain( + 'basket_large' + ); + }); + }); +}); +``` + +## Re-evaluation behavior + +When using dynamic mapping, the entire configuration re-evaluates on each selection change: + +1. User selects new value +2. Context updates with new selection +3. All dynamic functions run again +4. Available options may change +5. Invalid selections are handled by the server + +**Important**: If a currently selected value disappears (like "brown grips" when switching to sport +saddle), the server will handle the state update appropriately. + +## Best practices + +1. **Keep functions pure**: Avoid side effects in mapping functions +2. **Handle missing dependencies**: Always provide defaults when context values are undefined +3. **Extract complex logic**: Move business rules to separate, testable functions +4. **Test your logic**: Unit test the extracted functions independently +5. **Document dependencies**: Make it clear which attributes depend on others +6. **Consider user experience**: Ensure logical defaults when options change + +## When to use dynamic vs static + +→ See [Types of mapping](./mapping-concept.md#types-of-mapping) in the mapping concepts guide for detailed +comparison and use cases. diff --git a/docs/mapping-concept.md b/docs/mapping-concept.md new file mode 100644 index 0000000..ec8105a --- /dev/null +++ b/docs/mapping-concept.md @@ -0,0 +1,414 @@ +# Understanding attribute mapping + +## What is attribute mapping + +Attribute mapping is the bridge between your product's business logic and its 3D visualization. It translates +your product options into specific parts of the 3D model that should be visible. + +This translation layer solves a fundamental disconnect: + +- Your business domain speaks in product terminology - colors, sizes, features, SKUs +- The 3D world operates with technical constructs - meshes, nodes, materials, transforms +- Your customers think in their own terms - "I want the blue one with the basket" + +Without this bridge, you'd be forced to embed business logic into your 3D models or technical details into your +product catalog. Mapping keeps both worlds pure and focused on what they do best. + +## Why do we use mapping + +### The separation of concerns + +The 3D server is intentionally generic - it doesn't know anything about your specific products, pricing, +inventory, or business rules. This separation provides several benefits: + +1. **Business flexibility**: Update product options, pricing, or availability without touching the 3D model +2. **Reusability**: Use the same 3D server for all your products +3. **Independence**: Your developers work on business logic while 3D artists work on models +4. **Scalability**: Add new product variants without modifying the 3D infrastructure + +### Key advantages + +- **Full control**: You decide exactly which parts of the model are shown for each option +- **Business logic stays pure**: Your application handles products, the 3D server just visualizes +- **Easy updates**: When products or availability changes, just update the mapping +- **Logical grouping**: Complex 3D structures become simple user choices (one button can control multiple nodes) + +## When do you need mapping + +### You need mapping when + +- **Products with color/material variations**: Different textures or materials per option +- **Products with size options**: Different 3D meshes or positioning per size +- **Modular products**: Parts that can be added, removed, or swapped +- **Optional features**: Accessories, upgrades, or add-ons +- **Any scenario where user choices affect what's visible in 3D** + +### You don't need mapping when + +- **Static 3D models**: Always look the same regardless of user interaction +- **Product showcases**: Pure visualization without configuration options +- **Architectural visualizations**: Static buildings or environments +- **Art pieces or sculptures**: Single-configuration display items +- **Single-variant products**: No customization options available + +## Example: City bicycle configurator + +To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. +A typical bicycle has many customizable options, but let's start with the most obvious ones: + +- Frame color (blue, red, or black) +- Accessories (adding a basket or lights) +- Saddle type (comfort or sport) + +When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. +When they add a basket, the basket accessory becomes visible on the 3D model. + +## Components of mapping + +### 1. Attributes + +An attribute represents a customizable aspect of your product. For our complete bicycle +configurator, all configurable options are: + +- **Frame Color** - The main frame color +- **Frame Size** - Small, Medium, Large +- **Saddle Type** - Comfort or sport +- **Grips** - Brown or black +- **Accessories** - Basket, lights, bell +- **Gear System** - Single speed or 7-speed +- **Wheel Size** - 26" or 28" (depends on frame size in dynamic mapping) + +### 2. Node IDs + +Node IDs are the names of specific parts in the bicycle's 3D model: + +- `frame_blue` - The blue-colored frame mesh +- `frame_red` - The red-colored frame mesh +- `frame_black` - The black-colored frame mesh +- `basket_front` - Front basket accessory +- `lights_front` - Front light +- `lights_rear` - Rear light +- `bell_chrome` - Chrome bell on handlebar +- `saddle_comfort_brown` - Brown comfort saddle +- `saddle_sport_black` - Black sport saddle +- `gears_7speed` - 7-speed gear system parts + +These are the actual 3D elements that will be shown or hidden. The 3D artist decides these names when +creating the model, and they cannot be changed. You must use the exact node IDs that exist in the 3D file. + +### 3. Values + +Each attribute has multiple possible values. For our bicycle: + +- **Frame Color**: "blue", "red", "black" +- **Frame Size**: "small", "medium", "large" +- **Accessories**: "none", "basket", "lights", "full" (basket + lights + bell) +- **Saddle Type**: "comfort", "sport" +- **Gear System**: "single", "7-speed" + +**The value property:** This is an identifier that connects your UI to the 3D visualization. It can be any +string value - the client library simply uses it to match your selection calls with the mapping configuration. + +While you can use any values you want (even made-up ones like "option-a", "variant-1"), it's best practice +to use the same values as your existing e-commerce or product system. This makes integration easier. + +### 4. Selection state + +Each value can be marked as selected or not: + +- `isSelected: true` - This option is active by default +- `isSelected: false` or omitted - This option is not active + +For our bicycle, we might want: + +- Default frame color: Blue (`isSelected: true`) +- Default frame size: Medium (most common) +- Default accessories: None (base model) + +**Complete example with all properties:** + +```typescript +// Good practice: Match your existing system +{ + value: 'blue', + nodeIds: ['frame_blue'], + isSelected: true // This is the default color +} + +// Using SKU system +{ + value: 'SKU-BASKET-01', + nodeIds: ['basket_front'], + isSelected: false // Not selected by default +} + +// Also valid: Custom identifiers +{ + value: 'primary-color', + nodeIds: ['frame_blue'], + isSelected: true // Default selection +} +``` + +**Connecting to UI elements:** Here's how the value connects your bicycle configurator interface to the 3D model: + +```html + + + + + + + + + + + + + + + + + + +``` + +The key is: whatever value you define in your mapping must match what you pass to `select()`. + +## How the mapping process works + +### Step 1: Define your mapping structure + +Here's a complete mapping for our bicycle configurator: + +```typescript +{ + attributes: [ + { + name: 'Frame Color', + values: [ + { + value: 'blue', + nodeIds: ['frame_blue'], + isSelected: true, // Default color + }, + { + value: 'red', + nodeIds: ['frame_red'], + }, + { + value: 'black', + nodeIds: ['frame_black'], + }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, // Default saddle + }, + { + value: 'sport', + nodeIds: ['saddle_sport_black'], + }, + ], + }, + { + name: 'Accessories', + values: [ + { + value: 'none', + nodeIds: [], // No accessories visible + isSelected: true, + }, + { + value: 'basket', + nodeIds: ['basket_front'], + }, + { + value: 'lights', + nodeIds: ['lights_front', 'lights_rear'], + }, + { + value: 'full', + nodeIds: [ + 'basket_front', + 'lights_front', + 'lights_rear', + 'bell_chrome', + ], + }, + ], + }, + ]; +} +``` + +### Step 2: Client processes the mapping + +When you call `client.setMapping()`: + +1. **Validation**: The configuration is validated for correctness +2. **Storage**: The mapping is stored locally in the client +3. **Initial mutations**: The client calculates which nodes should be visible based on `isSelected` + - Shows: `frame_blue`, `saddle_comfort_brown` + - Hides: All red and black frames, sport saddle, all accessories +4. **Message to server**: These visibility changes are sent to the 3D server + +### Step 3: Server applies the changes + +The 3D server: + +1. Receives the visibility instructions +2. Shows/hides the appropriate bicycle parts +3. Sends back confirmation of the current state + +### Step 4: User interactions + +When a user customizes their bicycle: + +1. User selects "Red" frame color from dropdown +2. Your code calls `client.getAttribute('Frame Color').select('red')` +3. Client calculates what needs to change: + - Hide: `frame_blue` + - Show: `frame_red` +4. These mutations are sent to the server +5. Server updates the 3D bicycle model and confirms the new state + +```mermaid +sequenceDiagram + participant UI as Bicycle configurator UI + participant Client as Client library + participant Server as 3D server + + UI->>Client: getAttribute('Frame Color').select('red') + Client->>Client: Calculate mutations
(hide blue, show red) + Client->>Server: Send mutations + Note right of Client: Returns immediately + Server->>Server: Apply visibility changes + Server->>Client: STATE_CHANGED event + Client->>Client: Update local state + Client->>UI: Trigger onChange callbacks +``` + +## Key principles + +### 1. Server is the source of truth + +The client sends mutation requests, but the server determines the actual state. Only the server knows which +nodes really exist in the 3D model. The client's state is updated only after receiving the STATE_CHANGED event +from the server. + +### 2. Fire and forget + +When you call `select()`, the method returns immediately without waiting for server confirmation. Your code can +continue, but the actual state update happens asynchronously when the server responds. + +### 3. Declarative configuration + +You describe what should be visible for each option, not how to transition between states. +The system handles the complexity. + +### 4. Grouping for simplicity + +Multiple 3D nodes can be grouped under one user-facing option. Users see "Full Package" while the system manages +multiple accessory parts (`basket_front`, `lights_front`, `lights_rear`, `bell_chrome`). + +**Example benefits:** + +```typescript +// Group related 3D parts into one logical choice +{ + value: 'Red', + nodeIds: [ + 'frame_red', // Red frame mesh + 'fenders_red', // Red fenders material + 'chain_guard_red', // Red chain guard texture + 'grips_red' // Red grip details + ] +} + +// The server automatically handles showing Red parts +// and hiding Blue/Black parts when Red is selected +``` + +## Types of mapping + +There are two approaches to implementing attribute mapping, each suited to different scenarios: + +### Static mapping + +All options are predefined and independent. Every combination is valid. + +**Example**: Basic bicycle configurator + +- Color: blue, red, black (always available) +- Saddle: comfort, sport (always available) +- Accessories: basket, lights, bell (always available) + +**When to use**: Products with simple variations where choices don't affect each other. + +→ [Learn how to implement static mapping](./static-mapping.md) + +### Dynamic mapping + +Options change based on other selections. The system prevents invalid combinations. + +**Example**: Advanced bicycle configurator + +- Frame size: small → forces 26" wheels, positions parts closer +- Frame size: large → allows 28" wheels, spreads components wider +- Saddle: sport → requires matching sport grips +- Color: premium red → only available on medium/large frames + +**When to use**: Products where one choice affects others, requires specific combinations, or has conditional rules. + +→ [Learn how to implement dynamic mapping](./dynamic-mapping.md) + +## Need help with mapping + +- We help you connect your product catalog to your 3D models +- Visual mapping tool coming soon to simplify this process +- Contact [support@virtualdisplay.io](mailto:support@virtualdisplay.io) for mapping assistance diff --git a/docs/static-mapping.md b/docs/static-mapping.md new file mode 100644 index 0000000..cebb63e --- /dev/null +++ b/docs/static-mapping.md @@ -0,0 +1,262 @@ +# Static mapping guide + +Static mapping is the simplest approach for product configurators where all options are predetermined and +independent of each other. + +> **New to attribute mapping?** If you haven't read about the core concepts yet, start with +> [Understanding attribute mapping](./mapping-concept.md) for the fundamentals. + +## What makes mapping "static" + +In static mapping: + +- All attribute values are defined upfront +- Options don't change based on other selections +- Every combination is valid (from the mapping perspective) +- The configuration structure remains constant + +## When to use static mapping + +Static mapping is ideal for: + +- **Simple products**: Limited options without dependencies +- **Color/material variants**: Different finishes of the same product +- **Independent features**: Where options don't affect each other +- **Small product catalogs**: When managing all combinations is feasible + +## City bicycle example + +To make these concepts concrete, we'll use a city bicycle configurator throughout this documentation. +A typical bicycle has many customizable options, but let's start with the most obvious ones: + +- Frame color (blue, red, or black) +- Accessories (adding a basket or lights) +- Saddle type (comfort or sport) + +When the user selects "blue frame", the system shows the blue frame mesh and hides the red and black ones. +When they add a basket, the basket accessory becomes visible on the 3D model. + +Let's configure this city bicycle with simple, independent options. Each option can be selected without affecting the others: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Bell', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'chrome', nodeIds: ['bell_chrome'] }, + { value: 'black', nodeIds: ['bell_black'] }, + ], + }, + ], +}); +``` + +With this static mapping: + +- You can choose any frame color +- You can choose any saddle type +- You can add or remove a bell +- All 3 × 2 × 3 = 18 combinations are valid + +## Common patterns + +### Single attribute configuration + +The most basic pattern - one customizable aspect: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + ], +}); +``` + +### Multi-part grouping + +When one selection controls multiple 3D elements. For our bicycle, selecting a color might change multiple parts: + +```typescript +{ + value: 'red', + nodeIds: [ + 'frame_red', // Main frame + 'fenders_red', // Matching fenders + 'chain_guard_red', // Chain guard + 'grips_red' // Handle grips + ] +} +``` + +**Important**: All nodeIds must exist in the 3D model. Adding new elements requires updating the mapping +configuration. + +### Optional accessories + +For add-ons or optional components like lights or basket: + +```typescript +{ + name: 'Accessories', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'basket', nodeIds: ['basket_front'] }, + { value: 'lights', nodeIds: ['lights_front', 'lights_rear'] }, + { value: 'both', nodeIds: ['basket_front', 'lights_front', 'lights_rear'] } + ] +} +``` + +## Creating UI from mapping + +A powerful aspect of attribute mapping is that it contains all the information needed to generate a user +interface. The mapping can drive your UI directly: + +```typescript +// Get frame color options from the mapping +const colorAttribute = client.getAttribute('Frame Color'); +const colors = colorAttribute.getValues(); + +// Generate color swatches +colors.forEach((colorValue) => { + const swatch = document.createElement('button'); + swatch.className = 'color-swatch'; + swatch.style.backgroundColor = colorValue.value; // 'blue', 'red', 'black' + swatch.title = `${colorValue.value} frame`; + swatch.onclick = () => colorAttribute.select(colorValue.value); + + // Update swatch state when 3D model changes + colorValue.onChange = () => { + swatch.classList.toggle('selected', colorValue.isSelected); + }; +}); +``` + +See the [color configurator example](../examples/color-configurator/index.html) for a complete implementation +that generates its entire UI from the mapping configuration. + +## Complete bicycle configurator + +Here's a full static mapping for our city bicycle with all independent options: + +```typescript +client.setMapping({ + attributes: [ + { + name: 'Frame Color', + values: [ + { value: 'blue', nodeIds: ['frame_blue'], isSelected: true }, + { value: 'red', nodeIds: ['frame_red'] }, + { value: 'black', nodeIds: ['frame_black'] }, + ], + }, + { + name: 'Saddle Type', + values: [ + { + value: 'comfort', + nodeIds: ['saddle_comfort_brown'], + isSelected: true, + }, + { value: 'sport', nodeIds: ['saddle_sport_black'] }, + ], + }, + { + name: 'Grips', + values: [ + { value: 'brown', nodeIds: ['grips_brown'], isSelected: true }, + { value: 'black', nodeIds: ['grips_black'] }, + ], + }, + { + name: 'Basket', + values: [ + { value: 'none', nodeIds: [], isSelected: true }, + { value: 'wicker', nodeIds: ['basket_wicker'] }, + { value: 'metal', nodeIds: ['basket_metal'] }, + ], + }, + { + name: 'Bell', + values: [ + { value: 'none', nodeIds: [], isSelected: false }, + { value: 'chrome', nodeIds: ['bell_chrome'], isSelected: true }, + ], + }, + ], +}); +``` + +## Limitations of static mapping + +Static mapping works great for our simple bicycle configurator, but consider dynamic mapping when: + +- **Frame size affects wheel size**: Small frame needs 26" wheels, large frame needs 28" wheels +- **Accessories depend on frame type**: Racing saddle only available with sport frame +- **Complex compatibility rules**: Certain colors only available in certain sizes +- **Price calculations**: Different combinations have different pricing logic + +## Best practices + +### 1. Keep it simple + +Static mapping works best for straightforward products: + +- Few attributes (1-5) +- Limited values per attribute (2-5) +- No interdependencies + +### 2. Match the 3D model structure + +Your nodeIds must exactly match what's in the 3D file: + +- Coordinate with 3D artists on naming conventions +- Document the expected node structure +- Test all combinations during development + +### 3. Use meaningful defaults + +Set `isSelected: true` on sensible defaults: + +- Most popular color (blue frame) +- Standard configuration (comfort saddle) +- Essential accessories (bell included) + +## When to upgrade to dynamic mapping + +You've outgrown static mapping when: + +- You need conditional logic ("26-inch wheels only available with small frame") +- Options affect available choices ("sport package includes racing saddle") +- You're duplicating similar configurations with small variations +- Business rules become complex to express statically + +→ [Learn about dynamic mapping](./dynamic-mapping.md) diff --git a/examples/snapshot-demo/index.html b/examples/snapshot-demo/index.html index ca8cc08..85252be 100644 --- a/examples/snapshot-demo/index.html +++ b/examples/snapshot-demo/index.html @@ -650,8 +650,7 @@

Activity log

const match = nameWithoutExt.match(/(\d+)$/); if (match) { const num = parseInt(match[1]) + 1; - const newName = nameWithoutExt.replace(/\d+$/, num) + ext; - filenameInput.value = newName; + filenameInput.value = nameWithoutExt.replace(/\d+$/, num) + ext; } else { filenameInput.value = nameWithoutExt + '-2' + ext; } diff --git a/package.json b/package.json index ef0f84f..bd04806 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "ajv": "8.17.1" }, "devDependencies": { - "@eslint/js": "^9.31.0", + "@eslint/js": "^9.32.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "7.1.0", @@ -34,7 +34,7 @@ "@virtualdisplay-io/shared-config": "^1.4.0", "@vitest/coverage-v8": "^3.2.4", "conventional-changelog-conventionalcommits": "9.1.0", - "eslint": "^9.31.0", + "eslint": "^9.32.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.3", @@ -47,7 +47,7 @@ "semantic-release": "^24.2.7", "terser": "^5.43.1", "typescript": "^5.8.3", - "vite": "^7.0.5", + "vite": "^7.0.6", "vite-bundle-visualizer": "^1.2.1", "vite-plugin-dts": "^4.5.4", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9846f72..24697a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: version: 8.17.1 devDependencies: '@eslint/js': - specifier: ^9.31.0 - version: 9.31.0 + specifier: ^9.32.0 + version: 9.32.0 '@semantic-release/changelog': specifier: ^6.0.3 version: 6.0.3(semantic-release@24.2.7(typescript@5.8.3)) @@ -44,13 +44,13 @@ importers: version: 24.1.0 '@typescript-eslint/eslint-plugin': specifier: ^8.38.0 - version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3) + version: 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.38.0 - version: 8.38.0(eslint@9.31.0)(typescript@5.8.3) + version: 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@virtualdisplay-io/shared-config': specifier: ^1.4.0 - version: 1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0))(eslint-plugin-unicorn@60.0.0(eslint@9.31.0))(eslint@9.31.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3) + version: 1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0))(eslint-plugin-unicorn@60.0.0(eslint@9.32.0))(eslint@9.32.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jsdom@26.1.0)(terser@5.43.1)) @@ -58,20 +58,20 @@ importers: specifier: 9.1.0 version: 9.1.0 eslint: - specifier: ^9.31.0 - version: 9.31.0 + specifier: ^9.32.0 + version: 9.32.0 eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0) + version: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@9.31.0) + version: 6.10.2(eslint@9.32.0) eslint-plugin-prettier: specifier: ^5.5.3 - version: 5.5.3(eslint@9.31.0)(prettier@3.6.2) + version: 5.5.3(eslint@9.32.0)(prettier@3.6.2) eslint-plugin-unicorn: specifier: ^60.0.0 - version: 60.0.0(eslint@9.31.0) + version: 60.0.0(eslint@9.32.0) globals: specifier: ^16.3.0 version: 16.3.0 @@ -97,14 +97,14 @@ importers: specifier: ^5.8.3 version: 5.8.3 vite: - specifier: ^7.0.5 - version: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + specifier: ^7.0.6 + version: 7.0.6(@types/node@24.1.0)(terser@5.43.1) vite-bundle-visualizer: specifier: ^1.2.1 - version: 1.2.1(rollup@4.45.1) + version: 1.2.1(rollup@4.46.1) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@24.1.0)(rollup@4.45.1)(typescript@5.8.3)(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)) + version: 4.5.4(@types/node@24.1.0)(rollup@4.46.1)(typescript@5.8.3)(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)) vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.1.0)(jsdom@26.1.0)(terser@5.43.1) @@ -135,8 +135,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -357,8 +357,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.31.0': - resolution: {integrity: sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==} + '@eslint/js@9.32.0': + resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': @@ -515,103 +515,103 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.45.1': - resolution: {integrity: sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==} + '@rollup/rollup-android-arm-eabi@4.46.1': + resolution: {integrity: sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.45.1': - resolution: {integrity: sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==} + '@rollup/rollup-android-arm64@4.46.1': + resolution: {integrity: sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.45.1': - resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==} + '@rollup/rollup-darwin-arm64@4.46.1': + resolution: {integrity: sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.45.1': - resolution: {integrity: sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==} + '@rollup/rollup-darwin-x64@4.46.1': + resolution: {integrity: sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.45.1': - resolution: {integrity: sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==} + '@rollup/rollup-freebsd-arm64@4.46.1': + resolution: {integrity: sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.45.1': - resolution: {integrity: sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==} + '@rollup/rollup-freebsd-x64@4.46.1': + resolution: {integrity: sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': - resolution: {integrity: sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==} + '@rollup/rollup-linux-arm-gnueabihf@4.46.1': + resolution: {integrity: sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.45.1': - resolution: {integrity: sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==} + '@rollup/rollup-linux-arm-musleabihf@4.46.1': + resolution: {integrity: sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.45.1': - resolution: {integrity: sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==} + '@rollup/rollup-linux-arm64-gnu@4.46.1': + resolution: {integrity: sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.45.1': - resolution: {integrity: sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==} + '@rollup/rollup-linux-arm64-musl@4.46.1': + resolution: {integrity: sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': - resolution: {integrity: sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==} + '@rollup/rollup-linux-loongarch64-gnu@4.46.1': + resolution: {integrity: sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': - resolution: {integrity: sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==} + '@rollup/rollup-linux-ppc64-gnu@4.46.1': + resolution: {integrity: sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.45.1': - resolution: {integrity: sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==} + '@rollup/rollup-linux-riscv64-gnu@4.46.1': + resolution: {integrity: sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.45.1': - resolution: {integrity: sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==} + '@rollup/rollup-linux-riscv64-musl@4.46.1': + resolution: {integrity: sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.45.1': - resolution: {integrity: sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==} + '@rollup/rollup-linux-s390x-gnu@4.46.1': + resolution: {integrity: sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.45.1': - resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==} + '@rollup/rollup-linux-x64-gnu@4.46.1': + resolution: {integrity: sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.45.1': - resolution: {integrity: sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==} + '@rollup/rollup-linux-x64-musl@4.46.1': + resolution: {integrity: sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.45.1': - resolution: {integrity: sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==} + '@rollup/rollup-win32-arm64-msvc@4.46.1': + resolution: {integrity: sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.45.1': - resolution: {integrity: sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==} + '@rollup/rollup-win32-ia32-msvc@4.46.1': + resolution: {integrity: sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.45.1': - resolution: {integrity: sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==} + '@rollup/rollup-win32-x64-msvc@4.46.1': + resolution: {integrity: sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==} cpu: [x64] os: [win32] @@ -856,14 +856,14 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@volar/language-core@2.4.20': - resolution: {integrity: sha512-dRDF1G33xaAIDqR6+mXUIjXYdu9vzSxlMGfMEwBxQsfY/JMUEXSpLTR057oTKlUQ2nIvCmP9k94A8h8z2VrNSA==} + '@volar/language-core@2.4.22': + resolution: {integrity: sha512-gp4M7Di5KgNyIyO903wTClYBavRt6UyFNpc5LWfyZr1lBsTUY+QrVZfmbNF2aCyfklBOVk9YC4p+zkwoyT7ECg==} - '@volar/source-map@2.4.20': - resolution: {integrity: sha512-mVjmFQH8mC+nUaVwmbxoYUy8cww+abaO8dWzqPUjilsavjxH0jCJ3Mp8HFuHsdewZs2c+SP+EO7hCd8Z92whJg==} + '@volar/source-map@2.4.22': + resolution: {integrity: sha512-L2nVr/1vei0xKRgO2tYVXtJYd09HTRjaZi418e85Q+QdbbqA8h7bBjfNyPPSsjnrOO4l4kaAo78c8SQUAdHvgA==} - '@volar/typescript@2.4.20': - resolution: {integrity: sha512-Oc4DczPwQyXcVbd+5RsNEqX6ia0+w3p+klwdZQ6ZKhFjWoBP9PCPQYlKYRi/tDemWphW93P/Vv13vcE9I9D2GQ==} + '@volar/typescript@2.4.22': + resolution: {integrity: sha512-6ZczlJW1/GWTrNnkmZxJp4qyBt/SGVlcTuCWpI5zLrdPdCZsj66Aff9ZsfFaT3TyjG8zVYgBMYPuCm/eRkpcpQ==} '@vue/compiler-core@3.5.18': resolution: {integrity: sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==} @@ -1341,8 +1341,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.190: - resolution: {integrity: sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==} + electron-to-chromium@1.5.192: + resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1506,8 +1506,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.31.0: - resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} + eslint@9.32.0: + resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2525,8 +2525,8 @@ packages: - which - write-file-atomic - nwsapi@2.2.20: - resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + nwsapi@2.2.21: + resolution: {integrity: sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -2846,8 +2846,8 @@ packages: rollup: optional: true - rollup@4.45.1: - resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==} + rollup@4.46.1: + resolution: {integrity: sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2975,9 +2975,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} spawn-error-forwarder@1.0.0: resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} @@ -3328,8 +3328,8 @@ packages: vite: optional: true - vite@7.0.5: - resolution: {integrity: sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==} + vite@7.0.6: + resolution: {integrity: sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3541,9 +3541,9 @@ snapshots: '@babel/parser@7.28.0': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 - '@babel/types@7.28.1': + '@babel/types@7.28.2': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 @@ -3651,9 +3651,9 @@ snapshots: '@esbuild/win32-x64@0.25.8': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0)': + '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)': dependencies: - eslint: 9.31.0 + eslint: 9.32.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -3686,7 +3686,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.31.0': {} + '@eslint/js@9.32.0': {} '@eslint/object-schema@2.1.6': {} @@ -3861,72 +3861,72 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@rollup/pluginutils@5.2.0(rollup@4.45.1)': + '@rollup/pluginutils@5.2.0(rollup@4.46.1)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.45.1 + rollup: 4.46.1 - '@rollup/rollup-android-arm-eabi@4.45.1': + '@rollup/rollup-android-arm-eabi@4.46.1': optional: true - '@rollup/rollup-android-arm64@4.45.1': + '@rollup/rollup-android-arm64@4.46.1': optional: true - '@rollup/rollup-darwin-arm64@4.45.1': + '@rollup/rollup-darwin-arm64@4.46.1': optional: true - '@rollup/rollup-darwin-x64@4.45.1': + '@rollup/rollup-darwin-x64@4.46.1': optional: true - '@rollup/rollup-freebsd-arm64@4.45.1': + '@rollup/rollup-freebsd-arm64@4.46.1': optional: true - '@rollup/rollup-freebsd-x64@4.45.1': + '@rollup/rollup-freebsd-x64@4.46.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.45.1': + '@rollup/rollup-linux-arm-gnueabihf@4.46.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.45.1': + '@rollup/rollup-linux-arm-musleabihf@4.46.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.45.1': + '@rollup/rollup-linux-arm64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.45.1': + '@rollup/rollup-linux-arm64-musl@4.46.1': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.45.1': + '@rollup/rollup-linux-loongarch64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': + '@rollup/rollup-linux-ppc64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.45.1': + '@rollup/rollup-linux-riscv64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.45.1': + '@rollup/rollup-linux-riscv64-musl@4.46.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.45.1': + '@rollup/rollup-linux-s390x-gnu@4.46.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.45.1': + '@rollup/rollup-linux-x64-gnu@4.46.1': optional: true - '@rollup/rollup-linux-x64-musl@4.45.1': + '@rollup/rollup-linux-x64-musl@4.46.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.45.1': + '@rollup/rollup-win32-arm64-msvc@4.46.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.45.1': + '@rollup/rollup-win32-ia32-msvc@4.46.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.45.1': + '@rollup/rollup-win32-x64-msvc@4.46.1': optional: true '@rtsao/scc@1.1.0': {} @@ -4110,15 +4110,15 @@ snapshots: '@types/unist@2.0.11': {} - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/type-utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 - eslint: 9.31.0 + eslint: 9.32.0 graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -4127,14 +4127,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.38.0 debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.32.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -4157,13 +4157,13 @@ snapshots: dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) debug: 4.4.1 - eslint: 9.31.0 + eslint: 9.32.0 ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: @@ -4187,13 +4187,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.38.0(eslint@9.31.0)(typescript@5.8.3)': + '@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@typescript-eslint/scope-manager': 8.38.0 '@typescript-eslint/types': 8.38.0 '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - eslint: 9.31.0 + eslint: 9.32.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -4205,15 +4205,15 @@ snapshots: '@virtualdisplay-io/logger@1.2.0': {} - '@virtualdisplay-io/shared-config@1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0))(eslint-plugin-unicorn@60.0.0(eslint@9.31.0))(eslint@9.31.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3)': + '@virtualdisplay-io/shared-config@1.4.0(@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3))(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0))(eslint-plugin-unicorn@60.0.0(eslint@9.32.0))(eslint@9.32.0)(markdownlint-cli2@0.18.1)(prettier@3.6.2)(typescript@5.8.3)': dependencies: - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0)(typescript@5.8.3) - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - eslint: 9.31.0 - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.31.0) - eslint-plugin-lit: 2.1.1(eslint@9.31.0) - eslint-plugin-unicorn: 60.0.0(eslint@9.31.0) + '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + eslint: 9.32.0 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.32.0) + eslint-plugin-lit: 2.1.1(eslint@9.32.0) + eslint-plugin-unicorn: 60.0.0(eslint@9.32.0) markdownlint-cli2: 0.18.1 prettier: 3.6.2 typescript: 5.8.3 @@ -4245,13 +4245,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1))': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -4279,15 +4279,15 @@ snapshots: loupe: 3.2.0 tinyrainbow: 2.0.0 - '@volar/language-core@2.4.20': + '@volar/language-core@2.4.22': dependencies: - '@volar/source-map': 2.4.20 + '@volar/source-map': 2.4.22 - '@volar/source-map@2.4.20': {} + '@volar/source-map@2.4.22': {} - '@volar/typescript@2.4.20': + '@volar/typescript@2.4.22': dependencies: - '@volar/language-core': 2.4.20 + '@volar/language-core': 2.4.22 path-browserify: 1.0.1 vscode-uri: 3.1.0 @@ -4311,7 +4311,7 @@ snapshots: '@vue/language-core@2.2.0(typescript@5.8.3)': dependencies: - '@volar/language-core': 2.4.20 + '@volar/language-core': 2.4.22 '@vue/compiler-dom': 3.5.18 '@vue/compiler-vue2': 2.7.16 '@vue/shared': 3.5.18 @@ -4504,7 +4504,7 @@ snapshots: browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001727 - electron-to-chromium: 1.5.190 + electron-to-chromium: 1.5.192 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) @@ -4785,7 +4785,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.190: {} + electron-to-chromium@1.5.192: {} emoji-regex@8.0.0: {} @@ -4939,17 +4939,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) - eslint: 9.31.0 + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) + eslint: 9.32.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint@9.31.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4958,9 +4958,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.31.0 + eslint: 9.32.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.31.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.31.0) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.32.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4972,13 +4972,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.38.0(eslint@9.31.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.31.0): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.32.0): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -4988,7 +4988,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.31.0 + eslint: 9.32.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -4997,29 +4997,29 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-lit@2.1.1(eslint@9.31.0): + eslint-plugin-lit@2.1.1(eslint@9.32.0): dependencies: - eslint: 9.31.0 + eslint: 9.32.0 parse5: 6.0.1 parse5-htmlparser2-tree-adapter: 6.0.1 - eslint-plugin-prettier@5.5.3(eslint@9.31.0)(prettier@3.6.2): + eslint-plugin-prettier@5.5.3(eslint@9.32.0)(prettier@3.6.2): dependencies: - eslint: 9.31.0 + eslint: 9.32.0 prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.11.11 - eslint-plugin-unicorn@60.0.0(eslint@9.31.0): + eslint-plugin-unicorn@60.0.0(eslint@9.32.0): dependencies: '@babel/helper-validator-identifier': 7.27.1 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@eslint/plugin-kit': 0.3.4 change-case: 5.4.4 ci-info: 4.3.0 clean-regexp: 1.0.0 core-js-compat: 3.44.0 - eslint: 9.31.0 + eslint: 9.32.0 esquery: 1.6.0 find-up-simple: 1.0.1 globals: 16.3.0 @@ -5041,15 +5041,15 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.31.0: + eslint@9.32.0: dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.0 '@eslint/core': 0.15.1 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.31.0 + '@eslint/js': 9.32.0 '@eslint/plugin-kit': 0.3.4 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -5677,7 +5677,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.20 + nwsapi: 2.2.21 parse5: 7.3.0 rrweb-cssom: 0.8.0 saxes: 6.0.0 @@ -5810,7 +5810,7 @@ snapshots: magicast@0.3.5: dependencies: '@babel/parser': 7.28.0 - '@babel/types': 7.28.1 + '@babel/types': 7.28.2 source-map-js: 1.2.1 make-dir@4.0.0: @@ -6142,7 +6142,7 @@ snapshots: npm@10.9.3: {} - nwsapi@2.2.20: {} + nwsapi@2.2.21: {} object-assign@4.1.1: {} @@ -6449,39 +6449,39 @@ snapshots: reusify@1.1.0: {} - rollup-plugin-visualizer@5.14.0(rollup@4.45.1): + rollup-plugin-visualizer@5.14.0(rollup@4.46.1): dependencies: open: 8.4.2 picomatch: 4.0.3 - source-map: 0.7.4 + source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: - rollup: 4.45.1 + rollup: 4.46.1 - rollup@4.45.1: + rollup@4.46.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.45.1 - '@rollup/rollup-android-arm64': 4.45.1 - '@rollup/rollup-darwin-arm64': 4.45.1 - '@rollup/rollup-darwin-x64': 4.45.1 - '@rollup/rollup-freebsd-arm64': 4.45.1 - '@rollup/rollup-freebsd-x64': 4.45.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.45.1 - '@rollup/rollup-linux-arm-musleabihf': 4.45.1 - '@rollup/rollup-linux-arm64-gnu': 4.45.1 - '@rollup/rollup-linux-arm64-musl': 4.45.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.45.1 - '@rollup/rollup-linux-powerpc64le-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-gnu': 4.45.1 - '@rollup/rollup-linux-riscv64-musl': 4.45.1 - '@rollup/rollup-linux-s390x-gnu': 4.45.1 - '@rollup/rollup-linux-x64-gnu': 4.45.1 - '@rollup/rollup-linux-x64-musl': 4.45.1 - '@rollup/rollup-win32-arm64-msvc': 4.45.1 - '@rollup/rollup-win32-ia32-msvc': 4.45.1 - '@rollup/rollup-win32-x64-msvc': 4.45.1 + '@rollup/rollup-android-arm-eabi': 4.46.1 + '@rollup/rollup-android-arm64': 4.46.1 + '@rollup/rollup-darwin-arm64': 4.46.1 + '@rollup/rollup-darwin-x64': 4.46.1 + '@rollup/rollup-freebsd-arm64': 4.46.1 + '@rollup/rollup-freebsd-x64': 4.46.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.46.1 + '@rollup/rollup-linux-arm-musleabihf': 4.46.1 + '@rollup/rollup-linux-arm64-gnu': 4.46.1 + '@rollup/rollup-linux-arm64-musl': 4.46.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.46.1 + '@rollup/rollup-linux-ppc64-gnu': 4.46.1 + '@rollup/rollup-linux-riscv64-gnu': 4.46.1 + '@rollup/rollup-linux-riscv64-musl': 4.46.1 + '@rollup/rollup-linux-s390x-gnu': 4.46.1 + '@rollup/rollup-linux-x64-gnu': 4.46.1 + '@rollup/rollup-linux-x64-musl': 4.46.1 + '@rollup/rollup-win32-arm64-msvc': 4.46.1 + '@rollup/rollup-win32-ia32-msvc': 4.46.1 + '@rollup/rollup-win32-x64-msvc': 4.46.1 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -6649,7 +6649,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.7.4: {} + source-map@0.7.6: {} spawn-error-forwarder@1.0.0: {} @@ -6980,11 +6980,11 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-bundle-visualizer@1.2.1(rollup@4.45.1): + vite-bundle-visualizer@1.2.1(rollup@4.46.1): dependencies: cac: 6.7.14 import-from-esm: 1.3.4 - rollup-plugin-visualizer: 5.14.0(rollup@4.45.1) + rollup-plugin-visualizer: 5.14.0(rollup@4.46.1) tmp: 0.2.3 transitivePeerDependencies: - rolldown @@ -6997,7 +6997,7 @@ snapshots: debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - jiti @@ -7012,11 +7012,11 @@ snapshots: - tsx - yaml - vite-plugin-dts@4.5.4(@types/node@24.1.0)(rollup@4.45.1)(typescript@5.8.3)(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)): + vite-plugin-dts@4.5.4(@types/node@24.1.0)(rollup@4.46.1)(typescript@5.8.3)(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)): dependencies: '@microsoft/api-extractor': 7.52.9(@types/node@24.1.0) - '@rollup/pluginutils': 5.2.0(rollup@4.45.1) - '@volar/typescript': 2.4.20 + '@rollup/pluginutils': 5.2.0(rollup@4.46.1) + '@volar/typescript': 2.4.22 '@vue/language-core': 2.2.0(typescript@5.8.3) compare-versions: 6.1.1 debug: 4.4.1 @@ -7025,19 +7025,19 @@ snapshots: magic-string: 0.30.17 typescript: 5.8.3 optionalDependencies: - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite@7.0.5(@types/node@24.1.0)(terser@5.43.1): + vite@7.0.6(@types/node@24.1.0)(terser@5.43.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.45.1 + rollup: 4.46.1 tinyglobby: 0.2.14 optionalDependencies: '@types/node': 24.1.0 @@ -7048,7 +7048,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.5(@types/node@24.1.0)(terser@5.43.1)) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.1.0)(terser@5.43.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7066,7 +7066,7 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.5(@types/node@24.1.0)(terser@5.43.1) + vite: 7.0.6(@types/node@24.1.0)(terser@5.43.1) vite-node: 3.2.4(@types/node@24.1.0)(terser@5.43.1) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/src/attributes/attribute-service.ts b/src/attributes/attribute-service.ts index 2258387..db2a8a8 100644 --- a/src/attributes/attribute-service.ts +++ b/src/attributes/attribute-service.ts @@ -1,11 +1,11 @@ import { Attribute } from './attribute'; import { AttributeValue } from './attribute-value'; -import type { AttributeValueConfig, MappingConfiguration } from './mapping-types'; +import type { MappingConfiguration, MappingContext, DynamicValue, AttributeConfig } from './mapping-types'; import { MappingValidator } from './mapping-validator'; import type { EventBus } from '../events/event-bus'; import { EVENT_NAMES } from '../events/event-names'; import { createMutationMessage } from '../messaging/message-utils'; -import type { Mutation } from '../mutations/mutation'; +import { Mutation } from '../mutations/mutation'; import type { ModelNode } from '../nodes/node'; import { logger } from '../utils/logger'; @@ -14,12 +14,21 @@ import { logger } from '../utils/logger'; * Handles business logic and coordinates with other domains via events */ export class AttributeService { - private readonly attributes = new Map(); + private attributes = new Map(); private readonly eventBus: EventBus; private isInitialized = false; + // Store original config for re-evaluation + private mappingConfig: MappingConfiguration | null = null; + + // Store current selections + private readonly selections = new Map(); + + // Store current visible nodes per attribute + private readonly currentState = new Map>(); + constructor(eventBus: EventBus) { this.eventBus = eventBus; @@ -35,33 +44,28 @@ export class AttributeService { public loadMapping(config: MappingConfiguration): void { MappingValidator.validate(config); + // Store config for re-evaluation + this.mappingConfig = config; + if (this.isInitialized) { this.attributes.clear(); + this.selections.clear(); + this.currentState.clear(); } - const mutations: Mutation[] = []; - - config.attributes.forEach(attrConfig => { - const attribute = new Attribute(attrConfig.name); - - attrConfig.values.forEach((valueConfig: AttributeValueConfig) => { - const attributeValue = new AttributeValue( - valueConfig.value, - valueConfig.nodeIds, - valueConfig.isSelected, - ); - attribute.addValue(attributeValue); + // Initial evaluation + this.attributes = this.evaluateMapping(); - logger.debug('Created attribute value', attributeValue); - }); + // Initialize selections from default values + this.initializeSelections(); - this.attributes.set(attribute.name, attribute); - - mutations.push(...attribute.getDefaultMutations()); - }); + // Update current state + this.updateCurrentState(); this.isInitialized = true; + // Send initial mutations + const mutations = this.getInitialMutations(); if (mutations.length > 0) { const mutationMessage = createMutationMessage(mutations); this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { @@ -71,19 +75,45 @@ export class AttributeService { } public selectAttributeValue(attributeName: string, value: string): void { + // Check if attribute exists const attribute = this.attributes.get(attributeName); if (attribute === undefined) { + logger.warn('Attribute not found', { attributeName }); return; } - const mutations = attribute.select(value); - if (mutations.length === 0) { + // Check if value exists in attribute + if (!attribute.hasValue(value)) { + logger.warn('Value not found in attribute', { attributeName, value }); return; } - this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { - message: createMutationMessage(mutations), - }); + // 1. Update selection + this.selections.set(attributeName, value); + + // 2. Store current visible nodes + const oldState = new Map(this.currentState); + + // 3. Check if we need to re-evaluate the entire mapping + // This happens when we have dynamic mappings with dependencies + if (this.hasDynamicMapping()) { + // Re-evaluate the entire mapping with updated selections + this.attributes = this.evaluateMapping(); + } + + // 4. Calculate new state based on selections + // (AttributeValues will be updated when server responds with STATE_CHANGED) + this.updateCurrentState(); + + // 5. Calculate mutations + const mutations = this.calculateMutations(oldState, this.currentState); + + // 6. Send mutations + if (mutations.length > 0) { + this.eventBus.emit(EVENT_NAMES.MUTATION_MESSAGE, { + message: createMutationMessage(mutations), + }); + } } public getAttribute(name: string): Attribute | undefined { @@ -103,26 +133,205 @@ export class AttributeService { return; } - nodes.forEach(node => { - const attributeValue = this.getAttributeValueForNode(node.id); - if (attributeValue !== undefined) { - attributeValue.setSelected(node.isVisible); - } - }); - } + // Create a map of node visibility for quick lookup + const nodeVisibility = new Map(); + for (const node of nodes) { + nodeVisibility.set(node.id, node.isVisible); + } - private getAttributeValueForNode(nodeId: string): AttributeValue | undefined { + // For each attribute, update the selected state of values based on node visibility for (const attribute of this.attributes.values()) { - const value = attribute.getAllValues().find(v => v.hasNode(nodeId)); - if (value !== undefined) { - return value; + for (const value of attribute.getAllValues()) { + // Check if all nodes for this value are visible + const allNodesVisible = value.nodeList.every( + nodeId => nodeVisibility.get(nodeId) ?? false, + ); + + // Update the selected state + value.setSelected(allNodesVisible); } } - return undefined; } public clear(): void { this.attributes.clear(); + this.selections.clear(); + this.currentState.clear(); + this.mappingConfig = null; this.isInitialized = false; } + + /** + * Create context for dynamic evaluation + */ + private createContext(): MappingContext { + return { + getValue: (attributeName: string) => this.selections.get(attributeName), + getAllValues: () => new Map(this.selections), + }; + } + + /** + * Evaluate a property that might be static or dynamic + */ + private evaluateProperty(prop: DynamicValue, context: MappingContext): T { + return typeof prop === 'function' ? (prop as (context: MappingContext) => T)(context) : prop; + } + + /** + * Evaluate the entire mapping with current context + */ + private evaluateMapping(): Map { + if (this.mappingConfig === null) { + return new Map(); + } + + const context = this.createContext(); + const evaluatedAttributes = new Map(); + + for (const attrConfig of this.mappingConfig.attributes) { + const attribute = this.createAttributeFromConfig(attrConfig, context); + evaluatedAttributes.set(attribute.name, attribute); + } + + return evaluatedAttributes; + } + + /** + * Create a single attribute from configuration + */ + private createAttributeFromConfig( + attrConfig: AttributeConfig, + context: MappingContext, + ): Attribute { + const attribute = new Attribute(attrConfig.name); + + // Evaluate values (might be function or array) + const values = this.evaluateProperty(attrConfig.values, context); + + for (const valueConfig of values) { + // Evaluate each property + const value = this.evaluateProperty(valueConfig.value, context); + const nodeIds = this.evaluateProperty(valueConfig.nodeIds, context); + const isSelected = this.evaluateProperty(valueConfig.isSelected ?? false, context); + + // Check if this value is currently selected + const currentSelection = this.selections.get(attrConfig.name); + const shouldBeSelected = currentSelection !== undefined + ? currentSelection === value + : isSelected; + + const attributeValue = new AttributeValue(value, nodeIds, shouldBeSelected); + attribute.addValue(attributeValue); + + logger.debug('Created attribute value', attributeValue); + } + + return attribute; + } + + /** + * Initialize selections from default values + */ + private initializeSelections(): void { + for (const [name, attribute] of this.attributes) { + const selectedValue = attribute.getCurrentValue(); + if (selectedValue !== undefined) { + this.selections.set(name, selectedValue.value); + } + } + } + + /** + * Check if the mapping configuration contains dynamic elements + */ + private hasDynamicMapping(): boolean { + if (this.mappingConfig === null) { + return false; + } + return MappingValidator.containsDynamicValues(this.mappingConfig); + } + + /** + * Update current state map with visible nodes per attribute + */ + private updateCurrentState(): void { + this.currentState.clear(); + + for (const [name, attribute] of this.attributes) { + const visibleNodes = this.getVisibleNodesForAttribute(name, attribute); + this.currentState.set(name, visibleNodes); + } + } + + private getVisibleNodesForAttribute(name: string, attribute: Attribute): Set { + const visibleNodes = new Set(); + const selectedValueName = this.selections.get(name); + + if (selectedValueName === undefined) { + return visibleNodes; + } + + const selectedValue = attribute.getValue(selectedValueName); + if (selectedValue !== undefined) { + selectedValue.nodeList.forEach(nodeId => visibleNodes.add(nodeId)); + } + + return visibleNodes; + } + + /** + * Get initial mutations for default selections + */ + private getInitialMutations(): Mutation[] { + const mutations: Mutation[] = []; + + for (const attribute of this.attributes.values()) { + mutations.push(...attribute.getDefaultMutations()); + } + + return mutations; + } + + /** + * Calculate mutations by comparing old and new state + */ + private calculateMutations( + oldState: Map>, + newState: Map>, + ): Mutation[] { + const mutations: Mutation[] = []; + const allNodeIds = new Set(); + + // Collect all nodeIds from both states + oldState.forEach(nodes => nodes.forEach(id => allNodeIds.add(id))); + newState.forEach(nodes => nodes.forEach(id => allNodeIds.add(id))); + + // For each node, determine if visibility changed + for (const nodeId of allNodeIds) { + const wasVisible = this.isNodeVisible(nodeId, oldState); + const isVisible = this.isNodeVisible(nodeId, newState); + + if (wasVisible && !isVisible) { + mutations.push(Mutation.hide(nodeId)); + } + if (!wasVisible && isVisible) { + mutations.push(Mutation.show(nodeId)); + } + } + + return mutations; + } + + /** + * Check if a node is visible in the given state + */ + private isNodeVisible(nodeId: string, state: Map>): boolean { + for (const nodes of state.values()) { + if (nodes.has(nodeId)) { + return true; + } + } + return false; + } } diff --git a/src/attributes/attribute.ts b/src/attributes/attribute.ts index b8de0b3..d069eca 100644 --- a/src/attributes/attribute.ts +++ b/src/attributes/attribute.ts @@ -18,27 +18,6 @@ export class Attribute { this.values.set(attributeValue.value, attributeValue); } - /** - * Select a value - returns mutations needed for the change - * Pure function - no side effects - */ - public select(value: string): Mutation[] { - const newValue = this.values.get(value); - if (newValue === undefined) { - return []; - } - - const currentValue = this.getCurrentValue(); - if (currentValue === newValue) { - return []; - } - - return [ - ...(currentValue?.getMutations().map(m => m.inverse()) ?? []), - ...newValue.getMutations().map(m => m.inverse()), - ]; - } - public getDefaultMutations(): Mutation[] { return Array.from(this.values.values()) .flatMap(value => value.getMutations()); @@ -68,4 +47,8 @@ export class Attribute { public get currentSelection(): string | undefined { return this.getCurrentValue()?.value; } + + public clear(): void { + this.values.clear(); + } } diff --git a/src/attributes/mapping-types.ts b/src/attributes/mapping-types.ts index 2c11939..b861e17 100644 --- a/src/attributes/mapping-types.ts +++ b/src/attributes/mapping-types.ts @@ -1,10 +1,24 @@ +/** + * Context provided to dynamic value functions + */ +export interface MappingContext { + getValue: (attributeName: string) => string | undefined; + getAllValues: () => Map; +} + +/** + * A value that can be static or dynamically computed + */ +export type DynamicValue = T | ((context: MappingContext) => T); + /** * Configuration for a single attribute value + * All properties can be static or dynamic */ export interface AttributeValueConfig { - readonly value: string; - readonly nodeIds: string[]; - readonly isSelected?: boolean; + readonly value: DynamicValue; + readonly nodeIds: DynamicValue; + readonly isSelected?: DynamicValue; } /** @@ -12,7 +26,7 @@ export interface AttributeValueConfig { */ export interface AttributeConfig { readonly name: string; - readonly values: AttributeValueConfig[]; + readonly values: DynamicValue; } /** diff --git a/src/attributes/mapping-validator.ts b/src/attributes/mapping-validator.ts index 8a2d2f1..24f6ac9 100644 --- a/src/attributes/mapping-validator.ts +++ b/src/attributes/mapping-validator.ts @@ -17,6 +17,18 @@ export class MappingValidator { * @throws {VirtualdisplayError} When mapping configuration is invalid */ public static validate(config: MappingConfiguration): void { + if (config === null || config === undefined) { + throw VirtualdisplayError.invalidMapping('Mapping configuration cannot be null or undefined'); + } + + // Check if mapping contains dynamic values (functions) + if (this.containsDynamicValues(config)) { + // Skip JSON schema validation for dynamic mappings + // We can't validate functions with JSON Schema + this.validateBasicStructure(config); + return; + } + if (!this.validateFn(config)) { // AJV already provides good error messages const errors = this.validateFn.errors! @@ -26,4 +38,52 @@ export class MappingValidator { throw VirtualdisplayError.invalidMapping(errors); } } + + /** + * Check if configuration contains dynamic values (functions) + */ + public static containsDynamicValues(config: MappingConfiguration): boolean { + if (config.attributes === undefined || !Array.isArray(config.attributes)) { + return false; + } + + return config.attributes.some(attr => { + // Check if values is a function + if (typeof attr.values === 'function') { + return true; + } + + // Check if any value properties are functions + if (Array.isArray(attr.values)) { + return attr.values.some(val => typeof val.value === 'function' || + typeof val.nodeIds === 'function' || + typeof val.isSelected === 'function'); + } + + return false; + }); + } + + /** + * Basic structure validation for dynamic mappings + */ + private static validateBasicStructure(config: MappingConfiguration): void { + if (config.attributes === undefined || !Array.isArray(config.attributes)) { + throw VirtualdisplayError.invalidMapping('attributes must be an array'); + } + + if (config.attributes.length === 0) { + throw VirtualdisplayError.invalidMapping('attributes array must not be empty'); + } + + config.attributes.forEach((attr, index) => { + if (attr.name === undefined || attr.name === '' || typeof attr.name !== 'string') { + throw VirtualdisplayError.invalidMapping(`attribute at index ${index} must have a name`); + } + + if (attr.values === undefined) { + throw VirtualdisplayError.invalidMapping(`attribute '${attr.name}' must have values`); + } + }); + } } diff --git a/src/events/event-names.ts b/src/events/event-names.ts index 090c672..f74e608 100644 --- a/src/events/event-names.ts +++ b/src/events/event-names.ts @@ -21,5 +21,4 @@ export const EVENT_NAMES = { // Snapshot flow SNAPSHOT_MESSAGE: 'snapshot:take' as const, - SNAPSHOT_DEVELOPED: 'snapshot:developed' as const, } as const; diff --git a/src/messaging/message-types.ts b/src/messaging/message-types.ts index 9810eac..4c6f496 100644 --- a/src/messaging/message-types.ts +++ b/src/messaging/message-types.ts @@ -1,4 +1,4 @@ -import type { CameraConfig } from '../camera/camera-config'; +import type { CameraConfig } from '../camera'; import type { MutationDto } from '../mutations/mutation'; /** @@ -13,8 +13,6 @@ export const MESSAGE_TYPES = { SNAPSHOT: 'snapshot', } as const; -export type MessageType = typeof MESSAGE_TYPES[keyof typeof MESSAGE_TYPES]; - /** * DTO for model node data received from server */ diff --git a/src/ui/virtualdisplay-viewer-service.ts b/src/ui/virtualdisplay-viewer-service.ts index d252fca..b6ae8e3 100644 --- a/src/ui/virtualdisplay-viewer-service.ts +++ b/src/ui/virtualdisplay-viewer-service.ts @@ -1,4 +1,4 @@ -import type { CameraConfig } from '../camera/camera-config'; +import type { CameraConfig } from '../camera'; import type { ClientOptions } from '../client/client-options'; import type { EventBus } from '../events/event-bus'; import { EVENT_NAMES } from '../events/event-names'; diff --git a/test/feature/attribute-onchange-3d-viewer-sync.test.ts b/test/feature/attribute-onchange-3d-viewer-sync.test.ts new file mode 100644 index 0000000..c5ba7f9 --- /dev/null +++ b/test/feature/attribute-onchange-3d-viewer-sync.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - 3D viewer synchronization', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + let changeLog: Array<{ attribute: string; value: string; selected: boolean }> = []; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + changeLog = []; + + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Material', + values: [ + { value: 'Wood', nodeIds: ['mat-wood'], isSelected: true }, + { value: 'Metal', nodeIds: ['mat-metal'] }, + { value: 'Glass', nodeIds: ['mat-glass'] }, + ], + }, + { + name: 'Finish', + values: [ + { value: 'Matte', nodeIds: ['finish-matte'], isSelected: true }, + { value: 'Glossy', nodeIds: ['finish-glossy'] }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Register onChange for all values + const materialAttr = attributeService.getAttribute('Material'); + materialAttr?.getAllValues().forEach(value => { + value.setOnChange(() => { + changeLog.push({ + attribute: 'Material', + value: value.value, + selected: value.isSelected, + }); + }); + }); + + const finishAttr = attributeService.getAttribute('Finish'); + finishAttr?.getAllValues().forEach(value => { + value.setOnChange(() => { + changeLog.push({ + attribute: 'Finish', + value: value.value, + selected: value.isSelected, + }); + }); + }); + }); + + it('should not trigger onChange for API selections - prevents infinite loops', () => { + // API selection should NOT trigger onChange to prevent UI feedback loops + attributeService.selectAttributeValue('Material', 'Metal'); + expect(changeLog).toHaveLength(0); + }); + + it('should trigger onChange for 3D viewer state changes - enables UI sync', () => { + // 3D viewer state change (user clicks in 3D viewer) + const nodes = createTestNodes([ + { id: 'mat-wood', name: 'Wood', isVisible: false }, + { id: 'mat-metal', name: 'Metal', isVisible: false }, + { id: 'mat-glass', name: 'Glass', isVisible: true }, + { id: 'finish-matte', name: 'Matte', isVisible: false }, + { id: 'finish-glossy', name: 'Glossy', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Should trigger onChange for all affected values + expect(changeLog).toHaveLength(5); // All 5 values changed state + + // Verify specific changes + const woodChange = changeLog.find(c => c.attribute === 'Material' && c.value === 'Wood'); + expect(woodChange?.selected).toBe(false); + + const glassChange = changeLog.find(c => c.attribute === 'Material' && c.value === 'Glass'); + expect(glassChange?.selected).toBe(true); + + const glossyChange = changeLog.find(c => c.attribute === 'Finish' && c.value === 'Glossy'); + expect(glossyChange?.selected).toBe(true); + }); +}); diff --git a/test/feature/attribute-onchange-edge-cases.test.ts b/test/feature/attribute-onchange-edge-cases.test.ts new file mode 100644 index 0000000..5f4906e --- /dev/null +++ b/test/feature/attribute-onchange-edge-cases.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - Edge cases', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should trigger onChange even when external state matches current', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Status', + values: [ + { value: 'On', nodeIds: ['status-on'], isSelected: true }, + { value: 'Off', nodeIds: ['status-off'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + const onChange = vi.fn(); + const statusAttr = attributeService.getAttribute('Status'); + statusAttr?.getValue('On')?.setOnChange(onChange); + statusAttr?.getValue('Off')?.setOnChange(onChange); + + // External state same as current + const nodes = createTestNodes([ + { id: 'status-on', name: 'On', isVisible: true }, + { id: 'status-off', name: 'Off', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Should still trigger because syncStateWithAttributes always calls setSelected + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('should handle onChange added after initialization', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Mode', + values: [ + { value: 'Auto', nodeIds: ['mode-auto'], isSelected: true }, + { value: 'Manual', nodeIds: ['mode-manual'] }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // First state change without onChange + const nodes1 = createTestNodes([ + { id: 'mode-auto', name: 'Auto', isVisible: false }, + { id: 'mode-manual', name: 'Manual', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes: nodes1 }); + + // Add onChange after state change + const onChange = vi.fn(); + const modeAttr = attributeService.getAttribute('Mode'); + modeAttr?.getValue('Manual')?.setOnChange(onChange); + + // Second state change + const nodes2 = createTestNodes([ + { id: 'mode-auto', name: 'Auto', isVisible: true }, + { id: 'mode-manual', name: 'Manual', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes: nodes2 }); + + // Should trigger for the second change + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/feature/attribute-onchange-ui-sync.test.ts b/test/feature/attribute-onchange-ui-sync.test.ts new file mode 100644 index 0000000..6010ec9 --- /dev/null +++ b/test/feature/attribute-onchange-ui-sync.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { EVENT_NAMES, type MappingConfiguration } from '../../src'; +import { AttributeService } from '../../src/attributes/attribute-service'; +import { EventBus } from '../../src/events/event-bus'; +import { createTestNodes } from '../helpers/node-test-helpers'; + +describe('Feature: onChange - UI sync', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should support live UI updates via onChange', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Color', + values: [ + { value: 'Red', nodeIds: ['color-red'], isSelected: false }, + { value: 'Blue', nodeIds: ['color-blue'], isSelected: true }, + { value: 'Green', nodeIds: ['color-green'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // UI state tracker + const uiState = { + buttons: { + Red: { highlighted: false }, + Blue: { highlighted: true }, + Green: { highlighted: false }, + }, + }; + + // Setup onChange to update UI state + const colorAttr = attributeService.getAttribute('Color'); + + colorAttr?.getValue('Red')?.setOnChange(() => { + uiState.buttons.Red.highlighted = colorAttr.getValue('Red')?.isSelected ?? false; + }); + + colorAttr?.getValue('Blue')?.setOnChange(() => { + uiState.buttons.Blue.highlighted = colorAttr.getValue('Blue')?.isSelected ?? false; + }); + + colorAttr?.getValue('Green')?.setOnChange(() => { + uiState.buttons.Green.highlighted = colorAttr.getValue('Green')?.isSelected ?? false; + }); + + // External state change - user clicks Green in 3D viewer + const nodes = createTestNodes([ + { id: 'color-red', name: 'Red', isVisible: false }, + { id: 'color-blue', name: 'Blue', isVisible: false }, + { id: 'color-green', name: 'Green', isVisible: true }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // UI state should update automatically + expect(uiState.buttons.Red.highlighted).toBe(false); + expect(uiState.buttons.Blue.highlighted).toBe(false); + expect(uiState.buttons.Green.highlighted).toBe(true); + }); +}); diff --git a/test/helpers/create-element-helper.ts b/test/helpers/create-element-helper.ts index ce38c71..d6c7a65 100644 --- a/test/helpers/create-element-helper.ts +++ b/test/helpers/create-element-helper.ts @@ -6,16 +6,13 @@ export function createElementForTest( tagName: K, ): HTMLElementTagNameMap[K] { - // Cache document reference - const doc = document; // Call createElement on cached reference - return doc.createElement(tagName); + return document.createElement(tagName); } /** * Get a bound createElement function for mocking */ export function getBoundCreateElement(): typeof document.createElement { - const doc = document; - return doc.createElement.bind(doc); + return document.createElement.bind(document); } diff --git a/test/helpers/dom-helpers.ts b/test/helpers/dom-helpers.ts index 5048ed5..98051fd 100644 --- a/test/helpers/dom-helpers.ts +++ b/test/helpers/dom-helpers.ts @@ -9,6 +9,5 @@ export function createTestElement( tagName: K, ): HTMLElementTagNameMap[K] { // Create element using DOM API - const doc = document; - return doc.createElement(tagName); + return document.createElement(tagName); } diff --git a/test/helpers/node-test-helpers.ts b/test/helpers/node-test-helpers.ts new file mode 100644 index 0000000..49ec0a1 --- /dev/null +++ b/test/helpers/node-test-helpers.ts @@ -0,0 +1,27 @@ +import { ModelNode, NODE_TYPES } from '../../src'; + +/** + * Helper to create ModelNode instances for tests + */ +export function createTestNode(params: { + id: string; + type?: 'mesh' | 'variant'; + name?: string; + isVisible: boolean; +}): ModelNode { + return new ModelNode({ + id: params.id, + name: params.name ?? params.id, + type: params.type ?? NODE_TYPES.MESH, + visible: params.isVisible, + }); +} + +/** + * Helper to create multiple test nodes at once + */ +export function createTestNodes( + nodes: Array<{ id: string; type?: 'mesh' | 'variant'; name?: string; isVisible: boolean }>, +): ModelNode[] { + return nodes.map(node => createTestNode(node)); +} diff --git a/test/integration/camera-config-messaging.test.ts b/test/integration/camera-config-messaging.test.ts index b0b335d..d9ff2df 100644 --- a/test/integration/camera-config-messaging.test.ts +++ b/test/integration/camera-config-messaging.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi, } from 'vitest'; -import { VirtualdisplayClient } from '../../src/index'; -import { MESSAGE_TYPES, type ConfigMessage } from '../../src/messaging/message-types'; +import { MESSAGE_TYPES, type ConfigMessage, VirtualdisplayClient } from '../../src'; // Test state let client = null as VirtualdisplayClient | null; diff --git a/test/unit/attributes/attribute-dynamic-context.test.ts b/test/unit/attributes/attribute-dynamic-context.test.ts new file mode 100644 index 0000000..00215bb --- /dev/null +++ b/test/unit/attributes/attribute-dynamic-context.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { AttributeService } from '../../../src/attributes/attribute-service'; +import type { MappingConfiguration, MappingContext } from '../../../src/attributes/mapping-types'; +import { EventBus } from '../../../src/events/event-bus'; + +describe('AttributeService - Dynamic mapping context', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should have empty context during initial evaluation - preventing attribute dependencies', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Color', + values: [ + { value: 'Red', nodeIds: ['color-red'], isSelected: true }, + { value: 'Blue', nodeIds: ['color-blue'] }, + ], + }, + { + name: 'Size', + values: (context: MappingContext) => { + // This documents an important limitation: + // You cannot read other attributes during initial mapping load + const color = context.getValue('Color'); + expect(color).toBeUndefined(); + + // Real world scenario: conditional sizing based on color would fail + // This forces developers to handle this case properly + return [ + { value: 'Default', nodeIds: ['size-default'], isSelected: true }, + ]; + }, + }, + ], + }; + + attributeService.loadMapping(mapping); + + const sizeAttr = attributeService.getAttribute('Size'); + expect(sizeAttr?.getCurrentValue()?.value).toBe('Default'); + }); + + it('should re-evaluate dynamic mappings when selections change - enables dependencies', () => { + let evaluationCount = 0; + + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'DynamicOptions', + values: (): Array<{ value: string; nodeIds: string[]; isSelected?: boolean }> => { + evaluationCount++; + // Dynamic mappings re-evaluate to handle dependencies + return [ + { value: 'Option1', nodeIds: ['opt-1'], isSelected: true }, + { value: 'Option2', nodeIds: ['opt-2'] }, + ]; + }, + }, + ], + }; + + attributeService.loadMapping(mapping); + expect(evaluationCount).toBe(1); + + // Each selection triggers re-evaluation for dynamic mappings + attributeService.selectAttributeValue('DynamicOptions', 'Option2'); + expect(evaluationCount).toBe(2); + + attributeService.selectAttributeValue('DynamicOptions', 'Option1'); + expect(evaluationCount).toBe(3); + + attributeService.selectAttributeValue('DynamicOptions', 'Option2'); + expect(evaluationCount).toBe(4); + }); +}); diff --git a/test/unit/attributes/attribute-state-basic-sync.test.ts b/test/unit/attributes/attribute-state-basic-sync.test.ts new file mode 100644 index 0000000..9ae88fc --- /dev/null +++ b/test/unit/attributes/attribute-state-basic-sync.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../../src'; +import { AttributeService } from '../../../src/attributes/attribute-service'; +import { EventBus } from '../../../src/events/event-bus'; +import { createTestNodes } from '../../helpers/node-test-helpers'; + +describe('AttributeService - Basic state sync', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should sync attribute values with node visibility', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Material', + values: [ + { value: 'Wood', nodeIds: ['mat-wood-1', 'mat-wood-2'], isSelected: true }, + { value: 'Metal', nodeIds: ['mat-metal'], isSelected: false }, + { value: 'Plastic', nodeIds: ['mat-plastic'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Initial state + const materialAttr = attributeService.getAttribute('Material'); + expect(materialAttr?.getCurrentValue()?.value).toBe('Wood'); + + // Simulate external state change from 3D viewer + const nodes = createTestNodes([ + { id: 'mat-wood-1', name: 'Wood1', isVisible: false }, + { id: 'mat-wood-2', name: 'Wood2', isVisible: false }, + { id: 'mat-metal', name: 'Metal', isVisible: true }, + { id: 'mat-plastic', name: 'Plastic', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + // Check that values are synced + const woodValue = materialAttr?.getValue('Wood'); + const metalValue = materialAttr?.getValue('Metal'); + const plasticValue = materialAttr?.getValue('Plastic'); + + expect(woodValue?.isSelected).toBe(false); + expect(metalValue?.isSelected).toBe(true); + expect(plasticValue?.isSelected).toBe(false); + }); + + it('should handle partial node visibility correctly', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Component', + values: [ + { value: 'Full', nodeIds: ['comp-1', 'comp-2', 'comp-3'], isSelected: true }, + { value: 'Partial', nodeIds: ['comp-4', 'comp-5'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Only some nodes of "Full" are visible + const nodes = createTestNodes([ + { id: 'comp-1', name: 'C1', isVisible: true }, + { id: 'comp-2', name: 'C2', isVisible: false }, + { id: 'comp-3', name: 'C3', isVisible: true }, + { id: 'comp-4', name: 'C4', isVisible: false }, + { id: 'comp-5', name: 'C5', isVisible: false }, + ]); + + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + + const componentAttr = attributeService.getAttribute('Component'); + const fullValue = componentAttr?.getValue('Full'); + const partialValue = componentAttr?.getValue('Partial'); + + // Full should not be selected because not all nodes are visible + expect(fullValue?.isSelected).toBe(false); + expect(partialValue?.isSelected).toBe(false); + }); +}); diff --git a/test/unit/attributes/attribute-state-edge-cases.test.ts b/test/unit/attributes/attribute-state-edge-cases.test.ts new file mode 100644 index 0000000..edfbf75 --- /dev/null +++ b/test/unit/attributes/attribute-state-edge-cases.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { type MappingConfiguration, EVENT_NAMES } from '../../../src'; +import { AttributeService } from '../../../src/attributes/attribute-service'; +import { EventBus } from '../../../src/events/event-bus'; +import { createTestNodes } from '../../helpers/node-test-helpers'; + +describe('AttributeService - State sync edge cases', () => { + let attributeService: AttributeService = null as unknown as AttributeService; + let eventBus: EventBus = null as unknown as EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + attributeService = new AttributeService(eventBus); + }); + + it('should gracefully handle nodes not in mapping - prevents crashes with dynamic 3D models', () => { + const mapping: MappingConfiguration = { + attributes: [ + { + name: 'Known', + values: [ + { value: 'A', nodeIds: ['known-a'], isSelected: true }, + { value: 'B', nodeIds: ['known-b'], isSelected: false }, + ], + }, + ], + }; + + attributeService.loadMapping(mapping); + + // Real scenario: 3D model has extra nodes not in the mapping + // This happens when models are updated but mapping isn't + const nodes = createTestNodes([ + { id: 'known-a', name: 'A', isVisible: false }, + { id: 'known-b', name: 'B', isVisible: true }, + { id: 'unknown-1', name: 'Unknown1', isVisible: true }, + { id: 'unknown-2', name: 'Unknown2', isVisible: false }, + { id: 'new-feature-node', name: 'NewFeature', isVisible: true }, + ]); + + // Should not throw even with unknown nodes + expect(() => { + eventBus.emit(EVENT_NAMES.STATE_CHANGED, { nodes }); + }).not.toThrow(); + + // Known attributes should still sync correctly + const knownAttr = attributeService.getAttribute('Known'); + expect(knownAttr?.getValue('A')?.isSelected).toBe(false); + expect(knownAttr?.getValue('B')?.isSelected).toBe(true); + }); +}); diff --git a/test/unit/attributes/attribute.test.ts b/test/unit/attributes/attribute.test.ts index afc7ac9..7145de8 100644 --- a/test/unit/attributes/attribute.test.ts +++ b/test/unit/attributes/attribute.test.ts @@ -20,51 +20,6 @@ describe('Attribute - Creation', () => { }); }); -describe('Attribute - Selection', () => { - it('should select value and return mutations', () => { - const attribute = new Attribute('Material'); - const leather = new AttributeValue('Leather', ['mat-leather'], true); - const fabric = new AttributeValue('Fabric', ['mat-fabric']); - - attribute.addValue(leather); - attribute.addValue(fabric); - - const mutations = attribute.select('Fabric'); - - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat-leather' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric' }, - ]); - }); - - it('should handle multiple nodes per value', () => { - const attribute = new Attribute('Material'); - const leather = new AttributeValue('Leather', ['mat-leather'], true); - const fabric = new AttributeValue('Fabric', ['mat-fabric-1', 'mat-fabric-2']); - - attribute.addValue(leather); - attribute.addValue(fabric); - - const mutations = attribute.select('Fabric'); - - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat-leather' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric-1' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat-fabric-2' }, - ]); - }); - - it('should return empty array for invalid selection', () => { - const attribute = new Attribute('Color'); - const red = new AttributeValue('Red', ['red-node']); - attribute.addValue(red); - - const mutations = attribute.select('NonExistent'); - - expect(mutations).toEqual([]); - }); -}); - describe('Attribute - Default Handling', () => { it('should get default mutations', () => { const attribute = new Attribute('Color'); diff --git a/test/unit/attributes/mutation-inverse-logic.test.ts b/test/unit/attributes/mutation-inverse-logic.test.ts deleted file mode 100644 index 539b6fb..0000000 --- a/test/unit/attributes/mutation-inverse-logic.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { MUTATION_TYPES } from '../../../src'; -import { Attribute } from '../../../src/attributes/attribute'; -import { AttributeValue } from '../../../src/attributes/attribute-value'; - -describe('Mutation Inverse Logic', () => { - it('should correctly handle attribute selection with inverse', () => { - // Setup attribute with two values - const attribute = new Attribute('Color'); - const redValue = new AttributeValue('Red', ['mat_red'], false); // Not selected (hidden) - const blueValue = new AttributeValue('Blue', ['mat_blue'], false); // Not selected (hidden) - - attribute.addValue(redValue); - attribute.addValue(blueValue); - - // Select red (from nothing selected to red) - const mutations = attribute.select('Red'); - - // We expect: - // - Red nodes to be shown (red is hidden, getMutations returns hide, inverse shows) - // - Blue nodes to be hidden (blue is hidden, getMutations returns hide, inverse shows) - // Wait, this doesn't make sense... - - // Let me trace through the actual logic: - // 1. redValue.selected = false - // 2. redValue.getMutations() returns hide mutations (because selected = false) - // 3. In determineMutationsForSelection, newValue.getMutations().inverse() converts hide to show - // 4. No currentValue, so we just return the show mutations for red - - expect(mutations).toHaveLength(1); - expect(mutations[0]?.type).toBe(MUTATION_TYPES.SHOW); - expect(mutations[0]?.nodeId).toBe('mat_red'); - }); - - it('should correctly switch between values', () => { - // Setup attribute with two values - const attribute = new Attribute('Color'); - const redValue = new AttributeValue('Red', ['mat_red'], true); // Selected (visible) - const blueValue = new AttributeValue('Blue', ['mat_blue'], false); // Not selected (hidden) - - attribute.addValue(redValue); - attribute.addValue(blueValue); - - // Now red is selected, let's trace what happens - // redValue.selected = true, so getMutations() returns show mutations - // blueValue.selected = false, so getMutations() returns hide mutations - - // Select blue (switch from red to blue) - const mutations = attribute.select('Blue'); - - // Expected flow: - // 1. currentValue = redValue (selected = true) - // 2. currentValue.getMutations() returns show mutations - // 3. inverse() converts show to hide mutations for red - // 4. newValue = blueValue (selected = false) - // 5. newValue.getMutations() returns hide mutations - // 6. inverse() converts hide to show mutations for blue - - // So we expect: hide red, show blue - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'mat_red' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'mat_blue' }, - ]); - }); - - it('should handle multiple nodes per value', () => { - const attribute = new Attribute('Size'); - const largeValue = new AttributeValue('Large', ['scale_x_large', 'scale_y_large'], true); // Selected - const smallValue = new AttributeValue('Small', ['scale_x_small', 'scale_y_small'], false); // Not selected - - attribute.addValue(largeValue); - attribute.addValue(smallValue); - - // Switch from large to small - const mutations = attribute.select('Small'); - - // Expected: hide 2 large nodes, show 2 small nodes - expect(mutations).toEqual([ - { type: MUTATION_TYPES.HIDE, nodeId: 'scale_x_large' }, - { type: MUTATION_TYPES.HIDE, nodeId: 'scale_y_large' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'scale_x_small' }, - { type: MUTATION_TYPES.SHOW, nodeId: 'scale_y_small' }, - ]); - }); -}); diff --git a/test/unit/camera/camera-reset.test.ts b/test/unit/camera/camera-reset.test.ts index a80e07b..2eef353 100644 --- a/test/unit/camera/camera-reset.test.ts +++ b/test/unit/camera/camera-reset.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Camera } from '../../../src/camera'; +import { Camera, EVENT_NAMES, MESSAGE_TYPES } from '../../../src'; import type { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; describe('Camera - reset', () => { let camera: Camera = null as unknown as Camera; diff --git a/test/unit/camera/camera.test.ts b/test/unit/camera/camera.test.ts index 22cd64b..77a9062 100644 --- a/test/unit/camera/camera.test.ts +++ b/test/unit/camera/camera.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Camera } from '../../../src/camera'; +import { Camera, EVENT_NAMES, MESSAGE_TYPES } from '../../../src'; import type { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; describe('Camera', () => { let camera: Camera = null as unknown as Camera; diff --git a/test/unit/snapshot/photo.test.ts b/test/unit/snapshot/photo.test.ts index fe755d4..a0d39a5 100644 --- a/test/unit/snapshot/photo.test.ts +++ b/test/unit/snapshot/photo.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { Photo, type PhotoData } from '../../../src/snapshot/photo'; +import { Photo, type PhotoData } from '../../../src'; describe('Photo - basic functionality', () => { describe('constructor', () => { diff --git a/test/unit/snapshot/snapshot-message-handler.test.ts b/test/unit/snapshot/snapshot-message-handler.test.ts index 9c23ede..44a6081 100644 --- a/test/unit/snapshot/snapshot-message-handler.test.ts +++ b/test/unit/snapshot/snapshot-message-handler.test.ts @@ -1,10 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MESSAGE_TYPES, EVENT_NAMES, Photo } from '../../../src'; import { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; -import { Photo } from '../../../src/snapshot/photo'; -import { SnapshotMessageHandler } from '../../../src/snapshot/snapshot-message-handler'; +import { SnapshotMessageHandler } from '../../../src/snapshot'; describe('SnapshotMessageHandler', () => { let eventBus = new EventBus(); diff --git a/test/unit/snapshot/snapshot.test.ts b/test/unit/snapshot/snapshot.test.ts index 2d27cc1..c336b2c 100644 --- a/test/unit/snapshot/snapshot.test.ts +++ b/test/unit/snapshot/snapshot.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { VirtualdisplayError, ERROR_CODES } from '../../../src/client/virtualdisplay-error'; +import { + Photo, Snapshot, MESSAGE_TYPES, EVENT_NAMES, VirtualdisplayError, ERROR_CODES, +} from '../../../src'; import { EventBus } from '../../../src/events/event-bus'; -import { EVENT_NAMES } from '../../../src/events/event-names'; -import { MESSAGE_TYPES } from '../../../src/messaging/message-types'; -import { Photo } from '../../../src/snapshot/photo'; -import { Snapshot } from '../../../src/snapshot/snapshot'; describe('Snapshot - basic tests', () => { let eventBus = new EventBus();