From 643eede3961f929c8b4b80254ff61ca20581425b Mon Sep 17 00:00:00 2001 From: Eros Taborelli Date: Wed, 19 Nov 2025 17:07:21 +0100 Subject: [PATCH 01/12] feat(framed): initial implementation of new v2.0.0 layout --- package.json | 2 +- src/App.vue | 146 ++++++--- src/components/controls/DownloadButton.vue | 6 +- src/components/controls/ResetButton.vue | 6 +- src/components/layout/ActionBar.vue | 55 ++-- src/components/layout/CanvasContainer.vue | 15 +- src/components/layout/ConfigBar.vue | 66 ++-- tests/unit/App.test.js | 298 +++++++++++++----- .../unit/components/layout/ActionBar.test.js | 11 +- .../components/layout/CanvasContainer.test.js | 4 +- .../unit/components/layout/ConfigBar.test.js | 50 ++- 11 files changed, 424 insertions(+), 235 deletions(-) diff --git a/package.json b/package.json index 5e0a840..715eb87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "erost-framed", - "version": "1.1.0", + "version": "2.0.0", "description": "Canvas-based picture framing application", "type": "module", "scripts": { diff --git a/src/App.vue b/src/App.vue index af8c86a..9723210 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,69 +3,79 @@ Picture Frame Creator - Main application layout --> diff --git a/tests/unit/App.test.js b/tests/unit/App.test.js index b9686ee..6598f30 100644 --- a/tests/unit/App.test.js +++ b/tests/unit/App.test.js @@ -1,8 +1,28 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; import App from '@/App.vue'; import { PREVIEW_CONSTRAINTS } from '@/utils/constants'; +// Mock useFrameConfig composable +vi.mock('@/composables/useFrameConfig', () => ({ + useFrameConfig: () => ({ + frameWidth: ref(1000), + frameHeight: ref(750), + orientation: ref('landscape'), + aspectRatio: ref('4:3'), + frameSize: ref(100), + spacing: ref(20), + frameColor: ref('#ffffff'), + backgroundColor: ref('#000000'), + updateBackgroundColor: vi.fn(), + updateAspectRatio: vi.fn(), + updateFrameSize: vi.fn(), + updateSpacing: vi.fn(), + reset: vi.fn(), + }), +})); + describe('App.vue - Window Resize', () => { let mockResizeObserver; let resizeObserverCallback; @@ -24,6 +44,28 @@ describe('App.vue - Window Resize', () => { return id; }); global.cancelAnimationFrame = vi.fn(); + + // Mock window.matchMedia + global.window.matchMedia = vi.fn((query) => ({ + matches: query === '(min-width: 768px)', + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + // Mock window dimensions (default to desktop size) + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1280, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, + }); }); afterEach(() => { @@ -31,105 +73,156 @@ describe('App.vue - Window Resize', () => { }); describe('updatePreviewWidth()', () => { - it('does nothing when mainContentRef is not set', () => { + it('calculates preview width from window dimensions', () => { const wrapper = mount(App); - const initialWidth = wrapper.vm.previewWidth; - - wrapper.vm.mainContentRef = null; - wrapper.vm.updatePreviewWidth(); - expect(wrapper.vm.previewWidth).toBe(initialWidth); - }); + // Mock window dimensions + window.innerWidth = 1280; + window.innerHeight = 800; - it('calculates preview width from container width', () => { - const wrapper = mount(App); - - // Mock mainContentRef with specific width - wrapper.vm.mainContentRef = { - clientWidth: 1000, + // Mock asideRef with sidebar width (desktop: 320px) + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 1000px - // Effective width: min(1000, 1024) = 1000 - // Available width: 1000 - 32 - 48 = 920 - // Preview width: min(920, 800) = 800 + // Desktop mode: Window: 1280px x 800px, Aside: 320px wide + // Available width: 1280 - 320 = 960, minus padding: 960 - 48 = 912 + // Available height: 800, minus padding: 800 - 48 = 752 + // Frame: 1000px x 750px + // Scale by width: 912 / 1000 = 0.912 + // Scale by height: 752 / 750 = 1.0027 + // Use min scale: 0.912 + // Scaled width: 1000 * 0.912 = 912 + // Preview width: min(912, 800) = 800 expect(wrapper.vm.previewWidth).toBe(800); }); - it('caps at max width of 1024px', () => { + it('caps at max width of 800px', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 2000, + // Large window + window.innerWidth = 2000; + window.innerHeight = 1500; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 2000px - // Effective width: min(2000, 1024) = 1024 - // Available width: 1024 - 32 - 48 = 944 - // Preview width: min(944, 800) = 800 + // Desktop mode: Window: 2000px x 1500px, Aside: 320px + // Available width: 2000 - 320 - 48 = 1632 + // Available height: 1500 - 48 = 1452 + // Frame: 1000px x 750px + // Scale by width: 1632 / 1000 = 1.632 + // Scale by height: 1452 / 750 = 1.936 + // Use min scale: 1.632 + // Scaled width: 1000 * 1.632 = 1632 + // Preview width: min(1632, 800) = 800 (capped) expect(wrapper.vm.previewWidth).toBe(800); }); - it('subtracts padding from available width', () => { + it('subtracts padding and aside from available space', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 500, + // Small window + window.innerWidth = 820; + window.innerHeight = 600; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 500px - // Effective width: min(500, 1024) = 500 - // Available width: 500 - 32 - 48 = 420 - // Preview width: min(420, 800) = 420 - expect(wrapper.vm.previewWidth).toBe(420); + // Desktop mode: Window: 820px x 600px, Aside: 320px + // Available width: 820 - 320 - 48 = 452 + // Available height: 600 - 48 = 552 + // Frame: 1000px x 750px + // Scale by width: 452 / 1000 = 0.452 + // Scale by height: 552 / 750 = 0.736 + // Use min scale: 0.452 + // Scaled width: 1000 * 0.452 = 452 + expect(wrapper.vm.previewWidth).toBe(452); }); it('caps at PREVIEW_CONSTRAINTS.defaultWidth', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 1024, + window.innerWidth = 1520; + window.innerHeight = 900; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 1024px - // Effective width: min(1024, 1024) = 1024 - // Available width: 1024 - 32 - 48 = 944 - // Preview width: min(944, 800) = 800 + // Desktop mode: Window: 1520px x 900px, Aside: 320px + // Available width: 1520 - 320 - 48 = 1152 + // Available height: 900 - 48 = 852 + // Frame: 1000px x 750px + // Scale by width: 1152 / 1000 = 1.152 + // Scale by height: 852 / 750 = 1.136 + // Use min scale: 1.136 + // Scaled width: 1000 * 1.136 = 1136 + // Preview width: min(1136, 800) = 800 (capped) expect(wrapper.vm.previewWidth).toBe(PREVIEW_CONSTRAINTS.defaultWidth); }); - it('handles small container widths', () => { + it('handles mobile mode with bottom aside', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 200, + // Mock mobile mode for small screens + global.window.matchMedia = vi.fn((query) => ({ + matches: false, // Mobile mode + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + window.innerWidth = 400; + window.innerHeight = 800; + + wrapper.vm.asideRef = { + clientWidth: 400, + clientHeight: 150, // Bottom aside height }; wrapper.vm.updatePreviewWidth(); - // Container: 200px - // Effective width: min(200, 1024) = 200 - // Available width: 200 - 32 - 48 = 120 - // Preview width: min(120, 800) = 120 - expect(wrapper.vm.previewWidth).toBe(120); + // Mobile mode: Window: 400px x 800px, Aside: 150px height at bottom + // Available width: 400 - 32 = 368 + // Available height: 800 - 150 - 32 = 618 + // Frame: 1000px x 750px + // Scale by width: 368 / 1000 = 0.368 + // Scale by height: 618 / 750 = 0.824 + // Use min scale: 0.368 + // Scaled width: 1000 * 0.368 = 368 + expect(wrapper.vm.previewWidth).toBe(368); }); it('handles exact default width scenario', () => { const wrapper = mount(App); // To get exactly 800px preview width: - // Available width = 800 - // Container width = 800 + 32 + 48 = 880 - wrapper.vm.mainContentRef = { - clientWidth: 880, + // Need scale of 0.8 (800 / 1000) + // Available width needed: 1000 * 0.8 = 800 + // Window width: 800 + 320 (aside) + 48 (padding) = 1168 + window.innerWidth = 1168; + window.innerHeight = 900; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); @@ -142,9 +235,12 @@ describe('App.vue - Window Resize', () => { it('calls updatePreviewWidth on mount', () => { const wrapper = mount(App); - // Mock mainContentRef - wrapper.vm.mainContentRef = { - clientWidth: 1000, + window.innerWidth = 1280; + window.innerHeight = 800; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; // Manually trigger mounted behavior @@ -175,7 +271,9 @@ describe('App.vue - Window Resize', () => { await wrapper.vm.$nextTick(); if (resizeObserverCallback) { - wrapper.vm.mainContentRef = { clientWidth: 1000 }; + window.innerWidth = 1280; + window.innerHeight = 800; + wrapper.vm.asideRef = { clientWidth: 320, clientHeight: 100 }; resizeObserverCallback(); expect(global.requestAnimationFrame).toHaveBeenCalled(); } @@ -186,7 +284,9 @@ describe('App.vue - Window Resize', () => { await wrapper.vm.$nextTick(); if (resizeObserverCallback) { - wrapper.vm.mainContentRef = { clientWidth: 1000 }; + window.innerWidth = 1280; + window.innerHeight = 800; + wrapper.vm.asideRef = { clientWidth: 320, clientHeight: 100 }; // First resize resizeObserverCallback(); @@ -300,7 +400,9 @@ describe('App.vue - Window Resize', () => { await wrapper.vm.$nextTick(); if (resizeObserverCallback) { - wrapper.vm.mainContentRef = { clientWidth: 1000 }; + window.innerWidth = 1280; + window.innerHeight = 800; + wrapper.vm.asideRef = { clientWidth: 320, clientHeight: 100 }; // Simulate rapid resize events resizeObserverCallback(); @@ -317,62 +419,96 @@ describe('App.vue - Window Resize', () => { await wrapper.vm.$nextTick(); if (resizeObserverCallback) { - - - wrapper.vm.mainContentRef = { clientWidth: 600 }; + window.innerWidth = 920; + window.innerHeight = 800; + wrapper.vm.asideRef = { clientWidth: 320, clientHeight: 100 }; resizeObserverCallback(); // Width should be updated (via requestAnimationFrame callback) - // Available width: 600 - 32 - 48 = 520 - expect(wrapper.vm.previewWidth).toBe(520); + // Desktop mode: Window: 920px x 800px, Aside: 320px + // Available width: 920 - 320 - 48 = 552 + // Available height: 800 - 48 = 752 + // Frame: 1000px x 750px + // Scale by width: 552 / 1000 = 0.552 + // Scale by height: 752 / 750 = 1.0027 + // Use min scale: 0.552 + // Scaled width: 1000 * 0.552 = 552 + expect(wrapper.vm.previewWidth).toBe(552); } }); }); describe('Edge Cases', () => { - it('handles zero width container', () => { + it('handles very small window width', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 0, + window.innerWidth = 368; + window.innerHeight = 800; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 0px - // Effective width: min(0, 1024) = 0 - // Available width: 0 - 32 - 48 = -80 - // Preview width: min(-80, 800) = -80 - expect(wrapper.vm.previewWidth).toBeLessThan(0); + // Desktop mode: Window: 368px x 800px, Aside: 320px + // Available width: 368 - 320 - 48 = 0 + // Available height: 800 - 48 = 752 + // Scale by width: 0 / 1000 = 0 + // Scale by height: 752 / 750 = 1.0027 + // Use min scale: 0 + // Scaled width: 1000 * 0 = 0 + expect(wrapper.vm.previewWidth).toBe(0); }); - it('handles very large container width', () => { + it('handles very large window dimensions', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 10000, + window.innerWidth = 10000; + window.innerHeight = 8000; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Still capped at 1024 max width, then 800 preview max + // Desktop mode: Window: 10000px x 8000px, Aside: 320px + // Available width: 10000 - 320 - 48 = 9632 + // Available height: 8000 - 48 = 7952 + // Frame: 1000px x 750px + // Scale by width: 9632 / 1000 = 9.632 + // Scale by height: 7952 / 750 = 10.603 + // Use min scale: 9.632 + // Scaled width: 1000 * 9.632 = 9632 + // Capped at 800 expect(wrapper.vm.previewWidth).toBe(800); }); - it('handles negative width scenarios gracefully', () => { + it('handles small window with aside', () => { const wrapper = mount(App); - wrapper.vm.mainContentRef = { - clientWidth: 50, // Less than padding total (80) + window.innerWidth = 620; + window.innerHeight = 600; + + wrapper.vm.asideRef = { + clientWidth: 320, + clientHeight: 100, }; wrapper.vm.updatePreviewWidth(); - // Container: 50px - // Effective width: min(50, 1024) = 50 - // Available width: 50 - 32 - 48 = -30 - // Preview width: min(-30, 800) = -30 - expect(wrapper.vm.previewWidth).toBe(-30); + // Desktop mode: Window: 620px x 600px, Aside: 320px + // Available width: 620 - 320 - 48 = 252 + // Available height: 600 - 48 = 552 + // Frame: 1000px x 750px + // Scale by width: 252 / 1000 = 0.252 + // Scale by height: 552 / 750 = 0.736 + // Use min scale: 0.252 + // Scaled width: 1000 * 0.252 = 252 + expect(wrapper.vm.previewWidth).toBe(252); }); }); }); diff --git a/tests/unit/components/layout/ActionBar.test.js b/tests/unit/components/layout/ActionBar.test.js index 423cbbe..3093e7d 100644 --- a/tests/unit/components/layout/ActionBar.test.js +++ b/tests/unit/components/layout/ActionBar.test.js @@ -52,8 +52,7 @@ describe('ActionBar', () => { expect(wrapper.find('[data-testid="download-button"]').exists()).toBe(true); }); - it('contains output controls and action buttons', () => { - expect(wrapper.find('[data-testid="output-action-bar"]').exists()).toBe(true); + it('contains action buttons section', () => { expect(wrapper.find('[data-testid="buttons-action-bar"]').exists()).toBe(true); }); }); @@ -88,10 +87,10 @@ describe('ActionBar', () => { }); describe('Component Organization', () => { - it('has quality and filename inputs in output section', () => { - const outputSection = wrapper.find('[data-testid="output-action-bar"]'); - expect(outputSection.find('[data-testid="quality-input"]').exists()).toBe(true); - expect(outputSection.find('[data-testid="file-name-input"]').exists()).toBe(true); + it('has output settings on desktop (hidden on mobile)', () => { + // Output settings (filename, format, quality) are in a hidden div on mobile + expect(wrapper.find('[data-testid="quality-input"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="file-name-input"]').exists()).toBe(true); }); it('has reset and download buttons in buttons section', () => { diff --git a/tests/unit/components/layout/CanvasContainer.test.js b/tests/unit/components/layout/CanvasContainer.test.js index def8de1..6cab8ca 100644 --- a/tests/unit/components/layout/CanvasContainer.test.js +++ b/tests/unit/components/layout/CanvasContainer.test.js @@ -39,12 +39,12 @@ describe('CanvasContainer', () => { expect(wrapper.find('[data-testid="frame-canvas"]').exists()).toBe(true); }); - it('has max width constraint', () => { + it('is full height', () => { wrapper = mount(CanvasContainer, { props: { previewWidth: 800 }, }); const container = wrapper.find('[data-testid="canvas-container"]'); - expect(container.classes()).toContain('max-w-[1024px]'); + expect(container.classes()).toContain('h-full'); }); it('is full width', () => { diff --git a/tests/unit/components/layout/ConfigBar.test.js b/tests/unit/components/layout/ConfigBar.test.js index 0890d8e..72f229f 100644 --- a/tests/unit/components/layout/ConfigBar.test.js +++ b/tests/unit/components/layout/ConfigBar.test.js @@ -50,38 +50,29 @@ describe('ConfigBar', () => { expect(wrapper.find('[data-testid="config-bar"]').exists()).toBe(true); }); - it('renders both configuration rows', () => { - expect(wrapper.find('[data-testid="row-orientation-aspect"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="row-frame-config"]').exists()).toBe(true); + it('renders all controls in a single container', () => { + // New structure has all controls in one flex container + const controls = wrapper.findAll('[data-testid]'); + expect(controls.length).toBeGreaterThan(0); }); }); - describe('Row 1: Orientation and Aspect Ratio', () => { + describe('Controls Layout', () => { it('renders orientation and aspect ratio controls', () => { - const row = wrapper.find('[data-testid="row-orientation-aspect"]'); - expect(row.find('[data-testid="orientation-toggle"]').exists()).toBe(true); - expect(row.find('[data-testid="aspect-ratio-selector"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="orientation-toggle"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="aspect-ratio-selector"]').exists()).toBe(true); }); - it('contains exactly 2 child components', () => { - const row = wrapper.find('[data-testid="row-orientation-aspect"]'); - const children = row.findAll('[data-testid]'); - expect(children).toHaveLength(2); + it('renders all 5 configuration controls', () => { + const controls = wrapper.findAll('[data-testid]'); + // Should have 5 controls + 1 config-bar container = 6 testids + expect(controls.length).toBeGreaterThanOrEqual(5); }); - }); - describe('Row 2: Frame Configuration Controls', () => { it('renders color picker, frame size, and spacing controls', () => { - const row = wrapper.find('[data-testid="row-frame-config"]'); - expect(row.find('[data-testid="color-picker"]').exists()).toBe(true); - expect(row.find('[data-testid="frame-size-input"]').exists()).toBe(true); - expect(row.find('[data-testid="spacing-input"]').exists()).toBe(true); - }); - - it('contains exactly 3 child components', () => { - const row = wrapper.find('[data-testid="row-frame-config"]'); - const children = row.findAll('[data-testid]'); - expect(children).toHaveLength(3); + expect(wrapper.find('[data-testid="color-picker"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="frame-size-input"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="spacing-input"]').exists()).toBe(true); }); }); @@ -97,11 +88,14 @@ describe('ConfigBar', () => { }); describe('Layout Organization', () => { - it('maintains correct order of rows', () => { - const rows = wrapper.findAll('[data-testid^="row-"]'); - expect(rows).toHaveLength(2); - expect(rows[0].attributes('data-testid')).toBe('row-orientation-aspect'); - expect(rows[1].attributes('data-testid')).toBe('row-frame-config'); + it('maintains correct order of controls', () => { + // Verify all controls are rendered in order + const allTestIds = wrapper.findAll('[data-testid]').map(w => w.attributes('data-testid')); + expect(allTestIds).toContain('orientation-toggle'); + expect(allTestIds).toContain('aspect-ratio-selector'); + expect(allTestIds).toContain('color-picker'); + expect(allTestIds).toContain('frame-size-input'); + expect(allTestIds).toContain('spacing-input'); }); }); }); From 403f2f865037f7434d602affa5cbec673cc2ce23 Mon Sep 17 00:00:00 2001 From: Eros Taborelli Date: Mon, 24 Nov 2025 10:23:42 +0100 Subject: [PATCH 02/12] feat(framed): improved UX, remove all text input, clean up duplicate constant usage --- .claude/documentation/ARCHITECTURE_DESIGN.md | 113 ++-- src/assets/styles/main.css | 85 +++ src/components/canvas/FrameCanvas.vue | 6 +- .../controls/AspectRatioSelector.vue | 39 +- src/components/controls/BorderSlider.vue | 63 +++ src/components/controls/ColorPicker.vue | 107 +--- src/components/controls/FileNameInput.vue | 38 -- src/components/controls/FormatSelector.vue | 55 +- src/components/controls/FrameSizeInput.vue | 63 --- src/components/controls/FrameSizeSelector.vue | 73 +++ src/components/controls/OrientationToggle.vue | 159 +++--- src/components/controls/QualityInput.vue | 164 ------ src/components/controls/QualitySlider.vue | 52 ++ src/components/controls/SpacingInput.vue | 63 --- src/components/layout/ActionBar.vue | 53 +- src/components/layout/ConfigBar.vue | 70 ++- src/components/shared/BaseInput.vue | 218 ------- src/components/shared/ConfigElement.vue | 50 ++ src/composables/useCanvasRenderer.js | 60 +- src/composables/useFrameConfig.js | 22 +- src/composables/useImageState.js | 10 +- src/utils/calculations.js | 72 ++- src/utils/constants.js | 27 +- src/utils/validation.js | 106 ++-- tests/unit/App.test.js | 6 +- .../components/canvas/FrameCanvas.test.js | 3 +- .../controls/AspectRatioSelector.test.js | 24 +- .../components/controls/BorderSlider.test.js | 297 ++++++++++ .../components/controls/ColorPicker.test.js | 195 +++---- .../controls/FormatSelector.test.js | 76 ++- .../controls/FrameSizeInput.test.js | 533 ------------------ .../controls/FrameSizeSelector.test.js | 315 +++++++++++ .../controls/OrientationToggle.test.js | 31 +- .../components/controls/QualitySlider.test.js | 278 +++++++++ .../components/controls/SpacingInput.test.js | 444 --------------- .../unit/components/layout/ActionBar.test.js | 24 +- .../unit/components/layout/ConfigBar.test.js | 52 +- .../unit/components/shared/BaseInput.test.js | 491 ---------------- .../components/shared/ConfigElement.test.js | 184 ++++++ .../composables/useCanvasRenderer.test.js | 122 ++-- tests/unit/composables/useFrameConfig.test.js | 78 +-- tests/unit/composables/useImageState.test.js | 22 - tests/unit/utils/calculations.test.js | 25 +- tests/unit/utils/constants.test.js | 51 +- tests/unit/utils/validation.test.js | 139 ++--- 45 files changed, 2219 insertions(+), 2939 deletions(-) create mode 100644 src/components/controls/BorderSlider.vue delete mode 100644 src/components/controls/FileNameInput.vue delete mode 100644 src/components/controls/FrameSizeInput.vue create mode 100644 src/components/controls/FrameSizeSelector.vue delete mode 100644 src/components/controls/QualityInput.vue create mode 100644 src/components/controls/QualitySlider.vue delete mode 100644 src/components/controls/SpacingInput.vue delete mode 100644 src/components/shared/BaseInput.vue create mode 100644 src/components/shared/ConfigElement.vue create mode 100644 tests/unit/components/controls/BorderSlider.test.js delete mode 100644 tests/unit/components/controls/FrameSizeInput.test.js create mode 100644 tests/unit/components/controls/FrameSizeSelector.test.js create mode 100644 tests/unit/components/controls/QualitySlider.test.js delete mode 100644 tests/unit/components/controls/SpacingInput.test.js delete mode 100644 tests/unit/components/shared/BaseInput.test.js create mode 100644 tests/unit/components/shared/ConfigElement.test.js diff --git a/.claude/documentation/ARCHITECTURE_DESIGN.md b/.claude/documentation/ARCHITECTURE_DESIGN.md index 06f2e9a..243cc01 100644 --- a/.claude/documentation/ARCHITECTURE_DESIGN.md +++ b/.claude/documentation/ARCHITECTURE_DESIGN.md @@ -131,12 +131,9 @@ src/ │ │ ├── AspectRatioSelector.vue # Button group (3:2, 4:3, 5:4, 16:9) │ │ ├── ColorPicker.vue # Color swatch + hex input │ │ ├── DownloadButton.vue # Download canvas button -│ │ ├── FrameSizeInput.vue # Frame size input (uses BaseInput) +│ │ ├── FrameSizeSelector.vue # Button group (1024px, 2048px, 4096px, Native) │ │ ├── OrientationToggle.vue # Portrait/Landscape toggle -│ │ ├── ResetButton.vue # Reset configuration button -│ │ └── SpacingInput.vue # Spacing input (uses BaseInput) -│ └── shared/ -│ └── BaseInput.vue # Reusable input component +│ │ └── ResetButton.vue # Reset configuration button ├── composables/ │ ├── useFrameConfig.js # Frame configuration state │ ├── useImageState.js # Image upload and management @@ -165,7 +162,7 @@ src/ #### **ConfigBar.vue** - Groups frame configuration controls in 2 rows: - **Row 1**: OrientationToggle + AspectRatioSelector (side-by-side on desktop, stacked on mobile) - - **Row 2**: ColorPicker + FrameSizeInput + SpacingInput (responsive flex layout) + - **Row 2**: ColorPicker + FrameSizeSelector + BorderSlider (responsive flex layout) - No props required (uses composables directly) #### **ActionBar.vue** @@ -193,30 +190,29 @@ src/ #### **OrientationToggle.vue** - Button group: Portrait and Landscape -- Uses scoped CSS for styling (.orientation-btn, .btn-active, .btn-inactive) +- Uses shared selector styles from main.css - Full-width responsive layout with equal button distribution #### **AspectRatioSelector.vue** - Button group: 3:2, 4:3, 5:4, 16:9 ratios -- Uses scoped CSS matching OrientationToggle style +- Uses shared selector styles from main.css - Always horizontal layout, buttons shrink/grow equally #### **ColorPicker.vue** -- Color swatch (40px square) + hex text input -- Uses scoped CSS (.color-picker-swatch, .color-text-input) -- Height matches BaseInput components +- Native HTML5 color picker (full-width) - Auto-validation and formatting for hex colors -- Hint text: "(hex format)" +- Simplified UI with no text input field -#### **FrameSizeInput.vue** -- Uses BaseInput component -- Number input with "px" unit display -- Validation for min/max frame size +#### **FrameSizeSelector.vue** +- Button group: 1024px, 2048px, 4096px, Native +- Uses shared selector styles from main.css +- Native option uses -1 value to indicate original/native size -#### **SpacingInput.vue** -- Uses BaseInput component -- Number input with "px" unit display -- Validation for min/max spacing +#### **BorderSlider.vue** +- Range slider for border percentage (1-25%) +- Uses shared range slider styles from main.css with value label overlay +- Border calculated as: `Math.round(frameSize * percentage / 100 / 2)` +- Fixed 20px inner spacing between images (not configurable) #### **DownloadButton.vue** - Triggers high-resolution canvas export @@ -227,10 +223,16 @@ src/ - Resets all configuration to defaults - Clears uploaded images -#### **BaseInput.vue** -- Shared input component with label, hint, and error display -- Supports unit display (e.g., "px") -- Consistent styling across all form inputs +#### **FormatSelector.vue** +- Button group: PNG, JPEG, WebP +- Uses shared selector styles from main.css +- Dynamically generated filenames at download time + +#### **QualitySlider.vue** +- Range slider for export quality (1-100%) +- Value displayed in positioned overlay label +- Uses shared range slider styles from main.css +- Affects compression for JPEG and WebP formats --- @@ -247,9 +249,9 @@ The application uses Vue 3 Composition API composables for state management, pro { orientation: 'portrait' | 'landscape', aspectRatio: '3:2' | '4:3' | '5:4' | '16:9', - backgroundColor: string, // Hex color - frameSize: number, // pixels (default 3000) - spacing: number, // pixels (default 150) + backgroundColor: string, // Hex color + frameSize: number, // pixels (default 2048) + borderPercentage: number, // 1-25% (default 2) frameWidth: computed, // Based on orientation and aspect ratio frameHeight: computed, // Based on orientation and aspect ratio } @@ -262,15 +264,33 @@ The application uses Vue 3 Composition API composables for state management, pro { id: string, file: File, + fileName: string, dataUrl: string, width: number, height: number, + orientation: string, + aspectRatio: number, }, // ... second image ] } ``` +#### **Canvas Renderer** (`useCanvasRenderer.js`) +```javascript +{ + quality: number, // 1-100% (default 85) + format: string, // 'image/png' | 'image/jpeg' | 'image/webp' + stageConfig: computed, + backgroundConfig: computed, + image1Config: computed, + image2Config: computed, + previewScale: computed, + isReady: computed, + canExport: computed, +} +``` + --- ## 7. Konva Integration @@ -376,10 +396,11 @@ Removed, only dark UI | Composables | 80%+ | ✅ Achieved | | Components | 70%+ | ✅ Achieved | -**Total Tests**: 350 tests passing +**Total Tests**: 450+ tests passing - All components have dedicated test suites - Vitest with @vue/test-utils for component testing - Mock components used for integration tests to avoid dependency issues +- Comprehensive coverage of utilities, composables, and components --- @@ -402,18 +423,14 @@ export const ORIENTATIONS = { export const IMAGE_CONSTRAINTS = { maxFileSize: 40 * 1024 * 1024, // 40MB - minDimension: 800, supportedFormats: ['image/jpeg', 'image/png', 'image/webp'], - aspectRatioTolerance: 0.05, }; export const FRAME_CONSTRAINTS = { - minSize: 2000, - maxSize: 6000, - defaultSize: 3000, - minSpacing: 50, - maxSpacing: 500, - defaultSpacing: 150, + minSize: 800, + maxSize: 10000, + minBorderPercentage: 1, + maxBorderPercentage: 25, }; ``` @@ -421,7 +438,8 @@ export const FRAME_CONSTRAINTS = { Key functions: - `calculateFrameDimensions()`: Calculate frame width/height based on orientation and aspect ratio -- `calculateImageLayout()`: Calculate positions for two images +- `calculateBorderSpacing()`: Calculate border spacing from percentage +- `calculateImageLayout()`: Calculate positions for two images with border and fixed 20px inner spacing - `calculatePreviewScale()`: Calculate scale ratio for responsive preview - `calculateScaledDimensions()`: Fit image within container maintaining aspect ratio - `calculateCenterOffset()`: Center image within slot @@ -429,10 +447,12 @@ Key functions: ### 11.3 Validation (`utils/validation.js`) Key functions: -- `validateImageFile()`: Validate file type and size +- `validateFile()`: Validate file type and size +- `validateImageDimensions()`: Validate image meets minimum dimensions - `validateFrameSize()`: Validate frame size within constraints - `validateSpacing()`: Validate spacing within constraints -- `isValidHexColor()`: Validate hex color codes +- `extractValidFilenameChars()`: Extract valid characters from filename (1-10 chars) +- `generateUuidV1Short()`: Generate 8-character time-based UUID --- @@ -503,16 +523,17 @@ Key functions: - Simpler than CSS Grid for this use case - Better browser support and performance -### 12.7 CSS Organization: Scoped CSS with Tailwind @apply +### 12.7 CSS Organization: Shared Selector Styles -**Decision**: Move repetitive Tailwind classes to scoped CSS using `@apply` +**Decision**: Centralize common button group styles in main.css **Rationale**: -- Reduces template clutter -- Easier to maintain consistent styling -- Better separation of concerns (structure vs. styling) -- Improves readability of component templates -- Allows for semantic class names (.btn-active, .ratio-btn, etc.) +- DRY principle - single source of truth for selector button styling +- All selector components (OrientationToggle, AspectRatioSelector, FrameSizeSelector, FormatSelector) share identical styles +- Better maintainability - style changes in one place +- Reduced code duplication (~109 lines of CSS removed) +- Consistent styling guaranteed across all selectors +- Shared classes: `.selector-group`, `.selector-btn`, `.selector-btn-active`, `.selector-btn-inactive` --- diff --git a/src/assets/styles/main.css b/src/assets/styles/main.css index 9506d6c..a6be042 100644 --- a/src/assets/styles/main.css +++ b/src/assets/styles/main.css @@ -36,4 +36,89 @@ @apply bg-gray-50 dark:bg-gray-700; @apply border-b border-gray-200 dark:border-gray-600; } + + /** + * Shared button group selector styles + * Used by: OrientationToggle, AspectRatioSelector, FrameSizeSelector, FormatSelector + */ + + /* Container for button group */ + .selector-group { + @apply flex w-full rounded-lg border border-gray-600 p-1 bg-gray-800; + } + + /* Base button styles for selector buttons */ + .selector-btn { + @apply flex flex-1 items-center justify-center px-3 py-2 text-sm font-medium rounded-md; + @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 whitespace-nowrap; + @apply transition-colors; + } + + /* Active button state (currently selected option) */ + .selector-btn-active { + @apply bg-gray-700 text-blue-400 shadow-sm; + } + + /* Inactive button state (non-selected options) */ + .selector-btn-inactive { + @apply text-gray-300 hover:text-gray-100 hover:bg-gray-700 cursor-pointer; + } + + /** + * Shared range slider styles + * Used by: QualitySlider, BorderSlider + */ + + /* Range input with value label in thumb */ + .range-input-with-value { + @apply w-full appearance-none cursor-pointer bg-transparent; + @apply focus:outline-none; + height: 32px; /* Increased height to accommodate squared thumb */ + } + + .range-input-with-value::-webkit-slider-thumb { + @apply appearance-none rounded cursor-pointer opacity-0; + width: 32px; + height: 32px; + } + + .range-input-with-value::-moz-range-thumb { + @apply rounded border-0 cursor-pointer opacity-0; + width: 32px; + height: 32px; + } + + .range-input-with-value::-webkit-slider-runnable-track { + @apply w-full h-2 rounded-lg; + @apply bg-gray-200 dark:bg-gray-600; + } + + .range-input-with-value::-moz-range-track { + @apply w-full h-2 rounded-lg; + @apply bg-gray-200 dark:bg-gray-600; + } + + /* Value label that appears as thumb */ + .range-value-label { + @apply absolute pointer-events-none; + @apply flex items-center justify-center; + @apply bg-blue-600 dark:bg-blue-500 text-white; + @apply rounded font-medium text-xs; + @apply transition-colors; + width: 32px; + height: 32px; + top: 0px; + } + + .range-input-with-value:hover + .range-value-label { + @apply bg-blue-700 dark:bg-blue-600; + } + + .range-input-with-value:focus + .range-value-label { + @apply ring-2 ring-blue-500 ring-offset-2; + } + + .range-input-with-value:disabled + .range-value-label { + @apply opacity-50 cursor-not-allowed; + } } diff --git a/src/components/canvas/FrameCanvas.vue b/src/components/canvas/FrameCanvas.vue index ba2a883..4421a40 100644 --- a/src/components/canvas/FrameCanvas.vue +++ b/src/components/canvas/FrameCanvas.vue @@ -87,7 +87,7 @@ />

- {{ orientation === 'portrait' ? 'Top' : 'Left' }} Image + {{ orientation === ORIENTATIONS.PORTRAIT ? 'Top' : 'Left' }} Image

Click or drop @@ -158,7 +158,7 @@ />

- {{ orientation === 'portrait' ? 'Bottom' : 'Right' }} Image + {{ orientation === ORIENTATIONS.PORTRAIT ? 'Bottom' : 'Right' }} Image

Click or drop @@ -228,7 +228,7 @@ import { ref, computed, onMounted, watch, toRef } from 'vue'; import { useCanvasRenderer } from '@/composables/useCanvasRenderer'; import { useImageState } from '@/composables/useImageState'; import { useFrameConfig } from '@/composables/useFrameConfig'; -import { PREVIEW_CONSTRAINTS } from '@/utils/constants'; +import { PREVIEW_CONSTRAINTS, ORIENTATIONS } from '@/utils/constants'; /** * FrameCanvas component diff --git a/src/components/controls/AspectRatioSelector.vue b/src/components/controls/AspectRatioSelector.vue index b1a0b45..a025dd7 100644 --- a/src/components/controls/AspectRatioSelector.vue +++ b/src/components/controls/AspectRatioSelector.vue @@ -5,8 +5,7 @@