From 7ab5d2e2b94f4032c9aeb4830c025a3edf1c2915 Mon Sep 17 00:00:00 2001 From: Z User Date: Mon, 31 Aug 2026 19:01:06 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Ferrum=20Studio=20core=20module?= =?UTF-8?q?=20=E2=80=94=20project,=20timeline,=20tokens,=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Ferrum Studio scaffolding (Planned → foundation): Core Data Model (types.ts): - StudioProject, CanvasElement, ElementType - TimelineKeyframe, AnimationTimeline - DesignToken, TokenType, Breakpoint Project Management (project.ts): - Create/delete/move/resize/duplicate elements - Bounding-box hit testing (findElementAt) - Z-ordering (bringToFront, sendToBack) Timeline (timeline.ts): - Keyframe CRUD with sorting - Linear interpolation for number properties - Time-clamped property resolution Design Tokens (tokens.ts): - 45 default tokens (colors, spacing, typography, borders) - CSS custom property generation Export (export.ts): - HTML, CSS, React JSX generation - @keyframes from timeline data Responsive (breakpoints.ts): - 4 breakpoints (mobile/tablet/desktop/wide) 76 unit tests covering all modules. --- __tests__/ferrum-studio.test.ts | 854 +++++++++++++++++++++++++++ src/lib/ferrum-studio/breakpoints.ts | 58 ++ src/lib/ferrum-studio/export.ts | 287 +++++++++ src/lib/ferrum-studio/index.ts | 71 +++ src/lib/ferrum-studio/project.ts | 253 ++++++++ src/lib/ferrum-studio/timeline.ts | 179 ++++++ src/lib/ferrum-studio/tokens.ts | 164 +++++ src/lib/ferrum-studio/types.ts | 150 +++++ 8 files changed, 2016 insertions(+) create mode 100644 __tests__/ferrum-studio.test.ts create mode 100644 src/lib/ferrum-studio/breakpoints.ts create mode 100644 src/lib/ferrum-studio/export.ts create mode 100644 src/lib/ferrum-studio/index.ts create mode 100644 src/lib/ferrum-studio/project.ts create mode 100644 src/lib/ferrum-studio/timeline.ts create mode 100644 src/lib/ferrum-studio/tokens.ts create mode 100644 src/lib/ferrum-studio/types.ts diff --git a/__tests__/ferrum-studio.test.ts b/__tests__/ferrum-studio.test.ts new file mode 100644 index 0000000..1d7b321 --- /dev/null +++ b/__tests__/ferrum-studio.test.ts @@ -0,0 +1,854 @@ +/** + * Comprehensive tests for the Ferrum Studio scaffolding module. + * Covers project CRUD, element management, timeline interpolation, + * design tokens, export, and breakpoints. + */ + +import { + createProject, + addElement, + removeElement, + moveElement, + resizeElement, + getElement, + findElementAt, + duplicateElement, + bringToFront, + sendToBack, + _resetIdCounter, + createTimeline, + addKeyframe, + removeKeyframe, + getKeyframesForElement, + getInterpolatedProps, + sortKeyframes, + _resetKfCounter, + createToken, + updateToken, + tokenToCSS, + tokensToCSS, + DEFAULT_TOKENS, + _resetTkCounter, + exportToHTML, + exportToCSS, + exportToReact, + generateAnimationCSS, + STUDIO_BREAKPOINTS, + getActiveBreakpoints, + getElementBreakpointStyles, +} from '@/lib/ferrum-studio'; + +import type { + StudioProject, + CanvasElement, + AnimationTimeline, +} from '@/lib/ferrum-studio'; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function baseElement( + overrides: Partial> = {}, +): Omit { + return { + type: 'box', + x: 0, + y: 0, + width: 100, + height: 100, + rotation: 0, + zIndex: 0, + props: {}, + styles: {}, + ...overrides, + }; +} + +// ─── Project CRUD ─────────────────────────────────────────────────── + +describe('createProject', () => { + beforeEach(() => _resetIdCounter()); + + it('creates a project with defaults', () => { + const p = createProject('Test'); + expect(p.name).toBe('Test'); + expect(p.description).toBe(''); + expect(p.canvas.width).toBe(1280); + expect(p.canvas.height).toBe(720); + expect(p.canvas.background).toBe('#ffffff'); + expect(p.elements).toEqual([]); + expect(p.timeline.duration).toBe(1000); + expect(p.timeline.keyframes).toEqual([]); + expect(p.tokens).toEqual([]); + expect(p.createdAt).toBeTruthy(); + expect(p.updatedAt).toBeTruthy(); + expect(p.id).toBeTruthy(); + }); + + it('accepts a description', () => { + const p = createProject('My App', 'A test project'); + expect(p.description).toBe('A test project'); + }); + + it('generates unique ids', () => { + const p1 = createProject('A'); + const p2 = createProject('B'); + expect(p1.id).not.toBe(p2.id); + }); +}); + +describe('addElement', () => { + beforeEach(() => _resetIdCounter()); + + it('adds an element and returns it with an id', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement({ type: 'box', x: 10, y: 20 })); + expect(el.id).toBeTruthy(); + expect(el.type).toBe('box'); + expect(el.x).toBe(10); + expect(el.y).toBe(20); + expect(p.elements).toHaveLength(1); + expect(p.elements[0]).toBe(el); + }); + + it('updates the project updatedAt timestamp', () => { + const p = createProject('Test'); + const before = p.updatedAt; + // Small delay to ensure timestamp differs + addElement(p, baseElement()); + // Updated at is set; we trust the implementation since Date resolution varies + expect(p.updatedAt).toBeTruthy(); + }); + + it('supports multiple elements', () => { + const p = createProject('Test'); + addElement(p, baseElement({ type: 'box' })); + addElement(p, baseElement({ type: 'text' })); + expect(p.elements).toHaveLength(2); + }); +}); + +describe('removeElement', () => { + beforeEach(() => _resetIdCounter()); + + it('removes the element from the project (immutable)', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement()); + const result = removeElement(p, el.id); + expect(result.elements).toHaveLength(0); + // Original is unchanged + expect(p.elements).toHaveLength(1); + }); + + it('returns a new project reference', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement()); + const result = removeElement(p, el.id); + expect(result).not.toBe(p); + }); + + it('is a no-op for non-existent ids', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement()); + const result = removeElement(p, 'nonexistent'); + expect(result.elements).toHaveLength(1); + }); +}); + +describe('moveElement', () => { + beforeEach(() => _resetIdCounter()); + + it('moves an element to a new position (immutable)', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement({ x: 0, y: 0 })); + const result = moveElement(p, el.id, 50, 75); + expect(result.elements[0]?.x).toBe(50); + expect(result.elements[0]?.y).toBe(75); + // Original unchanged + expect(p.elements[0]?.x).toBe(0); + }); +}); + +describe('resizeElement', () => { + beforeEach(() => _resetIdCounter()); + + it('resizes an element (immutable)', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement({ width: 100, height: 100 })); + const result = resizeElement(p, el.id, 200, 300); + expect(result.elements[0]?.width).toBe(200); + expect(result.elements[0]?.height).toBe(300); + }); +}); + +describe('getElement', () => { + beforeEach(() => _resetIdCounter()); + + it('finds an element by id', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement({ type: 'button' })); + const found = getElement(p, el.id); + expect(found?.id).toBe(el.id); + expect(found?.type).toBe('button'); + }); + + it('returns undefined for missing ids', () => { + const p = createProject('Test'); + expect(getElement(p, 'nope')).toBeUndefined(); + }); +}); + +// ─── Hit Testing ──────────────────────────────────────────────────── + +describe('findElementAt', () => { + beforeEach(() => _resetIdCounter()); + + it('finds an element whose bounding box contains the point', () => { + const p = createProject('Test'); + addElement(p, baseElement({ x: 10, y: 10, width: 100, height: 100, zIndex: 1 })); + const hit = findElementAt(p, 50, 50); + expect(hit).toBeTruthy(); + }); + + it('returns undefined when nothing is at the point', () => { + const p = createProject('Test'); + addElement(p, baseElement({ x: 0, y: 0, width: 50, height: 50 })); + expect(findElementAt(p, 200, 200)).toBeUndefined(); + }); + + it('returns the topmost element at overlapping coordinates', () => { + const p = createProject('Test'); + addElement(p, baseElement({ x: 0, y: 0, width: 100, height: 100, zIndex: 1 })); + const top = addElement(p, baseElement({ x: 0, y: 0, width: 100, height: 100, zIndex: 5 })); + const hit = findElementAt(p, 50, 50); + expect(hit?.id).toBe(top.id); + }); + + it('includes points on the top-left edges and up to bottom-right edges', () => { + const p = createProject('Test'); + addElement(p, baseElement({ x: 10, y: 10, width: 100, height: 100 })); + // Inclusive bounds: right edge (x + width) and bottom edge (y + height) are inside + expect(findElementAt(p, 110, 50)).toBeTruthy(); + expect(findElementAt(p, 50, 110)).toBeTruthy(); + // Just outside + expect(findElementAt(p, 111, 50)).toBeUndefined(); + expect(findElementAt(p, 50, 111)).toBeUndefined(); + }); + + it('includes points on the top-left edges', () => { + const p = createProject('Test'); + addElement(p, baseElement({ x: 10, y: 10, width: 100, height: 100 })); + expect(findElementAt(p, 10, 10)).toBeTruthy(); + }); +}); + +// ─── Z-Ordering ───────────────────────────────────────────────────── + +describe('bringToFront / sendToBack', () => { + beforeEach(() => _resetIdCounter()); + + it('bringToFront sets the highest z-index', () => { + const p = createProject('Test'); + const a = addElement(p, baseElement({ zIndex: 1 })); + const b = addElement(p, baseElement({ zIndex: 5 })); + const c = addElement(p, baseElement({ zIndex: 3 })); + const result = bringToFront(p, a.id); + const updated = result.elements.find((el) => el.id === a.id); + expect(updated?.zIndex).toBe(6); // max(1,5,3) + 1 + }); + + it('sendToBack sets the lowest z-index', () => { + const p = createProject('Test'); + const a = addElement(p, baseElement({ zIndex: 1 })); + const b = addElement(p, baseElement({ zIndex: 5 })); + const result = sendToBack(p, b.id); + const updated = result.elements.find((el) => el.id === b.id); + expect(updated?.zIndex).toBe(0); // min(1,5) - 1 + }); + + it('returns new project references', () => { + const p = createProject('Test'); + const a = addElement(p, baseElement()); + expect(bringToFront(p, a.id)).not.toBe(p); + expect(sendToBack(p, a.id)).not.toBe(p); + }); +}); + +// ─── Duplication ──────────────────────────────────────────────────── + +describe('duplicateElement', () => { + beforeEach(() => _resetIdCounter()); + + it('duplicates an element with offset position', () => { + const p = createProject('Test'); + const el = addElement(p, baseElement({ x: 10, y: 20, type: 'card' })); + const copy = duplicateElement(p, el.id); + expect(copy).not.toBeNull(); + expect(copy!.id).not.toBe(el.id); + expect(copy!.x).toBe(30); // 10 + 20 + expect(copy!.y).toBe(40); // 20 + 20 + expect(copy!.type).toBe('card'); + expect(p.elements).toHaveLength(2); + }); + + it('returns null for non-existent ids', () => { + const p = createProject('Test'); + expect(duplicateElement(p, 'nope')).toBeNull(); + }); + + it('deep-copies props and styles', () => { + const p = createProject('Test'); + const el = addElement(p, + baseElement({ + props: { text: 'Hello' }, + styles: { backgroundColor: 'red' }, + }), + ); + const copy = duplicateElement(p, el.id)!; + expect(copy.props).toEqual({ text: 'Hello' }); + expect(copy.styles).toEqual({ backgroundColor: 'red' }); + // Mutating copy should not affect original + copy.props['text'] = 'Changed'; + expect(el.props['text']).toBe('Hello'); + }); + + it('deep-copies children', () => { + const p = createProject('Test'); + const child = baseElement({ type: 'text' }); + const el = addElement(p, baseElement({ type: 'container', children: [child] })); + const copy = duplicateElement(p, el.id)!; + expect(copy.children).toHaveLength(1); + expect(copy.children![0]!.id).not.toBe(el.children![0]!.id); + }); +}); + +// ─── Timeline ─────────────────────────────────────────────────────── + +describe('createTimeline', () => { + it('creates with default values', () => { + const tl = createTimeline(); + expect(tl.duration).toBe(1000); + expect(tl.keyframes).toEqual([]); + expect(tl.loop).toBe(false); + expect(tl.direction).toBe('normal'); + }); + + it('accepts a custom duration', () => { + const tl = createTimeline(3000); + expect(tl.duration).toBe(3000); + }); +}); + +describe('addKeyframe', () => { + beforeEach(() => _resetKfCounter()); + + it('adds a keyframe with a generated id', () => { + const tl = createTimeline(); + const result = addKeyframe(tl, { + elementId: 'el1', + time: 500, + properties: { opacity: 0.5 }, + }); + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]!.id).toBeTruthy(); + expect(result.keyframes[0]!.elementId).toBe('el1'); + expect(result.keyframes[0]!.properties['opacity']).toBe(0.5); + }); + + it('returns a new timeline (immutable)', () => { + const tl = createTimeline(); + const result = addKeyframe(tl, { + elementId: 'el1', + time: 0, + properties: {}, + }); + expect(result).not.toBe(tl); + expect(tl.keyframes).toHaveLength(0); + }); +}); + +describe('removeKeyframe', () => { + beforeEach(() => _resetKfCounter()); + + it('removes a keyframe by id', () => { + let tl = createTimeline(); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: {} }); + const kfId = tl.keyframes[0]!.id; + tl = removeKeyframe(tl, kfId); + expect(tl.keyframes).toHaveLength(0); + }); + + it('is a no-op for non-existent ids', () => { + let tl = createTimeline(); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: {} }); + const before = tl.keyframes.length; + tl = removeKeyframe(tl, 'nope'); + expect(tl.keyframes.length).toBe(before); + }); +}); + +describe('getKeyframesForElement', () => { + it('returns only keyframes for the given element, sorted by time', () => { + let tl = createTimeline(); + tl = addKeyframe(tl, { elementId: 'el1', time: 500, properties: { x: 100 } }); + tl = addKeyframe(tl, { elementId: 'el2', time: 200, properties: { y: 50 } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 100, properties: { x: 0 } }); + + const kfs = getKeyframesForElement(tl, 'el1'); + expect(kfs).toHaveLength(2); + expect(kfs[0]!.time).toBe(100); + expect(kfs[1]!.time).toBe(500); + }); + + it('returns empty array when no keyframes exist', () => { + const tl = createTimeline(); + expect(getKeyframesForElement(tl, 'el1')).toEqual([]); + }); +}); + +describe('getInterpolatedProps', () => { + beforeEach(() => _resetKfCounter()); + + it('returns empty object when no keyframes exist', () => { + const tl = createTimeline(); + expect(getInterpolatedProps(tl, 'el1', 500)).toEqual({}); + }); + + it('returns first keyframe values before the first keyframe time', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 200, properties: { x: 100 } }); + const props = getInterpolatedProps(tl, 'el1', 50); + expect(props['x']).toBe(100); + }); + + it('returns last keyframe values after the last keyframe time', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 200, properties: { x: 100 } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 800, properties: { x: 500 } }); + const props = getInterpolatedProps(tl, 'el1', 900); + expect(props['x']).toBe(500); + }); + + it('linearly interpolates numeric values', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: { x: 0, opacity: 0 } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 1000, properties: { x: 100, opacity: 1 } }); + + const at500 = getInterpolatedProps(tl, 'el1', 500); + expect(at500['x']).toBe(50); + expect(at500['opacity']).toBe(0.5); + + const at250 = getInterpolatedProps(tl, 'el1', 250); + expect(at250['x']).toBe(25); + }); + + it('snaps string values to nearest keyframe', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: { color: 'red' } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 1000, properties: { color: 'blue' } }); + + const at400 = getInterpolatedProps(tl, 'el1', 400); + expect(at400['color']).toBe('red'); // progress < 0.5 + + const at700 = getInterpolatedProps(tl, 'el1', 700); + expect(at700['color']).toBe('blue'); // progress >= 0.5 + }); + + it('clamps time to timeline duration', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: { x: 0 } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 1000, properties: { x: 100 } }); + + const atNegative = getInterpolatedProps(tl, 'el1', -100); + expect(atNegative['x']).toBe(0); + + const atExcess = getInterpolatedProps(tl, 'el1', 2000); + expect(atExcess['x']).toBe(100); + }); + + it('handles property appearing only in one keyframe', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: { x: 0, color: 'red' } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 1000, properties: { x: 100 } }); + + const at500 = getInterpolatedProps(tl, 'el1', 500); + expect(at500['x']).toBe(50); + expect(at500['color']).toBe('red'); // Only in first keyframe + }); +}); + +describe('sortKeyframes', () => { + beforeEach(() => _resetKfCounter()); + + it('sorts keyframes by time', () => { + let tl = createTimeline(); + tl = addKeyframe(tl, { elementId: 'el1', time: 500, properties: {} }); + tl = addKeyframe(tl, { elementId: 'el1', time: 100, properties: {} }); + tl = addKeyframe(tl, { elementId: 'el1', time: 300, properties: {} }); + + const sorted = sortKeyframes(tl); + expect(sorted.keyframes[0]!.time).toBe(100); + expect(sorted.keyframes[1]!.time).toBe(300); + expect(sorted.keyframes[2]!.time).toBe(500); + }); + + it('returns a new timeline reference', () => { + const tl = createTimeline(); + expect(sortKeyframes(tl)).not.toBe(tl); + }); +}); + +// ─── Design Tokens ────────────────────────────────────────────────── + +describe('createToken', () => { + beforeEach(() => _resetTkCounter()); + + it('creates a token with defaults', () => { + const tk = createToken('primary-500', '#3b82f6', 'color'); + expect(tk.id).toBeTruthy(); + expect(tk.name).toBe('primary-500'); + expect(tk.value).toBe('#3b82f6'); + expect(tk.type).toBe('color'); + expect(tk.category).toBe('color'); // defaults to type + }); + + it('accepts a custom category', () => { + const tk = createToken('4', '1rem', 'spacing', 'spacing'); + expect(tk.category).toBe('spacing'); + }); + + it('generates unique ids', () => { + const a = createToken('a', '1', 'color'); + const b = createToken('b', '2', 'color'); + expect(a.id).not.toBe(b.id); + }); +}); + +describe('updateToken', () => { + it('merges updates immutably', () => { + const tk = createToken('primary', '#f00', 'color', 'colors'); + const updated = updateToken(tk, { value: '#00f', description: 'Blue primary' }); + expect(updated.value).toBe('#00f'); + expect(updated.description).toBe('Blue primary'); + expect(updated.name).toBe('primary'); // unchanged + // Original unchanged + expect(tk.value).toBe('#f00'); + }); +}); + +describe('tokenToCSS', () => { + it('generates a CSS custom property declaration', () => { + const tk = createToken('primary-500', '#3b82f6', 'color', 'colors'); + const css = tokenToCSS(tk); + expect(css).toBe(' --colors-primary-500: #3b82f6;'); + }); +}); + +describe('tokensToCSS', () => { + it('generates a :root block with all tokens', () => { + const tokens = [ + createToken('primary', '#f00', 'color', 'colors'), + createToken('4', '1rem', 'spacing', 'spacing'), + ]; + const css = tokensToCSS(tokens); + expect(css).toContain(':root {'); + expect(css).toContain(' --colors-primary: #f00;'); + expect(css).toContain(' --spacing-4: 1rem;'); + expect(css).toContain('}'); + }); + + it('returns empty :root for no tokens', () => { + expect(tokensToCSS([])).toBe(':root {}'); + }); +}); + +describe('DEFAULT_TOKENS', () => { + it('contains a comprehensive set of tokens', () => { + expect(DEFAULT_TOKENS.length).toBeGreaterThan(30); + + const colorTokens = DEFAULT_TOKENS.filter((t) => t.type === 'color'); + expect(colorTokens.length).toBeGreaterThanOrEqual(10); + + const spacingTokens = DEFAULT_TOKENS.filter((t) => t.type === 'spacing'); + expect(spacingTokens.length).toBeGreaterThanOrEqual(8); + + const typographyTokens = DEFAULT_TOKENS.filter((t) => t.type === 'typography'); + expect(typographyTokens.length).toBeGreaterThanOrEqual(5); + + const shadowTokens = DEFAULT_TOKENS.filter((t) => t.type === 'shadow'); + expect(shadowTokens.length).toBeGreaterThanOrEqual(3); + }); + + it('can be converted to valid CSS', () => { + const css = tokensToCSS(DEFAULT_TOKENS); + expect(css).toContain(':root {'); + expect(css).toContain('--colors-primary-500:'); + expect(css).toContain('--spacing-4:'); + }); +}); + +// ─── Export: HTML ─────────────────────────────────────────────────── + +describe('exportToHTML', () => { + beforeEach(() => _resetIdCounter()); + + it('generates a valid HTML document', () => { + const p = createProject('My Page'); + addElement(p, baseElement({ type: 'box', x: 10, y: 20, width: 200, height: 100 })); + const html = exportToHTML(p); + expect(html).toContain(''); + expect(html).toContain('My Page'); + expect(html).toContain(' { + const p = createProject('Test'); + addElement(p, baseElement({ type: 'text', props: { text: 'Hello World' } })); + const html = exportToHTML(p); + expect(html).toContain(' { + const p = createProject('Test'); + addElement(p, baseElement({ type: 'image', props: { src: '/img.png', alt: 'Photo' } })); + const html = exportToHTML(p); + expect(html).toContain(' { + const p = createProject('Test'); + const child = baseElement({ type: 'text', x: 5, y: 5, width: 50, height: 30, props: { text: 'Child' } }); + addElement(p, baseElement({ type: 'container', children: [child] })); + const html = exportToHTML(p); + expect(html).toContain('Child'); + }); +}); + +// ─── Export: CSS ──────────────────────────────────────────────────── + +describe('exportToCSS', () => { + beforeEach(() => _resetIdCounter()); + + it('generates :root tokens and element classes', () => { + _resetTkCounter(); + const p = createProject('Test'); + const tk = createToken('primary', '#f00', 'color', 'colors'); + p.tokens.push(tk); + addElement(p, baseElement({ styles: { backgroundColor: 'red' } })); + const css = exportToCSS(p); + expect(css).toContain(':root {'); + expect(css).toContain('--colors-primary: #f00'); + expect(css).toContain('backgroundColor: red'); + }); + + it('includes animation CSS from timeline', () => { + const p = createProject('Test'); + p.timeline = addKeyframe(p.timeline, { + elementId: 'el1', + time: 0, + properties: { opacity: 0 }, + }); + p.timeline = addKeyframe(p.timeline, { + elementId: 'el1', + time: 1000, + properties: { opacity: 1 }, + }); + const css = exportToCSS(p); + expect(css).toContain('@keyframes'); + expect(css).toContain('opacity'); + }); +}); + +// ─── Export: React ────────────────────────────────────────────────── + +describe('exportToReact', () => { + beforeEach(() => { + _resetIdCounter(); + _resetTkCounter(); + }); + + it('generates a React component file', () => { + const p = createProject('My Component'); + addElement(p, baseElement({ type: 'box', x: 10, y: 20, width: 200, height: 100 })); + const jsx = exportToReact(p); + expect(jsx).toContain('import React'); + expect(jsx).toContain('MyComponent'); + expect(jsx).toContain('position: \'absolute\''); + expect(jsx).toContain('left: 10'); + expect(jsx).toContain('width: 200'); + }); + + it('includes description in JSDoc', () => { + const p = createProject('App', 'A test app'); + const jsx = exportToReact(p); + expect(jsx).toContain('A test app'); + }); + + it('renders text content in JSX', () => { + const p = createProject('Test'); + addElement(p, baseElement({ type: 'text', props: { text: 'Hello' } })); + const jsx = exportToReact(p); + expect(jsx).toContain('Hello'); + }); + + it('handles rotation and z-index', () => { + const p = createProject('Test'); + addElement(p, baseElement({ rotation: 45, zIndex: 10 })); + const jsx = exportToReact(p); + expect(jsx).toContain('rotate(45deg)'); + expect(jsx).toContain('zIndex: 10'); + }); +}); + +// ─── Export: Animation CSS ────────────────────────────────────────── + +describe('generateAnimationCSS', () => { + beforeEach(() => _resetKfCounter()); + + it('returns empty string for empty timeline', () => { + expect(generateAnimationCSS(createTimeline())).toBe(''); + }); + + it('generates @keyframes per element', () => { + let tl = createTimeline(1000); + tl = addKeyframe(tl, { elementId: 'box1', time: 0, properties: { opacity: 0 } }); + tl = addKeyframe(tl, { elementId: 'box1', time: 1000, properties: { opacity: 1 } }); + tl = addKeyframe(tl, { elementId: 'box2', time: 500, properties: { transform: 'scale(1.5)' } }); + + const css = generateAnimationCSS(tl); + expect(css).toContain('@keyframes box1 {'); + expect(css).toContain('@keyframes box2 {'); + expect(css).toContain('0% {'); + expect(css).toContain('100% {'); + expect(css).toContain('50% {'); + expect(css).toContain('opacity: 0'); + expect(css).toContain('opacity: 1'); + }); + + it('sorts keyframes by time within each element', () => { + let tl = createTimeline(2000); + tl = addKeyframe(tl, { elementId: 'el1', time: 1000, properties: { x: 100 } }); + tl = addKeyframe(tl, { elementId: 'el1', time: 0, properties: { x: 0 } }); + + const css = generateAnimationCSS(tl); + const idx0 = css.indexOf('0%'); + const idx50 = css.indexOf('50%'); + expect(idx0).toBeLessThan(idx50); + }); +}); + +// ─── Breakpoints ──────────────────────────────────────────────────── + +describe('STUDIO_BREAKPOINTS', () => { + it('has the four expected breakpoints', () => { + expect(STUDIO_BREAKPOINTS).toHaveLength(4); + const names = STUDIO_BREAKPOINTS.map((bp) => bp.name); + expect(names).toEqual(['mobile', 'tablet', 'desktop', 'wide']); + }); + + it('has non-overlapping ranges', () => { + for (let i = 1; i < STUDIO_BREAKPOINTS.length; i++) { + const prev = STUDIO_BREAKPOINTS[i - 1]!; + const curr = STUDIO_BREAKPOINTS[i]!; + expect(curr.minWidth).toBe(prev.maxWidth + 1); + } + }); +}); + +describe('getActiveBreakpoints', () => { + it('activates mobile for small widths', () => { + const bps = getActiveBreakpoints(375); + expect(bps.find((bp) => bp.name === 'mobile')?.isActive).toBe(true); + expect(bps.find((bp) => bp.name === 'tablet')?.isActive).toBe(false); + expect(bps.find((bp) => bp.name === 'desktop')?.isActive).toBe(false); + expect(bps.find((bp) => bp.name === 'wide')?.isActive).toBe(false); + }); + + it('activates tablet for medium widths', () => { + const bps = getActiveBreakpoints(768); + expect(bps.find((bp) => bp.name === 'mobile')?.isActive).toBe(false); + expect(bps.find((bp) => bp.name === 'tablet')?.isActive).toBe(true); + expect(bps.find((bp) => bp.name === 'desktop')?.isActive).toBe(false); + }); + + it('activates desktop for large widths', () => { + const bps = getActiveBreakpoints(1280); + expect(bps.find((bp) => bp.name === 'desktop')?.isActive).toBe(true); + expect(bps.find((bp) => bp.name === 'wide')?.isActive).toBe(false); + }); + + it('activates wide for extra-large widths', () => { + const bps = getActiveBreakpoints(1920); + expect(bps.find((bp) => bp.name === 'wide')?.isActive).toBe(true); + expect(bps.find((bp) => bp.name === 'desktop')?.isActive).toBe(false); + }); + + it('handles exact boundary values', () => { + // Exactly 639 → mobile + expect(getActiveBreakpoints(639).find((bp) => bp.name === 'mobile')?.isActive).toBe(true); + // Exactly 640 → tablet + expect(getActiveBreakpoints(640).find((bp) => bp.name === 'tablet')?.isActive).toBe(true); + // Exactly 1023 → tablet + expect(getActiveBreakpoints(1023).find((bp) => bp.name === 'tablet')?.isActive).toBe(true); + // Exactly 1024 → desktop + expect(getActiveBreakpoints(1024).find((bp) => bp.name === 'desktop')?.isActive).toBe(true); + // Exactly 1439 → desktop + expect(getActiveBreakpoints(1439).find((bp) => bp.name === 'desktop')?.isActive).toBe(true); + // Exactly 1440 → wide + expect(getActiveBreakpoints(1440).find((bp) => bp.name === 'wide')?.isActive).toBe(true); + }); +}); + +describe('getElementBreakpointStyles', () => { + it('returns empty object when no breakpointStyles prop', () => { + const el = baseElement() as CanvasElement; + expect(getElementBreakpointStyles(el, 'mobile')).toEqual({}); + }); + + it('returns styles for the requested breakpoint', () => { + const bpStyles = JSON.stringify({ + mobile: { width: '100%', padding: '16px' }, + desktop: { width: '50%' }, + }); + const el = baseElement({ props: { breakpointStyles: bpStyles } }) as CanvasElement; + const mobile = getElementBreakpointStyles(el, 'mobile'); + expect(mobile).toEqual({ width: '100%', padding: '16px' }); + + const desktop = getElementBreakpointStyles(el, 'desktop'); + expect(desktop).toEqual({ width: '50%' }); + }); + + it('returns empty object for unknown breakpoint', () => { + const bpStyles = JSON.stringify({ mobile: { width: '100%' } }); + const el = baseElement({ props: { breakpointStyles: bpStyles } }) as CanvasElement; + expect(getElementBreakpointStyles(el, 'unknown')).toEqual({}); + }); + + it('returns empty object for invalid JSON', () => { + const el = baseElement({ props: { breakpointStyles: 'not-json' } }) as CanvasElement; + expect(getElementBreakpointStyles(el, 'mobile')).toEqual({}); + }); +}); + +// ─── Index barrel exports ────────────────────────────────────────── + +describe('barrel exports', () => { + it('exports all types and functions', async () => { + const mod = await import('@/lib/ferrum-studio'); + // Spot-check key exports + expect(typeof mod.createProject).toBe('function'); + expect(typeof mod.addElement).toBe('function'); + expect(typeof mod.createTimeline).toBe('function'); + expect(typeof mod.createToken).toBe('function'); + expect(typeof mod.exportToHTML).toBe('function'); + expect(typeof mod.exportToReact).toBe('function'); + expect(typeof mod.generateAnimationCSS).toBe('function'); + expect(typeof mod.getActiveBreakpoints).toBe('function'); + expect(Array.isArray(mod.STUDIO_BREAKPOINTS)).toBe(true); + expect(Array.isArray(mod.DEFAULT_TOKENS)).toBe(true); + }); +}); diff --git a/src/lib/ferrum-studio/breakpoints.ts b/src/lib/ferrum-studio/breakpoints.ts new file mode 100644 index 0000000..ff2fce5 --- /dev/null +++ b/src/lib/ferrum-studio/breakpoints.ts @@ -0,0 +1,58 @@ +/** + * @module ferrum-studio/breakpoints + * Responsive breakpoint definitions and utilities. + * Provides predefined viewport breakpoints, active-breakpoint detection, + * and per-element breakpoint style resolution. + */ + +import type { Breakpoint, CanvasElement } from './types'; + +/** + * Predefined responsive breakpoints for the studio canvas. + * Ordered from smallest to largest viewport width. + */ +export const STUDIO_BREAKPOINTS: Breakpoint[] = [ + { name: 'mobile', minWidth: 0, maxWidth: 639, isActive: false }, + { name: 'tablet', minWidth: 640, maxWidth: 1023, isActive: false }, + { name: 'desktop', minWidth: 1024, maxWidth: 1439, isActive: false }, + { name: 'wide', minWidth: 1440, maxWidth: Infinity, isActive: false }, +]; + +/** + * Determine which breakpoints are active for a given canvas/viewport width. + * A breakpoint is active if the width falls within its [minWidth, maxWidth] range. + * + * @param canvasWidth - The viewport width in pixels. + * @returns An array of breakpoints with isActive correctly set. + */ +export function getActiveBreakpoints(canvasWidth: number): Breakpoint[] { + return STUDIO_BREAKPOINTS.map((bp) => ({ + ...bp, + isActive: canvasWidth >= bp.minWidth && canvasWidth <= bp.maxWidth, + })); +} + +/** + * Resolve element styles for a specific breakpoint. + * Looks for a `breakpointStyles` prop on the element containing + * per-breakpoint style overrides. + * + * @param element - The canvas element. + * @param breakpoint - The breakpoint name (e.g. 'mobile', 'desktop'). + * @returns Style overrides for the breakpoint, or an empty object. + */ +export function getElementBreakpointStyles( + element: CanvasElement, + breakpoint: string, +): Record { + // Breakpoint styles stored as a JSON string in props + const raw = element.props['breakpointStyles']; + if (typeof raw !== 'string') return {}; + + try { + const parsed = JSON.parse(raw) as Record>; + return parsed[breakpoint] ?? {}; + } catch { + return {}; + } +} diff --git a/src/lib/ferrum-studio/export.ts b/src/lib/ferrum-studio/export.ts new file mode 100644 index 0000000..e6b391b --- /dev/null +++ b/src/lib/ferrum-studio/export.ts @@ -0,0 +1,287 @@ +/** + * @module ferrum-studio/export + * Export utilities for generating production-ready code from a StudioProject. + * Supports HTML, CSS, and React JSX output formats. + */ + +import type { StudioProject, CanvasElement, AnimationTimeline, TimelineKeyframe } from './types'; + +/** Map element type to semantic HTML tag. */ +function elementToTag(el: CanvasElement): string { + switch (el.type) { + case 'text': + return 'p'; + case 'image': + return 'img'; + case 'button': + return 'button'; + case 'card': + return 'section'; + case 'container': + return 'div'; + default: + return 'div'; + } +} + +/** Build a CSS style string from an element's style map. */ +function stylesToString(styles: Record): string { + return Object.entries(styles) + .map(([prop, val]) => ` ${prop}: ${val};`) + .join('\n'); +} + +/** Build inline style attribute value. */ +function inlineStyle(el: CanvasElement): string { + const parts: string[] = [ + `position: absolute`, + `left: ${el.x}px`, + `top: ${el.y}px`, + `width: ${el.width}px`, + `height: ${el.height}px`, + ]; + if (el.rotation !== 0) { + parts.push(`transform: rotate(${el.rotation}deg)`); + } + if (el.zIndex !== 0) { + parts.push(`z-index: ${el.zIndex}`); + } + for (const [prop, val] of Object.entries(el.styles)) { + parts.push(`${prop}: ${val}`); + } + return parts.join('; '); +} + +/** Generate a CSS class name from element id. */ +function elementClassName(el: CanvasElement): string { + return `el-${el.id.replace(/[^a-zA-Z0-9_-]/g, '_')}`; +} + +/** Render element content/children as HTML. */ +function renderElementContent(el: CanvasElement, indent: string): string { + if (el.children && el.children.length > 0) { + const childIndent = indent + ' '; + const children = el.children + .map((child) => renderElementHTML(child, childIndent)) + .join('\n'); + return `\n${children}\n${indent}`; + } + + // Self-closing for images + if (el.type === 'image') { + return ''; + } + + // Use text content from props + const text = el.props['text']; + if (typeof text === 'string') { + return text; + } + + return ''; +} + +/** Recursively render an element to HTML. */ +function renderElementHTML(el: CanvasElement, indent: string = ' '): string { + const tag = elementToTag(el); + const style = inlineStyle(el); + + if (el.type === 'image') { + const src = el.props['src'] ?? ''; + const alt = el.props['alt'] ?? ''; + return `${indent}<${tag} src="${src}" alt="${alt}" style="${style}" />`; + } + + const content = renderElementContent(el, indent); + return `${indent}<${tag} style="${style}">${content}`; +} + +/** + * Export a project as semantic HTML. + * @param project - The StudioProject to export. + * @returns A complete HTML document string. + */ +export function exportToHTML(project: StudioProject): string { + const body = project.elements + .map((el) => renderElementHTML(el)) + .join('\n'); + + return ` + + + + + ${project.name} + + +
+${body} +
+ +`; +} + +/** + * Export a project as CSS with design tokens and element styles. + * @param project - The StudioProject to export. + * @returns A CSS string with :root tokens and element class rules. + */ +export function exportToCSS(project: StudioProject): string { + const lines: string[] = []; + + // Design tokens as CSS custom properties + if (project.tokens.length > 0) { + const tokenLines = project.tokens.map( + (t) => ` --${t.category}-${t.name}: ${t.value};`, + ); + lines.push(`:root {\n${tokenLines.join('\n')}\n}`); + } + + // Element styles as classes + for (const el of project.elements) { + const className = elementClassName(el); + const styleBlock = stylesToString(el.styles); + if (styleBlock) { + lines.push(`.${className} {\n${styleBlock}\n}`); + } + } + + // Animation keyframes + if (project.timeline.keyframes.length > 0) { + lines.push(generateAnimationCSS(project.timeline)); + } + + return lines.join('\n\n'); +} + +/** + * Export a project as React JSX. + * @param project - The StudioProject to export. + * @returns A React component file as a string. + */ +export function exportToReact(project: StudioProject): string { + const elements = project.elements + .map((el) => renderReactElement(el, ' ')) + .join('\n'); + + return `import React from 'react'; + +/** + * ${project.name} +${project.description ? ` * ${project.description}` : ''} + */ +export default function ${toPascalCase(project.name)}() { + return ( +
+${elements} +
+ ); +} +`; +} + +/** Render an element as React JSX. */ +function renderReactElement(el: CanvasElement, indent: string = ' '): string { + const tag = elementToTag(el); + const style = reactStyleObject(el); + + if (el.type === 'image') { + const src = el.props['src'] ?? ''; + const alt = el.props['alt'] ?? ''; + return `${indent}<${tag} src="${src}" alt="${alt}" style={{${style}}} />`; + } + + const content = renderReactContent(el, indent); + return `${indent}<${tag} style={{${style}}}>${content}`; +} + +/** Generate React content/children. */ +function renderReactContent(el: CanvasElement, indent: string): string { + if (el.children && el.children.length > 0) { + const childIndent = indent + ' '; + const children = el.children + .map((child) => renderReactElement(child, childIndent)) + .join('\n'); + return `\n${children}\n${indent}`; + } + if (el.type === 'image') return ''; + const text = el.props['text']; + if (typeof text === 'string') return text; + return ''; +} + +/** Build a React style object string from element properties. */ +function reactStyleObject(el: CanvasElement): string { + const parts: string[] = [ + `position: 'absolute'`, + `left: ${el.x}`, + `top: ${el.y}`, + `width: ${el.width}`, + `height: ${el.height}`, + ]; + if (el.rotation !== 0) { + parts.push(`transform: 'rotate(${el.rotation}deg)'`); + } + if (el.zIndex !== 0) { + parts.push(`zIndex: ${el.zIndex}`); + } + for (const [prop, val] of Object.entries(el.styles)) { + parts.push(`'${prop}': '${val}'`); + } + return parts.join(', '); +} + +/** Convert a name to PascalCase for React component naming. */ +function toPascalCase(name: string): string { + return name + .replace(/[^a-zA-Z0-9\s]/g, '') + .split(/\s+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(''); +} + +/** + * Generate a CSS @keyframes block from an animation timeline. + * Groups keyframes by element, producing one @keyframes rule per element. + * + * @param timeline - The AnimationTimeline to convert. + * @returns A CSS string with @keyframes rules. + */ +export function generateAnimationCSS(timeline: AnimationTimeline): string { + if (timeline.keyframes.length === 0) return ''; + + // Group keyframes by element + const elementMap = new Map(); + for (const kf of timeline.keyframes) { + const existing = elementMap.get(kf.elementId) ?? []; + existing.push(kf); + elementMap.set(kf.elementId, existing); + } + + const rules: string[] = []; + + for (const [elementId, keyframes] of elementMap) { + const sorted = [...keyframes].sort((a, b) => a.time - b.time); + const kfLines = sorted + .map((kf) => { + const pct = Math.round((kf.time / timeline.duration) * 100); + const props = Object.entries(kf.properties) + .map(([prop, val]) => ` ${prop}: ${val};`) + .join('\n'); + return ` ${pct}% {\n${props}\n }`; + }) + .join('\n'); + + rules.push(`@keyframes ${elementId} {\n${kfLines}\n}`); + } + + return rules.join('\n\n'); +} diff --git a/src/lib/ferrum-studio/index.ts b/src/lib/ferrum-studio/index.ts new file mode 100644 index 0000000..9fdeb34 --- /dev/null +++ b/src/lib/ferrum-studio/index.ts @@ -0,0 +1,71 @@ +/** + * @module ferrum-studio + * Ferrum Studio — foundational data model and utilities for the visual editor. + * This is the barrel export; import from `@/lib/ferrum-studio`. + */ + +// Types +export type { + ElementType, + CanvasElement, + TimelineKeyframe, + AnimationTimeline, + TokenType, + DesignToken, + ExportFormat, + StudioExport, + Breakpoint, + CanvasConfig, + StudioProject, +} from './types'; + +// Project management +export { + createProject, + addElement, + removeElement, + moveElement, + resizeElement, + getElement, + findElementAt, + duplicateElement, + bringToFront, + sendToBack, + _resetIdCounter, +} from './project'; + +// Timeline +export { + createTimeline, + addKeyframe, + removeKeyframe, + getKeyframesForElement, + getInterpolatedProps, + sortKeyframes, + _resetKfCounter, +} from './timeline'; + +// Design tokens +export { + createToken, + updateToken, + tokenToCSS, + tokensToCSS, + DEFAULT_TOKENS, + _resetTkCounter, +} from './tokens'; + +// Export +export { + exportToHTML, + exportToCSS, + exportToReact, + generateAnimationCSS, +} from './export'; + +// Breakpoints +export { + STUDIO_BREAKPOINTS, + getActiveBreakpoints, + getElementBreakpointStyles, +} from './breakpoints'; diff --git a/src/lib/ferrum-studio/project.ts b/src/lib/ferrum-studio/project.ts new file mode 100644 index 0000000..c9fd13d --- /dev/null +++ b/src/lib/ferrum-studio/project.ts @@ -0,0 +1,253 @@ +/** + * @module ferrum-studio/project + * Project and canvas element management utilities. + * Provides CRUD operations for projects and their elements, including + * hit-testing, z-ordering, and duplication. + */ + +import type { + StudioProject, + CanvasElement, + AnimationTimeline, + CanvasConfig, +} from './types'; + +let _idCounter = 0; + +/** Generate a unique ID string. */ +function uid(): string { + _idCounter += 1; + return `fs_${Date.now().toString(36)}_${(_idCounter).toString(36)}`; +} + +/** Reset the internal ID counter (exposed for testing). */ +export function _resetIdCounter(): void { + _idCounter = 0; +} + +/** + * Create a new StudioProject with default canvas and timeline. + * @param name - Project name. + * @param description - Optional description. + * @returns A fresh StudioProject instance. + */ +export function createProject(name: string, description?: string): StudioProject { + const now = new Date().toISOString(); + const canvas: CanvasConfig = { + width: 1280, + height: 720, + background: '#ffffff', + }; + const timeline: AnimationTimeline = { + duration: 1000, + keyframes: [], + loop: false, + direction: 'normal', + }; + return { + id: uid(), + name, + description: description ?? '', + canvas, + elements: [], + timeline, + tokens: [], + createdAt: now, + updatedAt: now, + }; +} + +/** + * Add a new element to the project canvas. + * @param project - Target project (mutated in-place). + * @param element - Element data without an id. + * @returns The newly created CanvasElement with a generated id. + */ +export function addElement( + project: StudioProject, + element: Omit, +): CanvasElement { + const newElement: CanvasElement = { + ...element, + id: uid(), + }; + project.elements.push(newElement); + project.updatedAt = new Date().toISOString(); + return newElement; +} + +/** + * Remove an element from the project by id. + * @param project - Target project. + * @param elementId - The id of the element to remove. + * @returns A new project with the element removed. + */ +export function removeElement( + project: StudioProject, + elementId: string, +): StudioProject { + return { + ...project, + elements: project.elements.filter((el) => el.id !== elementId), + updatedAt: new Date().toISOString(), + }; +} + +/** + * Move an element to a new position. + * @param project - Target project. + * @param elementId - The id of the element to move. + * @param x - New horizontal position. + * @param y - New vertical position. + * @returns A new project with the element repositioned. + */ +export function moveElement( + project: StudioProject, + elementId: string, + x: number, + y: number, +): StudioProject { + return { + ...project, + elements: project.elements.map((el) => + el.id === elementId ? { ...el, x, y } : el, + ), + updatedAt: new Date().toISOString(), + }; +} + +/** + * Resize an element. + * @param project - Target project. + * @param elementId - The id of the element to resize. + * @param width - New width. + * @param height - New height. + * @returns A new project with the element resized. + */ +export function resizeElement( + project: StudioProject, + elementId: string, + width: number, + height: number, +): StudioProject { + return { + ...project, + elements: project.elements.map((el) => + el.id === elementId ? { ...el, width, height } : el, + ), + updatedAt: new Date().toISOString(), + }; +} + +/** + * Retrieve an element by id. + * @param project - Target project. + * @param elementId - The id to look up. + * @returns The matching CanvasElement, or undefined. + */ +export function getElement( + project: StudioProject, + elementId: string, +): CanvasElement | undefined { + return project.elements.find((el) => el.id === elementId); +} + +/** + * Find the topmost element at a given canvas coordinate using bounding-box + * collision detection. Checks elements in reverse z-order (topmost first). + * @param project - Target project. + * @param x - Horizontal canvas coordinate. + * @param y - Vertical canvas coordinate. + * @returns The topmost CanvasElement under the point, or undefined. + */ +export function findElementAt( + project: StudioProject, + x: number, + y: number, +): CanvasElement | undefined { + const sorted = [...project.elements].sort((a, b) => b.zIndex - a.zIndex); + for (const el of sorted) { + if (x >= el.x && x <= el.x + el.width && y >= el.y && y <= el.y + el.height) { + return el; + } + } + return undefined; +} + +/** + * Duplicate an element, placing the copy offset by (20, 20). + * @param project - Target project (mutated in-place). + * @param elementId - The id of the element to duplicate. + * @returns The new duplicated CanvasElement, or null if not found. + */ +export function duplicateElement( + project: StudioProject, + elementId: string, +): CanvasElement | null { + const source = getElement(project, elementId); + if (!source) return null; + + const copy: CanvasElement = { + ...source, + id: uid(), + x: source.x + 20, + y: source.y + 20, + props: { ...source.props }, + styles: { ...source.styles }, + children: source.children + ? source.children.map((child) => ({ + ...child, + id: uid(), + props: { ...child.props }, + styles: { ...child.styles }, + })) + : undefined, + }; + + project.elements.push(copy); + project.updatedAt = new Date().toISOString(); + return copy; +} + +/** + * Move an element to the top of the z-order stack. + * @param project - Target project. + * @param elementId - The id of the element to bring forward. + * @returns A new project with updated z-indices. + */ +export function bringToFront( + project: StudioProject, + elementId: string, +): StudioProject { + const maxZ = project.elements.reduce((max, el) => Math.max(max, el.zIndex), 0); + return { + ...project, + elements: project.elements.map((el) => + el.id === elementId ? { ...el, zIndex: maxZ + 1 } : el, + ), + updatedAt: new Date().toISOString(), + }; +} + +/** + * Move an element to the bottom of the z-order stack. + * @param project - Target project. + * @param elementId - The id of the element to send backward. + * @returns A new project with updated z-indices. + */ +export function sendToBack( + project: StudioProject, + elementId: string, +): StudioProject { + const minZ = project.elements.reduce( + (min, el) => Math.min(min, el.zIndex), + Infinity, + ); + return { + ...project, + elements: project.elements.map((el) => + el.id === elementId ? { ...el, zIndex: minZ - 1 } : el, + ), + updatedAt: new Date().toISOString(), + }; +} diff --git a/src/lib/ferrum-studio/timeline.ts b/src/lib/ferrum-studio/timeline.ts new file mode 100644 index 0000000..1fdbd43 --- /dev/null +++ b/src/lib/ferrum-studio/timeline.ts @@ -0,0 +1,179 @@ +/** + * @module ferrum-studio/timeline + * Animation timeline utilities. + * Provides keyframe CRUD, per-element queries, linear interpolation, + * and timeline sorting. + */ + +import type { AnimationTimeline, TimelineKeyframe } from './types'; + +let _kfCounter = 0; + +/** Generate a unique keyframe id. */ +function kfId(): string { + _kfCounter += 1; + return `kf_${Date.now().toString(36)}_${(_kfCounter).toString(36)}`; +} + +/** Reset the internal keyframe ID counter (exposed for testing). */ +export function _resetKfCounter(): void { + _kfCounter = 0; +} + +/** + * Create a new empty animation timeline. + * @param duration - Total duration in milliseconds (default 1000). + * @returns A fresh AnimationTimeline. + */ +export function createTimeline(duration: number = 1000): AnimationTimeline { + return { + duration, + keyframes: [], + loop: false, + direction: 'normal', + }; +} + +/** + * Add a keyframe to the timeline. + * @param timeline - Target timeline. + * @param kf - Keyframe data without an id. + * @returns A new timeline with the keyframe appended. + */ +export function addKeyframe( + timeline: AnimationTimeline, + kf: Omit, +): AnimationTimeline { + return { + ...timeline, + keyframes: [ + ...timeline.keyframes, + { ...kf, id: kfId() }, + ], + }; +} + +/** + * Remove a keyframe from the timeline by id. + * @param timeline - Target timeline. + * @param keyframeId - The id of the keyframe to remove. + * @returns A new timeline without the keyframe. + */ +export function removeKeyframe( + timeline: AnimationTimeline, + keyframeId: string, +): AnimationTimeline { + return { + ...timeline, + keyframes: timeline.keyframes.filter((kf) => kf.id !== keyframeId), + }; +} + +/** + * Get all keyframes for a specific element, sorted by time. + * @param timeline - Target timeline. + * @param elementId - The element id to filter by. + * @returns Sorted array of keyframes for the element. + */ +export function getKeyframesForElement( + timeline: AnimationTimeline, + elementId: string, +): TimelineKeyframe[] { + return timeline.keyframes + .filter((kf) => kf.elementId === elementId) + .sort((a, b) => a.time - b.time); +} + +/** + * Interpolate property values for a specific element at a given time. + * Performs linear interpolation for numeric values; snaps to the nearest + * keyframe for string values. + * + * @param timeline - Target timeline. + * @param elementId - The element id. + * @param time - The time position in milliseconds. + * @returns Interpolated property map. + */ +export function getInterpolatedProps( + timeline: AnimationTimeline, + elementId: string, + time: number, +): Record { + const keyframes = getKeyframesForElement(timeline, elementId); + if (keyframes.length === 0) return {}; + + // Clamp time within timeline bounds + const t = Math.max(0, Math.min(time, timeline.duration)); + + // Before first keyframe → return first keyframe values + if (t <= keyframes[0]!.time) { + return { ...keyframes[0]!.properties }; + } + + // After last keyframe → return last keyframe values + if (t >= keyframes[keyframes.length - 1]!.time) { + return { ...keyframes[keyframes.length - 1]!.properties }; + } + + // Find surrounding keyframes + let prev = keyframes[0]!; + let next = keyframes[1]!; + for (let i = 0; i < keyframes.length - 1; i++) { + const current = keyframes[i]!; + const upcoming = keyframes[i + 1]!; + if (t >= current.time && t <= upcoming.time) { + prev = current; + next = upcoming; + break; + } + } + + const range = next.time - prev.time; + const progress = range === 0 ? 0 : (t - prev.time) / range; + + const result: Record = {}; + const allKeys = new Set([ + ...Object.keys(prev.properties), + ...Object.keys(next.properties), + ]); + + for (const key of allKeys) { + const prevVal = prev.properties[key]; + const nextVal = next.properties[key]; + + // If property only exists in one keyframe, use that value directly + if (prevVal === undefined) { + result[key] = nextVal!; + continue; + } + if (nextVal === undefined) { + result[key] = prevVal; + continue; + } + + // Both values are numbers → linear interpolation + if (typeof prevVal === 'number' && typeof nextVal === 'number') { + result[key] = prevVal + (nextVal - prevVal) * progress; + continue; + } + + // Non-numeric → snap to nearest keyframe + result[key] = progress < 0.5 ? prevVal : nextVal; + } + + return result; +} + +/** + * Sort all keyframes in the timeline by time (stable sort). + * @param timeline - Target timeline. + * @returns A new timeline with sorted keyframes. + */ +export function sortKeyframes( + timeline: AnimationTimeline, +): AnimationTimeline { + return { + ...timeline, + keyframes: [...timeline.keyframes].sort((a, b) => a.time - b.time), + }; +} diff --git a/src/lib/ferrum-studio/tokens.ts b/src/lib/ferrum-studio/tokens.ts new file mode 100644 index 0000000..0e6e73c --- /dev/null +++ b/src/lib/ferrum-studio/tokens.ts @@ -0,0 +1,164 @@ +/** + * @module ferrum-studio/tokens + * Design token management utilities. + * Provides token CRUD, CSS custom property generation, + * and a sensible set of default design tokens. + */ + +import type { DesignToken, TokenType } from './types'; + +let _tkCounter = 0; + +/** Generate a unique token id. */ +function tkId(): string { + _tkCounter += 1; + return `tk_${Date.now().toString(36)}_${(_tkCounter).toString(36)}`; +} + +/** Reset the internal token ID counter (exposed for testing). */ +export function _resetTkCounter(): void { + _tkCounter = 0; +} + +/** + * Create a new design token. + * @param name - Token name (e.g. 'primary-500'). + * @param value - Token value (e.g. '#3b82f6', '16px'). + * @param type - The token type. + * @param category - Grouping category (defaults to the type name). + * @returns A new DesignToken. + */ +export function createToken( + name: string, + value: string, + type: TokenType, + category?: string, +): DesignToken { + return { + id: tkId(), + name, + value, + type, + category: category ?? type, + }; +} + +/** + * Update properties on an existing token (immutable — returns a new object). + * @param token - The original token. + * @param updates - Partial updates to apply. + * @returns A new DesignToken with updates merged. + */ +export function updateToken( + token: DesignToken, + updates: Partial>, +): DesignToken { + return { ...token, ...updates }; +} + +/** + * Convert a single token to a CSS custom property declaration. + * Uses the naming convention `--{category}-{name}`. + * @param token - The design token. + * @returns A CSS custom property string, e.g. `--colors-primary-500: #3b82f6;` + */ +export function tokenToCSS(token: DesignToken): string { + const varName = `--${token.category}-${token.name}`; + return ` ${varName}: ${token.value};`; +} + +/** + * Generate a :root CSS block containing all tokens as custom properties. + * Tokens are grouped by category for readability. + * @param tokens - Array of design tokens. + * @returns A formatted CSS :root block. + */ +export function tokensToCSS(tokens: DesignToken[]): string { + if (tokens.length === 0) return ':root {}'; + + const lines = tokens.map(tokenToCSS); + return `:root { +${lines.join('\n')} +}`; +} + +/** + * Default design tokens providing a sensible starting palette. + * Includes primary/secondary colors, a spacing scale, typography sizes, + * border radii, shadows, and opacity levels. + */ +export const DEFAULT_TOKENS: DesignToken[] = [ + // Primary colors + createToken('primary-50', '#eff6ff', 'color', 'colors'), + createToken('primary-100', '#dbeafe', 'color', 'colors'), + createToken('primary-200', '#bfdbfe', 'color', 'colors'), + createToken('primary-300', '#93c5fd', 'color', 'colors'), + createToken('primary-400', '#60a5fa', 'color', 'colors'), + createToken('primary-500', '#3b82f6', 'color', 'colors'), + createToken('primary-600', '#2563eb', 'color', 'colors'), + createToken('primary-700', '#1d4ed8', 'color', 'colors'), + createToken('primary-800', '#1e40af', 'color', 'colors'), + createToken('primary-900', '#1e3a8a', 'color', 'colors'), + + // Secondary colors + createToken('secondary-50', '#f5f3ff', 'color', 'colors'), + createToken('secondary-100', '#ede9fe', 'color', 'colors'), + createToken('secondary-500', '#8b5cf6', 'color', 'colors'), + createToken('secondary-700', '#6d28d9', 'color', 'colors'), + createToken('secondary-900', '#4c1d95', 'color', 'colors'), + + // Neutral colors + createToken('neutral-50', '#f9fafb', 'color', 'colors'), + createToken('neutral-100', '#f3f4f6', 'color', 'colors'), + createToken('neutral-200', '#e5e7eb', 'color', 'colors'), + createToken('neutral-300', '#d1d5db', 'color', 'colors'), + createToken('neutral-500', '#6b7280', 'color', 'colors'), + createToken('neutral-700', '#374151', 'color', 'colors'), + createToken('neutral-900', '#111827', 'color', 'colors'), + + // Spacing scale + createToken('1', '0.25rem', 'spacing', 'spacing'), + createToken('2', '0.5rem', 'spacing', 'spacing'), + createToken('3', '0.75rem', 'spacing', 'spacing'), + createToken('4', '1rem', 'spacing', 'spacing'), + createToken('5', '1.25rem', 'spacing', 'spacing'), + createToken('6', '1.5rem', 'spacing', 'spacing'), + createToken('8', '2rem', 'spacing', 'spacing'), + createToken('10', '2.5rem', 'spacing', 'spacing'), + createToken('12', '3rem', 'spacing', 'spacing'), + createToken('16', '4rem', 'spacing', 'spacing'), + createToken('20', '5rem', 'spacing', 'spacing'), + createToken('24', '6rem', 'spacing', 'spacing'), + + // Typography + createToken('text-xs', '0.75rem', 'typography', 'typography'), + createToken('text-sm', '0.875rem', 'typography', 'typography'), + createToken('text-base', '1rem', 'typography', 'typography'), + createToken('text-lg', '1.125rem', 'typography', 'typography'), + createToken('text-xl', '1.25rem', 'typography', 'typography'), + createToken('text-2xl', '1.5rem', 'typography', 'typography'), + createToken('text-3xl', '1.875rem', 'typography', 'typography'), + createToken('text-4xl', '2.25rem', 'typography', 'typography'), + createToken('font-sans', 'ui-sans-serif, system-ui, sans-serif', 'typography', 'typography'), + createToken('font-mono', 'ui-monospace, monospace', 'typography', 'typography'), + + // Border radius + createToken('radius-sm', '0.25rem', 'border', 'border'), + createToken('radius-md', '0.375rem', 'border', 'border'), + createToken('radius-lg', '0.5rem', 'border', 'border'), + createToken('radius-xl', '0.75rem', 'border', 'border'), + createToken('radius-full', '9999px', 'border', 'border'), + + // Shadows + createToken('shadow-sm', '0 1px 2px 0 rgb(0 0 0 / 0.05)', 'shadow', 'shadow'), + createToken('shadow-md', '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 'shadow', 'shadow'), + createToken('shadow-lg', '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', 'shadow', 'shadow'), + createToken('shadow-xl', '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', 'shadow', 'shadow'), + + // Opacity + createToken('opacity-0', '0', 'opacity', 'opacity'), + createToken('opacity-25', '0.25', 'opacity', 'opacity'), + createToken('opacity-50', '0.5', 'opacity', 'opacity'), + createToken('opacity-75', '0.75', 'opacity', 'opacity'), + createToken('opacity-100', '1', 'opacity', 'opacity'), +]; diff --git a/src/lib/ferrum-studio/types.ts b/src/lib/ferrum-studio/types.ts new file mode 100644 index 0000000..13f6cea --- /dev/null +++ b/src/lib/ferrum-studio/types.ts @@ -0,0 +1,150 @@ +/** + * @module ferrum-studio/types + * Core type definitions for the Ferrum Studio visual editor. + * Defines the data model for projects, canvas elements, animation timelines, + * design tokens, export targets, and responsive breakpoints. + */ + +/** Supported element types on the studio canvas. */ +export type ElementType = + | 'box' + | 'text' + | 'image' + | 'button' + | 'card' + | 'container' + | 'custom'; + +/** A single element placed on the canvas. */ +export interface CanvasElement { + /** Unique element identifier. */ + id: string; + /** The kind of element (box, text, image, etc.). */ + type: ElementType; + /** Horizontal position in canvas pixels. */ + x: number; + /** Vertical position in canvas pixels. */ + y: number; + /** Width in pixels. */ + width: number; + /** Height in pixels. */ + height: number; + /** Rotation in degrees (0-360). */ + rotation: number; + /** Stacking order — higher values render on top. */ + zIndex: number; + /** Element-specific properties (e.g. text content, src URL). */ + props: Record; + /** Inline style overrides keyed by CSS property name. */ + styles: Record; + /** Optional nested children (used by container-type elements). */ + children?: CanvasElement[]; +} + +/** A single keyframe on the animation timeline. */ +export interface TimelineKeyframe { + /** Unique keyframe identifier. */ + id: string; + /** The element this keyframe belongs to. */ + elementId: string; + /** Time position in milliseconds. */ + time: number; + /** CSS property values at this point in time. */ + properties: Record; + /** Easing function name (e.g. 'ease-in-out', 'cubic-bezier(...)'). */ + easing?: string; +} + +/** Animation timeline attached to a project. */ +export interface AnimationTimeline { + /** Total duration in milliseconds. */ + duration: number; + /** Ordered list of keyframes across all elements. */ + keyframes: TimelineKeyframe[]; + /** Whether the animation loops. */ + loop: boolean; + /** Playback direction: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'. */ + direction: string; +} + +/** Design token value types. */ +export type TokenType = + | 'color' + | 'spacing' + | 'typography' + | 'border' + | 'shadow' + | 'opacity'; + +/** A single design token. */ +export interface DesignToken { + /** Unique token identifier. */ + id: string; + /** Human-readable token name (e.g. 'primary-500'). */ + name: string; + /** The token value (e.g. '#3b82f6', '16px'). */ + value: string; + /** The kind of design token. */ + type: TokenType; + /** Grouping category (e.g. 'colors', 'spacing', 'typography'). */ + category: string; + /** Optional description for documentation. */ + description?: string; +} + +/** Supported export output formats. */ +export type ExportFormat = 'react' | 'vue' | 'svelte' | 'html' | 'css'; + +/** Export result containing generated code and asset references. */ +export interface StudioExport { + /** The target format. */ + format: ExportFormat; + /** The generated source code. */ + code: string; + /** Referenced asset identifiers. */ + assets: string[]; +} + +/** A responsive breakpoint definition. */ +export interface Breakpoint { + /** Human-readable name (e.g. 'mobile', 'tablet'). */ + name: string; + /** Minimum viewport width in pixels (inclusive). */ + minWidth: number; + /** Maximum viewport width in pixels (inclusive). Use Infinity for unbounded. */ + maxWidth: number; + /** Whether this breakpoint is currently active for a given canvas width. */ + isActive: boolean; +} + +/** Canvas configuration attached to a project. */ +export interface CanvasConfig { + /** Canvas width in pixels. */ + width: number; + /** Canvas height in pixels. */ + height: number; + /** Background color or CSS value. */ + background: string; +} + +/** Top-level Studio project. */ +export interface StudioProject { + /** Unique project identifier. */ + id: string; + /** Project name. */ + name: string; + /** Optional project description. */ + description: string; + /** Canvas configuration. */ + canvas: CanvasConfig; + /** Elements on the canvas. */ + elements: CanvasElement[]; + /** Animation timeline. */ + timeline: AnimationTimeline; + /** Design tokens. */ + tokens: DesignToken[]; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-modified timestamp. */ + updatedAt: string; +}