Compare commits

...

6 Commits

Author SHA1 Message Date
d7d6d650bc v1.3.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 12:16:48 +00:00
53c5d839ca feat(recording-panel): Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation 2025-12-11 12:16:48 +00:00
6cbfd714eb update 2025-12-11 12:06:18 +00:00
7c8c194fd8 update 2025-12-11 11:45:02 +00:00
278000bb36 update 2025-12-11 11:23:02 +00:00
52ffe81352 update 2025-12-11 11:14:37 +00:00
23 changed files with 3955 additions and 2094 deletions

View File

@@ -1,5 +1,14 @@
# Changelog
## 2025-12-11 - 1.3.0 - feat(recording-panel)
Add demo wrapper utilities, improve recording trim behavior, and harden property panel element detection; update documentation
- Add dees-demowrapper (ts_demotools) with runAfterRender callback to run post-render demo logic (supports async callbacks).
- Improve recording UI and trimming: handle WebM files with Infinity/NaN durations by falling back to tracked recording duration; replace numeric handle positioning with CSS calc strings for responsive trim handles.
- Harden property extraction: implement recursive element search (including shadowRoots), add an initial delay and retry loop to wait for demo rendering, and add an advanced JSON editor for Object/Array properties with open/save/cancel and per-editor error reporting.
- Add and expand documentation: new ts_web/ and ts_demotools/ READMEs, reorganized main README with clearer feature list, usage examples, and API reference.
- Minor exports and module/docs housekeeping (index exports, readme reorder, examples updated to import classes).
## 2025-11-16 - 1.2.1 - fix(dependencies)
Bump dependencies and developer tooling versions

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-wcctools",
"version": "1.2.1",
"version": "1.3.0",
"private": false,
"description": "A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.",
"exports": {
@@ -23,14 +23,14 @@
"lit": "^3.3.1"
},
"devDependencies": {
"@api.global/typedserver": "^3.0.79",
"@git.zone/tsbuild": "^2.7.1",
"@git.zone/tsbundle": "^2.5.1",
"@git.zone/tsrun": "^1.6.2",
"@git.zone/tstest": "^2.7.0",
"@git.zone/tswatch": "^2.2.1",
"@api.global/typedserver": "^7.11.1",
"@git.zone/tsbuild": "^3.1.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.0",
"@git.zone/tstest": "^3.1.3",
"@git.zone/tswatch": "^2.3.10",
"@push.rocks/projectinfo": "^5.0.2",
"@types/node": "^22.18.6"
"@types/node": "^25.0.0"
},
"files": [
"ts/**/*",

3824
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
onlyBuiltDependencies:
- esbuild
- mongodb-memory-server
- puppeteer

274
readme.md
View File

@@ -1,23 +1,31 @@
# @design.estate/dees-wcctools
Web Component Development Tools - A powerful framework for building, testing, and documenting web components
🛠️ **Web Component Development Tools** — A powerful framework for building, testing, documenting, and recording web components
## Overview
`@design.estate/dees-wcctools` provides a comprehensive development environment for web components, featuring:
- 🎨 Interactive component catalogue with live preview
- 🔧 Real-time property editing
- 🌓 Theme switching (light/dark modes)
- 📱 Responsive viewport testing
- 🧪 Advanced demo tools for component testing
- 🚀 Zero-config setup with TypeScript and Lit support
- 🎨 **Interactive Component Catalogue** — Live preview with sidebar navigation
- 🔧 **Real-time Property Editing** — Modify component props on the fly with auto-detected editors
- 🌓 **Theme Switching** — Test light/dark modes instantly
- 📱 **Responsive Viewport Testing** — Phone, phablet, tablet, and desktop views
- 🎬 **Screen Recording** — Record component demos with audio support and video trimming
- 🧪 **Advanced Demo Tools** — Post-render hooks for interactive testing
- 🚀 **Zero-config Setup** — TypeScript and Lit support out of the box
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Installation
```bash
# Using npm
npm install @design.estate/dees-wcctools --save-dev
# Using pnpm (recommended)
pnpm add -D @design.estate/dees-wcctools
# Using npm
npm install @design.estate/dees-wcctools --save-dev
```
## Quick Start
@@ -35,10 +43,10 @@ export class MyButton extends DeesElement {
`;
@property({ type: String })
public label: string = 'Button';
accessor label: string = 'Button';
@property({ type: String })
public variant: 'primary' | 'secondary' = 'primary';
accessor variant: 'primary' | 'secondary' = 'primary';
public static styles = [
css`
@@ -85,8 +93,8 @@ import { setupWccTools } from '@design.estate/dees-wcctools';
import { html } from 'lit';
// Import your components
import './components/my-button.js';
import './components/my-card.js';
import { MyButton } from './components/my-button.js';
import { MyCard } from './components/my-card.js';
// Define elements for the catalogue
const elements = {
@@ -132,21 +140,28 @@ setupWccTools(elements, pages);
## Features
### 🎯 Live Property Editing
The properties panel automatically detects and allows editing of:
- **String** properties with text inputs
- **Number** properties with number inputs
- **Boolean** properties with checkboxes
- **Enum** properties with select dropdowns
- **Object** and **Array** properties (read-only display)
| Property Type | Editor |
|--------------|--------|
| **String** | Text input |
| **Number** | Number input |
| **Boolean** | Checkbox |
| **Enum** | Select dropdown |
| **Object/Array** | JSON editor modal |
### 📱 Viewport Testing
Test your components across different screen sizes:
- **Phone** (320px width)
- **Phablet** (600px width)
- **Tablet** (768px width)
- **Desktop** (full width)
- **Phone** — 320px width
- **Phablet** — 600px width
- **Tablet** — 768px width
- **Desktop** — Full width (native)
### 🌓 Theme Support
Components automatically adapt to light/dark themes using the `goBright` property:
```typescript
@@ -159,7 +174,8 @@ public render() {
}
```
Or use CSS custom properties:
Or use CSS custom properties with the theme manager:
```typescript
import { cssManager } from '@design.estate/dees-element';
@@ -173,39 +189,44 @@ public static styles = [
];
```
### 🧪 Advanced Demo Tools
### 🎬 Screen Recording
The demo tools provide enhanced testing capabilities:
Record component demos directly from the catalogue! The built-in recorder supports:
- **Viewport Recording** — Record just the component viewport
- **Full Screen Recording** — Capture the entire screen
- **Audio Support** — Add microphone commentary with live level monitoring
- **Video Trimming** — Trim start/end before export with visual timeline
- **WebM Export** — High-quality video output
Click the red record button in the bottom toolbar to start.
### 🧪 Demo Tools
The demotools module provides enhanced testing capabilities with `dees-demowrapper`:
```typescript
import * as demoTools from '@design.estate/dees-wcctools/demotools';
import '@design.estate/dees-wcctools/demotools';
@customElement('my-component')
export class MyComponent extends DeesElement {
public static demo = () => html`
<dees-demowrapper .runAfterRender=${async (wrapper) => {
// Use querySelector to find specific elements
const myComponent = wrapper.querySelector('my-component') as MyComponent;
console.log('Component found:', myComponent);
// Access all children via wrapper.children
console.log('Total children:', wrapper.children.length);
// Use querySelectorAll for multiple elements
const allDivs = wrapper.querySelectorAll('div');
console.log('Found divs:', allDivs.length);
// Find elements using standard DOM APIs
const myComponent = wrapper.querySelector('my-component');
// Simulate user interactions
myComponent.value = 'Test value';
await myComponent.updateComplete;
// Work with all children
Array.from(wrapper.children).forEach((child, index) => {
console.log(`Child ${index}:`, child.tagName);
// Work with multiple elements
wrapper.querySelectorAll('.item').forEach((el, i) => {
console.log(`Item ${i}:`, el.textContent);
});
}}>
<my-component></my-component>
<div>Additional content</div>
<div class="item">Item 1</div>
<div class="item">Item 2</div>
</dees-demowrapper>
`;
}
@@ -213,20 +234,18 @@ export class MyComponent extends DeesElement {
### ⏳ Async Demos
If your catalogue needs additional setup before rendering, return a `Promise` from the `demo` function. The dashboard waits for the result before inserting it into the viewport:
Return a `Promise` from `demo` for async setup. The dashboard waits for resolution:
```typescript
public static demo = async () => {
await Promise.resolve(); // e.g. fetch data, load fixtures, or await wrappers
return html`<my-component .value=${'Loaded asynchronously'}></my-component>`;
const data = await fetchSomeData();
return html`<my-component .data=${data}></my-component>`;
};
```
The same pattern works for page factories you pass into `setupWccTools`, enabling asynchronous data preparation across the entire demo surface.
### 🎭 Container Queries
### 🎭 Container Queries Support
Components can respond to their container size:
Components can respond to their container size using the `wccToolsViewport` container:
```typescript
public static styles = [
@@ -236,7 +255,7 @@ public static styles = [
flex-direction: row;
}
}
@container wccToolsViewport (max-width: 767px) {
:host {
flex-direction: column;
@@ -249,61 +268,94 @@ public static styles = [
## Component Guidelines
### Required for Catalogue Display
1. Components must expose a static `demo` property returning a Lit template
2. Use `@property()` decorators for properties you want to be editable
2. Use `@property()` decorators with the `accessor` keyword for editable properties
3. Export component classes for proper detection
### Best Practices
```typescript
@customElement('best-practice-component')
export class BestPracticeComponent extends DeesElement {
// ✅ Static demo property
public static demo = () => html`
<best-practice-component
<best-practice-component
.complexProp=${{ key: 'value' }}
simpleAttribute="test"
></best-practice-component>
`;
// ✅ Typed properties with defaults
// ✅ Typed properties with defaults (TC39 decorators)
@property({ type: String })
public title: string = 'Default Title';
accessor title: string = 'Default Title';
// ✅ Complex property without attribute
@property({ attribute: false })
public complexProp: { key: string } = { key: 'default' };
accessor complexProp: { key: string } = { key: 'default' };
// ✅ Enum with proper typing
@property({ type: String })
public variant: 'small' | 'medium' | 'large' = 'medium';
accessor variant: 'small' | 'medium' | 'large' = 'medium';
}
```
## URL Routing
The catalogue uses URL routing for deep linking:
```
/wcctools-route/:type/:name/:viewport/:theme
Example:
Examples:
/wcctools-route/element/my-button/desktop/dark
/wcctools-route/page/home/tablet/bright
```
## Development Workflow
## API Reference
### Build and Watch
```json
{
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element",
"watch": "tswatch element",
"serve": "http-server ./dist"
}
}
### `setupWccTools(elements, pages?)`
Initialize the WCC Tools dashboard.
| Parameter | Type | Description |
|-----------|------|-------------|
| `elements` | `Record<string, typeof LitElement>` | Map of element names to classes |
| `pages` | `Record<string, TTemplateFactory>` | Optional map of page names to template functions |
### `DeesDemoWrapper`
Component for wrapping demos with post-render logic.
| Property | Type | Description |
|----------|------|-------------|
| `runAfterRender` | `(wrapper) => void \| Promise<void>` | Callback after wrapped elements render |
The wrapper provides full DOM API access:
- `wrapper.querySelector()` — Find single element
- `wrapper.querySelectorAll()` — Find multiple elements
- `wrapper.children` — Access child elements directly
### Recording Components (Advanced)
For custom recording integrations:
```typescript
import { RecorderService } from '@design.estate/dees-wcctools';
const recorder = new RecorderService({
onDurationUpdate: (duration) => console.log(`${duration}s`),
onRecordingComplete: (blob) => console.log('Recording done!', blob),
onAudioLevelUpdate: (level) => console.log(`Audio: ${level}%`),
});
await recorder.startRecording({ mode: 'viewport' });
// ... later
recorder.stopRecording();
```
### Project Structure
## Project Structure
```
my-components/
├── src/
@@ -316,90 +368,30 @@ my-components/
└── package.json
```
## Advanced Features
### Custom Property Handlers
For complex property types, implement custom logic in your demo:
```typescript
public static demo = () => html`
<dees-demowrapper .runAfterRender=${(wrapper) => {
// Use querySelector to target specific elements
const component = wrapper.querySelector('my-component');
if (component) {
component.addEventListener('property-change', (e) => {
console.log('Property changed:', e.detail);
});
}
// Or handle all elements of a type
wrapper.querySelectorAll('my-component').forEach(el => {
el.addEventListener('click', () => console.log('Clicked!'));
});
}}>
<my-component></my-component>
</dees-demowrapper>
`;
```
### Responsive Testing Helpers
```typescript
import * as domtools from '@design.estate/dees-domtools';
public static styles = [
// Media query helpers
domtools.breakpoints.cssForPhone(css`
:host { font-size: 14px; }
`),
domtools.breakpoints.cssForTablet(css`
:host { font-size: 16px; }
`),
domtools.breakpoints.cssForDesktop(css`
:host { font-size: 18px; }
`)
];
```
## API Reference
### setupWccTools(elements, pages?)
Initialize the WCC Tools dashboard.
- `elements`: Object mapping element names to element classes
- `pages`: Optional object mapping page names to template functions
### DeesDemoWrapper
Component for wrapping demos with post-render logic.
- `runAfterRender`: Function called after the wrapped elements render
- Receives the wrapper element itself, providing full DOM API access
- Use `wrapper.querySelector()` and `wrapper.querySelectorAll()` for element selection
- Access children via `wrapper.children` property
- Supports async operations
## Browser Support
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Mobile browsers with Web Components support
- ✅ Chrome/Edge (latest)
- ✅ Firefox (latest)
- ✅ Safari (latest)
- ✅ Mobile browsers with Web Components support
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -36,13 +36,13 @@ export class TestComplexTypes extends DeesElement {
`;
@property({ type: Array })
public stringArray: string[] = ['apple', 'banana', 'cherry'];
accessor stringArray: string[] = ['apple', 'banana', 'cherry'];
@property({ type: Array })
public numberArray: number[] = [1, 2, 3, 4, 5];
accessor numberArray: number[] = [1, 2, 3, 4, 5];
@property({ attribute: false })
public complexData: IComplexData = {
accessor complexData: IComplexData = {
name: 'Default Name',
age: 0,
tags: [],
@@ -54,19 +54,19 @@ export class TestComplexTypes extends DeesElement {
};
@property({ type: Object })
public simpleObject = {
accessor simpleObject = {
key1: 'value1',
key2: 'value2',
key3: 123
};
@property({ attribute: false })
public functionProperty = () => {
accessor functionProperty = () => {
console.log('This is a function property');
};
@property({ type: Date })
public dateProperty = new Date();
accessor dateProperty = new Date();
public static styles = [
css`

View File

@@ -21,35 +21,35 @@ export class TestDemoelement extends DeesElement {
public static demo = () => html`<test-demoelement>This is a slot text</test-demoelement>`;
@property()
public notTyped = 'hello';
accessor notTyped = 'hello';
@property({
type: String,
})
public typedAndNotInitizalized: string;
accessor typedAndNotInitizalized: string;
@property()
public notTypedAndNotInitizalized: string;
accessor notTypedAndNotInitizalized: string;
@property({
type: Boolean,
})
public demoBoolean = false;
accessor demoBoolean = false;
@property({
type: String,
})
public demoString = 'default demo string';
accessor demoString = 'default demo string';
@property({
type: Number,
})
public demoNumber = 2;
accessor demoNumber = 2;
@property({
type: ETestEnum,
})
public demoENum: ETestEnum = ETestEnum.first;
accessor demoENum: ETestEnum = ETestEnum.first;
constructor() {
super();

View File

@@ -13,60 +13,60 @@ export class TestEdgeCases extends DeesElement {
// Property with null value
@property({ type: String })
public nullableString: string | null = null;
accessor nullableString: string | null = null;
// Property with undefined value
@property({ type: Number })
public undefinedNumber: number | undefined = undefined;
accessor undefinedNumber: number | undefined = undefined;
// Very long string
@property({ type: String })
public longString: string = 'Lorem ipsum '.repeat(50);
accessor longString: string = 'Lorem ipsum '.repeat(50);
// Property with special characters
@property({ type: String })
public specialChars: string = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~';
accessor specialChars: string = '!@#$%^&*()_+-=[]{}|;\':",./<>?`~';
// Property that could cause rendering issues
@property({ type: String })
public htmlString: string = '<script>alert("test")</script><b>Bold text</b>';
accessor htmlString: string = '<script>alert("test")</script><b>Bold text</b>';
// Numeric edge cases
@property({ type: Number })
public infinityNumber: number = Infinity;
accessor infinityNumber: number = Infinity;
@property({ type: Number })
public nanNumber: number = NaN;
accessor nanNumber: number = NaN;
@property({ type: Number })
public veryLargeNumber: number = Number.MAX_SAFE_INTEGER;
accessor veryLargeNumber: number = Number.MAX_SAFE_INTEGER;
@property({ type: Number })
public verySmallNumber: number = Number.MIN_SAFE_INTEGER;
accessor verySmallNumber: number = Number.MIN_SAFE_INTEGER;
@property({ type: Number })
public floatNumber: number = 3.14159265359;
accessor floatNumber: number = 3.14159265359;
// Boolean-like values
@property({ type: String })
public booleanString: string = 'false';
accessor booleanString: string = 'false';
@property({ type: Number })
public booleanNumber: number = 0;
accessor booleanNumber: number = 0;
// Empty values
@property({ type: String })
public emptyString: string = '';
accessor emptyString: string = '';
@property({ type: Array })
public emptyArray: any[] = [];
accessor emptyArray: any[] = [];
@property({ type: Object })
public emptyObject: {} = {};
accessor emptyObject: {} = {};
// Circular reference (should not break properties panel)
@property({ attribute: false })
public circularRef: any = (() => {
accessor circularRef: any = (() => {
const obj: any = { name: 'circular' };
obj.self = obj;
return obj;

View File

@@ -23,13 +23,13 @@ class TestNestedWrapper extends DeesElement {
@customElement('test-nested-target')
class TestNestedTarget extends DeesElement {
@property({ type: String })
public message: string = 'I am deeply nested!';
accessor message: string = 'I am deeply nested!';
@property({ type: Number })
public depth: number = 0;
accessor depth: number = 0;
@property({ type: Boolean })
public found: boolean = false;
accessor found: boolean = false;
public static styles = [
css`
@@ -67,7 +67,7 @@ export class TestNested extends DeesElement {
`;
@property({ type: String })
public testId: string = 'nested-test';
accessor testId: string = 'nested-test';
public static styles = [
css`

View File

@@ -39,13 +39,13 @@ export class TestWithWrapper extends DeesElement {
`;
@property({ type: String })
public dynamicValue: string = 'Initial value';
accessor dynamicValue: string = 'Initial value';
@property({ type: Number })
public counter: number = 0;
accessor counter: number = 0;
@property({ type: Boolean })
public isActive: boolean = false;
accessor isActive: boolean = false;
public static styles = [
css`

View File

@@ -3,7 +3,7 @@ import { DeesElement, customElement, html, css, property, type TemplateResult }
@customElement('dees-demowrapper')
export class DeesDemoWrapper extends DeesElement {
@property({ attribute: false })
public runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
accessor runAfterRender: (wrapperElement: DeesDemoWrapper) => void | Promise<void>;
public static styles = [
css`

147
ts_demotools/readme.md Normal file
View File

@@ -0,0 +1,147 @@
# @design.estate/dees-wcctools/demotools
🧪 **Demo Wrapper Utilities** — Enhanced testing tools for web component demos
## Overview
The demotools module provides `dees-demowrapper`, a utility component for executing post-render logic in component demos. Perfect for simulating user interactions, setting up test data, or validating component state.
## Installation
This module is included with `@design.estate/dees-wcctools`:
```bash
pnpm add -D @design.estate/dees-wcctools
```
## Usage
Import the demotools subpath:
```typescript
import '@design.estate/dees-wcctools/demotools';
```
## DeesDemoWrapper
A wrapper component that executes a callback after its slotted content renders.
### Properties
| Property | Type | Description |
|----------|------|-------------|
| `runAfterRender` | `(wrapper: DeesDemoWrapper) => void \| Promise<void>` | Callback executed after content renders |
### Example: Basic Usage
```typescript
import { html } from 'lit';
import '@design.estate/dees-wcctools/demotools';
public static demo = () => html`
<dees-demowrapper .runAfterRender=${(wrapper) => {
const button = wrapper.querySelector('my-button');
console.log('Button found:', button);
}}>
<my-button>Click Me</my-button>
</dees-demowrapper>
`;
```
### Example: Async Operations
```typescript
public static demo = () => html`
<dees-demowrapper .runAfterRender=${async (wrapper) => {
const form = wrapper.querySelector('my-form');
// Wait for component initialization
await form.updateComplete;
// Simulate user input
form.values = { name: 'Test User', email: 'test@example.com' };
// Trigger validation
await form.validate();
console.log('Form state:', form.isValid);
}}>
<my-form></my-form>
</dees-demowrapper>
`;
```
### Example: Multiple Elements
```typescript
public static demo = () => html`
<dees-demowrapper .runAfterRender=${(wrapper) => {
// Find all cards
const cards = wrapper.querySelectorAll('my-card');
console.log(`Found ${cards.length} cards`);
// Access by index
Array.from(wrapper.children).forEach((child, i) => {
console.log(`Child ${i}:`, child.tagName);
});
// Add event listeners
wrapper.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => console.log('Clicked!'));
});
}}>
<my-card title="Card 1"></my-card>
<my-card title="Card 2"></my-card>
<my-card title="Card 3"></my-card>
</dees-demowrapper>
`;
```
### Example: Component State Manipulation
```typescript
public static demo = () => html`
<dees-demowrapper .runAfterRender=${async (wrapper) => {
const tabs = wrapper.querySelector('my-tabs');
// Programmatically switch tabs
tabs.activeTab = 'settings';
await tabs.updateComplete;
// Verify content updated
const content = tabs.shadowRoot.querySelector('.tab-content');
console.log('Active content:', content.textContent);
}}>
<my-tabs>
<div slot="home">Home Content</div>
<div slot="settings">Settings Content</div>
</my-tabs>
</dees-demowrapper>
`;
```
## How It Works
1. The wrapper renders its slot content immediately
2. After a brief delay (50ms) to allow slotted content to initialize
3. The `runAfterRender` callback is invoked with the wrapper element
4. You have full DOM API access to query and manipulate children
## Key Features
- 📦 **Light DOM Access** — Slotted elements remain accessible via standard DOM APIs
- ⏱️ **Async Support** — Return a Promise for async operations
- 🎯 **Full DOM API** — Use `querySelector`, `querySelectorAll`, `children`, etc.
- 🛡️ **Error Handling** — Errors in callbacks are caught and logged
## CSS Behavior
The wrapper uses `display: contents` so it doesn't affect layout:
```css
:host {
display: contents;
}
```
This means the wrapper is "invisible" in the layout — its children render as if they were direct children of the wrapper's parent.

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-wcctools',
version: '1.2.1',
version: '1.3.0',
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
}

View File

@@ -17,38 +17,38 @@ import { WccFrame } from './wcc-frame.js';
export class WccDashboard extends DeesElement {
@property()
public selectedType: TElementType;
accessor selectedType: TElementType;
@property()
public selectedItemName: string;
accessor selectedItemName: string;
@property()
public selectedItem: TTemplateFactory | DeesElement;
accessor selectedItem: TTemplateFactory | DeesElement;
@property()
public selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
accessor selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
@property()
public selectedTheme: TTheme = 'dark';
accessor selectedTheme: TTheme = 'dark';
@property()
public isFullscreen: boolean = false;
accessor isFullscreen: boolean = false;
@property()
public pages: Record<string, TTemplateFactory> = {};
accessor pages: Record<string, TTemplateFactory> = {};
@property()
public elements: { [key: string]: DeesElement } = {};
accessor elements: { [key: string]: DeesElement } = {};
@property()
public warning: string = null;
accessor warning: string = null;
private frameScrollY: number = 0;
private sidebarScrollY: number = 0;
private scrollPositionsApplied: boolean = false;
@queryAsync('wcc-frame')
public wccFrame: Promise<WccFrame>;
accessor wccFrame: Promise<WccFrame>;
constructor(
elementsArg?: { [key: string]: DeesElement },

View File

@@ -11,13 +11,13 @@ declare global {
@customElement('wcc-frame')
export class WccFrame extends DeesElement {
@property()
public viewport: string;
accessor viewport: string;
@property({ type: Boolean })
public advancedEditorOpen: boolean = false;
accessor advancedEditorOpen: boolean = false;
@property({ type: Boolean })
public isFullscreen: boolean = false;
accessor isFullscreen: boolean = false;
public static styles = [
css`

View File

@@ -1,6 +1,8 @@
import { DeesElement, property, html, customElement, type TemplateResult, state } from '@design.estate/dees-element';
import { WccDashboard } from './wcc-dashboard.js';
import type { TTemplateFactory } from './wcctools.helpers.js';
import './wcc-record-button.js';
import './wcc-recording-panel.js';
export type TPropertyType = 'String' | 'Number' | 'Boolean' | 'Object' | 'Enum' | 'Array';
@@ -18,28 +20,28 @@ export class WccProperties extends DeesElement {
@property({
type: WccDashboard
})
public dashboardRef: WccDashboard;
accessor dashboardRef: WccDashboard;
@property()
public selectedItem: TTemplateFactory | DeesElement;
accessor selectedItem: TTemplateFactory | DeesElement;
@property()
public selectedViewport: TEnvironment = 'native';
accessor selectedViewport: TEnvironment = 'native';
@property()
public selectedTheme: TTheme = 'dark';
accessor selectedTheme: TTheme = 'dark';
@property()
public warning: string = null;
accessor warning: string = null;
@property()
public isFullscreen: boolean = false;
accessor isFullscreen: boolean = false;
@state()
propertyContent: TemplateResult[] = [];
accessor propertyContent: TemplateResult[] = [];
@state()
editingProperties: Array<{
accessor editingProperties: Array<{
id: string;
name: string;
value: any;
@@ -48,6 +50,16 @@ export class WccProperties extends DeesElement {
editorError: string;
}> = [];
// Recording coordination state
@state()
accessor showRecordingPanel: boolean = false;
@state()
accessor isRecording: boolean = false;
@state()
accessor recordingDuration: number = 0;
public editorHeight: number = 300;
public render(): TemplateResult {
@@ -88,7 +100,7 @@ export class WccProperties extends DeesElement {
}
.grid {
display: grid;
grid-template-columns: 1fr 150px 300px 70px;
grid-template-columns: 1fr 150px 300px 70px 70px;
height: 100%;
}
.properties {
@@ -654,9 +666,26 @@ export class WccProperties extends DeesElement {
${this.isFullscreen ? 'fullscreen_exit' : 'fullscreen'}
</i>
</div>
<!-- Recording Button -->
<wcc-record-button
.state=${this.isRecording ? 'recording' : 'idle'}
.duration=${this.recordingDuration}
@record-click=${() => this.handleRecordButtonClick()}
></wcc-record-button>
</div>
${this.warning ? html`<div class="warning">${this.warning}</div>` : null}
</div>
<!-- Recording Panel (options + preview) -->
${this.showRecordingPanel ? html`
<wcc-recording-panel
.dashboardRef=${this.dashboardRef}
@recording-start=${() => { this.isRecording = true; }}
@recording-stop=${() => { this.isRecording = false; }}
@duration-update=${(e: CustomEvent) => { this.recordingDuration = e.detail.duration; }}
@close=${() => { this.showRecordingPanel = false; this.isRecording = false; this.recordingDuration = 0; }}
></wcc-recording-panel>
` : null}
`;
}
@@ -994,4 +1023,19 @@ export class WccProperties extends DeesElement {
})
);
}
// ==================== Recording Methods ====================
private handleRecordButtonClick() {
if (this.isRecording) {
// Stop recording by calling the panel's stopRecording method
const panel = this.shadowRoot?.querySelector('wcc-recording-panel') as any;
if (panel && panel.stopRecording) {
panel.stopRecording();
}
} else {
// Toggle the recording panel
this.showRecordingPanel = !this.showRecordingPanel;
}
}
}

View File

@@ -0,0 +1,108 @@
import { DeesElement, customElement, html, css, property, type TemplateResult } from '@design.estate/dees-element';
@customElement('wcc-record-button')
export class WccRecordButton extends DeesElement {
@property({ type: String })
accessor state: 'idle' | 'recording' = 'idle';
@property({ type: Number })
accessor duration: number = 0;
public static styles = [
css`
:host {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
color: #666;
user-select: none;
}
:host(:hover) {
background: rgba(239, 68, 68, 0.05);
color: #f87171;
}
:host(.recording) {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.rec-icon {
width: 12px;
height: 12px;
border-radius: 50%;
background: currentColor;
}
:host(.recording) .rec-icon {
animation: pulse-recording 1s ease-in-out infinite;
}
@keyframes pulse-recording {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.9); }
}
.recording-timer {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.7rem;
}
`
];
private formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
public render(): TemplateResult {
return html`
<div class="content">
<div class="rec-icon"></div>
${this.state === 'recording' ? html`
<span class="recording-timer">${this.formatDuration(this.duration)}</span>
` : null}
</div>
`;
}
async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.addEventListener('click', this.handleClick);
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.removeEventListener('click', this.handleClick);
}
private handleClick = (): void => {
this.dispatchEvent(new CustomEvent('record-click', {
bubbles: true,
composed: true
}));
};
updated(changedProperties: Map<string, unknown>): void {
super.updated(changedProperties);
if (changedProperties.has('state')) {
if (this.state === 'recording') {
this.classList.add('recording');
} else {
this.classList.remove('recording');
}
}
}
}

View File

@@ -0,0 +1,974 @@
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
import { RecorderService } from '../services/recorder.service.js';
import type { WccDashboard } from './wcc-dashboard.js';
@customElement('wcc-recording-panel')
export class WccRecordingPanel extends DeesElement {
// External configuration
@property({ attribute: false })
accessor dashboardRef: WccDashboard;
// Panel state
@state()
accessor panelState: 'options' | 'recording' | 'preview' = 'options';
// Recording options
@state()
accessor recordingMode: 'viewport' | 'screen' = 'viewport';
@state()
accessor audioEnabled: boolean = false;
@state()
accessor selectedMicrophoneId: string = '';
@state()
accessor availableMicrophones: MediaDeviceInfo[] = [];
@state()
accessor audioLevel: number = 0;
// Recording state
@state()
accessor recordingDuration: number = 0;
// Preview/trim state
@state()
accessor previewVideoUrl: string = '';
@state()
accessor trimStart: number = 0;
@state()
accessor trimEnd: number = 0;
@state()
accessor videoDuration: number = 0;
@state()
accessor isDraggingTrim: 'start' | 'end' | null = null;
@state()
accessor isExporting: boolean = false;
// Service instance
private recorderService: RecorderService;
constructor() {
super();
this.recorderService = new RecorderService({
onDurationUpdate: (duration) => {
this.recordingDuration = duration;
this.dispatchEvent(new CustomEvent('duration-update', {
detail: { duration },
bubbles: true,
composed: true
}));
},
onRecordingComplete: (blob) => {
this.handleRecordingComplete(blob);
},
onAudioLevelUpdate: (level) => {
this.audioLevel = level;
},
onStreamEnded: () => {
this.stopRecording();
}
});
}
public static styles = [
css`
:host {
/* CSS Variables */
--background: #0a0a0a;
--foreground: #e5e5e5;
--input: #141414;
--primary: #3b82f6;
--border: rgba(255, 255, 255, 0.06);
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
}
/* Recording Options Panel */
.recording-options-panel {
position: fixed;
right: 16px;
bottom: 116px;
width: 360px;
background: #0c0c0c;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow: hidden;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.recording-options-header {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.recording-options-title {
font-size: 0.8rem;
font-weight: 500;
color: #ccc;
}
.recording-options-close {
width: 24px;
height: 24px;
background: transparent;
border: none;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.recording-options-close:hover {
background: rgba(255, 255, 255, 0.05);
color: #999;
}
.recording-options-content {
padding: 1rem;
}
.recording-option-group {
margin-bottom: 1rem;
}
.recording-option-group:last-child {
margin-bottom: 0;
}
.recording-option-label {
font-size: 0.7rem;
font-weight: 500;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.recording-mode-buttons {
display: flex;
gap: 0.5rem;
}
.recording-mode-btn {
flex: 1;
padding: 0.6rem 0.75rem;
background: var(--input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: #999;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.recording-mode-btn:hover {
border-color: var(--primary);
color: #ccc;
}
.recording-mode-btn.selected {
background: rgba(59, 130, 246, 0.15);
border-color: var(--primary);
color: var(--primary);
}
.audio-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.audio-toggle input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--primary);
}
.audio-toggle label {
font-size: 0.75rem;
color: #999;
cursor: pointer;
}
.microphone-select {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--foreground);
font-size: 0.75rem;
outline: none;
cursor: pointer;
transition: all 0.15s ease;
}
.microphone-select:focus {
border-color: var(--primary);
}
.microphone-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.audio-level-container {
margin-top: 0.75rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-sm);
}
.audio-level-label {
font-size: 0.65rem;
color: #666;
margin-bottom: 0.25rem;
}
.audio-level-bar {
height: 8px;
background: var(--input);
border-radius: 4px;
overflow: hidden;
}
.audio-level-fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #84cc16, #eab308);
border-radius: 4px;
transition: width 0.1s ease;
}
.start-recording-btn {
width: 100%;
padding: 0.75rem;
background: #dc2626;
border: none;
border-radius: var(--radius-sm);
color: white;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.start-recording-btn:hover {
background: #b91c1c;
}
.start-recording-btn .rec-dot {
width: 10px;
height: 10px;
background: white;
border-radius: 50%;
}
/* Preview Modal */
.preview-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.preview-modal {
width: 90%;
max-width: 800px;
background: #0c0c0c;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
}
.preview-modal-header {
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-modal-title {
font-size: 0.9rem;
font-weight: 500;
color: #ccc;
}
.preview-modal-close {
width: 28px;
height: 28px;
background: transparent;
border: none;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 1.2rem;
transition: all 0.15s ease;
}
.preview-modal-close:hover {
background: rgba(255, 255, 255, 0.05);
color: #999;
}
.preview-modal-content {
padding: 1.25rem;
}
.preview-video-container {
background: #000;
border-radius: var(--radius-sm);
overflow: hidden;
aspect-ratio: 16 / 9;
}
.preview-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-modal-actions {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.preview-btn {
padding: 0.6rem 1.25rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.preview-btn.secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #999;
}
.preview-btn.secondary:hover {
border-color: rgba(255, 255, 255, 0.2);
color: #ccc;
}
.preview-btn.primary {
background: var(--primary);
border: none;
color: white;
}
.preview-btn.primary:hover {
background: #2563eb;
}
.preview-btn.primary:disabled {
background: #1e3a5f;
cursor: not-allowed;
opacity: 0.7;
}
/* Trim Timeline Styles */
.trim-section {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.trim-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.trim-section-title {
font-size: 0.75rem;
font-weight: 500;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.trim-duration-info {
font-size: 0.7rem;
color: #666;
font-family: 'Consolas', 'Monaco', monospace;
}
.trim-timeline {
position: relative;
height: 48px;
background: var(--input);
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
user-select: none;
}
.trim-track {
position: absolute;
top: 50%;
left: 12px;
right: 12px;
height: 6px;
background: #333;
transform: translateY(-50%);
border-radius: 3px;
}
.trim-selected {
position: absolute;
top: 50%;
height: 6px;
background: var(--primary);
transform: translateY(-50%);
border-radius: 3px;
pointer-events: none;
}
.trim-handle {
position: absolute;
top: 50%;
width: 16px;
height: 36px;
background: white;
border: 2px solid var(--primary);
border-radius: 4px;
transform: translate(-50%, -50%);
cursor: ew-resize;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, transform 0.1s ease;
}
.trim-handle:hover {
background: #e0e0e0;
}
.trim-handle:active {
background: var(--primary);
transform: translate(-50%, -50%) scale(1.05);
}
.trim-handle::before {
content: '';
width: 2px;
height: 16px;
background: #666;
border-radius: 1px;
}
.trim-handle:active::before {
background: white;
}
.trim-time-labels {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
color: #666;
font-family: 'Consolas', 'Monaco', monospace;
padding: 0 12px;
}
.trim-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.trim-action-btn {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: #999;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.trim-action-btn:hover {
border-color: var(--primary);
color: #ccc;
}
.export-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`
];
public render(): TemplateResult {
if (this.panelState === 'options') {
return this.renderOptionsPanel();
} else if (this.panelState === 'preview') {
return this.renderPreviewModal();
}
return html``;
}
private renderOptionsPanel(): TemplateResult {
return html`
<div class="recording-options-panel">
<div class="recording-options-header">
<span class="recording-options-title">Recording Settings</span>
<button class="recording-options-close" @click=${() => this.close()}>✕</button>
</div>
<div class="recording-options-content">
<div class="recording-option-group">
<div class="recording-option-label">Record Area</div>
<div class="recording-mode-buttons">
<button
class="recording-mode-btn ${this.recordingMode === 'viewport' ? 'selected' : ''}"
@click=${() => this.recordingMode = 'viewport'}
>
Viewport Only
</button>
<button
class="recording-mode-btn ${this.recordingMode === 'screen' ? 'selected' : ''}"
@click=${() => this.recordingMode = 'screen'}
>
Entire Screen
</button>
</div>
</div>
<div class="recording-option-group">
<div class="recording-option-label">Audio</div>
<div class="audio-toggle">
<input
type="checkbox"
id="audioToggle"
?checked=${this.audioEnabled}
@change=${(e: Event) => this.handleAudioToggle((e.target as HTMLInputElement).checked)}
/>
<label for="audioToggle">Enable Microphone</label>
</div>
${this.audioEnabled ? html`
<select
class="microphone-select"
.value=${this.selectedMicrophoneId}
@change=${(e: Event) => this.handleMicrophoneChange((e.target as HTMLSelectElement).value)}
>
<option value="">Select Microphone...</option>
${this.availableMicrophones.map(mic => html`
<option value=${mic.deviceId}>${mic.label || `Microphone ${mic.deviceId.slice(0, 8)}`}</option>
`)}
</select>
${this.selectedMicrophoneId ? html`
<div class="audio-level-container">
<div class="audio-level-label">Input Level</div>
<div class="audio-level-bar">
<div class="audio-level-fill" style="width: ${this.audioLevel}%"></div>
</div>
</div>
` : null}
` : null}
</div>
<button class="start-recording-btn" @click=${() => this.startRecording()}>
<div class="rec-dot"></div>
Start Recording
</button>
</div>
</div>
`;
}
private renderPreviewModal(): TemplateResult {
return html`
<div class="preview-modal-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) {
this.discardRecording();
}
}}>
<div class="preview-modal">
<div class="preview-modal-header">
<span class="preview-modal-title">Recording Preview</span>
<button class="preview-modal-close" @click=${() => this.discardRecording()}>✕</button>
</div>
<div class="preview-modal-content">
<div class="preview-video-container">
<video
class="preview-video"
src=${this.previewVideoUrl}
controls
@loadedmetadata=${(e: Event) => this.handleVideoLoaded(e.target as HTMLVideoElement)}
></video>
</div>
<!-- Trim Section -->
<div class="trim-section">
<div class="trim-section-header">
<span class="trim-section-title">Trim Video</span>
<span class="trim-duration-info">
${this.formatDuration(Math.floor(this.trimEnd - this.trimStart))}
${this.trimStart > 0 || this.trimEnd < this.videoDuration
? `(trimmed from ${this.formatDuration(Math.floor(this.videoDuration))})`
: ''}
</span>
</div>
<div
class="trim-timeline"
@mousedown=${(e: MouseEvent) => this.handleTimelineClick(e)}
@mousemove=${(e: MouseEvent) => this.handleTimelineDrag(e)}
@mouseup=${() => this.handleTimelineDragEnd()}
@mouseleave=${() => this.handleTimelineDragEnd()}
>
<div class="trim-track"></div>
<div
class="trim-selected"
style="left: ${this.getHandlePositionStyle(this.trimStart)}; right: ${this.getHandlePositionFromEndStyle(this.trimEnd)};"
></div>
<div
class="trim-handle start-handle"
style="left: ${this.getHandlePositionStyle(this.trimStart)};"
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'start'; }}
></div>
<div
class="trim-handle end-handle"
style="left: ${this.getHandlePositionStyle(this.trimEnd)};"
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'end'; }}
></div>
</div>
<div class="trim-time-labels">
<span>${this.formatDuration(Math.floor(this.trimStart))}</span>
<span>${this.formatDuration(Math.floor(this.trimEnd))}</span>
</div>
<div class="trim-actions">
<button class="trim-action-btn" @click=${() => this.resetTrim()}>
Reset Trim
</button>
<button class="trim-action-btn" @click=${() => this.previewTrimmedSection()}>
Preview Selection
</button>
</div>
</div>
</div>
<div class="preview-modal-actions">
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
<button
class="preview-btn primary"
?disabled=${this.isExporting}
@click=${() => this.downloadRecording()}
>
${this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : 'Download'}
</button>
</div>
</div>
</div>
`;
}
// ==================== Audio Methods ====================
private async handleAudioToggle(enabled: boolean): Promise<void> {
this.audioEnabled = enabled;
if (enabled) {
this.availableMicrophones = await this.recorderService.loadMicrophones(true);
if (this.availableMicrophones.length > 0 && !this.selectedMicrophoneId) {
this.selectedMicrophoneId = this.availableMicrophones[0].deviceId;
await this.recorderService.startAudioMonitoring(this.selectedMicrophoneId);
}
} else {
this.recorderService.stopAudioMonitoring();
this.selectedMicrophoneId = '';
this.audioLevel = 0;
}
}
private async handleMicrophoneChange(deviceId: string): Promise<void> {
this.selectedMicrophoneId = deviceId;
if (deviceId) {
await this.recorderService.startAudioMonitoring(deviceId);
} else {
this.recorderService.stopAudioMonitoring();
this.audioLevel = 0;
}
}
// ==================== Recording Methods ====================
private async startRecording(): Promise<void> {
try {
let viewportElement: HTMLElement | undefined;
if (this.recordingMode === 'viewport' && this.dashboardRef) {
const wccFrame = await this.dashboardRef.wccFrame;
viewportElement = await wccFrame.getViewportElement();
}
await this.recorderService.startRecording({
mode: this.recordingMode,
audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined,
viewportElement
});
this.panelState = 'recording';
this.dispatchEvent(new CustomEvent('recording-start', {
bubbles: true,
composed: true
}));
} catch (error) {
console.error('Failed to start recording:', error);
this.panelState = 'options';
}
}
public stopRecording(): void {
this.recorderService.stopRecording();
}
private handleRecordingComplete(blob: Blob): void {
if (this.previewVideoUrl) {
URL.revokeObjectURL(this.previewVideoUrl);
}
this.previewVideoUrl = URL.createObjectURL(blob);
this.panelState = 'preview';
this.dispatchEvent(new CustomEvent('recording-stop', {
bubbles: true,
composed: true
}));
}
private discardRecording(): void {
if (this.previewVideoUrl) {
URL.revokeObjectURL(this.previewVideoUrl);
this.previewVideoUrl = '';
}
this.recorderService.reset();
this.trimStart = 0;
this.trimEnd = 0;
this.videoDuration = 0;
this.isExporting = false;
this.recordingDuration = 0;
this.close();
}
private async downloadRecording(): Promise<void> {
const recordedBlob = this.recorderService.recordedBlob;
if (!recordedBlob) return;
this.isExporting = true;
try {
let blobToDownload: Blob;
const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1;
if (needsTrim) {
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
if (video) {
blobToDownload = await this.recorderService.exportTrimmedVideo(video, this.trimStart, this.trimEnd);
} else {
blobToDownload = recordedBlob;
}
} else {
blobToDownload = recordedBlob;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `wcctools-recording-${timestamp}.webm`;
const url = URL.createObjectURL(blobToDownload);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.discardRecording();
} catch (error) {
console.error('Error exporting video:', error);
this.isExporting = false;
}
}
// ==================== Trim Methods ====================
private handleVideoLoaded(video: HTMLVideoElement): void {
// WebM files from MediaRecorder may have Infinity/NaN duration
// Fall back to the tracked recording duration
const duration = Number.isFinite(video.duration) ? video.duration : this.recordingDuration;
this.videoDuration = duration;
this.trimStart = 0;
this.trimEnd = duration;
}
private formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
private getHandlePositionStyle(time: number): string {
if (this.videoDuration === 0) return '12px';
const percentage = time / this.videoDuration;
// Formula: 12px padding + percentage of remaining width (total - 24px padding)
// At 0%: 12px (left edge of track)
// At 100%: calc(100% - 12px) (right edge of track)
return `calc(12px + ${(percentage * 100).toFixed(2)}% - ${(percentage * 24).toFixed(2)}px)`;
}
private getHandlePositionFromEndStyle(time: number): string {
if (this.videoDuration === 0) return '12px';
const percentage = time / this.videoDuration;
const remainingPercentage = 1 - percentage;
// For CSS 'right' property: distance from right edge
// At trimEnd = 100%: right = 12px (at right edge of track)
// At trimEnd = 0%: right = calc(100% - 12px) (at left edge of track)
return `calc(12px + ${(remainingPercentage * 100).toFixed(2)}% - ${(remainingPercentage * 24).toFixed(2)}px)`;
}
private handleTimelineClick(e: MouseEvent): void {
if (this.isDraggingTrim) return;
const timeline = e.currentTarget as HTMLElement;
const rect = timeline.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
const time = percentage * this.videoDuration;
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
if (video) {
video.currentTime = time;
}
}
private handleTimelineDrag(e: MouseEvent): void {
if (!this.isDraggingTrim) return;
const timeline = e.currentTarget as HTMLElement;
const rect = timeline.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24)));
const time = percentage * this.videoDuration;
const minDuration = 1;
if (this.isDraggingTrim === 'start') {
this.trimStart = Math.min(time, this.trimEnd - minDuration);
this.trimStart = Math.max(0, this.trimStart);
} else if (this.isDraggingTrim === 'end') {
this.trimEnd = Math.max(time, this.trimStart + minDuration);
this.trimEnd = Math.min(this.videoDuration, this.trimEnd);
}
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
if (video) {
video.currentTime = this.isDraggingTrim === 'start' ? this.trimStart : this.trimEnd;
}
}
private handleTimelineDragEnd(): void {
this.isDraggingTrim = null;
}
private resetTrim(): void {
this.trimStart = 0;
this.trimEnd = this.videoDuration;
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
if (video) {
video.currentTime = 0;
}
}
private previewTrimmedSection(): void {
const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement;
if (!video) return;
video.currentTime = this.trimStart;
video.play();
const checkTime = () => {
if (video.currentTime >= this.trimEnd) {
video.pause();
video.removeEventListener('timeupdate', checkTime);
}
};
video.addEventListener('timeupdate', checkTime);
}
// ==================== Lifecycle ====================
private close(): void {
this.recorderService.stopAudioMonitoring();
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true
}));
}
async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.recorderService.dispose();
if (this.previewVideoUrl) {
URL.revokeObjectURL(this.previewVideoUrl);
}
}
}

View File

@@ -8,16 +8,16 @@ export type TElementType = 'element' | 'page';
@customElement('wcc-sidebar')
export class WccSidebar extends DeesElement {
@property({ attribute: false })
public selectedItem: DeesElement | TTemplateFactory;
accessor selectedItem: DeesElement | TTemplateFactory;
@property({ attribute: false })
public selectedType: TElementType;
accessor selectedType: TElementType;
@property()
public dashboardRef: WccDashboard;
accessor dashboardRef: WccDashboard;
@property()
public isFullscreen: boolean = false;
accessor isFullscreen: boolean = false;
public render(): TemplateResult {
return html`

View File

@@ -2,6 +2,11 @@ import { WccDashboard } from './elements/wcc-dashboard.js';
import { LitElement } from 'lit';
import type { TTemplateFactory } from './elements/wcctools.helpers.js';
// Export recording components and service
export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js';
export { WccRecordButton } from './elements/wcc-record-button.js';
export { WccRecordingPanel } from './elements/wcc-recording-panel.js';
const setupWccTools = (
elementsArg?: { [key: string]: LitElement },
pagesArg?: Record<string, TTemplateFactory>

123
ts_web/readme.md Normal file
View File

@@ -0,0 +1,123 @@
# @design.estate/dees-wcctools
🛠️ **Web Component Catalogue Tools** — The core dashboard and UI components for building interactive component catalogues
## Overview
This is the main module of `@design.estate/dees-wcctools`, providing the complete dashboard experience for developing, testing, and documenting web components.
## Installation
```bash
pnpm add -D @design.estate/dees-wcctools
```
## Usage
```typescript
import { setupWccTools } from '@design.estate/dees-wcctools';
import { MyButton } from './components/my-button.js';
setupWccTools({
'my-button': MyButton,
});
```
## Exports
### Main Entry Point
| Export | Description |
|--------|-------------|
| `setupWccTools` | Initialize the component catalogue dashboard |
### Recording Components
| Export | Description |
|--------|-------------|
| `RecorderService` | Service class for screen/viewport recording |
| `WccRecordButton` | Record button UI component |
| `WccRecordingPanel` | Recording options and preview panel |
| `IRecorderEvents` | TypeScript interface for recorder callbacks |
| `IRecordingOptions` | TypeScript interface for recording options |
## Internal Components
The module includes these internal web components:
| Component | Description |
|-----------|-------------|
| `wcc-dashboard` | Main dashboard container with routing |
| `wcc-sidebar` | Navigation sidebar with element/page listing |
| `wcc-frame` | Iframe viewport with responsive sizing |
| `wcc-properties` | Property panel with live editing |
| `wcc-record-button` | Recording state indicator button |
| `wcc-recording-panel` | Recording workflow UI |
## RecorderService API
For programmatic recording control:
```typescript
import { RecorderService, type IRecorderEvents } from '@design.estate/dees-wcctools';
const events: IRecorderEvents = {
onDurationUpdate: (duration) => console.log(`Recording: ${duration}s`),
onRecordingComplete: (blob) => saveBlob(blob),
onAudioLevelUpdate: (level) => updateMeter(level),
onError: (error) => console.error(error),
onStreamEnded: () => console.log('User stopped sharing'),
};
const recorder = new RecorderService(events);
// Load available microphones
const mics = await recorder.loadMicrophones(true); // true = request permission
// Start audio level monitoring
await recorder.startAudioMonitoring(mics[0].deviceId);
// Start recording
await recorder.startRecording({
mode: 'viewport', // or 'screen'
audioDeviceId: mics[0].deviceId,
viewportElement: document.querySelector('.viewport'),
});
// Stop recording
recorder.stopRecording();
// Export trimmed video
const trimmedBlob = await recorder.exportTrimmedVideo(videoElement, startTime, endTime);
// Cleanup
recorder.dispose();
```
## Architecture
```
ts_web/
├── index.ts # Main exports
├── elements/
│ ├── wcc-dashboard.ts # Root dashboard component
│ ├── wcc-sidebar.ts # Navigation sidebar
│ ├── wcc-frame.ts # Responsive iframe viewport
│ ├── wcc-properties.ts # Property editing panel
│ ├── wcc-record-button.ts # Recording button
│ ├── wcc-recording-panel.ts # Recording options/preview
│ └── wcctools.helpers.ts # Shared utilities
├── services/
│ └── recorder.service.ts # MediaRecorder abstraction
└── pages/
└── index.ts # Built-in pages
```
## Features
- 🎨 Interactive component preview
- 🔧 Real-time property editing with type detection
- 🌓 Theme switching (light/dark)
- 📱 Responsive viewport testing
- 🎬 Screen recording with trimming
- 🔗 URL-based deep linking

View File

@@ -0,0 +1,391 @@
/**
* RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic
*/
export interface IRecorderEvents {
onDurationUpdate?: (duration: number) => void;
onRecordingComplete?: (blob: Blob) => void;
onAudioLevelUpdate?: (level: number) => void;
onError?: (error: Error) => void;
onStreamEnded?: () => void;
}
export interface IRecordingOptions {
mode: 'viewport' | 'screen';
audioDeviceId?: string;
viewportElement?: HTMLElement;
}
export class RecorderService {
// Recording state
private mediaRecorder: MediaRecorder | null = null;
private recordedChunks: Blob[] = [];
private durationInterval: number | null = null;
private _duration: number = 0;
private _recordedBlob: Blob | null = null;
private _isRecording: boolean = false;
// Audio monitoring state
private audioContext: AudioContext | null = null;
private audioAnalyser: AnalyserNode | null = null;
private audioMonitoringInterval: number | null = null;
private monitoringStream: MediaStream | null = null;
// Current recording stream
private currentStream: MediaStream | null = null;
// Event callbacks
private events: IRecorderEvents = {};
constructor(events?: IRecorderEvents) {
if (events) {
this.events = events;
}
}
// Public getters
get isRecording(): boolean {
return this._isRecording;
}
get duration(): number {
return this._duration;
}
get recordedBlob(): Blob | null {
return this._recordedBlob;
}
// Update event callbacks
setEvents(events: IRecorderEvents): void {
this.events = { ...this.events, ...events };
}
// ==================== Microphone Management ====================
async loadMicrophones(requestPermission: boolean = false): Promise<MediaDeviceInfo[]> {
try {
if (requestPermission) {
// Request permission by getting a temporary stream
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(track => track.stop());
}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(d => d.kind === 'audioinput');
} catch (error) {
console.error('Error loading microphones:', error);
return [];
}
}
async startAudioMonitoring(deviceId: string): Promise<void> {
this.stopAudioMonitoring();
if (!deviceId) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId } }
});
this.monitoringStream = stream;
this.audioContext = new AudioContext();
const source = this.audioContext.createMediaStreamSource(stream);
this.audioAnalyser = this.audioContext.createAnalyser();
this.audioAnalyser.fftSize = 256;
source.connect(this.audioAnalyser);
const dataArray = new Uint8Array(this.audioAnalyser.frequencyBinCount);
this.audioMonitoringInterval = window.setInterval(() => {
if (this.audioAnalyser) {
this.audioAnalyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
const level = Math.min(100, (average / 128) * 100);
this.events.onAudioLevelUpdate?.(level);
}
}, 50);
} catch (error) {
console.error('Error starting audio monitoring:', error);
this.events.onAudioLevelUpdate?.(0);
}
}
stopAudioMonitoring(): void {
if (this.audioMonitoringInterval) {
clearInterval(this.audioMonitoringInterval);
this.audioMonitoringInterval = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.monitoringStream) {
this.monitoringStream.getTracks().forEach(track => track.stop());
this.monitoringStream = null;
}
this.audioAnalyser = null;
}
// ==================== Recording Control ====================
async startRecording(options: IRecordingOptions): Promise<void> {
try {
// Stop audio monitoring before recording
this.stopAudioMonitoring();
// Get video stream based on mode
const displayMediaOptions: DisplayMediaStreamOptions = {
video: {
displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor'
} as MediaTrackConstraints,
audio: false
};
// Add preferCurrentTab hint for viewport mode
if (options.mode === 'viewport') {
(displayMediaOptions as any).preferCurrentTab = true;
}
const videoStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
// If viewport mode, try to crop to viewport element using Element Capture API
if (options.mode === 'viewport' && options.viewportElement) {
try {
if ('CropTarget' in window) {
const cropTarget = await (window as any).CropTarget.fromElement(options.viewportElement);
const [videoTrack] = videoStream.getVideoTracks();
await (videoTrack as any).cropTo(cropTarget);
}
} catch (e) {
console.warn('Element Capture not supported, recording full tab:', e);
}
}
// Combine video with audio if enabled
let combinedStream = videoStream;
if (options.audioDeviceId) {
try {
const audioStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: options.audioDeviceId } }
});
combinedStream = new MediaStream([
...videoStream.getVideoTracks(),
...audioStream.getAudioTracks()
]);
} catch (audioError) {
console.warn('Could not add audio:', audioError);
}
}
// Store stream for cleanup
this.currentStream = combinedStream;
// Create MediaRecorder
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm';
this.mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
this.recordedChunks = [];
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
this.recordedChunks.push(e.data);
}
};
this.mediaRecorder.onstop = () => this.handleRecordingComplete();
// Handle stream ending (user clicks "Stop sharing")
videoStream.getVideoTracks()[0].onended = () => {
if (this._isRecording) {
this.stopRecording();
this.events.onStreamEnded?.();
}
};
this.mediaRecorder.start(1000); // Capture in 1-second chunks
// Start duration timer
this._duration = 0;
this.durationInterval = window.setInterval(() => {
this._duration++;
this.events.onDurationUpdate?.(this._duration);
}, 1000);
this._isRecording = true;
} catch (error) {
console.error('Error starting recording:', error);
this._isRecording = false;
this.events.onError?.(error as Error);
throw error;
}
}
stopRecording(): void {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
if (this.durationInterval) {
clearInterval(this.durationInterval);
this.durationInterval = null;
}
}
private handleRecordingComplete(): void {
// Create blob from recorded chunks
this._recordedBlob = new Blob(this.recordedChunks, { type: 'video/webm' });
// Stop all tracks
if (this.currentStream) {
this.currentStream.getTracks().forEach(track => track.stop());
this.currentStream = null;
}
this._isRecording = false;
this.events.onRecordingComplete?.(this._recordedBlob);
}
// ==================== Trim & Export ====================
async exportTrimmedVideo(
videoElement: HTMLVideoElement,
trimStart: number,
trimEnd: number
): Promise<Blob> {
return new Promise((resolve, reject) => {
// Create a canvas for capturing frames
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth || 1280;
canvas.height = videoElement.videoHeight || 720;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
// Create canvas stream for video
const canvasStream = canvas.captureStream(30);
// Try to capture audio from video element
let combinedStream: MediaStream;
try {
// Create audio context to capture video's audio
const audioCtx = new AudioContext();
const source = audioCtx.createMediaElementSource(videoElement);
const destination = audioCtx.createMediaStreamDestination();
source.connect(destination);
source.connect(audioCtx.destination); // Also play through speakers
// Combine video (from canvas) and audio (from video element)
combinedStream = new MediaStream([
...canvasStream.getVideoTracks(),
...destination.stream.getAudioTracks()
]);
// Store audioCtx for cleanup
const cleanup = () => {
audioCtx.close();
};
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, cleanup, resolve, reject);
} catch (audioError) {
console.warn('Could not capture audio, recording video only:', audioError);
combinedStream = canvasStream;
this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, () => {}, resolve, reject);
}
});
}
private recordTrimmedStream(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
stream: MediaStream,
trimStart: number,
trimEnd: number,
cleanup: () => void,
resolve: (blob: Blob) => void,
reject: (error: Error) => void
): void {
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: 'video/webm';
const recorder = new MediaRecorder(stream, { mimeType });
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
recorder.onstop = () => {
cleanup();
resolve(new Blob(chunks, { type: 'video/webm' }));
};
recorder.onerror = (e) => {
cleanup();
reject(new Error('Recording error: ' + e));
};
// Seek to trim start
video.currentTime = trimStart;
video.onseeked = () => {
// Start recording
recorder.start(100);
// Start playing
video.play();
// Draw frames to canvas
const drawFrame = () => {
if (video.currentTime >= trimEnd || video.paused || video.ended) {
video.pause();
video.onseeked = null;
// Give a small delay before stopping to ensure last frame is captured
setTimeout(() => {
if (recorder.state === 'recording') {
recorder.stop();
}
}, 100);
return;
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
requestAnimationFrame(drawFrame);
};
drawFrame();
};
}
// ==================== Cleanup ====================
reset(): void {
this._recordedBlob = null;
this.recordedChunks = [];
this._duration = 0;
this._isRecording = false;
}
dispose(): void {
this.stopRecording();
this.stopAudioMonitoring();
this.reset();
if (this.currentStream) {
this.currentStream.getTracks().forEach(track => track.stop());
this.currentStream = null;
}
}
}

View File

@@ -1,7 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",