Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions backend/features/project/service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_count_projects():
res = service.count_projects("user_123")
assert res == 5

def test_process_base64_preview():
def test_process_base64_images():
service = ProjectService(MockDatabaseProvider(), MockStorageProvider())

# 1x1 transparent PNG
Expand All @@ -39,10 +39,10 @@ def test_process_base64_preview():
mosaic_data={}
)

service.process_base64_preview(req, "user_123")
service.process_base64_images(req, "user_123")
assert req.mosaic_preview_url == "https://storage.mock/mosaics/preview.png"

def test_process_base64_preview_invalid():
def test_process_base64_images_invalid():
service = ProjectService(MockDatabaseProvider(), MockStorageProvider())

req = SaveProjectRequest(
Expand All @@ -55,10 +55,10 @@ def test_process_base64_preview_invalid():
mosaic_data={}
)

service.process_base64_preview(req, "user_123")
service.process_base64_images(req, "user_123")
assert req.mosaic_preview_url == ""

def test_process_base64_preview_not_data_image():
def test_process_base64_images_not_data_image():
service = ProjectService(MockDatabaseProvider(), MockStorageProvider())

req = SaveProjectRequest(
Expand All @@ -71,5 +71,5 @@ def test_process_base64_preview_not_data_image():
mosaic_data={}
)

service.process_base64_preview(req, "user_123")
service.process_base64_images(req, "user_123")
assert req.mosaic_preview_url == "https://example.com/normal_image.png"
5 changes: 1 addition & 4 deletions docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@
- [ ] **Center Mosaic Canvas Vertically and Horizontally**
- **Description**: The mosaic canvas is centered horizontally but not vertically within the canvas wrapper. Fix the layout so the mosaic is centered on both axes, and ensure centering is preserved when zooming in/out via the slider.
- **Context**: The parent flex container lacks proper height constraints for vertical centering to work. `applyZoom()` also needs adjustment to maintain centering during zoom changes.
- [ ] **Remove Obsolete Mosaic Config Step**
- **Description**: Delete the "Mosaic Config" sidebar step (`step-options`) that appears between cropping and mosaic generation. All config controls already exist in the Quick Config panel on the build-plan page. After cropping, the flow should go directly to generating the mosaic with default config.
- **Impact**: Simpler user flow (crop → auto-generate → view), one fewer navigation step.
- **Context**: The step-options sidebar (Color Mode, Dithering, Image Adjustments, Gradient) is 100% duplicated in the Quick Config panel on the build-plan tab.

### Architecture Improvements
- [ ] **Eliminate Redundant Base64 Mosaic Payloads**
Expand Down Expand Up @@ -71,3 +67,4 @@
| **Zoom Slider UI Cutoff** | 2026-04-16 | Redesigned the zoom popover into a narrow 40px-wide vertical layout: rotated the `<input type=range>` 90° and gave it `position:absolute` inside a fixed-height wrapper so it never expands the flex parent. Removed stale `#zoom-popover { min-width:220px }` CSS that was overriding layout, added dark-theme custom track/thumb styling, updated JS toggle to `style.display='flex'` (not `hidden` class), and finally changed popover anchor from `bottom-full mb-2` to `top-full mt-2` so it opens downward into the canvas instead of upward into the navigation bar. |
| **Eliminate CustomEvent Bridges** | 2026-04-15 | Removed all 5 `window.dispatchEvent(new CustomEvent(...))` bridges that existed between `app.js` and Alpine templates. Added `setsLoading`, `recentImages`, `recentLoading`, `recentError`, `projects`, `projectsError`, and `projectsVisible` directly to `Alpine.store('app')`. Templates for sets-grid, cart footer, recent-uploads-grid, compare-mode-view, and projects-grid now read from `$store.app.*` directly, making state flow entirely reactive and unidirectional. |

| **Remove Obsolete Mosaic Config Step** | 2026-04-16 | Removed `<aside id="step-options">` from `frontend/index.html` and deleted related logic (`updateOptionsPanel`, `stepOptions`) from `frontend/app.js`. Updated `applyCrop()` to bypass the config step and call `generateMosaic()` directly. |
28 changes: 1 addition & 27 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,6 @@ function showEditorStep(step) {
// Hide all editor steps
stepUpload.classList.add('hidden');
stepCrop.classList.add('hidden');
stepOptions.classList.add('hidden');

const hints = {
upload: 'Drop an image or click to browse',
Expand All @@ -951,14 +950,6 @@ function showEditorStep(step) {
$('#editor-hint').textContent = hints.crop;
// Hide the title group to save vertical space
$('#editor-title-group').classList.add('hidden');
} else if (step === 'options') {
stepOptions.classList.remove('hidden');
stepOptions.style.display = 'flex';
$('#editor-hint').textContent = hints.options;
$('#editor-title').textContent = 'Mosaic Config';
$('#editor-subtitle').textContent = 'Fine-tune rendering settings';
$('#editor-title-group').classList.remove('hidden');
updateOptionsPanel();
}
}

Expand Down Expand Up @@ -1840,7 +1831,7 @@ async function applyCrop() {
state.historyIndex = -1;
updateHistoryUI(); // Clear UI dots

showEditorStep('options');
await generateMosaic();
} catch (e) {
console.error('Crop failed:', e);
alert(`Crop failed: ${e.message}`);
Expand All @@ -1849,23 +1840,8 @@ async function applyCrop() {
}
}

// ═════════════════════════════════════════════════
// STEP 3: OPTIONS PANEL
// ═════════════════════════════════════════════════
function updateOptionsPanel() {
const srcImg = $('#source-preview');
if (srcImg && state.croppedImageUrl) {
srcImg.src = state.croppedImageUrl;
}
const info = getMergedSetInfo();
const setNameLabel = $('#set-name-label');
const gridSizeLabel = $('#grid-size-label');
if (setNameLabel) setNameLabel.textContent = info.name;
if (gridSizeLabel) gridSizeLabel.textContent = `${state.targetW || info.grid[0]}×${state.targetH || info.grid[1]} studs`;
}

function setupGenerate() {
btnGenerate.addEventListener('click', generateMosaic);

if (colorModeSelect && quickColorModeSelect) {
colorModeSelect.addEventListener('change', () => {
Expand Down Expand Up @@ -2368,7 +2344,6 @@ function setupPreprocessingControls() {

async function generateMosaic() {
// Show a loading state gracefully whether on Editor or Build Plan
if (btnGenerate) setBtnLoading(btnGenerate, true, 'Processing…');
const loadingOverlay = document.createElement('div');
if ($('#tab-build-plan').style.display !== 'none') {
loadingOverlay.className = 'absolute inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center';
Expand Down Expand Up @@ -2443,7 +2418,6 @@ async function generateMosaic() {
console.error('Generation failed:', e);
alert('Mosaic generation failed: ' + e.message);
} finally {
if (btnGenerate) setBtnLoading(btnGenerate, false);
if (loadingOverlay.parentNode) loadingOverlay.remove();
}
}
Expand Down
173 changes: 0 additions & 173 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -546,179 +546,6 @@ <h3 class="font-label text-xs uppercase tracking-widest text-on-surface-variant
</main>

<!-- RIGHT SIDEBAR: Mosaic Config (Step 3 / Options) -->
<aside id="step-options" class="hidden w-full lg:w-[360px] bg-surface-container border-t lg:border-t-0 lg:border-l border-outline-variant/30 p-4 sm:p-6 lg:p-8 flex flex-col gap-6 sm:gap-8 z-40 overflow-y-auto lg:h-full">

<!-- Source image preview -->
<div>
<h3 class="font-headline font-bold text-lg text-secondary neon-text-glow-secondary flex items-center gap-2 mb-4">
<span class="material-symbols-outlined text-base">image</span>
Source Image
</h3>
<div class="rounded-lg overflow-hidden border border-outline-variant/30 aspect-square bg-surface-container-lowest">
<img id="source-preview" class="w-full h-full object-cover" alt="Source image"/>
</div>
<div class="flex gap-2 mt-2 text-[11px] font-label text-on-surface-variant">
<span id="set-name-label"></span>
<span id="grid-size-label"></span>
</div>
</div>

<div class="h-px bg-outline-variant/40"></div>

<!-- Config section -->
<div>
<h3 class="font-headline font-bold text-xl text-secondary neon-text-glow-secondary flex items-center gap-3 mb-1">
<span class="material-symbols-outlined">settings_input_component</span>
Mosaic Config
</h3>
<div class="h-px w-full bg-gradient-to-r from-secondary/50 to-transparent mt-3"></div>
</div>

<!-- Toggles -->
<div class="space-y-8">
<div class="flex flex-col gap-3">
<div class="flex flex-col">
<div class="flex items-center gap-1.5 w-max group relative cursor-help">
<span class="font-label text-xs uppercase tracking-widest text-on-surface font-bold">Color Mode</span>
<span class="material-symbols-outlined text-[14px] text-on-surface-variant group-hover:text-primary transition-colors">info</span>
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-64 p-3 bg-surface-container-highest border border-outline-variant rounded-lg text-xs text-on-surface-variant font-label opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none drop-shadow-xl z-50 text-center">
Pop-Art replaces pixels by luminance ranking. Realistic finds the closest exact color match. Gradient customizes colors linearly.
</div>
</div>
<span class="text-[10px] text-on-surface-variant uppercase mt-1">Algorithm Strategy</span>
</div>
<select id="color-mode-select" class="bg-surface-container-highest border border-outline-variant text-sm text-on-surface rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-secondary focus:border-secondary transition-all">
<option value="realistic">Realistic (Color Match)</option>
<option value="pop_art" selected>Pop-Art (Luminance Match)</option>
<option value="gradient">Gradient Mapping</option>
</select>
<!-- Gradient Colors Config (Hidden by default) -->
<div id="gradient-config" class="hidden flex flex-col gap-2 mt-2 p-3 bg-surface-container-highest border border-outline-variant rounded-lg">
<label class="font-label text-xs text-on-surface-variant uppercase tracking-widest">Gradient Colors</label>
<div id="gradient-color-pickers" class="flex gap-2">
<input type="color" value="#000000" class="w-8 h-8 rounded cursor-pointer border-none p-0">
<input type="color" value="#ff2d78" class="w-8 h-8 rounded cursor-pointer border-none p-0">
<input type="color" value="#ffffff" class="w-8 h-8 rounded cursor-pointer border-none p-0">
</div>
<button id="btn-add-gradient-color" class="text-xs font-label text-primary hover:text-white mt-1 text-left">+ Add Color (Max 5)</button>
</div>
</div>

<div class="border-t border-outline-variant pt-4 mt-2">
<div class="flex items-center gap-1.5 w-max group/tt relative cursor-help mb-4">
<span class="font-label text-xs uppercase tracking-widest text-primary font-bold">Image Adjustments</span>
<span class="material-symbols-outlined text-[14px] text-on-surface-variant group-hover/tt:text-primary transition-colors">tune</span>
</div>

<div id="preprocessing-sliders" class="space-y-4 flex flex-col">
<!-- Contrast -->
<div class="space-y-2 flex flex-col" data-adjust="contrast">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Contrast (CLAHE)</span>
<span id="contrast-value" class="font-label text-[10px] text-tertiary font-bold">1.0×</span>
</div>
<input type="range" id="contrast-slider" min="0" max="2" step="0.1" value="1.0" class="neon-slider w-full"/>
</div>

<!-- Saturation -->
<div class="space-y-2 flex flex-col" data-adjust="saturation">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Saturation</span>
<span id="saturation-value" class="font-label text-[10px] text-tertiary font-bold">0%</span>
</div>
<input type="range" id="saturation-slider" min="-100" max="100" step="1" value="0" class="neon-slider w-full"/>
</div>

<!-- Temperature -->
<div class="space-y-2 flex flex-col" data-adjust="temperature">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Temperature</span>
<span id="temperature-value" class="font-label text-[10px] text-tertiary font-bold">0</span>
</div>
<input type="range" id="temperature-slider" min="-50" max="50" step="1" value="0" class="neon-slider w-full"/>
</div>

<!-- Sharpen -->
<div class="space-y-2 flex flex-col" data-adjust="sharpen">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Sharpen</span>
<span id="sharpen-value" class="font-label text-[10px] text-tertiary font-bold">0.0</span>
</div>
<input type="range" id="sharpen-slider" min="0" max="5" step="0.1" value="0" class="neon-slider w-full"/>
</div>

<div id="advanced-adjustments" class="hidden space-y-4 flex flex-col mt-4">
<!-- Gamma -->
<div class="space-y-2 flex flex-col" data-adjust="gamma">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Gamma (Midtones)</span>
<span id="gamma-value" class="font-label text-[10px] text-tertiary font-bold">1.0</span>
</div>
<input type="range" id="gamma-slider" min="0.2" max="3.0" step="0.1" value="1.0" class="neon-slider w-full"/>
</div>

<!-- Shadows (Black Point) -->
<div class="space-y-2 flex flex-col" data-adjust="black-point">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Shadows Cutoff</span>
<span id="black-point-value" class="font-label text-[10px] text-tertiary font-bold">0</span>
</div>
<input type="range" id="black-point-slider" min="0" max="100" step="1" value="0" class="neon-slider w-full"/>
</div>

<!-- Highlights (White Point) -->
<div class="space-y-2 flex flex-col" data-adjust="white-point">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Highlights Limit</span>
<span id="white-point-value" class="font-label text-[10px] text-tertiary font-bold">255</span>
</div>
<input type="range" id="white-point-slider" min="155" max="255" step="1" value="255" class="neon-slider w-full"/>
</div>

<!-- Posterize -->
<div class="space-y-2 flex flex-col" data-adjust="posterize" id="posterize-group">
<div class="flex justify-between items-center">
<span class="font-label text-[10px] uppercase tracking-wider text-on-surface">Posterize Levels</span>
<span id="posterize-value" class="font-label text-[10px] text-tertiary font-bold">32</span>
</div>
<input type="range" id="posterize-slider" min="2" max="32" step="1" value="32" class="neon-slider w-full"/>
</div>
</div>
</div>

<button id="btn-toggle-advanced-adjustments" class="text-[10px] font-label text-primary hover:text-white mt-4 text-left w-full uppercase tracking-widest">+ Show Advanced Adjustments</button>
</div>

<div class="flex items-center justify-between group" id="dithering-wrapper">
<div class="flex flex-col">
<div class="flex items-center gap-1.5 w-max group/tt relative cursor-help">
<span class="font-label text-xs uppercase tracking-widest text-on-surface font-bold" id="dithering-label">Dithering</span>
<span class="material-symbols-outlined text-[14px] text-on-surface-variant group-hover/tt:text-primary transition-colors">info</span>
<div class="absolute bottom-full left-0 mb-2 w-64 p-3 bg-surface-container-highest border border-outline-variant rounded-lg text-xs text-on-surface-variant font-label opacity-0 group-hover/tt:opacity-100 transition-opacity pointer-events-none drop-shadow-xl z-50">
Uses Floyd-Steinberg algorithm to diffuse color errors. Ideal for smooth gradients in Realistic mode. Native Pop-Art handles this automatically.
</div>
</div>
<span class="text-[10px] text-on-surface-variant uppercase mt-1" id="dithering-subtitle">Simulate Smooth Gradients</span>
</div>
<label class="relative w-12 h-6 cursor-pointer" id="dithering-toggle-label">
<input type="checkbox" id="dithering-toggle" class="sr-only peer"/>
<div class="w-12 h-6 bg-surface-container-highest rounded-full border border-outline-variant peer-checked:border-secondary/50 transition-colors peer-disabled:opacity-50"></div>
<div class="absolute top-1 left-1 w-4 h-4 bg-on-surface-variant peer-checked:bg-secondary peer-checked:shadow-[0_0_8px_#00ffcc] rounded-full peer-checked:translate-x-6 transition-all duration-200 peer-disabled:bg-surface-container peer-disabled:shadow-none peer-disabled:translate-x-1"></div>
</label>
</div>
</div>



<!-- CTA Buttons -->
<div class="space-y-4 mt-auto">
>
<button id="btn-generate" class="w-full py-4 px-6 bg-primary text-on-primary font-headline font-extrabold uppercase tracking-[0.15em] text-sm shadow-[0_0_20px_rgba(255,45,120,0.5)] hover:scale-[1.02] transition-transform active:scale-95 rounded-lg flex items-center justify-center gap-2">
<span class="btn-text">Generate Mosaic</span>
<span class="btn-loader hidden flex items-center gap-2"><span class="spinner"></span>Processing…</span>
</button>
</div>
</aside>
</div>

<!-- ── TAB: BUILD PLAN (Result) ── -->
Expand Down
Loading