chore: snapshot v2 version

This commit is contained in:
chris 2025-12-01 09:18:19 -05:00
parent 9cabe15481
commit 70d29cefca
4 changed files with 502 additions and 881 deletions

View File

@ -16,6 +16,8 @@
const PALETTE_KEY = 'classic:colors:v2'; const PALETTE_KEY = 'classic:colors:v2';
const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; const TOPPER_COLOR_KEY = 'classic:topperColor:v2';
const MAX_SLOTS = 20;
const SLOT_COUNT_KEY = 'classic:slotCount:v1';
const defaultColors = () => [ const defaultColors = () => [
{ hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null }, { hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null },
{ hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null }, { hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null },
@ -31,18 +33,19 @@
const saved = JSON.parse(savedJSON); const saved = JSON.parse(savedJSON);
if (Array.isArray(saved) && saved.length > 0) { if (Array.isArray(saved) && saved.length > 0) {
if (typeof saved[0] === 'string') { if (typeof saved[0] === 'string') {
arr = saved.slice(0, 5).map(hex => ({ hex: normHex(hex), image: null })); arr = saved.slice(0, MAX_SLOTS).map(hex => ({ hex: normHex(hex), image: null }));
} else if (typeof saved[0] === 'object' && saved[0] !== null) { } else if (typeof saved[0] === 'object' && saved[0] !== null) {
arr = saved.slice(0, 5); arr = saved.slice(0, MAX_SLOTS);
} }
while (arr.length < 5) arr.push({ hex: '#ffffff', image: null });
} }
while (arr.length < 5) arr.push({ hex: '#ffffff', image: null });
if (arr.length > MAX_SLOTS) arr = arr.slice(0, MAX_SLOTS);
} catch (e) { console.error('Failed to parse classic colors:', e); } } catch (e) { console.error('Failed to parse classic colors:', e); }
return arr; return arr;
} }
function setClassicColors(arr) { function setClassicColors(arr) {
const clean = (arr || []).slice(0, 5).map(c => ({ const clean = (arr || []).slice(0, MAX_SLOTS).map(c => ({
hex: normHex(c.hex), image: c.image || null hex: normHex(c.hex), image: c.image || null
})); }));
while (clean.length < 5) clean.push({ hex: '#ffffff', image: null }); while (clean.length < 5) clean.push({ hex: '#ffffff', image: null });
@ -196,6 +199,11 @@ function distinctPaletteSlots(palette) {
const balloonsPerCluster = pattern.balloonsPerCluster || 4; const balloonsPerCluster = pattern.balloonsPerCluster || 4;
const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse));
const rowColorPatterns = {}; const rowColorPatterns = {};
const stackedSlots = (() => {
const slots = distinctPaletteSlots(model.palette);
const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster));
return slots.slice(0, limit);
})();
const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]]; const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]];
const colorBlock5 = const colorBlock5 =
@ -222,10 +230,16 @@ function distinctPaletteSlots(palette) {
const rowIndex = cell.y; const rowIndex = cell.y;
if (!rowColorPatterns[rowIndex]) { if (!rowColorPatterns[rowIndex]) {
const qEff = rowIndex + 1; const totalRows = model.rowCount * (pattern.cellsPerRow || 1);
const isRightHalf = false; // mirror mode removed
const baseRow = rowIndex;
const qEff = baseRow + 1;
let pat; let pat;
if (balloonsPerCluster === 5) { if (pattern.colorMode === 'stacked') {
const slot = stackedSlots[(rowIndex) % stackedSlots.length] || stackedSlots[0] || 1;
pat = new Array(balloonsPerCluster).fill(slot);
} else if (balloonsPerCluster === 5) {
const base = (qEff - 1) % 5; const base = (qEff - 1) % 5;
pat = colorBlock5[base].slice(); pat = colorBlock5[base].slice();
} else { } else {
@ -236,22 +250,21 @@ function distinctPaletteSlots(palette) {
} }
} }
if (reversed && pat.length > 1) { // Swap left/right emphasis every 5 clusters to break repetition (per template override)
pat.reverse(); if (balloonsPerCluster === 5) {
const SWAP_EVERY = 5;
const blockIndex = Math.floor(rowIndex / SWAP_EVERY);
if (blockIndex % 2 === 1) {
[pat[0], pat[4]] = [pat[4], pat[0]];
}
} }
// --- NEW: swap left/right after every 5 clusters --- if (pat.length > 1) {
const SWAP_EVERY = 5; // clusters per block let shouldReverse;
const blockIndex = Math.floor(rowIndex / SWAP_EVERY); shouldReverse = reversed;
if (shouldReverse) pat.reverse();
}
// swap on blocks #2, #4, #6, ... (i.e., rows 610, 1620, ...)
if (blockIndex % 2 === 1) {
if (balloonsPerCluster === 5) {
// [leftMid, leftBack, front, rightBack, rightMid]
[pat[0], pat[4]] = [pat[4], pat[0]];
// [pat[1], pat[3]] = [pat[3], pat[1]];
}
}
rowColorPatterns[rowIndex] = pat; rowColorPatterns[rowIndex] = pat;
} }
@ -314,7 +327,7 @@ if (blockIndex % 2 === 1) {
if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ }); if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ });
} }
} }
if (name === 'Column 4' && topperEnabled) { if (name.toLowerCase().includes('column') && topperEnabled) {
const shapeName = `topper-${topperType}`; const shapeName = `topper-${topperType}`;
const originalShape = pattern.balloonShapes[shapeName]; const originalShape = pattern.balloonShapes[shapeName];
if (originalShape) { if (originalShape) {
@ -374,64 +387,47 @@ if (blockIndex % 2 === 1) {
return { x: -r*Math.cos(a), y: -r*Math.sin(a) }; return { x: -r*Math.cos(a), y: -r*Math.sin(a) };
} }
}; };
// --- Column 5 (template geometry) ---
// --- START: MODIFIED SECTION ---
// This is the new 'Column 5' definition, adapted from your template file.
patterns['Column 5'] = { patterns['Column 5'] = {
baseBalloonSize: 25, baseBalloonSize: 25,
_reverse: false, _reverse: false,
balloonsPerCluster: 5, // Kept this from classic.js to ensure 5-color spiral balloonsPerCluster: 5,
tile: { size: { x: 5, y: 1 } }, tile: { size: { x: 5, y: 1 } },
cellsPerRow: 1, cellsPerRow: 1,
cellsPerColumn: 5, cellsPerColumn: 5,
// Balloon shapes from your template, converted to classic.js format
// (type: "qlink" is approx size: 3.0)
balloonShapes: { balloonShapes: {
"front": { zIndex:5, base:{radius:0.5}, size:3.0 }, "front": { zIndex:5, base:{radius:0.5}, size:3.0 },
"front2": { zIndex:4, base:{radius:0.5}, size:3.0 }, "front2": { zIndex:4, base:{radius:0.5}, size:3.0 },
"middle": { zIndex:3, base:{radius:0.5}, size:3.0 }, "middle": { zIndex:3, base:{radius:0.5}, size:3.0 },
"middle2": { zIndex:2, base:{radius:0.5}, size:3.0 }, "middle2": { zIndex:2, base:{radius:0.5}, size:3.0 },
"back": { zIndex:1, base:{radius:0.5}, size:3.0 }, "back": { zIndex:1, base:{radius:0.5}, size:3.0 },
"back2": { zIndex:0, base:{radius:0.5}, size:3.0 } "back2": { zIndex:0, base:{radius:0.5}, size:3.0 },
'topper-round':{base:{type:'ellipse', radius:0.5}, size:8},
'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5}, size:8},
'topper-heart':{base:{type:'path', d:'M0,0.35 C-0.5,0, -0.14,-0.35, 0,-0.14 C0.14,-0.35, 0.5,0, 0,0.35 Z', radius:0.5}, size:20}
}, },
// gridX function from your template
// (I've hard-coded `this.exploded` to false, as it's not in classic.js)
gridX(row, col) { gridX(row, col) {
var mid = 0.6; // this.exploded ? 0.2 : 0.6 var mid = 0.6;
return (0.9) * (col + (0 === col % 5 && -0.5) + (1 === col % 5 && -mid) + (3 === col % 5 && mid) + (4 === col % 5 && 0.5) - 0.5); return (0.9) * (col + (0 === col % 5 && -0.5) + (1 === col % 5 && -mid) + (3 === col % 5 && mid) + (4 === col % 5 && 0.5) - 0.5);
}, },
// gridY function is inherited from Column 4 via `deriveFrom` in your template.
// So, we use the gridY function from this file's 'Column 4'.
gridY(row, col){ gridY(row, col){
return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2));
}, },
// createCell function from your template, adapted for classic.js
createCell(x, y) { createCell(x, y) {
var yOdd = !!(y % 2); var yOdd = !!(y % 2);
const shapePattern = yOdd
// Re-created logic from template's createCell ? ['middle', 'back', 'front', 'back', 'middle']
const shapePattern = yOdd ? : ['middle2', 'front2', 'back2', 'front2', 'middle2'];
['middle', 'back', 'front', 'back', 'middle'] : var shapeName = shapePattern[x % 5];
['middle2', 'front2', 'back2', 'front2', 'middle2']; var shape = this.balloonShapes[shapeName];
return shape ? { shape: {...shape} } : null;
var shapeName = shapePattern[x % 5];
var shape = this.balloonShapes[shapeName];
// Return in classic.js format
return shape ? { shape: {...shape} } : null;
} }
}; };
// This is the new 'Arch 5' definition. // Arch 5 derives from Column 5
// It derives from the new 'Column 5' and uses the same arching logic as 'Arch 4'.
patterns['Arch 5'] = { patterns['Arch 5'] = {
deriveFrom: 'Column 5', deriveFrom: 'Column 5',
transform(point, col, row, model){ transform(point, col, row, model){
// This transform logic is standard and will work with the new Column 5's gridY
const len = this.gridY(model.rowCount * this.tile.size.y, 0) - this.gridY(0, 0); const len = this.gridY(model.rowCount * this.tile.size.y, 0) - this.gridY(0, 0);
const r = (len / Math.PI) + point.x; const r = (len / Math.PI) + point.x;
const y = point.y - this.gridY(0, 0); const y = point.y - this.gridY(0, 0);
@ -441,53 +437,75 @@ if (blockIndex % 2 === 1) {
}; };
// --- END: MODIFIED SECTION --- // --- END: MODIFIED SECTION ---
// --- Stacked variants (same geometry, single-color clusters alternating rows) ---
patterns['Arch 4 Stacked'] = { deriveFrom: 'Arch 4', colorMode: 'stacked' };
patterns['Arch 5 Stacked'] = { deriveFrom: 'Arch 5', colorMode: 'stacked' };
patterns['Column 4 Stacked'] = { deriveFrom: 'Column 4', colorMode: 'stacked' };
patterns['Column 5 Stacked'] = { deriveFrom: 'Column 5', colorMode: 'stacked' };
Object.keys(patterns).forEach(n => extend(patterns[n])); Object.keys(patterns).forEach(n => extend(patterns[n]));
return api; return api;
} }
const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4); const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4);
function getStoredSlotCount() {
try {
const saved = parseInt(localStorage.getItem(SLOT_COUNT_KEY), 10);
if (Number.isFinite(saved) && saved > 0) return Math.min(saved, MAX_SLOTS);
} catch {}
return 5;
}
function setStoredSlotCount(n) {
const v = Math.max(1, Math.min(MAX_SLOTS, n|0));
try { localStorage.setItem(SLOT_COUNT_KEY, String(v)); } catch {}
return v;
}
function initClassicColorPicker(onColorChange) { function initClassicColorPicker(onColorChange) {
const slots = Array.from(document.querySelectorAll('#classic-slots .slot-btn')), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), dockColorBtn = document.getElementById('dock-classic-color'); const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot');
if (!slots.length || !topperSwatch || !swatchGrid || !activeLabel) return; if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return;
topperSwatch.classList.add('tab-btn'); topperSwatch.classList.add('tab-btn');
let classicColors = getClassicColors(), activeTarget = '1'; let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
const syncDockColor = (color) => {
if (!dockColorBtn) return;
const next = color?.hex ? color : { hex: '#2563eb', image: null };
if (next.image) {
dockColorBtn.style.backgroundImage = `url("${next.image}")`;
dockColorBtn.style.backgroundSize = '200%';
dockColorBtn.style.backgroundColor = 'transparent';
} else {
dockColorBtn.style.backgroundImage = 'none';
dockColorBtn.style.backgroundColor = next.hex;
}
};
function visibleSlotCount() { function visibleSlotCount() {
const patSelect = document.getElementById('classic-pattern'); const patSelect = document.getElementById('classic-pattern');
const name = patSelect?.value || 'Arch 4'; const name = patSelect?.value || 'Arch 4';
return patternSlotCount(name); const baseCount = patternSlotCount(name);
const isStacked = (name || '').toLowerCase().includes('stacked');
if (!isStacked) return baseCount;
const lengthInp = document.getElementById('classic-length-ft');
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
const maxSlots = Math.min(MAX_SLOTS, clusters);
return Math.min(Math.max(baseCount, slotCount), maxSlots);
}
function renderSlots() {
slotsContainer.innerHTML = '';
const count = visibleSlotCount();
for (let i = 1; i <= count; i++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'slot-btn tab-btn';
btn.dataset.slot = String(i);
btn.textContent = `#${i}`;
btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); });
slotsContainer.appendChild(btn);
}
} }
function enforceSlotVisibility() { function enforceSlotVisibility() {
const count = visibleSlotCount(); const count = visibleSlotCount();
slots.forEach((slot, i) => {
const show = i < count;
slot.classList.toggle('hidden', !show);
if (!show && activeTarget === slot.dataset.slot) activeTarget = '1';
});
if (parseInt(activeTarget, 10) > count) activeTarget = '1'; if (parseInt(activeTarget, 10) > count) activeTarget = '1';
renderSlots();
} }
function updateUI() { function updateUI() {
enforceSlotVisibility(); enforceSlotVisibility();
[...slots, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); }); const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn'));
[...buttons, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
slots.forEach((slot, i) => { buttons.forEach((slot, i) => {
const color = classicColors[i]; const color = classicColors[i];
if (!color) return; // Safeguard against errors if (!color) return; // Safeguard against errors
slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none'; slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none';
@ -502,9 +520,17 @@ if (blockIndex % 2 === 1) {
topperSwatch.style.backgroundSize = '200%'; topperSwatch.style.backgroundSize = '200%';
topperSwatch.style.backgroundPosition = 'center'; topperSwatch.style.backgroundPosition = 'center';
const patSelect = document.getElementById('classic-pattern');
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
if (addSlotBtn) {
const lengthInp = document.getElementById('classic-length-ft');
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
const maxSlots = Math.min(MAX_SLOTS, clusters);
addSlotBtn.classList.toggle('hidden', !isStacked);
addSlotBtn.disabled = !isStacked || slotCount >= maxSlots;
}
activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`; activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`;
const displayColor = activeTarget === 'T' ? topperColor : classicColors[(parseInt(activeTarget, 10) || 1) - 1] || classicColors[0];
syncDockColor(displayColor);
} }
const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = ''; const allPaletteColors = flattenPalette(); swatchGrid.innerHTML = '';
@ -525,7 +551,7 @@ if (blockIndex % 2 === 1) {
if (activeTarget === 'T') setTopperColor(selectedColor); if (activeTarget === 'T') setTopperColor(selectedColor);
else { else {
const index = parseInt(activeTarget, 10) - 1; const index = parseInt(activeTarget, 10) - 1;
if (index >= 0 && index < 5) { classicColors[index] = selectedColor; setClassicColors(classicColors); } if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); }
} }
updateUI(); onColorChange(); updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
@ -534,7 +560,6 @@ if (blockIndex % 2 === 1) {
}); });
swatchGrid.appendChild(row); swatchGrid.appendChild(row);
}); });
slots.forEach(slot => { slot.addEventListener('click', () => { activeTarget = slot.dataset.slot; updateUI(); }); });
topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); });
randomizeBtn?.addEventListener('click', () => { randomizeBtn?.addEventListener('click', () => {
const pool = allPaletteColors.slice(); const picks = []; const pool = allPaletteColors.slice(); const picks = [];
@ -544,153 +569,137 @@ if (blockIndex % 2 === 1) {
updateUI(); onColorChange(); updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
}); });
addSlotBtn?.addEventListener('click', () => {
const patSelect = document.getElementById('classic-pattern');
const name = patSelect?.value || '';
const isStacked = name.toLowerCase().includes('stacked');
if (!isStacked) return;
const lengthInp = document.getElementById('classic-length-ft');
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
const maxSlots = Math.min(MAX_SLOTS, clusters);
if (slotCount >= maxSlots) return;
slotCount = setStoredSlotCount(slotCount + 1);
while (classicColors.length < slotCount) {
const fallback = allPaletteColors[Math.floor(Math.random() * allPaletteColors.length)] || { hex: '#ffffff', image: null };
classicColors.push({ hex: fallback.hex, image: fallback.image });
}
setClassicColors(classicColors);
updateUI(); onColorChange();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
});
updateUI(); updateUI();
} }
function initClassic() { function initClassic() {
try { try {
if (typeof window.m === 'undefined') return fail('Mithril not loaded'); if (typeof window.m === 'undefined') return fail('Mithril not loaded');
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperTypeSelect = document.getElementById('classic-topper-type'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), lengthPresetWrap = document.getElementById('classic-length-presets'), lengthLabel = document.getElementById('classic-length-label'); const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled');
const ARCH_LENGTHS = [20, 25, 30, 35, 40]; const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]'));
const COLUMN_LENGTHS = [3,4,5,6,7,8,9,10,11,12,13,14,15]; const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
const ARCH_DEFAULT = 20; const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
const COLUMN_DEFAULT = 5;
const lengthDialDrawer = document.getElementById('classic-length-drawer');
const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper')); const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper'));
const slotButtons = Array.from(document.querySelectorAll('#classic-slots .slot-btn')); const topperTypeButtons = Array.from(document.querySelectorAll('.topper-type-btn'));
const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn')); const slotsContainer = document.getElementById('classic-slots');
const variantBtns = Array.from(document.querySelectorAll('.classic-variant-btn'));
const topperBtns = Array.from(document.querySelectorAll('.classic-topper-btn'));
const topperInline = document.getElementById('topper-inline');
const topperTypeInline = document.getElementById('classic-topper-type-inline');
const topperSizeInline = document.getElementById('classic-topper-size-inline');
const topperColorInline = document.getElementById('classic-topper-color-swatch-inline');
let topperOffsetX = 0, topperOffsetY = 0; let topperOffsetX = 0, topperOffsetY = 0;
let userTopperChoice = false; let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}`
const topperPresets = {
'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 },
'Column 4:star': { enabled: true, offsetX: 3, offsetY: -7.5, size: 1.65 },
'Column 4:round': { enabled: true, offsetX: 3, offsetY: -2, size: 1.25 },
'Column 5:heart': { enabled: true, offsetX: 2, offsetY: -10, size: 1.15 },
'Column 5:star': { enabled: true, offsetX: 2.5, offsetY: -7.5, size: 1.75 },
'Column 5:round': { enabled: true, offsetX: 2.5, offsetY: -2, size: 1.3 }
};
if (!display) return fail('#classic-display not found'); if (!display) return fail('#classic-display not found');
const GC = GridCalculator(), ctrl = GC.controller(display); const GC = GridCalculator(), ctrl = GC.controller(display);
const syncDockPatternButtons = (patternName) => { const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
const isArch = (patternName || '').toLowerCase().includes('arch'); const setTopperType = (type) => {
const isFive = (patternName || '').includes('5'); topperTypeButtons.forEach(btn => {
patternBtns.forEach(btn => { const active = btn.dataset.type === type;
const base = (btn.dataset.patternBase || '').toLowerCase();
const active = isArch ? base === 'arch' : base === 'column';
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active)); btn.setAttribute('aria-pressed', String(active));
}); btn.classList.toggle('tab-active', active);
variantBtns.forEach(btn => { btn.classList.toggle('tab-idle', !active);
const active = btn.dataset.patternVariant === (isFive ? '5' : '4');
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
};
const syncDockTopperButton = () => {
const on = !!topperEnabledCb?.checked;
topperBtns.forEach(btn => {
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
}); });
}; };
const syncTopperInline = () => { function applyTopperPreset(patternName, type) {
const on = !!topperEnabledCb?.checked; const key = `${patternName}:${type}`;
if (topperInline) topperInline.classList.toggle('hidden', !on); const preset = topperPresets[key];
if (topperTypeInline && topperTypeSelect) topperTypeInline.value = topperTypeSelect.value; if (!preset) return;
if (topperSizeInline && topperSizeInp) topperSizeInline.value = topperSizeInp.value; if (lastPresetKey === key || lastPresetKey === 'custom') return;
if (topperColorInline) { topperOffsetX = preset.offsetX;
const tc = getTopperColor(); topperOffsetY = preset.offsetY;
topperColorInline.style.backgroundImage = tc.image ? `url("${tc.image}")` : 'none'; if (topperSizeInp) topperSizeInp.value = preset.size;
topperColorInline.style.backgroundColor = tc.hex; if (topperEnabledCb) topperEnabledCb.checked = preset.enabled;
} setTopperType(type);
lastPresetKey = key;
}
let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral';
const computePatternName = () => {
const base = patternShape === 'column' ? 'Column' : 'Arch';
const count = patternCount === 5 ? '5' : '4';
const layout = patternLayout === 'stacked' ? ' Stacked' : '';
return `${base} ${count}${layout}`;
}; };
const syncPatternStateFromSelect = () => {
const renderLengthPresets = (patternName) => { const val = (patSel?.value || '').toLowerCase();
if (!lengthInp) return; patternShape = val.includes('column') ? 'column' : 'arch';
const isArch = (patternName || '').toLowerCase().includes('arch'); patternCount = val.includes('5') ? 5 : 4;
const list = isArch ? ARCH_LENGTHS : COLUMN_LENGTHS; patternLayout = val.includes('stacked') ? 'stacked' : 'spiral';
const current = parseFloat(lengthInp.value) || (isArch ? ARCH_DEFAULT : COLUMN_DEFAULT); };
if (lengthLabel) lengthLabel.textContent = `${current} ft`; const applyPatternButtons = () => {
const setActive = (btns, attr, val) => btns.forEach(b => {
const targets = [lengthPresetWrap, lengthDialDrawer]; const active = b.dataset[attr] === val;
targets.forEach(container => { b.classList.toggle('tab-active', active);
if (!container) return; b.classList.toggle('tab-idle', !active);
container.innerHTML = ''; b.setAttribute('aria-pressed', String(active));
list.forEach(len => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'dock-pill';
if (Math.abs(current - len) < 1e-6) btn.classList.add('active');
btn.textContent = `${len} ft`;
btn.dataset.len = len;
btn.addEventListener('click', () => {
lengthInp.value = len;
if (lengthLabel) lengthLabel.textContent = `${len} ft`;
updateClassicDesign();
btn.classList.add('ping');
btn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
setTimeout(() => btn.classList.remove('ping'), 280);
document.getElementById('classic-drawer-pattern')?.classList.add('hidden');
window.__dockActiveMenu = null;
});
container.appendChild(btn);
});
}); });
setActive(patternShapeBtns, 'patternShape', patternShape);
setActive(patternCountBtns, 'patternCount', String(patternCount));
setActive(patternLayoutBtns, 'patternLayout', patternLayout);
}; };
syncPatternStateFromSelect();
const ensureLengthForPattern = (patternName) => { applyPatternButtons();
if (!lengthInp) return;
const isArch = (patternName || '').toLowerCase().includes('arch');
const list = isArch ? ARCH_LENGTHS : COLUMN_LENGTHS;
const cur = parseFloat(lengthInp.value);
if (!list.includes(cur)) {
const def = isArch ? ARCH_DEFAULT : COLUMN_DEFAULT;
lengthInp.value = def;
}
};
function updateClassicDesign() { function updateClassicDesign() {
if (!lengthInp || !patSel) return; if (!lengthInp || !patSel) return;
patSel.value = computePatternName();
const patternName = patSel.value || 'Arch 4'; const patternName = patSel.value || 'Arch 4';
ensureLengthForPattern(patternName);
const isColumn = patternName.toLowerCase().includes('column'); const isColumn = patternName.toLowerCase().includes('column');
const hasTopper = patternName.includes('4') || patternName.includes('5'); const hasTopper = patternName.includes('4') || patternName.includes('5');
const showToggle = isColumn && hasTopper; const showToggle = isColumn && hasTopper;
if (showToggle && topperEnabledCb && !userTopperChoice) { if (patternName.toLowerCase().includes('column')) {
topperEnabledCb.checked = true; const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4';
applyTopperPreset(baseName, getTopperType());
} }
if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle); if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle);
const showTopper = showToggle && topperEnabledCb?.checked; const showTopper = showToggle && topperEnabledCb?.checked;
slotButtons.forEach((btn, i) => {
const count = patternSlotCount(patternName);
const show = i < count;
btn.classList.toggle('hidden', !show);
});
topperControls.classList.toggle('hidden', !showTopper); topperControls.classList.toggle('hidden', !showTopper);
topperTypeSelect.disabled = !showTopper;
GC.setTopperEnabled(showTopper); GC.setTopperEnabled(showTopper);
GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2)); GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2));
GC.setReverse(!!reverseCb?.checked); GC.setReverse(!!reverseCb?.checked);
GC.setTopperType(topperTypeSelect.value); GC.setTopperType(getTopperType());
GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetX(topperOffsetX);
GC.setTopperOffsetY(topperOffsetY); GC.setTopperOffsetY(topperOffsetY);
GC.setTopperSize(topperSizeInp?.value); GC.setTopperSize(topperSizeInp?.value);
GC.setShineEnabled(!!shineEnabledCb?.checked); GC.setShineEnabled(!!shineEnabledCb?.checked);
if (document.body) {
if (showTopper) document.body.dataset.topperOverlay = '1';
else delete document.body.dataset.topperOverlay;
}
window.__updateFloatingNudge?.();
if(clusterHint) clusterHint.textContent = `${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`; if(clusterHint) clusterHint.textContent = `${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
ctrl.selectPattern(patternName); ctrl.selectPattern(patternName);
syncDockPatternButtons(patternName);
syncDockTopperButton();
renderLengthPresets(patternName);
syncTopperInline();
} }
const setLengthForPattern = () => { const setLengthForPattern = () => {
if (!lengthInp || !patSel) return; if (!lengthInp || !patSel) return;
const isArch = (patSel.value || '').toLowerCase().includes('arch'); const isArch = (computePatternName()).toLowerCase().includes('arch');
lengthInp.value = isArch ? ARCH_DEFAULT : COLUMN_DEFAULT; lengthInp.value = isArch ? 20 : 5;
}; };
window.ClassicDesigner = window.ClassicDesigner || {}; window.ClassicDesigner = window.ClassicDesigner || {};
@ -701,45 +710,38 @@ if (blockIndex % 2 === 1) {
document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50));
patSel?.addEventListener('change', () => { patSel?.addEventListener('change', () => {
ensureLengthForPattern(patSel.value); lastPresetKey = null;
syncPatternStateFromSelect();
applyPatternButtons();
setLengthForPattern();
updateClassicDesign(); updateClassicDesign();
renderLengthPresets(patSel.value);
}); });
patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => { patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; lastPresetKey = null; applyPatternButtons(); updateClassicDesign(); }));
topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => { topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => {
const dx = Number(btn.dataset.dx || 0); const dx = Number(btn.dataset.dx || 0);
const dy = Number(btn.dataset.dy || 0); const dy = Number(btn.dataset.dy || 0);
topperOffsetX += dx; topperOffsetX += dx;
topperOffsetY += dy; topperOffsetY += dy;
lastPresetKey = 'custom';
GC.setTopperOffsetX(topperOffsetX); GC.setTopperOffsetX(topperOffsetX);
GC.setTopperOffsetY(topperOffsetY); GC.setTopperOffsetY(topperOffsetY);
updateClassicDesign(); updateClassicDesign();
})); }));
[lengthInp, reverseCb, topperEnabledCb, topperTypeSelect, topperSizeInp] topperTypeButtons.forEach(btn => btn.addEventListener('click', () => {
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, updateClassicDesign); }); setTopperType(btn.dataset.type);
lastPresetKey = null;
updateClassicDesign();
}));
[lengthInp, reverseCb, topperEnabledCb, topperSizeInp]
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
topperEnabledCb?.addEventListener('change', updateClassicDesign); topperEnabledCb?.addEventListener('change', updateClassicDesign);
topperEnabledCb?.addEventListener('change', (e) => {
if (e.isTrusted) userTopperChoice = true;
syncDockTopperButton();
syncTopperInline();
});
topperTypeInline?.addEventListener('change', () => {
if (topperTypeSelect) topperTypeSelect.value = topperTypeInline.value;
updateClassicDesign();
});
topperSizeInline?.addEventListener('input', () => {
if (topperSizeInp) topperSizeInp.value = topperSizeInline.value;
updateClassicDesign();
});
topperColorInline?.addEventListener('click', () => {
const sw = document.getElementById('classic-topper-color-swatch');
sw?.click();
});
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); }); shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
initClassicColorPicker(updateClassicDesign); initClassicColorPicker(updateClassicDesign);
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {} try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
setLengthForPattern(); setLengthForPattern();
updateClassicDesign(); updateClassicDesign();
renderLengthPresets(patSel?.value || '');
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
log('Classic ready'); log('Classic ready');
} catch (e) { fail(e.message || e); } } catch (e) { fail(e.message || e); }

View File

@ -13,6 +13,7 @@
<script src="colors.js"></script> <script src="colors.js"></script>
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style> <style>
.tab-btn{padding:.5rem .75rem;border-radius:.5rem;font-size:.875rem;font-weight:600;transition:background-color .2s,color .2s,box-shadow .2s} .tab-btn{padding:.5rem .75rem;border-radius:.5rem;font-size:.875rem;font-weight:600;transition:background-color .2s,color .2s,box-shadow .2s}
.tab-active{background:#2563eb;color:#fff;box-shadow:0 2px 6px rgba(37,99,235,.35)} .tab-active{background:#2563eb;color:#fff;box-shadow:0 2px 6px rgba(37,99,235,.35)}
@ -21,42 +22,59 @@
.copy-message{opacity:0;pointer-events:none;transition:opacity .2s}.copy-message.show{opacity:1} .copy-message{opacity:0;pointer-events:none;transition:opacity .2s}.copy-message.show{opacity:1}
</style> </style>
</head> </head>
<body class="flex flex-col min-h-screen p-0 md:p-6 items-center justify-start bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800"> <body class="p-0 md:p-6 flex flex-col items-center justify-start min-h-screen bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800 overflow-hidden">
<div class="container mx-auto mt-2 p-4 lg:p-6 bg-white/80 lg:backdrop-blur-xl rounded-3xl border border-white/50 shadow-2xl flex flex-col gap-4 max-w-7xl lg:h-[calc(100vh-2rem)] overflow-hidden ring-1 ring-black/5">
<header class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
<header class="flex items-center justify-between gap-3 px-2 lg:px-0 py-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div> <div>
<div class="text-xl font-black text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-pink-600 tracking-tight filter drop-shadow-sm">Balloon Studio</div> <div class="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-pink-600 tracking-tight filter drop-shadow-sm">Balloon Studio</div>
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
</div> </div>
</div> </div>
<nav id="mode-tabs" class="flex gap-2">
<!-- Mode Switcher Restored --> <button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
<nav id="mode-tabs" class="flex gap-1 bg-slate-100 p-1 rounded-lg"> <button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
<button type="button" class="tab-btn tab-active text-xs !py-1 !px-2" data-target="#tab-organic" aria-pressed="true">Organic</button>
<button type="button" class="tab-btn tab-idle text-xs !py-1 !px-2" data-target="#tab-classic" aria-pressed="false">Classic</button>
</nav> </nav>
<div class="flex items-center gap-2">
<button id="header-undo" class="tool-btn !p-2" title="Undo">
<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
</button>
<button id="header-redo" class="tool-btn !p-2" title="Redo">
<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 9 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>
</button>
<button id="header-export" class="btn-blue text-xs font-bold !py-2 !px-3" data-export="png">Export</button>
</div>
</header> </header>
<section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]"> <section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto"> <aside id="controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row"> <div class="panel-header-row">
<h2 class="panel-title">Organic Controls</h2> <h2 class="panel-title">Organic Controls</h2>
<button type="button" class="sheet-close-btn" data-sheet-toggle="controls-panel">Hide</button>
</div> </div>
<div class="control-stack" data-mobile-tab="controls"> <div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Selection Options</div> <div class="panel-heading">Tools</div>
<div class="panel-card"> <div class="panel-card">
<div class="grid grid-cols-3 gap-2 mb-3">
<button id="tool-draw" class="tool-btn" aria-pressed="true" title="V">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<span class="hidden sm:inline">Draw</span>
</button>
<button id="tool-erase" class="tool-btn" aria-pressed="false" title="E">
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l8.48-8.48-3.54-3.54-8.48 8.48c-.79.79-.79 2.05 0 2.84z"/></svg>
<span class="hidden sm:inline">Erase</span>
</button>
<button id="tool-select" class="tool-btn" aria-pressed="false" title="S">
<svg viewBox="0 0 24 24"><path d="M7 2l12 11.2-5.8.5 3.3 7.3-2.2.9-3.2-7.4-4.4 4V2z"/></svg>
<span class="hidden sm:inline">Select</span>
</button>
</div>
<div class="grid grid-cols-3 gap-2 mb-3">
<button id="tool-undo" class="tool-btn" title="Ctrl+Z" aria-label="Undo">
<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
<span class="hidden sm:inline">Undo</span>
</button>
<button id="tool-redo" class="tool-btn" title="Ctrl+Y" aria-label="Redo">
<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 9 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>
<span class="hidden sm:inline">Redo</span>
</button>
<button id="tool-eyedropper" class="tool-btn" title="Pick Color" aria-label="Eyedropper" aria-pressed="false">
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
<span class="hidden sm:inline">Picker</span>
</button>
</div>
<div id="eraser-controls" class="hidden flex flex-col gap-2"> <div id="eraser-controls" class="hidden flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label> <label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
<input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> <input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
@ -94,46 +112,45 @@
</div> </div>
<p class="hint">Click a balloon to select. Del/Backspace removes. Esc clears.</p> <p class="hint">Click a balloon to select. Del/Backspace removes. Esc clears.</p>
</div> </div>
<!-- Fallback hint if nothing selected/erasing -->
<p id="controls-empty-hint" class="hint mt-2 hidden">Select a balloon or choose the Eraser tool to see options.</p>
</div> </div>
</div>
<div class="control-stack" data-mobile-tab="colors"> <div class="panel-heading mt-4">Size & Shine</div>
<div class="panel-heading">Balloon Size</div>
<div class="panel-card"> <div class="panel-card">
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div> <div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
<p class="hint mb-3">Global scale lives in <code>PX_PER_INCH</code> (see <code>script.js</code>).</p>
<label class="text-sm inline-flex items-center gap-2 font-medium"> <label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked> <input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
Enable Shine Enable Shine
</label> </label>
</div> </div>
</div>
<div class="panel-heading mt-4">Palette in Use</div> <div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Used Colors</div>
<div class="panel-card"> <div class="panel-card">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">Colors currently on your canvas.</span> <span class="text-sm text-gray-600">Built from the current design. Click a swatch to select that color.</span>
<button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button> <button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button>
</div> </div>
<div id="used-palette" class="palette-box min-h-[3rem]"></div> <div id="used-palette" class="palette-box min-h-[3rem]"></div>
</div> </div>
<div class="panel-heading mt-4">Color Library</div> <div class="panel-heading mt-4">Allowed Colors</div>
<div class="panel-card"> <div class="panel-card">
<p class="hint mb-2">Pick a color to draw with.</p> <p class="hint mb-2">Alt+Click a balloon on canvas to pick its color.</p>
<div id="color-palette" class="palette-box"></div> <div id="color-palette" class="palette-box"></div>
</div> </div>
<div class="panel-heading mt-4">Swap Colors</div> <div class="panel-heading mt-4">Replace Color</div>
<div class="panel-card"> <div class="panel-card">
<div class="grid grid-cols-1 gap-2"> <div class="grid grid-cols-1 gap-2">
<label class="text-sm font-medium">Change this color:</label> <label class="text-sm font-medium">From (used):</label>
<select id="replace-from" class="select"></select> <select id="replace-from" class="select"></select>
<label class="text-sm font-medium">To this new color:</label> <label class="text-sm font-medium">To (allowed):</label>
<select id="replace-to" class="select"></select> <select id="replace-to" class="select"></select>
<button id="replace-btn" class="btn-blue">Swap All</button> <button id="replace-btn" class="btn-blue">Replace</button>
<p id="replace-msg" class="hint"></p> <p id="replace-msg" class="hint"></p>
</div> </div>
</div> </div>
@ -175,32 +192,45 @@
<aside id="classic-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto"> <aside id="classic-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row"> <div class="panel-header-row">
<h2 class="panel-title">Classic Controls</h2> <h2 class="panel-title">Classic Controls</h2>
<button type="button" class="sheet-close-btn" data-sheet-toggle="classic-controls-panel">Hide</button>
</div> </div>
<div class="control-stack" data-mobile-tab="controls"> <div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Pattern & Layout</div> <div class="panel-heading">Pattern & Layout</div>
<div class="panel-card space-y-4"> <div class="panel-card space-y-4">
<div class="flex flex-wrap items-center gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<span class="classic-quick-label">Colors</span> <div class="space-y-2">
<div class="dock-pill-group"> <div class="text-sm font-medium text-gray-700">Shape</div>
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="4">4 colors</button> <div class="flex gap-2">
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="5">5 colors</button> <button type="button" class="tab-btn tab-active pattern-btn" data-pattern-shape="arch" aria-pressed="true">Arch</button>
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-shape="column" aria-pressed="false">Column</button>
</div>
</div> </div>
</div> <div class="space-y-2">
<select id="classic-pattern" class="select hidden"> <div class="text-sm font-medium text-gray-700">Balloon Count</div>
<option value="Arch 4">Arch 4 (4-color spiral)</option> <div class="flex gap-2">
<option value="Column 4">Column 4 (quad wrap)</option> <button type="button" class="tab-btn tab-active pattern-btn" data-pattern-count="4" aria-pressed="true">4 Colors</button>
<option value="Arch 5">Arch 5 (5-color spiral)</option> <button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-count="5" aria-pressed="false">5 Colors</button>
<option value="Column 5">Column 5 (5-balloon wrap)</option> </div>
</select>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="classic-quick-label">Length</span>
<span class="text-xs text-gray-500" id="classic-length-label">ft</span>
</div> </div>
<div id="classic-length-presets" class="length-dial"></div> <div class="md:col-span-2 space-y-2">
<input id="classic-length-ft" type="number" min="1" max="100" step="0.5" value="5" class="hidden"> <div class="text-sm font-medium text-gray-700">Layout</div>
<div class="flex gap-2">
<button type="button" class="tab-btn tab-active pattern-btn" data-pattern-layout="spiral" aria-pressed="true">Spiral</button>
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="stacked" aria-pressed="false">Stacked</button>
</div>
</div>
<select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1">
<option value="Arch 4">Arch 4 (4-color spiral)</option>
<option value="Column 4">Column 4 (quad wrap)</option>
<option value="Arch 5">Arch 5 (5-color spiral)</option>
<option value="Column 5">Column 5 (5-balloon wrap)</option>
<option value="Arch 4 Stacked">Arch 4 (stacked)</option>
<option value="Column 4 Stacked">Column 4 (stacked)</option>
<option value="Arch 5 Stacked">Arch 5 (stacked)</option>
<option value="Column 5 Stacked">Column 5 (stacked)</option>
</select>
<label class="text-sm">Length (ft):
<input id="classic-length-ft" type="number" min="1" max="100" step="0.5" value="5" class="w-full px-2 py-1 border rounded align-middle">
</label>
</div> </div>
<div id="classic-topper-toggle-row" class="flex items-center gap-3 pt-2 border-t border-gray-200 hidden"> <div id="classic-topper-toggle-row" class="flex items-center gap-3 pt-2 border-t border-gray-200 hidden">
@ -210,29 +240,19 @@
</label> </label>
</div> </div>
<div id="topper-controls" class="hidden grid grid-cols-1 sm:grid-cols-4 gap-3 items-end pt-2"> <div id="topper-controls" class="hidden grid grid-cols-1 sm:grid-cols-4 gap-3 items-start pt-2">
<label class="text-sm sm:col-span-2">Topper Type: <div class="sm:col-span-2">
<select id="classic-topper-type" class="select align-middle" disabled> <div class="text-sm font-medium mb-2">Topper Shape</div>
<option value="round">24" Round</option> <div id="topper-type-group" class="topper-type-group">
<option value="star">24" Star</option> <button type="button" class="tab-btn topper-type-btn tab-active" data-type="round" aria-pressed="true"><i class="fa-regular fa-circle-dot"></i><span class="hidden sm:inline">Round</span></button>
<option value="heart">24" Heart</option> <button type="button" class="tab-btn topper-type-btn tab-idle" data-type="star" aria-pressed="false"><i class="fa-solid fa-star"></i><span class="hidden sm:inline">Star</span></button>
</select> <button type="button" class="tab-btn topper-type-btn tab-idle" data-type="heart" aria-pressed="false"><i class="fa-solid fa-heart"></i><span class="hidden sm:inline">Heart</span></button>
</label>
<label class="text-sm">Topper Color:
<div id="classic-topper-color-swatch" class="slot-swatch mx-auto" title="Click to change topper color">T</div>
</label>
<div class="col-span-full">
<div class="panel-heading mt-2 mb-2">Topper Nudge</div>
<div class="grid grid-cols-3 gap-2 max-w-xs justify-items-center">
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0.5" data-dy="0" aria-label="Move Topper Right"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="-0.5" aria-label="Move Topper Down"></button>
<button type="button" class="btn-nudge nudge-topper col-span-3" data-dx="-0.5" data-dy="0" aria-label="Move Topper Left"></button>
</div> </div>
</div> </div>
<label class="text-sm">Topper Size: <div class="sm:col-span-2">
<input id="classic-topper-size" type="range" min="0.5" max="2" step="0.05" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"> <div class="text-sm font-medium mb-1">Topper Size</div>
</label> <input id="classic-topper-size" type="range" min="0.5" max="2" step="0.05" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
@ -255,18 +275,20 @@
<div class="control-stack" data-mobile-tab="colors"> <div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Classic Colors</div> <div class="panel-heading">Classic Colors</div>
<div class="panel-card"> <div class="panel-card">
<div id="classic-slots" class="flex items-center gap-2 mb-3"> <div class="flex items-center justify-between mb-2">
<button type="button" class="slot-btn tab-btn" data-slot="1">#1</button> <div id="classic-slots" class="flex items-center gap-2"></div>
<button type="button" class="slot-btn tab-btn" data-slot="2">#2</button> <button id="classic-add-slot" class="btn-dark text-sm px-3 py-2 hidden" type="button" title="Add color slot">+</button>
<button type="button" class="slot-btn tab-btn" data-slot="3">#3</button>
<button type="button" class="slot-btn tab-btn" data-slot="4">#4</button>
<button type="button" class="slot-btn tab-btn" data-slot="5">#5</button>
</div> </div>
<div class="text-sm text-gray-600 mb-1">Pick a color for <span id="classic-active-label" class="font-bold">Slot #1</span> (from colors.js):</div> <div class="text-sm text-gray-600 mb-1">Pick a color for <span id="classic-active-label" class="font-bold">Slot #1</span> (from colors.js):</div>
<div id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div> <div id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div>
<div class="flex flex-wrap gap-2 mt-3"> <div class="flex flex-wrap gap-2 mt-3">
<button id="classic-randomize-colors" class="btn-dark">Randomize 5</button> <button id="classic-randomize-colors" class="btn-dark">Randomize 5</button>
</div> </div>
<div class="panel-heading mt-3">Topper Color</div>
<div class="flex items-center gap-3">
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
<p class="hint">Select a color then click to apply.</p>
</div>
</div> </div>
</div> </div>
@ -288,90 +310,43 @@
<div id="classic-display" <div id="classic-display"
class="rounded-xl" class="rounded-xl"
style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div> style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div>
</section> <div id="floating-topper-nudge" class="floating-nudge hidden">
<div class="floating-nudge-header">
</section> <div class="panel-heading">Nudge Topper</div>
<button type="button" id="floating-nudge-toggle" class="btn-dark text-xs px-3 py-2">Hide</button>
</div>
<div id="mobile-tabbar" class="mobile-tabbar justify-center gap-6 !py-4 !bg-slate-900/95 backdrop-blur-xl border-t border-white/10"> <div class="floating-nudge-body">
<div id="dock-organic" class="flex items-center gap-3"> <div class="grid grid-cols-3 gap-2">
<button id="dock-draw" class="mobile-tool-btn active" data-dock="organic" title="Draw"> <div></div>
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> <button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up"></button>
</button> <div></div>
<button id="dock-erase" class="mobile-tool-btn" data-dock="organic" title="Erase"> <button type="button" class="btn-dark nudge-topper" data-dx="-0.5" data-dy="0" aria-label="Move Topper Left"></button>
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l8.48-8.48-3.54-3.54-8.48 8.48c-.79.79-.79 2.05 0 2.84z"/></svg> <div></div>
</button> <button type="button" class="btn-dark nudge-topper" data-dx="0.5" data-dy="0" aria-label="Move Topper Right"></button>
<button id="dock-color-trigger" class="dock-color-btn" style="background-color: #2563eb;" aria-label="Open Colors"></button> <div></div>
<button id="dock-select" class="mobile-tool-btn" data-dock="organic" title="Select"> <button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="-0.5" aria-label="Move Topper Down"></button>
<svg viewBox="0 0 24 24"><path d="M7 2l12 11.2-5.8.5 3.3 7.3-2.2.9-3.2-7.4-4.4 4V2z"/></svg> <div></div>
</button> </div>
<button id="dock-picker" class="mobile-tool-btn" data-dock="organic" title="Picker">
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
</button>
</div>
<div id="dock-classic" class="hidden flex items-center justify-center gap-3">
<button id="dock-arch" class="mobile-tool-btn classic-pattern-btn" data-pattern-base="Arch" title="Arch Pattern">
<svg viewBox="0 0 24 24"><path d="M4 18a8 8 0 0 1 16 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button id="dock-classic-color" class="dock-color-btn" style="background-color:#2563eb;" aria-label="Open Colors"></button>
<button id="dock-column" class="mobile-tool-btn classic-pattern-btn" data-pattern-base="Column" title="Column Pattern">
<svg viewBox="0 0 24 24"><path d="M8 4v16M16 4v16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<!-- Classic drawers for mobile -->
<div id="classic-drawer-pattern" class="classic-drawer hidden">
<div class="drawer-row">
<div class="drawer-pill-group">
<button type="button" class="dock-pill classic-pattern-btn" data-pattern-base="Arch">Arch</button>
<button type="button" class="dock-pill classic-pattern-btn" data-pattern-base="Column">Column</button>
</div>
<div class="drawer-pill-group">
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="4">4 colors</button>
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="5">5 colors</button>
</div>
</div>
<div class="drawer-row dial-row">
<span class="drawer-label">Length</span>
<div id="classic-length-drawer" class="length-dial"></div>
</div>
<div class="drawer-row topper-row">
<button type="button" class="dock-pill classic-topper-btn" data-topper-toggle>Topper</button>
<div class="topper-inline hidden" id="topper-inline">
<select id="classic-topper-type-inline" class="select select-xs">
<option value="round">Round</option>
<option value="star">Star</option>
<option value="heart">Heart</option>
</select>
<div id="classic-topper-color-swatch-inline" class="slot-swatch" title="Topper color">T</div>
<div class="nudge-pad">
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="0.5"></button>
<div class="flex gap-2">
<button type="button" class="btn-nudge nudge-topper" data-dx="-0.5" data-dy="0"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0.5" data-dy="0"></button>
</div> </div>
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="-0.5"></button>
</div> </div>
<div class="topper-size-wrap"> </section>
<label class="text-xs text-slate-600">Size</label> </section>
<input id="classic-topper-size-inline" type="range" min="0.5" max="2" step="0.05" value="1">
</div>
</div>
</div>
</div> </div>
<div id="classic-drawer-colors" class="classic-drawer hidden"> <div id="mobile-tabbar" class="mobile-tabbar">
<div class="drawer-row"> <button type="button" class="mobile-tab-btn" data-mobile-tab="controls" aria-pressed="true" aria-label="Tools">
<div id="classic-slots-drawer" class="flex items-center gap-2"></div> <i class="mobile-tab-icon fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
<div class="flex-1 text-right"> <span class="sr-only">Tools</span>
<button id="classic-randomize-colors-inline" class="dock-pill">Shuffle 5</button> </button>
</div> <button type="button" class="mobile-tab-btn" data-mobile-tab="colors" aria-pressed="false" aria-label="Colors">
</div> <i class="mobile-tab-icon fa-solid fa-palette" aria-hidden="true"></i>
<div id="classic-swatch-drawer" class="palette-box"></div> <span class="sr-only">Colors</span>
</button>
<button type="button" class="mobile-tab-btn" data-mobile-tab="save" aria-pressed="false" aria-label="Save and Share">
<i class="mobile-tab-icon fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
<span class="sr-only">Save</span>
</button>
</div> </div>
<div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50"> <div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">

406
script.js
View File

@ -199,19 +199,8 @@
} }
// Bind Undo/Redo Buttons // Bind Undo/Redo Buttons
document.getElementById('tool-undo')?.addEventListener('click', () => { document.getElementById('tool-undo')?.addEventListener('click', undo);
undo(); document.getElementById('tool-redo')?.addEventListener('click', redo);
// Auto-minimize on mobile to see changes
if (window.innerWidth < 1024) {
document.getElementById('controls-panel')?.classList.add('minimized');
}
});
document.getElementById('tool-redo')?.addEventListener('click', () => {
redo();
if (window.innerWidth < 1024) {
document.getElementById('controls-panel')?.classList.add('minimized');
}
});
// Eyedropper Tool // Eyedropper Tool
const toolEyedropperBtn = document.getElementById('tool-eyedropper'); const toolEyedropperBtn = document.getElementById('tool-eyedropper');
@ -221,10 +210,6 @@
setMode('draw'); // toggle off setMode('draw'); // toggle off
} else { } else {
setMode('eyedropper'); setMode('eyedropper');
// Auto-minimize on mobile
if (window.innerWidth < 1024) {
document.getElementById('controls-panel')?.classList.add('minimized');
}
} }
}); });
@ -304,53 +289,18 @@
toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select')); toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select'));
toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper')); toolEyedropperBtn?.setAttribute('aria-pressed', String(mode === 'eyedropper'));
// Update Mobile Dock Active States
document.querySelectorAll('.mobile-tool-btn[data-dock="organic"]').forEach(btn => btn.classList.remove('active'));
if (mode === 'draw') document.getElementById('dock-draw')?.classList.add('active');
if (mode === 'erase') document.getElementById('dock-erase')?.classList.add('active');
if (mode === 'select') document.getElementById('dock-select')?.classList.add('active');
if (mode === 'eyedropper') document.getElementById('dock-picker')?.classList.add('active');
eraserControls?.classList.toggle('hidden', mode !== 'erase'); eraserControls?.classList.toggle('hidden', mode !== 'erase');
selectControls?.classList.toggle('hidden', mode !== 'select'); selectControls?.classList.toggle('hidden', mode !== 'select');
// Show/Hide empty hint in Selection Options panel
const emptyHint = document.getElementById('controls-empty-hint');
if (emptyHint) {
emptyHint.classList.toggle('hidden', mode === 'erase' || mode === 'select');
emptyHint.textContent = mode === 'draw' ? 'Switch to Select or Erase tool to see options.' : 'Select a tool...';
}
if (mode === 'erase') canvas.style.cursor = 'none'; if (mode === 'erase') canvas.style.cursor = 'none';
else if (mode === 'select') { else if (mode === 'select') canvas.style.cursor = 'default'; // will be move over items
canvas.style.cursor = 'default';
}
else if (mode === 'eyedropper') canvas.style.cursor = 'cell'; else if (mode === 'eyedropper') canvas.style.cursor = 'cell';
else canvas.style.cursor = 'crosshair'; else canvas.style.cursor = 'crosshair';
// Contextual Tab Switching
if (window.innerWidth < 1024) {
if (mode === 'select' || mode === 'erase') {
setMobileTab('controls');
} else if (mode === 'draw') {
// Optional: switch to colors, or stay put?
// setMobileTab('colors');
}
// Minimize drawer on tool switch to clear view
const panel = document.getElementById('controls-panel');
if (panel && !panel.classList.contains('minimized')) {
panel.classList.add('minimized');
}
}
draw(); draw();
persist(); persist();
} }
// ... (rest of the file) ...
function updateSelectButtons() { function updateSelectButtons() {
const has = !!selectedBalloonId; const has = !!selectedBalloonId;
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
@ -466,31 +416,13 @@
}, { passive: true }); }, { passive: true });
// ====== Canvas & Drawing ====== // ====== Canvas & Drawing ======
let hasFittedView = false;
function resizeCanvas() { function resizeCanvas() {
const rect = canvas.parentElement?.getBoundingClientRect?.() || canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const prevDpr = dpr || 1;
const prevCw = canvas.width / prevDpr;
const prevCh = canvas.height / prevDpr;
const prevCenter = {
x: (prevCw / 2) / (view.s || 1) - view.tx,
y: (prevCh / 2) / (view.s || 1) - view.ty
};
dpr = Math.max(1, window.devicePixelRatio || 1); dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(Math.min(rect.width, window.innerWidth) * dpr); canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(Math.min(rect.height, window.innerHeight) * dpr); canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
fitView();
if (!hasFittedView) {
fitView();
hasFittedView = true;
} else if (prevCw > 0 && prevCh > 0) {
const cw = canvas.width / dpr;
const ch = canvas.height / dpr;
view.tx = (cw / (2 * (view.s || 1))) - prevCenter.x;
view.ty = (ch / (2 * (view.s || 1))) - prevCenter.y;
}
draw(); draw();
} }
function clearCanvasArea() { function clearCanvasArea() {
@ -631,22 +563,8 @@
function saveAppState() { function saveAppState() {
// Note: isShineEnabled is managed globally. // Note: isShineEnabled is managed globally.
const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, mode, view, usedSortDesc }; const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, view, usedSortDesc };
try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {} try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
// Update dock color trigger
const meta = FLAT_COLORS[selectedColorIdx];
const trig = document.getElementById('dock-color-trigger');
if (trig && meta) {
if (meta.image) {
trig.style.backgroundImage = `url("${meta.image}")`;
trig.style.backgroundSize = '200%';
trig.style.backgroundColor = 'transparent';
} else {
trig.style.backgroundImage = 'none';
trig.style.backgroundColor = meta.hex;
}
}
} }
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })(); const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
@ -664,7 +582,6 @@
if (eraserSizeInput) eraserSizeInput.value = eraserRadius; if (eraserSizeInput) eraserSizeInput.value = eraserRadius;
if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius; if (eraserSizeLabel) eraserSizeLabel.textContent = eraserRadius;
} }
if (typeof s.mode === 'string') mode = s.mode;
if (s.view && typeof s.view.s === 'number') view = s.view; if (s.view && typeof s.view.s === 'number') view = s.view;
if (typeof s.usedSortDesc === 'boolean') { if (typeof s.usedSortDesc === 'boolean') {
usedSortDesc = s.usedSortDesc; usedSortDesc = s.usedSortDesc;
@ -1188,20 +1105,11 @@
return id; return id;
} }
function updateSheets(activeId) { function updateSheets() {
const tab = activeId || detectCurrentTab(); const tab = detectCurrentTab();
// Panels should be visible if their tab is active. const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1';
// Mobile minimization is handled by the .minimized class, not .hidden. if (orgSheet) orgSheet.classList.toggle('hidden', hide || tab !== '#tab-organic');
if (orgSheet) orgSheet.classList.toggle('hidden', tab !== '#tab-organic'); if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic');
if (claSheet) claSheet.classList.toggle('hidden', tab !== '#tab-classic');
// Ensure Dock is visible on both tabs (content managed by setTab)
const dock = document.getElementById('mobile-tabbar');
if (dock) dock.style.display = 'flex';
const dockOrg = document.getElementById('dock-organic');
const dockCla = document.getElementById('dock-classic');
dockOrg?.classList.toggle('hidden', tab === '#tab-classic');
dockCla?.classList.toggle('hidden', tab !== '#tab-classic');
} }
async function exportPng() { async function exportPng() {
@ -1333,7 +1241,6 @@
const worldH = ch / view.s; const worldH = ch / view.s;
view.tx = (worldW - w) * 0.5 - box.minX; view.tx = (worldW - w) * 0.5 - box.minX;
view.ty = (worldH - h) * 0.5 - box.minY; view.ty = (worldH - h) * 0.5 - box.minY;
hasFittedView = true;
} }
function balloonScreenBounds(b) { function balloonScreenBounds(b) {
@ -1518,6 +1425,7 @@
window.syncAppShine(on); window.syncAppShine(on);
}); });
mode = 'draw'; // force default tool on load
renderAllowedPalette(); renderAllowedPalette();
resizeCanvas(); resizeCanvas();
loadFromUrl(); loadFromUrl();
@ -1561,17 +1469,12 @@
if (!panel) return; if (!panel) return;
const stacks = Array.from(panel.querySelectorAll('.control-stack')); const stacks = Array.from(panel.querySelectorAll('.control-stack'));
if (!stacks.length) return; if (!stacks.length) return;
// If we passed 'all', show everything (Desktop mode)
const showAll = (tabName === 'all');
stacks.forEach(stack => { stacks.forEach(stack => {
if (isHidden) { if (isHidden) {
stack.style.display = 'none'; stack.style.display = 'none';
} else { } else {
const show = showAll ? true : stack.dataset.mobileTab === target; const show = stack.dataset.mobileTab === target;
// Use flex to match CSS .control-stack stack.style.display = show ? 'block' : 'none';
stack.style.display = show ? 'flex' : 'none';
} }
}); });
} }
@ -1590,6 +1493,21 @@
} }
window.__setMobileTab = setMobileTab; window.__setMobileTab = setMobileTab;
let floatingNudgeCollapsed = false;
function updateFloatingNudge() {
const el = document.getElementById('floating-topper-nudge');
if (!el) return;
const isMobile = !window.matchMedia('(min-width: 1024px)').matches;
const classicActive = document.body?.dataset.activeTab === '#tab-classic';
const topperActive = document.body?.dataset.topperOverlay === '1';
const shouldShow = isMobile && classicActive && topperActive;
el.classList.toggle('hidden', !shouldShow);
el.classList.toggle('collapsed', floatingNudgeCollapsed);
const toggle = document.getElementById('floating-nudge-toggle');
if (toggle) toggle.textContent = floatingNudgeCollapsed ? 'Show' : 'Hide';
}
window.__updateFloatingNudge = updateFloatingNudge;
if (orgSection && claSection && tabBtns.length > 0) { if (orgSection && claSection && tabBtns.length > 0) {
let current = '#tab-organic'; let current = '#tab-organic';
@ -1605,11 +1523,8 @@
orgSection.classList.toggle('hidden', id !== '#tab-organic'); orgSection.classList.toggle('hidden', id !== '#tab-organic');
claSection.classList.toggle('hidden', id !== '#tab-classic'); claSection.classList.toggle('hidden', id !== '#tab-classic');
updateSheets(id); updateSheets();
updateFloatingNudge();
// Ensure Dock is visible
const dock = document.getElementById('mobile-tabbar');
if (dock) dock.style.display = 'flex';
tabBtns.forEach(btn => { tabBtns.forEach(btn => {
const active = btn.dataset.target === id; const active = btn.dataset.target === id;
@ -1624,6 +1539,8 @@
if (document.body) delete document.body.dataset.controlsHidden; if (document.body) delete document.body.dataset.controlsHidden;
setMobileTab(document.body?.dataset?.mobileTab || 'controls'); setMobileTab(document.body?.dataset?.mobileTab || 'controls');
orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
} }
@ -1645,232 +1562,55 @@
setMobileTab(document.body.dataset.mobileTab); setMobileTab(document.body.dataset.mobileTab);
updateSheets(); updateSheets();
updateMobileStacks(document.body.dataset.mobileTab); updateMobileStacks(document.body.dataset.mobileTab);
// Sheet toggle buttons (Hide/Show)
document.querySelectorAll('[data-sheet-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.sheetToggle;
const panel = document.getElementById(id);
if (!panel) return;
const now = panel.classList.contains('minimized');
panel.classList.toggle('minimized', !now);
});
});
} }
// =============================== // ===============================
// ===== Mobile Dock Logic ======= // ===== Mobile bottom tabs ======
// =============================== // ===============================
(function initMobileDock() { (function initMobileTabs() {
if (window.__dockInit) return; const buttons = Array.from(document.querySelectorAll('#mobile-tabbar .mobile-tab-btn'));
window.__dockInit = true; if (!buttons.length) return;
const dockOrganic = document.getElementById('dock-organic'); buttons.forEach(btn => {
const dockClassic = document.getElementById('dock-classic'); btn.addEventListener('click', () => {
const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn')); const tab = btn.dataset.mobileTab || 'controls';
const variantBtns = Array.from(document.querySelectorAll('.classic-variant-btn')); const panel = (!document.getElementById('tab-classic')?.classList.contains('hidden')
const topperBtns = Array.from(document.querySelectorAll('.classic-topper-btn')); ? document.getElementById('classic-controls-panel')
const drawerPattern = document.getElementById('classic-drawer-pattern'); : document.getElementById('controls-panel'));
const drawerColors = document.getElementById('classic-drawer-colors');
function openColorsPanel() { const currentTab = document.body.dataset.mobileTab;
const isMobile = window.matchMedia('(max-width: 1023px)').matches;
if (isMobile) setMobileTab('colors');
const tab = detectCurrentTab();
const panel = tab === '#tab-classic'
? document.getElementById('classic-controls-panel')
: document.getElementById('controls-panel');
if (isMobile) panel?.classList.remove('minimized');
}
const openOrganicPanel = (tab = 'controls') => { if (tab === currentTab) {
document.body.dataset.mobileTab = tab; // Toggle minimized state
const panel = document.getElementById('controls-panel'); panel.classList.toggle('minimized');
panel?.classList.remove('minimized'); } else {
updateSheets('#tab-organic'); // Switch tab and ensure expanded
updateMobileStacks(tab); panel.classList.remove('minimized');
}; setMobileTab(tab);
panel.scrollTop = 0;
const closeClassicDrawers = () => {
drawerPattern?.classList.add('hidden');
drawerColors?.classList.add('hidden');
activeClassicMenu = null;
};
const openDrawer = (which) => {
closeClassicDrawers();
if (which === 'pattern') drawerPattern?.classList.remove('hidden');
if (which === 'colors') drawerColors?.classList.remove('hidden');
activeClassicMenu = which;
};
function syncDockGroup() {
const tab = detectCurrentTab();
dockOrganic?.classList.toggle('hidden', tab === '#tab-classic');
dockClassic?.classList.toggle('hidden', tab !== '#tab-classic');
}
const currentPatternParts = () => {
const sel = document.getElementById('classic-pattern');
const val = sel?.value || 'Arch 4';
const isArch = val.toLowerCase().includes('arch');
const variant = val.includes('5') ? '5' : '4';
return { base: isArch ? 'Arch' : 'Column', variant };
};
let activeClassicMenu = null;
const toggleClassicMenu = (target) => {
const panel = document.getElementById('classic-controls-panel');
const isMobile = window.matchMedia('(max-width: 1023px)').matches;
if (!panel) return;
const alreadyOpen = activeClassicMenu === target && isMobile && !panel.classList.contains('minimized');
if (alreadyOpen) {
closeClassicDrawers();
panel.classList.add('minimized');
patternBtns.forEach(btn => btn.classList.remove('active'));
topperBtns.forEach(btn => btn.classList.remove('active'));
return;
} }
activeClassicMenu = target;
panel.classList.remove('minimized');
if (isMobile) setMobileTab(target === 'colors' ? 'colors' : 'controls');
patternBtns.forEach(btn => btn.classList.toggle('active', target === 'pattern' && btn.dataset.patternBase === currentPatternParts().base));
topperBtns.forEach(btn => btn.classList.toggle('active', target === 'topper'));
if (target === 'pattern') openDrawer('pattern');
else if (target === 'colors') openDrawer('colors');
};
const applyPattern = (base, variant) => {
const sel = document.getElementById('classic-pattern');
if (!sel) return;
const target = `${base} ${variant}`;
if (sel.value !== target) sel.value = target;
sel.dispatchEvent(new Event('change', { bubbles: true }));
};
const refreshClassicButtons = () => {
const { base } = currentPatternParts();
const { variant } = currentPatternParts();
const topperOn = !!document.getElementById('classic-topper-enabled')?.checked;
patternBtns.forEach(btn => {
const b = (btn.dataset.patternBase || '').toLowerCase();
const active = base.toLowerCase() === b;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
variantBtns.forEach(btn => {
const active = btn.dataset.patternVariant === variant;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', String(active));
});
topperBtns.forEach(btn => {
btn.classList.toggle('active', topperOn);
btn.setAttribute('aria-pressed', String(topperOn));
});
};
// Organic bindings
document.getElementById('dock-draw')?.addEventListener('click', () => {
setMode('draw');
openOrganicPanel('controls');
});
document.getElementById('dock-erase')?.addEventListener('click', () => {
setMode('erase');
openOrganicPanel('controls');
});
document.getElementById('dock-select')?.addEventListener('click', () => {
setMode('select');
openOrganicPanel('controls');
});
document.getElementById('dock-picker')?.addEventListener('click', () => {
if (mode === 'eyedropper') setMode('draw');
else setMode('eyedropper');
openOrganicPanel('controls');
});
document.getElementById('dock-color-trigger')?.addEventListener('click', () => {
const isMobile = window.matchMedia('(max-width: 1023px)').matches;
if (isMobile) openOrganicPanel('colors');
else openColorsPanel();
}); });
});
// Classic bindings const mq = window.matchMedia('(min-width: 1024px)');
patternBtns.forEach(btn => { const sync = () => {
btn.addEventListener('click', () => { if (mq.matches) {
const { variant } = currentPatternParts(); document.body?.removeAttribute('data-mobile-tab');
const base = btn.dataset.patternBase || 'Arch'; updateMobileStacks('controls'); // keep controls visible on desktop
applyPattern(base, variant); } else {
toggleClassicMenu('pattern'); setMobileTab(document.body?.dataset?.mobileTab || 'controls');
closeClassicDrawers(); }
refreshClassicButtons(); updateFloatingNudge();
}); };
}); mq.addEventListener('change', sync);
variantBtns.forEach(btn => { setMobileTab(document.body?.dataset?.mobileTab || 'controls');
btn.addEventListener('click', () => { sync();
const { base } = currentPatternParts();
const variant = btn.dataset.patternVariant || '4';
applyPattern(base, variant);
toggleClassicMenu('pattern');
closeClassicDrawers();
refreshClassicButtons();
});
});
topperBtns.forEach(btn => {
btn.addEventListener('click', () => {
const cb = document.getElementById('classic-topper-enabled');
if (!cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change', { bubbles: true }));
toggleClassicMenu('topper');
refreshClassicButtons();
});
});
document.getElementById('dock-classic-color')?.addEventListener('click', () => {
if (activeClassicMenu === 'colors') closeClassicDrawers();
else toggleClassicMenu('colors');
refreshClassicButtons();
});
// Header Export const nudgeToggle = document.getElementById('floating-nudge-toggle');
document.getElementById('header-export')?.addEventListener('click', () => exportPng()); nudgeToggle?.addEventListener('click', () => {
document.getElementById('header-undo')?.addEventListener('click', undo); floatingNudgeCollapsed = !floatingNudgeCollapsed;
document.getElementById('header-redo')?.addEventListener('click', redo); updateFloatingNudge();
});
const mq = window.matchMedia('(min-width: 1024px)');
const sync = () => {
if (mq.matches) {
document.body?.removeAttribute('data-mobile-tab');
updateMobileStacks('all');
// Remove minimized on desktop just in case
const orgPanel = document.getElementById('controls-panel');
const claPanel = document.getElementById('classic-controls-panel');
if (orgPanel) { orgPanel.classList.remove('minimized'); orgPanel.style.display = ''; }
if (claPanel) { claPanel.classList.remove('minimized'); claPanel.style.display = ''; }
} else {
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
// Start minimized on mobile
document.getElementById('controls-panel')?.classList.add('minimized');
document.getElementById('classic-controls-panel')?.classList.add('minimized');
}
syncDockGroup();
refreshClassicButtons();
};
mq.addEventListener('change', sync);
setMobileTab(document.body?.dataset?.mobileTab || 'controls');
sync();
// keep dock in sync when tab switches
document.querySelectorAll('#mode-tabs .tab-btn').forEach(btn => {
btn.addEventListener('click', () => setTimeout(() => { syncDockGroup(); refreshClassicButtons(); }, 50));
});
document.getElementById('classic-pattern')?.addEventListener('change', refreshClassicButtons);
document.getElementById('classic-topper-enabled')?.addEventListener('change', refreshClassicButtons);
refreshClassicButtons();
})(); })();
}); });
})(); })();

260
style.css
View File

@ -160,6 +160,46 @@ body { color: #1f2937; }
transform: scale(1.1); transform: scale(1.1);
} }
.topper-type-group {
display: flex;
gap: .5rem;
flex-wrap: wrap;
}
.topper-type-btn {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
gap: .4rem;
}
.topper-type-btn i { font-size: 1rem; }
.floating-nudge {
position: fixed;
right: 0.9rem;
bottom: 4.6rem;
background: rgba(255,255,255,0.95);
border: 1px solid rgba(148,163,184,0.3);
border-radius: 1rem;
padding: 0.75rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18);
width: 210px;
z-index: 35;
}
.floating-nudge-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .35rem;
}
.floating-nudge-body.collapsed { display: none; }
.floating-nudge.collapsed .floating-nudge-body { display: none; }
.floating-nudge.collapsed #floating-nudge-toggle { opacity: 0.8; }
@media (min-width: 1024px) {
.floating-nudge { display: none !important; }
}
.slot-label { .slot-label {
font-weight: 600; font-weight: 600;
color: #4b5563; color: #4b5563;
@ -177,8 +217,8 @@ body { color: #1f2937; }
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.6);
border-radius: 0.75rem; border-radius: 1rem;
padding: 0.75rem; padding: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.03); box-shadow: 0 4px 20px rgba(0,0,0,0.03);
} }
.control-stack { .control-stack {
@ -198,15 +238,15 @@ body { color: #1f2937; }
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-top: 1px solid rgba(255,255,255,0.5); border-top: 1px solid rgba(255,255,255,0.5);
box-shadow: 0 -4px 30px rgba(0,0,0,0.08); box-shadow: 0 -4px 30px rgba(0,0,0,0.08);
border-radius: 1.25rem 1.25rem 0 0; border-radius: 1.5rem 1.5rem 0 0;
padding: 1rem 0.75rem; padding: 1.25rem 1rem;
overflow-y: auto; overflow-y: auto;
z-index: 30; z-index: 30;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
} }
.control-sheet.hidden { display: none; } .control-sheet.hidden { display: none; }
/* .control-sheet.minimized removed from global scope to fix desktop visibility */ .control-sheet.minimized { transform: translateY(100%); }
.panel-title { .panel-title {
font-weight: 900; font-weight: 900;
font-size: 1.1rem; font-size: 1.1rem;
@ -238,9 +278,8 @@ body { color: #1f2937; }
@media (max-width: 1023px) { @media (max-width: 1023px) {
body { padding-bottom: 88px; } body { padding-bottom: 88px; }
html, body { height: 100%; } html, body { height: 100%; overflow: hidden; }
.control-sheet.minimized { transform: translateY(100%); }
.control-sheet .control-stack { display: none; } .control-sheet .control-stack { display: none; }
body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"], body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"], body[data-mobile-tab="colors"] #controls-panel [data-mobile-tab="colors"],
@ -254,187 +293,52 @@ body { color: #1f2937; }
.mobile-tabbar { .mobile-tabbar {
position: fixed; position: fixed;
left: 0; inset-inline: 0;
width: 100%;
bottom: 0; bottom: 0;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: stretch; align-items: center;
padding: .55rem .85rem .8rem; padding: .6rem .9rem .9rem;
background: rgba(15, 23, 42, 0.95); background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(224,242,254,0.92));
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
color: #fff; color: #0f172a;
z-index: 9999; z-index: 40;
gap: .4rem;
box-shadow: 0 -6px 30px rgba(15, 23, 42, 0.12);
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.mobile-tabbar .mobile-tab-btn {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
gap: .35rem; gap: .35rem;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
.mobile-tool-btn {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
color: #94a3b8;
transition: all 0.2s ease;
}
.mobile-tool-btn svg { width: 1.5rem; height: 1.5rem; fill: currentColor; }
.mobile-tool-btn.active {
background: rgba(255,255,255,0.1);
color: #38bdf8; /* Sky blue */
}
.mobile-tool-btn:active { transform: scale(0.9); }
#dock-classic { width: 100%; }
#dock-organic { align-items: center; }
.classic-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 4.2rem;
z-index: 9500;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: rgba(15,23,42,0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 -8px 30px rgba(0,0,0,0.3);
}
.classic-drawer.hidden { display: none; }
.drawer-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.drawer-label { color: #cbd5e1; font-weight: 700; font-size: 0.9rem; }
.drawer-pill-group { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.dial-row { flex-direction: column; align-items: flex-start; }
.topper-row { gap: 0.75rem; }
.topper-inline { display: flex; align-items: center; gap: 0.45rem; }
.select-xs { padding: 0.3rem 0.45rem; font-size: 0.85rem; height: 2.2rem; }
.nudge-pad { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.topper-size-wrap { display: flex; flex-direction: column; gap: 0.1rem; min-width: 120px; }
.topper-size-wrap input[type=range] { accent-color: #2563eb; }
.dock-color-btn {
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
border: 4px solid #fff;
box-shadow: 0 8px 20px rgba(0,0,0,0.18);
transition: transform 0.15s ease;
}
.dock-color-btn:active { transform: scale(0.95); }
.dock-variant-btn {
min-width: 2.4rem;
height: 2.4rem;
padding: 0 .5rem;
border-radius: 0.65rem;
background: rgba(255,255,255,0.08);
color: #e2e8f0;
border: 1px solid rgba(255,255,255,0.15);
font-weight: 700;
font-size: .95rem;
transition: all 0.2s ease;
}
.dock-variant-btn.active {
background: #38bdf8;
color: #0f172a;
border-color: #38bdf8;
box-shadow: 0 6px 18px rgba(56,189,248,0.35);
}
.dock-variant-btn:active { transform: scale(0.95); }
.dock-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 0.9rem;
padding: 0.4rem 0.65rem;
}
.dock-label {
font-size: 0.85rem;
font-weight: 700;
color: #cbd5e1;
}
.dock-pill-group {
display: flex;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.dock-pill {
padding: 0.5rem 0.8rem;
border-radius: 0.8rem;
background: rgba(255,255,255,0.08);
color: #e2e8f0;
border: 1px solid rgba(255,255,255,0.14);
font-weight: 700;
font-size: 0.95rem;
transition: all 0.2s ease;
white-space: nowrap;
}
.dock-pill:hover { background: rgba(255,255,255,0.14); }
.dock-pill.active {
background: #38bdf8;
color: #0f172a;
border-color: #38bdf8;
box-shadow: 0 6px 18px rgba(56,189,248,0.35);
}
.dock-pill:active { transform: scale(0.96); }
.classic-quick-label { font-weight: 700; color: #334155; font-size: 0.9rem; }
.btn-nudge {
background: #0f172a;
color: #fff;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
box-shadow: 0 4px 12px rgba(0,0,0,0.18);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-nudge:active { transform: translateY(1px) scale(0.97); box-shadow: 0 2px 8px rgba(0,0,0,0.18); }
.length-dial {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 0.35rem 0.25rem;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
position: relative;
mask-image: linear-gradient(90deg, transparent 0, #000 12%, #000 88%, transparent 100%);
}
.length-dial .dock-pill {
scroll-snap-align: center;
min-width: 72px;
text-align: center; text-align: center;
transition: transform 0.15s ease, box-shadow 0.2s ease; padding: .7rem .5rem;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255,255,255,0.8);
color: #1d4ed8;
font-weight: 700;
font-size: .9rem;
letter-spacing: 0.01em;
transition: all 0.2s ease;
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
} }
.length-dial .dock-pill.active { .mobile-tabbar .mobile-tab-icon {
transform: scale(1.02); font-size: 1.2rem;
box-shadow: 0 6px 18px rgba(37,99,235,0.28); line-height: 1;
display: block;
flex-shrink: 0;
} }
.length-dial .dock-pill.ping { .mobile-tabbar .mobile-tab-btn[aria-pressed="true"] {
animation: tap-pulse 260ms ease; background: linear-gradient(135deg, #2563eb, #0ea5e9);
border-color: rgba(37, 99, 235, 0.2);
color: #fff;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.3);
transform: translateY(-2px);
} }
@keyframes tap-pulse {
0% { transform: scale(0.95); }
50% { transform: scale(1.08); }
100% { transform: scale(1.0); }
}
/* Removed old mobile-tab-btn styles as they are replaced by the new layout */
@media (min-width: 1024px) { @media (min-width: 1024px) {