Compare commits

...

56 Commits

Author SHA1 Message Date
fe62278d74 v3.6.0
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 16:41:24 +00:00
3ee8afcdae feat(sidebar): restructure sidebar layout, add search clear button, and improve scrolling behavior 2026-01-04 16:41:24 +00:00
ab517b6ba8 v3.5.3
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 15:59:51 +00:00
2e4cbd911c fix(deps): bump dependency versions: @design.estate/dees-domtools to ^2.3.7, @design.estate/dees-element to ^2.1.5, lit to ^3.3.2; update devDependencies @api.global/typedserver to ^8.1.0 and @git.zone/tstest to ^3.1.4 2026-01-04 15:59:51 +00:00
6e14ebde03 v3.5.2
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 11:43:54 +00:00
28d1227d30 fix(elements): delay hiding sidebar and properties panels during native-mode transition and use transparent rgba border for frame to avoid layout jumps 2026-01-04 11:43:54 +00:00
8c60d3bea3 v3.5.1
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 11:31:02 +00:00
9ed614994f fix(sidebar): disable frame CSS transition while user is resizing the sidebar to prevent janky animations 2026-01-04 11:31:02 +00:00
61b79aa4dc update 2026-01-04 11:29:19 +00:00
1134cba575 update 2026-01-04 10:57:45 +00:00
29c0df489e v3.5.0
Some checks failed
Default (tags) / security (push) Failing after 13s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-04 10:48:03 +00:00
53df62a9fd feat(wcctools): add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements 2026-01-04 10:48:03 +00:00
4fe17f5afd v3.4.0
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-30 12:30:45 +00:00
63dd6a27b3 feat(sidebar): add searchable sidebar with URL-backed query state and highlighted matches 2025-12-30 12:30:45 +00:00
287cc4d1c3 v3.3.0
Some checks failed
Default (tags) / security (push) Failing after 17s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-28 12:51:55 +00:00
14e63738b7 feat(wcctools): Add section-based configuration API for setupWccTools, new Views, and section-aware routing/sidebar 2025-12-28 12:51:55 +00:00
dd151bdad8 v3.2.0
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-22 10:49:02 +00:00
bd409745e6 feat(wcc-sidebar): auto-expand sidebar folder when selecting an element with multiple demos 2025-12-22 10:49:02 +00:00
eb7f482b75 v3.1.2
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-21 18:30:41 +00:00
d16d482120 fix(wcc-properties): Use LitElement.updated to recreate properties only when selectedItem changes and handle errors; remove custom scheduleUpdate implementation 2025-12-21 18:30:41 +00:00
5ba4e55011 v3.1.1
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-21 18:24:46 +00:00
0068c0749d fix(wcc-properties): Improve wcc-properties CSS to prevent grid overflow, properly size and center icon glyphs, and adjust right-side offset 2025-12-21 18:24:46 +00:00
36dd6b5064 v3.1.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-19 09:21:30 +00:00
ddecfcdb4c feat(wcc-properties): add Share selector with inline Record button and adjust properties panel grid layout 2025-12-19 09:21:30 +00:00
8f0f8606a1 v3.0.0
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-19 09:08:08 +00:00
7dca519d9a BREAKING CHANGE(ts_web): Replace fullscreen boolean with native viewport mode across components, add native viewport selector and toggle, and update dev deps and npmextra config 2025-12-19 09:08:08 +00:00
d48cd063c4 v2.0.1
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 19:26:59 +00:00
bb04895be8 fix(@git.zone/tswatch): Bump @git.zone/tswatch devDependency to ^2.3.12 2025-12-11 19:26:59 +00:00
54b34b6faa v2.0.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-11 19:02:02 +00:00
12c85fa4cb BREAKING CHANGE(recorder): Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness 2025-12-11 19:02:02 +00:00
d90df9717b update 2025-12-11 18:03:46 +00:00
d4b161437b update 2025-12-11 16:58:04 +00:00
ad033c8104 update 2025-12-11 15:49:04 +00:00
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
91194f6388 v1.2.1
Some checks failed
Default (tags) / security (push) Failing after 22s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-11-16 15:49:36 +00:00
904bb92057 fix(dependencies): Bump dependencies and developer tooling versions 2025-11-16 15:49:36 +00:00
4c23739d9a 1.2.0
Some checks failed
Default (tags) / security (push) Failing after 23s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-19 13:24:46 +00:00
dd048d42a8 feat(wcc-properties): Add advanced property editors, recursive element detection, demo wrapper, UI refresh and test fixtures 2025-09-19 13:24:46 +00:00
ca28dbd9db feat: add async demo support and enhance template resolution
- Introduced async demo functionality in the README, allowing for asynchronous data preparation before rendering components.
- Updated WccDashboard, WccProperties, and WccSidebar to support Promise-based template factories.
- Implemented resolveTemplateFactory to handle both synchronous and asynchronous template results.
- Added tests for resolveTemplateFactory to ensure correct behavior for both sync and async templates.
- Updated pnpm workspace configuration.
2025-09-19 13:02:16 +00:00
7148b12066 1.1.1
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 15s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-07-07 08:48:35 +00:00
309d708830 add fullscreen mode 2025-07-07 08:48:09 +00:00
923bedc4fc 1.1.0
Some checks failed
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-06-27 20:52:27 +00:00
e8b771bde4 feat(wcctools): Enhance component tools with an advanced property editor, improved element detection and modernized UI styling for a more responsive dashboard experience. 2025-06-27 20:52:27 +00:00
7a248993bc update 2025-06-27 20:51:31 +00:00
03f215e0f1 update 2025-06-27 20:50:32 +00:00
216cb0288d update 2025-06-27 20:41:24 +00:00
65acda3de1 update 2025-06-27 20:40:06 +00:00
88ff74bb86 update styling 2025-06-27 20:28:47 +00:00
98a5b1b5a3 update 2025-06-27 20:26:42 +00:00
bbf738d4e2 update 2025-06-27 20:22:28 +00:00
4f8ca7061a update 2025-06-27 20:03:54 +00:00
45 changed files with 11470 additions and 2695 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,5 +1,164 @@
# Changelog
## 2026-01-04 - 3.6.0 - feat(sidebar)
restructure sidebar layout, add search clear button, and improve scrolling behavior
- Change sidebar root to a flex column layout and add a .sidebar-header to separate header content from the scrollable menu
- Move pinned section into the header and make .menu flex: 1 with min-height: 0 so the menu becomes the scrollable area
- Replace overflow-y on the root with overflow:hidden to avoid double scrolling and constrain scrolling to .menu
- Add a clear button for the search input (.search-clear) with positioning, hover styles, and a clearSearch() method to reset the query and emit searchChanged
- Adjust search input padding and make .search-container position: relative to correctly position the clear button
## 2026-01-04 - 3.5.3 - fix(deps)
bump dependency versions: @design.estate/dees-domtools to ^2.3.7, @design.estate/dees-element to ^2.1.5, lit to ^3.3.2; update devDependencies @api.global/typedserver to ^8.1.0 and @git.zone/tstest to ^3.1.4
- Updated runtime dependencies: @design.estate/dees-domtools ^2.3.7, @design.estate/dees-element ^2.1.5, lit ^3.3.2.
- Updated devDependencies: @api.global/typedserver ^8.1.0 (major bump), @git.zone/tstest ^3.1.4.
- @api.global/typedserver major bump affects development tooling only (devDependency), not runtime API.
- Current package version is 3.5.2; recommend a patch release to 3.5.3.
## 2026-01-04 - 3.5.2 - fix(elements)
delay hiding sidebar and properties panels during native-mode transition and use transparent rgba border for frame to avoid layout jumps
- Add isHidden state to wcc-sidebar and wcc-properties and switch display bindings to use isHidden instead of directly using isNative
- Introduce a 300ms delayed hide when entering native mode so UI hides after frame animation completes; show immediately when exiting native mode
- Replace hardcoded hex border values in wcc-frame with rgba and set native border to a transparent 0px to prevent abrupt visual jumps
## 2026-01-04 - 3.5.1 - fix(sidebar)
disable frame CSS transition while user is resizing the sidebar to prevent janky animations
- Added isResizing boolean property to wcc-frame to toggle transitions during resize
- Set frame.isResizing = true at resize start and false on mouseup to re-enable transitions
- Updated CSS to skip transition while isResizing is true
- Files changed: ts_web/elements/wcc-frame.ts, ts_web/elements/wcc-sidebar.ts
## 2026-01-04 - 3.5.0 - feat(wcctools)
add context menu and pinning support, persist pinned state in URL, and add grouped demo test elements
- Add wcc-contextmenu custom element with a static show() API, proper positioning, visibility transitions, outside-click and Escape handling, and menu item actions.
- Introduce pinnedItems (Set<string>) on wcc-dashboard and wcc-sidebar; pass pinnedItems to the sidebar, handle pinnedChanged events, and persist pinned item keys in the URL query param 'pinned'. Changes include defensive updates to avoid unnecessary update loops.
- Enhance wcc-sidebar to render pinned state: new styles for pinned items and pinned sections, contextmenu integration for element items, adjusted layout (grid-template-columns) and improved element/demo rendering logic.
- Add grouped demo test components and exports to demo the demoGroup feature: test-button-primary, test-button-secondary, test-button-danger, test-input-text, and test-input-checkbox.
- Misc: adjust dashboard URL state serialization/deserialization to include pinned items and ensure scroll/search state handling remains stable.
## 2025-12-30 - 3.4.0 - feat(sidebar)
add searchable sidebar with URL-backed query state and highlighted matches
- Add search input to wcc-sidebar and expose a searchQuery property
- Filter sidebar sections and items client-side based on the search query and hide sections with no matches
- Highlight matching substrings in sidebar item labels
- Emit a 'searchChanged' event from the sidebar and handle it in wcc-dashboard to keep dashboard.searchQuery in sync
- Persist the search query in the route query parameter 'search' when building URLs and restore/clear it on navigation
- Preserve existing scroll-state handling while adding search state to URL updates
## 2025-12-28 - 3.3.0 - feat(wcctools)
Add section-based configuration API for setupWccTools, new Views, and section-aware routing/sidebar
- Introduce IWccSection and IWccConfig types and migrate setupWccTools to accept a sections config while preserving legacy (elements, pages) format
- WccDashboard and WccSidebar updated to support sections, filtering, sorting, collapsed sections, and section-aware URL routing (uses sectionName in routes with legacy fallbacks)
- Add Views: view-dashboard, view-settings, view-empty-state plus test/views index exports and demo variations
- Add helpers: getSectionItems, convertLegacyToConfig and isWccConfig; update build URL and routing logic to be section-aware
- Update docs and README/readme.hints with sections API, examples, migration notes and UI/UX updates
## 2025-12-22 - 3.2.0 - feat(wcc-sidebar)
auto-expand sidebar folder when selecting an element with multiple demos
- Add updated() lifecycle to auto-expand folder when selectedItem changes
- Find element name by matching selectedItem against dashboardRef.elements
- Only auto-expand when the element has multiple demos (checks item.demo and hasMultipleDemos)
- Immutably update expandedElements set to trigger re-render and avoid duplicate additions
## 2025-12-21 - 3.1.2 - fix(wcc-properties)
Use LitElement.updated to recreate properties only when selectedItem changes and handle errors; remove custom scheduleUpdate implementation
- Replaced public async scheduleUpdate() with protected updated(changedProperties) lifecycle method
- Call super.updated(...) and only recreate properties when selectedItem changed to avoid unnecessary work
- Preserve error handling and clear propertyContent on failure
- Removed explicit super.scheduleUpdate() call to rely on LitElement's update lifecycle
## 2025-12-21 - 3.1.1 - fix(wcc-properties)
Improve wcc-properties CSS to prevent grid overflow, properly size and center icon glyphs, and adjust right-side offset
- Use minmax(0, 1fr) for grid-template-columns (selectorButtons1/2/4/5) to avoid flexbox overflow and ensure consistent column sizing
- Add min-width/min-height and inline-flex centering to .material-symbols-outlined to stabilize icon sizing and vertical/horizontal alignment
- Increase right calc offset from 520px to 600px to accommodate wider content/controls
- Changes applied in ts_web/elements/wcc-properties.ts
## 2025-12-19 - 3.1.0 - feat(wcc-properties)
add Share selector with inline Record button and adjust properties panel grid layout
- Increase rightmost column width from 70px to 100px in properties grid
- Introduce .shareSelector and .selectorButtons1 CSS classes and markup
- Replace <wcc-record-button> with inline Record button that toggles icon based on isRecording
- Add Share panel heading and integrate record control into selector area
## 2025-12-19 - 3.0.0 - BREAKING CHANGE(ts_web)
Replace fullscreen boolean with native viewport mode across components, add native viewport selector and toggle, and update dev deps and npmextra config
- Introduce isNative (derived from selectedViewport === 'native') replacing isFullscreen on WccDashboard, WccFrame, WccProperties and WccSidebar (property and styling changes).
- WccDashboard: remove isFullscreen property, add isNative getter, implement toggleNative to switch selectedViewport between 'native' and 'desktop', update ESC handler to exit native mode and call buildUrl().
- WccProperties: add Native button to viewport selector, adjust grid columns and selector layout, replace fullscreen toggle with toggleNative (dispatches 'toggleNative' event).
- WccFrame: rename isFullscreen -> isNative and update style/markup branching to use isNative.
- WccSidebar: visibility now driven by isNative instead of isFullscreen.
- API/event changes: 'toggleFullscreen' event renamed to 'toggleNative' — this is an incompatible change for consumers relying on the old event/property.
- package.json: bump devDependencies @git.zone/tsbuild -> ^4.0.2, @git.zone/tsrun -> ^2.0.1, @git.zone/tswatch -> ^2.3.13, @types/node -> ^25.0.3.
- npmextra.json: rename keys (e.g. "gitzone" -> "@git.zone/cli", "tsdoc" -> "@git.zone/tsdoc"), add @ship.zone/szci entry and add release registries/accessLevel for @git.zone/cli.
## 2025-12-11 - 2.0.1 - fix(@git.zone/tswatch)
Bump @git.zone/tswatch devDependency to ^2.3.12
- Updated devDependency @git.zone/tswatch from ^2.3.11 to ^2.3.12 in package.json
## 2025-12-11 - 2.0.0 - BREAKING CHANGE(recorder)
Remove FFmpeg-based MP4 conversion; simplify recorder/export to WebM and improve recorder/editor robustness
- Removed FFmpegService and all client-side MP4 conversion logic — exports are now WebM-only (MP4 conversion and related UI/controls removed).
- ts_web/elements/wcc-recording-panel: dropped outputFormat and conversion states/UI; download flow simplified to always export WebM.
- ts_web/index.ts: removed FFmpegService exports and conversion types from public API.
- package.json: removed @ffmpeg/* dependencies.
- RecorderService: handleRecordingComplete is now async and fixes recorded blob assignment and cleanup timing.
- wcc-properties: improved element detection and robustness — recursive search through light/shadow DOM with retry/delay, plus an advanced JSON editor for Object/Array props (supports multiple open editors and frame resize events).
- wcc-sidebar: force re-render after selecting demos to ensure child demo selection indicators update correctly.
- dees-demowrapper: ensure slotted content is rendered before calling runAfterRender (small timing/stability improvements).
- Test update: demo definitions can be arrays (multiple demos) — test-demoelement updated to use multiple demo entries.
## 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
- Bump @design.estate/dees-domtools from ^2.3.3 to ^2.3.6
- Bump @design.estate/dees-element from ^2.1.2 to ^2.1.3
- Upgrade @git.zone/tsbuild from ^2.6.8 to ^2.7.1
- Upgrade @git.zone/tsrun from ^1.2.44 to ^1.6.2
- Upgrade @git.zone/tstest from ^2.3.8 to ^2.7.0
## 2025-09-19 - 1.2.0 - feat(wcc-properties)
Add advanced property editors, recursive element detection, demo wrapper, UI refresh and test fixtures
- Advanced JSON property editor: multiple side-by-side editors with save/cancel, syntax validation and inline error display; editors affect frame layout (frame bottom increases when editors open).
- Improved properties panel element detection: recursive search through nested children and shadow DOM, initial delay and retry mechanism to handle async Lit rendering.
- Add dees-demowrapper component in ts_demotools to run post-render callbacks and support async demo setup and DOM access for demos.
- UI refresh with shadcn-like styles: CSS variables for theming, redesigned properties panel and sidebar, improved form controls, theme and viewport selectors.
- Viewport and frame improvements: responsive padding based on viewport type, theme-aware background rendering, and scroll position tracking with URL/state restoration for frame and sidebar.
- Add test fixtures and demo elements/pages under test/ to exercise properties, complex types, nested elements and scroll restoration; include node test for resolveTemplateFactory.
- Expose setupWccTools entry point and plugin wiring (wcctools.plugins exports for dees-domtools and smartdelay) for easier integration.
## 2025-06-27 - 1.1.0 - feat(wcctools)
Enhance component tools with an advanced property editor, improved element detection and modernized UI styling for a more responsive dashboard experience.
- Updated documentation and in-code hints with new shadcn-like design patterns for the dashboard UI.
- Introduced an advanced complex properties editor supporting JSON validation and multi-editor handling.
- Refined recursive element search in the properties panel to improve asynchronous rendering detection.
- Expanded test coverage with scenarios for edge cases, nested elements and wrapper components.
## 2025-06-26 - 1.0.101 - fix(wcc-dashboard)
Improve scroll listener management and add new test pages

View File

@@ -2,9 +2,32 @@
import * as deesWccTools from '../ts_web/index.js';
import * as deesDomTools from '@design.estate/dees-domtools';
// elements and pages
// elements, views and pages
import * as elements from '../test/elements/index.js';
import * as views from '../test/views/index.js';
import * as pages from '../test/pages/index.js';
deesWccTools.setupWccTools(elements as any, pages);
// Sections-based API with Views
deesWccTools.setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Views',
type: 'elements',
items: views,
icon: 'web',
},
{
name: 'Elements',
type: 'elements',
items: elements,
sort: ([a], [b]) => a.localeCompare(b),
},
],
});
deesDomTools.elementBasic.setup();

View File

@@ -1,5 +1,5 @@
{
"gitzone": {
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "code.foss.global",
@@ -21,13 +21,19 @@
"element testing",
"page development"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"tsdoc": {
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis 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. \n\n**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.\n\n### Trademarks\n\nThis 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.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy 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.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-wcctools",
"version": "1.0.101",
"version": "3.6.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": {
@@ -17,18 +17,20 @@
"author": "Lossless GmbH",
"license": "UNLICENSED",
"dependencies": {
"@design.estate/dees-domtools": "^2.0.57",
"@design.estate/dees-element": "^2.0.34",
"@design.estate/dees-domtools": "^2.3.7",
"@design.estate/dees-element": "^2.1.5",
"@push.rocks/smartdelay": "^3.0.5",
"lit": "^3.1.3"
"lit": "^3.3.2"
},
"devDependencies": {
"@api.global/typedserver": "^3.0.29",
"@git.zone/tsbuild": "^2.1.72",
"@git.zone/tsbundle": "^2.0.15",
"@git.zone/tsrun": "^1.2.44",
"@git.zone/tswatch": "^2.0.23",
"@push.rocks/projectinfo": "^5.0.2"
"@api.global/typedserver": "^8.1.0",
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.4",
"@git.zone/tswatch": "^2.3.13",
"@push.rocks/projectinfo": "^5.0.2",
"@types/node": "^25.0.3"
},
"files": [
"ts/**/*",

7563
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,169 @@
# Project Hints and Findings
## Section-based Configuration API (2025-12-27)
### Overview
Refactored `setupWccTools` to accept a section-based configuration object instead of separate elements/pages arguments. This allows multiple custom sections with filtering, sorting, and collapsible headers.
### New API
```typescript
import * as deesWccTools from '@design.estate/dees-wcctools';
deesWccTools.setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Elements',
type: 'elements',
items: elements,
filter: (name, item) => !name.startsWith('internal-'),
sort: ([a], [b]) => a.localeCompare(b),
},
{
name: 'Views',
type: 'elements',
items: elements,
filter: (name, item) => name.startsWith('view-'),
icon: 'web',
collapsed: true, // Start collapsed
},
],
});
```
### Section Properties
- `name`: Display name for the section header
- `type`: `'elements'` (shows demos) or `'pages'` (renders directly)
- `items`: Record of items (element classes or page factories)
- `filter`: Optional `(name, item) => boolean` to filter items
- `sort`: Optional `([name, item], [name, item]) => number` for ordering
- `icon`: Optional Material icon name for section header
- `collapsed`: Optional boolean to start section collapsed
### Backwards Compatibility
Legacy format is still supported:
```typescript
deesWccTools.setupWccTools(elements, pages); // Still works
```
### URL Routing
Changed from `/wcctools-route/:itemType/:itemName/...` to `/wcctools-route/:sectionName/:itemName/...`
Section names are URL-encoded. Legacy routes (`element`/`page` as section name) still work for backwards compatibility.
### Files Changed
- `ts_web/wcctools.interfaces.ts` - New types: `IWccSection`, `IWccConfig`
- `ts_web/index.ts` - Updated `setupWccTools` with backwards compat detection
- `ts_web/elements/wcc-dashboard.ts` - Uses sections, updated routing
- `ts_web/elements/wcc-sidebar.ts` - Dynamic section rendering
---
## UI Redesign with Shadcn-like Styles (2025-06-27)
### Changes Made
Updated the WCC Dashboard UI components (properties and sidebar) to use shadcn-like design patterns:
1. **Color System**: Implemented CSS variables for theming:
- `--background`, `--foreground`, `--card`, `--primary`, `--secondary`
- `--muted`, `--accent`, `--border`, `--input`, `--ring`
- Consistent dark theme with subtle borders and proper contrast
- Dynamic theme switching between light and dark modes
2. **Properties Panel Improvements (Updated)**:
- Changed from fixed 3-column grid to flexible flexbox layout
- Properties now wrap and use space more efficiently
- Added rounded corners (using --radius-md) and better spacing
- Property items use flexbox with min-width for responsive layout
- Property labels now show as styled headers with type info
- Form controls updated with shadcn-style focus states and transitions
- Complex properties (Objects/Arrays) show "Edit" button
- Advanced JSON editor appears above properties panel when editing complex types
- Dynamic height adjustment (50px when editor is open, 120px normally)
3. **Sidebar Styling**:
- Updated with consistent color scheme
- Added rounded corners to menu items
- Improved hover states with smooth transitions
- Better typography with proper font weights
4. **Advanced Property Editor**:
- JSON editor for complex types (Objects and Arrays)
- Monaco-style monospace font for code editing
- Live updates to element properties
- Positioned above the properties panel with smooth transitions
5. **Theme and Viewport Selectors (New)**:
- Redesigned buttons with flexbox layout for better icon/text alignment
- Added hover effects with transform and shadow
- Smooth transitions on all interactive elements
- Selected state uses primary color variables
- Icons reduced in size for better balance
6. **Form Controls (New)**:
- Input fields and selects now have:
- Rounded corners (--radius-sm)
- Consistent padding (0.5rem 0.75rem)
- Focus states with ring effect using box-shadow
- Smooth transition animations
- Checkboxes use accent-color for theming
### Technical Details
- Uses system font stack ('Inter' preferred) for better native appearance
- Subtle borders with CSS variables for consistency
- Consistent spacing using rem units
- Smooth transitions (0.2s ease) for interactive elements
- Custom scrollbar styling for better visual integration
- Grid layout with 1px gaps creating subtle dividers
- Warning display with backdrop blur and rounded corners
## Advanced Complex Properties Editor (2025-06-27)
### Overview
Implemented an advanced editor for complex properties (Arrays and Objects) that appears between the wcc-properties panel and frame when activated.
### Features
1. **Dynamic Layout**: Frame shrinks by 300px from bottom when editor opens
2. **Multiple Editors**: Can edit multiple properties simultaneously side by side
3. **JSON Editor**:
- Monospace font for code editing
- Tab key support for indentation
- Syntax validation with error messages
- Live preview of changes
4. **Smooth Transitions**: Animated opening/closing with 0.3s ease
5. **Error Handling**: Invalid JSON shows clear error messages that disappear on typing
6. **Close All Button**: Single button to close all open editors at once
### Technical Implementation (Updated)
- **State Management**: Changed from single editor to array of editors with unique IDs
- **Editor Structure**: Each editor instance contains:
- `id`: Unique identifier (`propertyName-timestamp`)
- `name`: Property name
- `value`: Original value
- `element`: Reference to the element
- `editorValue`: Current JSON string
- `editorError`: Validation error message
- **Event System**: Uses custom 'editorStateChanged' event to communicate with parent dashboard
- **Dynamic Styling**: wcc-frame's bottom position changes from 100px to 400px when any editor is open
- **Property Types**: Object and Array properties show "Edit Object/Array" button instead of inline controls
### User Flow
1. Click "Edit Object/Array" button on complex property
2. Editor slides up between properties panel and frame
3. Click additional "Edit" buttons to open more properties side by side
4. Each editor can be saved/cancelled independently
5. "Close All" button dismisses all editors at once
6. Frame automatically resizes back when all editors are closed
### Layout Details
- **Container**: Flexbox with horizontal scrolling when multiple editors overflow
- **Editor Width**: Min 300px, max 500px, flexible between
- **Scrollbar**: Custom styled thin scrollbar for horizontal overflow
- **Header Bar**: Fixed top bar with "Property Editors" title and "Close All" button
## Properties Panel Element Detection Issue (Fixed)
### Problem

482
readme.md
View File

@@ -1,23 +1,32 @@
# @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 customizable sidebar sections
- 🔧 **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
- 📂 **Section-based Organization** — Group components into custom sections with filtering and sorting
- 🚀 **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 +44,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`
@@ -49,29 +58,22 @@ export class MyButton extends DeesElement {
padding: 8px 16px;
border-radius: 4px;
border: none;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
}
button.primary {
background: #007bff;
background: #3b82f6;
color: white;
}
button.secondary {
background: #6c757d;
background: #6b7280;
color: white;
}
button:hover {
opacity: 0.9;
}
`
];
public render() {
return html`
<button class="${this.variant}">
${this.label}
</button>
<button class="${this.variant}">${this.label}</button>
`;
}
}
@@ -85,33 +87,32 @@ 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 * as elements from './components/index.js';
import * as views from './views/index.js';
import * as pages from './pages/index.js';
// Define elements for the catalogue
const elements = {
'my-button': MyButton,
'my-card': MyCard,
};
// Optionally define pages
const pages = {
'home': () => html`
<div style="padding: 20px;">
<h1>Welcome to My Component Library</h1>
<p>Browse components using the sidebar.</p>
</div>
`,
'getting-started': () => html`
<div style="padding: 20px;">
<h2>Getting Started</h2>
<p>Installation and usage instructions...</p>
</div>
`,
};
// Initialize the catalogue
setupWccTools(elements, pages);
// Initialize with sections-based configuration
setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Views',
type: 'elements',
items: views,
icon: 'web',
},
{
name: 'Elements',
type: 'elements',
items: elements,
sort: ([a], [b]) => a.localeCompare(b),
},
],
});
```
### 3. Create an HTML Entry Point
@@ -129,91 +130,183 @@ setupWccTools(elements, pages);
</html>
```
## 📂 Sections Configuration
The sections-based API gives you full control over how components are organized in the sidebar.
### Section Properties
| Property | Type | Description |
|----------|------|-------------|
| `name` | `string` | Display name for the section header |
| `type` | `'elements' \| 'pages'` | How items render (`elements` show demos, `pages` render directly) |
| `items` | `Record<string, any>` | Object containing element classes or page factories |
| `filter` | `(name, item) => boolean` | Optional filter function to include/exclude items |
| `sort` | `([a, itemA], [b, itemB]) => number` | Optional sort function for ordering items |
| `icon` | `string` | Optional Material Symbols icon name |
| `collapsed` | `boolean` | Start section collapsed (default: `false`) |
### Advanced Example
```typescript
import { setupWccTools } from '@design.estate/dees-wcctools';
import * as allElements from './elements/index.js';
import * as pages from './pages/index.js';
setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Form Controls',
type: 'elements',
items: allElements,
icon: 'edit_note',
filter: (name) => name.startsWith('form-') || name.includes('input'),
sort: ([a], [b]) => a.localeCompare(b),
},
{
name: 'Layout',
type: 'elements',
items: allElements,
icon: 'dashboard',
filter: (name) => name.startsWith('layout-') || name.startsWith('grid-'),
},
{
name: 'Legacy',
type: 'elements',
items: allElements,
filter: (name) => name.startsWith('legacy-'),
collapsed: true, // Start collapsed
},
],
});
```
### Legacy API (Still Supported)
```typescript
// The old format still works for simple use cases
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
public render() {
return html`
<div class="${this.goBright ? 'light-theme' : 'dark-theme'}">
<!-- Your component content -->
</div>
`;
}
```
Components automatically adapt to light/dark themes. Use CSS custom properties with the theme manager:
Or use CSS custom properties:
```typescript
import { cssManager } from '@design.estate/dees-element';
public static styles = [
css`
:host {
color: ${cssManager.bdTheme('#000', '#fff')};
background: ${cssManager.bdTheme('#fff', '#000')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
`
];
```
### 🧪 Advanced Demo Tools
### 🎬 Screen Recording
The demo tools provide enhanced testing capabilities:
Record component demos directly from the catalogue:
- **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>
`;
}
```
### 🎭 Container Queries Support
### 🎭 Multiple Demos
Components can respond to their container size:
Components can expose multiple demo variations:
```typescript
@customElement('my-button')
export class MyButton extends DeesElement {
public static demo = [
() => html`<my-button variant="primary">Primary</my-button>`,
() => html`<my-button variant="secondary">Secondary</my-button>`,
() => html`<my-button variant="danger">Danger</my-button>`,
];
}
```
Each demo appears as a numbered item in an expandable folder in the sidebar.
### ⏳ Async Demos
Return a `Promise` from `demo` for async setup:
```typescript
public static demo = async () => {
const data = await fetchSomeData();
return html`<my-component .data=${data}></my-component>`;
};
```
### 🎯 Container Queries
Components can respond to their container size using the `wccToolsViewport` container:
```typescript
public static styles = [
@@ -223,7 +316,7 @@ public static styles = [
flex-direction: row;
}
}
@container wccToolsViewport (max-width: 767px) {
:host {
flex-direction: column;
@@ -236,157 +329,154 @@ 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
1. Components must expose a static `demo` property returning a Lit template (or array of templates)
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
// ✅ Static demo property (single or array)
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
/wcctools-route/:sectionName/:itemName/:demoIndex/:viewport/:theme
Example:
/wcctools-route/element/my-button/desktop/dark
/wcctools-route/page/home/tablet/bright
```
## Development Workflow
### Build and Watch
```json
{
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element",
"watch": "tswatch element",
"serve": "http-server ./dist"
}
}
```
### Project Structure
```
my-components/
├── src/
│ ├── components/
│ │ ├── my-button.ts
│ │ └── my-card.ts
│ └── catalogue.ts
├── dist/
├── index.html
└── 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; }
`)
];
Examples:
/wcctools-route/Elements/my-button/0/desktop/dark
/wcctools-route/Views/view-dashboard/0/tablet/bright
/wcctools-route/Pages/home/0/desktop/dark
```
## API Reference
### setupWccTools(elements, pages?)
Initialize the WCC Tools dashboard.
### `setupWccTools(config)`
- `elements`: Object mapping element names to element classes
- `pages`: Optional object mapping page names to template functions
Initialize the WCC Tools dashboard with sections configuration.
```typescript
interface IWccSection {
name: string;
type: 'elements' | 'pages';
items: Record<string, any>;
filter?: (name: string, item: any) => boolean;
sort?: (a: [string, any], b: [string, any]) => number;
icon?: string;
collapsed?: boolean;
}
interface IWccConfig {
sections: IWccSection[];
}
setupWccTools(config: IWccConfig): void;
// Legacy (still supported)
setupWccTools(elements: Record<string, any>, pages?: Record<string, TTemplateFactory>): void;
```
### `DeesDemoWrapper`
### 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
| 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
```
my-component-library/
├── src/
│ ├── elements/ # UI components
│ │ ├── my-button.ts
│ │ ├── my-card.ts
│ │ └── index.ts
│ ├── views/ # Full-page layouts
│ │ ├── view-dashboard.ts
│ │ └── index.ts
│ ├── pages/ # Documentation pages
│ │ ├── home.ts
│ │ └── index.ts
│ └── catalogue.ts # WCC Tools setup
├── html/
│ └── index.html
└── package.json
```
## 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.
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

@@ -97,4 +97,11 @@ Properties panel was overwriting values set by demo functions
2. This prevents browser from firing input events during initialization
3. Added proper number parsing for number inputs
4. Increased initial wait to 200ms for demo wrappers to complete
5. Simplified select element handling to use property binding
5. Simplified select element handling to use property binding
# Async Demo Support (IN PROGRESS)
## Tasks
- [ ] Allow dashboard-selected items to return Promise-based TemplateResults
- [ ] Await async demos/pages before rendering them into the viewport
- [ ] Add regression test covering async demo usage
- [ ] Document async demo pattern in README and verify with pnpm scripts

View File

@@ -4,3 +4,10 @@ export * from './test-complextypes.js';
export * from './test-withwrapper.js';
export * from './test-edgecases.js';
export * from './test-nested.js';
// Grouped elements to demo the demoGroup feature
export * from './test-button-primary.js';
export * from './test-button-secondary.js';
export * from './test-button-danger.js';
export * from './test-input-text.js';
export * from './test-input-checkbox.js';

View File

@@ -0,0 +1,45 @@
import {
DeesElement,
customElement,
html,
property,
css,
} from '@design.estate/dees-element';
@customElement('test-button-danger')
export class TestButtonDanger extends DeesElement {
// Same group as other buttons
public static demoGroup = 'Buttons';
public static demo = () => html`
<test-button-danger>Delete</test-button-danger>
`;
@property({ type: String })
accessor label: string = 'Delete';
public static styles = [
css`
:host {
display: inline-block;
}
button {
background: #ef4444;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #dc2626;
}
`,
];
public render() {
return html`<button><slot>${this.label}</slot></button>`;
}
}

View File

@@ -0,0 +1,45 @@
import {
DeesElement,
customElement,
html,
property,
css,
} from '@design.estate/dees-element';
@customElement('test-button-primary')
export class TestButtonPrimary extends DeesElement {
// This groups the element with other "Buttons" in the sidebar
public static demoGroup = 'Buttons';
public static demo = () => html`
<test-button-primary>Click Me</test-button-primary>
`;
@property({ type: String })
accessor label: string = 'Button';
public static styles = [
css`
:host {
display: inline-block;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #2563eb;
}
`,
];
public render() {
return html`<button><slot>${this.label}</slot></button>`;
}
}

View File

@@ -0,0 +1,45 @@
import {
DeesElement,
customElement,
html,
property,
css,
} from '@design.estate/dees-element';
@customElement('test-button-secondary')
export class TestButtonSecondary extends DeesElement {
// Same group as test-button-primary - they'll appear together
public static demoGroup = 'Buttons';
public static demo = () => html`
<test-button-secondary>Secondary Action</test-button-secondary>
`;
@property({ type: String })
accessor label: string = 'Button';
public static styles = [
css`
:host {
display: inline-block;
}
button {
background: transparent;
color: #3b82f6;
border: 1px solid #3b82f6;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: rgba(59, 130, 246, 0.1);
}
`,
];
public render() {
return html`<button><slot>${this.label}</slot></button>`;
}
}

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

@@ -18,38 +18,42 @@ enum ETestEnum {
@customElement('test-demoelement')
export class TestDemoelement extends DeesElement {
public static demo = () => html`<test-demoelement>This is a slot text</test-demoelement>`;
public static demo = [
() => html`<test-demoelement>This is demo 1</test-demoelement>`,
() => html`<test-demoelement>This is demo 2</test-demoelement>`,
() => html`<test-demoelement>This is demo 2</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

@@ -0,0 +1,68 @@
import {
DeesElement,
customElement,
html,
property,
css,
} from '@design.estate/dees-element';
@customElement('test-input-checkbox')
export class TestInputCheckbox extends DeesElement {
// Same group as test-input-text
public static demoGroup = 'Inputs';
public static demo = () => html`
<test-input-checkbox label="Accept terms and conditions"></test-input-checkbox>
`;
@property({ type: String })
accessor label: string = 'Checkbox';
@property({ type: Boolean })
accessor checked: boolean = false;
public static styles = [
css`
:host {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox {
width: 18px;
height: 18px;
border: 1px solid #333;
border-radius: 4px;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.checkbox.checked {
background: #3b82f6;
border-color: #3b82f6;
}
.checkbox.checked::after {
content: '✓';
color: white;
font-size: 12px;
}
.label {
color: #e5e5e5;
font-size: 14px;
}
`,
];
public render() {
return html`
<div
class="checkbox ${this.checked ? 'checked' : ''}"
@click=${() => this.checked = !this.checked}
></div>
<span class="label">${this.label}</span>
`;
}
}

View File

@@ -0,0 +1,59 @@
import {
DeesElement,
customElement,
html,
property,
css,
} from '@design.estate/dees-element';
@customElement('test-input-text')
export class TestInputText extends DeesElement {
// Different group - "Inputs"
public static demoGroup = 'Inputs';
public static demo = () => html`
<test-input-text placeholder="Enter text..."></test-input-text>
`;
@property({ type: String })
accessor placeholder: string = '';
@property({ type: String })
accessor value: string = '';
public static styles = [
css`
:host {
display: inline-block;
}
input {
background: #1a1a1a;
color: #e5e5e5;
border: 1px solid #333;
padding: 10px 14px;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
min-width: 200px;
}
input:focus {
border-color: #3b82f6;
}
input::placeholder {
color: #666;
}
`,
];
public render() {
return html`
<input
type="text"
.placeholder=${this.placeholder}
.value=${this.value}
@input=${(e: Event) => this.value = (e.target as HTMLInputElement).value}
/>
`;
}
}

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

@@ -0,0 +1,22 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { resolveTemplateFactory } from '../ts_web/elements/wcctools.helpers.js';
import { html } from 'lit';
const waitFor = (durationMs: number) => new Promise(resolve => setTimeout(resolve, durationMs));
tap.test('resolveTemplateFactory returns sync TemplateResult', async () => {
const template = html`<p>sync demo</p>`;
const resolvedTemplate = await resolveTemplateFactory(() => template);
expect(resolvedTemplate).toEqual(template);
});
tap.test('resolveTemplateFactory awaits async TemplateResult', async () => {
const template = html`<p>async demo</p>`;
const resolvedTemplate = await resolveTemplateFactory(async () => {
await waitFor(5);
return template;
});
expect(resolvedTemplate).toEqual(template);
});
export default tap.start();

3
test/views/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './view-dashboard.js';
export * from './view-settings.js';
export * from './view-empty-state.js';

View File

@@ -0,0 +1,286 @@
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
@customElement('view-dashboard')
export class ViewDashboard extends DeesElement {
public static demo = () => html`<view-dashboard></view-dashboard>`;
@property()
accessor title: string = 'Dashboard';
@property({ type: Number })
accessor notificationCount: number = 3;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
min-height: 100%;
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.dashboard {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr;
min-height: 100vh;
}
.header {
grid-column: 1 / -1;
background: ${cssManager.bdTheme('#fff', '#111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.header h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.notification-badge {
position: relative;
padding: 8px;
cursor: pointer;
}
.notification-badge::after {
content: attr(data-count);
position: absolute;
top: 2px;
right: 2px;
background: #ef4444;
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 600;
}
.sidebar {
background: ${cssManager.bdTheme('#fff', '#0f0f0f')};
border-right: 1px solid ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
padding: 16px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
color: ${cssManager.bdTheme('#666', '#999')};
cursor: pointer;
transition: all 0.15s ease;
}
.nav-item:hover {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
color: ${cssManager.bdTheme('#1a1a1a', '#fff')};
}
.nav-item.active {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.15)')};
color: #3b82f6;
border-right: 3px solid #3b82f6;
}
.content {
padding: 24px;
overflow-y: auto;
}
.content-header {
margin-bottom: 24px;
}
.content-header h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
}
.content-header p {
color: ${cssManager.bdTheme('#666', '#888')};
margin: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: ${cssManager.bdTheme('#fff', '#111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
border-radius: 8px;
padding: 20px;
}
.stat-card .label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#666', '#888')};
margin-bottom: 8px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
}
.stat-card .change {
font-size: 12px;
color: #22c55e;
margin-top: 4px;
}
.stat-card .change.negative {
color: #ef4444;
}
.recent-activity {
background: ${cssManager.bdTheme('#fff', '#111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
border-radius: 8px;
padding: 20px;
}
.recent-activity h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px 0;
}
.activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.activity-content {
flex: 1;
}
.activity-content .title {
font-weight: 500;
margin-bottom: 2px;
}
.activity-content .time {
font-size: 12px;
color: ${cssManager.bdTheme('#888', '#666')};
}
`,
];
public render() {
return html`
<div class="dashboard">
<header class="header">
<h1>${this.title}</h1>
<div class="header-actions">
<div class="notification-badge" data-count="${this.notificationCount}">
<span>Notifications</span>
</div>
<span>User</span>
</div>
</header>
<nav class="sidebar">
<div class="nav-item active">Overview</div>
<div class="nav-item">Analytics</div>
<div class="nav-item">Projects</div>
<div class="nav-item">Team</div>
<div class="nav-item">Settings</div>
</nav>
<main class="content">
<div class="content-header">
<h2>Overview</h2>
<p>Welcome back! Here's what's happening with your projects.</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="label">Total Revenue</div>
<div class="value">$45,231</div>
<div class="change">+20.1% from last month</div>
</div>
<div class="stat-card">
<div class="label">Active Users</div>
<div class="value">2,350</div>
<div class="change">+180 new users</div>
</div>
<div class="stat-card">
<div class="label">Pending Tasks</div>
<div class="value">12</div>
<div class="change negative">-3 from yesterday</div>
</div>
<div class="stat-card">
<div class="label">Completion Rate</div>
<div class="value">94.2%</div>
<div class="change">+2.4% this week</div>
</div>
</div>
<div class="recent-activity">
<h3>Recent Activity</h3>
<div class="activity-item">
<div class="activity-icon">+</div>
<div class="activity-content">
<div class="title">New project created</div>
<div class="time">2 minutes ago</div>
</div>
</div>
<div class="activity-item">
<div class="activity-icon">U</div>
<div class="activity-content">
<div class="title">User settings updated</div>
<div class="time">1 hour ago</div>
</div>
</div>
<div class="activity-item">
<div class="activity-icon">D</div>
<div class="activity-content">
<div class="title">Deployment completed</div>
<div class="time">3 hours ago</div>
</div>
</div>
</div>
</main>
</div>
`;
}
}

View File

@@ -0,0 +1,262 @@
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
@customElement('view-empty-state')
export class ViewEmptyState extends DeesElement {
public static demo = [
() => html`<view-empty-state></view-empty-state>`,
() => html`<view-empty-state variant="no-results"></view-empty-state>`,
() => html`<view-empty-state variant="error"></view-empty-state>`,
];
@property()
accessor variant: 'empty' | 'no-results' | 'error' = 'empty';
@property()
accessor title: string = '';
@property()
accessor description: string = '';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding: 40px;
box-sizing: border-box;
}
.empty-state {
text-align: center;
max-width: 400px;
}
.icon-container {
width: 120px;
height: 120px;
margin: 0 auto 24px;
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon {
font-size: 48px;
opacity: 0.5;
}
.icon.error {
color: #ef4444;
opacity: 1;
}
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
}
p {
color: ${cssManager.bdTheme('#666', '#888')};
margin: 0 0 24px 0;
line-height: 1.6;
}
.actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.btn-secondary {
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#ddd', '#222')};
}
.illustration {
margin-bottom: 24px;
}
.illustration svg {
width: 200px;
height: 150px;
}
.folder-icon {
width: 80px;
height: 80px;
margin: 0 auto 16px;
position: relative;
}
.folder-back {
position: absolute;
width: 80px;
height: 60px;
background: ${cssManager.bdTheme('#ddd', '#333')};
border-radius: 4px 4px 8px 8px;
bottom: 0;
}
.folder-front {
position: absolute;
width: 80px;
height: 50px;
background: ${cssManager.bdTheme('#e5e5e5', '#444')};
border-radius: 0 4px 8px 8px;
bottom: 0;
}
.folder-tab {
position: absolute;
width: 30px;
height: 12px;
background: ${cssManager.bdTheme('#ddd', '#333')};
border-radius: 4px 4px 0 0;
top: 0;
left: 0;
}
.search-icon {
width: 60px;
height: 60px;
border: 4px solid ${cssManager.bdTheme('#ccc', '#444')};
border-radius: 50%;
position: relative;
margin: 0 auto 16px;
}
.search-icon::after {
content: '';
position: absolute;
width: 4px;
height: 20px;
background: ${cssManager.bdTheme('#ccc', '#444')};
border-radius: 2px;
bottom: -18px;
right: -8px;
transform: rotate(-45deg);
}
.error-icon {
width: 80px;
height: 80px;
border: 4px solid #ef4444;
border-radius: 50%;
position: relative;
margin: 0 auto 16px;
display: flex;
align-items: center;
justify-content: center;
}
.error-icon::before {
content: '!';
font-size: 40px;
font-weight: 700;
color: #ef4444;
}
`,
];
private getContent() {
switch (this.variant) {
case 'no-results':
return {
title: this.title || 'No results found',
description:
this.description ||
"We couldn't find what you're looking for. Try adjusting your search or filters.",
icon: 'search',
primaryAction: 'Clear Filters',
secondaryAction: 'Go Back',
};
case 'error':
return {
title: this.title || 'Something went wrong',
description:
this.description ||
"We're having trouble loading this page. Please try again or contact support if the problem persists.",
icon: 'error',
primaryAction: 'Try Again',
secondaryAction: 'Contact Support',
};
default:
return {
title: this.title || 'No items yet',
description:
this.description ||
"Get started by creating your first item. It only takes a few seconds.",
icon: 'folder',
primaryAction: 'Create New',
secondaryAction: 'Learn More',
};
}
}
public render() {
const content = this.getContent();
return html`
<div class="empty-state">
${this.renderIcon(content.icon)}
<h2>${content.title}</h2>
<p>${content.description}</p>
<div class="actions">
<button class="btn btn-primary">${content.primaryAction}</button>
<button class="btn btn-secondary">${content.secondaryAction}</button>
</div>
</div>
`;
}
private renderIcon(type: string) {
switch (type) {
case 'search':
return html`<div class="search-icon"></div>`;
case 'error':
return html`<div class="error-icon"></div>`;
default:
return html`
<div class="folder-icon">
<div class="folder-tab"></div>
<div class="folder-back"></div>
<div class="folder-front"></div>
</div>
`;
}
}
}

436
test/views/view-settings.ts Normal file
View File

@@ -0,0 +1,436 @@
import { DeesElement, customElement, html, css, property, cssManager } from '@design.estate/dees-element';
@customElement('view-settings')
export class ViewSettings extends DeesElement {
public static demo = [
() => html`<view-settings></view-settings>`,
() => html`<view-settings activeTab="notifications"></view-settings>`,
() => html`<view-settings activeTab="security"></view-settings>`,
];
@property()
accessor activeTab: 'profile' | 'notifications' | 'security' = 'profile';
@property()
accessor userName: string = 'John Doe';
@property()
accessor userEmail: string = 'john@example.com';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
min-height: 100%;
background: ${cssManager.bdTheme('#f5f5f5', '#0a0a0a')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
.settings-layout {
max-width: 1000px;
margin: 0 auto;
padding: 40px 24px;
}
.settings-header {
margin-bottom: 32px;
}
.settings-header h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px 0;
}
.settings-header p {
color: ${cssManager.bdTheme('#666', '#888')};
margin: 0;
}
.settings-content {
display: grid;
grid-template-columns: 200px 1fr;
gap: 32px;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
padding: 12px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('#666', '#999')};
}
.nav-item:hover {
background: ${cssManager.bdTheme('#e5e5e5', '#1a1a1a')};
color: ${cssManager.bdTheme('#1a1a1a', '#fff')};
}
.nav-item.active {
background: ${cssManager.bdTheme('#1a1a1a', '#3b82f6')};
color: #fff;
}
.settings-panel {
background: ${cssManager.bdTheme('#fff', '#111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
border-radius: 12px;
padding: 24px;
}
.panel-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
}
.panel-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
}
.panel-header p {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#888')};
margin: 0;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 10px 14px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
border-radius: 6px;
font-size: 14px;
background: ${cssManager.bdTheme('#fff', '#0a0a0a')};
color: ${cssManager.bdTheme('#1a1a1a', '#e5e5e5')};
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid ${cssManager.bdTheme('#f0f0f0', '#1a1a1a')};
}
.toggle-group:last-child {
border-bottom: none;
}
.toggle-label {
display: flex;
flex-direction: column;
gap: 4px;
}
.toggle-label .title {
font-weight: 500;
}
.toggle-label .description {
font-size: 13px;
color: ${cssManager.bdTheme('#666', '#888')};
}
.toggle {
width: 44px;
height: 24px;
background: ${cssManager.bdTheme('#ddd', '#333')};
border-radius: 12px;
position: relative;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle.active {
background: #3b82f6;
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.toggle.active::after {
transform: translateX(20px);
}
.button-group {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#222')};
}
.btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: transparent;
color: ${cssManager.bdTheme('#666', '#999')};
border: 1px solid ${cssManager.bdTheme('#ddd', '#333')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
}
.btn-danger {
background: #ef4444;
color: white;
border: none;
}
.btn-danger:hover {
background: #dc2626;
}
.security-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: ${cssManager.bdTheme('#f9f9f9', '#0a0a0a')};
border-radius: 8px;
margin-bottom: 12px;
}
.security-item .info {
display: flex;
flex-direction: column;
gap: 4px;
}
.security-item .title {
font-weight: 500;
}
.security-item .status {
font-size: 13px;
color: ${cssManager.bdTheme('#666', '#888')};
}
.security-item .status.enabled {
color: #22c55e;
}
`,
];
private handleTabClick(tab: 'profile' | 'notifications' | 'security') {
this.activeTab = tab;
}
public render() {
return html`
<div class="settings-layout">
<div class="settings-header">
<h1>Settings</h1>
<p>Manage your account settings and preferences</p>
</div>
<div class="settings-content">
<nav class="settings-nav">
<div
class="nav-item ${this.activeTab === 'profile' ? 'active' : ''}"
@click=${() => this.handleTabClick('profile')}
>
Profile
</div>
<div
class="nav-item ${this.activeTab === 'notifications' ? 'active' : ''}"
@click=${() => this.handleTabClick('notifications')}
>
Notifications
</div>
<div
class="nav-item ${this.activeTab === 'security' ? 'active' : ''}"
@click=${() => this.handleTabClick('security')}
>
Security
</div>
</nav>
<div class="settings-panel">
${this.activeTab === 'profile' ? this.renderProfile() : null}
${this.activeTab === 'notifications' ? this.renderNotifications() : null}
${this.activeTab === 'security' ? this.renderSecurity() : null}
</div>
</div>
</div>
`;
}
private renderProfile() {
return html`
<div class="panel-header">
<h2>Profile Information</h2>
<p>Update your personal details and email address</p>
</div>
<div class="form-row">
<div class="form-group">
<label>First Name</label>
<input type="text" value="John" />
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" value="Doe" />
</div>
</div>
<div class="form-group">
<label>Email Address</label>
<input type="email" value="${this.userEmail}" />
</div>
<div class="form-group">
<label>Bio</label>
<input type="text" placeholder="Tell us about yourself..." />
</div>
<div class="button-group">
<button class="btn btn-primary">Save Changes</button>
<button class="btn btn-secondary">Cancel</button>
</div>
`;
}
private renderNotifications() {
return html`
<div class="panel-header">
<h2>Notification Preferences</h2>
<p>Choose what notifications you want to receive</p>
</div>
<div class="toggle-group">
<div class="toggle-label">
<span class="title">Email Notifications</span>
<span class="description">Receive email updates about your account activity</span>
</div>
<div class="toggle active"></div>
</div>
<div class="toggle-group">
<div class="toggle-label">
<span class="title">Push Notifications</span>
<span class="description">Receive push notifications on your device</span>
</div>
<div class="toggle active"></div>
</div>
<div class="toggle-group">
<div class="toggle-label">
<span class="title">Weekly Digest</span>
<span class="description">Get a weekly summary of your activity</span>
</div>
<div class="toggle"></div>
</div>
<div class="toggle-group">
<div class="toggle-label">
<span class="title">Marketing Emails</span>
<span class="description">Receive tips, updates, and promotions</span>
</div>
<div class="toggle"></div>
</div>
<div class="button-group">
<button class="btn btn-primary">Save Preferences</button>
</div>
`;
}
private renderSecurity() {
return html`
<div class="panel-header">
<h2>Security Settings</h2>
<p>Manage your password and security options</p>
</div>
<div class="security-item">
<div class="info">
<span class="title">Password</span>
<span class="status">Last changed 30 days ago</span>
</div>
<button class="btn btn-secondary">Change Password</button>
</div>
<div class="security-item">
<div class="info">
<span class="title">Two-Factor Authentication</span>
<span class="status enabled">Enabled</span>
</div>
<button class="btn btn-secondary">Manage</button>
</div>
<div class="security-item">
<div class="info">
<span class="title">Active Sessions</span>
<span class="status">3 devices</span>
</div>
<button class="btn btn-secondary">View All</button>
</div>
<div class="button-group">
<button class="btn btn-danger">Delete Account</button>
</div>
`;
}
}

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.0.101',
version: '3.6.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

@@ -0,0 +1,211 @@
import { DeesElement, property, html, customElement, type TemplateResult, state, css, cssManager } from '@design.estate/dees-element';
export interface IContextMenuItem {
name: string;
iconName?: string;
action: () => void | Promise<void>;
disabled?: boolean;
}
@customElement('wcc-contextmenu')
export class WccContextmenu extends DeesElement {
// Static method to show context menu at position
public static async show(
event: MouseEvent,
menuItems: IContextMenuItem[]
): Promise<void> {
event.preventDefault();
event.stopPropagation();
// Remove any existing context menu
const existing = document.querySelector('wcc-contextmenu');
if (existing) {
existing.remove();
}
const menu = new WccContextmenu();
menu.menuItems = menuItems;
menu.x = event.clientX;
menu.y = event.clientY;
document.body.appendChild(menu);
// Wait for render then adjust position if needed
await menu.updateComplete;
menu.adjustPosition();
}
@property({ type: Array })
accessor menuItems: IContextMenuItem[] = [];
@property({ type: Number })
accessor x: number = 0;
@property({ type: Number })
accessor y: number = 0;
@state()
accessor visible: boolean = false;
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
private boundHandleKeydown = this.handleKeydown.bind(this);
public static styles = [
css`
:host {
position: fixed;
z-index: 10000;
opacity: 0;
transform: scale(0.95) translateY(-5px);
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
:host(.visible) {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.menu {
min-width: 160px;
background: #0f0f0f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
padding: 4px 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 12px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #ccc;
cursor: pointer;
transition: background 0.1s ease;
user-select: none;
}
.menu-item:hover {
background: rgba(59, 130, 246, 0.15);
color: #fff;
}
.menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.menu-item .icon {
font-family: 'Material Symbols Outlined';
font-size: 16px;
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
opacity: 0.7;
}
.menu-item:hover .icon {
opacity: 1;
}
.menu-item .label {
flex: 1;
}
`
];
public render(): TemplateResult {
return html`
<div class="menu">
${this.menuItems.map(item => html`
<div
class="menu-item ${item.disabled ? 'disabled' : ''}"
@click=${() => this.handleItemClick(item)}
>
${item.iconName ? html`<span class="icon">${item.iconName}</span>` : null}
<span class="label">${item.name}</span>
</div>
`)}
</div>
`;
}
async connectedCallback() {
await super.connectedCallback();
// Delay adding listeners to avoid immediate close
requestAnimationFrame(() => {
document.addEventListener('click', this.boundHandleOutsideClick);
document.addEventListener('contextmenu', this.boundHandleOutsideClick);
document.addEventListener('keydown', this.boundHandleKeydown);
this.classList.add('visible');
});
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick);
document.removeEventListener('contextmenu', this.boundHandleOutsideClick);
document.removeEventListener('keydown', this.boundHandleKeydown);
}
private adjustPosition() {
const rect = this.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let x = this.x;
let y = this.y;
// Adjust if menu goes off right edge
if (x + rect.width > windowWidth - 10) {
x = windowWidth - rect.width - 10;
}
// Adjust if menu goes off bottom edge
if (y + rect.height > windowHeight - 10) {
y = windowHeight - rect.height - 10;
}
// Ensure not off left or top
if (x < 10) x = 10;
if (y < 10) y = 10;
this.style.left = `${x}px`;
this.style.top = `${y}px`;
}
private handleOutsideClick(e: Event) {
const path = e.composedPath();
if (!path.includes(this)) {
this.close();
}
}
private handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
this.close();
}
}
private async handleItemClick(item: IContextMenuItem) {
if (item.disabled) return;
await item.action();
this.close();
}
private close() {
this.classList.remove('visible');
setTimeout(() => this.remove(), 150);
}
}

View File

@@ -1,4 +1,7 @@
import { DeesElement, property, html, customElement, type TemplateResult, queryAsync, render, domtools } from '@design.estate/dees-element';
import { resolveTemplateFactory, getDemoAtIndex, getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
import type { TTemplateFactory } from './wcctools.helpers.js';
import type { IWccConfig, IWccSection, TElementType } from '../wcctools.interfaces.js';
import * as plugins from '../wcctools.plugins.js';
@@ -7,58 +10,108 @@ import './wcc-frame.js';
import './wcc-sidebar.js';
import './wcc-properties.js';
import { type TTheme } from './wcc-properties.js';
import { type TElementType } from './wcc-sidebar.js';
import { breakpoints } from '@design.estate/dees-domtools';
import { WccFrame } from './wcc-frame.js';
/**
* Get filtered and sorted items from a section
*/
export const getSectionItems = (section: IWccSection): Array<[string, any]> => {
let entries = Object.entries(section.items);
// Apply filter if provided
if (section.filter) {
entries = entries.filter(([name, item]) => section.filter(name, item));
}
// Apply sort if provided
if (section.sort) {
entries.sort(section.sort);
}
return entries;
};
@customElement('wcc-dashboard')
export class WccDashboard extends DeesElement {
@property()
public selectedType: TElementType;
accessor sections: IWccSection[] = [];
@property()
public selectedItemName: string;
accessor selectedSection: IWccSection | null = null;
@property()
public selectedItem: (() => TemplateResult) | DeesElement;
accessor selectedType: TElementType;
@property()
public selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
accessor selectedItemName: string;
@property()
public selectedTheme: TTheme = 'dark';
accessor selectedItem: TTemplateFactory | DeesElement;
@property({ type: Number })
accessor selectedDemoIndex: number = 0;
@property()
public pages: { [key: string]: () => TemplateResult } = {};
accessor selectedViewport: plugins.deesDomtools.breakpoints.TViewport = 'desktop';
@property()
public elements: { [key: string]: DeesElement } = {};
accessor selectedTheme: TTheme = 'dark';
@property()
public warning: string = null;
accessor searchQuery: string = '';
// Pinned items as Set of "sectionName::itemName"
@property({ attribute: false })
accessor pinnedItems: Set<string> = new Set();
// Sidebar width (resizable)
@property({ type: Number })
accessor sidebarWidth: number = 200;
// Derived from selectedViewport - no need for separate property
public get isNative(): boolean {
return this.selectedViewport === 'native';
}
@property()
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 },
pagesArg?: { [key: string]: () => TemplateResult }
) {
constructor(config?: IWccConfig) {
super();
if (elementsArg) {
this.elements = elementsArg;
console.log('got elements:');
console.log(this.elements);
if (config && config.sections) {
this.sections = config.sections;
console.log('got sections:', this.sections.map(s => s.name));
}
}
if (pagesArg) {
this.pages = pagesArg;
/**
* Find an item by name across all sections, returns the item and its section
*/
public findItemByName(name: string): { item: any; section: IWccSection } | null {
for (const section of this.sections) {
const entries = getSectionItems(section);
const found = entries.find(([itemName]) => itemName === name);
if (found) {
return { item: found[1], section };
}
}
return null;
}
/**
* Find a section by name (URL-decoded)
*/
public findSectionByName(name: string): IWccSection | null {
return this.sections.find(s => s.name === name) || null;
}
public render(): TemplateResult {
@@ -76,6 +129,10 @@ export class WccDashboard extends DeesElement {
<wcc-sidebar
.dashboardRef=${this}
.selectedItem=${this.selectedItem}
.searchQuery=${this.searchQuery}
.pinnedItems=${this.pinnedItems}
.sidebarWidth=${this.sidebarWidth}
.isNative=${this.isNative}
@selectedType=${(eventArg) => {
this.selectedType = eventArg.detail;
}}
@@ -85,6 +142,23 @@ export class WccDashboard extends DeesElement {
@selectedItem=${(eventArg) => {
this.selectedItem = eventArg.detail;
}}
@searchChanged=${(eventArg: CustomEvent) => {
this.searchQuery = eventArg.detail;
this.updateUrlWithScrollState();
}}
@pinnedChanged=${(eventArg: CustomEvent) => {
this.pinnedItems = eventArg.detail;
this.updateUrlWithScrollState();
}}
@widthChanged=${async (eventArg: CustomEvent) => {
this.sidebarWidth = eventArg.detail;
this.updateUrlWithScrollState();
const frame = await this.wccFrame;
if (frame) {
frame.sidebarWidth = eventArg.detail;
frame.requestUpdate();
}
}}
></wcc-sidebar>
<wcc-properties
.dashboardRef=${this}
@@ -92,6 +166,8 @@ export class WccDashboard extends DeesElement {
.selectedItem=${this.selectedItem}
.selectedViewport=${this.selectedViewport}
.selectedTheme=${this.selectedTheme}
.isNative=${this.isNative}
.sidebarWidth=${this.sidebarWidth}
@selectedViewport=${(eventArg) => {
this.selectedViewport = eventArg.detail;
this.scheduleUpdate();
@@ -99,8 +175,18 @@ export class WccDashboard extends DeesElement {
@selectedTheme=${(eventArg) => {
this.selectedTheme = eventArg.detail;
}}
@editorStateChanged=${async (eventArg) => {
const frame = await this.wccFrame;
if (frame) {
frame.advancedEditorOpen = eventArg.detail.isOpen;
frame.requestUpdate();
}
}}
@toggleNative=${() => {
this.toggleNative();
}}
></wcc-properties>
<wcc-frame id="wccFrame" viewport=${this.selectedViewport}>
<wcc-frame id="wccFrame" viewport=${this.selectedViewport} .isNative=${this.isNative} .sidebarWidth=${this.sidebarWidth}>
</wcc-frame>
`;
}
@@ -115,45 +201,195 @@ export class WccDashboard extends DeesElement {
}
}
public toggleNative() {
// Toggle between 'native' and 'desktop' viewports
this.selectedViewport = this.selectedViewport === 'native' ? 'desktop' : 'native';
this.buildUrl();
}
public async firstUpdated() {
this.domtools = await plugins.deesDomtools.DomTools.setupDomTools();
// Add ESC key handler for native mode
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this.isNative) {
this.selectedViewport = 'desktop';
this.buildUrl();
}
});
// Set up scroll listeners after DOM is ready
setTimeout(() => {
this.setupScrollListeners();
}, 500);
// New route format with section name
this.domtools.router.on(
'/wcctools-route/:itemType/:itemName/:viewport/:theme',
'/wcctools-route/:sectionName/:itemName/:demoIndex/:viewport/:theme',
async (routeInfo) => {
this.selectedType = routeInfo.params.itemType as TElementType;
const sectionName = decodeURIComponent(routeInfo.params.sectionName);
this.selectedSection = this.findSectionByName(sectionName);
this.selectedItemName = routeInfo.params.itemName;
this.selectedDemoIndex = parseInt(routeInfo.params.demoIndex) || 0;
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
this.selectedTheme = routeInfo.params.theme as TTheme;
if (routeInfo.params.itemType === 'element') {
this.selectedItem = this.elements[routeInfo.params.itemName];
} else if (routeInfo.params.itemType === 'page') {
this.selectedItem = this.pages[routeInfo.params.itemName];
if (this.selectedSection) {
// Find item within the section
const entries = getSectionItems(this.selectedSection);
const found = entries.find(([name]) => name === routeInfo.params.itemName);
if (found) {
this.selectedItem = found[1];
this.selectedType = this.selectedSection.type === 'elements' ? 'element' : 'page';
}
} else {
// Fallback: try legacy format (element/page as section name)
const legacyType = routeInfo.params.sectionName;
if (legacyType === 'element' || legacyType === 'page') {
this.selectedType = legacyType as TElementType;
// Find item in any matching section
const result = this.findItemByName(routeInfo.params.itemName);
if (result) {
this.selectedItem = result.item;
this.selectedSection = result.section;
}
}
}
// Restore scroll positions from query parameters
// Restore state from query parameters
if (routeInfo.queryParams) {
const search = routeInfo.queryParams.search;
const frameScrollY = routeInfo.queryParams.frameScrollY;
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
const pinned = routeInfo.queryParams.pinned;
const sidebarWidth = routeInfo.queryParams.sidebarWidth;
if (search) {
this.searchQuery = search;
} else {
this.searchQuery = '';
}
if (frameScrollY) {
this.frameScrollY = parseInt(frameScrollY);
}
if (sidebarScrollY) {
this.sidebarScrollY = parseInt(sidebarScrollY);
}
if (pinned) {
const newPinned = new Set(pinned.split(',').filter(Boolean));
// Only update if actually different to avoid update loops
if (this.pinnedItems.size !== newPinned.size ||
![...newPinned].every(k => this.pinnedItems.has(k))) {
this.pinnedItems = newPinned;
}
} else if (this.pinnedItems.size > 0) {
this.pinnedItems = new Set();
}
if (sidebarWidth) {
this.sidebarWidth = parseInt(sidebarWidth, 10);
}
// Apply scroll positions and update frame after a short delay to ensure DOM is ready
setTimeout(async () => {
this.applyScrollPositions();
// Ensure frame gets the sidebarWidth
const frame = await this.wccFrame;
if (frame) {
frame.sidebarWidth = this.sidebarWidth;
frame.requestUpdate();
}
}, 100);
} else {
this.searchQuery = '';
// Only clear if not already empty to avoid update loops
if (this.pinnedItems.size > 0) {
this.pinnedItems = new Set();
}
}
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
this.selectedTheme === 'bright'
? domtoolsInstance.themeManager.goBright()
: domtoolsInstance.themeManager.goDark();
}
);
// Legacy route without demo index (for backwards compatibility)
this.domtools.router.on(
'/wcctools-route/:sectionName/:itemName/:viewport/:theme',
async (routeInfo) => {
const sectionName = decodeURIComponent(routeInfo.params.sectionName);
this.selectedSection = this.findSectionByName(sectionName);
this.selectedItemName = routeInfo.params.itemName;
this.selectedDemoIndex = 0;
this.selectedViewport = routeInfo.params.viewport as breakpoints.TViewport;
this.selectedTheme = routeInfo.params.theme as TTheme;
if (this.selectedSection) {
const entries = getSectionItems(this.selectedSection);
const found = entries.find(([name]) => name === routeInfo.params.itemName);
if (found) {
this.selectedItem = found[1];
this.selectedType = this.selectedSection.type === 'elements' ? 'element' : 'page';
}
} else {
// Fallback: try legacy format
const legacyType = routeInfo.params.sectionName;
if (legacyType === 'element' || legacyType === 'page') {
this.selectedType = legacyType as TElementType;
const result = this.findItemByName(routeInfo.params.itemName);
if (result) {
this.selectedItem = result.item;
this.selectedSection = result.section;
}
}
}
// Restore state from query parameters
if (routeInfo.queryParams) {
const search = routeInfo.queryParams.search;
const frameScrollY = routeInfo.queryParams.frameScrollY;
const sidebarScrollY = routeInfo.queryParams.sidebarScrollY;
const pinned = routeInfo.queryParams.pinned;
const sidebarWidth = routeInfo.queryParams.sidebarWidth;
if (search) {
this.searchQuery = search;
} else {
this.searchQuery = '';
}
if (frameScrollY) {
this.frameScrollY = parseInt(frameScrollY);
}
if (sidebarScrollY) {
this.sidebarScrollY = parseInt(sidebarScrollY);
}
if (pinned) {
const newPinned = new Set(pinned.split(',').filter(Boolean));
// Only update if actually different to avoid update loops
if (this.pinnedItems.size !== newPinned.size ||
![...newPinned].every(k => this.pinnedItems.has(k))) {
this.pinnedItems = newPinned;
}
} else if (this.pinnedItems.size > 0) {
this.pinnedItems = new Set();
}
if (sidebarWidth) {
this.sidebarWidth = parseInt(sidebarWidth, 10);
}
// Apply scroll positions after a short delay to ensure DOM is ready
setTimeout(() => {
this.applyScrollPositions();
}, 100);
} else {
this.searchQuery = '';
// Only clear if not already empty to avoid update loops
if (this.pinnedItems.size > 0) {
this.pinnedItems = new Set();
}
}
const domtoolsInstance = await plugins.deesDomtools.elementBasic.setup();
this.selectedTheme === 'bright'
? domtoolsInstance.themeManager.goBright()
@@ -175,7 +411,9 @@ export class WccDashboard extends DeesElement {
if (typeof this.selectedItem === 'function') {
console.log('slotting page.');
const viewport = await wccFrame.getViewportElement();
render(this.selectedItem(), viewport);
const pageFactory = this.selectedItem as TTemplateFactory;
const pageTemplate = await resolveTemplateFactory(pageFactory);
render(pageTemplate, viewport);
console.log('rendered page.');
} else {
console.error('The selected item looks strange:');
@@ -188,32 +426,60 @@ export class WccDashboard extends DeesElement {
this.setWarning(`component ${anonItem.name} does not expose a demo property.`);
return;
}
if (!(typeof anonItem.demo === 'function')) {
// Support both single demo (function) and multiple demos (array)
const isArray = Array.isArray(anonItem.demo);
const isFunction = typeof anonItem.demo === 'function';
if (!isArray && !isFunction) {
this.setWarning(
`component ${anonItem.name} has demo property, but it is not of type function`
`component ${anonItem.name} has demo property, but it is not a function or array of functions`
);
return;
}
// Get the specific demo to render
const demoFactory = getDemoAtIndex(anonItem.demo, this.selectedDemoIndex);
if (!demoFactory) {
this.setWarning(
`component ${anonItem.name} does not have a demo at index ${this.selectedDemoIndex + 1}`
);
return;
}
this.setWarning(null);
const viewport = await wccFrame.getViewportElement();
render(anonItem.demo(), viewport);;
const demoTemplate = await resolveTemplateFactory(demoFactory);
render(demoTemplate, viewport);
}
}
public buildUrl() {
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedViewport}/${this.selectedTheme}`;
const sectionName = this.selectedSection
? encodeURIComponent(this.selectedSection.name)
: this.selectedType; // Fallback for legacy
const baseUrl = `/wcctools-route/${sectionName}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
const queryParams = new URLSearchParams();
if (this.searchQuery) {
queryParams.set('search', this.searchQuery);
}
if (this.frameScrollY > 0) {
queryParams.set('frameScrollY', this.frameScrollY.toString());
}
if (this.sidebarScrollY > 0) {
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
}
if (this.pinnedItems.size > 0) {
queryParams.set('pinned', Array.from(this.pinnedItems).join(','));
}
if (this.sidebarWidth !== 200) {
queryParams.set('sidebarWidth', this.sidebarWidth.toString());
}
const queryString = queryParams.toString();
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
this.domtools.router.pushUrl(fullUrl);
}
@@ -255,19 +521,31 @@ export class WccDashboard extends DeesElement {
}
private updateUrlWithScrollState() {
const baseUrl = `/wcctools-route/${this.selectedType}/${this.selectedItemName}/${this.selectedViewport}/${this.selectedTheme}`;
const sectionName = this.selectedSection
? encodeURIComponent(this.selectedSection.name)
: this.selectedType; // Fallback for legacy
const baseUrl = `/wcctools-route/${sectionName}/${this.selectedItemName}/${this.selectedDemoIndex}/${this.selectedViewport}/${this.selectedTheme}`;
const queryParams = new URLSearchParams();
if (this.searchQuery) {
queryParams.set('search', this.searchQuery);
}
if (this.frameScrollY > 0) {
queryParams.set('frameScrollY', this.frameScrollY.toString());
}
if (this.sidebarScrollY > 0) {
queryParams.set('sidebarScrollY', this.sidebarScrollY.toString());
}
if (this.pinnedItems.size > 0) {
queryParams.set('pinned', Array.from(this.pinnedItems).join(','));
}
if (this.sidebarWidth !== 200) {
queryParams.set('sidebarWidth', this.sidebarWidth.toString());
}
const queryString = queryParams.toString();
const fullUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
// Use replaceState to update URL without navigation
window.history.replaceState(null, '', fullUrl);
}

View File

@@ -11,18 +11,28 @@ declare global {
@customElement('wcc-frame')
export class WccFrame extends DeesElement {
@property()
public viewport: string;
accessor viewport: string;
@property({ type: Boolean })
accessor advancedEditorOpen: boolean = false;
@property({ type: Boolean })
accessor isNative: boolean = false;
@property({ type: Number })
accessor sidebarWidth: number = 200;
@property({ type: Boolean })
accessor isResizing: boolean = false;
public static styles = [
css`
:host {
border: 10px solid #ffaeaf;
border: 10px solid rgba(255, 174, 175, 1);
position: absolute;
background: ${cssManager.bdTheme('#333', '#000')};
left: 200px;
right: 0px;
top: 0px;
bottom: 100px;
overflow-y: auto;
overflow-x: auto;
overscroll-behavior: contain;
@@ -41,7 +51,19 @@ export class WccFrame extends DeesElement {
return html`
<style>
:host {
${(() => {
${this.isNative ? `
border: 0px solid rgba(255, 174, 175, 0) !important;
left: 0px !important;
right: 0px !important;
top: 0px !important;
bottom: 0px !important;
` : `
bottom: ${this.advancedEditorOpen ? '400px' : '100px'};
border: 10px solid rgba(255, 174, 175, 1);
left: ${this.sidebarWidth}px;
`}
transition: ${this.isResizing ? 'none' : 'all 0.3s ease'};
${this.isNative ? 'padding: 0px;' : (() => {
switch (this.viewport) {
case 'desktop':
return `
@@ -50,19 +72,19 @@ export class WccFrame extends DeesElement {
case 'tablet':
return `
padding: 0px ${
(document.body.clientWidth - 200 - domtools.breakpoints.tablet) / 2
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.tablet) / 2
}px;
`;
case 'phablet':
return `
padding: 0px ${
(document.body.clientWidth - 200 - domtools.breakpoints.phablet) / 2
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.phablet) / 2
}px;
`;
case 'phone':
return `
padding: 0px ${
(document.body.clientWidth - 200 - domtools.breakpoints.phone) / 2
(document.body.clientWidth - this.sidebarWidth - domtools.breakpoints.phone) / 2
}px;
`;
}
@@ -70,7 +92,7 @@ export class WccFrame extends DeesElement {
}
.viewport {
${this.viewport !== 'desktop'
${!this.isNative && this.viewport !== 'desktop'
? html` border-right: 1px dotted #444; border-left: 1px dotted #444; `
: html``
}

View File

@@ -1,5 +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';
@@ -17,48 +20,103 @@ export class WccProperties extends DeesElement {
@property({
type: WccDashboard
})
public dashboardRef: WccDashboard;
accessor dashboardRef: WccDashboard;
@property()
public selectedItem: (() => TemplateResult) | 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()
accessor isNative: boolean = false;
@property({ type: Number })
accessor sidebarWidth: number = 200;
@state()
propertyContent: TemplateResult[] = [];
accessor propertyContent: TemplateResult[] = [];
@state()
accessor editingProperties: Array<{
id: string;
name: string;
value: any;
element: HTMLElement;
editorValue: string;
editorError: string;
}> = [];
// Recording coordination state
@state()
accessor showRecordingPanel: boolean = false;
@state()
accessor isRecording: boolean = false;
@state()
accessor recordingDuration: number = 0;
// Delayed hide for native mode transition
@state()
accessor isHidden: boolean = false;
public editorHeight: number = 300;
public render(): TemplateResult {
return html`
<style>
:host {
font-family: 'Roboto', sans-serif;
/* CSS Variables - Always dark theme */
--background: #0a0a0a;
--foreground: #e5e5e5;
--card: #0f0f0f;
--card-foreground: #f0f0f0;
--muted: #1a1a1a;
--muted-foreground: #666;
--accent: #222;
--accent-foreground: #fff;
--border: rgba(255, 255, 255, 0.06);
--input: #141414;
--primary: #3b82f6;
--primary-foreground: #fff;
--ring: #3b82f6;
--radius: 4px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 6px;
/* Base styles */
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
box-sizing: border-box;
position: absolute;
left: 200px;
height: 100px;
left: ${this.sidebarWidth}px;
height: ${this.editingProperties.length > 0 ? 100 + this.editorHeight : 100}px;
bottom: 0px;
right: 0px;
overflow: hidden;
background: #111;
color: #fff;
background: var(--background);
color: var(--foreground);
display: ${this.isHidden ? 'none' : 'block'};
}
.grid {
display: grid;
grid-template-columns: auto 150px 300px 70px;
grid-template-columns: 1fr 150px 350px 100px;
height: 100%;
}
.properties {
border-right: 1px solid #999;
height: 100px;
background: transparent;
overflow-y: auto;
display: grid;
grid-template-columns: 33% 33% 33%;
grid-template-columns: repeat(3, 1fr);
border-right: 1px solid var(--border);
align-content: start;
}
.material-symbols-outlined {
@@ -77,152 +135,585 @@ export class WccProperties extends DeesElement {
}
.properties .property {
padding: 5px;
background: #444;
border: 1px solid #000;
padding: 0.4rem;
background: transparent;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
transition: all 0.15s ease;
}
.properties .property:hover {
background: rgba(255, 255, 255, 0.02);
}
.properties .property-label {
font-size: 0.65rem;
font-weight: 400;
color: #888;
margin-bottom: 0.2rem;
text-transform: capitalize;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.properties input,
.properties input[type="text"],
.properties input[type="number"],
.properties select {
display: block;
width: 100%;
background: #333;
border: none;
color: #fff;
padding: 0.25rem 0.4rem;
background: var(--input);
border: 1px solid transparent;
color: var(--foreground);
border-radius: var(--radius-sm);
font-size: 0.7rem;
transition: all 0.15s ease;
outline: none;
}
.properties input[type="text"]:focus,
.properties input[type="number"]:focus,
.properties select:focus {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.1);
}
.properties input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: var(--primary);
}
.properties .editor-button {
padding: 0.25rem 0.5rem;
background: var(--input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--foreground);
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.properties .editor-button:hover {
border-color: var(--primary);
background: rgba(59, 130, 246, 0.1);
}
.viewportSelector,
.themeSelector {
.themeSelector,
.shareSelector {
user-select: none;
border-right: 1px solid #999;
background: transparent;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
}
.selectorButtons2 {
display: grid;
grid-template-columns: 50% 50%;
grid-template-columns: repeat(2, minmax(0, 1fr));
flex: 1;
}
.selectorButtons4 {
display: grid;
grid-template-columns: 25% 25% 25% 25%;
grid-template-columns: repeat(4, minmax(0, 1fr));
flex: 1;
}
.selectorButtons5 {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
flex: 1;
}
.selectorButtons1 {
display: grid;
grid-template-columns: minmax(0, 1fr);
flex: 1;
}
.button {
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem 0.25rem;
text-align: center;
border: 1px solid #000;
transition: all 0.2s;
background: transparent;
border: 1px solid var(--border);
transition: all 0.15s ease;
cursor: pointer;
font-size: 0.65rem;
gap: 0.2rem;
color: #999;
}
.button:hover {
color: #333;
background: #fff;
background: rgba(59, 130, 246, 0.05);
color: #bbb;
}
.button.selected {
background: #455a64;
background: rgba(59, 130, 246, 0.15);
color: var(--primary);
border-color: rgba(59, 130, 246, 0.3);
}
.button.selected:hover {
color: #ffffff;
background: #455a64;
background: rgba(59, 130, 246, 0.2);
}
.button .material-symbols-outlined {
font-size: 18px;
font-variation-settings: 'FILL' 0, 'wght' 300;
min-width: 18px;
min-height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.button.selected .material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 400;
}
.panelheading {
padding: 5px;
font-weight: bold;
background: #444;
border: 1px solid #000;
padding: 0.3rem 0.5rem;
font-weight: 500;
font-size: 0.65rem;
background: rgba(59, 130, 246, 0.03);
border-bottom: 1px solid var(--border);
color: #888;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.docs {
text-align: center;
line-height: 100px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
background: transparent;
cursor: pointer;
font-size: 0.65rem;
font-weight: 500;
letter-spacing: 0.08em;
transition: all 0.15s ease;
color: #666;
}
.docs:hover {
color: #333;
background: #fff;
background: rgba(59, 130, 246, 0.05);
color: #999;
}
.warning {
position: absolute;
background: #222;
color: #CCC;
top: 0px;
bottom: 0px;
left: 0px;
right: 520px;
text-align: center;
padding: 35px;
font-size: 25px;
background: rgba(20, 20, 20, 0.8);
color: #888;
top: 0.5rem;
bottom: 0.5rem;
left: 0.5rem;
right: calc(600px + 0.5rem);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
font-size: 0.85rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
backdrop-filter: blur(8px);
}
.advanced-editor-container {
position: absolute;
left: 0;
right: 0;
top: 0;
height: ${this.editorHeight}px;
background: #050505;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.editor-header-bar {
padding: 0.5rem 0.75rem;
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
height: 36px;
}
.editor-header-title {
font-size: 0.7rem;
font-weight: 500;
color: #666;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-close-all {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
color: #999;
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.editor-close-all:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
color: #f87171;
}
.editors-container {
flex: 1;
display: flex;
overflow-x: auto;
overflow-y: hidden;
gap: 0;
background: rgba(255, 255, 255, 0.02);
padding: 0.75rem;
}
.editors-container::-webkit-scrollbar {
height: 8px;
}
.editors-container::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
}
.editors-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.editors-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.12);
}
.editor-instance {
min-width: 320px;
flex: 1;
max-width: 480px;
background: rgba(10, 10, 10, 0.6);
display: flex;
flex-direction: column;
border-radius: var(--radius);
overflow: hidden;
margin-right: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.2s ease;
}
.editor-instance:hover {
border-color: rgba(255, 255, 255, 0.1);
}
.editor-instance:last-child {
margin-right: 0;
}
.editor-header {
padding: 0.5rem 0.75rem;
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;
height: 36px;
}
.editor-title {
font-size: 0.75rem;
font-weight: 500;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Consolas', 'Monaco', monospace;
}
.editor-actions {
display: flex;
gap: 0.25rem;
}
.editor-button {
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
color: #666;
font-size: 1rem;
cursor: pointer;
transition: all 0.15s ease;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.editor-button:hover {
background: rgba(255, 255, 255, 0.05);
color: #999;
}
.editor-button.primary {
color: #4ade80;
}
.editor-button.primary:hover {
background: rgba(74, 222, 128, 0.1);
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
}
.editor-textarea {
width: 100%;
height: 100%;
background: transparent;
border: none;
color: #d0d0d0;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.8125rem;
line-height: 1.6;
padding: 0.75rem;
resize: none;
outline: none;
transition: all 0.15s ease;
overflow: auto;
}
.editor-textarea:focus {
background: rgba(255, 255, 255, 0.01);
}
.editor-textarea::selection {
background: rgba(59, 130, 246, 0.3);
}
.editor-error {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem 0.75rem;
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(4px);
color: #fff;
font-size: 0.7rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.375rem;
border-top: 1px solid rgba(239, 68, 68, 0.5);
}
.editor-error::before {
content: '!';
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 0.65rem;
font-weight: bold;
}
.main-content {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 100px;
}
</style>
<div class="grid">
<div class="properties">
<div class="panelheading">Properties</div>
${this.propertyContent}
${this.editingProperties.length > 0 ? html`
<div class="advanced-editor-container">
<div class="editor-header-bar">
<div class="editor-header-title">Property Editors</div>
<button class="editor-close-all" @click=${this.closeAllEditors}>
Close All
</button>
</div>
<div class="editors-container">
${this.editingProperties.length === 0 ? html`
<div style="
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 0.875rem;
text-align: center;
padding: 2rem;
">
<div>
<div style="margin-bottom: 0.5rem; font-size: 1.5rem; opacity: 0.5;">{ }</div>
<div>No properties being edited</div>
<div style="font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.7;">Click "Edit Object/Array" buttons to start editing</div>
</div>
</div>
` : null}
${this.editingProperties.map(prop => html`
<div class="editor-instance">
<div class="editor-header">
<div class="editor-title">${prop.name}</div>
<div class="editor-actions">
<button class="editor-button" @click=${() => this.handleEditorCancel(prop.id)}>✕</button>
<button class="editor-button primary" @click=${() => this.handleEditorSave(prop.id)}>✓</button>
</div>
</div>
<div class="editor-content">
<textarea
class="editor-textarea"
.value=${prop.editorValue}
@input=${(e: InputEvent) => {
const editor = this.editingProperties.find(p => p.id === prop.id);
if (editor) {
editor.editorValue = (e.target as HTMLTextAreaElement).value;
editor.editorError = '';
this.requestUpdate();
}
}}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Tab') {
e.preventDefault();
const target = e.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
const value = target.value;
target.value = value.substring(0, start) + ' ' + value.substring(end);
target.selectionStart = target.selectionEnd = start + 2;
}
}}
></textarea>
${prop.editorError ? html`
<div class="editor-error">${prop.editorError}</div>
` : null}
</div>
</div>
`)}
</div>
</div>
<div class="themeSelector">
<div class="panelheading">Theme</div>
<div class="selectorButtons2">
<div
class="button ${this.selectedTheme === 'dark' ? 'selected' : null}"
@click=${() => {
this.selectTheme('dark');
}}
>
Dark<br /><i class="material-symbols-outlined">brightness_3</i>
` : null}
<div class="main-content">
<div class="grid">
<div class="properties">
${this.propertyContent}
</div>
<div class="themeSelector">
<div class="panelheading">Theme</div>
<div class="selectorButtons2">
<div
class="button ${this.selectedTheme === 'dark' ? 'selected' : null}"
@click=${() => {
this.selectTheme('dark');
}}
>
Dark<i class="material-symbols-outlined">brightness_3</i>
</div>
<div
class="button ${this.selectedTheme === 'bright' ? 'selected' : null}"
@click=${() => {
this.selectTheme('bright');
}}
>
Bright<i class="material-symbols-outlined">flare</i>
</div>
</div>
<div
class="button ${this.selectedTheme === 'bright' ? 'selected' : null}"
@click=${() => {
this.selectTheme('bright');
}}
>
Bright<br /><i class="material-symbols-outlined">flare</i>
</div>
<div class="viewportSelector">
<div class="panelheading">Viewport</div>
<div class="selectorButtons5">
<div
class="button ${this.selectedViewport === 'phone' ? 'selected' : null}"
@click=${() => {
this.selectViewport('phone');
}}
>
Phone<i class="material-symbols-outlined">smartphone</i>
</div>
<div
class="button ${this.selectedViewport === 'phablet' ? 'selected' : null}"
@click=${() => {
this.selectViewport('phablet');
}}
>
Phablet<i class="material-symbols-outlined">smartphone</i>
</div>
<div
class="button ${this.selectedViewport === 'tablet' ? 'selected' : null}"
@click=${() => {
this.selectViewport('tablet');
}}
>
Tablet<i class="material-symbols-outlined">tablet</i>
</div>
<div
class="button ${this.selectedViewport === 'desktop' ? 'selected' : null}"
@click=${() => {
this.selectViewport('desktop');
}}
>
Desktop<i class="material-symbols-outlined">desktop_windows</i>
</div>
<div
class="button ${this.selectedViewport === 'native' ? 'selected' : null}"
@click=${() => {
this.selectViewport('native');
}}
>
Native<i class="material-symbols-outlined">fullscreen</i>
</div>
</div>
</div>
<div class="shareSelector">
<div class="panelheading">Share</div>
<div class="selectorButtons1">
<div
class="button ${this.isRecording ? 'selected' : ''}"
@click=${() => this.handleRecordButtonClick()}
>
Record<i class="material-symbols-outlined">${this.isRecording ? 'stop_circle' : 'videocam'}</i>
</div>
</div>
</div>
</div>
<div class="viewportSelector">
<div class="panelheading">Viewport</div>
<div class="selectorButtons4">
<div
class="button ${this.selectedViewport === 'phone' ? 'selected' : null}"
@click=${() => {
this.selectViewport('phone');
}}
>
Phone<br /><i class="material-symbols-outlined">smartphone</i>
</div>
<div
class="button ${this.selectedViewport === 'phablet' ? 'selected' : null}"
@click=${() => {
this.selectViewport('phablet');
}}
>
Phablet<br /><i class="material-symbols-outlined">smartphone</i>
</div>
<div
class="button ${this.selectedViewport === 'tablet' ? 'selected' : null}"
@click=${() => {
this.selectViewport('tablet');
}}
>
Tablet<br /><i class="material-symbols-outlined">tablet</i>
</div>
<div
class="button ${this.selectedViewport === 'desktop' ||
this.selectedViewport === 'native'
? 'selected'
: null}"
@click=${() => {
this.selectViewport('native');
}}
>
Desktop<br /><i class="material-symbols-outlined">desktop_windows</i>
</div>
</div>
</div>
<div class="docs">Docs</div>
${this.warning ? html`<div class="warning">${this.warning}</div>` : null}
</div>
${this.warning ? html`<div class="warning">${this.warning}</div>` : null}
<!-- 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}
`;
}
@@ -356,7 +847,7 @@ export class WccProperties extends DeesElement {
propertyArray.push(
html`
<div class="property">
${key} / ${propertyTypeString}<br />
<div class="property-label">${key} (${propertyTypeString})</div>
${(() => {
switch (propertyTypeString) {
case 'Boolean':
@@ -401,6 +892,17 @@ export class WccProperties extends DeesElement {
`;
})}
</select>`;
case 'Object':
case 'Array':
return html`<button
class="editor-button"
style="width: 100%; margin-top: 0.25rem;"
@click="${() => this.openAdvancedEditor(key, firstFoundInstantiatedElement[key], firstFoundInstantiatedElement)}"
>
Edit ${propertyTypeString}
</button>`;
default:
return html`<div style="color: #666; font-size: 0.7rem;">Unsupported type</div>`;
}
})()}
</div>
@@ -430,16 +932,29 @@ export class WccProperties extends DeesElement {
this.dashboardRef.buildUrl();
}
public async scheduleUpdate() {
try {
await this.createProperties();
} catch (error) {
console.error('Error creating properties:', error);
// Clear property content on error to show clean state
this.propertyContent = [];
protected updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
// Handle delayed hide for native mode transition
if (changedProperties.has('isNative')) {
if (this.isNative) {
// Delay hiding until frame animation completes
setTimeout(() => {
this.isHidden = true;
}, 300);
} else {
// Show immediately when exiting native mode
this.isHidden = false;
}
}
// Only recreate properties when selectedItem changes
if (changedProperties.has('selectedItem')) {
this.createProperties().catch(error => {
console.error('Error creating properties:', error);
this.propertyContent = [];
});
}
// Always call super.scheduleUpdate to ensure component updates
super.scheduleUpdate();
}
public selectViewport(viewport: TEnvironment) {
@@ -452,4 +967,116 @@ export class WccProperties extends DeesElement {
);
this.dashboardRef.buildUrl();
}
private openAdvancedEditor(propertyName: string, value: any, element: HTMLElement) {
// Check if this property is already being edited
const existingEditor = this.editingProperties.find(p => p.name === propertyName && p.element === element);
if (existingEditor) {
return; // Property is already open for editing
}
const newEditor = {
id: `${propertyName}-${Date.now()}`,
name: propertyName,
value: value,
element: element,
editorValue: JSON.stringify(value, null, 2),
editorError: ''
};
this.editingProperties = [...this.editingProperties, newEditor];
// Notify parent to resize frame if this is the first editor
if (this.editingProperties.length === 1) {
this.dispatchEvent(
new CustomEvent('editorStateChanged', {
detail: { isOpen: true },
bubbles: true
})
);
}
}
private handleEditorSave(editorId: string) {
const editor = this.editingProperties.find(p => p.id === editorId);
if (!editor) return;
try {
const parsedValue = JSON.parse(editor.editorValue);
editor.element[editor.name] = parsedValue;
// Remove this editor from the list
this.editingProperties = this.editingProperties.filter(p => p.id !== editorId);
// If no more editors, notify parent to resize frame
if (this.editingProperties.length === 0) {
this.dispatchEvent(
new CustomEvent('editorStateChanged', {
detail: { isOpen: false },
bubbles: true
})
);
}
// Refresh properties display
this.createProperties();
} catch (error) {
// Update error for this specific editor
const editorIndex = this.editingProperties.findIndex(p => p.id === editorId);
if (editorIndex !== -1) {
this.editingProperties[editorIndex].editorError = `Invalid JSON: ${error.message}`;
this.requestUpdate();
}
}
}
private handleEditorCancel(editorId: string) {
// Remove this editor from the list
this.editingProperties = this.editingProperties.filter(p => p.id !== editorId);
// If no more editors, notify parent to resize frame
if (this.editingProperties.length === 0) {
this.dispatchEvent(
new CustomEvent('editorStateChanged', {
detail: { isOpen: false },
bubbles: true
})
);
}
}
private closeAllEditors() {
this.editingProperties = [];
// Notify parent to resize frame back
this.dispatchEvent(
new CustomEvent('editorStateChanged', {
detail: { isOpen: false },
bubbles: true
})
);
}
private toggleNative() {
this.dispatchEvent(
new CustomEvent('toggleNative', {
bubbles: true
})
);
}
// ==================== 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,978 @@
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 WebM'}
</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;
// Handle trimming if needed
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;
}
// Trigger download
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

@@ -1,47 +1,160 @@
import * as plugins from '../wcctools.plugins.js';
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
import { WccDashboard } from './wcc-dashboard.js';
export type TElementType = 'element' | 'page';
import { DeesElement, property, html, customElement, type TemplateResult, state } from '@design.estate/dees-element';
import { WccDashboard, getSectionItems } from './wcc-dashboard.js';
import type { TTemplateFactory } from './wcctools.helpers.js';
import { getDemoCount, hasMultipleDemos } from './wcctools.helpers.js';
import type { IWccSection, TElementType } from '../wcctools.interfaces.js';
import { WccContextmenu } from './wcc-contextmenu.js';
@customElement('wcc-sidebar')
export class WccSidebar extends DeesElement {
@property({ attribute: false })
public selectedItem: DeesElement | (() => TemplateResult);
accessor selectedItem: DeesElement | TTemplateFactory;
@property({ attribute: false })
public selectedType: TElementType;
accessor selectedType: TElementType;
@property()
public dashboardRef: WccDashboard;
accessor dashboardRef: WccDashboard;
@property()
accessor isNative: boolean = false;
// Track which elements are expanded (for multi-demo elements)
@state()
accessor expandedElements: Set<string> = new Set();
// Track which sections are collapsed
@state()
accessor collapsedSections: Set<string> = new Set();
// Search query for filtering sidebar items
@property()
accessor searchQuery: string = '';
// Pinned items as Set of "sectionName::itemName"
@property({ attribute: false })
accessor pinnedItems: Set<string> = new Set();
// Sidebar width (resizable)
@property({ type: Number })
accessor sidebarWidth: number = 200;
// Track if currently resizing
@state()
accessor isResizing: boolean = false;
// Delayed hide for native mode transition
@state()
accessor isHidden: boolean = false;
private sectionsInitialized = false;
public render(): TemplateResult {
return html`
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet" />
<style>
:host {
display: block;
border-right: 1px solid #999;
font-family: 'Roboto', 'Inter', sans-serif;
font-size: 12px;
/* CSS Variables - Always dark theme to match wcc-properties */
--background: #0a0a0a;
--foreground: #e5e5e5;
--card: #0f0f0f;
--card-foreground: #f0f0f0;
--muted: #1a1a1a;
--muted-foreground: #666;
--accent: #222;
--accent-foreground: #fff;
--border: rgba(255, 255, 255, 0.06);
--input: #141414;
--primary: #3b82f6;
--primary-foreground: #fff;
--ring: #3b82f6;
--radius: 4px;
display: ${this.isHidden ? 'none' : 'flex'};
flex-direction: column;
border-right: 1px solid rgba(255, 255, 255, 0.08);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 14px;
box-sizing: border-box;
position: absolute;
left: 0px;
width: 200px;
width: ${this.sidebarWidth}px;
top: 0px;
bottom: 0px;
overflow-y: scroll;
overflow: hidden;
background: var(--background);
color: var(--foreground);
}
.sidebar-header {
flex-shrink: 0;
}
.menu {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #222;
color: #fff;
padding: 5px;
padding: 0.5rem 0;
}
.section-header {
padding: 0.3rem 0.75rem;
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #888;
margin: 0;
margin-top: 0.5rem;
background: rgba(59, 130, 246, 0.03);
border-bottom: 1px solid var(--border);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
}
.section-header:first-child {
margin-top: 0;
}
.section-header:hover {
background: rgba(59, 130, 246, 0.08);
}
.section-header .expand-icon {
font-size: 14px;
opacity: 0.5;
transition: transform 0.2s ease;
}
.section-header.collapsed .expand-icon {
transform: rotate(-90deg);
}
.section-header .section-icon {
font-size: 14px;
opacity: 0.6;
}
.section-content {
overflow: hidden;
}
.section-content.collapsed {
display: none;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px; /* Preferred icon size */
font-size: 16px;
display: inline-block;
line-height: 1;
text-transform: none;
@@ -49,101 +162,732 @@ export class WccSidebar extends DeesElement {
word-wrap: normal;
white-space: nowrap;
direction: ltr;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48;
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
opacity: 0.5;
}
.selectOption {
user-select: none;
position: relative;
line-height: 24px;
padding: 5px;
transition: all 0.2s;
margin: 0.125rem 0.5rem;
padding: 0.5rem 0.75rem;
transition: all 0.15s ease;
display: grid;
grid-template-columns: 28px auto;
grid-template-columns: 20px 1fr;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.75rem;
color: #999;
background: transparent;
}
.selectOption.folder {
grid-template-columns: 16px 1fr;
}
.selectOption.folder .text {
margin-left: 4px;
}
.selectOption .expand-icon {
font-size: 14px;
opacity: 0.5;
transition: transform 0.2s ease;
}
.selectOption.expanded .expand-icon {
transform: rotate(90deg);
}
.selectOption:hover {
padding: 5px;
color: #333;
background: #fff;
background: rgba(59, 130, 246, 0.05);
color: #bbb;
}
.selectOption:hover .material-symbols-outlined {
opacity: 0.7;
}
.selectOption.selected {
background: #455A64;;
}
.selectOption.selected:hover {
color: #ffffff;
background: #455A64;
}
.selectOption .material-symbols-outlined {
color: #666;
display: block;
transition: all 0.2s;
background: rgba(59, 130, 246, 0.15);
color: var(--primary);
}
.selectOption.selected .material-symbols-outlined {
color: #000;
opacity: 1;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.selectOption.selected:hover {
background: rgba(59, 130, 246, 0.2);
color: var(--primary);
}
.selectOption .text {
display: block;
word-wrap: break-word;
word-break: break-all;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 400;
}
.demo-children {
margin-left: 1rem;
overflow: hidden;
}
.demo-child {
user-select: none;
position: relative;
margin: 0.125rem 0.5rem;
padding: 0.35rem 0.75rem;
transition: all 0.15s ease;
display: grid;
grid-template-columns: 16px 1fr;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.7rem;
color: #777;
background: transparent;
}
.demo-child:hover {
background: rgba(59, 130, 246, 0.05);
color: #bbb;
}
.demo-child.selected {
background: rgba(59, 130, 246, 0.15);
color: var(--primary);
}
.demo-child .material-symbols-outlined {
font-size: 14px;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.search-container {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.search-input {
width: 100%;
box-sizing: border-box;
background: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 1.75rem 0.5rem 0.75rem;
color: var(--foreground);
font-size: 0.75rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
}
.search-input:focus {
border-color: var(--primary);
}
.search-input::placeholder {
color: var(--muted-foreground);
}
.search-clear {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: color 0.15s ease, background 0.15s ease;
}
.search-clear:hover {
color: var(--foreground);
background: rgba(255, 255, 255, 0.1);
}
.search-clear .material-symbols-outlined {
font-size: 14px;
opacity: 1;
}
.highlight {
background: rgba(59, 130, 246, 0.3);
border-radius: 2px;
}
/* Pinned item highlight in original section */
.selectOption.pinned {
background: rgba(245, 158, 11, 0.08);
}
.selectOption.pinned:hover {
background: rgba(245, 158, 11, 0.12);
}
.selectOption.pinned.selected {
background: rgba(245, 158, 11, 0.18);
}
/* Pinned section styling */
.section-header.pinned-section {
background: rgba(245, 158, 11, 0.08);
color: #f59e0b;
}
.section-header.pinned-section:hover {
background: rgba(245, 158, 11, 0.12);
}
.section-header.pinned-section .section-icon {
opacity: 0.8;
}
/* Section tag pill for pinned items */
.section-tag {
font-size: 0.5rem;
color: #888;
margin-left: auto;
text-transform: uppercase;
letter-spacing: 0.02em;
background: rgba(255, 255, 255, 0.06);
padding: 0.15rem 0.4rem;
border-radius: 9999px;
white-space: nowrap;
}
/* Group container */
.item-group {
margin: 0.375rem 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.25rem 0;
background: rgba(255, 255, 255, 0.01);
}
.item-group-legend {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #555;
padding: 0.125rem 0.625rem 0.25rem;
display: block;
}
.item-group .selectOption {
margin-left: 0.25rem;
margin-right: 0.25rem;
}
/* Resize handle */
.resize-handle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.15s ease;
z-index: 10;
}
.resize-handle:hover {
background: rgba(59, 130, 246, 0.3);
}
.resize-handle.active {
background: var(--primary);
}
</style>
<div class="sidebar-header">
<div class="search-container">
<input
type="text"
class="search-input"
placeholder="Search..."
.value=${this.searchQuery}
@input=${this.handleSearchInput}
/>
${this.searchQuery ? html`
<button class="search-clear" @click=${this.clearSearch}>
<i class="material-symbols-outlined">close</i>
</button>
` : null}
</div>
${this.renderPinnedSection()}
</div>
<div class="menu">
<h3>Pages</h3>
${(() => {
const pages = Object.keys(this.dashboardRef.pages);
return pages.map(pageName => {
const item = this.dashboardRef.pages[pageName];
return html`
<div
class="selectOption ${this.selectedItem === item ? 'selected' : null}"
@click=${async () => {
const domtools = await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('page', pageName, item);
}}
>
<i class="material-symbols-outlined">insert_drive_file</i>
<div class="text">${pageName}</div>
</div>
`;
});
})()}
<h3>Elements</h3>
${(() => {
const elements = Object.keys(this.dashboardRef.elements);
return elements.map(elementName => {
const item = this.dashboardRef.elements[elementName];
return html`
<div
class="selectOption ${this.selectedItem === item ? 'selected' : null}"
@click=${async () => {
const domtools = await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item);
}}
>
<i class="material-symbols-outlined">featured_video</i>
<div class="text">${elementName}</div>
</div>
`;
});
})()}
${this.renderSections()}
</div>
<div
class="resize-handle ${this.isResizing ? 'active' : ''}"
@mousedown=${this.startResize}
></div>
`;
}
/**
* Initialize collapsed sections from section config
*/
private initCollapsedSections() {
if (this.sectionsInitialized || !this.dashboardRef?.sections) return;
const collapsed = new Set<string>();
for (const section of this.dashboardRef.sections) {
if (section.collapsed) {
collapsed.add(section.name);
}
}
this.collapsedSections = collapsed;
this.sectionsInitialized = true;
}
// ============ Pinning helpers ============
private getPinKey(sectionName: string, itemName: string): string {
return `${sectionName}::${itemName}`;
}
private isPinned(sectionName: string, itemName: string): boolean {
return this.pinnedItems.has(this.getPinKey(sectionName, itemName));
}
private togglePin(sectionName: string, itemName: string) {
const key = this.getPinKey(sectionName, itemName);
const newPinned = new Set(this.pinnedItems);
if (newPinned.has(key)) {
newPinned.delete(key);
} else {
newPinned.add(key);
}
this.pinnedItems = newPinned;
this.dispatchEvent(new CustomEvent('pinnedChanged', { detail: newPinned }));
}
private showContextMenu(e: MouseEvent, sectionName: string, itemName: string) {
const isPinned = this.isPinned(sectionName, itemName);
WccContextmenu.show(e, [
{
name: isPinned ? 'Unpin' : 'Pin',
iconName: isPinned ? 'push_pin' : 'push_pin',
action: () => this.togglePin(sectionName, itemName),
},
]);
}
/**
* Render the PINNED section (only if there are pinned items)
*/
private renderPinnedSection() {
if (!this.dashboardRef?.sections || this.pinnedItems.size === 0) {
return null;
}
const isCollapsed = this.collapsedSections.has('__pinned__');
// Collect pinned items with their original section info
// Pinned items are NOT filtered by search - they always remain visible
const pinnedEntries: Array<{ sectionName: string; itemName: string; item: any; section: IWccSection }> = [];
for (const key of this.pinnedItems) {
const [sectionName, itemName] = key.split('::');
const section = this.dashboardRef.sections.find(s => s.name === sectionName);
if (section) {
const entries = getSectionItems(section);
const found = entries.find(([name]) => name === itemName);
if (found) {
pinnedEntries.push({ sectionName, itemName, item: found[1], section });
}
}
}
if (pinnedEntries.length === 0) {
return null;
}
return html`
<div
class="section-header pinned-section ${isCollapsed ? 'collapsed' : ''}"
@click=${() => this.toggleSectionCollapsed('__pinned__')}
>
<i class="material-symbols-outlined expand-icon">expand_more</i>
<i class="material-symbols-outlined section-icon">push_pin</i>
<span>Pinned</span>
</div>
<div class="section-content ${isCollapsed ? 'collapsed' : ''}">
${pinnedEntries.map(({ sectionName, itemName, item, section }) => {
const isSelected = this.selectedItem === item;
const type = section.type === 'elements' ? 'element' : 'page';
const icon = section.type === 'elements' ? 'featured_video' : 'insert_drive_file';
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem(type, itemName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, sectionName, itemName)}
>
<i class="material-symbols-outlined">${icon}</i>
<div class="text">${this.highlightMatch(itemName)}</div>
<span class="section-tag">${sectionName}</span>
</div>
`;
})}
</div>
`;
}
public selectItem(typeArg: TElementType, itemNameArg: string, itemArg: (() => TemplateResult) | DeesElement) {
/**
* Render all sections
*/
private renderSections() {
if (!this.dashboardRef?.sections) {
return null;
}
this.initCollapsedSections();
return this.dashboardRef.sections.map((section) => {
// Check if section has any matching items
const entries = getSectionItems(section);
const filteredEntries = entries.filter(([name]) => this.matchesSearch(name));
// Hide section if no items match the search
if (filteredEntries.length === 0 && this.searchQuery) {
return null;
}
const isCollapsed = this.collapsedSections.has(section.name);
const sectionIcon = section.icon || (section.type === 'pages' ? 'insert_drive_file' : 'widgets');
return html`
<div
class="section-header ${isCollapsed ? 'collapsed' : ''}"
@click=${() => this.toggleSectionCollapsed(section.name)}
>
<i class="material-symbols-outlined expand-icon">expand_more</i>
${section.icon ? html`<i class="material-symbols-outlined section-icon">${section.icon}</i>` : null}
<span>${section.name}</span>
</div>
<div class="section-content ${isCollapsed ? 'collapsed' : ''}">
${this.renderSectionItems(section)}
</div>
`;
});
}
/**
* Render items for a section
*/
private renderSectionItems(section: IWccSection) {
const entries = getSectionItems(section);
// Filter entries by search query
const filteredEntries = entries.filter(([name]) => this.matchesSearch(name));
if (section.type === 'pages') {
return filteredEntries.map(([pageName, item]) => {
const isPinned = this.isPinned(section.name, pageName);
return html`
<div
class="selectOption ${this.selectedItem === item ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('page', pageName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, pageName)}
>
<i class="material-symbols-outlined">insert_drive_file</i>
<div class="text">${this.highlightMatch(pageName)}</div>
</div>
`;
});
} else {
// type === 'elements' - group by demoGroup
const groupedItems = new Map<string | null, Array<[string, any]>>();
for (const entry of filteredEntries) {
const [, item] = entry;
const group = (item as any).demoGroup || null;
if (!groupedItems.has(group)) {
groupedItems.set(group, []);
}
groupedItems.get(group)!.push(entry);
}
const result: TemplateResult[] = [];
// Render ungrouped items first
const ungrouped = groupedItems.get(null) || [];
for (const entry of ungrouped) {
result.push(this.renderElementItem(entry, section));
}
// Render grouped items
for (const [groupName, items] of groupedItems) {
if (groupName === null) continue;
result.push(html`
<div class="item-group">
<span class="item-group-legend">${groupName}</span>
${items.map((entry) => this.renderElementItem(entry, section))}
</div>
`);
}
return result;
}
}
/**
* Render a single element item (used by renderSectionItems)
*/
private renderElementItem(entry: [string, any], section: IWccSection): TemplateResult {
const [elementName, item] = entry;
const anonItem = item as any;
const demoCount = anonItem.demo ? getDemoCount(anonItem.demo) : 0;
const isMultiDemo = anonItem.demo && hasMultipleDemos(anonItem.demo);
const isExpanded = this.expandedElements.has(elementName);
const isSelected = this.selectedItem === item;
const isPinned = this.isPinned(section.name, elementName);
if (isMultiDemo) {
// Multi-demo element - render as expandable folder
return html`
<div
class="selectOption folder ${isExpanded ? 'expanded' : ''} ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${() => this.toggleExpanded(elementName)}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
>
<i class="material-symbols-outlined expand-icon">chevron_right</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
${isExpanded ? html`
<div class="demo-children">
${Array.from({ length: demoCount }, (_, i) => {
const demoIndex = i;
const isThisDemoSelected = isSelected && this.dashboardRef.selectedDemoIndex === demoIndex;
return html`
<div
class="demo-child ${isThisDemoSelected ? 'selected' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, demoIndex, section);
}}
>
<i class="material-symbols-outlined">play_circle</i>
<div class="text">demo${demoIndex + 1}</div>
</div>
`;
})}
</div>
` : null}
`;
} else {
// Single demo element
return html`
<div
class="selectOption ${isSelected ? 'selected' : ''} ${isPinned ? 'pinned' : ''}"
@click=${async () => {
await plugins.deesDomtools.DomTools.setupDomTools();
this.selectItem('element', elementName, item, 0, section);
}}
@contextmenu=${(e: MouseEvent) => this.showContextMenu(e, section.name, elementName)}
>
<i class="material-symbols-outlined">featured_video</i>
<div class="text">${this.highlightMatch(elementName)}</div>
</div>
`;
}
}
private toggleSectionCollapsed(sectionName: string) {
const newSet = new Set(this.collapsedSections);
if (newSet.has(sectionName)) {
newSet.delete(sectionName);
} else {
newSet.add(sectionName);
}
this.collapsedSections = newSet;
}
private toggleExpanded(elementName: string) {
const newSet = new Set(this.expandedElements);
if (newSet.has(elementName)) {
newSet.delete(elementName);
} else {
newSet.add(elementName);
}
this.expandedElements = newSet;
}
private handleSearchInput(e: Event) {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
this.dispatchEvent(new CustomEvent('searchChanged', { detail: this.searchQuery }));
}
private clearSearch() {
this.searchQuery = '';
this.dispatchEvent(new CustomEvent('searchChanged', { detail: this.searchQuery }));
}
private matchesSearch(name: string): boolean {
if (!this.searchQuery) return true;
return name.toLowerCase().includes(this.searchQuery.toLowerCase());
}
private highlightMatch(text: string): TemplateResult {
if (!this.searchQuery) return html`${text}`;
const lowerText = text.toLowerCase();
const lowerQuery = this.searchQuery.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return html`${text}`;
const before = text.slice(0, index);
const match = text.slice(index, index + this.searchQuery.length);
const after = text.slice(index + this.searchQuery.length);
return html`${before}<span class="highlight">${match}</span>${after}`;
}
protected updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
// Handle delayed hide for native mode transition
if (changedProperties.has('isNative')) {
if (this.isNative) {
// Delay hiding until frame animation completes
setTimeout(() => {
this.isHidden = true;
}, 300);
} else {
// Show immediately when exiting native mode
this.isHidden = false;
}
}
// Auto-expand folder when a multi-demo element is selected
if (changedProperties.has('selectedItem') && this.selectedItem && this.dashboardRef?.sections) {
// Find the element in any section
for (const section of this.dashboardRef.sections) {
if (section.type !== 'elements') continue;
const entries = getSectionItems(section);
const found = entries.find(([_, item]) => item === this.selectedItem);
if (found) {
const [elementName, item] = found;
const anonItem = item as any;
if (anonItem.demo && hasMultipleDemos(anonItem.demo)) {
if (!this.expandedElements.has(elementName)) {
const newSet = new Set(this.expandedElements);
newSet.add(elementName);
this.expandedElements = newSet;
}
}
break;
}
}
}
}
// ============ Resize functionality ============
private startResize = (e: MouseEvent) => {
e.preventDefault();
this.isResizing = true;
const startX = e.clientX;
const startWidth = this.sidebarWidth;
// Cache references once at start
const frame = this.dashboardRef?.shadowRoot?.querySelector('wcc-frame') as any;
const properties = this.dashboardRef?.shadowRoot?.querySelector('wcc-properties') as any;
// Disable frame transition during resize
if (frame) {
frame.isResizing = true;
}
const onMouseMove = (e: MouseEvent) => {
const newWidth = Math.min(400, Math.max(150, startWidth + (e.clientX - startX)));
this.sidebarWidth = newWidth;
// Update frame and properties directly
if (frame) {
frame.sidebarWidth = newWidth;
}
if (properties) {
properties.sidebarWidth = newWidth;
}
};
const onMouseUp = () => {
this.isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Re-enable frame transition
if (frame) {
frame.isResizing = false;
}
// Dispatch event on release for URL persistence
this.dispatchEvent(new CustomEvent('widthChanged', { detail: this.sidebarWidth }));
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
public selectItem(
typeArg: TElementType,
itemNameArg: string,
itemArg: TTemplateFactory | DeesElement,
demoIndex: number = 0,
section?: IWccSection
) {
console.log('selected item');
console.log(itemNameArg);
console.log(itemArg);
console.log('demo index:', demoIndex);
console.log('section:', section?.name);
this.selectedItem = itemArg;
this.selectedType = typeArg;
this.dashboardRef.selectedDemoIndex = demoIndex;
// Set the selected section on dashboard
if (section) {
this.dashboardRef.selectedSection = section;
}
this.dispatchEvent(
new CustomEvent('selectedType', {
detail: typeArg
@@ -159,7 +903,10 @@ export class WccSidebar extends DeesElement {
detail: itemArg
})
);
this.dashboardRef.buildUrl();
// Force re-render to update demo child selection indicator
this.requestUpdate();
}
}

View File

@@ -0,0 +1,40 @@
import type { TemplateResult } from 'lit';
export type TTemplateFactory = () => TemplateResult | Promise<TemplateResult>;
// Demo can be a single function or an array of functions
export type TDemoDefinition = TTemplateFactory | TTemplateFactory[];
export const resolveTemplateFactory = async (
factoryArg: TTemplateFactory
): Promise<TemplateResult> => {
return await Promise.resolve(factoryArg());
};
/**
* Get the number of demos for an element
*/
export const getDemoCount = (demo: TDemoDefinition): number => {
if (Array.isArray(demo)) {
return demo.length;
}
return 1;
};
/**
* Get a specific demo by index (0-based internally, displayed as 1-based)
*/
export const getDemoAtIndex = (demo: TDemoDefinition, index: number): TTemplateFactory | null => {
if (Array.isArray(demo)) {
return demo[index] ?? null;
}
// Single demo - only index 0 is valid
return index === 0 ? demo : null;
};
/**
* Check if an element has multiple demos
*/
export const hasMultipleDemos = (demo: TDemoDefinition): boolean => {
return Array.isArray(demo) && demo.length > 1;
};

View File

@@ -1,12 +1,86 @@
import { WccDashboard } from './elements/wcc-dashboard.js';
import { LitElement, type TemplateResult } from 'lit';
import { LitElement } from 'lit';
import type { TTemplateFactory } from './elements/wcctools.helpers.js';
import type { IWccConfig, IWccSection } from './wcctools.interfaces.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';
// Export types for external use
export type { IWccConfig, IWccSection } from './wcctools.interfaces.js';
/**
* Converts legacy (elements, pages) format to new sections config
*/
const convertLegacyToConfig = (
elementsArg?: { [key: string]: LitElement },
pagesArg?: Record<string, TTemplateFactory>
): IWccConfig => {
const sections: IWccSection[] = [];
if (pagesArg && Object.keys(pagesArg).length > 0) {
sections.push({
name: 'Pages',
type: 'pages',
items: pagesArg,
});
}
if (elementsArg && Object.keys(elementsArg).length > 0) {
sections.push({
name: 'Elements',
type: 'elements',
items: elementsArg,
});
}
return { sections };
};
/**
* Check if the argument is the new config format
*/
const isWccConfig = (arg: any): arg is IWccConfig => {
return arg && typeof arg === 'object' && 'sections' in arg && Array.isArray(arg.sections);
};
/**
* Setup WCC Tools dashboard
*
* New format (recommended):
* ```typescript
* setupWccTools({
* sections: [
* { name: 'Elements', type: 'elements', items: elements },
* { name: 'Pages', type: 'pages', items: pages },
* ]
* });
* ```
*
* Legacy format (still supported):
* ```typescript
* setupWccTools(elements, pages);
* ```
*/
const setupWccTools = (
configOrElements?: IWccConfig | { [key: string]: LitElement },
pagesArg?: Record<string, TTemplateFactory>
) => {
let config: IWccConfig;
if (isWccConfig(configOrElements)) {
config = configOrElements;
} else {
config = convertLegacyToConfig(configOrElements, pagesArg);
}
const setupWccTools = (elementsArg?: { [key: string]: LitElement }, pagesArg?: { [key: string]: () => TemplateResult }) => {
let hasRun = false;
const runWccToolsSetup = async () => {
if (document.readyState === 'complete' && !hasRun) {
hasRun = true;
const wccTools = new WccDashboard(elementsArg as any, pagesArg);
const wccTools = new WccDashboard(config);
document.querySelector('body').append(wccTools);
}
};

173
ts_web/readme.md Normal file
View File

@@ -0,0 +1,173 @@
# @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
### Sections-based Configuration (Recommended)
```typescript
import { setupWccTools } from '@design.estate/dees-wcctools';
import * as elements from './elements/index.js';
import * as views from './views/index.js';
import * as pages from './pages/index.js';
setupWccTools({
sections: [
{
name: 'Pages',
type: 'pages',
items: pages,
},
{
name: 'Views',
type: 'elements',
items: views,
icon: 'web',
},
{
name: 'Elements',
type: 'elements',
items: elements,
filter: (name) => !name.startsWith('internal-'),
sort: ([a], [b]) => a.localeCompare(b),
},
],
});
```
### Legacy Format (Still Supported)
```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 |
| `IWccConfig` | TypeScript interface for sections configuration |
| `IWccSection` | TypeScript interface for individual section |
### 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 |
## Section Configuration
| Property | Type | Description |
|----------|------|-------------|
| `name` | `string` | Display name for the section header |
| `type` | `'elements' \| 'pages'` | Rendering behavior |
| `items` | `Record<string, any>` | Element classes or page factories |
| `filter` | `(name, item) => boolean` | Optional filter function |
| `sort` | `([a, itemA], [b, itemB]) => number` | Optional sort function |
| `icon` | `string` | Material Symbols icon name |
| `collapsed` | `boolean` | Start section collapsed (default: false) |
## Internal Components
The module includes these internal web components:
| Component | Description |
|-----------|-------------|
| `wcc-dashboard` | Main dashboard container with routing |
| `wcc-sidebar` | Navigation sidebar with collapsible sections |
| `wcc-frame` | Responsive viewport with size controls |
| `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);
// Start audio level monitoring
await recorder.startAudioMonitoring(mics[0].deviceId);
// Start recording
await recorder.startRecording({
mode: 'viewport',
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
├── wcctools.interfaces.ts # Type definitions
├── elements/
│ ├── wcc-dashboard.ts # Root dashboard component
│ ├── wcc-sidebar.ts # Navigation sidebar
│ ├── wcc-frame.ts # Responsive 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
- 📂 Section-based sidebar with filtering & sorting
- 🔧 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,393 @@
/**
* 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 async handleRecordingComplete(): Promise<void> {
// Create blob from recorded chunks
const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
this._recordedBlob = blob;
// 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

@@ -0,0 +1,31 @@
/**
* Configuration for a section in the WCC Tools sidebar
*/
export interface IWccSection {
/** Display name for the section header */
name: string;
/** How items in this section are rendered - 'elements' show demos, 'pages' render directly */
type: 'elements' | 'pages';
/** The items in this section - either element classes or page factory functions */
items: Record<string, any>;
/** Optional filter function to include/exclude items */
filter?: (name: string, item: any) => boolean;
/** Optional sort function for ordering items */
sort?: (a: [string, any], b: [string, any]) => number;
/** Optional Material icon name for the section header */
icon?: string;
/** Whether this section should start collapsed (default: false) */
collapsed?: boolean;
}
/**
* Configuration object for setupWccTools
*/
export interface IWccConfig {
sections: IWccSection[];
}
/**
* Type for element selection types - now section-based
*/
export type TElementType = 'element' | 'page';

View File

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