From 0b47d197760cff9e75c7507a4de1da2a8cbb9c5e Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Mon, 15 Dec 2025 11:58:55 +0100 Subject: [PATCH] Finished inital project migration workflow --- .../CANVAS-MODERNISATION-PROJECT.md | 688 +++++++++++++ dev-docs/reference/LEARNINGS.md | 299 ++++++ .../CHANGELOG.md | 0 .../CHECKLIST.md | 0 .../NOTES.md | 0 .../README.md | 0 .../CHANGELOG.md | 382 ++++++++ .../CHECKLIST.md | 33 +- .../NODES-000-existing-nodes-update.md | 911 ++++++++++++++++++ .../00-OVERVIEW.md | 111 +++ .../01-FOUNDATION.md | 369 +++++++ .../02-EDITOR-UI.md | 600 ++++++++++++ .../NODES-001-responsive-update/03-RUNTIME.md | 619 ++++++++++++ .../04-VARIANTS.md | 511 ++++++++++ .../05-VISUAL-STATES-COMBO.md | 575 +++++++++++ .../NODES-002-expression-function-updates.md | 0 .../NODES-003-video-player.md | 489 ++++++++++ .../NODES-004-rich-text-node.md | 0 .../layout/BaseDialog/BaseDialog.module.scss | 247 ++--- .../editor/src/models/DialogLayerModel.tsx | 41 + .../src/models/migration/MigrationSession.ts | 104 +- .../src/models/migration/ProjectScanner.ts | 8 +- .../src/editor/src/styles/projectsview.css | 125 +++ .../editor/src/templates/projectsview.html | 20 +- .../editor/src/utils/LocalProjectsModel.ts | 145 +++ .../migration/MigrationWizard.module.scss | 44 + .../src/views/migration/MigrationWizard.tsx | 395 ++++++++ .../components/WizardProgress.module.scss | 78 ++ .../migration/components/WizardProgress.tsx | 77 ++ .../migration/steps/CompleteStep.module.scss | 168 ++++ .../views/migration/steps/CompleteStep.tsx | 304 ++++++ .../migration/steps/ConfirmStep.module.scss | 172 ++++ .../src/views/migration/steps/ConfirmStep.tsx | 203 ++++ .../migration/steps/FailedStep.module.scss | 128 +++ .../src/views/migration/steps/FailedStep.tsx | 225 +++++ .../migration/steps/ReportStep.module.scss | 215 +++++ .../src/views/migration/steps/ReportStep.tsx | 338 +++++++ .../migration/steps/ScanningStep.module.scss | 154 +++ .../views/migration/steps/ScanningStep.tsx | 186 ++++ .../src/editor/src/views/projectsview.ts | 143 ++- .../src/main/src/cloud-function-server.js | 37 +- .../react-19-migration-example/README.md | 23 + .../material-icons/manifest.json | 1 + .../project-truncated.json | 1 + 44 files changed, 8995 insertions(+), 174 deletions(-) create mode 100644 dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md rename dev-docs/tasks/phase-2/{TASK-001-http-node.md => TASK-001-new-node-test}/CHANGELOG.md (100%) rename dev-docs/tasks/phase-2/{TASK-001-http-node.md => TASK-001-new-node-test}/CHECKLIST.md (100%) rename dev-docs/tasks/phase-2/{TASK-001-http-node.md => TASK-001-new-node-test}/NOTES.md (100%) rename dev-docs/tasks/phase-2/{TASK-001-http-node.md => TASK-001-new-node-test}/README.md (100%) create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-000-existing-nodes-update.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/00-OVERVIEW.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/01-FOUNDATION.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/02-EDITOR-UI.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/03-RUNTIME.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/04-VARIANTS.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/05-VISUAL-STATES-COMBO.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-002-expression-function-updates.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-003-video-player.md create mode 100644 dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-004-rich-text-node.md create mode 100644 packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/MigrationWizard.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/components/WizardProgress.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/CompleteStep.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ConfirmStep.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/FailedStep.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ReportStep.tsx create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.module.scss create mode 100644 packages/noodl-editor/src/editor/src/views/migration/steps/ScanningStep.tsx create mode 100644 project-examples/version 1.1.0/react-19-migration-example/README.md create mode 100644 project-examples/version 1.1.0/react-19-migration-example/noodl_modules/material-icons/manifest.json create mode 100644 project-examples/version 1.1.0/react-19-migration-example/project-truncated.json diff --git a/dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md b/dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md new file mode 100644 index 0000000..df0cab4 --- /dev/null +++ b/dev-docs/future-projects/CANVAS-MODERNISATION-PROJECT.md @@ -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; + 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; + + // Clear trace state + clearTrace(): void; + + // Visual state + readonly activeTrace: ReadonlyArray; +} + +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?" +``` diff --git a/dev-docs/reference/LEARNINGS.md b/dev-docs/reference/LEARNINGS.md index 3dbc7a8..449f93a 100644 --- a/dev-docs/reference/LEARNINGS.md +++ b/dev-docs/reference/LEARNINGS.md @@ -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'; + + +
+ {/* Your content */} +
+
+``` + +**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 diff --git a/dev-docs/tasks/phase-2/TASK-001-http-node.md/CHANGELOG.md b/dev-docs/tasks/phase-2/TASK-001-new-node-test/CHANGELOG.md similarity index 100% rename from dev-docs/tasks/phase-2/TASK-001-http-node.md/CHANGELOG.md rename to dev-docs/tasks/phase-2/TASK-001-new-node-test/CHANGELOG.md diff --git a/dev-docs/tasks/phase-2/TASK-001-http-node.md/CHECKLIST.md b/dev-docs/tasks/phase-2/TASK-001-new-node-test/CHECKLIST.md similarity index 100% rename from dev-docs/tasks/phase-2/TASK-001-http-node.md/CHECKLIST.md rename to dev-docs/tasks/phase-2/TASK-001-new-node-test/CHECKLIST.md diff --git a/dev-docs/tasks/phase-2/TASK-001-http-node.md/NOTES.md b/dev-docs/tasks/phase-2/TASK-001-new-node-test/NOTES.md similarity index 100% rename from dev-docs/tasks/phase-2/TASK-001-http-node.md/NOTES.md rename to dev-docs/tasks/phase-2/TASK-001-new-node-test/NOTES.md diff --git a/dev-docs/tasks/phase-2/TASK-001-http-node.md/README.md b/dev-docs/tasks/phase-2/TASK-001-new-node-test/README.md similarity index 100% rename from dev-docs/tasks/phase-2/TASK-001-http-node.md/README.md rename to dev-docs/tasks/phase-2/TASK-001-new-node-test/README.md diff --git a/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHANGELOG.md b/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHANGELOG.md index 72788f8..5e73a92 100644 --- a/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHANGELOG.md +++ b/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHANGELOG.md @@ -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` + - Detection tracking: `detectingProjects: Set` + - 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 +- 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 diff --git a/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHECKLIST.md b/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHECKLIST.md index ccc39be..9b21b2f 100644 --- a/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHECKLIST.md +++ b/dev-docs/tasks/phase-2/TASK-004-runtime-migration-system/CHECKLIST.md @@ -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) diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-000-existing-nodes-update.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-000-existing-nodes-update.md new file mode 100644 index 0000000..4827c21 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-000-existing-nodes-update.md @@ -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** - ``, `<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}} + {description && } + {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
{props.children}
; + }); +} +``` + +### After: ref as Prop Pattern +```javascript +getReactComponent() { + return function GroupComponent({ ref, style, children }) { + return
{children}
; + }; +} +``` + +### 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 ( +
+ {itemsToRender.map(item => /* render item */)} +
+ ); + }; +} +``` + +### 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 `` +- [ ] 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}} + {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 diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/00-OVERVIEW.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/00-OVERVIEW.md new file mode 100644 index 0000000..d5ec4f0 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/00-OVERVIEW.md @@ -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 diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/01-FOUNDATION.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/01-FOUNDATION.md new file mode 100644 index 0000000..49bdce7 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/01-FOUNDATION.md @@ -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>; + +// 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 | diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/02-EDITOR-UI.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/02-EDITOR-UI.md new file mode 100644 index 0000000..0771702 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/02-EDITOR-UI.md @@ -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; // { tablet: 2, phone: 3 } + onBreakpointChange: (breakpointId: string) => void; +} + +export function BreakpointSelector({ + breakpoints, + selectedBreakpoint, + overrideCounts, + onBreakpointChange +}: BreakpointSelectorProps) { + return ( +
+ Breakpoint: +
+ {breakpoints.map((bp) => ( + + + + ))} +
+
+ ); +} + +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 ( + + + (inherited) + {onReset && ( + + )} + + + ); + } + + return ( + + + + {onReset && ( + + )} + + + ); +} +``` + +### 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 { + const counts: Record = {}; + 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 ( +
+ +
+ {children} + {isBreakpointAware && ( + + )} +
+
+ ); +} +``` + +### 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 ( + + + + + + + + + +
+ {settings.breakpoints.map((bp, index) => ( + handleBreakpointChange(index, field, value)} + /> + ))} +
+
+ ); +} +``` + +### Step 7: Add Template to Property Editor HTML + +**File:** `packages/noodl-editor/src/editor/src/templates/propertyeditor.html` + +Add breakpoint selector container: + +```html + +
+``` + +## 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 diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/03-RUNTIME.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/03-RUNTIME.md new file mode 100644 index 0000000..a32801f --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/03-RUNTIME.md @@ -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 │ +├─────────────────────────────────────────┤ +│ + 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()); + }); + ``` diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/04-VARIANTS.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/04-VARIANTS.md new file mode 100644 index 0000000..c357b7e --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/04-VARIANTS.md @@ -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; + stateParameters: Record>; + stateTransitions: Record; + defaultStateTransitions: any; + + // NEW + breakpointParameters: Record>; + + 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 { + // ... existing implementation + + renderEditMode() { + const hasBreakpointPorts = this.hasBreakpointAwarePorts(); + + return ( +
+
Edit variant
+ + {/* Show breakpoint selector in variant edit mode */} + {hasBreakpointPorts && ( + + )} + +
+ + +
+
+ ); + } + + onBreakpointChanged(breakpoint: string) { + this.setState({ breakpoint }); + this.props.onBreakpointChanged?.(breakpoint); + } + + calculateVariantOverrideCounts(): Record { + const counts: Record = {}; + 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. diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/05-VISUAL-STATES-COMBO.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/05-VISUAL-STATES-COMBO.md new file mode 100644 index 0000000..4b81a34 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-001-responsive-update/05-VISUAL-STATES-COMBO.md @@ -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>; + + 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 ( + + + ● {currentState} + {currentBreakpoint} + + + ); + + case 'state': + return ( + + ● {currentState} + + ); + + case 'breakpoint': + return ( + + ● {currentBreakpoint} + + ); + + case 'base': + default: + if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') { + return (inherited); + } + return null; + } + } + + return ( +
+ +
+ {children} + {getIndicator()} + {valueSource !== 'base' && onReset && ( + + )} +
+
+ ); +} +``` + +### 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>; + + // 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. diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-002-expression-function-updates.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-002-expression-function-updates.md new file mode 100644 index 0000000..e69de29 diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-003-video-player.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-003-video-player.md new file mode 100644 index 0000000..872cc80 --- /dev/null +++ b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-003-video-player.md @@ -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 ( + + ); +} +``` + +--- + +## 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 diff --git a/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-004-rich-text-node.md b/dev-docs/tasks/phase-2/TASK-005-new-nodes/NODES-004-rich-text-node.md new file mode 100644 index 0000000..e69de29 diff --git a/packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss b/packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss index 5652339..fa627c9 100644 --- a/packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss +++ b/packages/noodl-core-ui/src/components/layout/BaseDialog/BaseDialog.module.scss @@ -1,123 +1,124 @@ -.Root { - border: none; - padding: 0; - background: transparent; - box-sizing: border-box; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - - &.has-backdrop { - background-color: var(--theme-color-bg-1-transparent); - } - - &.is-locking-scroll { - background-color: transparent; - } - - &:not(.has-backdrop):not(.is-locking-scroll) { - pointer-events: none; - } -} - -.VisibleDialog { - filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2)); - box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2); - position: absolute; - width: var(--width); - pointer-events: all; - - .Root.is-centered & { - top: 50%; - left: 50%; - animation: enter-centered var(--speed-quick) var(--easing-base) both; - } - - .Root:not(.is-centered) &.is-visible { - &.is-variant-default { - animation: enter var(--speed-quick) var(--easing-base) both; - } - - &.is-variant-select { - transform: translate(var(--offsetX), var(--offsetY)); - } - } - - &::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--background); - border-radius: 2px; - overflow: hidden; - } -} - -.Arrow { - position: absolute; - width: 0; - height: 0; - top: var(--arrow-top); - left: var(--arrow-left); - pointer-events: none; - - &::after { - content: ''; - display: block; - width: 11px; - height: 11px; - transform: translate(-50%, -50%) rotate(45deg); - background: var(--background); - } - - &.is-contrast::after { - background: var(--backgroundContrast); - } -} - -.Title { - background-color: var(--backgroundContrast); - padding: 12px; -} - -.MeasuringContainer { - pointer-events: none; - height: 0; - overflow: visible; - opacity: 0; -} - -.ChildContainer { - position: relative; - z-index: 1; -} - -@keyframes enter { - from { - opacity: 0; - transform: translate( - calc(var(--animationStartOffsetX) + var(--offsetX)), - calc(var(--animationStartOffsetY) + var(--offsetY)) - ); - } - to { - opacity: 1; - transform: translate(var(--offsetX), var(--offsetY)); - } -} - -@keyframes enter-centered { - from { - opacity: 0; - transform: translate(-50%, calc(-50% + 16px)); - } - to { - opacity: 1; - transform: translate(-50%, -50%); - } -} +.Root { + border: none; + padding: 0; + background: transparent; + box-sizing: border-box; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + &.has-backdrop { + background-color: var(--theme-color-bg-1-transparent); + } + + &.is-locking-scroll { + background-color: transparent; + } + + &:not(.has-backdrop):not(.is-locking-scroll) { + pointer-events: none; + } +} + +.VisibleDialog { + filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2)); + box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2); + position: absolute; + width: var(--width); + pointer-events: all; + + .Root.is-centered & { + top: 50%; + left: 50%; + animation: enter-centered var(--speed-quick) var(--easing-base) both; + } + + .Root:not(.is-centered) &.is-visible { + &.is-variant-default { + animation: enter var(--speed-quick) var(--easing-base) both; + } + + &.is-variant-select { + transform: translate(var(--offsetX), var(--offsetY)); + } + } + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--background); + border-radius: 2px; + overflow: hidden; + pointer-events: none; // Allow clicks to pass through to content + } +} + +.Arrow { + position: absolute; + width: 0; + height: 0; + top: var(--arrow-top); + left: var(--arrow-left); + pointer-events: none; + + &::after { + content: ''; + display: block; + width: 11px; + height: 11px; + transform: translate(-50%, -50%) rotate(45deg); + background: var(--background); + } + + &.is-contrast::after { + background: var(--backgroundContrast); + } +} + +.Title { + background-color: var(--backgroundContrast); + padding: 12px; +} + +.MeasuringContainer { + pointer-events: none; + height: 0; + overflow: visible; + opacity: 0; +} + +.ChildContainer { + position: relative; + z-index: 1; +} + +@keyframes enter { + from { + opacity: 0; + transform: translate( + calc(var(--animationStartOffsetX) + var(--offsetX)), + calc(var(--animationStartOffsetY) + var(--offsetY)) + ); + } + to { + opacity: 1; + transform: translate(var(--offsetX), var(--offsetY)); + } +} + +@keyframes enter-centered { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 16px)); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} diff --git a/packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx b/packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx index 9e32060..557e8fe 100644 --- a/packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx +++ b/packages/noodl-editor/src/editor/src/models/DialogLayerModel.tsx @@ -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 { public static instance = new DialogLayerModel(); @@ -84,4 +89,40 @@ export class DialogLayerModel extends Model 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; + } } diff --git a/packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts b/packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts index 861dbfd..de6a1a9 100644 --- a/packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts +++ b/packages/noodl-editor/src/editor/src/models/migration/MigrationSession.ts @@ -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 { + 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 + try { + // Check if target already exists + const targetExists = await filesystem.exists(targetPath); + if (targetExists) { + throw new Error(`Target directory already exists: ${targetPath}`); + } - await this.simulateDelay(500); + // Create target directory + await filesystem.makeDirectory(targetPath); + + // Copy all files recursively + await this.copyDirectoryRecursive(sourcePath, targetPath); - if (this.session) { 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; } + } - this.addLogEntry({ - level: 'success', - message: 'Project copied successfully' - }); + /** + * Recursively copies a directory and its contents + */ + private async copyDirectoryRecursive(sourcePath: string, targetPath: string): Promise { + const entries = await filesystem.listDirectory(sourcePath); - this.updateProgress({ current: 1 }); + 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 { @@ -493,14 +540,47 @@ export class MigrationSessionManager extends EventDispatcher { } private async executeFinalizePhase(): Promise { + 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; + + // 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', diff --git a/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts b/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts index 273cb5d..aa625bb 100644 --- a/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts +++ b/packages/noodl-editor/src/editor/src/models/migration/ProjectScanner.ts @@ -315,12 +315,14 @@ export async function detectRuntimeVersion(projectPath: string): Promise