Finished component sidebar updates, with one small bug remaining and documented

This commit is contained in:
Richard Osborne
2025-12-28 22:07:29 +01:00
parent 5f8ce8d667
commit fad9f1006d
193 changed files with 22245 additions and 506 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,698 @@
# User Location Node Specification
## Overview
The **User Location** node provides user geolocation functionality with multiple precision levels and fallback strategies. It handles the browser Geolocation API, manages permissions gracefully, and provides clear status reporting for different location acquisition methods.
This is a **logic node** (non-visual) that responds to signal triggers and outputs location data with comprehensive error handling and status reporting.
## Use Cases
- **Location-aware features**: Show nearby stores, events, or services
- **Personalization**: Adapt content based on user's region
- **Analytics**: Track geographic usage patterns (with user consent)
- **Shipping/delivery**: Pre-fill location fields in forms
- **Weather apps**: Get local weather based on position
- **Progressive enhancement**: Start with coarse location, refine to precise GPS when available
## Technical Foundation
### Browser Geolocation API
- **Primary method**: `navigator.geolocation.getCurrentPosition()`
- **Permissions**: Requires user consent (browser prompt)
- **Accuracy**: GPS on mobile (~5-10m), WiFi/IP on desktop (~100-1000m)
- **Browser support**: Universal (Chrome, Firefox, Safari, Edge)
- **HTTPS requirement**: Geolocation API requires secure context
### IP-based Fallback
- **Service**: ipapi.co free tier (no API key required for basic usage)
- **Accuracy**: City-level (~10-50km radius)
- **Privacy**: Does not require user permission
- **Limits**: 1,000 requests/day on free tier
- **Fallback strategy**: Used when GPS unavailable or permission denied
## Node Interface
### Category & Metadata
```javascript
{
name: 'User Location',
category: 'Data',
color: 'data',
docs: 'https://docs.noodl.net/nodes/data/user-location',
searchTags: ['geolocation', 'gps', 'position', 'coordinates', 'location'],
displayName: 'User Location'
}
```
### Signal Inputs
#### `Get Location`
Triggers location acquisition based on current accuracy mode setting.
**Behavior:**
- Checks if geolocation is supported
- Requests appropriate permission level
- Executes location query
- Sends appropriate output signal when complete
#### `Cancel`
Aborts an in-progress location request.
**Behavior:**
- Clears any pending geolocation watchPosition
- Aborts any in-flight IP geolocation requests
- Sends `Canceled` signal
- Resets internal state
### Parameters
#### `Accuracy Mode`
**Type:** Enum (dropdown)
**Default:** `"precise"`
**Options:**
- `"precise"` - High accuracy GPS (mobile: ~5-10m, desktop: ~100m)
- `"coarse"` - Lower accuracy, faster, better battery (mobile: ~100m-1km)
- `"city"` - IP-based location, no permission required (~10-50km)
**Details:**
- **Precise**: Uses `enableHighAccuracy: true`, ideal for navigation/directions
- **Coarse**: Uses `enableHighAccuracy: false`, better for "nearby" features
- **City**: Uses IP geolocation service, for region-level personalization
#### `Timeout`
**Type:** Number
**Default:** `10000` (10 seconds)
**Unit:** Milliseconds
**Range:** 1000-60000
Specifies how long to wait for location before timing out.
#### `Cache Age`
**Type:** Number
**Default:** `60000` (1 minute)
**Unit:** Milliseconds
**Range:** 0-3600000
Maximum age of a cached position. Setting to `0` forces a fresh location.
#### `Auto Request`
**Type:** Boolean
**Default:** `false`
If `true`, automatically requests location when node initializes (useful for apps that always need location).
**Warning:** Requesting location on load can be jarring to users. Best practice is to request only when needed.
### Data Outputs
#### `Latitude`
**Type:** Number
**Precision:** 6-8 decimal places
**Example:** `59.3293`
Geographic latitude in decimal degrees.
#### `Longitude`
**Type:** Number
**Precision:** 6-8 decimal places
**Example:** `18.0686`
Geographic longitude in decimal degrees.
#### `Accuracy`
**Type:** Number
**Unit:** Meters
**Example:** `10.5`
Accuracy radius in meters. Represents confidence circle around the position.
#### `Altitude` (Optional)
**Type:** Number
**Unit:** Meters
**Example:** `45.2`
Height above sea level. May be `null` if unavailable (common on desktop).
#### `Altitude Accuracy` (Optional)
**Type:** Number
**Unit:** Meters
Accuracy of altitude measurement. May be `null` if unavailable.
#### `Heading` (Optional)
**Type:** Number
**Unit:** Degrees (0-360)
**Example:** `90.0` (East)
Direction of device movement. `null` when stationary or unavailable.
#### `Speed` (Optional)
**Type:** Number
**Unit:** Meters per second
**Example:** `1.5` (walking pace)
Device movement speed. `null` when stationary or unavailable.
#### `Timestamp`
**Type:** Number
**Format:** Unix timestamp (milliseconds since epoch)
**Example:** `1703001234567`
When the position was acquired.
#### `City`
**Type:** String
**Example:** `"Stockholm"`
City name (only available with IP-based location).
#### `Region`
**Type:** String
**Example:** `"Stockholm County"`
Region/state name (only available with IP-based location).
#### `Country`
**Type:** String
**Example:** `"Sweden"`
Country name (only available with IP-based location).
#### `Country Code`
**Type:** String
**Example:** `"SE"`
ISO 3166-1 alpha-2 country code (only available with IP-based location).
#### `Postal Code`
**Type:** String
**Example:** `"111 22"`
Postal/ZIP code (only available with IP-based location).
#### `Error Message`
**Type:** String
**Example:** `"User denied geolocation permission"`
Human-readable error message when location acquisition fails.
#### `Error Code`
**Type:** Number
**Values:**
- `0` - No error
- `1` - Permission denied
- `2` - Position unavailable
- `3` - Timeout
- `4` - Browser not supported
- `5` - Network error (IP geolocation)
Numeric error code for programmatic handling.
### Signal Outputs
#### `Success`
Sent when location is successfully acquired.
**Guarantees:**
- `Latitude` and `Longitude` are populated
- `Accuracy` contains valid accuracy estimate
- Other outputs populated based on method and device capabilities
#### `Permission Denied`
Sent when user explicitly denies location permission.
**User recovery:**
- Show message explaining why location is needed
- Provide alternative (manual location entry)
- Offer "Settings" link to browser permissions
#### `Position Unavailable`
Sent when location service reports position cannot be determined.
**Causes:**
- GPS signal lost (indoors, urban canyon)
- WiFi/cell network unavailable
- Location services disabled at OS level
#### `Timeout`
Sent when location request exceeds configured timeout.
**Response:**
- May succeed if retried with longer timeout
- Consider falling back to IP-based location
#### `Not Supported`
Sent when browser doesn't support geolocation.
**Response:**
- Fall back to manual location entry
- Use IP-based estimation
- Show graceful degradation message
#### `Canceled`
Sent when location request is explicitly canceled via `Cancel` signal.
#### `Network Error`
Sent when IP geolocation service fails (only for city-level accuracy).
**Causes:**
- Network connectivity issues
- API rate limit exceeded
- Service unavailable
## State Management
The node maintains internal state to track:
```javascript
this._internal = {
watchId: null, // Active geolocation watch ID
abortController: null, // For canceling IP requests
pendingRequest: false, // Is request in progress?
lastPosition: null, // Cached position data
lastError: null, // Last error encountered
permissionState: 'prompt' // 'granted', 'denied', 'prompt'
}
```
## Implementation Details
### Permission Handling Strategy
1. **Check permission state** (if Permissions API available)
2. **Request location** based on accuracy mode
3. **Handle response** with appropriate success/error signal
4. **Cache result** for subsequent requests within cache window
### Geolocation Options
```javascript
// For "precise" mode
{
enableHighAccuracy: true,
timeout: this._internal.timeout,
maximumAge: this._internal.cacheAge
}
// For "coarse" mode
{
enableHighAccuracy: false,
timeout: this._internal.timeout,
maximumAge: this._internal.cacheAge
}
```
### IP Geolocation Implementation
```javascript
async function getIPLocation() {
const controller = new AbortController();
this._internal.abortController = controller;
try {
const response = await fetch('https://ipapi.co/json/', {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Populate outputs
this.setOutputs({
latitude: data.latitude,
longitude: data.longitude,
accuracy: 50000, // ~50km city-level accuracy
city: data.city,
region: data.region,
country: data.country_name,
countryCode: data.country_code,
postalCode: data.postal,
timestamp: Date.now()
});
this.sendSignalOnOutput('success');
} catch (error) {
if (error.name === 'AbortError') {
this.sendSignalOnOutput('canceled');
} else {
this._internal.lastError = error.message;
this.flagOutputDirty('errorMessage');
this.sendSignalOnOutput('networkError');
}
}
}
```
### Error Mapping
```javascript
function handleGeolocationError(error) {
this._internal.lastError = error;
this.setOutputValue('errorCode', error.code);
switch(error.code) {
case 1: // PERMISSION_DENIED
this.setOutputValue('errorMessage', 'User denied geolocation permission');
this.sendSignalOnOutput('permissionDenied');
break;
case 2: // POSITION_UNAVAILABLE
this.setOutputValue('errorMessage', 'Position unavailable');
this.sendSignalOnOutput('positionUnavailable');
break;
case 3: // TIMEOUT
this.setOutputValue('errorMessage', 'Location request timed out');
this.sendSignalOnOutput('timeout');
break;
default:
this.setOutputValue('errorMessage', 'Unknown error occurred');
this.sendSignalOnOutput('positionUnavailable');
}
}
```
## Security & Privacy Considerations
### User Privacy
- **Explicit permission**: Always require user consent for GPS (precise/coarse)
- **Clear purpose**: Document why location is needed in app UI
- **Minimal data**: Only request accuracy level needed for feature
- **No storage**: Don't store location unless explicitly needed
- **User control**: Provide easy way to revoke/change location settings
### HTTPS Requirement
- Geolocation API **requires HTTPS** in modern browsers
- Will fail silently or throw error on HTTP pages
- Development exception: `localhost` works over HTTP
### Rate Limiting
- IP geolocation service has 1,000 requests/day limit (free tier)
- Implement smart caching to reduce API calls
- Consider upgrading to paid tier for high-traffic apps
### Permission Persistence
- Browser remembers user's permission choice
- Can be revoked at any time in browser settings
- Node should gracefully handle permission changes
## User Experience Guidelines
### When to Request Location
**✅ DO:**
- Request when user triggers location-dependent feature
- Explain why location is needed before requesting
- Provide fallback for users who decline
**❌ DON'T:**
- Request on page load without context
- Re-prompt immediately after denial
- Block functionality if permission denied
### Error Handling UX
```
┌─────────────────────────────────────┐
│ Permission Denied │
├─────────────────────────────────────┤
│ We need your location to show │
│ nearby stores. You can enable it │
│ in your browser settings. │
│ │
│ [Enter Location Manually] │
└─────────────────────────────────────┘
```
### Progressive Enhancement
1. **Start coarse**: Request city-level (no permission)
2. **Offer precise**: "Show exact location" button
3. **Graceful degradation**: Manual entry fallback
## Testing Strategy
### Unit Tests
```javascript
describe('User Location Node', () => {
it('should request high accuracy location in precise mode', () => {
// Mock navigator.geolocation.getCurrentPosition
// Verify enableHighAccuracy: true
});
it('should timeout after configured duration', () => {
// Set timeout to 1000ms
// Mock delayed response
// Verify timeout signal fires
});
it('should use cached location within cache age', () => {
// Get location once
// Get location again within cache window
// Verify no new geolocation call made
});
it('should fall back to IP location in city mode', () => {
// Set mode to 'city'
// Trigger get location
// Verify fetch called to ipapi.co
});
it('should handle permission denial gracefully', () => {
// Mock permission denied error
// Verify permissionDenied signal fires
// Verify error message set
});
it('should cancel in-progress requests', () => {
// Start location request
// Trigger cancel
// Verify canceled signal fires
});
});
```
### Integration Tests
- Test on actual devices (mobile + desktop)
- Test with/without GPS enabled
- Test with permission granted/denied/prompt states
- Test network failures for IP geolocation
- Test timeout behavior with slow networks
- Test HTTPS requirement enforcement
### Browser Compatibility Tests
| Browser | Version | Notes |
|---------|---------|-------|
| Chrome | 90+ | Full support |
| Firefox | 88+ | Full support |
| Safari | 14+ | Full support, may prompt per session |
| Edge | 90+ | Full support |
| Mobile Safari | iOS 14+ | High accuracy works well |
| Mobile Chrome | Android 10+ | High accuracy works well |
## Example Usage Patterns
### Pattern 1: Simple Location Request
```
[Button] → Click Signal
[User Location] → Get Location
Success → [Text] "Your location: {Latitude}, {Longitude}"
Permission Denied → [Text] "Please enable location access"
```
### Pattern 2: Progressive Enhancement
```
[User Location] (mode: city)
Success → [Text] "Shopping near {City}"
[Button] "Show exact location"
[User Location] (mode: precise) → Get Location
Success → Update map with precise position
```
### Pattern 3: Error Recovery Chain
```
[User Location] (mode: precise)
Permission Denied OR Timeout
[User Location] (mode: city) → Get Location
Success → Use coarse location
Network Error → [Text] "Enter location manually"
```
### Pattern 4: Map Integration
```
[User Location]
Success → [Object] Store lat/lng
[Function] Call map API
[HTML Element] Display map with user marker
```
## Documentation Requirements
### Node Reference Page
1. **Overview section** explaining location acquisition
2. **Permission explanation** with browser screenshots
3. **Accuracy mode comparison** table
4. **Common use cases** with visual examples
5. **Error handling guide** with recovery strategies
6. **Privacy best practices** section
7. **HTTPS requirement** warning
8. **Example implementations** for each pattern
### Tutorial Content
- "Building a Store Locator with User Location"
- "Progressive Location Permissions"
- "Handling Location Errors Gracefully"
## File Locations
### Implementation
- **Path**: `/packages/noodl-runtime/src/nodes/std-library/data/userlocation.js`
- **Registration**: Add to `/packages/noodl-runtime/src/nodes/std-library/index.js`
### Tests
- **Unit**: `/packages/noodl-runtime/tests/nodes/data/userlocation.test.js`
- **Integration**: Manual testing checklist document
### Documentation
- **Main docs**: `/docs/nodes/data/user-location.md`
- **Examples**: `/docs/examples/location-features.md`
## Dependencies
### Runtime Dependencies
- Native browser APIs (no external dependencies)
- Optional: `ipapi.co` for IP-based location (free service, no npm package needed)
### Development Dependencies
- Jest for unit tests
- Mock implementations of `navigator.geolocation`
## Implementation Phases
### Phase 1: Core GPS Location (2-3 days)
- [ ] Basic node structure with inputs/outputs
- [ ] GPS location acquisition (precise/coarse modes)
- [ ] Permission handling
- [ ] Error handling and signal outputs
- [ ] Basic unit tests
### Phase 2: IP Fallback (1-2 days)
- [ ] City mode implementation
- [ ] IP geolocation API integration
- [ ] Network error handling
- [ ] Extended test coverage
### Phase 3: Polish & Edge Cases (1-2 days)
- [ ] Cancel functionality
- [ ] Cache management
- [ ] Auto request feature
- [ ] Browser compatibility testing
- [ ] Permission state tracking
### Phase 4: Documentation (1-2 days)
- [ ] Node reference documentation
- [ ] Usage examples
- [ ] Tutorial content
- [ ] Privacy guidelines
- [ ] Troubleshooting guide
**Total estimated effort:** 5-9 days
## Success Criteria
- [ ] Node successfully acquires location in all three accuracy modes
- [ ] Permission states handled gracefully (grant/deny/prompt)
- [ ] Clear error messages for all failure scenarios
- [ ] Timeout and cancel functionality work correctly
- [ ] Cache prevents unnecessary repeated requests
- [ ] Works across major browsers and devices
- [ ] Comprehensive unit test coverage (>80%)
- [ ] Documentation complete with examples
- [ ] Privacy considerations clearly documented
- [ ] Community feedback incorporated
## Future Enhancements
### Continuous Location Tracking
Add `Watch Location` signal input that continuously monitors position changes. Useful for:
- Navigation apps
- Fitness tracking
- Delivery tracking
**Implementation:** Use `navigator.geolocation.watchPosition()`
### Geofencing
Add ability to define geographic boundaries and trigger signals when user enters/exits.
**Outputs:**
- `Entered Geofence` signal
- `Exited Geofence` signal
- `Inside Geofence` boolean
### Custom IP Services
Allow users to specify their own IP geolocation service URL and API key for:
- Higher rate limits
- Additional data (ISP, timezone, currency)
- Enterprise requirements
### Location History
Optional caching of location history with timestamp array output for:
- Journey tracking
- Location analytics
- Movement patterns
### Distance Calculations
Built-in distance calculation between user location and target coordinates:
- Distance to store/event
- Sorting by proximity
- "Nearby" filtering
## Related Nodes
- **REST**: Can be used to send location data to APIs
- **Object**: Store location data in app state
- **Condition**: Branch logic based on error codes
- **Function**: Calculate distances, format coordinates
- **Array**: Store multiple location readings
## Questions for Community/Team
1. Should we include "Watch Location" in v1 or defer to v2?
2. Do we need additional country/region data beyond what ipapi.co provides?
3. Should we support other IP geolocation services?
4. Is 1-minute default cache age appropriate?
5. Should we add a "Remember Permission" feature?
---
**Document Version:** 1.0
**Last Updated:** 2024-12-16
**Author:** AI Assistant (Claude)
**Status:** RFC - Ready for Review