Finished inital project migration workflow

This commit is contained in:
Richard Osborne
2025-12-15 11:58:55 +01:00
parent 1477a29ff7
commit 0b47d19776
44 changed files with 8995 additions and 174 deletions

View File

@@ -0,0 +1,688 @@
# Project: Node Canvas Editor Modernization
## Overview
**Goal:** Transform the custom node canvas editor from an opaque, monolithic legacy system into a well-documented, modular, and testable architecture that the team can confidently extend and maintain.
**Why this matters:**
- The canvas is the core developer UX - every user interaction flows through it
- Current ~2000+ line monolith (`nodegrapheditor.ts`) is intimidating for contributors
- AI-assisted coding works dramatically better with smaller, focused files
- Enables future features (minimap, connection tracing, better comments) without fear
- Establishes patterns for modernizing other legacy parts of the codebase
**Out of scope (for now):**
- Migration to React Flow or other library
- Runtime/execution changes
- New feature implementation (those come after this foundation)
---
## Current Architecture Analysis
### Core Files
| File | Lines (est.) | Responsibility | Coupling Level |
|------|--------------|----------------|----------------|
| `nodegrapheditor.ts` | ~2000+ | Everything: rendering, interaction, selection, pan/zoom, connections, undo, clipboard | Extreme - God object |
| `NodeGraphEditorNode.ts` | ~600 | Node rendering, layout, port drawing | High - tied to parent |
| `NodeGraphEditorConnection.ts` | ~300 | Connection/noodle rendering, hit testing | Medium |
| `commentlayer.ts` | ~400 | Comment system orchestration | Medium - React bridge |
| `CommentLayer/*.tsx` | ~500 total | Comment React components | Lower - mostly isolated |
### Key Integration Points
The canvas talks to these systems (will need interface boundaries):
- `ProjectModel.instance` - Project state singleton
- `NodeLibrary.instance` - Node type definitions, color schemes
- `DebugInspector.InspectorsModel` - Data inspection/pinning
- `WarningsModel.instance` - Node warning states
- `UndoQueue.instance` - Undo/redo management
- `EventDispatcher.instance` - Global event bus
- `PopupLayer.instance` - Context menus, tooltips
- `ToastLayer` - User notifications
### Current Rendering Pipeline
```
paint() called
→ clearRect()
→ scale & translate context
→ paintHierarchy() - parent/child lines
→ paint connections (normal)
→ paint connections (highlighted - second pass for z-order)
→ paint nodes
→ paint drag indicators
→ paint multiselect box
→ paint dragging connection preview
```
### Current Interaction Handling
All mouse events funnel through single `mouse(type, pos, evt)` method with massive switch/if chains handling:
- Node selection (single, multi, add-to)
- Node dragging
- Connection creation
- Pan (right-click, middle-click, space+left)
- Zoom (wheel)
- Context menus
- Insert location indicators
---
## Target Architecture
### Module Structure
```
views/
└── NodeGraphEditor/
├── index.ts # Public API export
├── NodeGraphEditor.ts # Main orchestrator (slim)
├── ARCHITECTURE.md # Living documentation
├── core/
│ ├── CanvasRenderer.ts # Canvas 2D rendering pipeline
│ ├── ViewportManager.ts # Pan, zoom, scale, bounds
│ ├── GraphLayout.ts # Node positioning, AABB calculations
│ └── types.ts # Shared interfaces and types
├── interaction/
│ ├── InteractionManager.ts # Mouse/keyboard event routing
│ ├── SelectionManager.ts # Single/multi select, highlight state
│ ├── DragManager.ts # Node dragging, drop targets
│ ├── ConnectionDragManager.ts # Creating new connections
│ └── PanZoomHandler.ts # Viewport manipulation
├── rendering/
│ ├── NodeRenderer.ts # Individual node painting
│ ├── ConnectionRenderer.ts # Connection/noodle painting
│ ├── HierarchyRenderer.ts # Parent-child relationship lines
│ └── OverlayRenderer.ts # Selection boxes, drag previews
├── features/
│ ├── ClipboardManager.ts # Cut, copy, paste
│ ├── UndoIntegration.ts # UndoQueue bridge
│ ├── ContextMenus.ts # Right-click menus
│ └── ConnectionTracer.ts # NEW: Connection chain navigation
├── comments/ # Existing React layer (enhance)
│ ├── CommentLayer.ts
│ ├── CommentLayerView.tsx
│ ├── CommentForeground.tsx
│ ├── CommentBackground.tsx
│ └── CommentStyles.ts # NEW: Extended styling options
└── __tests__/
├── CanvasRenderer.test.ts
├── ViewportManager.test.ts
├── SelectionManager.test.ts
├── ConnectionRenderer.test.ts
└── integration/
└── NodeGraphEditor.integration.test.ts
```
### Key Interfaces
```typescript
// core/types.ts
export interface IViewport {
readonly pan: { x: number; y: number };
readonly scale: number;
readonly bounds: AABB;
setPan(x: number, y: number): void;
setScale(scale: number, focalPoint?: Point): void;
screenToCanvas(screenPoint: Point): Point;
canvasToScreen(canvasPoint: Point): Point;
fitToContent(padding?: number): void;
}
export interface ISelectionManager {
readonly selectedNodes: ReadonlyArray<NodeGraphEditorNode>;
readonly highlightedNode: NodeGraphEditorNode | null;
readonly highlightedConnection: NodeGraphEditorConnection | null;
select(nodes: NodeGraphEditorNode[]): void;
addToSelection(node: NodeGraphEditorNode): void;
removeFromSelection(node: NodeGraphEditorNode): void;
clearSelection(): void;
setHighlight(node: NodeGraphEditorNode | null): void;
isSelected(node: NodeGraphEditorNode): boolean;
// Events
on(event: 'selectionChanged', handler: (nodes: NodeGraphEditorNode[]) => void): void;
}
export interface IConnectionTracer {
// Start tracing from a connection
startTrace(connection: NodeGraphEditorConnection): void;
// Navigate along the trace
nextConnection(): NodeGraphEditorConnection | null;
previousConnection(): NodeGraphEditorConnection | null;
// Get all connections in current trace
getTraceChain(): ReadonlyArray<NodeGraphEditorConnection>;
// Clear trace state
clearTrace(): void;
// Visual state
readonly activeTrace: ReadonlyArray<NodeGraphEditorConnection>;
}
export interface IRenderContext {
ctx: CanvasRenderingContext2D;
viewport: IViewport;
paintRect: AABB;
theme: ColorScheme;
}
```
---
## Implementation Phases
### Phase 1: Documentation & Analysis (3-4 days)
**Goal:** Fully understand and document current system before changing anything.
**Tasks:**
1. Create `ARCHITECTURE.md` documenting:
- Current file responsibilities
- Data flow diagrams
- Event flow diagrams
- Integration point catalog
- Known quirks and gotchas
2. Add inline documentation to existing code:
- JSDoc for all public methods
- Explain non-obvious logic
- Mark technical debt with `// TODO(canvas-refactor):`
3. Create dependency graph visualization
**Deliverables:**
- `NodeGraphEditor/ARCHITECTURE.md`
- Fully documented `nodegrapheditor.ts` (comments only, no code changes)
- Mermaid diagram of component interactions
**Confidence checkpoint:** Can explain any part of the canvas system to a new developer.
---
### Phase 2: Testing Foundation (4-5 days)
**Goal:** Establish testing infrastructure before refactoring.
**Tasks:**
1. Set up testing environment for canvas code:
- Jest configuration for canvas mocking
- Helper utilities for creating test nodes/connections
- Snapshot testing for render output (optional)
2. Write characterization tests for current behavior:
- Selection behavior (single click, shift+click, ctrl+click, marquee)
- Pan/zoom behavior
- Connection creation
- Clipboard operations
- Undo/redo integration
3. Create test fixtures:
- Sample graph configurations
- Mock ProjectModel, NodeLibrary, etc.
**Deliverables:**
- `__tests__/` directory structure
- Test utilities and fixtures
- 70%+ characterization test coverage for interaction logic
- CI integration for canvas tests
**Confidence checkpoint:** Tests catch regressions when code is modified.
---
### Phase 3: Extract Core Modules (5-6 days)
**Goal:** Pull out clearly separable concerns without changing behavior.
**Order of extraction (lowest risk first):**
1. **ViewportManager** (~1 day)
- Extract: `getPanAndScale`, `setPanAndScale`, `clampPanAndScale`, `updateZoomLevel`, `centerToFit`
- Pure calculations, minimal dependencies
- Easy to test independently
2. **GraphLayout** (~1 day)
- Extract: `calculateNodesAABB`, `getCenterPanAndScale`, `getCenterRootPanAndScale`, AABB utilities
- Pure geometry calculations
- Easy to test
3. **SelectionManager** (~1.5 days)
- Extract: `selector` object, highlight state, multi-select logic
- Currently scattered across mouse handlers
- Introduce event emitter for state changes
4. **ClipboardManager** (~1 day)
- Extract: `copySelected`, `paste`, `getNodeSetFromClipboard`, `insertNodeSet`
- Relatively self-contained
5. **Types & Interfaces** (~0.5 days)
- Create `types.ts` with all shared interfaces
- Migrate inline types
**Approach for each extraction:**
```
1. Create new file with extracted code
2. Import into nodegrapheditor.ts
3. Delegate calls to new module
4. Run tests - verify no behavior change
5. Commit
```
**Deliverables:**
- `core/ViewportManager.ts` with tests
- `core/GraphLayout.ts` with tests
- `interaction/SelectionManager.ts` with tests
- `features/ClipboardManager.ts` with tests
- `core/types.ts`
**Confidence checkpoint:** `nodegrapheditor.ts` reduced by ~400-500 lines, all tests pass.
---
### Phase 4: Extract Rendering Pipeline (4-5 days)
**Goal:** Separate what we draw from when/why we draw it.
**Tasks:**
1. **CanvasRenderer** (~1.5 days)
- Extract: `paint()` method orchestration
- Introduce `IRenderContext` for dependency injection
- Make rendering stateless (receives state, outputs pixels)
2. **NodeRenderer** (~1 day)
- Extract from `NodeGraphEditorNode.paint()`
- Parameterize colors, sizes for future customization
- Document the rendering anatomy of a node
3. **ConnectionRenderer** (~1 day)
- Extract from `NodeGraphEditorConnection.paint()`
- Prepare for future routing algorithms
- Add support for trace highlighting (prep for Phase 6)
4. **OverlayRenderer** (~0.5 days)
- Extract: multiselect box, drag preview, insert indicators
- These are temporary visual states
**Deliverables:**
- `rendering/` module with all renderers
- Renderer unit tests
- Clear separation: state management ≠ rendering
**Confidence checkpoint:** Can modify node appearance without touching interaction code.
---
### Phase 5: Extract Interaction Handling (4-5 days)
**Goal:** Untangle the mouse event spaghetti.
**Tasks:**
1. **InteractionManager** (~1 day)
- Central event router
- Delegates to specialized handlers based on state
- Manages interaction modes (normal, panning, dragging, connecting)
2. **DragManager** (~1 day)
- Node drag start/move/end
- Drop target detection
- Insert location indicators
3. **ConnectionDragManager** (~1 day)
- New connection creation flow
- Port detection and highlighting
- Connection preview rendering
4. **PanZoomHandler** (~0.5 days)
- Mouse wheel zoom
- Right/middle click pan
- Space+drag pan
5. **Refactor main mouse() method** (~0.5 days)
- Reduce to simple routing logic
- Each handler owns its interaction mode
**Deliverables:**
- `interaction/` module complete
- Interaction tests (simulate mouse events)
- `nodegrapheditor.ts` mouse handling reduced to ~50 lines
**Confidence checkpoint:** Can add new interaction modes without touching existing handlers.
---
### Phase 6: Feature Enablement - Connection Tracer (3-4 days)
**Goal:** Implement connection tracing as proof that the new architecture works.
**Feature spec:**
- Click a connection to start tracing
- Highlighted connection chain shows the data flow path
- Keyboard navigation (Tab/Shift+Tab) to walk the chain
- Visual distinction for traced connections (glow, thicker line, different color)
- Click elsewhere or Escape to clear trace
**Tasks:**
1. **ConnectionTracer module** (~1.5 days)
- Graph traversal logic
- Find upstream/downstream connections from a node's port
- Handle cycles gracefully
2. **Visual integration** (~1 day)
- Extend `ConnectionRenderer` for trace state
- Add trace highlight color to theme
- Subtle animation for active trace (optional)
3. **Interaction integration** (~1 day)
- Add to `InteractionManager`
- Keyboard handler for navigation
- Context menu option: "Trace connection"
**Deliverables:**
- `features/ConnectionTracer.ts` with full tests
- Working connection tracing feature
- Documentation for how to add similar features
**Confidence checkpoint:** Feature works, and implementation was straightforward given new architecture.
---
### Phase 7: Feature Enablement - Comment Enhancements (2-3 days)
**Goal:** Improve comment system as second proof point.
**Feature spec:**
- More color options
- Border style options (solid, dashed, none)
- Font size options (small, medium, large, extra-large)
- Opacity control for filled comments
- Corner radius options
- Z-index control (send to back, bring to front)
**Tasks:**
1. **Extend comment model** (~0.5 days)
- Add new properties: borderStyle, fontSize, opacity, cornerRadius, zIndex
- Migration for existing comments (defaults)
2. **Update CommentForeground controls** (~1 day)
- Extended toolbar UI
- New control components
3. **Update rendering** (~0.5 days)
- Apply new styles in CommentBackground
- CSS updates
4. **Tests** (~0.5 days)
- Comment styling tests
- Backward compatibility tests
**Deliverables:**
- Enhanced comment styling options
- Updated `CommentStyles.ts`
- Tests for new functionality
---
## File Change Summary
### Files to Create
```
views/NodeGraphEditor/
├── ARCHITECTURE.md
├── core/
│ ├── CanvasRenderer.ts
│ ├── ViewportManager.ts
│ ├── GraphLayout.ts
│ └── types.ts
├── interaction/
│ ├── InteractionManager.ts
│ ├── SelectionManager.ts
│ ├── DragManager.ts
│ ├── ConnectionDragManager.ts
│ └── PanZoomHandler.ts
├── rendering/
│ ├── NodeRenderer.ts
│ ├── ConnectionRenderer.ts
│ ├── HierarchyRenderer.ts
│ └── OverlayRenderer.ts
├── features/
│ ├── ClipboardManager.ts
│ ├── UndoIntegration.ts
│ ├── ContextMenus.ts
│ └── ConnectionTracer.ts
├── comments/
│ └── CommentStyles.ts
└── __tests__/
└── [comprehensive test suite]
```
### Files to Modify
- `nodegrapheditor.ts` → Slim orchestrator importing modules
- `NodeGraphEditorNode.ts` → Delegate rendering to NodeRenderer
- `NodeGraphEditorConnection.ts` → Delegate rendering to ConnectionRenderer
- `CommentLayerView.tsx` → Extended styling UI
- `CommentForeground.tsx` → New controls
- `CommentBackground.tsx` → New style application
### Files Unchanged
- `commentlayer.ts` → Keep as bridge layer (minor updates)
- Model files (ProjectModel, NodeLibrary, etc.) → Interface boundaries only
---
## Testing Strategy
### Unit Tests
Each extracted module gets comprehensive unit tests:
```typescript
// Example: ViewportManager.test.ts
describe('ViewportManager', () => {
describe('screenToCanvas', () => {
it('converts screen coordinates at scale 1', () => {
const viewport = new ViewportManager({ width: 800, height: 600 });
viewport.setPan(100, 50);
const result = viewport.screenToCanvas({ x: 200, y: 150 });
expect(result).toEqual({ x: 100, y: 100 });
});
it('accounts for scale when converting', () => {
const viewport = new ViewportManager({ width: 800, height: 600 });
viewport.setScale(0.5);
viewport.setPan(100, 50);
const result = viewport.screenToCanvas({ x: 200, y: 150 });
expect(result).toEqual({ x: 300, y: 250 });
});
});
describe('fitToContent', () => {
it('adjusts pan and scale to show all nodes', () => {
// ...
});
});
});
```
### Integration Tests
Test module interactions:
```typescript
// Example: Selection + Rendering integration
describe('Selection rendering integration', () => {
it('renders selection box around selected nodes', () => {
const graph = createTestGraph([
{ id: 'node1', x: 0, y: 0 },
{ id: 'node2', x: 200, y: 0 }
]);
const selection = new SelectionManager();
const renderer = new CanvasRenderer();
selection.select([graph.nodes[0], graph.nodes[1]]);
renderer.render(graph, selection);
expect(renderer.getLastRenderCall()).toContainOverlay('multiselect-box');
});
});
```
### Characterization Tests
Capture current behavior before refactoring:
```typescript
// Example: Existing pan behavior
describe('Pan behavior (characterization)', () => {
it('right-click drag pans the viewport', async () => {
const editor = await createTestEditor();
const initialPan = editor.getPanAndScale();
await editor.simulateMouseEvent('down', { x: 100, y: 100, button: 2 });
await editor.simulateMouseEvent('move', { x: 150, y: 120 });
await editor.simulateMouseEvent('up', { x: 150, y: 120, button: 2 });
const finalPan = editor.getPanAndScale();
expect(finalPan.x - initialPan.x).toBe(50);
expect(finalPan.y - initialPan.y).toBe(20);
});
});
```
---
## Success Criteria
### Quantitative
- [ ] `nodegrapheditor.ts` reduced from ~2000 to <500 lines
- [ ] No single file >400 lines in new structure
- [ ] Test coverage >80% for new modules
- [ ] All existing functionality preserved (zero regressions)
### Qualitative
- [ ] New developer can understand canvas architecture in <30 minutes
- [ ] Adding a new interaction mode takes <2 hours
- [ ] Adding a new visual effect takes <1 hour
- [ ] AI coding assistants can work effectively with individual modules
- [ ] `ARCHITECTURE.md` accurately describes the system
### Feature Validation
- [ ] Connection tracing works as specified
- [ ] Comment enhancements work as specified
- [ ] Both features implemented using new architecture patterns
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Hidden dependencies break during extraction | Medium | High | Extensive characterization tests before any changes |
| Performance regression from module overhead | Low | Medium | Benchmark critical paths, keep hot loops tight |
| Over-engineering abstractions | Medium | Medium | Extract only what exists, don't pre-build for imagined needs |
| Scope creep into features | Medium | Medium | Strict phase gates, no features until Phase 6 |
| Breaking existing user workflows | Low | High | Full test coverage, careful rollout |
---
## Estimated Timeline
| Phase | Duration | Dependencies |
|-------|----------|--------------|
| Phase 1: Documentation | 3-4 days | None |
| Phase 2: Testing Foundation | 4-5 days | Phase 1 |
| Phase 3: Core Modules | 5-6 days | Phase 2 |
| Phase 4: Rendering | 4-5 days | Phase 3 |
| Phase 5: Interaction | 4-5 days | Phase 3, 4 |
| Phase 6: Connection Tracer | 3-4 days | Phase 5 |
| Phase 7: Comment Enhancements | 2-3 days | Phase 4 |
**Total: 26-32 days** (5-7 weeks at sustainable pace)
Phases 6 and 7 can be done in parallel or interleaved with other work.
---
## Getting Started
1. Create feature branch: `feature/canvas-editor-modernization`
2. Start with Phase 1 - no code changes, just documentation
3. Review `ARCHITECTURE.md` with team before proceeding
4. Set up CI for canvas tests before Phase 3
5. Small, frequent commits with clear messages
---
## Appendix: Current Code Locations
```
packages/noodl-editor/src/editor/src/views/
├── nodegrapheditor.ts # Main canvas (THE MONOLITH)
├── nodegrapheditor/
│ ├── NodeGraphEditorNode.ts # Node rendering
│ └── NodeGraphEditorConnection.ts # Connection rendering
├── commentlayer.ts # Comment orchestration
├── CommentLayer/
│ ├── CommentLayer.css
│ ├── CommentLayerView.tsx
│ ├── CommentForeground.tsx
│ └── CommentBackground.tsx
└── documents/EditorDocument/
└── hooks/
├── UseCanvasView.ts
└── UseImportNodeset.ts
```
---
## Notes for AI-Assisted Development
When working with Cline or similar tools on this refactoring:
1. **Single module focus**: Work on one module at a time, complete with tests
2. **Confidence checks**: After each extraction, verify tests pass before continuing
3. **Small commits**: Each extraction should be a single, reviewable commit
4. **Documentation first**: Update `ARCHITECTURE.md` as you go
5. **No premature optimization**: Extract what exists, optimize later if needed
Example prompt structure for Phase 3 extractions:
```
"Extract ViewportManager from nodegrapheditor.ts:
1. Identify all pan/zoom/scale related code
2. Create core/ViewportManager.ts with those methods
3. Create interface IViewport in types.ts
4. Add comprehensive unit tests
5. Update nodegrapheditor.ts to use ViewportManager
6. Verify all existing tests still pass
7. Confidence score before committing?"
```

View File

@@ -239,6 +239,305 @@ render() {
---
## Electron & Node.js Patterns
### [2025-12-14] - EPIPE Errors When Writing to stdout
**Context**: Editor was crashing with `Error: write EPIPE` when trying to open projects.
**Discovery**: EPIPE errors occur when a process tries to write to stdout/stderr but the receiving pipe has been closed (e.g., the terminal or parent process that spawned the subprocess is gone). In Electron apps, this happens when:
- The terminal that started `npm run dev` is closed before the app
- The parent process that spawned a child dies unexpectedly
- stdout is redirected to a file that gets closed
Cloud-function-server.js was calling `console.log()` during project operations. When the stdout pipe was broken, the error bubbled up and crashed the editor.
**Fix**: Wrap console.log calls in a try-catch:
```javascript
function safeLog(...args) {
try {
console.log(...args);
} catch (e) {
// Ignore EPIPE errors - stdout pipe may be broken
}
}
```
**Location**: `packages/noodl-editor/src/main/src/cloud-function-server.js`
**Keywords**: EPIPE, console.log, stdout, broken pipe, electron, subprocess, crash
---
## Webpack & Build Patterns
### [2025-12-14] - Webpack SCSS Cache Can Persist Old Files
**Context**: MigrationWizard.module.scss was fixed on disk but webpack kept showing errors for a removed import line.
**Discovery**: Webpack's sass-loader caches compiled SCSS files aggressively. Even after fixing a file on disk, if an old error is cached, webpack may continue to report the stale error. This is especially confusing because:
- `cat` and `grep` show the correct file contents
- But webpack reports errors for lines that no longer exist
- The webpack process may be from a previous session that cached the old content
**Fix Steps**:
1. Kill ALL webpack processes: `pkill -9 -f webpack`
2. Clear webpack cache: `rm -rf node_modules/.cache/` in the affected package
3. Touch the file to force rebuild: `touch path/to/file.scss`
4. Restart dev server fresh
**Location**: Any SCSS file processed by sass-loader
**Keywords**: webpack, sass-loader, cache, SCSS, stale error, module build failed
---
## Event-Driven UI Patterns
### [2025-12-14] - Async Detection Requires Re-render Listener
**Context**: Migration UI badges weren't showing on legacy projects even though runtime detection was working.
**Discovery**: In OpenNoodl's jQuery-based View system, the template is rendered once when `render()` is called. If data is populated asynchronously (e.g., runtime detection), the UI won't update unless you explicitly listen for a completion event and re-render.
The pattern:
1. `renderProjectItems()` is called - projects show without runtime info
2. `detectAllProjectRuntimes()` runs async in background
3. Detection completes, `runtimeDetectionComplete` event fires
4. BUT... no one was listening → UI stays stale
**Fix**: Subscribe to the async completion event in the View:
```javascript
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
```
This pattern applies to any async data in the jQuery View system:
- Runtime detection
- Cloud service status
- Git remote checks
- etc.
**Location**: `packages/noodl-editor/src/editor/src/views/projectsview.ts`
**Keywords**: async, re-render, event listener, runtimeDetectionComplete, jQuery View, stale UI
---
## CSS & Styling Patterns
### [2025-12-14] - BaseDialog `::after` Pseudo-Element Blocks Clicks
**Context**: Migration wizard popup buttons weren't clickable at all - no response to any interaction.
**Discovery**: The BaseDialog component uses a `::after` pseudo-element on `.VisibleDialog` to render the background color. This pseudo covers the entire dialog area:
```scss
.VisibleDialog {
&::after {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--background);
// Without pointer-events: none, this blocks all clicks!
}
}
```
The `.ChildContainer` has `z-index: 1` which should put it above the `::after`, but due to stacking context behavior with `filter: drop-shadow()` on the parent, clicks were being intercepted by the pseudo-element.
**Fix**: Add `pointer-events: none` to the `::after` pseudo-element:
```scss
&::after {
// ...existing styles...
pointer-events: none; // Allow clicks to pass through
}
```
**Location**: `packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss`
**Keywords**: BaseDialog, ::after, pointer-events, click not working, buttons broken, Modal, dialog
---
### [2025-12-14] - Theme Color Variables Are `--theme-color-*` Not `--color-*`
**Context**: Migration wizard UI appeared gray-on-gray with unreadable text.
**Discovery**: OpenNoodl's theme system uses CSS variables prefixed with `--theme-color-*`, NOT `--color-*`. Using undefined variables like `--color-grey-800` results in invalid/empty values causing display issues.
**Correct Variables:**
| Wrong | Correct |
|-------|---------|
| `--color-grey-800` | `--theme-color-bg-3` |
| `--color-grey-700` | `--theme-color-bg-2` |
| `--color-grey-400`, `--color-grey-300` | `--theme-color-secondary-as-fg` (for text!) |
| `--color-grey-200`, `--color-grey-100` | `--theme-color-fg-highlight` |
| `--color-primary` | `--theme-color-primary` |
| `--color-success-500` | `--theme-color-success` |
| `--color-warning` | `--theme-color-warning` |
| `--color-danger` | `--theme-color-danger` |
**Location**: Any SCSS module files in `@noodl-core-ui` or `noodl-editor`
**Keywords**: CSS variables, theme-color, --color, --theme-color, gray text, contrast, undefined variable, SCSS
---
### [2025-12-14] - `--theme-color-secondary` Is NOT For Text - Use `--theme-color-secondary-as-fg`
**Context**: Migration wizard text was impossible to read even after using `--theme-color-*` prefix.
**Discovery**: Two commonly misused theme variables cause text to be unreadable:
1. **`--theme-color-fg-1` doesn't exist!** The correct variable is:
- `--theme-color-fg-highlight` = `#f5f5f5` (white/light text)
- `--theme-color-fg-default` = `#b8b8b8` (normal text)
- `--theme-color-fg-default-shy` = `#9a9999` (subtle text)
- `--theme-color-fg-muted` = `#7e7d7d` (muted text)
2. **`--theme-color-secondary` is a BACKGROUND color!**
- `--theme-color-secondary` = `#005769` (dark teal - use for backgrounds only!)
- `--theme-color-secondary-as-fg` = `#7ec2cf` (light teal - use for text!)
When text appears invisible/gray, check for these common mistakes:
```scss
// WRONG - produces invisible text
color: var(--theme-color-fg-1); // Variable doesn't exist!
color: var(--theme-color-secondary); // Dark teal background color!
// CORRECT - visible text
color: var(--theme-color-fg-highlight); // White text
color: var(--theme-color-secondary-as-fg); // Light teal text
```
**Color Reference from `colors.css`:**
```css
--theme-color-bg-1: #151414; /* Darkest background */
--theme-color-bg-2: #292828;
--theme-color-bg-3: #3c3c3c;
--theme-color-bg-4: #504f4f; /* Lightest background */
--theme-color-fg-highlight: #f5f5f5; /* Bright white text */
--theme-color-fg-default-contrast: #d4d4d4; /* High contrast text */
--theme-color-fg-default: #b8b8b8; /* Normal text */
--theme-color-fg-default-shy: #9a9999; /* Subtle text */
--theme-color-fg-muted: #7e7d7d; /* Muted text */
--theme-color-secondary: #005769; /* BACKGROUND only! */
--theme-color-secondary-as-fg: #7ec2cf; /* For text */
```
**Location**: `packages/noodl-core-ui/src/styles/custom-properties/colors.css`
**Keywords**: --theme-color-fg-1, --theme-color-secondary, invisible text, gray on gray, secondary-as-fg, text color, theme variables
---
### [2025-12-14] - Flex Container Scrolling Requires `min-height: 0`
**Context**: Migration wizard content wasn't scrollable on shorter screens.
**Discovery**: When using flexbox with `overflow: auto` on a child, the child needs `min-height: 0` (or `min-width: 0` for horizontal) to allow it to shrink below its content size. Without this, the default `min-height: auto` prevents shrinking and breaks scrolling.
**Pattern:**
```scss
.Parent {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
.ScrollableChild {
flex: 1;
min-height: 0; // Critical! Allows shrinking
overflow-y: auto;
}
```
The `min-height: 0` overrides the default `min-height: auto` which would prevent the element from being smaller than its content.
**Location**: Any scrollable flex container, e.g., `MigrationWizard.module.scss`
**Keywords**: flex, overflow, scroll, min-height, flex-shrink, not scrolling, content cut off
---
### [2025-12-14] - useReducer State Must Be Initialized Before Actions Work
**Context**: Migration wizard "Start Migration" button did nothing - no errors, no state change, no visual feedback.
**Discovery**: When using `useReducer` to manage component state, all action handlers typically guard against null state:
```typescript
case 'START_SCAN':
if (!state.session) return state; // Does nothing if session is null!
return { ...state, session: { ...state.session, step: 'scanning' } };
```
The bug pattern:
1. Component initializes with `session: null` in reducer state
2. External manager (`migrationSessionManager`) creates and stores the session
3. UI renders using `manager.getSession()` - works fine
4. Button click dispatches action to reducer
5. Reducer checks `if (!state.session)` → returns unchanged state
6. Nothing happens - no errors, no visual change
The fix is to dispatch a `SET_SESSION` action to initialize the reducer state:
```typescript
// In useEffect after creating session:
const session = await manager.createSession(...);
dispatch({ type: 'SET_SESSION', session }); // Initialize reducer!
// In reducer:
case 'SET_SESSION':
return { ...state, session: action.session };
```
**Key Insight**: If using both an external manager AND useReducer, the reducer state must be explicitly synchronized with the manager's state for actions to work.
**Location**: `packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx`
**Keywords**: useReducer, dispatch, null state, button does nothing, state not updating, SET_SESSION, state synchronization
---
### [2025-12-14] - CoreBaseDialog vs Modal Component Patterns
**Context**: Migration wizard popup wasn't working - clicks blocked, layout broken.
**Discovery**: OpenNoodl has two dialog patterns:
1. **CoreBaseDialog** (Working, Recommended):
- Direct component from `@noodl-core-ui/components/layout/BaseDialog`
- Used by ConfirmDialog and other working dialogs
- Props: `isVisible`, `hasBackdrop`, `onClose`
- Content is passed as children
2. **Modal** (Problematic):
- Wrapper component with additional complexity
- Was causing issues with click handling and layout
When creating new dialogs, use the CoreBaseDialog pattern:
```tsx
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
<div className={css['YourContainer']}>
{/* Your content */}
</div>
</CoreBaseDialog>
```
**Location**:
- Working example: `packages/noodl-editor/src/editor/src/views/ConfirmDialog/`
- `packages/noodl-core-ui/src/components/layout/BaseDialog/`
**Keywords**: CoreBaseDialog, Modal, dialog, popup, BaseDialog, modal not working, clicks blocked
---
## Template for Future Entries
```markdown

View File

@@ -2,6 +2,388 @@
## [Unreleased]
### Session 8: Migration Marker Fix
#### 2024-12-15
**Fixed:**
- **Migrated Projects Still Showing as Legacy** (`MigrationSession.ts`):
- Root cause: `executeFinalizePhase()` was a placeholder with just `await this.simulateDelay(200)` and never updated project.json
- The runtime detection system checks for `runtimeVersion` or `migratedFrom` fields in project.json
- Without these markers, migrated projects were still detected as legacy React 17
- Implemented actual finalization that:
1. Reads the project.json from the target path
2. Adds `runtimeVersion: "react19"` field
3. Adds `migratedFrom` metadata object with:
- `version: "react17"` - what it was migrated from
- `date` - ISO timestamp of migration
- `originalPath` - path to source project
- `aiAssisted` - whether AI was used
4. Writes the updated project.json back
- Migrated projects now correctly identified as React 19 in project list
**Technical Notes:**
- Runtime detection checks these fields in order:
1. `runtimeVersion` field (highest confidence)
2. `migratedFrom` field (indicates already migrated)
3. `editorVersion` comparison to 1.2.0
4. Legacy pattern scanning
5. Creation date heuristic (lowest confidence)
- Adding `runtimeVersion: "react19"` provides "high" confidence detection
**Files Modified:**
```
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
```
---
### Session 7: Complete Migration Implementation
#### 2024-12-14
**Fixed:**
- **Text Color Invisible (Gray on Gray)** (All migration SCSS files):
- Root cause: SCSS files used non-existent CSS variables like `--theme-color-fg-1` and `--theme-color-secondary` for text
- `--theme-color-fg-1` doesn't exist in the theme - it's `--theme-color-fg-highlight`
- `--theme-color-secondary` is a dark teal color (`#005769`) meant for backgrounds, not text
- For text, should use `--theme-color-secondary-as-fg` which is a visible teal (`#7ec2cf`)
- Updated all migration SCSS files with correct variable names:
- `--theme-color-fg-1``--theme-color-fg-highlight` (white text, `#f5f5f5`)
- `--theme-color-secondary` (when used for text color) → `--theme-color-secondary-as-fg` (readable teal, `#7ec2cf`)
- Text is now visible with proper contrast against dark backgrounds
- **Migration Does Not Create Project Folder** (`MigrationSession.ts`):
- Root cause: `executeCopyPhase()` was a placeholder that never actually copied files
- Implemented actual file copying using `@noodl/platform` filesystem API
- New `copyDirectoryRecursive()` method recursively copies all project files
- Skips `node_modules` and `.git` directories for efficiency
- Checks if target directory exists before copying (prevents overwrites)
- **"Open Migrated Project" Button Does Nothing** (`projectsview.ts`):
- Root cause: `onComplete` callback didn't receive or use the target path
- Updated callback signature to receive `targetPath: string` parameter
- Now opens the migrated project from the correct target path
- Shows success toast and updates project list
**Technical Notes:**
- Theme color variable naming conventions:
- `--theme-color-bg-*` for backgrounds (bg-1 through bg-4, darker to lighter)
- `--theme-color-fg-*` for foreground/text (fg-highlight, fg-default, fg-default-shy, fg-muted)
- `--theme-color-secondary` is `#005769` (dark teal) - background only!
- `--theme-color-secondary-as-fg` is `#7ec2cf` (light teal) - use for text
- filesystem API:
- `filesystem.exists(path)` - check if path exists
- `filesystem.makeDirectory(path)` - create directory
- `filesystem.listDirectory(path)` - list contents (returns entries with `fullPath`, `name`, `isDirectory`)
- `filesystem.readFile(path)` - read file contents
- `filesystem.writeFile(path, content)` - write file contents
**Files Modified:**
```
packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts
packages/noodl-editor/src/editor/src/views/projectsview.ts
packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.module.scss
```
---
### Session 6: Dialog Pattern Fix & Button Functionality
#### 2024-12-14
**Fixed:**
- **"Start Migration" Button Does Nothing** (`MigrationWizard.tsx`):
- Root cause: useReducer `state.session` was never initialized
- Component used two sources of truth:
1. `migrationSessionManager.getSession()` for rendering - worked fine
2. `state.session` in reducer for actions - always null!
- All action handlers checked `if (!state.session) return state;` and returned unchanged
- Added `SET_SESSION` action type to initialize reducer state after session creation
- Button clicks now properly dispatch actions and update state
- **Switched from Modal to CoreBaseDialog** (`MigrationWizard.tsx`):
- Modal component was causing layout and interaction issues
- CoreBaseDialog is the pattern used by working dialogs like ConfirmDialog
- Changed import and component usage to use CoreBaseDialog directly
- Props: `isVisible`, `hasBackdrop`, `onClose`
- **Fixed duplicate variable declaration** (`MigrationWizard.tsx`):
- Had two `const session = migrationSessionManager.getSession()` declarations
- Renamed one to `currentSession` to avoid redeclaration error
**Technical Notes:**
- When using both an external manager AND useReducer, reducer state must be explicitly synchronized
- CoreBaseDialog is the preferred pattern for dialogs - simpler and more reliable than Modal
- Pattern for initializing reducer with async data:
```tsx
// In useEffect after async operation:
dispatch({ type: 'SET_SESSION', session: createdSession });
// In reducer:
case 'SET_SESSION':
return { ...state, session: action.session };
```
**Files Modified:**
```
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx
```
---
### Session 5: Critical UI Bug Fixes
#### 2024-12-14
**Fixed:**
- **Migration Wizard Buttons Not Clickable** (`BaseDialog.module.scss`):
- Root cause: The `::after` pseudo-element on `.VisibleDialog` was covering the entire dialog
- This overlay had no `pointer-events: none`, blocking all click events
- Added `pointer-events: none` to `::after` pseudo-element
- All buttons, icons, and interactive elements now work correctly
- **Migration Wizard Not Scrollable** (`MigrationWizard.module.scss`):
- Root cause: Missing proper flex layout and overflow settings
- Added `display: flex`, `flex-direction: column`, and `overflow: hidden` to `.MigrationWizard`
- Added `flex: 1`, `min-height: 0`, and `overflow-y: auto` to `.WizardContent`
- Modal content now scrolls properly on shorter screen heights
- **Gray-on-Gray Text (Low Contrast)** (All step SCSS modules):
- Root cause: SCSS files used undefined CSS variables like `--color-grey-800`, `--color-grey-400`, etc.
- The theme only defines `--theme-color-*` variables, causing undefined values
- Updated all migration wizard SCSS files to use proper theme variables:
- `--theme-color-bg-1`, `--theme-color-bg-2`, `--theme-color-bg-3` for backgrounds
- `--theme-color-fg-1` for primary text
- `--theme-color-secondary` for secondary text
- `--theme-color-primary`, `--theme-color-success`, `--theme-color-warning`, `--theme-color-danger` for status colors
- Text now has proper contrast against modal background
**Technical Notes:**
- BaseDialog uses a `::after` pseudo-element for background color rendering
- Without `pointer-events: none`, this pseudo covers content and blocks interaction
- Theme color variables follow pattern: `--theme-color-{semantic-name}`
- Custom color variables like `--color-grey-*` don't exist - always use theme variables
- Flex containers need `min-height: 0` on children to allow proper shrinking/scrolling
**Files Modified:**
```
packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.module.scss
packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scss
```
---
### Session 4: Bug Fixes & Polish
#### 2024-12-14
**Fixed:**
- **EPIPE Error on Project Open** (`cloud-function-server.js`):
- Added `safeLog()` wrapper function that catches and ignores EPIPE errors
- EPIPE occurs when stdout pipe is broken (e.g., terminal closed)
- All console.log calls in cloud-function-server now use safeLog
- Prevents editor crash when output pipe becomes unavailable
- **Runtime Detection Defaulting** (`ProjectScanner.ts`):
- Changed fallback runtime version from `'unknown'` to `'react17'`
- Projects without explicit markers now correctly identified as legacy
- Ensures old Noodl projects trigger migration UI even without version flags
- Updated indicator message: "No React 19 markers found - assuming legacy React 17 project"
- **Migration UI Not Showing** (`projectsview.ts`):
- Added listener for `'runtimeDetectionComplete'` event
- Project list now re-renders after async runtime detection completes
- Legacy badges and migrate buttons appear correctly for React 17 projects
- **SCSS Import Error** (`MigrationWizard.module.scss`):
- Removed invalid `@use '../../../../styles/utils/colors' as *;` import
- File was referencing non-existent styles/utils/colors.scss
- Webpack cache required clearing after fix
**Technical Notes:**
- safeLog pattern: `try { console.log(...args); } catch (e) { /* ignore EPIPE */ }`
- Runtime detection is async - UI must re-render after detection completes
- Webpack caches SCSS files aggressively - cache clearing may be needed after SCSS fixes
- The `runtimeDetectionComplete` event fires after `detectAllProjectRuntimes()` completes
**Files Modified:**
```
packages/noodl-editor/src/main/src/cloud-function-server.js
packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts
packages/noodl-editor/src/editor/src/views/projectsview.ts
packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss
```
---
### Session 3: Projects View Integration
#### 2024-12-14
**Added:**
- Extended `DialogLayerModel.tsx` with generic `showDialog()` method:
- Accepts render function `(close: () => void) => JSX.Element`
- Options include `onClose` callback for cleanup
- Enables mounting custom React components (like MigrationWizard) as dialogs
- Type: `ShowDialogOptions` interface added
- Extended `LocalProjectsModel.ts` with runtime detection:
- `RuntimeVersionInfo` import from migration/types
- `detectRuntimeVersion` import from migration/ProjectScanner
- `ProjectItemWithRuntime` interface extending ProjectItem with runtimeInfo
- In-memory cache: `runtimeInfoCache: Map<string, RuntimeVersionInfo>`
- Detection tracking: `detectingProjects: Set<string>`
- New methods:
- `getRuntimeInfo(projectPath)` - Get cached runtime info
- `isDetectingRuntime(projectPath)` - Check if detection in progress
- `getProjectsWithRuntime()` - Get all projects with runtime info
- `detectProjectRuntime(projectPath)` - Detect and cache runtime version
- `detectAllProjectRuntimes()` - Background detection for all projects
- `isLegacyProject(projectPath)` - Check if project is React 17
- `clearRuntimeCache(projectPath)` - Clear cache after migration
- Updated `projectsview.html` template with legacy project indicators:
- `data-class="isLegacy:projects-item--legacy"` conditional styling
- Legacy badge with warning SVG icon (positioned top-right)
- Legacy actions overlay with "Migrate Project" and "Open Read-Only" buttons
- Click handlers: `data-click="onMigrateProjectClicked"`, `data-click="onOpenReadOnlyClicked"`
- Detecting spinner with `data-class="isDetecting:projects-item-detecting"`
- Added CSS styles in `projectsview.css`:
- `.projects-item--legacy` - Orange border for legacy projects
- `.projects-item-legacy-badge` - Top-right warning badge
- `.projects-item-legacy-actions` - Hover overlay with migration buttons
- `.projects-item-migrate-btn` - Primary orange CTA button
- `.projects-item-readonly-btn` - Secondary ghost button
- `.projects-item-detecting` - Loading spinner animation
- `.hidden` utility class
- Updated `projectsview.ts` with migration handler logic:
- Imports for React, MigrationWizard, ProjectItemWithRuntime
- Extended `ProjectItemScope` type with `isLegacy` and `isDetecting` flags
- Updated `renderProjectItems()` to:
- Check `isLegacyProject()` and `isDetectingRuntime()` for each project
- Include flags in template scope for conditional rendering
- Trigger `detectAllProjectRuntimes()` on render
- New handlers:
- `onMigrateProjectClicked()` - Opens MigrationWizard via DialogLayerModel.showDialog()
- `onOpenReadOnlyClicked()` - Opens project normally (banner display deferred)
**Technical Notes:**
- DialogLayerModel uses existing Modal wrapper pattern with custom render function
- Runtime detection uses in-memory cache to avoid persistence to localStorage
- Template binding uses jQuery-based View system with `data-*` attributes
- CSS hover overlay only shows for legacy projects
- Tracker analytics integrated for "Migration Wizard Opened" and "Legacy Project Opened Read-Only"
- ToastLayer.showSuccess() used for migration completion notification
**Files Modified:**
```
packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx
packages/noodl-editor/src/editor/src/utils/LocalProjectsModel.ts
packages/noodl-editor/src/editor/src/templates/projectsview.html
packages/noodl-editor/src/editor/src/styles/projectsview.css
packages/noodl-editor/src/editor/src/views/projectsview.ts
```
**Remaining for Future Sessions:**
- EditorBanner component for legacy read-only mode warning (Post-Migration UX)
- wire open project flow for legacy detection (auto-detect on existing project open)
---
### Session 2: Wizard UI (Basic Flow)
#### 2024-12-14
**Added:**
- Created `packages/noodl-editor/src/editor/src/views/migration/` directory with:
- `MigrationWizard.tsx` - Main wizard container component:
- Uses Modal component from @noodl-core-ui
- useReducer for local state management
- Integrates with migrationSessionManager from Session 1
- Renders step components based on current session.step
- `components/WizardProgress.tsx` - Visual step progress indicator:
- Shows 5 steps with check icons for completed
- Connectors between steps with completion status
- `steps/ConfirmStep.tsx` - Step 1: Confirm source/target paths:
- Source path locked (read-only)
- Target path editable with filesystem.exists() validation
- Warning about original project being safe
- `steps/ScanningStep.tsx` - Step 2 & 4: Progress display:
- Reused for both scanning and migrating phases
- Progress bar with percentage
- Activity log with color-coded entries (info/success/warning/error)
- `steps/ReportStep.tsx` - Step 3: Scan results report:
- Stats row with automatic/simpleFixes/needsReview counts
- Collapsible category sections with component lists
- AI prompt section (disabled - future session)
- `steps/CompleteStep.tsx` - Step 5: Final summary:
- Stats cards (migrated/needsReview/failed)
- Duration and AI cost display
- Source/target path display
- Next steps guidance
- `steps/FailedStep.tsx` - Error handling step:
- Error details display
- Contextual suggestions (network/permission/general)
- Safety notice about original project
- Created SCSS modules for all components:
- `MigrationWizard.module.scss`
- `components/WizardProgress.module.scss`
- `steps/ConfirmStep.module.scss`
- `steps/ScanningStep.module.scss`
- `steps/ReportStep.module.scss`
- `steps/CompleteStep.module.scss`
- `steps/FailedStep.module.scss`
**Technical Notes:**
- Text component uses `className` not `UNSAFE_className` for styling
- Text component uses `textType` prop (TextType.Secondary, TextType.Shy) not variants
- TextInput onChange expects standard React ChangeEventHandler<HTMLInputElement>
- PrimaryButtonVariant has: Cta (default), Muted, Ghost, Danger (NO "Secondary")
- Using @noodl/platform filesystem.exists() for path checking
- VStack/HStack from @noodl-core-ui/components/layout/Stack for layout
- SVG icons defined inline in each component for self-containment
**Files Created:**
```
packages/noodl-editor/src/editor/src/views/migration/
├── MigrationWizard.tsx
├── MigrationWizard.module.scss
├── components/
│ ├── WizardProgress.tsx
│ └── WizardProgress.module.scss
└── steps/
├── ConfirmStep.tsx
├── ConfirmStep.module.scss
├── ScanningStep.tsx
├── ScanningStep.module.scss
├── ReportStep.tsx
├── ReportStep.module.scss
├── CompleteStep.tsx
├── CompleteStep.module.scss
├── FailedStep.tsx
└── FailedStep.module.scss
```
**Remaining for Session 2:**
- DialogLayerModel integration for showing wizard (deferred to Session 3)
---
### Session 1: Foundation + Detection
#### 2024-12-13

View File

@@ -10,20 +10,29 @@
- [x] Create index.ts module exports
## Session 2: Wizard UI (Basic Flow)
- [ ] MigrationWizard.tsx container
- [ ] ConfirmStep.tsx component
- [ ] ScanningStep.tsx component
- [ ] ReportStep.tsx component
- [ ] CompleteStep.tsx component
- [ ] MigrationExecutor.ts (project copy + basic fixes)
- [ ] DialogLayerModel integration for showing wizard
- [x] MigrationWizard.tsx container
- [x] WizardProgress.tsx component
- [x] ConfirmStep.tsx component
- [x] ScanningStep.tsx component
- [x] ReportStep.tsx component
- [x] CompleteStep.tsx component
- [x] FailedStep.tsx component
- [x] SCSS module files (MigrationWizard, WizardProgress, ConfirmStep, ScanningStep, ReportStep, CompleteStep, FailedStep)
- [ ] MigrationExecutor.ts (project copy + basic fixes) - deferred to Session 4
- [x] DialogLayerModel integration for showing wizard (completed in Session 3)
## Session 3: Projects View Integration
- [ ] Update projectsview.ts to detect and show legacy badges
- [ ] Add "Migrate Project" button to project cards
- [ ] Add "Open Read-Only" button to project cards
- [ ] Create EditorBanner.tsx for read-only mode warning
- [ ] Wire open project flow to detect legacy projects
- [x] DialogLayerModel.showDialog() generic method
- [x] LocalProjectsModel runtime detection with cache
- [x] Update projectsview.html template with legacy badges
- [x] Add CSS styles for legacy project indicators
- [x] Update projectsview.ts to detect and show legacy badges
- [x] Add "Migrate Project" button to project cards
- [x] Add "Open Read-Only" button to project cards
- [x] onMigrateProjectClicked handler (opens MigrationWizard)
- [x] onOpenReadOnlyClicked handler (opens project normally)
- [ ] Create EditorBanner.tsx for read-only mode warning - deferred to Post-Migration UX
- [ ] Wire auto-detect on existing project open - deferred to Post-Migration UX
## Session 4: AI Migration + Polish
- [ ] claudeClient.ts (Anthropic API integration)

View File

@@ -0,0 +1,911 @@
# Task: React 19 Node Modernization
## Overview
Update all frontend visual nodes in `noodl-viewer-react` to take advantage of React 19 features, remove deprecated patterns, and prepare the infrastructure for future React 19-only features like View Transitions.
**Priority:** High
**Estimated Effort:** 16-24 hours
**Branch:** `feature/react19-node-modernization`
---
## Background
With the editor upgraded to React 19 and the runtime to React 18.3 (95% compatible), we have an opportunity to modernize our node infrastructure. This work removes technical debt, simplifies code, and prepares the foundation for React 19-exclusive features.
### React 19 Changes That Affect Nodes
1. **`ref` as a regular prop** - No more `forwardRef` wrapper needed
2. **Improved `useTransition`** - Can now handle async functions
3. **`useDeferredValue` with initial value** - New parameter for better loading states
4. **Native document metadata** - `<title>`, `<meta>` render directly
5. **Better Suspense** - Works with more scenarios
6. **`use()` hook** - Read resources in render (promises, context)
7. **Form actions** - `useActionState`, `useFormStatus`, `useOptimistic`
8. **Cleaner cleanup** - Ref cleanup functions
---
## Phase 1: Infrastructure Updates
### 1.1 Update `createNodeFromReactComponent` Wrapper
**File:** `packages/noodl-viewer-react/src/react-component-node.js` (or `.ts`)
**Changes:**
- Remove automatic `forwardRef` wrapping logic
- Add support for `ref` as a standard prop
- Add optional `useTransition` integration for state updates
- Add optional `useDeferredValue` wrapper for specified inputs
**New Options:**
```javascript
createNodeFromReactComponent({
// ... existing options
// NEW: React 19 options
react19: {
// Enable transition wrapping for specified inputs
transitionInputs: ['items', 'filter'],
// Enable deferred value for specified inputs
deferredInputs: ['searchQuery'],
// Enable form action support
formActions: true,
}
})
```
### 1.2 Update Base Node Classes
**Files:**
- `packages/noodl-viewer-react/src/nodes/std-library/visual-base.js`
- Any shared base classes for visual nodes
**Changes:**
- Remove `forwardRef` patterns
- Update ref handling to use callback ref pattern
- Add utility methods for transitions:
- `this.startTransition(callback)` - wrap updates in transition
- `this.getDeferredValue(inputName)` - get deferred version of input
### 1.3 Update TypeScript Definitions
**Files:**
- `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
- Any relevant `.d.ts` files
**Changes:**
- Update component prop types to include `ref` as regular prop
- Add types for new React 19 hooks
- Update `Noodl` namespace types if needed
---
## Phase 2: Core Visual Nodes
### 2.1 Group Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/group.js`
**Current Issues:**
- Likely uses `forwardRef` or class component with ref forwarding
- May have legacy lifecycle patterns
**Updates:**
- Convert to functional component with `ref` as prop
- Use `useEffect` cleanup returns properly
- Add optional `useDeferredValue` for children rendering (large lists)
**New Capabilities:**
- `Defer Children` input (boolean) - uses `useDeferredValue` for smoother updates
- `Is Updating` output - true when deferred update pending
### 2.2 Text Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/text.js`
**Updates:**
- Remove `forwardRef` wrapper
- Simplify ref handling
### 2.3 Image Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/image.js`
**Updates:**
- Remove `forwardRef` wrapper
- Add resource preloading hints for React 19's `preload()` API (future enhancement slot)
### 2.4 Video Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/video.js`
**Updates:**
- Remove `forwardRef` wrapper
- Ensure ref cleanup is proper
### 2.5 Circle Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/circle.js`
**Updates:**
- Remove `forwardRef` wrapper
### 2.6 Icon Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/icon.js` (or `net.noodl.visual.icon`)
**Updates:**
- Remove `forwardRef` wrapper
---
## Phase 3: UI Control Nodes
### 3.1 Button Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/button.js` (or `net.noodl.controls.button`)
**Updates:**
- Remove `forwardRef` wrapper
- Add form action support preparation:
- `formAction` input (string) - for future form integration
- `Is Pending` output - when used in form with pending action
### 3.2 Text Input Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/textinput.js`
**Updates:**
- Remove `forwardRef` wrapper
- Consider `useDeferredValue` for `onChange` value updates
- Add form integration preparation
**New Capabilities (Optional):**
- `Defer Updates` input - delays `Value` output updates for performance
- `Immediate Value` output - non-deferred value for UI feedback
### 3.3 Checkbox Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/checkbox.js`
**Updates:**
- Remove `forwardRef` wrapper
- Add optimistic update preparation (`useOptimistic` slot)
### 3.4 Radio Button / Radio Button Group
**Files:**
- `packages/noodl-viewer-react/src/nodes/std-library/radiobutton.js`
- `packages/noodl-viewer-react/src/nodes/std-library/radiobuttongroup.js`
**Updates:**
- Remove `forwardRef` wrappers
- Ensure proper group state management
### 3.5 Options/Dropdown Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/options.js`
**Updates:**
- Remove `forwardRef` wrapper
- Consider `useDeferredValue` for large option lists
### 3.6 Range/Slider Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/range.js`
**Updates:**
- Remove `forwardRef` wrapper
- `useDeferredValue` for value output (prevent render thrashing during drag)
**New Capabilities:**
- `Deferred Value` output - smoothed value for expensive downstream renders
- `Immediate Value` output - raw value for UI display
---
## Phase 4: Navigation Nodes
### 4.1 Page Router / Router Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/router.js`
**Updates:**
- Add `useTransition` wrapping for navigation
- Prepare for View Transitions API integration
**New Capabilities:**
- `Is Transitioning` output - true during page transition
- `Use Transition` input (boolean, default true) - wrap navigation in React transition
### 4.2 Router Navigate Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/routernavigate.js`
**Updates:**
- Wrap navigation in `startTransition`
**New Capabilities:**
- `Is Pending` output - navigation in progress
- `Transition Priority` input (enum: 'normal', 'urgent') - for future prioritization
### 4.3 Page Stack / Component Stack
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pagestack.js`
**Updates:**
- Add `useTransition` for push/pop operations
**New Capabilities:**
- `Is Transitioning` output
- Prepare for animation coordination with View Transitions
### 4.4 Page Inputs Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pageinputs.js`
**Updates:**
- Standard cleanup, ensure no deprecated patterns
### 4.5 Popup Nodes
**Files:**
- `packages/noodl-viewer-react/src/nodes/std-library/showpopup.js`
- `packages/noodl-viewer-react/src/nodes/std-library/closepopup.js`
**Updates:**
- Consider `useTransition` for popup show/hide
---
## Phase 5: Layout Nodes
### 5.1 Columns Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/columns.js`
**Updates:**
- Remove `forwardRef` wrapper
- Remove `React.cloneElement` if present (React 19 has better patterns)
- Consider using CSS Grid native features
### 5.2 Repeater (For Each) Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/foreach.js`
**Critical Updates:**
- Add `useDeferredValue` for items array
- Add `useTransition` for item updates
**New Capabilities:**
- `Defer Updates` input (boolean) - uses deferred value for items
- `Is Updating` output - true when deferred update pending
- `Transition Updates` input (boolean) - wrap updates in transition
**Why This Matters:**
Large list updates currently cause jank. With these options:
- User toggles `Defer Updates` → list updates don't block UI
- `Is Updating` output → can show loading indicator
### 5.3 Component Children Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentchildren.js`
**Updates:**
- Standard cleanup
---
## Phase 6: Data/Object Nodes
### 6.1 Component Object Node
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentobject.js`
**Updates:**
- Consider context-based implementation for React 19
- `use(Context)` can now be called conditionally in React 19
### 6.2 Parent Component Object Node
**File:** Similar location
**Updates:**
- Same as Component Object
---
## Phase 7: SEO/Document Nodes (New Capability)
### 7.1 Update Page Node for Document Metadata
**File:** `packages/noodl-viewer-react/src/nodes/std-library/page.js`
**New Capabilities:**
React 19 allows rendering `<title>`, `<meta>`, `<link>` directly in components and they hoist to `<head>`.
**New Inputs:**
- `Page Title` - renders `<title>` (already exists, but implementation changes)
- `Meta Description` - renders `<meta name="description">`
- `Meta Keywords` - renders `<meta name="keywords">`
- `Canonical URL` - renders `<link rel="canonical">`
- `OG Title` - renders `<meta property="og:title">`
- `OG Description` - renders `<meta property="og:description">`
- `OG Image` - renders `<meta property="og:image">`
**Implementation:**
```jsx
function PageComponent({ title, description, ogTitle, ...props }) {
return (
<>
{title && <title>{title}</title>}
{description && <meta name="description" content={description} />}
{ogTitle && <meta property="og:title" content={ogTitle} />}
{/* ... rest of component */}
</>
);
}
```
This replaces the hacky SSR string replacement currently in `packages/noodl-viewer-react/static/ssr/index.js`.
---
## Phase 8: Testing & Validation
### 8.1 Unit Tests
**Update/Create Tests For:**
- `createNodeFromReactComponent` with new options
- Each updated node renders correctly
- Ref forwarding works without `forwardRef`
- Deferred values update correctly
- Transitions wrap updates properly
### 8.2 Integration Tests
- Page navigation with transitions
- Repeater with large datasets
- Form interactions with new patterns
### 8.3 Visual Regression Tests
- Ensure no visual changes from modernization
- Test all visual states (hover, pressed, disabled)
- Test variants still work
### 8.4 Performance Benchmarks
**Before/After Metrics:**
- Repeater with 1000 items - render time
- Page navigation - transition smoothness
- Text input rapid typing - lag measurement
---
## File List Summary
### Infrastructure Files
```
packages/noodl-viewer-react/src/
├── react-component-node.js # Main wrapper factory
├── nodes/std-library/
│ └── visual-base.js # Base class for visual nodes
```
### Visual Element Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── group.js
├── text.js
├── image.js
├── video.js
├── circle.js
├── icon.js (or net.noodl.visual.icon)
```
### UI Control Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── button.js (or net.noodl.controls.button)
├── textinput.js
├── checkbox.js
├── radiobutton.js
├── radiobuttongroup.js
├── options.js
├── range.js
```
### Navigation Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── router.js
├── routernavigate.js
├── pagestack.js
├── pageinputs.js
├── showpopup.js
├── closepopup.js
```
### Layout Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── columns.js
├── foreach.js
├── componentchildren.js
```
### Data Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── componentobject.js
├── parentcomponentobject.js
```
### Page/SEO Nodes
```
packages/noodl-viewer-react/src/nodes/std-library/
├── page.js
```
### Type Definitions
```
packages/noodl-viewer-react/static/viewer/
├── global.d.ts.keep
```
---
## Implementation Order
### Week 1: Foundation
1. Update `createNodeFromReactComponent` infrastructure
2. Update base classes
3. Update Group node (most used, good test case)
4. Update Text node
5. Create test suite for modernized patterns
### Week 2: Controls & Navigation
6. Update all UI Control nodes (Button, TextInput, etc.)
7. Update Navigation nodes with transition support
8. Update Repeater with deferred value support
9. Test navigation flow end-to-end
### Week 3: Polish & New Features
10. Update remaining nodes (Columns, Component Object, etc.)
11. Add Page metadata support
12. Performance testing and optimization
13. Documentation updates
---
## Success Criteria
### Must Have
- [ ] All nodes render correctly after updates
- [ ] No `forwardRef` usage in visual nodes
- [ ] All refs work correctly (DOM access, focus, etc.)
- [ ] No breaking changes to existing projects
- [ ] Tests pass
### Should Have
- [ ] Repeater has `Defer Updates` option
- [ ] Page Router has `Is Transitioning` output
- [ ] Page node has SEO metadata inputs
### Nice to Have
- [ ] Performance improvement measurable in benchmarks
- [ ] Text Input deferred value option
- [ ] Range slider deferred value option
---
## Migration Notes
### Backward Compatibility
These changes should be **fully backward compatible**:
- Existing projects continue to work unchanged
- New features are opt-in via new inputs
- No changes to how nodes are wired together
### Runtime Considerations
Since runtime is React 18.3:
- `useTransition` works (available since React 18)
- `useDeferredValue` works (available since React 18)
- `ref` as prop works (React 18.3 forward-ported this)
- Native metadata hoisting does NOT work (React 19 only)
- For runtime, metadata nodes will need polyfill/fallback
**Strategy:** Build features for React 19 editor, provide graceful degradation for React 18.3 runtime. Eventually upgrade runtime to React 19.
---
## Code Examples
### Before: forwardRef Pattern
```javascript
getReactComponent() {
return React.forwardRef((props, ref) => {
return <div ref={ref} style={props.style}>{props.children}</div>;
});
}
```
### After: ref as Prop Pattern
```javascript
getReactComponent() {
return function GroupComponent({ ref, style, children }) {
return <div ref={ref} style={style}>{children}</div>;
};
}
```
### Adding Deferred Value Support
```javascript
getReactComponent() {
return function RepeaterComponent({ items, deferUpdates, onIsUpdating }) {
const deferredItems = React.useDeferredValue(items);
const isStale = items !== deferredItems;
React.useEffect(() => {
onIsUpdating?.(isStale);
}, [isStale, onIsUpdating]);
const itemsToRender = deferUpdates ? deferredItems : items;
return (
<div>
{itemsToRender.map(item => /* render item */)}
</div>
);
};
}
```
### Adding Transition Support
```javascript
getReactComponent() {
return function RouterComponent({ onNavigate, onIsTransitioning }) {
const [isPending, startTransition] = React.useTransition();
React.useEffect(() => {
onIsTransitioning?.(isPending);
}, [isPending, onIsTransitioning]);
const handleNavigate = (target) => {
startTransition(() => {
onNavigate(target);
});
};
// ...
};
}
```
---
## Questions for Implementation
1. **File locations:** Need to verify actual file paths in `noodl-viewer-react` - the paths above are educated guesses based on patterns.
2. **Runtime compatibility:** Should we add feature detection to gracefully degrade on React 18.3 runtime, or assume eventual runtime upgrade?
3. **New inputs/outputs:** Should new capabilities (like `Defer Updates`) be hidden by default and exposed via a "React 19 Features" toggle in project settings?
4. **Breaking changes policy:** If we find any patterns that would break (unlikely), what's the policy? Migration path vs versioning?
---
## Related Future Work
This modernization enables but does not include:
- **Magic Transition Node** - View Transitions API wrapper
- **AI Component Node** - Generative UI with streaming
- **Async Boundary Node** - Suspense wrapper with error boundaries
- **Form Action Node** - React 19 form actions
These will be separate tasks building on this foundation.
# React 19 Node Modernization - Implementation Checklist
Quick reference checklist for implementation. See full spec for details.
---
## Pre-Flight Checks
- [ ] Verify React 19 is installed in editor package
- [ ] Verify React 18.3 is installed in runtime package
- [ ] Create feature branch: `feature/react19-node-modernization`
- [ ] Locate all node files in `packages/noodl-viewer-react/src/nodes/`
---
## Phase 1: Infrastructure
### createNodeFromReactComponent
- [ ] Find file: `packages/noodl-viewer-react/src/react-component-node.js`
- [ ] Remove automatic forwardRef wrapping
- [ ] Add `ref` prop passthrough to components
- [ ] Add optional `react19.transitionInputs` config
- [ ] Add optional `react19.deferredInputs` config
- [ ] Test: Basic node still renders
- [ ] Test: Ref forwarding works
### Base Classes
- [ ] Find visual-base.js or equivalent
- [ ] Add `this.startTransition()` utility method
- [ ] Add `this.getDeferredValue()` utility method
- [ ] Update TypeScript definitions if applicable
---
## Phase 2: Core Visual Nodes
### Group Node
- [ ] Remove forwardRef
- [ ] Use `ref` as regular prop
- [ ] Test: Renders correctly
- [ ] Test: Ref accessible for DOM manipulation
- [ ] Optional: Add `Defer Children` input
- [ ] Optional: Add `Is Updating` output
### Text Node
- [ ] Remove forwardRef
- [ ] Test: Renders correctly
### Image Node
- [ ] Remove forwardRef
- [ ] Test: Renders correctly
### Video Node
- [ ] Remove forwardRef
- [ ] Ensure proper ref cleanup
- [ ] Test: Renders correctly
### Circle Node
- [ ] Remove forwardRef
- [ ] Test: Renders correctly
### Icon Node
- [ ] Remove forwardRef
- [ ] Test: Renders correctly
---
## Phase 3: UI Control Nodes
### Button Node
- [ ] Remove forwardRef
- [ ] Test: Click events work
- [ ] Test: Visual states work (hover, pressed, disabled)
- [ ] Optional: Add `Is Pending` output for forms
### Text Input Node
- [ ] Remove forwardRef
- [ ] Test: Value binding works
- [ ] Test: Focus/blur events work
- [ ] Optional: Add `Defer Updates` input
- [ ] Optional: Add `Immediate Value` output
### Checkbox Node
- [ ] Remove forwardRef
- [ ] Test: Checked state works
### Radio Button Node
- [ ] Remove forwardRef
- [ ] Test: Selection works
### Radio Button Group Node
- [ ] Remove forwardRef
- [ ] Test: Group behavior works
### Options/Dropdown Node
- [ ] Remove forwardRef
- [ ] Test: Selection works
- [ ] Optional: useDeferredValue for large option lists
### Range/Slider Node
- [ ] Remove forwardRef
- [ ] Test: Value updates work
- [ ] Optional: Add `Deferred Value` output
- [ ] Optional: Add `Immediate Value` output
---
## Phase 4: Navigation Nodes
### Router Node
- [ ] Remove forwardRef if present
- [ ] Add useTransition for navigation
- [ ] Add `Is Transitioning` output
- [ ] Test: Page navigation works
- [ ] Test: Is Transitioning output fires correctly
### Router Navigate Node
- [ ] Wrap navigation in startTransition
- [ ] Add `Is Pending` output
- [ ] Test: Navigation triggers correctly
### Page Stack Node
- [ ] Add useTransition for push/pop
- [ ] Add `Is Transitioning` output
- [ ] Test: Stack operations work
### Page Inputs Node
- [ ] Standard cleanup
- [ ] Test: Parameters pass correctly
### Show Popup Node
- [ ] Consider useTransition
- [ ] Test: Popup shows/hides
### Close Popup Node
- [ ] Standard cleanup
- [ ] Test: Popup closes
---
## Phase 5: Layout Nodes
### Columns Node
- [ ] Remove forwardRef
- [ ] Remove React.cloneElement if present
- [ ] Test: Column layout works
### Repeater (For Each) Node ⭐ HIGH VALUE
- [ ] Remove forwardRef if present
- [ ] Add useDeferredValue for items
- [ ] Add useTransition for updates
- [ ] Add `Defer Updates` input
- [ ] Add `Is Updating` output
- [ ] Add `Transition Updates` input
- [ ] Test: Basic rendering works
- [ ] Test: Large list performance improved
- [ ] Test: Is Updating output fires correctly
### Component Children Node
- [ ] Standard cleanup
- [ ] Test: Children render correctly
---
## Phase 6: Data Nodes
### Component Object Node
- [ ] Review implementation
- [ ] Consider React 19 context patterns
- [ ] Test: Object access works
### Parent Component Object Node
- [ ] Same as Component Object
- [ ] Test: Parent access works
---
## Phase 7: Page/SEO Node ⭐ HIGH VALUE
### Page Node
- [ ] Add `Page Title` input → renders `<title>`
- [ ] Add `Meta Description` input → renders `<meta name="description">`
- [ ] Add `Canonical URL` input → renders `<link rel="canonical">`
- [ ] Add `OG Title` input → renders `<meta property="og:title">`
- [ ] Add `OG Description` input
- [ ] Add `OG Image` input
- [ ] Test: Metadata renders in head
- [ ] Test: SSR works correctly
- [ ] Provide fallback for React 18.3 runtime
---
## Phase 8: Testing
### Unit Tests
- [ ] createNodeFromReactComponent tests
- [ ] Ref forwarding tests
- [ ] Deferred value tests
- [ ] Transition tests
### Integration Tests
- [ ] Full navigation flow
- [ ] Repeater with large data
- [ ] Form interactions
### Visual Tests
- [ ] All nodes render same as before
- [ ] Visual states work
- [ ] Variants work
### Performance Tests
- [ ] Benchmark: Repeater 1000 items
- [ ] Benchmark: Page navigation
- [ ] Benchmark: Text input typing
---
## Final Steps
- [ ] Update documentation
- [ ] Update changelog
- [ ] Create PR
- [ ] Test in sample projects
- [ ] Deploy to staging
- [ ] User testing
---
## Quick Reference: Pattern Changes
### forwardRef Removal
**Before:**
```jsx
React.forwardRef((props, ref) => <div ref={ref} />)
```
**After:**
```jsx
function Component({ ref, ...props }) { return <div ref={ref} /> }
```
### Adding Deferred Value
```jsx
function Component({ items, deferUpdates, onIsUpdating }) {
const deferredItems = React.useDeferredValue(items);
const isStale = items !== deferredItems;
React.useEffect(() => {
onIsUpdating?.(isStale);
}, [isStale]);
return /* render deferUpdates ? deferredItems : items */;
}
```
### Adding Transitions
```jsx
function Component({ onNavigate, onIsPending }) {
const [isPending, startTransition] = React.useTransition();
React.useEffect(() => {
onIsPending?.(isPending);
}, [isPending]);
const handleNav = (target) => {
startTransition(() => onNavigate(target));
};
}
```
### Document Metadata (React 19)
```jsx
function Page({ title, description }) {
return (
<>
{title && <title>{title}</title>}
{description && <meta name="description" content={description} />}
{/* rest of page */}
</>
);
}
```
---
## Notes
- High value items marked with ⭐
- Start with infrastructure, then Group node as test case
- Test frequently - small iterations
- Keep backward compatibility - no breaking changes

View File

@@ -0,0 +1,111 @@
# Responsive Breakpoints System
## Feature Overview
A built-in responsive breakpoint system that works like visual states (hover/pressed/disabled) but for viewport widths. Users can define breakpoint-specific property values directly in the property panel without wiring up states nodes.
**Current Pain Point:**
Users must manually wire `[Screen Width] → [States Node] → [Visual Node]` for every responsive property, cluttering the node graph and making responsive design tedious.
**Solution:**
In the property panel, a breakpoint selector lets users switch between Desktop/Tablet/Phone/Small Phone views. When a breakpoint is selected, users see and edit that breakpoint's values. Values cascade down (desktop → tablet → phone) unless explicitly overridden.
## Key Decisions
| Decision | Choice |
|----------|--------|
| Terminology | "Breakpoints" |
| Default breakpoints | Desktop (≥1024px), Tablet (768-1023px), Phone (320-767px), Small Phone (<320px) |
| Cascade direction | Configurable (desktop-first default, mobile-first option) |
| Editor preview sync | Independent (changing breakpoint doesn't resize preview, and vice versa) |
## Breakpoint-Aware Properties
Only layout/dimension properties support breakpoints (not colors/shadows):
**✅ Supported:**
- **Dimensions**: width, height, minWidth, maxWidth, minHeight, maxHeight
- **Spacing**: marginTop/Right/Bottom/Left, paddingTop/Right/Bottom/Left, gap
- **Typography**: fontSize, lineHeight, letterSpacing
- **Layout**: flexDirection, alignItems, justifyContent, flexWrap, flexGrow, flexShrink
- **Visibility**: visible, mounted
**❌ Not Supported:**
- Colors (backgroundColor, borderColor, textColor, etc.)
- Borders (borderWidth, borderRadius, borderStyle)
- Shadows (boxShadow)
- Effects (opacity, transform)
## Data Model
```javascript
// Node model storage
{
parameters: {
marginTop: '40px', // desktop (default breakpoint)
},
breakpointParameters: {
tablet: { marginTop: '24px' },
phone: { marginTop: '16px' },
smallPhone: { marginTop: '12px' }
},
// Optional: combined visual state + breakpoint
stateBreakpointParameters: {
'hover:tablet': { /* ... */ }
}
}
// Project settings
{
breakpoints: {
desktop: { minWidth: 1024, isDefault: true },
tablet: { minWidth: 768, maxWidth: 1023 },
phone: { minWidth: 320, maxWidth: 767 },
smallPhone: { minWidth: 0, maxWidth: 319 }
},
breakpointOrder: ['desktop', 'tablet', 'phone', 'smallPhone'],
cascadeDirection: 'desktop-first' // or 'mobile-first'
}
```
## Implementation Phases
| Phase | Name | Estimate | Dependencies |
|-------|------|----------|--------------|
| 1 | Foundation - Data Model | 2-3 days | None |
| 2 | Editor UI - Property Panel | 3-4 days | Phase 1 |
| 3 | Runtime - Viewport Detection | 2-3 days | Phase 1 |
| 4 | Variants Integration | 1-2 days | Phases 1-3 |
| 5 | Visual States Combo | 2 days | Phases 1-4 |
**Total Estimate: 10-14 days**
## Success Criteria
1. Users can set different margin/padding/width values per breakpoint without any node wiring
2. Values cascade automatically (tablet inherits desktop unless overridden)
3. Property panel clearly shows inherited vs overridden values
4. Runtime automatically applies correct values based on viewport width
5. Variants support breakpoint-specific values
6. Project settings allow customizing breakpoint thresholds
7. Both desktop-first and mobile-first workflows supported
## File Structure
```
tasks/responsive-breakpoints/
├── 00-OVERVIEW.md (this file)
├── 01-FOUNDATION.md (Phase 1: Data model)
├── 02-EDITOR-UI.md (Phase 2: Property panel)
├── 03-RUNTIME.md (Phase 3: Viewport detection)
├── 04-VARIANTS.md (Phase 4: Variants integration)
└── 05-VISUAL-STATES-COMBO.md (Phase 5: Combined states)
```
## Related Documentation
- `codebase/nodes/visual-states.md` - Existing visual states system (pattern to follow)
- `codebase/nodes/variants.md` - Existing variants system
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/` - Property panel implementation
- `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` - Node model
- `packages/noodl-runtime/src/models/nodemodel.js` - Runtime node model

View File

@@ -0,0 +1,369 @@
# Phase 1: Foundation - Data Model
## Overview
Establish the data structures and model layer support for responsive breakpoints. This phase adds `breakpointParameters` storage to nodes, extends the model proxy, and adds project-level breakpoint configuration.
**Estimate:** 2-3 days
## Goals
1. Add `breakpointParameters` field to NodeGraphNode model
2. Extend NodeModel (runtime) with breakpoint parameter support
3. Add breakpoint configuration to project settings
4. Extend ModelProxy to handle breakpoint context
5. Add `allowBreakpoints` flag support to node definitions
## Technical Architecture
### Data Storage Pattern
Following the existing visual states pattern (`stateParameters`), we add parallel `breakpointParameters`:
```javascript
// NodeGraphNode / NodeModel
{
id: 'group-1',
type: 'Group',
parameters: {
marginTop: '40px', // base/default breakpoint value
backgroundColor: '#fff' // non-breakpoint property
},
stateParameters: { // existing - visual states
hover: { backgroundColor: '#eee' }
},
breakpointParameters: { // NEW - breakpoints
tablet: { marginTop: '24px' },
phone: { marginTop: '16px' },
smallPhone: { marginTop: '12px' }
}
}
```
### Project Settings Schema
```javascript
// project.settings.responsiveBreakpoints
{
enabled: true,
cascadeDirection: 'desktop-first', // or 'mobile-first'
defaultBreakpoint: 'desktop',
breakpoints: [
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'desktop' },
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'tablet' },
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'phone' },
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'phone-small' }
]
}
```
### Node Definition Flag
```javascript
// In node definition
{
inputs: {
marginTop: {
type: { name: 'number', units: ['px', '%'], defaultUnit: 'px' },
allowBreakpoints: true, // NEW flag
group: 'Margin and Padding'
},
backgroundColor: {
type: 'color',
allowVisualStates: true,
allowBreakpoints: false // colors don't support breakpoints
}
}
}
```
## Implementation Steps
### Step 1: Extend NodeGraphNode Model
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
```typescript
// Add to class properties
breakpointParameters: Record<string, Record<string, any>>;
// Add to constructor/initialization
this.breakpointParameters = args.breakpointParameters || {};
// Add new methods
hasBreakpointParameter(name: string, breakpoint: string): boolean {
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
}
getBreakpointParameter(name: string, breakpoint: string): any {
return this.breakpointParameters?.[breakpoint]?.[name];
}
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any): void {
// Similar pattern to setParameter but for breakpoint-specific values
// Include undo support
}
// Extend getParameter to support breakpoint context
getParameter(name: string, args?: { state?: string, breakpoint?: string }): any {
// If breakpoint specified, check breakpointParameters first
// Then cascade to larger breakpoints
// Finally fall back to base parameters
}
// Extend toJSON to include breakpointParameters
toJSON(): object {
return {
...existingFields,
breakpointParameters: this.breakpointParameters
};
}
```
### Step 2: Extend Runtime NodeModel
**File:** `packages/noodl-runtime/src/models/nodemodel.js`
```javascript
// Add breakpointParameters storage
NodeModel.prototype.setBreakpointParameter = function(name, value, breakpoint) {
if (!this.breakpointParameters) this.breakpointParameters = {};
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
if (value === undefined) {
delete this.breakpointParameters[breakpoint][name];
} else {
this.breakpointParameters[breakpoint][name] = value;
}
this.emit("breakpointParameterUpdated", { name, value, breakpoint });
};
NodeModel.prototype.setBreakpointParameters = function(breakpointParameters) {
this.breakpointParameters = breakpointParameters;
};
```
### Step 3: Add Project Settings Schema
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
```typescript
// Add default breakpoint settings
const DEFAULT_BREAKPOINT_SETTINGS = {
enabled: true,
cascadeDirection: 'desktop-first',
defaultBreakpoint: 'desktop',
breakpoints: [
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'DeviceDesktop' },
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'DeviceTablet' },
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'DevicePhone' },
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'DevicePhone' }
]
};
// Add helper methods
getBreakpointSettings(): BreakpointSettings {
return this.settings.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
}
setBreakpointSettings(settings: BreakpointSettings): void {
this.setSetting('responsiveBreakpoints', settings);
}
getBreakpointForWidth(width: number): string {
const settings = this.getBreakpointSettings();
const breakpoints = settings.breakpoints;
// Find matching breakpoint based on width
for (const bp of breakpoints) {
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
if (minMatch && maxMatch) return bp.id;
}
return settings.defaultBreakpoint;
}
```
### Step 4: Extend ModelProxy
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
```typescript
export class ModelProxy {
model: NodeGraphNode;
editMode: string;
visualState: string;
breakpoint: string; // NEW
constructor(args) {
this.model = args.model;
this.visualState = 'neutral';
this.breakpoint = 'desktop'; // NEW - default breakpoint
}
setBreakpoint(breakpoint: string) {
this.breakpoint = breakpoint;
}
// Extend getParameter to handle breakpoints
getParameter(name: string) {
const source = this.editMode === 'variant' ? this.model.variant : this.model;
const port = this.model.getPort(name, 'input');
// Check if this property supports breakpoints
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
// Check for breakpoint-specific value
const breakpointValue = source.getBreakpointParameter(name, this.breakpoint);
if (breakpointValue !== undefined) return breakpointValue;
// Cascade to larger breakpoints (desktop-first)
// TODO: Support mobile-first cascade
}
// Check visual state
if (this.visualState && this.visualState !== 'neutral') {
// existing visual state logic
}
// Fall back to base parameters
return source.getParameter(name, { state: this.visualState });
}
// Extend setParameter to handle breakpoints
setParameter(name: string, value: any, args: any = {}) {
const port = this.model.getPort(name, 'input');
// If setting a breakpoint-specific value
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
args.breakpoint = this.breakpoint;
}
// existing state handling
args.state = this.visualState;
const target = this.editMode === 'variant' ? this.model.variant : this.model;
if (args.breakpoint) {
target.setBreakpointParameter(name, value, args.breakpoint, args);
} else {
target.setParameter(name, value, args);
}
}
// Check if current value is inherited or explicitly set
isBreakpointValueInherited(name: string): boolean {
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
const source = this.editMode === 'variant' ? this.model.variant : this.model;
return !source.hasBreakpointParameter(name, this.breakpoint);
}
}
```
### Step 5: Update Node Type Registration
**File:** `packages/noodl-editor/src/editor/src/models/nodelibrary/nodelibrary.ts`
```typescript
// When registering node types, process allowBreakpoints flag
// Similar to how allowVisualStates is handled
processNodeType(nodeType) {
// existing processing...
// Process allowBreakpoints for inputs
if (nodeType.inputs) {
for (const [name, input] of Object.entries(nodeType.inputs)) {
if (input.allowBreakpoints) {
// Mark this port as breakpoint-aware
// This will be used by property panel to show breakpoint controls
}
}
}
}
```
### Step 6: Update GraphModel (Runtime)
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
```javascript
// Add method to update breakpoint parameters
GraphModel.prototype.updateNodeBreakpointParameter = function(
nodeId,
parameterName,
parameterValue,
breakpoint
) {
const node = this.getNodeWithId(nodeId);
if (!node) return;
node.setBreakpointParameter(parameterName, parameterValue, breakpoint);
};
// Extend project settings handling
GraphModel.prototype.getBreakpointSettings = function() {
return this.settings?.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
};
```
## Files to Modify
| File | Changes |
|------|---------|
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add breakpointParameters field, getter/setter methods |
| `packages/noodl-editor/src/editor/src/models/projectmodel.ts` | Add breakpoint settings helpers |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Add breakpoint context, extend get/setParameter |
| `packages/noodl-runtime/src/models/nodemodel.js` | Add breakpoint parameter methods |
| `packages/noodl-runtime/src/models/graphmodel.js` | Add breakpoint settings handling |
## Files to Create
| File | Purpose |
|------|---------|
| `packages/noodl-editor/src/editor/src/models/breakpointSettings.ts` | TypeScript interfaces for breakpoint settings |
## Testing Checklist
- [ ] NodeGraphNode can store and retrieve breakpointParameters
- [ ] NodeGraphNode serializes breakpointParameters to JSON correctly
- [ ] NodeGraphNode loads breakpointParameters from JSON correctly
- [ ] ModelProxy correctly returns breakpoint-specific values
- [ ] ModelProxy correctly identifies inherited vs explicit values
- [ ] Project settings store and load breakpoint configuration
- [ ] Cascade works correctly (tablet falls back to desktop)
- [ ] Undo/redo works for breakpoint parameter changes
## Success Criteria
1. ✅ Can programmatically set `node.setBreakpointParameter('marginTop', '24px', 'tablet')`
2. ✅ Can retrieve with `node.getBreakpointParameter('marginTop', 'tablet')`
3. ✅ Project JSON includes breakpointParameters when saved
4. ✅ Project JSON loads breakpointParameters when opened
5. ✅ ModelProxy returns correct value based on current breakpoint context
## Gotchas & Notes
1. **Undo Support**: Make sure breakpoint parameter changes are undoable. Follow the same pattern as `setParameter` with undo groups.
2. **Cascade Order**: Desktop-first means `tablet` inherits from `desktop`, `phone` inherits from `tablet`, `smallPhone` inherits from `phone`. Mobile-first reverses this.
3. **Default Breakpoint**: When `breakpoint === 'desktop'` (or whatever the default is), we should NOT use breakpointParameters - use base parameters instead.
4. **Parameter Migration**: Existing projects won't have breakpointParameters. Handle gracefully (undefined → empty object).
5. **Port Flag**: The `allowBreakpoints` flag on ports determines which properties show breakpoint controls in the UI. This is read-only metadata, not stored per-node.
## Confidence Checkpoints
After completing each step, verify:
| Step | Checkpoint |
|------|------------|
| 1 | Can add/get breakpoint params in editor console |
| 2 | Runtime node model accepts breakpoint params |
| 3 | Project settings UI shows breakpoint config |
| 4 | ModelProxy returns correct value per breakpoint |
| 5 | Saving/loading project preserves breakpoint data |

View File

@@ -0,0 +1,600 @@
# Phase 2: Editor UI - Property Panel
## Overview
Add the breakpoint selector UI to the property panel and implement the visual feedback for inherited vs overridden values. Users should be able to switch between breakpoints and see/edit breakpoint-specific values.
**Estimate:** 3-4 days
**Dependencies:** Phase 1 (Foundation)
## Goals
1. Add breakpoint selector component to property panel
2. Show inherited vs overridden values with visual distinction
3. Add reset button to clear breakpoint-specific overrides
4. Show badge summary of overrides per breakpoint
5. Add breakpoint configuration section to Project Settings
6. Filter property panel to only show breakpoint controls on `allowBreakpoints` properties
## UI Design
### Property Panel with Breakpoint Selector
```
┌─────────────────────────────────────────────────┐
│ Group │
├─────────────────────────────────────────────────┤
│ Breakpoint: [🖥️] [💻] [📱] [📱] │
│ Des Tab Pho Sml │
│ ───────────────────── │
│ ▲ selected │
├─────────────────────────────────────────────────┤
│ ┌─ Dimensions ────────────────────────────────┐ │
│ │ Width [100%] │ │
│ │ Height [auto] (inherited) [↺] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─ Margin and Padding ────────────────────────┐ │
│ │ Margin Top [24px] ● changed │ │
│ │ Padding [16px] (inherited) [↺] │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─ Style ─────────────────────────────────────┐ │
│ │ Background [#ffffff] (no breakpoints) │ │
│ │ Border [1px solid] (no breakpoints) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ 💻 2 overrides 📱 3 overrides 📱 1 override │
└─────────────────────────────────────────────────┘
```
### Visual States
| State | Appearance |
|-------|------------|
| Base value (desktop) | Normal text, no indicator |
| Inherited from larger breakpoint | Dimmed/italic text, "(inherited)" label |
| Explicitly set for this breakpoint | Normal text, filled dot indicator (●) |
| Reset button | Shows on hover for overridden values |
### Project Settings - Breakpoints Section
```
┌─────────────────────────────────────────────────┐
│ Responsive Breakpoints │
├─────────────────────────────────────────────────┤
│ ☑ Enable responsive breakpoints │
│ │
│ Cascade direction: [Desktop-first ▼] │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Name Min Width Max Width │ │
│ │ ─────────────────────────────────────────│ │
│ │ 🖥️ Desktop 1024px — [Default]│ │
│ │ 💻 Tablet 768px 1023px │ │
│ │ 📱 Phone 320px 767px │ │
│ │ 📱 Small Phone 0px 319px │ │
│ └───────────────────────────────────────────┘ │
│ │
│ [+ Add Breakpoint] [Reset to Defaults] │
└─────────────────────────────────────────────────┘
```
## Implementation Steps
### Step 1: Create BreakpointSelector Component
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx`
```tsx
import React from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import css from './BreakpointSelector.module.scss';
export interface Breakpoint {
id: string;
name: string;
icon: IconName;
minWidth?: number;
maxWidth?: number;
}
export interface BreakpointSelectorProps {
breakpoints: Breakpoint[];
selectedBreakpoint: string;
overrideCounts: Record<string, number>; // { tablet: 2, phone: 3 }
onBreakpointChange: (breakpointId: string) => void;
}
export function BreakpointSelector({
breakpoints,
selectedBreakpoint,
overrideCounts,
onBreakpointChange
}: BreakpointSelectorProps) {
return (
<div className={css.Root}>
<span className={css.Label}>Breakpoint:</span>
<div className={css.ButtonGroup}>
{breakpoints.map((bp) => (
<Tooltip
key={bp.id}
content={`${bp.name}${bp.minWidth ? ` (${bp.minWidth}px+)` : ''}`}
>
<button
className={classNames(css.Button, {
[css.isSelected]: selectedBreakpoint === bp.id,
[css.hasOverrides]: overrideCounts[bp.id] > 0
})}
onClick={() => onBreakpointChange(bp.id)}
>
<Icon icon={getIconForBreakpoint(bp.icon)} />
{overrideCounts[bp.id] > 0 && (
<span className={css.OverrideCount}>{overrideCounts[bp.id]}</span>
)}
</button>
</Tooltip>
))}
</div>
</div>
);
}
function getIconForBreakpoint(icon: string): IconName {
switch (icon) {
case 'desktop': return IconName.DeviceDesktop;
case 'tablet': return IconName.DeviceTablet;
case 'phone':
case 'phone-small':
default: return IconName.DevicePhone;
}
}
```
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss`
```scss
.Root {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--theme-color-bg-3);
background-color: var(--theme-color-bg-2);
}
.Label {
font-size: 12px;
color: var(--theme-color-fg-default);
margin-right: 8px;
}
.ButtonGroup {
display: flex;
gap: 2px;
}
.Button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
border: none;
background-color: var(--theme-color-bg-3);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-1);
}
&.isSelected {
background-color: var(--theme-color-primary);
svg path {
fill: var(--theme-color-on-primary);
}
}
svg path {
fill: var(--theme-color-fg-default);
}
}
.OverrideCount {
position: absolute;
top: -4px;
right: -4px;
min-width: 14px;
height: 14px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
color: var(--theme-color-on-primary);
background-color: var(--theme-color-secondary);
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
}
```
### Step 2: Create Inherited Value Indicator
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx`
```tsx
import React from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
import css from './InheritedIndicator.module.scss';
export interface InheritedIndicatorProps {
isInherited: boolean;
inheritedFrom?: string; // 'desktop', 'tablet', etc.
isBreakpointAware: boolean;
onReset?: () => void;
}
export function InheritedIndicator({
isInherited,
inheritedFrom,
isBreakpointAware,
onReset
}: InheritedIndicatorProps) {
if (!isBreakpointAware) {
return null; // Don't show anything for non-breakpoint properties
}
if (isInherited) {
return (
<Tooltip content={`Inherited from ${inheritedFrom}`}>
<span className={css.Inherited}>
(inherited)
{onReset && (
<button className={css.ResetButton} onClick={onReset}>
<Icon icon={IconName.Undo} size={12} />
</button>
)}
</span>
</Tooltip>
);
}
return (
<Tooltip content="Value set for this breakpoint">
<span className={css.Changed}>
<span className={css.Dot}></span>
{onReset && (
<button className={css.ResetButton} onClick={onReset}>
<Icon icon={IconName.Undo} size={12} />
</button>
)}
</span>
</Tooltip>
);
}
```
### Step 3: Integrate into Property Editor
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts`
```typescript
// Add to existing property editor
import { BreakpointSelector } from './components/BreakpointSelector';
// In render method, add breakpoint selector after visual states
renderBreakpointSelector() {
const node = this.model;
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
if (!hasBreakpointPorts) return; // Don't show if no breakpoint-aware properties
const settings = ProjectModel.instance.getBreakpointSettings();
const overrideCounts = this.calculateOverrideCounts();
const props = {
breakpoints: settings.breakpoints.map(bp => ({
id: bp.id,
name: bp.name,
icon: bp.icon,
minWidth: bp.minWidth,
maxWidth: bp.maxWidth
})),
selectedBreakpoint: this.modelProxy.breakpoint || settings.defaultBreakpoint,
overrideCounts,
onBreakpointChange: this.onBreakpointChanged.bind(this)
};
ReactDOM.render(
React.createElement(BreakpointSelector, props),
this.$('.breakpoint-selector')[0]
);
}
onBreakpointChanged(breakpointId: string) {
this.modelProxy.setBreakpoint(breakpointId);
this.scheduleRenderPortsView();
}
hasBreakpointAwarePorts(): boolean {
const ports = this.model.getPorts('input');
return ports.some(p => p.allowBreakpoints);
}
calculateOverrideCounts(): Record<string, number> {
const counts: Record<string, number> = {};
const settings = ProjectModel.instance.getBreakpointSettings();
for (const bp of settings.breakpoints) {
if (bp.id === settings.defaultBreakpoint) continue;
const overrides = this.model.breakpointParameters?.[bp.id];
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
}
return counts;
}
```
### Step 4: Update Property Panel Row Component
**File:** `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx`
```tsx
// Extend PropertyPanelRow to show inherited indicator
export interface PropertyPanelRowProps {
label: string;
children: React.ReactNode;
// NEW props for breakpoint support
isBreakpointAware?: boolean;
isInherited?: boolean;
inheritedFrom?: string;
onReset?: () => void;
}
export function PropertyPanelRow({
label,
children,
isBreakpointAware,
isInherited,
inheritedFrom,
onReset
}: PropertyPanelRowProps) {
return (
<div className={classNames(css.Root, { [css.isInherited]: isInherited })}>
<label className={css.Label}>{label}</label>
<div className={css.InputContainer}>
{children}
{isBreakpointAware && (
<InheritedIndicator
isInherited={isInherited}
inheritedFrom={inheritedFrom}
isBreakpointAware={isBreakpointAware}
onReset={!isInherited ? onReset : undefined}
/>
)}
</div>
</div>
);
}
```
### Step 5: Update Ports View
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts`
```typescript
// Extend the Ports view to pass breakpoint info to each property row
renderPort(port) {
const isBreakpointAware = port.allowBreakpoints;
const currentBreakpoint = this.modelProxy.breakpoint;
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
let isInherited = false;
let inheritedFrom = null;
if (isBreakpointAware && currentBreakpoint !== defaultBreakpoint) {
isInherited = this.modelProxy.isBreakpointValueInherited(port.name);
inheritedFrom = this.getInheritedFromBreakpoint(port.name, currentBreakpoint);
}
// Pass these to the PropertyPanelRow component
return {
...existingPortRenderData,
isBreakpointAware,
isInherited,
inheritedFrom,
onReset: isBreakpointAware && !isInherited
? () => this.resetBreakpointValue(port.name, currentBreakpoint)
: undefined
};
}
resetBreakpointValue(portName: string, breakpoint: string) {
this.modelProxy.setParameter(portName, undefined, {
breakpoint,
undo: true,
label: `reset ${portName} for ${breakpoint}`
});
this.render();
}
getInheritedFromBreakpoint(portName: string, currentBreakpoint: string): string {
const settings = ProjectModel.instance.getBreakpointSettings();
const breakpointOrder = settings.breakpoints.map(bp => bp.id);
const currentIndex = breakpointOrder.indexOf(currentBreakpoint);
// Walk up the cascade to find where value comes from
for (let i = currentIndex - 1; i >= 0; i--) {
const bp = breakpointOrder[i];
if (this.model.hasBreakpointParameter(portName, bp)) {
return settings.breakpoints.find(b => b.id === bp)?.name || bp;
}
}
return settings.breakpoints[0]?.name || 'Desktop'; // Default
}
```
### Step 6: Add Breakpoint Settings to Project Settings Panel
**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx`
```tsx
import React, { useState } from 'react';
import { ProjectModel } from '@noodl-models/projectmodel';
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
export function BreakpointSettingsSection() {
const [settings, setSettings] = useState(
ProjectModel.instance.getBreakpointSettings()
);
function handleEnabledChange(enabled: boolean) {
const newSettings = { ...settings, enabled };
setSettings(newSettings);
ProjectModel.instance.setBreakpointSettings(newSettings);
}
function handleCascadeDirectionChange(direction: string) {
const newSettings = { ...settings, cascadeDirection: direction };
setSettings(newSettings);
ProjectModel.instance.setBreakpointSettings(newSettings);
}
function handleBreakpointChange(index: number, field: string, value: any) {
const newBreakpoints = [...settings.breakpoints];
newBreakpoints[index] = { ...newBreakpoints[index], [field]: value };
const newSettings = { ...settings, breakpoints: newBreakpoints };
setSettings(newSettings);
ProjectModel.instance.setBreakpointSettings(newSettings);
}
return (
<CollapsableSection title="Responsive Breakpoints" hasGutter>
<PropertyPanelRow label="Enable breakpoints">
<PropertyPanelCheckbox
value={settings.enabled}
onChange={handleEnabledChange}
/>
</PropertyPanelRow>
<PropertyPanelRow label="Cascade direction">
<PropertyPanelSelectInput
value={settings.cascadeDirection}
onChange={handleCascadeDirectionChange}
options={[
{ label: 'Desktop-first', value: 'desktop-first' },
{ label: 'Mobile-first', value: 'mobile-first' }
]}
/>
</PropertyPanelRow>
<div className={css.BreakpointList}>
{settings.breakpoints.map((bp, index) => (
<BreakpointRow
key={bp.id}
breakpoint={bp}
isDefault={bp.id === settings.defaultBreakpoint}
onChange={(field, value) => handleBreakpointChange(index, field, value)}
/>
))}
</div>
</CollapsableSection>
);
}
```
### Step 7: Add Template to Property Editor HTML
**File:** `packages/noodl-editor/src/editor/src/templates/propertyeditor.html`
Add breakpoint selector container:
```html
<!-- Add after visual-states div -->
<div class="breakpoint-selector"></div>
```
## Files to Modify
| File | Changes |
|------|---------|
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts` | Add breakpoint selector rendering, integrate with ModelProxy |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass breakpoint info to property rows |
| `packages/noodl-editor/src/editor/src/templates/propertyeditor.html` | Add breakpoint selector container |
| `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx` | Add inherited indicator support |
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/ProjectSettingsPanel.tsx` | Add breakpoint settings section |
| `packages/noodl-editor/src/editor/src/styles/propertyeditor/` | Add breakpoint-related styles |
## Files to Create
| File | Purpose |
|------|---------|
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx` | Main breakpoint selector component |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss` | Styles for breakpoint selector |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/index.ts` | Export |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx` | Inherited value indicator |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.module.scss` | Styles |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/index.ts` | Export |
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx` | Project settings UI |
## Testing Checklist
- [ ] Breakpoint selector appears in property panel for nodes with breakpoint-aware properties
- [ ] Breakpoint selector does NOT appear for nodes without breakpoint-aware properties
- [ ] Clicking breakpoint buttons switches the current breakpoint
- [ ] Property values update to show breakpoint-specific values when switching
- [ ] Inherited values show dimmed with "(inherited)" label
- [ ] Override values show with dot indicator (●)
- [ ] Reset button appears on hover for overridden values
- [ ] Clicking reset removes the breakpoint-specific value
- [ ] Override count badges show correct counts
- [ ] Project Settings shows breakpoint configuration
- [ ] Can change cascade direction in project settings
- [ ] Can modify breakpoint thresholds in project settings
- [ ] Changes persist after saving and reloading project
## Success Criteria
1. ✅ Users can switch between breakpoints in property panel
2. ✅ Clear visual distinction between inherited and overridden values
3. ✅ Can set breakpoint-specific values by editing while breakpoint is selected
4. ✅ Can reset breakpoint-specific values to inherit from larger breakpoint
5. ✅ Override counts visible at a glance
6. ✅ Project settings allow breakpoint customization
## Gotchas & Notes
1. **Visual States Coexistence**: The breakpoint selector should appear ABOVE the visual states selector (if present). They're independent axes.
2. **Port Filtering**: Only ports with `allowBreakpoints: true` should show the inherited/override indicators. Non-breakpoint properties look normal.
3. **Connected Ports**: If a port is connected (has a wire), it shouldn't show breakpoint controls - the connection takes precedence.
4. **Performance**: Calculating override counts could be expensive if done on every render. Consider caching or only recalculating when breakpointParameters change.
5. **Mobile-First Logic**: When cascade direction is mobile-first, the inheritance flows the OTHER direction (phone → tablet → desktop). Make sure the `getInheritedFromBreakpoint` logic handles both.
6. **Keyboard Navigation**: Consider adding keyboard shortcuts to switch breakpoints (e.g., Ctrl+1/2/3/4).
## UI/UX Refinements (Optional)
- Animate the transition when switching breakpoints
- Add tooltips showing the pixel range for each breakpoint
- Consider a "copy to all breakpoints" action
- Add visual preview of how values differ across breakpoints

View File

@@ -0,0 +1,619 @@
# Phase 3: Runtime - Viewport Detection
## Overview
Implement the runtime system that detects viewport width changes and applies the correct breakpoint-specific values to nodes. This includes creating a BreakpointManager singleton, wiring up resize listeners, and ensuring nodes reactively update when the breakpoint changes.
**Estimate:** 2-3 days
**Dependencies:** Phase 1 (Foundation)
## Goals
1. Create BreakpointManager singleton for viewport detection
2. Implement viewport resize listener with debouncing
3. Wire nodes to respond to breakpoint changes
4. Implement value resolution with cascade logic
5. Support both desktop-first and mobile-first cascades
6. Ensure smooth transitions when breakpoint changes
## Technical Architecture
### BreakpointManager
Central singleton that:
- Monitors `window.innerWidth`
- Determines current breakpoint based on project settings
- Notifies subscribers when breakpoint changes
- Handles both desktop-first and mobile-first cascade
```
┌─────────────────────────────────────────┐
│ BreakpointManager │
├─────────────────────────────────────────┤
│ - currentBreakpoint: string │
│ - settings: BreakpointSettings │
│ - listeners: Set<Function> │
├─────────────────────────────────────────┤
│ + initialize(settings) │
│ + getCurrentBreakpoint(): string │
│ + getBreakpointForWidth(width): string │
│ + subscribe(callback): unsubscribe │
│ + getCascadeOrder(): string[] │
└─────────────────────────────────────────┘
│ notifies
┌─────────────────────────────────────────┐
│ Visual Nodes │
│ (subscribe to breakpoint changes) │
└─────────────────────────────────────────┘
```
### Value Resolution Flow
```
getResolvedValue(propertyName)
Is property breakpoint-aware?
├─ No → return parameters[propertyName]
└─ Yes → Get current breakpoint
Check breakpointParameters[currentBreakpoint]
├─ Has value → return it
└─ No value → Cascade to next breakpoint
(repeat until found or reach default)
return parameters[propertyName]
```
## Implementation Steps
### Step 1: Create BreakpointManager
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
```javascript
'use strict';
const EventEmitter = require('events');
const DEFAULT_SETTINGS = {
enabled: true,
cascadeDirection: 'desktop-first',
defaultBreakpoint: 'desktop',
breakpoints: [
{ id: 'desktop', minWidth: 1024 },
{ id: 'tablet', minWidth: 768, maxWidth: 1023 },
{ id: 'phone', minWidth: 320, maxWidth: 767 },
{ id: 'smallPhone', minWidth: 0, maxWidth: 319 }
]
};
class BreakpointManager extends EventEmitter {
constructor() {
super();
this.settings = DEFAULT_SETTINGS;
this.currentBreakpoint = DEFAULT_SETTINGS.defaultBreakpoint;
this._resizeTimeout = null;
this._boundHandleResize = this._handleResize.bind(this);
// Don't auto-initialize - wait for settings from project
}
initialize(settings) {
this.settings = settings || DEFAULT_SETTINGS;
this.currentBreakpoint = this.settings.defaultBreakpoint;
// Set up resize listener
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this._boundHandleResize);
window.addEventListener('resize', this._boundHandleResize);
// Initial detection
this._updateBreakpoint(window.innerWidth);
}
}
dispose() {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this._boundHandleResize);
}
this.removeAllListeners();
}
_handleResize() {
// Debounce resize events
if (this._resizeTimeout) {
clearTimeout(this._resizeTimeout);
}
this._resizeTimeout = setTimeout(() => {
this._updateBreakpoint(window.innerWidth);
}, 100); // 100ms debounce
}
_updateBreakpoint(width) {
const newBreakpoint = this.getBreakpointForWidth(width);
if (newBreakpoint !== this.currentBreakpoint) {
const previousBreakpoint = this.currentBreakpoint;
this.currentBreakpoint = newBreakpoint;
this.emit('breakpointChanged', {
breakpoint: newBreakpoint,
previousBreakpoint,
width
});
}
}
getBreakpointForWidth(width) {
if (!this.settings.enabled) {
return this.settings.defaultBreakpoint;
}
const breakpoints = this.settings.breakpoints;
for (const bp of breakpoints) {
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
if (minMatch && maxMatch) {
return bp.id;
}
}
return this.settings.defaultBreakpoint;
}
getCurrentBreakpoint() {
return this.currentBreakpoint;
}
/**
* Get the cascade order for value inheritance.
* Desktop-first: ['desktop', 'tablet', 'phone', 'smallPhone']
* Mobile-first: ['smallPhone', 'phone', 'tablet', 'desktop']
*/
getCascadeOrder() {
const breakpointIds = this.settings.breakpoints.map(bp => bp.id);
if (this.settings.cascadeDirection === 'mobile-first') {
return breakpointIds.slice().reverse();
}
return breakpointIds;
}
/**
* Get breakpoints that a given breakpoint inherits from.
* For desktop-first with current='phone':
* returns ['tablet', 'desktop'] (phone inherits from tablet, which inherits from desktop)
*/
getInheritanceChain(breakpointId) {
const cascadeOrder = this.getCascadeOrder();
const currentIndex = cascadeOrder.indexOf(breakpointId);
if (currentIndex <= 0) return []; // First in cascade inherits from nothing
return cascadeOrder.slice(0, currentIndex);
}
/**
* Subscribe to breakpoint changes.
* Returns unsubscribe function.
*/
subscribe(callback) {
this.on('breakpointChanged', callback);
return () => this.off('breakpointChanged', callback);
}
/**
* Force a breakpoint (for testing/preview).
* Pass null to return to auto-detection.
*/
forceBreakpoint(breakpointId) {
if (breakpointId === null) {
// Return to auto-detection
if (typeof window !== 'undefined') {
this._updateBreakpoint(window.innerWidth);
}
} else {
const previousBreakpoint = this.currentBreakpoint;
this.currentBreakpoint = breakpointId;
this.emit('breakpointChanged', {
breakpoint: breakpointId,
previousBreakpoint,
forced: true
});
}
}
}
// Singleton instance
const breakpointManager = new BreakpointManager();
module.exports = breakpointManager;
```
### Step 2: Integrate with GraphModel
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
```javascript
const breakpointManager = require('../breakpointmanager');
// In setSettings method, initialize breakpoint manager
GraphModel.prototype.setSettings = function(settings) {
this.settings = settings;
// Initialize breakpoint manager with project settings
if (settings.responsiveBreakpoints) {
breakpointManager.initialize(settings.responsiveBreakpoints);
}
this.emit('projectSettingsChanged', settings);
};
```
### Step 3: Add Value Resolution to Node Base
**File:** `packages/noodl-runtime/src/nodes/nodebase.js` (or equivalent base class)
```javascript
const breakpointManager = require('../breakpointmanager');
// Add to node initialization
{
_initializeBreakpointSupport() {
// Subscribe to breakpoint changes
this._breakpointUnsubscribe = breakpointManager.subscribe(
this._onBreakpointChanged.bind(this)
);
},
_disposeBreakpointSupport() {
if (this._breakpointUnsubscribe) {
this._breakpointUnsubscribe();
this._breakpointUnsubscribe = null;
}
},
_onBreakpointChanged({ breakpoint, previousBreakpoint }) {
// Re-apply all breakpoint-aware properties
this._applyBreakpointValues();
},
_applyBreakpointValues() {
const ports = this.getPorts ? this.getPorts('input') : [];
for (const port of ports) {
if (port.allowBreakpoints) {
const value = this.getResolvedParameterValue(port.name);
this._applyParameterValue(port.name, value);
}
}
// Force re-render if this is a React node
if (this.forceUpdate) {
this.forceUpdate();
}
},
/**
* Get the resolved value for a parameter, considering breakpoints and cascade.
*/
getResolvedParameterValue(name) {
const port = this.getPort ? this.getPort(name, 'input') : null;
// If not breakpoint-aware, just return the base value
if (!port || !port.allowBreakpoints) {
return this.getParameterValue(name);
}
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
const settings = breakpointManager.settings;
// If at default breakpoint, use base parameters
if (currentBreakpoint === settings.defaultBreakpoint) {
return this.getParameterValue(name);
}
// Check for value at current breakpoint
if (this._model.breakpointParameters?.[currentBreakpoint]?.[name] !== undefined) {
return this._model.breakpointParameters[currentBreakpoint][name];
}
// Cascade: check inheritance chain
const inheritanceChain = breakpointManager.getInheritanceChain(currentBreakpoint);
for (const bp of inheritanceChain.reverse()) { // Check from closest to furthest
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
return this._model.breakpointParameters[bp][name];
}
}
// Fall back to base parameters
return this.getParameterValue(name);
},
_applyParameterValue(name, value) {
// Override in specific node types to apply the value
// For visual nodes, this might update CSS properties
}
}
```
### Step 4: Integrate with Visual Nodes
**File:** `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` (or equivalent)
```javascript
const breakpointManager = require('@noodl/runtime/src/breakpointmanager');
// In visual node base
{
initialize() {
// ... existing initialization
// Set up breakpoint support
this._initializeBreakpointSupport();
},
_onNodeDeleted() {
// ... existing cleanup
this._disposeBreakpointSupport();
},
// Override to apply CSS property values
_applyParameterValue(name, value) {
// Map parameter name to CSS property
const cssProperty = this._getCSSPropertyForParameter(name);
if (cssProperty && this._internal.element) {
this._internal.element.style[cssProperty] = value;
}
// Or if using React, set state/props
if (this._internal.reactComponent) {
// Trigger re-render with new value
this.forceUpdate();
}
},
_getCSSPropertyForParameter(name) {
// Map Noodl parameter names to CSS properties
const mapping = {
marginTop: 'marginTop',
marginRight: 'marginRight',
marginBottom: 'marginBottom',
marginLeft: 'marginLeft',
paddingTop: 'paddingTop',
paddingRight: 'paddingRight',
paddingBottom: 'paddingBottom',
paddingLeft: 'paddingLeft',
width: 'width',
height: 'height',
minWidth: 'minWidth',
maxWidth: 'maxWidth',
minHeight: 'minHeight',
maxHeight: 'maxHeight',
fontSize: 'fontSize',
lineHeight: 'lineHeight',
letterSpacing: 'letterSpacing',
flexDirection: 'flexDirection',
alignItems: 'alignItems',
justifyContent: 'justifyContent',
flexWrap: 'flexWrap',
gap: 'gap'
};
return mapping[name];
},
// Override getStyle to use resolved breakpoint values
getStyle(name) {
// Check if this is a breakpoint-aware property
const port = this.getPort(name, 'input');
if (port?.allowBreakpoints) {
return this.getResolvedParameterValue(name);
}
// Fall back to existing behavior
return this._existingGetStyle(name);
}
}
```
### Step 5: Update React Component Props
**File:** For React-based visual nodes, update how props are computed
```javascript
// In the React component wrapper
getReactProps() {
const props = {};
const ports = this.getPorts('input');
for (const port of ports) {
// Use resolved value for breakpoint-aware properties
if (port.allowBreakpoints) {
props[port.name] = this.getResolvedParameterValue(port.name);
} else {
props[port.name] = this.getParameterValue(port.name);
}
}
return props;
}
```
### Step 6: Add Transition Support (Optional Enhancement)
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
```javascript
// Add transition support for smooth breakpoint changes
class BreakpointManager extends EventEmitter {
// ... existing code
_updateBreakpoint(width) {
const newBreakpoint = this.getBreakpointForWidth(width);
if (newBreakpoint !== this.currentBreakpoint) {
const previousBreakpoint = this.currentBreakpoint;
this.currentBreakpoint = newBreakpoint;
// Add CSS class for transitions
if (typeof document !== 'undefined') {
document.body.classList.add('noodl-breakpoint-transitioning');
// Remove after transition completes
setTimeout(() => {
document.body.classList.remove('noodl-breakpoint-transitioning');
}, 300);
}
this.emit('breakpointChanged', {
breakpoint: newBreakpoint,
previousBreakpoint,
width
});
}
}
}
```
**CSS:** Add to runtime styles
```css
/* Smooth transitions when breakpoint changes */
.noodl-breakpoint-transitioning * {
transition:
margin 0.2s ease-out,
padding 0.2s ease-out,
width 0.2s ease-out,
height 0.2s ease-out,
font-size 0.2s ease-out,
gap 0.2s ease-out !important;
}
```
### Step 7: Editor-Runtime Communication
**File:** `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts`
```typescript
// When breakpoint settings change in editor, sync to runtime
onBreakpointSettingsChanged(settings: BreakpointSettings) {
this.tryWebviewCall(() => {
this.webview.executeJavaScript(`
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
window.NoodlRuntime.breakpointManager.initialize(${JSON.stringify(settings)});
}
`);
});
}
// Optionally: Force breakpoint for preview purposes
forceRuntimeBreakpoint(breakpointId: string | null) {
this.tryWebviewCall(() => {
this.webview.executeJavaScript(`
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
window.NoodlRuntime.breakpointManager.forceBreakpoint(${JSON.stringify(breakpointId)});
}
`);
});
}
```
## Files to Modify
| File | Changes |
|------|---------|
| `packages/noodl-runtime/src/models/graphmodel.js` | Initialize breakpointManager with settings |
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add breakpoint value resolution |
| `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` | Wire up breakpoint subscriptions |
| `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts` | Add breakpoint sync to runtime |
## Files to Create
| File | Purpose |
|------|---------|
| `packages/noodl-runtime/src/breakpointmanager.js` | Central breakpoint detection and management |
| `packages/noodl-runtime/src/styles/breakpoints.css` | Optional transition styles |
## Testing Checklist
- [ ] BreakpointManager correctly detects breakpoint from window width
- [ ] BreakpointManager fires 'breakpointChanged' event on resize
- [ ] Debouncing prevents excessive events during resize drag
- [ ] Nodes receive breakpoint change notifications
- [ ] Nodes apply correct breakpoint-specific values
- [ ] Cascade works correctly (tablet inherits desktop values)
- [ ] Mobile-first cascade works when configured
- [ ] Values update smoothly during breakpoint transitions
- [ ] `forceBreakpoint` works for testing/preview
- [ ] Memory cleanup works (no leaks on node deletion)
- [ ] Works in both editor preview and deployed app
## Success Criteria
1. ✅ Resizing browser window changes applied breakpoint
2. ✅ Visual nodes update their dimensions/spacing instantly
3. ✅ Values cascade correctly when not overridden
4. ✅ Both desktop-first and mobile-first work
5. ✅ No performance issues with many nodes
## Gotchas & Notes
1. **SSR Considerations**: If Noodl supports SSR, `window` won't exist on server. Guard all window access with `typeof window !== 'undefined'`.
2. **Performance**: With many nodes subscribed, breakpoint changes could cause many re-renders. Consider:
- Batch updates using requestAnimationFrame
- Only re-render nodes whose values actually changed
3. **Debounce Tuning**: 100ms debounce is a starting point. May need adjustment based on feel.
4. **Transition Timing**: The CSS transition duration (0.2s) should match user expectations. Could make configurable.
5. **Initial Load**: On first load, breakpoint should be set BEFORE first render to avoid flash of wrong layout.
6. **Testing Breakpoints**: Add `breakpointManager.forceBreakpoint()` to allow testing different breakpoints without resizing window.
7. **React Strict Mode**: If using React Strict Mode, ensure subscriptions are properly cleaned up (may fire twice in dev).
## Performance Optimization Ideas
1. **Selective Updates**: Track which properties actually differ between breakpoints, only update those.
2. **CSS Variables**: Consider using CSS custom properties for breakpoint values, letting browser handle changes:
```javascript
// Set CSS variable per breakpoint
document.documentElement.style.setProperty('--node-123-margin-top', '24px');
```
3. **Batch Notifications**: Collect all changed nodes and update in single batch:
```javascript
requestAnimationFrame(() => {
changedNodes.forEach(node => node.forceUpdate());
});
```

View File

@@ -0,0 +1,511 @@
# Phase 4: Variants Integration
## Overview
Extend the existing Variants system to support breakpoint-specific values. When a user creates a variant (e.g., "Big Blue Button"), they should be able to define different margin/padding/width values for each breakpoint within that variant.
**Estimate:** 1-2 days
**Dependencies:** Phases 1-3
## Goals
1. Add `breakpointParameters` to VariantModel
2. Extend variant editing UI to show breakpoint selector
3. Implement value resolution hierarchy: Variant breakpoint → Variant base → Node base
4. Ensure variant updates propagate to all nodes using that variant
## Value Resolution Hierarchy
When a node uses a variant, values are resolved in this order:
```
1. Node instance breakpointParameters[currentBreakpoint][property]
↓ (if undefined)
2. Node instance parameters[property]
↓ (if undefined)
3. Variant breakpointParameters[currentBreakpoint][property]
↓ (if undefined, cascade to larger breakpoints)
4. Variant parameters[property]
↓ (if undefined)
5. Node type default
```
### Example
```javascript
// Variant "Big Blue Button"
{
name: 'Big Blue Button',
typename: 'net.noodl.visual.controls.button',
parameters: {
paddingLeft: '24px', // base padding
paddingRight: '24px'
},
breakpointParameters: {
tablet: { paddingLeft: '16px', paddingRight: '16px' },
phone: { paddingLeft: '12px', paddingRight: '12px' }
}
}
// Node instance using this variant
{
variantName: 'Big Blue Button',
parameters: {}, // no instance overrides
breakpointParameters: {
phone: { paddingLeft: '8px' } // only override phone left padding
}
}
// Resolution for paddingLeft on phone:
// 1. Check node.breakpointParameters.phone.paddingLeft → '8px' ✓ (use this)
// Resolution for paddingRight on phone:
// 1. Check node.breakpointParameters.phone.paddingRight → undefined
// 2. Check node.parameters.paddingRight → undefined
// 3. Check variant.breakpointParameters.phone.paddingRight → '12px' ✓ (use this)
```
## Implementation Steps
### Step 1: Extend VariantModel
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
```typescript
export class VariantModel extends Model {
name: string;
typename: string;
parameters: Record<string, any>;
stateParameters: Record<string, Record<string, any>>;
stateTransitions: Record<string, any>;
defaultStateTransitions: any;
// NEW
breakpointParameters: Record<string, Record<string, any>>;
constructor(args) {
super();
this.name = args.name;
this.typename = args.typename;
this.parameters = {};
this.stateParameters = {};
this.stateTransitions = {};
this.breakpointParameters = {}; // NEW
}
// NEW methods
hasBreakpointParameter(name: string, breakpoint: string): boolean {
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
}
getBreakpointParameter(name: string, breakpoint: string): any {
return this.breakpointParameters?.[breakpoint]?.[name];
}
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any) {
if (!this.breakpointParameters) this.breakpointParameters = {};
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
const oldValue = this.breakpointParameters[breakpoint][name];
if (value === undefined) {
delete this.breakpointParameters[breakpoint][name];
} else {
this.breakpointParameters[breakpoint][name] = value;
}
this.notifyListeners('variantParametersChanged', {
name,
value,
breakpoint
});
// Undo support
if (args?.undo) {
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
undo.push({
label: args.label || 'change variant breakpoint parameter',
do: () => this.setBreakpointParameter(name, value, breakpoint),
undo: () => this.setBreakpointParameter(name, oldValue, breakpoint)
});
}
}
// Extend getParameter to support breakpoint context
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
let value;
// Check breakpoint-specific value
if (args?.breakpoint && args.breakpoint !== 'desktop') {
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
if (value !== undefined) return value;
}
// Check state-specific value (existing logic)
if (args?.state && args.state !== 'neutral') {
if (this.stateParameters?.[args.state]?.[name] !== undefined) {
value = this.stateParameters[args.state][name];
}
if (value !== undefined) return value;
}
// Check base parameters
value = this.parameters[name];
if (value !== undefined) return value;
// Get default from port
const port = this.getPort(name, 'input');
return port?.default;
}
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
// Check current breakpoint
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
return this.breakpointParameters[breakpoint][name];
}
// Cascade to larger breakpoints
const settings = ProjectModel.instance.getBreakpointSettings();
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
const currentIndex = cascadeOrder.indexOf(breakpoint);
for (let i = currentIndex - 1; i >= 0; i--) {
const bp = cascadeOrder[i];
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
return this.breakpointParameters[bp][name];
}
}
return undefined;
}
// Extend updateFromNode to include breakpoint parameters
updateFromNode(node) {
_merge(this.parameters, node.parameters);
// Merge breakpoint parameters
if (node.breakpointParameters) {
if (!this.breakpointParameters) this.breakpointParameters = {};
for (const breakpoint in node.breakpointParameters) {
if (!this.breakpointParameters[breakpoint]) {
this.breakpointParameters[breakpoint] = {};
}
_merge(this.breakpointParameters[breakpoint], node.breakpointParameters[breakpoint]);
}
}
// ... existing state parameter merging
this.notifyListeners('variantParametersChanged');
}
// Extend toJSON
toJSON() {
return {
name: this.name,
typename: this.typename,
parameters: this.parameters,
stateParameters: this.stateParameters,
stateTransitions: this.stateTransitions,
defaultStateTransitions: this.defaultStateTransitions,
breakpointParameters: this.breakpointParameters // NEW
};
}
}
```
### Step 2: Extend Runtime Variant Handling
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
```javascript
// Add method to update variant breakpoint parameters
GraphModel.prototype.updateVariantBreakpointParameter = function(
variantName,
variantTypeName,
parameterName,
parameterValue,
breakpoint
) {
const variant = this.getVariant(variantTypeName, variantName);
if (!variant) {
console.log("updateVariantBreakpointParameter: can't find variant", variantName, variantTypeName);
return;
}
if (!variant.breakpointParameters) {
variant.breakpointParameters = {};
}
if (!variant.breakpointParameters[breakpoint]) {
variant.breakpointParameters[breakpoint] = {};
}
if (parameterValue === undefined) {
delete variant.breakpointParameters[breakpoint][parameterName];
} else {
variant.breakpointParameters[breakpoint][parameterName] = parameterValue;
}
this.emit('variantUpdated', variant);
};
```
### Step 3: Extend ModelProxy for Variant Editing
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
```typescript
export class ModelProxy {
// ... existing properties
getParameter(name: string) {
const source = this.editMode === 'variant' ? this.model.variant : this.model;
const port = this.model.getPort(name, 'input');
// Breakpoint handling
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
// Check for breakpoint-specific value in source
const breakpointValue = source.getBreakpointParameter?.(name, this.breakpoint);
if (breakpointValue !== undefined) return breakpointValue;
// Cascade logic...
}
// ... existing visual state and base parameter logic
return source.getParameter(name, {
state: this.visualState,
breakpoint: this.breakpoint
});
}
setParameter(name: string, value: any, args: any = {}) {
const port = this.model.getPort(name, 'input');
const target = this.editMode === 'variant' ? this.model.variant : this.model;
// If setting a breakpoint-specific value
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
target.setBreakpointParameter(name, value, this.breakpoint, {
...args,
undo: args.undo
});
return;
}
// ... existing parameter setting logic
}
isBreakpointValueInherited(name: string): boolean {
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
const source = this.editMode === 'variant' ? this.model.variant : this.model;
return !source.hasBreakpointParameter?.(name, this.breakpoint);
}
}
```
### Step 4: Update Variant Editor UI
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx`
```tsx
// Add breakpoint selector to variant editing mode
export class VariantsEditor extends React.Component<VariantsEditorProps, State> {
// ... existing implementation
renderEditMode() {
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
return (
<div style={{ width: '100%' }}>
<div className="variants-edit-mode-header">Edit variant</div>
{/* Show breakpoint selector in variant edit mode */}
{hasBreakpointPorts && (
<BreakpointSelector
breakpoints={this.getBreakpoints()}
selectedBreakpoint={this.state.breakpoint || 'desktop'}
overrideCounts={this.calculateVariantOverrideCounts()}
onBreakpointChange={this.onBreakpointChanged.bind(this)}
/>
)}
<div className="variants-section">
<label>{this.state.variant.name}</label>
<button
className="variants-button teal"
style={{ marginLeft: 'auto', width: '78px' }}
onClick={this.onDoneEditingVariant.bind(this)}
>
Close
</button>
</div>
</div>
);
}
onBreakpointChanged(breakpoint: string) {
this.setState({ breakpoint });
this.props.onBreakpointChanged?.(breakpoint);
}
calculateVariantOverrideCounts(): Record<string, number> {
const counts: Record<string, number> = {};
const variant = this.state.variant;
const settings = ProjectModel.instance.getBreakpointSettings();
for (const bp of settings.breakpoints) {
if (bp.id === settings.defaultBreakpoint) continue;
const overrides = variant.breakpointParameters?.[bp.id];
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
}
return counts;
}
hasBreakpointAwarePorts(): boolean {
const type = NodeLibrary.instance.getNodeTypeWithName(this.state.variant?.typename);
if (!type?.ports) return false;
return type.ports.some(p => p.allowBreakpoints);
}
}
```
### Step 5: Update NodeGraphNode Value Resolution
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
```typescript
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
let value;
// 1. Check instance breakpoint parameters
if (args?.breakpoint && args.breakpoint !== 'desktop') {
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
if (value !== undefined) return value;
}
// 2. Check instance base parameters
value = this.parameters[name];
if (value !== undefined) return value;
// 3. Check variant (if has one)
if (this.variant) {
value = this.variant.getParameter(name, args);
if (value !== undefined) return value;
}
// 4. Get port default
const port = this.getPort(name, 'input');
return port?.default;
}
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
// Check current breakpoint
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
return this.breakpointParameters[breakpoint][name];
}
// Cascade to larger breakpoints (instance level)
const settings = ProjectModel.instance.getBreakpointSettings();
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
const currentIndex = cascadeOrder.indexOf(breakpoint);
for (let i = currentIndex - 1; i >= 0; i--) {
const bp = cascadeOrder[i];
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
return this.breakpointParameters[bp][name];
}
}
// Check variant breakpoint parameters
if (this.variant) {
return this.variant.getBreakpointParameterWithCascade(name, breakpoint);
}
return undefined;
}
```
### Step 6: Sync Variant Changes to Runtime
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
```typescript
// When variant breakpoint parameters change, sync to runtime
onVariantParametersChanged(variant: VariantModel, changeInfo: any) {
// ... existing sync logic
// If breakpoint parameter changed, notify runtime
if (changeInfo.breakpoint) {
this.graphModel.updateVariantBreakpointParameter(
variant.name,
variant.typename,
changeInfo.name,
changeInfo.value,
changeInfo.breakpoint
);
}
}
```
## Files to Modify
| File | Changes |
|------|---------|
| `packages/noodl-editor/src/editor/src/models/VariantModel.ts` | Add breakpointParameters field and methods |
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Update value resolution to check variant breakpoints |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle variant breakpoint context |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx` | Add breakpoint selector to variant edit mode |
| `packages/noodl-runtime/src/models/graphmodel.js` | Add variant breakpoint parameter update method |
## Testing Checklist
- [ ] Can create variant with breakpoint-specific values
- [ ] Variant breakpoint values are saved to project JSON
- [ ] Variant breakpoint values are loaded from project JSON
- [ ] Node instance inherits variant breakpoint values correctly
- [ ] Node instance can override specific variant breakpoint values
- [ ] Cascade works: variant tablet inherits from variant desktop
- [ ] Editing variant in "Edit variant" mode shows breakpoint selector
- [ ] Changes to variant breakpoint values propagate to all instances
- [ ] Undo/redo works for variant breakpoint changes
- [ ] Runtime applies variant breakpoint values correctly
## Success Criteria
1. ✅ Variants can have different values per breakpoint
2. ✅ Node instances inherit variant breakpoint values
3. ✅ Node instances can selectively override variant values
4. ✅ UI allows editing variant breakpoint values
5. ✅ Runtime correctly resolves variant + breakpoint hierarchy
## Gotchas & Notes
1. **Resolution Order**: The hierarchy is complex. Make sure tests cover all combinations:
- Instance breakpoint override > Instance base > Variant breakpoint > Variant base > Type default
2. **Variant Edit Mode**: When editing a variant, the breakpoint selector edits the VARIANT's breakpoint values, not the instance's.
3. **Variant Update Propagation**: When a variant's breakpoint values change, ALL nodes using that variant need to update. This could be performance-sensitive.
4. **State + Breakpoint + Variant**: The full combination is: variant/instance × state × breakpoint. For simplicity, we might NOT support visual state variations within variant breakpoint values (e.g., no "variant hover on tablet"). Confirm this is acceptable.
5. **Migration**: Existing variants won't have breakpointParameters. Handle gracefully (undefined → empty object).
## Complexity Note
This phase adds a third dimension to the value resolution:
- **Visual States**: hover, pressed, disabled
- **Breakpoints**: desktop, tablet, phone
- **Variants**: named style variations
The full matrix can get complex. For this phase, we're keeping visual states and breakpoints as independent axes (they don't interact with each other within variants). A future phase could add combined state+breakpoint support if needed.

View File

@@ -0,0 +1,575 @@
# Phase 5: Visual States + Breakpoints Combo
## Overview
Enable granular control where users can define values for specific combinations of visual state AND breakpoint. For example: "button hover state on tablet" can have different padding than "button hover state on desktop".
**Estimate:** 2 days
**Dependencies:** Phases 1-4
## Goals
1. Add `stateBreakpointParameters` storage for combined state+breakpoint values
2. Implement resolution hierarchy with combo values at highest priority
3. Update property panel UI to show combo editing option
4. Ensure runtime correctly resolves combo values
## When This Is Useful
Without combo support:
- Button hover padding is `20px` (all breakpoints)
- Button tablet padding is `16px` (all states)
- When hovering on tablet → ambiguous! Which wins?
With combo support:
- Can explicitly set: "button hover ON tablet = `18px`"
- Clear, deterministic resolution
## Data Model
```javascript
{
parameters: {
paddingLeft: '24px' // base
},
stateParameters: {
hover: { paddingLeft: '28px' } // hover state (all breakpoints)
},
breakpointParameters: {
tablet: { paddingLeft: '16px' } // tablet (all states)
},
// NEW: Combined state + breakpoint
stateBreakpointParameters: {
'hover:tablet': { paddingLeft: '20px' }, // hover ON tablet
'hover:phone': { paddingLeft: '14px' }, // hover ON phone
'pressed:tablet': { paddingLeft: '18px' } // pressed ON tablet
}
}
```
## Resolution Hierarchy
From highest to lowest priority:
```
1. stateBreakpointParameters['hover:tablet'] // Most specific
↓ (if undefined)
2. stateParameters['hover'] // State-specific
↓ (if undefined)
3. breakpointParameters['tablet'] // Breakpoint-specific
↓ (if undefined, cascade to larger breakpoints)
4. parameters // Base value
↓ (if undefined)
5. variant values (same hierarchy)
↓ (if undefined)
6. type default
```
## Implementation Steps
### Step 1: Extend NodeGraphNode Model
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
```typescript
export class NodeGraphNode {
// ... existing properties
// NEW
stateBreakpointParameters: Record<string, Record<string, any>>;
constructor(args) {
// ... existing initialization
this.stateBreakpointParameters = args.stateBreakpointParameters || {};
}
// NEW methods
getStateBreakpointKey(state: string, breakpoint: string): string {
return `${state}:${breakpoint}`;
}
hasStateBreakpointParameter(name: string, state: string, breakpoint: string): boolean {
const key = this.getStateBreakpointKey(state, breakpoint);
return this.stateBreakpointParameters?.[key]?.[name] !== undefined;
}
getStateBreakpointParameter(name: string, state: string, breakpoint: string): any {
const key = this.getStateBreakpointKey(state, breakpoint);
return this.stateBreakpointParameters?.[key]?.[name];
}
setStateBreakpointParameter(
name: string,
value: any,
state: string,
breakpoint: string,
args?: any
): void {
const key = this.getStateBreakpointKey(state, breakpoint);
if (!this.stateBreakpointParameters) {
this.stateBreakpointParameters = {};
}
if (!this.stateBreakpointParameters[key]) {
this.stateBreakpointParameters[key] = {};
}
const oldValue = this.stateBreakpointParameters[key][name];
if (value === undefined) {
delete this.stateBreakpointParameters[key][name];
// Clean up empty objects
if (Object.keys(this.stateBreakpointParameters[key]).length === 0) {
delete this.stateBreakpointParameters[key];
}
} else {
this.stateBreakpointParameters[key][name] = value;
}
this.notifyListeners('parametersChanged', {
name,
value,
state,
breakpoint,
combo: true
});
// Undo support
if (args?.undo) {
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
undo.push({
label: args.label || `change ${name} for ${state} on ${breakpoint}`,
do: () => this.setStateBreakpointParameter(name, value, state, breakpoint),
undo: () => this.setStateBreakpointParameter(name, oldValue, state, breakpoint)
});
}
}
// Updated getParameter with full resolution
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
const state = args?.state;
const breakpoint = args?.breakpoint;
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
// 1. Check state + breakpoint combo (most specific)
if (state && state !== 'neutral' && breakpoint && breakpoint !== defaultBreakpoint) {
const comboValue = this.getStateBreakpointParameter(name, state, breakpoint);
if (comboValue !== undefined) return comboValue;
}
// 2. Check state-specific value
if (state && state !== 'neutral') {
if (this.stateParameters?.[state]?.[name] !== undefined) {
return this.stateParameters[state][name];
}
}
// 3. Check breakpoint-specific value (with cascade)
if (breakpoint && breakpoint !== defaultBreakpoint) {
const breakpointValue = this.getBreakpointParameterWithCascade(name, breakpoint);
if (breakpointValue !== undefined) return breakpointValue;
}
// 4. Check base parameters
if (this.parameters[name] !== undefined) {
return this.parameters[name];
}
// 5. Check variant (with same hierarchy)
if (this.variant) {
return this.variant.getParameter(name, args);
}
// 6. Type default
const port = this.getPort(name, 'input');
return port?.default;
}
// Extend toJSON
toJSON(): object {
return {
...existingFields,
stateBreakpointParameters: this.stateBreakpointParameters
};
}
}
```
### Step 2: Extend ModelProxy
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
```typescript
export class ModelProxy {
// ... existing properties
getParameter(name: string) {
const source = this.editMode === 'variant' ? this.model.variant : this.model;
const port = this.model.getPort(name, 'input');
const state = this.visualState;
const breakpoint = this.breakpoint;
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
// Check if both state and breakpoint are set (combo scenario)
const hasState = state && state !== 'neutral';
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
// For combo: only check if BOTH the property allows states AND breakpoints
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
const comboValue = source.getStateBreakpointParameter?.(name, state, breakpoint);
if (comboValue !== undefined) return comboValue;
}
// ... existing resolution logic
return source.getParameter(name, { state, breakpoint });
}
setParameter(name: string, value: any, args: any = {}) {
const port = this.model.getPort(name, 'input');
const target = this.editMode === 'variant' ? this.model.variant : this.model;
const state = this.visualState;
const breakpoint = this.breakpoint;
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
const hasState = state && state !== 'neutral';
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
// If BOTH state and breakpoint are active, and property supports both
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
target.setStateBreakpointParameter(name, value, state, breakpoint, {
...args,
undo: args.undo
});
return;
}
// If only breakpoint (and property supports it)
if (hasBreakpoint && port?.allowBreakpoints) {
target.setBreakpointParameter(name, value, breakpoint, {
...args,
undo: args.undo
});
return;
}
// ... existing parameter setting logic (state or base)
args.state = state;
target.setParameter(name, value, args);
}
// NEW: Check if current value is from combo
isComboValue(name: string): boolean {
if (!this.visualState || this.visualState === 'neutral') return false;
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
const source = this.editMode === 'variant' ? this.model.variant : this.model;
return source.hasStateBreakpointParameter?.(name, this.visualState, this.breakpoint) || false;
}
// NEW: Get info about where current value comes from
getValueSource(name: string): 'combo' | 'state' | 'breakpoint' | 'base' {
const source = this.editMode === 'variant' ? this.model.variant : this.model;
const state = this.visualState;
const breakpoint = this.breakpoint;
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
if (source.hasStateBreakpointParameter?.(name, state, breakpoint)) {
return 'combo';
}
}
if (state && state !== 'neutral') {
if (source.stateParameters?.[state]?.[name] !== undefined) {
return 'state';
}
}
if (breakpoint && breakpoint !== 'desktop') {
if (source.hasBreakpointParameter?.(name, breakpoint)) {
return 'breakpoint';
}
}
return 'base';
}
}
```
### Step 3: Update Property Panel UI
**File:** Update property row to show combo indicators
```tsx
// In PropertyPanelRow or equivalent
export function PropertyPanelRow({
label,
children,
isBreakpointAware,
allowsVisualStates,
valueSource, // 'combo' | 'state' | 'breakpoint' | 'base'
currentState,
currentBreakpoint,
onReset
}: PropertyPanelRowProps) {
function getIndicator() {
switch (valueSource) {
case 'combo':
return (
<Tooltip content={`Set for ${currentState} on ${currentBreakpoint}`}>
<span className={css.ComboIndicator}>
{currentState} + {currentBreakpoint}
</span>
</Tooltip>
);
case 'state':
return (
<Tooltip content={`Set for ${currentState} state`}>
<span className={css.StateIndicator}> {currentState}</span>
</Tooltip>
);
case 'breakpoint':
return (
<Tooltip content={`Set for ${currentBreakpoint}`}>
<span className={css.BreakpointIndicator}> {currentBreakpoint}</span>
</Tooltip>
);
case 'base':
default:
if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') {
return <span className={css.Inherited}>(inherited)</span>;
}
return null;
}
}
return (
<div className={css.Root}>
<label className={css.Label}>{label}</label>
<div className={css.InputContainer}>
{children}
{getIndicator()}
{valueSource !== 'base' && onReset && (
<button className={css.ResetButton} onClick={onReset}>
<Icon icon={IconName.Undo} size={12} />
</button>
)}
</div>
</div>
);
}
```
### Step 4: Update Runtime Resolution
**File:** `packages/noodl-runtime/src/nodes/nodebase.js`
```javascript
{
getResolvedParameterValue(name) {
const port = this.getPort ? this.getPort(name, 'input') : null;
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
const currentState = this._internal?.currentVisualState || 'default';
const defaultBreakpoint = breakpointManager.settings?.defaultBreakpoint || 'desktop';
// 1. Check combo value (state + breakpoint)
if (port?.allowVisualStates && port?.allowBreakpoints) {
if (currentState !== 'default' && currentBreakpoint !== defaultBreakpoint) {
const comboKey = `${currentState}:${currentBreakpoint}`;
const comboValue = this._model.stateBreakpointParameters?.[comboKey]?.[name];
if (comboValue !== undefined) return comboValue;
}
}
// 2. Check state-specific value
if (port?.allowVisualStates && currentState !== 'default') {
const stateValue = this._model.stateParameters?.[currentState]?.[name];
if (stateValue !== undefined) return stateValue;
}
// 3. Check breakpoint-specific value (with cascade)
if (port?.allowBreakpoints && currentBreakpoint !== defaultBreakpoint) {
const breakpointValue = this.getBreakpointValueWithCascade(name, currentBreakpoint);
if (breakpointValue !== undefined) return breakpointValue;
}
// 4. Base parameters
return this.getParameterValue(name);
},
getBreakpointValueWithCascade(name, breakpoint) {
// Check current breakpoint
if (this._model.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
return this._model.breakpointParameters[breakpoint][name];
}
// Cascade
const inheritanceChain = breakpointManager.getInheritanceChain(breakpoint);
for (const bp of inheritanceChain.reverse()) {
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
return this._model.breakpointParameters[bp][name];
}
}
return undefined;
}
}
```
### Step 5: Extend VariantModel (Optional)
If we want variants to also support combo values:
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
```typescript
export class VariantModel extends Model {
// ... existing properties
stateBreakpointParameters: Record<string, Record<string, any>>;
// Add similar methods as NodeGraphNode:
// - hasStateBreakpointParameter
// - getStateBreakpointParameter
// - setStateBreakpointParameter
// Update getParameter to include combo resolution
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
const state = args?.state;
const breakpoint = args?.breakpoint;
// 1. Check combo
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
const comboKey = `${state}:${breakpoint}`;
if (this.stateBreakpointParameters?.[comboKey]?.[name] !== undefined) {
return this.stateBreakpointParameters[comboKey][name];
}
}
// ... rest of resolution hierarchy
}
}
```
### Step 6: Update Serialization
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
```typescript
// In toJSON()
toJSON(): object {
const json: any = {
id: this.id,
type: this.type.name,
parameters: this.parameters,
// ... other fields
};
// Only include if not empty
if (this.stateParameters && Object.keys(this.stateParameters).length > 0) {
json.stateParameters = this.stateParameters;
}
if (this.breakpointParameters && Object.keys(this.breakpointParameters).length > 0) {
json.breakpointParameters = this.breakpointParameters;
}
if (this.stateBreakpointParameters && Object.keys(this.stateBreakpointParameters).length > 0) {
json.stateBreakpointParameters = this.stateBreakpointParameters;
}
return json;
}
// In fromJSON / constructor
static fromJSON(json) {
return new NodeGraphNode({
...json,
stateBreakpointParameters: json.stateBreakpointParameters || {}
});
}
```
## Files to Modify
| File | Changes |
|------|---------|
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add stateBreakpointParameters field and methods |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle combo context in get/setParameter |
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass combo info to property rows |
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add combo resolution to getResolvedParameterValue |
| `packages/noodl-runtime/src/models/nodemodel.js` | Add stateBreakpointParameters storage |
## Files to Create
None - this phase extends existing files.
## Testing Checklist
- [ ] Can set combo value (e.g., hover + tablet)
- [ ] Combo value takes priority over individual state/breakpoint values
- [ ] When state OR breakpoint changes, combo value is no longer used (falls through to next priority)
- [ ] Combo values are saved to project JSON
- [ ] Combo values are loaded from project JSON
- [ ] UI shows correct indicator for combo values
- [ ] Reset button clears combo value correctly
- [ ] Runtime applies combo values correctly when both conditions match
- [ ] Undo/redo works for combo value changes
## Success Criteria
1. ✅ Can define "hover on tablet" as distinct from "hover" and "tablet"
2. ✅ Clear UI indication of what level value is set at
3. ✅ Values fall through correctly when combo doesn't match
4. ✅ Runtime correctly identifies when combo conditions are met
## Gotchas & Notes
1. **Complexity**: This adds significant complexity. Consider if this is really needed, or if the simpler "state values apply across all breakpoints" is sufficient.
2. **UI Clarity**: The UI needs to clearly communicate which level a value is set at. Consider using different colors:
- Purple dot: combo value (state + breakpoint)
- Blue dot: state value only
- Green dot: breakpoint value only
- Gray/no dot: base value
3. **Property Support**: Only properties that have BOTH `allowVisualStates: true` AND `allowBreakpoints: true` can have combo values. In practice, this might be a small subset (mostly spacing properties for interactive elements).
4. **Variant Complexity**: If variants also support combos, the full hierarchy becomes:
- Instance combo → Instance state → Instance breakpoint → Instance base
- → Variant combo → Variant state → Variant breakpoint → Variant base
- → Type default
This is 9 levels! Consider if variant combo support is worth it.
5. **Performance**: With 4 breakpoints × 4 states × N properties, the parameter space grows quickly. Make sure resolution is efficient.
## Alternative: Simpler Approach
If combo complexity is too high, consider this simpler alternative:
**States inherit from breakpoint, not base:**
```
Current: state value = same across all breakpoints
Alternative: state value = applied ON TOP OF current breakpoint value
```
Example:
```javascript
// Base: paddingLeft = 24px
// Tablet: paddingLeft = 16px
// Hover state: paddingLeft = +4px (relative)
// Result:
// Desktop hover = 24 + 4 = 28px
// Tablet hover = 16 + 4 = 20px
```
This avoids needing explicit combo values but requires supporting relative/delta values for states, which has its own complexity.

View File

@@ -0,0 +1,489 @@
# TASK: Video Player Node
**Task ID:** NODES-001
**Priority:** Medium-High
**Estimated Effort:** 16-24 hours
**Prerequisites:** React 18.3+ runtime (completed)
**Status:** Ready for Implementation
---
## Overview
Create a comprehensive Video Player node that handles video playback from URLs or blobs with rich inputs and outputs for complete video management. This addresses a gap in Noodl's visual node offerings - currently users must resort to Function nodes for anything beyond basic video display.
### Why This Matters
- **Table stakes feature** - Users expect video playback in any modern low-code tool
- **App builder unlock** - Enables video-centric apps (portfolios, e-learning, social, editors)
- **Blob support differentiator** - Play local files without server upload (rare in competitors)
- **Community requested** - Direct request from OpenNoodl community
---
## Success Criteria
- [ ] Video plays from URL (mp4, webm)
- [ ] Video plays from blob/File object (from File Picker node)
- [ ] All playback controls work via signal inputs
- [ ] Time tracking outputs update in real-time
- [ ] Events fire correctly for all lifecycle moments
- [ ] Fullscreen and Picture-in-Picture work cross-browser
- [ ] Frame capture produces valid base64 image
- [ ] Captions/subtitles display from VTT file
- [ ] Works in both editor preview and deployed apps
- [ ] Performance: time updates don't cause UI jank
---
## Technical Architecture
### Node Registration
```
Location: packages/noodl-viewer-react/src/nodes/visual/videoplayer.js (new file)
Type: Visual/Frontend node using createNodeFromReactComponent
Category: "Visual" or "UI Elements" > "Media"
Name: net.noodl.visual.videoplayer
Display Name: Video Player
```
### Core Implementation Pattern
```javascript
import { createNodeFromReactComponent } from '@noodl/react-component-node';
const VideoPlayer = createNodeFromReactComponent({
name: 'net.noodl.visual.videoplayer',
displayName: 'Video Player',
category: 'Visual',
docs: 'https://docs.noodl.net/nodes/visual/video-player',
// Standard visual node frame options
frame: {
dimensions: true,
position: true,
margins: true,
align: true
},
allowChildren: false,
getReactComponent() {
return VideoPlayerComponent; // Defined below
},
// ... inputs/outputs defined below
});
```
### React Component Structure
```javascript
function VideoPlayerComponent(props) {
const videoRef = useRef(null);
const [state, setState] = useState({
isPlaying: false,
isPaused: true,
isEnded: false,
isBuffering: false,
isSeeking: false,
isFullscreen: false,
isPiP: false,
hasError: false,
errorMessage: '',
currentTime: 0,
duration: 0,
bufferedPercent: 0,
videoWidth: 0,
videoHeight: 0
});
// Use deferred value for time to prevent jank
const deferredTime = useDeferredValue(state.currentTime);
// ... event handlers, effects, signal handlers
return (
<video
ref={videoRef}
style={props.style}
src={props.url || undefined}
poster={props.posterImage}
controls={props.controlsVisible}
loop={props.loop}
muted={props.muted}
autoPlay={props.autoplay}
playsInline={props.playsInline}
preload={props.preload}
crossOrigin={props.crossOrigin}
// ... all event handlers
>
{props.captionsUrl && (
<track
kind="subtitles"
src={props.captionsUrl}
srcLang={props.captionsLanguage || 'en'}
default={props.captionsEnabled}
/>
)}
</video>
);
}
```
---
## Input/Output Specification
### Inputs - Source
| Name | Type | Default | Description |
|------|------|---------|-------------|
| URL | string | - | Video URL (mp4, webm, ogg, hls) |
| Blob | any | - | File/Blob object from File Picker |
| Poster Image | string | - | Thumbnail URL shown before play |
| Source Type | enum | auto | auto/mp4/webm/ogg/hls |
### Inputs - Playback Control (Signals)
| Name | Type | Description |
|------|------|-------------|
| Play | signal | Start playback |
| Pause | signal | Pause playback |
| Toggle Play/Pause | signal | Toggle current state |
| Stop | signal | Pause and seek to 0 |
| Seek To | signal | Seek to "Seek Time" value |
| Skip Forward | signal | Skip forward by "Skip Amount" |
| Skip Backward | signal | Skip backward by "Skip Amount" |
### Inputs - Playback Settings
| Name | Type | Default | Description |
|------|------|---------|-------------|
| Seek Time | number | 0 | Target time for Seek To (seconds) |
| Skip Amount | number | 10 | Seconds to skip forward/backward |
| Volume | number | 1 | Volume level 0-1 |
| Muted | boolean | false | Mute audio |
| Playback Rate | number | 1 | Speed: 0.25-4 |
| Loop | boolean | false | Loop playback |
| Autoplay | boolean | false | Auto-start on load |
| Preload | enum | auto | none/metadata/auto |
| Controls Visible | boolean | true | Show native controls |
### Inputs - Advanced
| Name | Type | Default | Description |
|------|------|---------|-------------|
| Start Time | number | 0 | Auto-seek on load |
| End Time | number | - | Auto-pause/loop point |
| Plays Inline | boolean | true | iOS inline playback |
| Cross Origin | enum | anonymous | anonymous/use-credentials |
| PiP Enabled | boolean | true | Allow Picture-in-Picture |
### Inputs - Captions
| Name | Type | Default | Description |
|------|------|---------|-------------|
| Captions URL | string | - | VTT subtitle file URL |
| Captions Enabled | boolean | false | Show captions |
| Captions Language | string | en | Language code |
### Inputs - Actions (Signals)
| Name | Type | Description |
|------|------|-------------|
| Enter Fullscreen | signal | Request fullscreen mode |
| Exit Fullscreen | signal | Exit fullscreen mode |
| Toggle Fullscreen | signal | Toggle fullscreen state |
| Enter PiP | signal | Enter Picture-in-Picture |
| Exit PiP | signal | Exit Picture-in-Picture |
| Capture Frame | signal | Capture current frame to output |
| Reload | signal | Reload video source |
### Outputs - State
| Name | Type | Description |
|------|------|-------------|
| Is Playing | boolean | Currently playing |
| Is Paused | boolean | Currently paused |
| Is Ended | boolean | Playback ended |
| Is Buffering | boolean | Waiting for data |
| Is Seeking | boolean | Currently seeking |
| Is Fullscreen | boolean | In fullscreen mode |
| Is Picture-in-Picture | boolean | In PiP mode |
| Has Error | boolean | Error occurred |
| Error Message | string | Error description |
### Outputs - Time
| Name | Type | Description |
|------|------|-------------|
| Current Time | number | Current position (seconds) |
| Duration | number | Total duration (seconds) |
| Progress | number | Position 0-1 |
| Remaining Time | number | Time remaining (seconds) |
| Formatted Current | string | "1:23" or "1:23:45" |
| Formatted Duration | string | Total as formatted string |
| Formatted Remaining | string | Remaining as formatted string |
### Outputs - Media Info
| Name | Type | Description |
|------|------|-------------|
| Video Width | number | Native video width |
| Video Height | number | Native video height |
| Aspect Ratio | number | Width/height ratio |
| Buffered Percent | number | Download progress 0-1 |
| Ready State | number | HTML5 readyState 0-4 |
### Outputs - Events (Signals)
| Name | Type | Description |
|------|------|-------------|
| Loaded Metadata | signal | Duration/dimensions available |
| Can Play | signal | Ready to start playback |
| Can Play Through | signal | Can play to end without buffering |
| Play Started | signal | Playback started |
| Paused | signal | Playback paused |
| Ended | signal | Playback ended |
| Seeking | signal | Seek operation started |
| Seeked | signal | Seek operation completed |
| Time Updated | signal | Time changed (frequent) |
| Volume Changed | signal | Volume or mute changed |
| Rate Changed | signal | Playback rate changed |
| Entered Fullscreen | signal | Entered fullscreen |
| Exited Fullscreen | signal | Exited fullscreen |
| Entered PiP | signal | Entered Picture-in-Picture |
| Exited PiP | signal | Exited Picture-in-Picture |
| Error Occurred | signal | Error happened |
| Buffering Started | signal | Started buffering |
| Buffering Ended | signal | Finished buffering |
### Outputs - Special
| Name | Type | Description |
|------|------|-------------|
| Captured Frame | string | Base64 data URL of captured frame |
---
## Implementation Phases
### Phase 1: Core Playback (4-6 hours)
- [ ] Create node file structure
- [ ] Basic video element with URL support
- [ ] Play/Pause/Stop signal inputs
- [ ] Basic state outputs (isPlaying, isPaused, etc.)
- [ ] Time outputs (currentTime, duration, progress)
- [ ] Register node in node library
### Phase 2: Extended Controls (4-6 hours)
- [ ] Seek functionality (seekTo, skipForward, skipBackward)
- [ ] Volume and mute controls
- [ ] Playback rate control
- [ ] Loop and autoplay
- [ ] All time-related event signals
- [ ] Formatted time outputs
### Phase 3: Advanced Features (4-6 hours)
- [ ] Blob/File support (from File Picker)
- [ ] Fullscreen API integration
- [ ] Picture-in-Picture API integration
- [ ] Frame capture functionality
- [ ] Start/End time range support
- [ ] Buffering state and events
### Phase 4: Polish & Testing (4-6 hours)
- [ ] Captions/subtitles support
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
- [ ] Mobile testing (iOS Safari, Android Chrome)
- [ ] Performance optimization (useDeferredValue for time)
- [ ] Error handling and edge cases
- [ ] Documentation
---
## File Locations
### New Files
```
packages/noodl-viewer-react/src/nodes/visual/videoplayer.js # Main node
```
### Modified Files
```
packages/noodl-viewer-react/src/nodes/index.js # Register node
packages/noodl-runtime/src/nodelibraryexport.js # Add to UI Elements category
```
### Reference Files (existing patterns)
```
packages/noodl-viewer-react/src/nodes/visual/image.js # Similar visual node
packages/noodl-viewer-react/src/nodes/visual/video.js # Existing basic video (if exists)
packages/noodl-viewer-react/src/nodes/controls/button.js # Signal input patterns
```
---
## Testing Checklist
### Manual Testing
**Basic Playback**
- [ ] MP4 URL loads and plays
- [ ] WebM URL loads and plays
- [ ] Poster image shows before play
- [ ] Native controls appear when enabled
- [ ] Native controls hidden when disabled
**Signal Controls**
- [ ] Play signal starts playback
- [ ] Pause signal pauses playback
- [ ] Toggle Play/Pause works correctly
- [ ] Stop pauses and seeks to 0
- [ ] Seek To jumps to correct time
- [ ] Skip Forward/Backward work with Skip Amount
**State Outputs**
- [ ] Is Playing true when playing, false otherwise
- [ ] Is Paused true when paused
- [ ] Is Ended true when video ends
- [ ] Is Buffering true during buffering
- [ ] Current Time updates during playback
- [ ] Duration correct after load
- [ ] Progress 0-1 range correct
**Events**
- [ ] Loaded Metadata fires when ready
- [ ] Play Started fires on play
- [ ] Paused fires on pause
- [ ] Ended fires when complete
- [ ] Time Updated fires during playback
**Advanced Features**
- [ ] Blob from File Picker plays correctly
- [ ] Fullscreen enter/exit works
- [ ] PiP enter/exit works (where supported)
- [ ] Frame Capture produces valid image
- [ ] Captions display from VTT file
- [ ] Start Time auto-seeks on load
- [ ] End Time auto-pauses/loops
**Cross-Browser**
- [ ] Chrome (latest)
- [ ] Firefox (latest)
- [ ] Safari (latest)
- [ ] Edge (latest)
- [ ] iOS Safari
- [ ] Android Chrome
**Edge Cases**
- [ ] Invalid URL shows error state
- [ ] Network error during playback
- [ ] Rapid play/pause doesn't break
- [ ] Seeking while buffering
- [ ] Source change during playback
- [ ] Multiple Video Player nodes on same page
---
## Code Examples for Users
### Basic Video Playback
```
[Video URL] → [Video Player]
[Is Playing] → [If node for UI state]
```
### Custom Controls
```
[Button "Play"] → Play signal → [Video Player]
[Button "Pause"] → Pause signal ↗
[Slider] → Seek Time + Seek To signal ↗
[Current Time] → [Text display]
[Duration] → [Text display]
```
### Video Upload Preview
```
[File Picker] → Blob → [Video Player]
[Capture Frame] → [Image node for thumbnail]
```
### E-Learning Progress Tracking
```
[Video Player]
[Progress] → [Progress Bar]
[Ended] → [Mark Lesson Complete logic]
```
---
## Performance Considerations
1. **Time Update Throttling**: The `timeupdate` event fires frequently (4-66Hz). Use `useDeferredValue` to prevent connected nodes from causing frame drops.
2. **Blob Memory**: When using blob sources, ensure proper cleanup on source change to prevent memory leaks.
3. **Frame Capture**: Canvas operations are synchronous. For large videos, this may cause brief UI freeze. Document this limitation.
4. **Multiple Instances**: Test with 3-5 Video Player nodes on same page to ensure no conflicts.
---
## React 19 Benefits
While this node works on React 18.3, React 19 offers:
1. **`ref` as prop** - Cleaner implementation without `forwardRef` wrapper
2. **`useDeferredValue` improvements** - Better time update performance
3. **`useTransition` for seeking** - Non-blocking seek operations
```javascript
// React 19 pattern for smooth seeking
const [isPending, startTransition] = useTransition();
function handleSeek(time) {
startTransition(() => {
videoRef.current.currentTime = time;
});
}
// isPending can drive "Is Seeking" output
```
---
## Documentation Requirements
After implementation, create:
- [ ] Node reference page for docs site
- [ ] Example project: "Video Gallery"
- [ ] Example project: "Custom Video Controls"
- [ ] Migration guide from Function-based video handling
---
## Notes & Gotchas
1. **iOS Autoplay**: iOS requires `playsInline` and `muted` for autoplay to work
2. **CORS**: External videos may need proper CORS headers for frame capture
3. **HLS/DASH**: May require additional libraries (hls.js, dash.js) - consider Phase 2 enhancement
4. **Safari PiP**: Has different API than Chrome/Firefox
5. **Fullscreen**: Different browsers have different fullscreen APIs - use unified helper
---
## Future Enhancements (Out of Scope)
- HLS/DASH streaming support via hls.js
- Video filters/effects
- Multiple audio tracks
- Chapter markers
- Thumbnail preview on seek (sprite sheet)
- Analytics integration
- DRM support

View File

@@ -55,6 +55,7 @@
background-color: var(--background);
border-radius: 2px;
overflow: hidden;
pointer-events: none; // Allow clicks to pass through to content
}
}

View File

@@ -23,6 +23,11 @@ export type DialogLayerOptions = {
id?: string;
};
export type ShowDialogOptions = DialogLayerOptions & {
/** Called when the dialog is closed */
onClose?: () => void;
};
export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerModelEvents> {
public static instance = new DialogLayerModel();
@@ -84,4 +89,40 @@ export class DialogLayerModel extends Model<DialogLayerModelEvent, DialogLayerMo
};
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
}
/**
* Show a custom dialog component.
* Returns a close function that can be called to programmatically close the dialog.
*
* @param render - Function that receives a close callback and returns JSX
* @param options - Dialog options including optional id
* @returns A function to close the dialog
*/
public showDialog(
render: (close: () => void) => JSX.Element,
options: ShowDialogOptions = {}
): () => void {
const id = options.id ?? guid();
const { onClose } = options;
const close = () => {
this.closeById(id);
onClose && onClose();
};
// Remove existing dialog with same id if present
if (this._dialogs[id]) {
this._order = this._order.filter((x) => x !== id);
delete this._dialogs[id];
}
this._order.push(id);
this._dialogs[id] = {
id,
slot: () => render(close)
};
this.notifyListeners(DialogLayerModelEvent.DialogsChanged);
return close;
}
}

View File

@@ -8,6 +8,8 @@
* @since 1.2.0
*/
import { filesystem } from '@noodl/platform';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import { detectRuntimeVersion, scanProjectForMigration } from './ProjectScanner';
import {
@@ -409,27 +411,72 @@ export class MigrationSessionManager extends EventDispatcher {
}
private async executeCopyPhase(): Promise<void> {
if (!this.session) return;
const sourcePath = this.session.source.path;
const targetPath = this.session.target.path;
this.updateProgress({ phase: 'copying', current: 0 });
this.addLogEntry({
level: 'info',
message: 'Creating project copy...'
message: `Copying project from ${sourcePath} to ${targetPath}...`
});
// TODO: Implement actual file copying using filesystem
// For now, this is a placeholder
await this.simulateDelay(500);
if (this.session) {
this.session.target.copied = true;
try {
// Check if target already exists
const targetExists = await filesystem.exists(targetPath);
if (targetExists) {
throw new Error(`Target directory already exists: ${targetPath}`);
}
// Create target directory
await filesystem.makeDirectory(targetPath);
// Copy all files recursively
await this.copyDirectoryRecursive(sourcePath, targetPath);
this.session.target.copied = true;
this.addLogEntry({
level: 'success',
message: 'Project copied successfully'
});
this.updateProgress({ current: 1 });
} catch (error) {
this.addLogEntry({
level: 'error',
message: `Failed to copy project: ${error instanceof Error ? error.message : 'Unknown error'}`
});
throw error;
}
}
/**
* Recursively copies a directory and its contents
*/
private async copyDirectoryRecursive(sourcePath: string, targetPath: string): Promise<void> {
const entries = await filesystem.listDirectory(sourcePath);
for (const entry of entries) {
const sourceItemPath = entry.fullPath;
const targetItemPath = `${targetPath}/${entry.name}`;
if (entry.isDirectory) {
// Skip node_modules and .git folders
if (entry.name === 'node_modules' || entry.name === '.git') {
continue;
}
// Create directory and recurse
await filesystem.makeDirectory(targetItemPath);
await this.copyDirectoryRecursive(sourceItemPath, targetItemPath);
} else {
// Copy file
const content = await filesystem.readFile(sourceItemPath);
await filesystem.writeFile(targetItemPath, content);
}
}
}
private async executeAutomaticPhase(): Promise<void> {
@@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher {
}
private async executeFinalizePhase(): Promise<void> {
if (!this.session) return;
this.updateProgress({ phase: 'finalizing' });
this.addLogEntry({
level: 'info',
message: 'Finalizing migration...'
});
// TODO: Update project.json with migration metadata
await this.simulateDelay(200);
try {
// Update project.json with migration metadata
const targetProjectJsonPath = `${this.session.target.path}/project.json`;
// Read existing project.json
const projectJson = await filesystem.readJson(targetProjectJsonPath) as Record<string, unknown>;
// Add React 19 markers
projectJson.runtimeVersion = 'react19';
projectJson.migratedFrom = {
version: 'react17',
date: new Date().toISOString(),
originalPath: this.session.source.path,
aiAssisted: this.session.ai?.enabled ?? false
};
// Write updated project.json back
await filesystem.writeFile(
targetProjectJsonPath,
JSON.stringify(projectJson, null, 2)
);
this.addLogEntry({
level: 'success',
message: 'Project marked as React 19'
});
} catch (error) {
this.addLogEntry({
level: 'warning',
message: `Could not update project.json metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
});
// Don't throw - this is not a critical failure
}
this.addLogEntry({
level: 'success',

View File

@@ -315,12 +315,14 @@ export async function detectRuntimeVersion(projectPath: string): Promise<Runtime
}
// ==========================================================================
// Default: Unknown - could be either version
// Default: Assume React 17 for older projects without explicit markers
// Any project without runtimeVersion, migratedFrom, or a recent editorVersion
// is most likely a legacy project from before OpenNoodl
// ==========================================================================
return {
version: 'unknown',
version: 'react17',
confidence: 'low',
indicators: ['No version indicators found - manual verification recommended']
indicators: ['No React 19 markers found - assuming legacy React 17 project']
};
}

View File

@@ -950,3 +950,128 @@
overflow: hidden overlay;
max-height: calc(100vh - 180px);
}
/* -------------------------------------------------------------------
Legacy Project Styles (React 17 Migration)
------------------------------------------------------------------- */
/* Legacy project card modifier */
.projects-item--legacy {
border: 2px solid #d49517;
box-sizing: border-box;
}
.projects-item--legacy:hover {
border-color: #fdb314;
}
/* Legacy badge in top-right corner */
.projects-item-legacy-badge {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 4px;
background-color: rgba(212, 149, 23, 0.9);
color: #000;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
z-index: 10;
}
.projects-item-legacy-badge svg {
flex-shrink: 0;
}
/* Hidden class for conditional display */
.hidden {
display: none !important;
}
/* Legacy project hover actions overlay */
.projects-item-legacy-actions {
position: absolute;
bottom: 70px;
left: 0;
right: 0;
top: 0;
background: rgba(19, 19, 19, 0.95);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 20px;
box-sizing: border-box;
}
.projects-item--legacy:hover .projects-item-legacy-actions {
display: flex;
}
/* Migrate Project button */
.projects-item-migrate-btn {
background-color: #d49517;
border: none;
color: #000;
padding: 10px 20px;
font-weight: 600;
font-size: 12px;
cursor: pointer;
width: 100%;
max-width: 180px;
border-radius: 4px;
text-align: center;
}
.projects-item-migrate-btn:hover {
background-color: #fdb314;
}
/* Open Read-Only button */
.projects-item-readonly-btn {
background-color: transparent;
border: 1px solid #666;
color: #aaa;
padding: 8px 16px;
font-weight: 500;
font-size: 11px;
cursor: pointer;
width: 100%;
max-width: 180px;
border-radius: 4px;
text-align: center;
}
.projects-item-readonly-btn:hover {
background-color: #333;
border-color: #888;
color: #fff;
}
/* Runtime detection pending indicator */
.projects-item-detecting {
opacity: 0.7;
}
.projects-item-detecting::after {
content: "";
position: absolute;
top: 8px;
right: 8px;
width: 12px;
height: 12px;
border: 2px solid #666;
border-top-color: #d49517;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -1,6 +1,6 @@
<div class="projects-main">
<!-- project item template -->
<div data-template="projects-item" class="projects-item projects-item-plate" data-click="onProjectItemClicked">
<div data-template="projects-item" class="projects-item projects-item-plate" data-class="isLegacy:projects-item--legacy" data-click="onProjectItemClicked">
<div class="projects-item-thumb" style="position:absolute; left:0px; top:0px; width:100%; bottom:70px;">
<div class="projects-item-cloud-download" style="width:100%; height:100%;">
@@ -10,6 +10,14 @@
</div>
</div>
<!-- Legacy project badge -->
<div class="projects-item-legacy-badge" data-class="!isLegacy:hidden">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
</svg>
<span>Legacy</span>
</div>
<div class="projects-item-label" style="position:absolute; bottom:36px; left:10px; right:10px;"
data-click="onRenameProjectClicked">
<span data-text="label" data-test="project-card-label"></span>
@@ -28,6 +36,16 @@
<img class="projects-remove-icon" src="../assets/images/sharp-clear-24px.svg">
</div>
<!-- Legacy project hover actions -->
<div class="projects-item-legacy-actions" data-class="!isLegacy:hidden">
<button class="projects-item-migrate-btn" data-click="onMigrateProjectClicked">
Migrate Project
</button>
<button class="projects-item-readonly-btn" data-click="onOpenReadOnlyClicked">
Open Read-Only
</button>
</div>
</div>
<!-- tutorial item template, guides etc (not lessons) -->

View File

@@ -13,6 +13,8 @@ import { templateRegistry } from '@noodl-utils/forge';
import Model from '../../../shared/model';
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
import { RuntimeVersionInfo } from '../models/migration/types';
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
import FileSystem from './filesystem';
import { tracker } from './tracker';
import { guid } from './utils';
@@ -25,6 +27,14 @@ export interface ProjectItem {
thumbURI: string;
retainedProjectDirectory: string;
}
/**
* Extended project item with runtime version info (not persisted)
*/
export interface ProjectItemWithRuntime extends ProjectItem {
runtimeInfo?: RuntimeVersionInfo;
runtimeDetectionPending?: boolean;
}
export class LocalProjectsModel extends Model {
public static instance = new LocalProjectsModel();
@@ -34,6 +44,17 @@ export class LocalProjectsModel extends Model {
name: 'recently_opened_project'
});
/**
* Cache for runtime version info - keyed by project directory path
* Not persisted, re-detected on each app session
*/
private runtimeInfoCache: Map<string, RuntimeVersionInfo> = new Map();
/**
* Set of project directories currently being detected
*/
private detectingProjects: Set<string> = new Set();
async fetch() {
// Fetch projects from local storage and verify project folders
const folders = (this.recentProjectsStore.get('recentProjects') || []) as ProjectItem[];
@@ -299,4 +320,128 @@ export class LocalProjectsModel extends Model {
setRequestGitAccount(func);
}
// =========================================================================
// Runtime Version Detection Methods
// =========================================================================
/**
* Get cached runtime info for a project, or null if not yet detected
* @param projectPath - The project directory path
*/
getRuntimeInfo(projectPath: string): RuntimeVersionInfo | null {
return this.runtimeInfoCache.get(projectPath) || null;
}
/**
* Check if runtime detection is currently in progress for a project
* @param projectPath - The project directory path
*/
isDetectingRuntime(projectPath: string): boolean {
return this.detectingProjects.has(projectPath);
}
/**
* Get projects with their runtime info (extended interface)
* Returns projects enriched with cached runtime detection status
*/
getProjectsWithRuntime(): ProjectItemWithRuntime[] {
return this.projectEntries.map((project) => ({
...project,
runtimeInfo: this.getRuntimeInfo(project.retainedProjectDirectory),
runtimeDetectionPending: this.isDetectingRuntime(project.retainedProjectDirectory)
}));
}
/**
* Detect runtime version for a single project.
* Results are cached and listeners are notified.
* @param projectPath - Path to the project directory
* @returns The detected runtime version info
*/
async detectProjectRuntime(projectPath: string): Promise<RuntimeVersionInfo> {
// Return cached result if available
const cached = this.runtimeInfoCache.get(projectPath);
if (cached) {
return cached;
}
// Skip if already detecting
if (this.detectingProjects.has(projectPath)) {
// Wait for existing detection to complete by polling
return new Promise((resolve) => {
const checkCached = () => {
const result = this.runtimeInfoCache.get(projectPath);
if (result) {
resolve(result);
} else if (this.detectingProjects.has(projectPath)) {
setTimeout(checkCached, 100);
} else {
// Detection finished but no result - return unknown
resolve({ version: 'unknown', confidence: 'low', indicators: ['Detection failed'] });
}
};
checkCached();
});
}
// Mark as detecting
this.detectingProjects.add(projectPath);
this.notifyListeners('runtimeDetectionStarted', projectPath);
try {
const runtimeInfo = await detectRuntimeVersion(projectPath);
this.runtimeInfoCache.set(projectPath, runtimeInfo);
this.notifyListeners('runtimeDetectionComplete', projectPath, runtimeInfo);
return runtimeInfo;
} catch (error) {
console.error(`Failed to detect runtime for ${projectPath}:`, error);
const fallback: RuntimeVersionInfo = {
version: 'unknown',
confidence: 'low',
indicators: ['Detection error: ' + (error instanceof Error ? error.message : 'Unknown error')]
};
this.runtimeInfoCache.set(projectPath, fallback);
this.notifyListeners('runtimeDetectionComplete', projectPath, fallback);
return fallback;
} finally {
this.detectingProjects.delete(projectPath);
}
}
/**
* Detect runtime version for all projects in the list (background)
* Useful for pre-populating the cache when the projects view loads
*/
async detectAllProjectRuntimes(): Promise<void> {
const projects = this.getProjects();
// Detect in parallel but don't wait for all to complete
// Instead, trigger detection and let events update the UI
for (const project of projects) {
// Don't await - let them run in background
this.detectProjectRuntime(project.retainedProjectDirectory).catch(() => {
// Errors are handled in detectProjectRuntime
});
}
}
/**
* Check if a project is a legacy project (React 17)
* @param projectPath - Path to the project directory
* @returns True if project is detected as React 17
*/
isLegacyProject(projectPath: string): boolean {
const info = this.getRuntimeInfo(projectPath);
return info?.version === 'react17';
}
/**
* Clear runtime cache for a specific project (e.g., after migration)
* @param projectPath - Path to the project directory
*/
clearRuntimeCache(projectPath: string): void {
this.runtimeInfoCache.delete(projectPath);
this.notifyListeners('runtimeCacheCleared', projectPath);
}
}

View File

@@ -0,0 +1,44 @@
/**
* MigrationWizard Styles
*
* Main container for the migration wizard using CoreBaseDialog.
*/
.WizardContainer {
position: relative;
width: 700px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-4);
border-radius: 4px;
overflow: hidden;
}
.CloseButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.WizardHeader {
padding: 24px 24px 16px;
padding-right: 48px; // Space for close button
}
.WizardContent {
display: flex;
flex-direction: column;
padding: 0 24px 24px;
gap: 16px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.StepContainer {
flex: 1;
min-height: 200px;
}

View File

@@ -0,0 +1,395 @@
/**
* MigrationWizard
*
* Main container component for the React 19 migration wizard.
* Manages step navigation and integrates with MigrationSessionManager.
*
* @module noodl-editor/views/migration
* @since 1.2.0
*/
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { CoreBaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { IconName } from '@noodl-core-ui/components/common/Icon';
import { MigrationSession, MigrationScan, MigrationResult } from '../../models/migration/types';
import { migrationSessionManager, getStepLabel, getStepNumber, getTotalSteps } from '../../models/migration/MigrationSession';
import { WizardProgress } from './components/WizardProgress';
import { ConfirmStep } from './steps/ConfirmStep';
import { ScanningStep } from './steps/ScanningStep';
import { ReportStep } from './steps/ReportStep';
import { CompleteStep } from './steps/CompleteStep';
import { FailedStep } from './steps/FailedStep';
import css from './MigrationWizard.module.scss';
// =============================================================================
// Types
// =============================================================================
export interface MigrationWizardProps {
/** Path to the source project */
sourcePath: string;
/** Name of the project */
projectName: string;
/** Called when migration completes successfully */
onComplete: (targetPath: string) => void;
/** Called when wizard is cancelled */
onCancel: () => void;
}
type WizardAction =
| { type: 'SET_SESSION'; session: MigrationSession }
| { type: 'SET_TARGET_PATH'; path: string }
| { type: 'START_SCAN' }
| { type: 'SCAN_COMPLETE'; scan: MigrationScan }
| { type: 'ERROR'; error: Error }
| { type: 'START_MIGRATE'; useAi: boolean }
| { type: 'MIGRATION_PROGRESS'; progress: number; currentComponent?: string }
| { type: 'COMPLETE'; result: MigrationResult }
| { type: 'RETRY' };
interface WizardState {
session: MigrationSession | null;
loading: boolean;
error: Error | null;
}
// =============================================================================
// Reducer
// =============================================================================
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case 'SET_SESSION':
return {
...state,
session: action.session
};
case 'SET_TARGET_PATH':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
target: { ...state.session.target, path: action.path }
}
};
case 'START_SCAN':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'scanning' },
loading: true
};
case 'SCAN_COMPLETE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'report',
scan: action.scan
},
loading: false
};
case 'ERROR':
if (!state.session) return state;
return {
...state,
session: { ...state.session, step: 'failed' },
loading: false,
error: action.error
};
case 'START_MIGRATE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'migrating',
ai: action.useAi ? state.session.ai : undefined
},
loading: true
};
case 'MIGRATION_PROGRESS':
if (!state.session?.progress) return state;
return {
...state,
session: {
...state.session,
progress: {
...state.session.progress,
current: action.progress,
currentComponent: action.currentComponent
}
}
};
case 'COMPLETE':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'complete',
result: action.result
},
loading: false
};
case 'RETRY':
if (!state.session) return state;
return {
...state,
session: {
...state.session,
step: 'confirm',
scan: undefined,
progress: undefined,
result: undefined
},
loading: false,
error: null
};
default:
return state;
}
}
// =============================================================================
// Component
// =============================================================================
export function MigrationWizard({
sourcePath,
projectName,
onComplete,
onCancel
}: MigrationWizardProps) {
// Initialize session on mount
const [state, dispatch] = useReducer(wizardReducer, {
session: null,
loading: false,
error: null
});
const [isInitialized, setIsInitialized] = useState(false);
// Create session on mount
useEffect(() => {
async function initSession() {
try {
// Create session in manager (stores it internally)
await migrationSessionManager.createSession(sourcePath, projectName);
// Set default target path
const defaultTargetPath = `${sourcePath}-react19`;
migrationSessionManager.setTargetPath(defaultTargetPath);
// Update session with new target path
const updatedSession = migrationSessionManager.getSession();
if (updatedSession) {
// Initialize reducer state with the session
dispatch({ type: 'SET_SESSION', session: updatedSession });
}
setIsInitialized(true);
} catch (error) {
console.error('Failed to create migration session:', error);
dispatch({ type: 'ERROR', error: error as Error });
}
}
initSession();
// Cleanup on unmount
return () => {
migrationSessionManager.cancelSession();
};
}, [sourcePath, projectName]);
// Sync local state with session manager
useEffect(() => {
if (!isInitialized) return;
const currentSession = migrationSessionManager.getSession();
if (currentSession) {
// Initialize local state from session manager
dispatch({ type: 'SET_TARGET_PATH', path: currentSession.target.path });
}
}, [isInitialized]);
// ==========================================================================
// Handlers
// ==========================================================================
const handleUpdateTargetPath = useCallback((path: string) => {
migrationSessionManager.setTargetPath(path);
dispatch({ type: 'SET_TARGET_PATH', path });
}, []);
const handleStartScan = useCallback(async () => {
dispatch({ type: 'START_SCAN' });
try {
const scan = await migrationSessionManager.startScanning();
dispatch({ type: 'SCAN_COMPLETE', scan });
} catch (error) {
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleStartMigration = useCallback(async (useAi: boolean) => {
dispatch({ type: 'START_MIGRATE', useAi });
try {
const result = await migrationSessionManager.startMigration();
dispatch({ type: 'COMPLETE', result });
} catch (error) {
dispatch({ type: 'ERROR', error: error as Error });
}
}, []);
const handleRetry = useCallback(async () => {
try {
await migrationSessionManager.resetForRetry();
dispatch({ type: 'RETRY' });
} catch (error) {
console.error('Failed to reset session:', error);
}
}, []);
const handleOpenProject = useCallback(() => {
const currentSession = migrationSessionManager.getSession();
if (currentSession?.target.path) {
onComplete(currentSession.target.path);
}
}, [onComplete]);
// ==========================================================================
// Render
// ==========================================================================
// Get current session from manager (source of truth)
const session = migrationSessionManager.getSession();
if (!session) {
return null; // Session not initialized yet
}
const currentStep = session.step;
const stepIndex = getStepNumber(currentStep);
const totalSteps = getTotalSteps(false); // No AI for now
const renderStep = () => {
switch (currentStep) {
case 'confirm':
return (
<ConfirmStep
sourcePath={sourcePath}
projectName={projectName}
targetPath={session.target.path}
onUpdateTargetPath={handleUpdateTargetPath}
onNext={handleStartScan}
onCancel={onCancel}
loading={state.loading}
/>
);
case 'scanning':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
/>
);
case 'report':
return (
<ReportStep
scan={session.scan!}
onMigrateWithoutAi={() => handleStartMigration(false)}
onMigrateWithAi={() => handleStartMigration(true)}
onCancel={onCancel}
/>
);
case 'migrating':
return (
<ScanningStep
sourcePath={sourcePath}
targetPath={session.target.path}
isMigrating
progress={session.progress}
/>
);
case 'complete':
return (
<CompleteStep
result={session.result!}
sourcePath={sourcePath}
targetPath={session.target.path}
onOpenProject={handleOpenProject}
/>
);
case 'failed':
return (
<FailedStep
error={state.error}
onRetry={handleRetry}
onCancel={onCancel}
/>
);
default:
return null;
}
};
return (
<CoreBaseDialog isVisible hasBackdrop onClose={onCancel}>
<div className={css['WizardContainer']}>
{/* Close Button */}
<div className={css['CloseButton']}>
<IconButton
icon={IconName.Close}
onClick={onCancel}
variant={IconButtonVariant.Transparent}
/>
</div>
{/* Header */}
<div className={css['WizardHeader']}>
<Title size={TitleSize.Large} variant={TitleVariant.Highlighted}>
Migrate Project to React 19
</Title>
<Text textType={TextType.Secondary}>{getStepLabel(currentStep)}</Text>
</div>
{/* Content */}
<div className={css['WizardContent']}>
<WizardProgress
currentStep={stepIndex}
totalSteps={totalSteps}
stepLabels={['Confirm', 'Scan', 'Report', 'Migrate', 'Complete']}
/>
<div className={css['StepContainer']}>
{renderStep()}
</div>
</div>
</div>
</CoreBaseDialog>
);
}
export default MigrationWizard;

View File

@@ -0,0 +1,78 @@
/**
* WizardProgress Styles
*
* Step progress indicator for migration wizard.
*/
.Root {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.Step {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
position: relative;
}
.StepCircle {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background-color: var(--theme-color-bg-2);
color: var(--theme-color-secondary-as-fg);
transition: background-color 0.2s, color 0.2s;
}
.Step.is-completed .StepCircle {
background-color: var(--theme-color-success);
color: white;
}
.Step.is-active .StepCircle {
background-color: var(--theme-color-primary);
color: white;
}
.StepLabel {
font-size: 10px;
color: var(--theme-color-secondary-as-fg);
text-align: center;
max-width: 60px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.Step.is-completed .StepLabel,
.Step.is-active .StepLabel {
color: var(--theme-color-fg-highlight);
}
.Connector {
width: 24px;
height: 2px;
background-color: var(--theme-color-bg-2);
margin-bottom: 20px;
transition: background-color 0.2s;
}
.Connector.is-completed {
background-color: var(--theme-color-success);
}
.CheckIcon {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,77 @@
/**
* WizardProgress
*
* A visual progress indicator showing the current step in the migration wizard.
*
* @module noodl-editor/views/migration/components
* @since 1.2.0
*/
import classNames from 'classnames';
import React from 'react';
import css from './WizardProgress.module.scss';
export interface WizardProgressProps {
/** Current step index (1-indexed) */
currentStep: number;
/** Total number of steps */
totalSteps: number;
/** Labels for each step */
stepLabels: string[];
}
export function WizardProgress({ currentStep, totalSteps, stepLabels }: WizardProgressProps) {
return (
<div className={css['Root']}>
<div className={css['Steps']}>
{stepLabels.map((label, index) => {
const stepNumber = index + 1;
const isActive = stepNumber === currentStep;
const isCompleted = stepNumber < currentStep;
return (
<div
key={label}
className={classNames(
css['Step'],
isActive && css['is-active'],
isCompleted && css['is-completed']
)}
>
<div className={css['StepIndicator']}>
{isCompleted ? (
<svg viewBox="0 0 16 16" className={css['CheckIcon']}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
) : (
<span>{stepNumber}</span>
)}
</div>
<span className={css['StepLabel']}>{label}</span>
{index < stepLabels.length - 1 && (
<div
className={classNames(
css['StepConnector'],
isCompleted && css['is-completed']
)}
/>
)}
</div>
);
})}
</div>
<div className={css['ProgressBar']}>
<div
className={css['ProgressFill']}
style={{ width: `${((currentStep - 1) / (totalSteps - 1)) * 100}%` }}
/>
</div>
</div>
);
}
export default WizardProgress;

View File

@@ -0,0 +1,168 @@
/**
* CompleteStep Styles
*
* Final step showing migration summary.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-success);
}
}
.Stats {
display: flex;
gap: 12px;
margin-top: 16px;
}
.StatCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
text-align: center;
}
.StatCardIcon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.StatCard.is-success .StatCardIcon {
color: var(--theme-color-success);
}
.StatCard.is-warning .StatCardIcon {
color: var(--theme-color-warning);
}
.StatCard.is-error .StatCardIcon {
color: var(--theme-color-danger);
}
.StatCardValue {
font-size: 24px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
.StatCardLabel {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.MetaInfo {
display: flex;
gap: 16px;
margin-top: 12px;
}
.MetaItem {
display: flex;
align-items: center;
gap: 6px;
color: var(--theme-color-secondary-as-fg);
svg {
width: 14px;
height: 14px;
}
}
.Paths {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathItem {
display: flex;
gap: 12px;
margin-top: 12px;
svg {
width: 16px;
height: 16px;
color: var(--theme-color-secondary-as-fg);
flex-shrink: 0;
margin-top: 2px;
}
&:first-of-type {
margin-top: 8px;
}
}
.PathContent {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
span:last-child {
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
}
.NextSteps {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.StepsList {
list-style: none;
padding: 0;
margin: 8px 0 0 0;
li {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--theme-color-bg-2);
&:last-child {
border-bottom: none;
}
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
color: var(--theme-color-secondary-as-fg);
}
}
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,304 @@
/**
* CompleteStep
*
* Step 5 of the migration wizard: Shows final summary.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationResult } from '../../../models/migration/types';
import css from './CompleteStep.module.scss';
export interface CompleteStepProps {
/** Migration result */
result: MigrationResult;
/** Path to the source project */
sourcePath: string;
/** Path to the migrated project */
targetPath: string;
/** Called when user wants to open the migrated project */
onOpenProject: () => void;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
export function CompleteStep({
result,
sourcePath,
targetPath,
onOpenProject
}: CompleteStepProps) {
const hasIssues = result.needsReview > 0;
return (
<div className={css['Root']}>
<VStack hasSpacing>
{/* Header */}
<div className={css['Header']}>
{hasIssues ? <CheckWarningIcon /> : <CheckCircleIcon />}
<Title size={TitleSize.Medium}>
{hasIssues ? 'Migration Complete (With Notes)' : 'Migration Complete!'}
</Title>
</div>
<Text textType={TextType.Secondary}>
Your project has been migrated to React 19. The original project remains untouched.
</Text>
{/* Stats */}
<div className={css['Stats']}>
<StatCard
icon={<CheckIcon />}
value={result.migrated}
label="Migrated"
variant="success"
/>
{result.needsReview > 0 && (
<StatCard
icon={<WarningIcon />}
value={result.needsReview}
label="Needs Review"
variant="warning"
/>
)}
{result.failed > 0 && (
<StatCard
icon={<ErrorIcon />}
value={result.failed}
label="Failed"
variant="error"
/>
)}
</div>
{/* Duration and Cost */}
<div className={css['MetaInfo']}>
<div className={css['MetaItem']}>
<ClockIcon />
<Text size={TextSize.Small}>Time: {formatDuration(result.duration)}</Text>
</div>
{result.totalCost > 0 && (
<div className={css['MetaItem']}>
<RobotIcon />
<Text size={TextSize.Small}>AI cost: ${result.totalCost.toFixed(2)}</Text>
</div>
)}
</div>
{/* Project Paths */}
<div className={css['Paths']}>
<Title size={TitleSize.Small}>Project Locations</Title>
<div className={css['PathItem']}>
<LockIcon />
<div className={css['PathContent']}>
<Text size={TextSize.Small} textType={TextType.Shy}>Original (untouched)</Text>
<Text size={TextSize.Small}>{sourcePath}</Text>
</div>
</div>
<div className={css['PathItem']}>
<FolderIcon />
<div className={css['PathContent']}>
<Text size={TextSize.Small} textType={TextType.Shy}>Migrated copy</Text>
<Text size={TextSize.Small}>{targetPath}</Text>
</div>
</div>
</div>
{/* What's Next */}
<div className={css['NextSteps']}>
<Title size={TitleSize.Small}>What&apos;s Next?</Title>
<ol className={css['StepsList']}>
{result.needsReview > 0 && (
<li>
<WarningIcon />
<Text size={TextSize.Small}>
Components marked with have notes in the component panel -
click to see migration details
</Text>
</li>
)}
<li>
<CheckIcon />
<Text size={TextSize.Small}>
Test your app thoroughly before deploying
</Text>
</li>
<li>
<TrashIcon />
<Text size={TextSize.Small}>
Once confirmed working, you can archive or delete the original folder
</Text>
</li>
</ol>
</div>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Open Migrated Project"
onClick={onOpenProject}
/>
</HStack>
</div>
</div>
);
}
// =============================================================================
// Sub-Components
// =============================================================================
interface StatCardProps {
icon: React.ReactNode;
value: number;
label: string;
variant: 'success' | 'warning' | 'error';
}
function StatCard({ icon, value, label, variant }: StatCardProps) {
return (
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
<div className={css['StatCardIcon']}>{icon}</div>
<div className={css['StatCardValue']}>{value}</div>
<div className={css['StatCardLabel']}>{label}</div>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function CheckCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function CheckWarningIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
);
}
function WarningIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ErrorIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
);
}
function ClockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zM8 3.5a.75.75 0 01.75.75V8h2.5a.75.75 0 110 1.5H8a.75.75 0 01-.75-.75V4.25A.75.75 0 018 3.5z"
fill="currentColor"
/>
</svg>
);
}
function RobotIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
fill="currentColor"
/>
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
);
}
function TrashIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 00-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"
fill="currentColor"
/>
</svg>
);
}
export default CompleteStep;

View File

@@ -0,0 +1,172 @@
/**
* ConfirmStep Styles
*
* First step of migration wizard - confirm source and target paths.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-primary);
}
}
.PathSection {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathHeader {
display: flex;
align-items: center;
gap: 8px;
}
.LockIcon,
.FolderIcon {
color: var(--theme-color-secondary-as-fg);
}
.PathFields {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.PathField {
display: flex;
flex-direction: column;
gap: 8px;
}
.PathLabel {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-color-secondary-as-fg);
svg {
width: 14px;
height: 14px;
}
}
.PathDisplay {
display: flex;
flex-direction: column;
gap: 4px;
}
.PathText {
font-family: monospace;
font-size: 12px;
word-break: break-all;
color: var(--theme-color-fg-highlight);
}
.ProjectName {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.PathValue {
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: var(--theme-color-fg-highlight);
word-break: break-all;
}
.PathInput {
input {
font-family: monospace;
font-size: 12px;
}
}
.PathError {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.Arrow {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
color: var(--theme-color-secondary-as-fg);
}
.InfoBox {
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.StepsList {
margin: 8px 0 0 0;
padding-left: 20px;
color: var(--theme-color-fg-default);
li {
margin-bottom: 4px;
}
}
.WarningBox {
display: flex;
gap: 12px;
padding: 12px 16px;
background-color: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
margin-top: 16px;
svg {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--theme-color-warning);
}
}
.WarningContent {
display: flex;
flex-direction: column;
gap: 4px;
}
.WarningTitle {
font-weight: 500;
color: var(--theme-color-warning);
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,203 @@
/**
* ConfirmStep
*
* Step 1 of the migration wizard: Confirm source and target paths.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React, { useState, useEffect, useCallback, ChangeEvent } from 'react';
import { FeedbackType } from '@noodl-constants/FeedbackType';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { filesystem } from '@noodl/platform';
import css from './ConfirmStep.module.scss';
export interface ConfirmStepProps {
/** Path to the source project */
sourcePath: string;
/** Name of the project */
projectName: string;
/** Current target path */
targetPath: string;
/** Called when target path changes */
onUpdateTargetPath: (path: string) => void;
/** Called when user proceeds to next step */
onNext: () => void;
/** Called when user cancels the wizard */
onCancel: () => void;
/** Whether the wizard is loading */
loading?: boolean;
}
export function ConfirmStep({
sourcePath,
projectName,
targetPath,
onUpdateTargetPath,
onNext,
onCancel,
loading = false
}: ConfirmStepProps) {
const [targetExists, setTargetExists] = useState(false);
const [checkingPath, setCheckingPath] = useState(false);
// Check if target path exists
const checkTargetPath = useCallback(async (path: string) => {
if (!path) {
setTargetExists(false);
return;
}
setCheckingPath(true);
try {
const exists = await filesystem.exists(path);
setTargetExists(exists);
} catch {
setTargetExists(false);
} finally {
setCheckingPath(false);
}
}, []);
useEffect(() => {
checkTargetPath(targetPath);
}, [targetPath, checkTargetPath]);
const handleTargetChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onUpdateTargetPath(e.target.value);
},
[onUpdateTargetPath]
);
const handleUseUniqueName = useCallback(() => {
const timestamp = Date.now();
const uniquePath = `${sourcePath}-react19-${timestamp}`;
onUpdateTargetPath(uniquePath);
}, [sourcePath, onUpdateTargetPath]);
const canProceed = targetPath && !targetExists && !loading && !checkingPath;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<Box hasBottomSpacing>
<Text>
We&apos;ll create a safe copy of your project before making any changes.
Your original project will remain untouched.
</Text>
</Box>
{/* Source Project (Read-only) */}
<div className={css['PathSection']}>
<div className={css['PathHeader']}>
<svg
viewBox="0 0 16 16"
className={css['LockIcon']}
width={16}
height={16}
>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
<Title size={TitleSize.Small}>Original Project (will not be modified)</Title>
</div>
<div className={css['PathDisplay']}>
<Text className={css['PathText']}>{sourcePath}</Text>
<Text className={css['ProjectName']}>{projectName}</Text>
</div>
</div>
{/* Arrow */}
<div className={css['Arrow']}>
<svg viewBox="0 0 16 16" width={20} height={20}>
<path
d="M8 2a.75.75 0 01.75.75v8.69l2.22-2.22a.75.75 0 111.06 1.06l-3.5 3.5a.75.75 0 01-1.06 0l-3.5-3.5a.75.75 0 111.06-1.06l2.22 2.22V2.75A.75.75 0 018 2z"
fill="currentColor"
/>
</svg>
<Text size={TextSize.Small}>Creates copy</Text>
</div>
{/* Target Path (Editable) */}
<div className={css['PathSection']}>
<div className={css['PathHeader']}>
<svg
viewBox="0 0 16 16"
className={css['FolderIcon']}
width={16}
height={16}
>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
<Title size={TitleSize.Small}>Migrated Copy Location</Title>
</div>
<TextInput
value={targetPath}
onChange={handleTargetChange}
UNSAFE_className={css['PathInput']}
/>
{targetExists && (
<div className={css['PathError']}>
<Text textType={FeedbackType.Danger}>
A folder already exists at this location.
</Text>
<PrimaryButton
label="Use Different Name"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleUseUniqueName}
/>
</div>
)}
</div>
{/* What happens next */}
<Box hasTopSpacing>
<div className={css['InfoBox']}>
<Title size={TitleSize.Small}>What happens next:</Title>
<ol className={css['StepsList']}>
<li>Your project will be copied to the new location</li>
<li>We&apos;ll scan for compatibility issues</li>
<li>You&apos;ll see a report of what needs to change</li>
<li>Automatic fixes will be applied</li>
</ol>
</div>
</Box>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
isDisabled={loading}
/>
<PrimaryButton
label={loading ? 'Starting...' : 'Start Migration'}
onClick={onNext}
isDisabled={!canProceed}
/>
</HStack>
</div>
</div>
);
}
export default ConfirmStep;

View File

@@ -0,0 +1,128 @@
/**
* FailedStep Styles
*
* Error state when migration fails.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.ErrorCircleIcon {
color: var(--theme-color-danger);
}
.DescriptionText {
color: var(--theme-color-secondary-as-fg);
}
.ErrorBox {
margin-top: 16px;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
overflow: hidden;
}
.ErrorHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: rgba(239, 68, 68, 0.15);
svg {
color: var(--theme-color-danger);
}
}
.ErrorText {
color: var(--theme-color-danger);
font-weight: 500;
}
.ErrorMessage {
padding: 12px 16px;
font-family: monospace;
font-size: 12px;
color: var(--theme-color-fg-highlight);
word-break: break-all;
}
.Suggestions {
margin-top: 16px;
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.SuggestionList {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
li {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--theme-color-bg-2);
&:last-child {
border-bottom: none;
}
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
color: var(--theme-color-secondary-as-fg);
}
}
}
.Link {
color: var(--theme-color-primary);
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
.SafetyNotice {
display: flex;
gap: 12px;
margin-top: 16px;
padding: 12px 16px;
background-color: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--theme-color-success);
}
}
.SafetyText {
color: var(--theme-color-secondary-as-fg);
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,225 @@
/**
* FailedStep
*
* Step shown when migration fails. Allows user to retry or cancel.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import css from './FailedStep.module.scss';
export interface FailedStepProps {
/** The error that caused the failure */
error: Error | null;
/** Called when user wants to retry */
onRetry: () => void;
/** Called when user wants to cancel */
onCancel: () => void;
}
export function FailedStep({
error,
onRetry,
onCancel
}: FailedStepProps) {
const errorMessage = error?.message || 'An unknown error occurred during migration.';
const isNetworkError = errorMessage.toLowerCase().includes('network') ||
errorMessage.toLowerCase().includes('timeout');
const isPermissionError = errorMessage.toLowerCase().includes('permission') ||
errorMessage.toLowerCase().includes('access');
return (
<div className={css['Root']}>
<VStack hasSpacing>
{/* Header */}
<div className={css['Header']}>
<ErrorCircleIcon />
<Title size={TitleSize.Medium}>
Migration Failed
</Title>
</div>
<Text className={css['DescriptionText']}>
Something went wrong during the migration process. Your original project is safe and unchanged.
</Text>
{/* Error Details */}
<div className={css['ErrorBox']}>
<div className={css['ErrorHeader']}>
<ErrorIcon />
<Text className={css['ErrorText']}>Error Details</Text>
</div>
<div className={css['ErrorMessage']}>
<Text size={TextSize.Small}>{errorMessage}</Text>
</div>
</div>
{/* Suggestions */}
<div className={css['Suggestions']}>
<Title size={TitleSize.Small}>What you can try:</Title>
<ul className={css['SuggestionList']}>
{isNetworkError && (
<li>
<WifiIcon />
<Text size={TextSize.Small}>Check your internet connection and try again</Text>
</li>
)}
{isPermissionError && (
<li>
<LockIcon />
<Text size={TextSize.Small}>Make sure you have write access to the target directory</Text>
</li>
)}
<li>
<RefreshIcon />
<Text size={TextSize.Small}>Click &quot;Try Again&quot; to restart the migration</Text>
</li>
<li>
<FolderIcon />
<Text size={TextSize.Small}>Try choosing a different target directory</Text>
</li>
<li>
<HelpIcon />
<Text size={TextSize.Small}>
If the problem persists, check the{' '}
<a
href="https://github.com/The-Low-Code-Foundation/OpenNoodl/issues"
target="_blank"
rel="noopener noreferrer"
className={css['Link']}
>
GitHub Issues
</a>
</Text>
</li>
</ul>
</div>
{/* Safety Notice */}
<div className={css['SafetyNotice']}>
<ShieldIcon />
<Text size={TextSize.Small} className={css['SafetyText']}>
Your original project remains untouched. Any partial migration files have been cleaned up.
</Text>
</div>
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
/>
<PrimaryButton
label="Try Again"
onClick={onRetry}
/>
</HStack>
</div>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function ErrorCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={32} height={32} className={css['ErrorCircleIcon']}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm0-11.5a.75.75 0 01.75.75v3.5a.75.75 0 11-1.5 0v-3.5A.75.75 0 018 4.5zm0 8a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ErrorIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
);
}
function WifiIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 12a1.5 1.5 0 100 3 1.5 1.5 0 000-3zM1.332 5.084a.75.75 0 10.97 1.142 9.5 9.5 0 0113.396 0 .75.75 0 00.97-1.142 11 11 0 00-15.336 0zm2.91 2.908a.75.75 0 10.97 1.142 5.5 5.5 0 017.576 0 .75.75 0 00.97-1.142 7 7 0 00-9.516 0z"
fill="currentColor"
/>
</svg>
);
}
function LockIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M4 6V4a4 4 0 118 0v2h1a1 1 0 011 1v7a1 1 0 01-1 1H3a1 1 0 01-1-1V7a1 1 0 011-1h1zm2 0h4V4a2 2 0 10-4 0v2zm3 4a1 1 0 10-2 0v2a1 1 0 102 0v-2z"
fill="currentColor"
/>
</svg>
);
}
function RefreshIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 3a5 5 0 00-4.546 2.914.5.5 0 01-.908-.414A6 6 0 0113.944 5H12.5a.5.5 0 010-1h3a.5.5 0 01.5.5v3a.5.5 0 11-1 0V6.057A5.956 5.956 0 018 3zM0 8a.5.5 0 01.5-.5h1a.5.5 0 010 1H.5A.5.5 0 010 8zm1.5 2.5a.5.5 0 01.5.5v1.443A5.956 5.956 0 008 13a5 5 0 004.546-2.914.5.5 0 01.908.414A6 6 0 012.056 11H3.5a.5.5 0 010 1h-3a.5.5 0 01-.5-.5v-3a.5.5 0 01.5-.5z"
fill="currentColor"
/>
</svg>
);
}
function FolderIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M1.75 2A1.75 1.75 0 000 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0016 12.25v-6.5A1.75 1.75 0 0014.25 4H7.5a.25.25 0 01-.2-.1l-.9-1.2a1.75 1.75 0 00-1.4-.7h-3.25z"
fill="currentColor"
/>
</svg>
);
}
function HelpIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 0a8 8 0 108 8A8 8 0 008 0zm0 14.5A6.5 6.5 0 1114.5 8 6.5 6.5 0 018 14.5zm0-10.25a2.25 2.25 0 00-2.25 2.25.75.75 0 001.5 0 .75.75 0 111.5 0c0 .52-.3.866-.658 1.075-.368.216-.842.425-.842 1.175a.75.75 0 001.5 0c0-.15.099-.282.282-.394.187-.114.486-.291.727-.524A2.25 2.25 0 008 4.25zM8 13a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
);
}
function ShieldIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M7.467.133a1.75 1.75 0 011.066 0l5.25 1.68A1.75 1.75 0 0115 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.7 1.7 0 01-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 011.217-1.667l5.25-1.68zm.61 1.429a.25.25 0 00-.153 0l-5.25 1.68a.25.25 0 00-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.2.2 0 00.154 0c2.245-.956 3.582-2.103 4.366-3.298C13.225 9.666 13.5 8.358 13.5 7V3.48a.25.25 0 00-.174-.238l-5.25-1.68zM11.28 6.28a.75.75 0 00-1.06-1.06L7.25 8.19 5.78 6.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z"
fill="currentColor"
/>
</svg>
);
}
export default FailedStep;

View File

@@ -0,0 +1,215 @@
/**
* ReportStep Styles
*
* Scan results report with categories and AI options.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
svg {
color: var(--theme-color-primary);
}
}
.StatsRow {
display: flex;
gap: 12px;
margin-top: 16px;
}
.StatCard {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
text-align: center;
}
.StatCardIcon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-secondary-as-fg);
}
.StatCard.is-automatic .StatCardIcon {
color: var(--theme-color-success);
}
.StatCard.is-simpleFixes .StatCardIcon {
color: var(--theme-color-warning);
}
.StatCard.is-needsReview .StatCardIcon {
color: var(--theme-color-danger);
}
.StatCardValue {
font-size: 24px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
}
.StatCardLabel {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.Categories {
flex: 1;
overflow-y: auto;
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.CategorySection {
background-color: var(--theme-color-bg-3);
border-radius: 8px;
overflow: hidden;
}
.CategoryHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: var(--theme-color-bg-2);
cursor: pointer;
&:hover {
background-color: var(--theme-color-bg-1);
}
}
.CategoryIcon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.CategorySection.is-automatic .CategoryIcon {
color: var(--theme-color-success);
}
.CategorySection.is-simpleFixes .CategoryIcon {
color: var(--theme-color-warning);
}
.CategorySection.is-needsReview .CategoryIcon {
color: var(--theme-color-danger);
}
.CategoryTitle {
flex: 1;
font-weight: 500;
}
.CategoryCount {
background-color: var(--theme-color-bg-1);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.ExpandIcon {
width: 16px;
height: 16px;
color: var(--theme-color-secondary-as-fg);
transition: transform 0.2s ease;
}
.CategorySection.is-expanded .ExpandIcon {
transform: rotate(180deg);
}
.ComponentList {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px;
max-height: 200px;
overflow-y: auto;
}
.ComponentItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
&:hover {
background-color: var(--theme-color-bg-1);
}
}
.ComponentName {
font-size: 13px;
}
.ComponentIssueCount {
font-size: 11px;
color: var(--theme-color-secondary-as-fg);
}
.AiPromptSection {
margin-top: 16px;
padding: 16px;
background-color: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 8px;
}
.AiPromptHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
svg {
width: 20px;
height: 20px;
color: #8b5cf6; // AI purple
}
}
.AiPromptTitle {
font-weight: 500;
color: #8b5cf6; // AI purple
}
.AiPromptSection.is-disabled {
opacity: 0.6;
pointer-events: none;
}
.Actions {
margin-top: auto;
padding-top: 24px;
display: flex;
justify-content: flex-end;
}

View File

@@ -0,0 +1,338 @@
/**
* ReportStep
*
* Step 3 of the migration wizard: Shows scan results by category.
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React, { useState } from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationScan, ComponentMigrationInfo } from '../../../models/migration/types';
import css from './ReportStep.module.scss';
export interface ReportStepProps {
/** Scan results */
scan: MigrationScan;
/** Called when user chooses to migrate without AI */
onMigrateWithoutAi: () => void;
/** Called when user chooses to migrate with AI */
onMigrateWithAi: () => void;
/** Called when user cancels */
onCancel: () => void;
}
export function ReportStep({
scan,
onMigrateWithoutAi,
onMigrateWithAi,
onCancel
}: ReportStepProps) {
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const { automatic, simpleFixes, needsReview } = scan.categories;
const totalIssues = simpleFixes.length + needsReview.length;
const allAutomatic = totalIssues === 0;
// Calculate estimated cost (placeholder - AI not yet implemented)
const estimatedCost = needsReview.length * 0.05 + simpleFixes.length * 0.02;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<Text>
Analyzed {scan.totalComponents} components and {scan.totalNodes} nodes.
{scan.customJsFiles > 0 && ` Found ${scan.customJsFiles} custom JavaScript files.`}
</Text>
{/* Summary Stats */}
<div className={css['SummaryStats']}>
<StatCard
icon={<CheckCircleIcon />}
value={automatic.length}
label="Automatic"
variant="success"
/>
<StatCard
icon={<ZapIcon />}
value={simpleFixes.length}
label="Simple Fixes"
variant="info"
/>
<StatCard
icon={<ToolIcon />}
value={needsReview.length}
label="Needs Review"
variant="warning"
/>
</div>
{/* Category Sections */}
<div className={css['Categories']}>
{/* Automatic */}
<CategorySection
title="Automatic"
description="These components will migrate without any changes"
icon={<CheckCircleIcon />}
items={automatic}
variant="success"
expanded={expandedCategory === 'automatic'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'automatic' ? null : 'automatic')
}
/>
{/* Simple Fixes */}
{simpleFixes.length > 0 && (
<CategorySection
title="Simple Fixes"
description="Minor syntax updates needed"
icon={<ZapIcon />}
items={simpleFixes}
variant="info"
expanded={expandedCategory === 'simpleFixes'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'simpleFixes' ? null : 'simpleFixes')
}
showIssueDetails
/>
)}
{/* Needs Review */}
{needsReview.length > 0 && (
<CategorySection
title="Needs Review"
description="May require manual adjustment after migration"
icon={<ToolIcon />}
items={needsReview}
variant="warning"
expanded={expandedCategory === 'needsReview'}
onToggle={() =>
setExpandedCategory(expandedCategory === 'needsReview' ? null : 'needsReview')
}
showIssueDetails
/>
)}
</div>
{/* AI Prompt (if there are issues) */}
{!allAutomatic && (
<div className={css['AiPrompt']}>
<div className={css['AiPromptIcon']}>
<RobotIcon />
</div>
<div className={css['AiPromptContent']}>
<Title size={TitleSize.Small}>AI-Assisted Migration (Coming Soon)</Title>
<Text textType={TextType.Secondary} size={TextSize.Small}>
Claude can help automatically fix the {totalIssues} components that need
code changes. Estimated cost: ~${estimatedCost.toFixed(2)}
</Text>
</div>
</div>
)}
</VStack>
{/* Actions */}
<div className={css['Actions']}>
<HStack hasSpacing>
<PrimaryButton
label="Cancel"
variant={PrimaryButtonVariant.Muted}
onClick={onCancel}
/>
<PrimaryButton
label={allAutomatic ? 'Migrate Project' : 'Migrate (Auto Only)'}
onClick={onMigrateWithoutAi}
/>
{!allAutomatic && (
<PrimaryButton
label="Migrate with AI"
onClick={onMigrateWithAi}
isDisabled // AI not yet implemented
/>
)}
</HStack>
</div>
</div>
);
}
// =============================================================================
// Sub-Components
// =============================================================================
interface StatCardProps {
icon: React.ReactNode;
value: number;
label: string;
variant: 'success' | 'info' | 'warning';
}
function StatCard({ icon, value, label, variant }: StatCardProps) {
return (
<div className={`${css['StatCard']} ${css[`is-${variant}`]}`}>
<div className={css['StatCardIcon']}>{icon}</div>
<div className={css['StatCardValue']}>{value}</div>
<div className={css['StatCardLabel']}>{label}</div>
</div>
);
}
interface CategorySectionProps {
title: string;
description: string;
icon: React.ReactNode;
items: ComponentMigrationInfo[];
variant: 'success' | 'info' | 'warning';
expanded: boolean;
onToggle: () => void;
showIssueDetails?: boolean;
}
function CategorySection({
title,
description,
icon,
items,
variant,
expanded,
onToggle,
showIssueDetails = false
}: CategorySectionProps) {
if (items.length === 0) return null;
return (
<div className={`${css['CategorySection']} ${css[`is-${variant}`]}`}>
<button className={css['CategoryHeader']} onClick={onToggle}>
<div className={css['CategoryHeaderLeft']}>
{icon}
<div>
<Text textType={TextType.Proud}>
{title} ({items.length})
</Text>
<Text textType={TextType.Secondary} size={TextSize.Small}>
{description}
</Text>
</div>
</div>
<ChevronIcon direction={expanded ? 'up' : 'down'} />
</button>
<Collapsible isCollapsed={!expanded}>
<div className={css['CategoryItems']}>
{items.map((item) => (
<div key={item.id} className={css['CategoryItem']}>
<ComponentIcon />
<div className={css['CategoryItemInfo']}>
<Text size={TextSize.Small}>{item.name}</Text>
{showIssueDetails && item.issues.length > 0 && (
<ul className={css['IssuesList']}>
{item.issues.map((issue) => (
<li key={issue.id}>
<code>{issue.type}</code>
<Text size={TextSize.Small} isSpan textType={TextType.Secondary}>
{' '}{issue.description}
</Text>
</li>
))}
</ul>
)}
</div>
{item.estimatedCost !== undefined && (
<Text size={TextSize.Small} textType={TextType.Shy}>
~${item.estimatedCost.toFixed(2)}
</Text>
)}
</div>
))}
</div>
</Collapsible>
</div>
);
}
// =============================================================================
// Icons
// =============================================================================
function CheckCircleIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.78-9.72a.75.75 0 00-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.06 0l4.5-4.5z"
fill="currentColor"
/>
</svg>
);
}
function ZapIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M9.504.43a.75.75 0 01.397.696L9.223 5h4.027a.75.75 0 01.577 1.22l-5.25 6.25a.75.75 0 01-1.327-.55l.678-4.42H3.902a.75.75 0 01-.577-1.22l5.25-6.25a.75.75 0 01.93-.18z"
fill="currentColor"
/>
</svg>
);
}
function ToolIcon() {
return (
<svg viewBox="0 0 16 16" width={16} height={16}>
<path
d="M5.433 2.304A4.492 4.492 0 003.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64l-.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 003.5 6a5.99 5.99 0 012.433-4.864.75.75 0 011.134.64v3.046l.5.865.5-.865V1.776a.75.75 0 011.134-.64A5.99 5.99 0 0111.5 6a4.495 4.495 0 01-.922 3.802c-.518.328-.929.923-.902 1.64l.086 2.27a.75.75 0 01-.75.72h-1.3a.75.75 0 01-.75-.72l-.086-2.27c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 007.5 6c0-.54-.185-1.061-.433-1.548"
fill="currentColor"
/>
</svg>
);
}
function RobotIcon() {
return (
<svg viewBox="0 0 16 16" width={24} height={24}>
<path
d="M8 0a1 1 0 011 1v1.5h2A2.5 2.5 0 0113.5 5v6a2.5 2.5 0 01-2.5 2.5h-6A2.5 2.5 0 012.5 11V5A2.5 2.5 0 015 2.5h2V1a1 1 0 011-1zM5 4a1 1 0 00-1 1v6a1 1 0 001 1h6a1 1 0 001-1V5a1 1 0 00-1-1H5zm1.5 2a1 1 0 110 2 1 1 0 010-2zm3 0a1 1 0 110 2 1 1 0 010-2zM6 8.5a.5.5 0 000 1h4a.5.5 0 000-1H6z"
fill="currentColor"
/>
</svg>
);
}
function ComponentIcon() {
return (
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8.186 1.113a.5.5 0 00-.372 0L1.814 3.5l-.372.149v8.702l.372.149 6 2.387a.5.5 0 00.372 0l6-2.387.372-.149V3.649l-.372-.149-6-2.387zM8 2.123l4.586 1.828L8 5.778 3.414 3.95 8 2.123zm-5.5 2.89l5 1.992v6.372l-5-1.992V5.013zm6.5 8.364V7.005l5-1.992v6.372l-5 1.992z"
fill="currentColor"
/>
</svg>
);
}
function ChevronIcon({ direction }: { direction: 'up' | 'down' }) {
return (
<svg
viewBox="0 0 16 16"
width={16}
height={16}
style={{ transform: direction === 'up' ? 'rotate(180deg)' : undefined }}
>
<path
d="M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
);
}
export default ReportStep;

View File

@@ -0,0 +1,154 @@
/**
* ScanningStep Styles
*
* Scanning/migrating progress display.
*/
.Root {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
}
.Header {
display: flex;
align-items: center;
gap: 12px;
svg {
color: var(--theme-color-primary);
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ProgressSection {
padding: 16px;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
}
.ProgressHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.ProgressBar {
height: 8px;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
overflow: hidden;
}
.ProgressFill {
height: 100%;
background-color: var(--theme-color-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.ActivityLog {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-3);
border-radius: 8px;
overflow: hidden;
}
.ActivityHeader {
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-bg-2);
display: flex;
align-items: center;
gap: 8px;
}
.ActivityList {
flex: 1;
overflow-y: auto;
padding: 8px;
max-height: 200px;
}
.ActivityItem {
display: flex;
gap: 8px;
padding: 6px 8px;
font-size: 12px;
border-radius: 4px;
animation: fadeIn 0.2s ease;
&.is-info {
color: var(--theme-color-secondary-as-fg);
}
&.is-success {
color: var(--theme-color-success);
}
&.is-warning {
color: var(--theme-color-warning);
}
&.is-error {
color: var(--theme-color-danger);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ActivityTime {
color: var(--theme-color-secondary-as-fg);
font-family: monospace;
flex-shrink: 0;
}
.ActivityMessage {
flex: 1;
}
.EmptyActivity {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
color: var(--theme-color-secondary-as-fg);
}
.InfoBox {
display: flex;
gap: 12px;
padding: 12px 16px;
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--theme-color-primary);
}
}

View File

@@ -0,0 +1,186 @@
/**
* ScanningStep
*
* Step 2 of the migration wizard: Shows progress while copying and scanning.
* Also used during the migration phase (step 4).
*
* @module noodl-editor/views/migration/steps
* @since 1.2.0
*/
import React from 'react';
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { MigrationProgress } from '../../../models/migration/types';
import css from './ScanningStep.module.scss';
export interface ScanningStepProps {
/** Path to the source project */
sourcePath: string;
/** Path to the target project */
targetPath: string;
/** Whether we're in migration phase (vs scanning phase) */
isMigrating?: boolean;
/** Progress information (for migration phase) */
progress?: MigrationProgress;
}
export function ScanningStep({
sourcePath: _sourcePath,
targetPath: _targetPath,
isMigrating = false,
progress
}: ScanningStepProps) {
// sourcePath and targetPath are available for future use (e.g., displaying paths)
void _sourcePath;
void _targetPath;
const title = isMigrating ? 'Migrating Project...' : 'Analyzing Project...';
const subtitle = isMigrating
? `Phase: ${getPhaseLabel(progress?.phase)}`
: 'Creating a safe copy before making any changes';
const progressPercent = progress
? Math.round((progress.current / progress.total) * 100)
: 0;
return (
<div className={css['Root']}>
<VStack hasSpacing>
<div className={css['Header']}>
<ActivityIndicator />
<Title size={TitleSize.Medium}>{title}</Title>
</div>
<Text textType={TextType.Secondary}>{subtitle}</Text>
{/* Progress Bar */}
<div className={css['ProgressSection']}>
<div className={css['ProgressBar']}>
<div
className={css['ProgressFill']}
style={{ width: `${progressPercent}%` }}
/>
</div>
{progress && (
<Text size={TextSize.Small} textType={TextType.Shy}>
{progress.current} / {progress.total} components
</Text>
)}
</div>
{/* Current Item */}
{progress?.currentComponent && (
<div className={css['CurrentItem']}>
<svg viewBox="0 0 16 16" width={14} height={14}>
<path
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
fill="currentColor"
/>
</svg>
<Text size={TextSize.Small}>{progress.currentComponent}</Text>
</div>
)}
{/* Log Entries */}
{progress?.log && progress.log.length > 0 && (
<div className={css['LogSection']}>
<Title size={TitleSize.Small}>Activity Log</Title>
<div className={css['LogEntries']}>
{progress.log.slice(-5).map((entry, index) => (
<div
key={index}
className={`${css['LogEntry']} ${css[`is-${entry.level}`]}`}
>
<LogIcon level={entry.level} />
<div className={css['LogContent']}>
{entry.component && (
<Text
size={TextSize.Small}
textType={TextType.Proud}
isSpan
>
{entry.component}:{' '}
</Text>
)}
<Text size={TextSize.Small} isSpan>
{entry.message}
</Text>
</div>
</div>
))}
</div>
</div>
)}
{/* Info Box */}
<Box hasTopSpacing>
<div className={css['InfoBox']}>
<Text textType={TextType.Shy} size={TextSize.Small}>
{isMigrating
? 'Please wait while we migrate your project. This may take a few minutes for larger projects.'
: 'Scanning components for React 17 patterns that need updating...'}
</Text>
</div>
</Box>
</VStack>
</div>
);
}
// Helper Components
function LogIcon({ level }: { level: string }) {
const icons: Record<string, JSX.Element> = {
info: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8 16A8 8 0 108 0a8 8 0 000 16zm.93-9.412l-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287h.001zm-.043-3.33a.86.86 0 110 1.72.86.86 0 010-1.72z"
fill="currentColor"
/>
</svg>
),
success: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
fill="currentColor"
/>
</svg>
),
warning: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M8.863 1.035c-.39-.678-1.336-.678-1.726 0L.187 12.78c-.403.7.096 1.57.863 1.57h13.9c.767 0 1.266-.87.863-1.57L8.863 1.035zM8 5a.75.75 0 01.75.75v2.5a.75.75 0 11-1.5 0v-2.5A.75.75 0 018 5zm0 7a1 1 0 100-2 1 1 0 000 2z"
fill="currentColor"
/>
</svg>
),
error: (
<svg viewBox="0 0 16 16" width={12} height={12}>
<path
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
)
};
return icons[level] || icons.info;
}
function getPhaseLabel(phase?: string): string {
const labels: Record<string, string> = {
copying: 'Copying files',
automatic: 'Applying automatic fixes',
'ai-assisted': 'AI-assisted migration',
finalizing: 'Finalizing'
};
return labels[phase || ''] || 'Starting';
}
export default ScanningStep;

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { filesystem, platform } from '@noodl/platform';
import { DialogLayerModel } from '@noodl-models/DialogLayerModel';
import { LessonsProjectsModel } from '@noodl-models/LessonsProjectModel';
import { CloudServiceMetadata } from '@noodl-models/projectmodel';
import { setCloudServices } from '@noodl-models/projectmodel.editor';
import { LocalProjectsModel, ProjectItem } from '@noodl-utils/LocalProjectsModel';
import { LocalProjectsModel, ProjectItem, ProjectItemWithRuntime } from '@noodl-utils/LocalProjectsModel';
import { MigrationWizard } from './migration/MigrationWizard';
import View from '../../../shared/view';
import LessonTemplatesModel from '../models/lessontemplatesmodel';
@@ -29,6 +33,10 @@ type ProjectItemScope = {
project: ProjectItem;
label: string;
latestAccessedTimeAgo: string;
/** Whether the project uses legacy React 17 runtime */
isLegacy: boolean;
/** Whether runtime detection is in progress */
isDetecting: boolean;
};
export class ProjectsView extends View {
@@ -112,6 +120,9 @@ export class ProjectsView extends View {
this.projectsModel.on('myProjectsChanged', () => this.renderProjectItemsPane(), this);
// Re-render when runtime detection completes to update legacy indicators
this.projectsModel.on('runtimeDetectionComplete', () => this.renderProjectItemsPane(), this);
this.switchPane('projects');
// this.$('#top-bar').css({ height: this.topBarHeight + 'px' });
@@ -274,27 +285,34 @@ export class ProjectsView extends View {
}) {
options = options || {};
const items = options.items;
const projectItems = options.items || [];
const projectItemsSelector = options.appendProjectItemsTo || '.projects-items';
const template = options.template || 'projects-item';
this.$(projectItemsSelector).html('');
for (const i in items) {
const label = items[i].name;
for (const item of projectItems) {
const label = item.name;
if (options.filter && label.toLowerCase().indexOf(options.filter) === -1) continue;
const latestAccessed = items[i].latestAccessed || Date.now();
const latestAccessed = item.latestAccessed || Date.now();
// Check if this is a legacy React 17 project
const projectPath = item.retainedProjectDirectory;
const isLegacy = projectPath ? this.projectsModel.isLegacyProject(projectPath) : false;
const isDetecting = projectPath ? this.projectsModel.isDetectingRuntime(projectPath) : false;
const scope: ProjectItemScope = {
project: items[i],
project: item,
label: label,
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago'
latestAccessedTimeAgo: timeSince(latestAccessed) + ' ago',
isLegacy,
isDetecting
};
const el = this.bindView(this.cloneTemplate(template), scope);
if (items[i].thumbURI) {
if (item.thumbURI) {
// Set the thumbnail image if there is one
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + items[i].thumbURI + ')');
View.$(el, '.projects-item-thumb').css('background-image', 'url(' + item.thumbURI + ')');
} else {
// No thumbnail, show cloud download icon
View.$(el, '.projects-item-cloud-download').show();
@@ -302,6 +320,12 @@ export class ProjectsView extends View {
this.$(projectItemsSelector).append(el);
}
// Trigger background runtime detection for all projects
this.projectsModel.detectAllProjectRuntimes().then(() => {
// Re-render after detection completes (if any legacy projects found)
// The on('runtimeDetected') listener handles this
});
}
renderTutorialItems() {
@@ -633,6 +657,107 @@ export class ProjectsView extends View {
});
}
/**
* Called when user clicks "Migrate Project" on a legacy project card.
* Opens the migration wizard dialog.
*/
onMigrateProjectClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
evt.stopPropagation();
const projectPath = scope.project.retainedProjectDirectory;
if (!projectPath) {
ToastLayer.showError('Cannot migrate project: path not found');
return;
}
// Show the migration wizard as a dialog
DialogLayerModel.instance.showDialog(
(close) =>
React.createElement(MigrationWizard, {
sourcePath: projectPath,
projectName: scope.project.name,
onComplete: async (targetPath: string) => {
close();
// Clear runtime cache for the source project
this.projectsModel.clearRuntimeCache(projectPath);
// Show activity indicator
const activityId = 'opening-migrated-project';
ToastLayer.showActivity('Opening migrated project', activityId);
try {
// Open the migrated project from the target path
const project = await this.projectsModel.openProjectFromFolder(targetPath);
if (!project.name) {
project.name = scope.project.name + ' (React 19)';
}
ToastLayer.hideActivity(activityId);
ToastLayer.showSuccess('Project migrated successfully!');
// Open the migrated project
this.notifyListeners('projectLoaded', project);
} catch (error) {
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Project migrated but could not open automatically. Check your projects list.');
console.error('Failed to open migrated project:', error);
// Refresh project list anyway
this.projectsModel.fetch();
}
},
onCancel: () => {
close();
}
}),
{
onClose: () => {
// Refresh project list when dialog closes
this.projectsModel.fetch();
}
}
);
tracker.track('Migration Wizard Opened', {
projectName: scope.project.name
});
}
/**
* Called when user clicks "Open Read-Only" on a legacy project card.
* Opens the project in read-only mode without migration.
* Note: The project will open normally; legacy banner display
* will be handled by the EditorBanner component based on runtime detection.
*/
async onOpenReadOnlyClicked(scope: ProjectItemScope, _el: unknown, evt: Event) {
evt.stopPropagation();
const activityId = 'opening-project-readonly';
ToastLayer.showActivity('Opening project in read-only mode', activityId);
try {
const project = await this.projectsModel.loadProject(scope.project);
ToastLayer.hideActivity(activityId);
if (!project) {
ToastLayer.showError("Couldn't load project.");
return;
}
tracker.track('Legacy Project Opened Read-Only', {
projectName: scope.project.name
});
// Open the project - the EditorBanner will detect legacy runtime
// and display a warning banner automatically
this.notifyListeners('projectLoaded', project);
} catch (error) {
ToastLayer.hideActivity(activityId);
ToastLayer.showError('Could not open project');
console.error('Failed to open legacy project:', error);
}
}
// Import a project from a URL
importFromUrl(uri) {
// Extract and remove query from url

View File

@@ -6,6 +6,15 @@ const URL = require('url');
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
// Safe console.log wrapper to prevent EPIPE errors when stdout is broken
function safeLog(...args) {
try {
console.log(...args);
} catch (e) {
// Ignore EPIPE errors - stdout pipe may be broken
}
}
function guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
@@ -30,7 +39,7 @@ function openCloudRuntimeDevTools() {
mode: 'detach'
});
} else {
console.log('No cloud sandbox active');
safeLog('No cloud sandbox active');
}
}
@@ -62,7 +71,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
show: false
});
console.log('starting cloud runtime');
safeLog('starting cloud runtime');
hasLoadedProject = false;
@@ -103,9 +112,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
headers: args.headers
};
console.log('noodl-cf-fetch');
console.log(_options);
console.log(args.body);
safeLog('noodl-cf-fetch');
safeLog(_options);
safeLog(args.body);
const httpx = url.protocol === 'https:' ? https : http;
const req = httpx.request(_options, (res) => {
@@ -120,13 +129,13 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
body: _data,
status: res.statusCode
};
console.log('response', _response);
safeLog('response', _response);
sandbox.webContents.send('noodl-cf-fetch-response', _response);
});
});
req.on('error', (error) => {
console.log('error', error);
safeLog('error', error);
sandbox.webContents.send('noodl-cf-fetch-response', {
token,
error: error
@@ -161,9 +170,9 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
if (path.startsWith('/functions/')) {
const functionName = decodeURIComponent(path.split('/')[2]);
console.log('Calling cloud function: ' + functionName);
safeLog('Calling cloud function: ' + functionName);
if (!sandbox) {
console.log('Error: No cloud runtime active...');
safeLog('Error: No cloud runtime active...');
return;
}
@@ -184,7 +193,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
}
try {
console.log('with body ', body);
safeLog('with body ', body);
const token = guid();
_responseHandlers[token] = (args) => {
@@ -205,7 +214,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
}
} catch (e) {
console.log(e);
safeLog(e);
response.writeHead(400, headers);
response.end(JSON.stringify({ error: 'Failed to run function.' }));
@@ -218,7 +227,7 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
var server;
if (process.env.ssl) {
console.log('Using SSL');
safeLog('Using SSL');
const options = {
key: fs.readFileSync(process.env.sslKey),
@@ -244,8 +253,8 @@ function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
});
});
server.on('listening', (e) => {
console.log('noodl cloud functions server running on port', port);
server.on('listening', () => {
safeLog('noodl cloud functions server running on port', port);
process.env.NOODL_CLOUD_FUNCTIONS_PORT = port;
});
}

View File

@@ -0,0 +1,23 @@
# The Noodl Starter Template
## Overview
The Noodl Starter Template is a community project aimed at helping Noodl builders start new apps faster. The template contains a variety of different visual elements and logic flows. You can cherry pick the parts you need for your own app, or use the template as a boiler plate.
## Features
* Log in / Sign up workflows
* Reset password workflow (Sendgrid API)
* Header top bar
* Tabs
* Collapsable menu
* File uploader
* Profile button with floating menu
## Installation
* Download the repository contents to a project folder on your local machine.
* Open your Noodl editor
* Click 'Open Folder'
* Select the folder where you placed the repository contents
* Connect a Noodl Cloud Services back end (to use the native Noodl cloud data nodes included in the template)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"name":"Noodl Starter Template","components":[{"name":"/#__cloud__/SendGrid/Send Email","id":"55e43c55-c5ec-c1bb-10ea-fdd520e6dc28","graph":{"connections":[{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Do","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"run"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Text","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Text"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Html","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Html"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"To","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-To"},{"fromId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","fromProperty":"out-Success","toId":"10a94c4f-0c3e-5250-70f2-5bd02a335402","toProperty":"Success"},{"fromId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","fromProperty":"out-Failure","toId":"10a94c4f-0c3e-5250-70f2-5bd02a335402","toProperty":"Failure"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"From","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-From"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"Subject","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-Subject"},{"fromId":"3efa1bbb-61fa-71ac-931a-cb900841f03c","fromProperty":"API Key","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-APIKey"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"CC","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-CC"},{"fromId":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","fromProperty":"BCC","toId":"ab378886-2b3f-7ad0-6eff-75318b66fe21","toProperty":"in-BCC"}],"roots":[{"id":"a5af92ae-5a67-8e9a-edee-3e83a50a9810","type":"Component Inputs","x":-312,"y":-62,"parameters":{},"ports":[{"name":"Do","plug":"output","type":"*","index":0},{"name":"Text","plug":"output","type":{"name":"*"},"index":1},{"name":"Html","plug":"output","type":{"name":"*"},"index":2}