From a3de8b5ac63f2d52837d055f6c2fca946bffdd67 Mon Sep 17 00:00:00 2001 From: chris Date: Mon, 1 Dec 2025 09:17:17 -0500 Subject: [PATCH] chore: snapshot new version --- classic.js | 750 +++++++++++++++++++++++++++++ index.html | 566 ++++++++++++---------- script.js | 1352 +++++++++++++++++++++++++++++++++++++--------------- style.css | 399 ++++++++++++++-- 4 files changed, 2385 insertions(+), 682 deletions(-) create mode 100644 classic.js diff --git a/classic.js b/classic.js new file mode 100644 index 0000000..73a8df8 --- /dev/null +++ b/classic.js @@ -0,0 +1,750 @@ +(() => { + 'use strict'; + + // -------- helpers ---------- + const log = (...a) => console.log('[Classic]', ...a); + const fail = (msg) => { + console.error('[Classic ERROR]', msg); + const d = document.getElementById('classic-display'); + if (d) d.innerHTML = `
+ Classic failed: ${String(msg)} +
`; + }; + const normHex = (h) => (String(h || '')).trim().toLowerCase(); + + // -------- persistent color selection (now supports image textures) ---------- + const PALETTE_KEY = 'classic:colors:v2'; + const TOPPER_COLOR_KEY = 'classic:topperColor:v2'; + + const defaultColors = () => [ + { hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null }, + { hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null }, + { hex: '#fcd34d', image: null } + ]; + const defaultTopper = () => ({ hex: '#a18b67', image: 'images/chrome-gold.webp' }); + + function getClassicColors() { + let arr = defaultColors(); + try { + const savedJSON = localStorage.getItem(PALETTE_KEY); + if (!savedJSON) return arr; + const saved = JSON.parse(savedJSON); + if (Array.isArray(saved) && saved.length > 0) { + if (typeof saved[0] === 'string') { + arr = saved.slice(0, 5).map(hex => ({ hex: normHex(hex), image: null })); + } else if (typeof saved[0] === 'object' && saved[0] !== null) { + arr = saved.slice(0, 5); + } + while (arr.length < 5) arr.push({ hex: '#ffffff', image: null }); + } + } catch (e) { console.error('Failed to parse classic colors:', e); } + return arr; + } + + function setClassicColors(arr) { + const clean = (arr || []).slice(0, 5).map(c => ({ + hex: normHex(c.hex), image: c.image || null + })); + while (clean.length < 5) clean.push({ hex: '#ffffff', image: null }); + try { localStorage.setItem(PALETTE_KEY, JSON.stringify(clean)); } catch {} + return clean; + } + + function getTopperColor() { + try { + const saved = JSON.parse(localStorage.getItem(TOPPER_COLOR_KEY)); + return (saved && saved.hex) ? saved : defaultTopper(); + } catch { return defaultTopper(); } + } + + function setTopperColor(colorObj) { + const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null }; + try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {} + } + + function buildClassicPalette() { + const colors = getClassicColors(); + const palette = { 0: { colour: '#FFFFFF', name: 'No Colour', image: null } }; + colors.forEach((c, i) => { + palette[i + 1] = { colour: c.hex, image: c.image }; + }); + return palette; + } + + function flattenPalette() { + const out = []; + if (Array.isArray(window.PALETTE)) { + window.PALETTE.forEach(group => { + (group.colors || []).forEach(c => { + if (!c?.hex) return; + out.push({ + hex: normHex(c.hex), name: c.name || c.hex, + family: group.family || '', image: c.image || null + }); + }); + }); + } + const seen = new Set(); + return out.filter(c => (seen.has(c.hex) ? false : (seen.add(c.hex), true))); + } + + // -------- tiny grid engine (Mithril) ---------- + function GridCalculator() { + if (typeof window.m === 'undefined') throw new Error('Mithril (m) not loaded'); + + let pxUnit = 10; + let clusters = 10; + let reverse = false; + let topperEnabled = false; + let topperType = 'round'; + let topperOffsetX_Px = 0; + let topperOffsetY_Px = 0; + let topperSizeMultiplier = 1; + let shineEnabled = true; + + const patterns = {}; + const api = { + patterns, + initialPattern: 'Arch 4', + controller: (el) => makeController(el), + setClusters(n) { clusters = Math.max(1, (Number(n)|0) || 10); }, + setReverse(on){ reverse = !!on; }, + setTopperEnabled(on) { topperEnabled = !!on; }, + setTopperType(type) { topperType = type || 'round'; }, + setTopperOffsetX(val) { topperOffsetX_Px = (Number(val) || 0) * 5; }, + setTopperOffsetY(val) { topperOffsetY_Px = (Number(val) || 0) * -5; }, + setTopperSize(multiplier) { topperSizeMultiplier = Number(multiplier) || 1; }, + setShineEnabled(on) { shineEnabled = !!on; } + }; + + const svg = (tag, attrs, children) => m(tag, attrs, children); + + function extend(p){ + const parentName = p.deriveFrom; if (!parentName) return; + const base = patterns[parentName]; if (!base) return; + if (base.deriveFrom) extend(base); + Object.keys(base).forEach(k => { if (!(k in p)) p[k] = base[k]; }); + p.parent = base; + } + + function BBox(){ this.min={x:Infinity,y:Infinity}; this.max={x:-Infinity,y:-Infinity}; } + BBox.prototype.add = function(x,y){ if(isNaN(x)||isNaN(y)) return this; + this.min.x=Math.min(this.min.x,x); this.min.y=Math.min(this.min.y,y); + this.max.x=Math.max(this.max.x,x); this.max.y=Math.max(this.max.y,y); return this; }; + BBox.prototype.w=function(){return this.max.x-this.min.x;}; + BBox.prototype.h=function(){return this.max.y-this.min.y;}; + + const balloonSize = (cell)=> (cell.shape.size ?? 1); + const cellScale = (cell)=> balloonSize(cell) * pxUnit; + + function cellView(cell, id, explicitFill, model){ + const shape = cell.shape; + const scale = cellScale(cell); + const transform = [(shape.base.transform||''), `scale(${scale})`].join(' '); + const commonAttrs = { + 'vector-effect': 'non-scaling-stroke', stroke: '#111827', + 'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon', + fill: explicitFill || '#cccccc' + }; + if (cell.isTopper) { + commonAttrs['data-is-topper'] = true; + } else { + commonAttrs['data-color-code'] = cell.colorCode || 0; + commonAttrs['data-quad-number'] = cell.y + 1; + } + + let shapeEl; + if (shape.base.type === 'path') shapeEl = svg('path', { ...commonAttrs, d: shape.base.d }); + else shapeEl = svg('ellipse', { ...commonAttrs, cx:0, cy:0, rx:0.5, ry:0.5 }); + + const kids = [shapeEl]; + const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round')); + if (applyShine) { + kids.push(svg('ellipse', { + class: 'shine', cx: -0.15, cy: -0.15, rx: 0.22, ry: 0.13, + fill: '#ffffff', opacity: 0.45, transform: 'rotate(-25)', 'pointer-events': 'none' + })); + } + return svg('g', { id, transform }, kids); + } + + function gridPos(x,y,z,inflate,pattern,model){ + const base = patterns[model.patternName].parent || patterns[model.patternName]; + const rel = (pattern.baseBalloonSize && base.baseBalloonSize) ? pattern.baseBalloonSize/base.baseBalloonSize : 1; + let p = { x: pattern.gridX(model.pattern.cellsPerRow > 1 ? y : x, x), y: pattern.gridY(y,x) }; + if (pattern.transform) p = pattern.transform(p,x,y,model); + return { x: p.x * rel * pxUnit, y: p.y * rel * pxUnit }; + } + + // === Spiral coloring helpers (shared by 4- and 5-balloon clusters) === +function distinctPaletteSlots(palette) { + // Collapse visually identical slots so 3-color spirals work even if you filled 5 slots. + const seen = new Set(), out = []; + for (let s = 1; s <= 5; s++) { + const c = palette[s]; + if (!c) continue; + const key = (c.image || '') + '|' + String(c.colour || '').toLowerCase(); + if (!seen.has(key)) { seen.add(key); out.push(s); } + } + return out.length ? out : [1,2,3,4,5]; +} + + + + function newGrid(pattern, cells, container, model){ + const kids = [], layers = [], bbox = new BBox(); + const balloonsPerCluster = pattern.balloonsPerCluster || 4; + const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse)); + const rowColorPatterns = {}; + + const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]]; + const colorBlock5 = + [ + [5, 2, 3, 4, 1], + [2, 3, 4, 5, 1], + [2, 4, 5, 1, 3], + [4, 5, 1, 2, 3], + [4, 1, 2, 3, 5], + ]; + + for (let cell of cells) { + let c, fill; + if (cell.isTopper) { + const topRowYIndex = 0, topClusterY = pattern.gridY(topRowYIndex, 0) * pxUnit; + const regularBalloonRadius = (pattern.balloonShapes['front'] || pattern.balloonShapes['penta'] || pattern.balloonShapes['middle']).size * pxUnit * 0.5; + const highestPoint = topClusterY - regularBalloonRadius; + const topperRadius = cell.shape.size * pxUnit * cell.shape.base.radius; + const topperY = highestPoint - topperRadius - (pxUnit * 0.5) + topperOffsetY_Px; + c = { x: topperOffsetX_Px, y: topperY }; + fill = model.topperColor.image ? `url(#classic-pattern-topper)` : model.topperColor.hex; + } else { + c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model); + + const rowIndex = cell.y; + if (!rowColorPatterns[rowIndex]) { + const qEff = rowIndex + 1; + let pat; + + if (balloonsPerCluster === 5) { + const base = (qEff - 1) % 5; + pat = colorBlock5[base].slice(); + } else { + const base = Math.floor((qEff - 1) / 2); + pat = colorBlock4[base % 4].slice(); + if (qEff % 2 === 0) { + pat = [pat[0], pat[2], pat[1], pat[3]]; + } + } + + if (reversed && pat.length > 1) { + pat.reverse(); + } + + // --- NEW: swap left/right after every 5 clusters --- +const SWAP_EVERY = 5; // clusters per block +const blockIndex = Math.floor(rowIndex / SWAP_EVERY); + +// swap on blocks #2, #4, #6, ... (i.e., rows 6–10, 16–20, ...) +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; + } + + const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster]; + cell.colorCode = colorCode; + const colorInfo = model.palette[colorCode]; + fill = colorInfo ? (colorInfo.image ? `url(#classic-pattern-slot-${colorCode})` : colorInfo.colour) : 'transparent'; + } + + const scale = cellScale(cell), shapeRadius = cell.shape.base.radius || 0.5, size = shapeRadius * scale; + bbox.add(c.x - size, c.y - size); + bbox.add(c.x + size, c.y + size); + const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model); + v.attrs.transform = `translate(${c.x},${c.y}) ${v.attrs.transform || ''}`; + const zi = cell.isTopper ? 100 + 2 : (100 + (cell.shape.zIndex || 0)); + (layers[zi] ||= []).push(v); + }; + + layers.forEach(layer => layer && layer.forEach(v => kids.push(v))); + const margin = 20; + const vb = [ bbox.min.x - margin, bbox.min.y - margin, Math.max(1,bbox.w()) + margin*2, Math.max(1,bbox.h()) + margin*2 ].join(' '); + + const patternsDefs = []; + const SVG_PATTERN_ZOOM = 2.5; + const offset = (1 - SVG_PATTERN_ZOOM) / 2; + + Object.entries(model.palette).forEach(([slot, colorInfo]) => { + if (colorInfo.image) { + patternsDefs.push(svg('pattern', {id: `classic-pattern-slot-${slot}`, patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, + [ svg('image', { href: colorInfo.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] + )); + } + }); + if (model.topperColor.image) { + patternsDefs.push(svg('pattern', {id: 'classic-pattern-topper', patternContentUnits: 'objectBoundingBox', width: 1, height: 1}, + [ svg('image', { href: model.topperColor.image, x: offset, y: offset, width: SVG_PATTERN_ZOOM, height: SVG_PATTERN_ZOOM, preserveAspectRatio: 'xMidYMid slice' }) ] + )); + } + const svgDefs = svg('defs', {}, patternsDefs); + + const mainGroup = svg('g', null, kids); + m.render(container, svg('svg', { xmlns: 'http://www.w3.org/2000/svg', width:'100%', height:'100%', viewBox: vb, preserveAspectRatio:'xMidYMid meet', style: 'isolation:isolate' }, [svgDefs, mainGroup])); + } + + function makeController(displayEl){ + const models = []; + function buildModel(name){ + const pattern = patterns[name]; + if (patterns['Column 4']) patterns['Column 4']._reverse = reverse; + if (patterns['Arch 4']) patterns['Arch 4']._reverse = reverse; + if (patterns['Column 5']) patterns['Column 5']._reverse = reverse; + if (patterns['Arch 5']) patterns['Arch 5']._reverse = reverse; + + const model = { patternName: name, pattern, cells: [], rowCount: clusters, palette: buildClassicPalette(), topperColor: getTopperColor(), topperType, shineEnabled }; + const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn; + for (let y=0; y [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; + let v0 = verts[0], v1 = verts[1]; let p0 = lerp(v0, v1, t); + let d = `M ${p0[0].toFixed(4)} ${p0[1].toFixed(4)}`; + for (let i = 0; i < verts.length; i++) { const v = verts[(i + 1) % verts.length], vNext = verts[(i + 2) % verts.length]; const p = lerp(v, vNext, t); d += ` Q ${v[0].toFixed(4)} ${v[1].toFixed(4)} ${p[0].toFixed(4)} ${p[1].toFixed(4)}`; } + return d + ' Z'; + } + + // --- Column 4: This is the existing logic from classic.js, which matches your template file --- + patterns['Column 4'] = { + baseBalloonSize: 25, _reverse: false, balloonsPerCluster: 4, + balloonShapes: { + 'front':{zIndex:4, base:{radius:0.5}, size:3}, 'front-inner':{zIndex:3, base:{radius:0.5}, size:3}, 'back-inner':{zIndex:2, base:{radius:0.5}, size:3}, 'back':{zIndex:1, base:{radius:0.5}, size:3}, + '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} + }, + tile: { size:{x:5,y:1} }, cellsPerRow: 1, cellsPerColumn: 5, + gridX(row, col){ return col + [0, -0.12, -0.24, -0.36, -0.48][col % 5]; }, + gridY(row, col){ return 2.2 * (1 - 1/5) * (Math.floor(row/2) + Math.floor((row+1)/2)); }, + createCell(x, y) { + const odd = !!(y % 2); + const A = ['front-inner','back','', 'front','back-inner'], B = ['back-inner', 'front','', 'back', 'front-inner']; + const arr = this._reverse ? (odd ? B : A) : (odd ? A : B); + const shapeName = arr[x % 5]; + const shape = this.balloonShapes[shapeName]; + return shape ? { shape:{...shape} } : null; + } + }; + + // --- Arch 4: This is the existing logic from classic.js, which matches your template file --- + patterns['Arch 4'] = { + deriveFrom: 'Column 4', + transform(point, col, row, model){ + const len = this.gridY(model.rowCount*this.tile.size.y, 0) - this.gridY(0, 0); + const r = (len / Math.PI) + point.x; + const y = point.y - this.gridY(0, 0); + const a = Math.PI * (y / len); + return { x: -r*Math.cos(a), y: -r*Math.sin(a) }; + } + }; + + // --- START: MODIFIED SECTION --- + // This is the new 'Column 5' definition, adapted from your template file. + patterns['Column 5'] = { + baseBalloonSize: 25, + _reverse: false, + balloonsPerCluster: 5, // Kept this from classic.js to ensure 5-color spiral + tile: { size: { x: 5, y: 1 } }, + cellsPerRow: 1, + cellsPerColumn: 5, + + // Balloon shapes from your template, converted to classic.js format + // (type: "qlink" is approx size: 3.0) + balloonShapes: { + "front": { zIndex:5, 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 }, + "middle2": { zIndex:2, 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 } + }, + + // gridX function from your template + // (I've hard-coded `this.exploded` to false, as it's not in classic.js) + gridX(row, col) { + var mid = 0.6; // this.exploded ? 0.2 : 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); + }, + + // 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){ + 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) { + var yOdd = !!(y % 2); + + // Re-created logic from template's createCell + const shapePattern = yOdd ? + ['middle', 'back', 'front', 'back', 'middle'] : + ['middle2', 'front2', 'back2', 'front2', 'middle2']; + + 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. + // It derives from the new 'Column 5' and uses the same arching logic as 'Arch 4'. + patterns['Arch 5'] = { + deriveFrom: 'Column 5', + 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 r = (len / Math.PI) + point.x; + const y = point.y - this.gridY(0, 0); + const a = Math.PI * (y / len); + return { x: -r * Math.cos(a), y: -r * Math.sin(a) }; + } + }; + // --- END: MODIFIED SECTION --- + + + Object.keys(patterns).forEach(n => extend(patterns[n])); + return api; + } + + const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4); + + 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'); + if (!slots.length || !topperSwatch || !swatchGrid || !activeLabel) return; + topperSwatch.classList.add('tab-btn'); + let classicColors = getClassicColors(), activeTarget = '1'; + + 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() { + const patSelect = document.getElementById('classic-pattern'); + const name = patSelect?.value || 'Arch 4'; + return patternSlotCount(name); + } + + function enforceSlotVisibility() { + 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'; + } + + function updateUI() { + 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); }); + + slots.forEach((slot, i) => { + const color = classicColors[i]; + if (!color) return; // Safeguard against errors + slot.style.backgroundImage = color.image ? `url("${color.image}")` : 'none'; + slot.style.backgroundColor = color.hex; + slot.style.backgroundSize = '200%'; + slot.style.backgroundPosition = 'center'; + }); + + const topperColor = getTopperColor(); + topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none'; + topperSwatch.style.backgroundColor = topperColor.hex; + topperSwatch.style.backgroundSize = '200%'; + topperSwatch.style.backgroundPosition = 'center'; + + 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 = ''; + (window.PALETTE || []).forEach(group => { + const title = document.createElement('div'); title.className = 'family-title'; title.textContent = group.family; swatchGrid.appendChild(title); + const row = document.createElement('div'); row.className = 'swatch-row'; + (group.colors || []).forEach(colorItem => { + const sw = document.createElement('button'); sw.type = 'button'; sw.className = 'swatch'; sw.title = colorItem.name; + sw.setAttribute('aria-label', colorItem.name); + + sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none'; + sw.style.backgroundColor = colorItem.hex; + sw.style.backgroundSize = '500%'; + sw.style.backgroundPosition = 'center'; + + sw.addEventListener('click', () => { + const selectedColor = { hex: colorItem.hex, image: colorItem.image }; + if (activeTarget === 'T') setTopperColor(selectedColor); + else { + const index = parseInt(activeTarget, 10) - 1; + if (index >= 0 && index < 5) { classicColors[index] = selectedColor; setClassicColors(classicColors); } + } + updateUI(); onColorChange(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + }); + row.appendChild(sw); + }); + swatchGrid.appendChild(row); + }); + slots.forEach(slot => { slot.addEventListener('click', () => { activeTarget = slot.dataset.slot; updateUI(); }); }); + topperSwatch.addEventListener('click', () => { activeTarget = 'T'; updateUI(); }); + randomizeBtn?.addEventListener('click', () => { + const pool = allPaletteColors.slice(); const picks = []; + const colorCount = visibleSlotCount(); + for (let i = 0; i < colorCount && pool.length; i++) { picks.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); } + classicColors = setClassicColors(picks.map(c => ({ hex: c.hex, image: c.image }))); + updateUI(); onColorChange(); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + }); + updateUI(); + } + + function initClassic() { + try { + 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 ARCH_LENGTHS = [20, 25, 30, 35, 40]; + const COLUMN_LENGTHS = [3,4,5,6,7,8,9,10,11,12,13,14,15]; + const ARCH_DEFAULT = 20; + const COLUMN_DEFAULT = 5; + const lengthDialDrawer = document.getElementById('classic-length-drawer'); + const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper')); + const slotButtons = Array.from(document.querySelectorAll('#classic-slots .slot-btn')); + const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn')); + 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 userTopperChoice = false; + if (!display) return fail('#classic-display not found'); + const GC = GridCalculator(), ctrl = GC.controller(display); + + const syncDockPatternButtons = (patternName) => { + const isArch = (patternName || '').toLowerCase().includes('arch'); + const isFive = (patternName || '').includes('5'); + patternBtns.forEach(btn => { + const base = (btn.dataset.patternBase || '').toLowerCase(); + const active = isArch ? base === 'arch' : base === 'column'; + btn.classList.toggle('active', active); + btn.setAttribute('aria-pressed', String(active)); + }); + variantBtns.forEach(btn => { + 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 = () => { + const on = !!topperEnabledCb?.checked; + if (topperInline) topperInline.classList.toggle('hidden', !on); + if (topperTypeInline && topperTypeSelect) topperTypeInline.value = topperTypeSelect.value; + if (topperSizeInline && topperSizeInp) topperSizeInline.value = topperSizeInp.value; + if (topperColorInline) { + const tc = getTopperColor(); + topperColorInline.style.backgroundImage = tc.image ? `url("${tc.image}")` : 'none'; + topperColorInline.style.backgroundColor = tc.hex; + } + }; + + const renderLengthPresets = (patternName) => { + if (!lengthInp) return; + const isArch = (patternName || '').toLowerCase().includes('arch'); + const list = isArch ? ARCH_LENGTHS : COLUMN_LENGTHS; + const current = parseFloat(lengthInp.value) || (isArch ? ARCH_DEFAULT : COLUMN_DEFAULT); + if (lengthLabel) lengthLabel.textContent = `${current} ft`; + + const targets = [lengthPresetWrap, lengthDialDrawer]; + targets.forEach(container => { + if (!container) return; + container.innerHTML = ''; + 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); + }); + }); + }; + + const ensureLengthForPattern = (patternName) => { + 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() { + if (!lengthInp || !patSel) return; + const patternName = patSel.value || 'Arch 4'; + ensureLengthForPattern(patternName); + const isColumn = patternName.toLowerCase().includes('column'); + const hasTopper = patternName.includes('4') || patternName.includes('5'); + const showToggle = isColumn && hasTopper; + if (showToggle && topperEnabledCb && !userTopperChoice) { + topperEnabledCb.checked = true; + } + if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle); + 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); + topperTypeSelect.disabled = !showTopper; + + GC.setTopperEnabled(showTopper); + GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2)); + GC.setReverse(!!reverseCb?.checked); + GC.setTopperType(topperTypeSelect.value); + GC.setTopperOffsetX(topperOffsetX); + GC.setTopperOffsetY(topperOffsetY); + GC.setTopperSize(topperSizeInp?.value); + GC.setShineEnabled(!!shineEnabledCb?.checked); + if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`; + ctrl.selectPattern(patternName); + syncDockPatternButtons(patternName); + syncDockTopperButton(); + renderLengthPresets(patternName); + syncTopperInline(); + } + + const setLengthForPattern = () => { + if (!lengthInp || !patSel) return; + const isArch = (patSel.value || '').toLowerCase().includes('arch'); + lengthInp.value = isArch ? ARCH_DEFAULT : COLUMN_DEFAULT; + }; + + window.ClassicDesigner = window.ClassicDesigner || {}; + window.ClassicDesigner.api = GC; + window.ClassicDesigner.redraw = updateClassicDesign; + window.ClassicDesigner.getColors = getClassicColors; + window.ClassicDesigner.getTopperColor = getTopperColor; + + document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50)); + patSel?.addEventListener('change', () => { + ensureLengthForPattern(patSel.value); + updateClassicDesign(); + renderLengthPresets(patSel.value); + }); + topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => { + const dx = Number(btn.dataset.dx || 0); + const dy = Number(btn.dataset.dy || 0); + topperOffsetX += dx; + topperOffsetY += dy; + GC.setTopperOffsetX(topperOffsetX); + GC.setTopperOffsetY(topperOffsetY); + updateClassicDesign(); + })); + [lengthInp, reverseCb, topperEnabledCb, topperTypeSelect, topperSizeInp] + .forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, 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); }); + initClassicColorPicker(updateClassicDesign); + try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {} + setLengthForPattern(); + updateClassicDesign(); + renderLengthPresets(patSel?.value || ''); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); + log('Classic ready'); + } catch (e) { fail(e.message || e); } + } + + window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null }; + document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } }); +})(); diff --git a/index.html b/index.html index 302f52f..3e32795 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Balloon Designer — Organic & Classic + Balloon Studio — Organic & Classic @@ -21,50 +21,42 @@ .copy-message{opacity:0;pointer-events:none;transition:opacity .2s}.copy-message.show{opacity:1} - -
- - - -
- - - -

(PNG for both modes, SVG for Classic mode)

-
+ -
- -
-
- - -
- +
+
-
- +
+
+
-
- - - ⋮⋮ - Classic Colors - - - + -
-
- - - - - -
-
Pick a color for Slot #1 (from colors.js):
-
-
-
- -
-
-
- - -
-
-
- - +
+
+ + + + + +
+ + +
+ + + + + - - - - + + - - \ No newline at end of file + diff --git a/script.js b/script.js index ab3ac0f..22c9af5 100644 --- a/script.js +++ b/script.js @@ -2,127 +2,6 @@ (() => { 'use strict'; - // ----------------------------- - // Accordion panel (shared) - // ----------------------------- - function setupAccordionPanel(options) { - const { panelId, expandBtnId, collapseBtnId, reorderBtnId, storagePrefix } = options; - - const accPanel = document.getElementById(panelId); - if (!accPanel) return; - - const expandBtn = document.getElementById(expandBtnId); - const collapseBtn = document.getElementById(collapseBtnId); - const reorderBtn = document.getElementById(reorderBtnId); - - const ACC_ORDER_KEY = `${storagePrefix}:accOrder:v1`; - const ACC_OPEN_KEY = `${storagePrefix}:accOpen:v1`; - const SCROLL_KEY = `${storagePrefix}:controlsScroll:v1`; - - const accSections = () => Array.from(accPanel.querySelectorAll('details[data-acc-id]')); - - function saveAccOpen() { - const map = {}; - accSections().forEach(d => (map[d.dataset.accId] = d.open ? 1 : 0)); - try { localStorage.setItem(ACC_OPEN_KEY, JSON.stringify(map)); } catch {} - } - - function restoreAccOpen() { - try { - const map = JSON.parse(localStorage.getItem(ACC_OPEN_KEY) || '{}'); - accSections().forEach(d => { - if (map[d.dataset.accId] === 1) d.open = true; - if (map[d.dataset.accId] === 0) d.open = false; - }); - } catch {} - } - - function saveAccOrder() { - const order = accSections().map(d => d.dataset.accId); - try { localStorage.setItem(ACC_ORDER_KEY, JSON.stringify(order)); } catch {} - } - - function restoreAccOrder() { - try { - const order = JSON.parse(localStorage.getItem(ACC_ORDER_KEY) || '[]'); - if (!Array.isArray(order) || order.length === 0) return; - const map = new Map(accSections().map(d => [d.dataset.accId, d])); - order.forEach(id => { - const el = map.get(id); - if (el) accPanel.appendChild(el); - }); - } catch {} - } - - // --- drag/reorder within the panel - let drag = { el: null, ph: null }; - accPanel.addEventListener('click', e => { if (e.target.closest('.drag-handle')) e.preventDefault(); }); - accPanel.addEventListener('pointerdown', e => { if (e.target.closest('.drag-handle')) e.stopPropagation(); }); - - accPanel.addEventListener('dragstart', e => { - const handle = e.target.closest('.drag-handle'); - if (!handle) return; - drag.el = handle.closest('details[data-acc-id]'); - drag.ph = document.createElement('div'); - drag.ph.className = 'rounded-lg border border-dashed border-gray-300 bg-white/30'; - drag.ph.style.height = drag.el.offsetHeight + 'px'; - drag.el.classList.add('opacity-50'); - drag.el.after(drag.ph); - e.dataTransfer.effectAllowed = 'move'; - }); - - accPanel.addEventListener('dragover', e => { - if (!drag.el) return; - e.preventDefault(); - const y = e.clientY; - let closest = null, dist = Infinity; - for (const it of accSections().filter(x => x !== drag.el)) { - const r = it.getBoundingClientRect(); - const m = r.top + r.height / 2; - const d = Math.abs(y - m); - if (d < dist) { dist = d; closest = it; } - } - if (!closest) return; - const r = closest.getBoundingClientRect(); - if (y < r.top + r.height / 2) accPanel.insertBefore(drag.ph, closest); - else accPanel.insertBefore(drag.ph, closest.nextSibling); - }); - - function cleanupDrag() { - if (!drag.el) return; - drag.el.classList.remove('opacity-50'); - if (drag.ph && drag.ph.parentNode) { - accPanel.insertBefore(drag.el, drag.ph); - drag.ph.remove(); - } - drag.el = drag.ph = null; - saveAccOrder(); - } - - accPanel.addEventListener('drop', e => { if (drag.el) { e.preventDefault(); cleanupDrag(); }}); - accPanel.addEventListener('dragend', () => cleanupDrag()); - accPanel.addEventListener('toggle', e => { if (e.target.matches('details[data-acc-id]')) saveAccOpen(); }, true); - accPanel.addEventListener('scroll', () => { try { localStorage.setItem(SCROLL_KEY, String(accPanel.scrollTop)); } catch {} }); - - function restorePanelScroll() { accPanel.scrollTop = Number(localStorage.getItem(SCROLL_KEY)) || 0; } - - // Toolbar - expandBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = true)); saveAccOpen(); }); - collapseBtn?.addEventListener('click', () => { accSections().forEach(d => (d.open = false)); saveAccOpen(); }); - reorderBtn?.addEventListener('click', () => { - const isReordering = accPanel.classList.toggle('reorder-on'); - reorderBtn.setAttribute('aria-pressed', String(isReordering)); - }); - - // Init - restoreAccOrder(); - restoreAccOpen(); - restorePanelScroll(); - } - - // expose for classic.js - window.setupAccordionPanel = setupAccordionPanel; - // ----------------------------- // Organic app logic // ----------------------------- @@ -193,6 +72,8 @@ // ====== DOM ====== const canvas = document.getElementById('balloon-canvas'); const ctx = canvas?.getContext('2d'); + const orgSheet = document.getElementById('controls-panel'); + const claSheet = document.getElementById('classic-controls-panel'); // tool buttons const toolDrawBtn = document.getElementById('tool-draw'); @@ -206,9 +87,16 @@ const eraserSizeLabel = document.getElementById('eraser-size-label'); const deleteSelectedBtn = document.getElementById('delete-selected'); const duplicateSelectedBtn = document.getElementById('duplicate-selected'); + const selectedSizeInput = document.getElementById('selected-size'); + const selectedSizeLabel = document.getElementById('selected-size-label'); + const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected')); + const bringForwardBtn = document.getElementById('bring-forward'); + const sendBackwardBtn = document.getElementById('send-backward'); + const applyColorBtn = document.getElementById('apply-selected-color'); const sizePresetGroup = document.getElementById('size-preset-group'); - const toggleShineBtn = document.getElementById('toggle-shine-btn'); + const toggleShineBtn = null; + const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox'); const paletteBox = document.getElementById('color-palette'); const usedPaletteBox = document.getElementById('used-palette'); @@ -225,10 +113,13 @@ const saveJsonBtn = document.getElementById('save-json-btn'); const loadJsonInput = document.getElementById('load-json-input'); - // delegate export buttons (shared IDs across tabs) + // delegate export buttons (now by data-export to allow multiple) document.body.addEventListener('click', e => { - if (e.target.id === 'export-png-btn') exportPng(); - else if (e.target.id === 'export-svg-btn') exportSvg(); + const btn = e.target.closest('[data-export]'); + if (!btn) return; + const type = btn.dataset.export; + if (type === 'png') exportPng(); + else if (type === 'svg') exportSvg(); }); const generateLinkBtn = document.getElementById('generate-link-btn'); @@ -243,8 +134,8 @@ // layout const controlsPanel = document.getElementById('controls-panel'); const canvasPanel = document.getElementById('canvas-panel'); - const expandBtn = document.getElementById('expand-workspace-btn'); - const fullscreenBtn = document.getElementById('fullscreen-btn'); + const expandBtn = null; + const fullscreenBtn = null; if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page @@ -263,8 +154,107 @@ let selectedBalloonId = null; let usedSortDesc = true; + // History for Undo/Redo + const historyStack = []; + let historyPointer = -1; + + function pushHistory() { + // Remove any future history if we are in the middle of the stack + if (historyPointer < historyStack.length - 1) { + historyStack.splice(historyPointer + 1); + } + // Deep clone balloons array + const snapshot = JSON.parse(JSON.stringify(balloons)); + historyStack.push(snapshot); + historyPointer++; + // Limit stack size + if (historyStack.length > 50) { + historyStack.shift(); + historyPointer--; + } + } + + function undo() { + if (historyPointer > 0) { + historyPointer--; + balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); + selectedBalloonId = null; // clear selection on undo to avoid issues + updateSelectButtons(); + draw(); + renderUsedPalette(); + persist(); + } + } + + function redo() { + if (historyPointer < historyStack.length - 1) { + historyPointer++; + balloons = JSON.parse(JSON.stringify(historyStack[historyPointer])); + selectedBalloonId = null; + updateSelectButtons(); + draw(); + renderUsedPalette(); + persist(); + } + } + + // Bind Undo/Redo Buttons + document.getElementById('tool-undo')?.addEventListener('click', () => { + undo(); + // 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 + const toolEyedropperBtn = document.getElementById('tool-eyedropper'); + toolEyedropperBtn?.addEventListener('click', () => { + // Toggle eyedropper mode + if (mode === 'eyedropper') { + setMode('draw'); // toggle off + } else { + setMode('eyedropper'); + // Auto-minimize on mobile + if (window.innerWidth < 1024) { + document.getElementById('controls-panel')?.classList.add('minimized'); + } + } + }); + + // ====== Helpers ====== const normalizeHex = h => (h || '').toLowerCase(); + function hexToRgb(hex) { + const h = normalizeHex(hex).replace('#',''); + if (h.length === 3) { + const r = parseInt(h[0] + h[0], 16); + const g = parseInt(h[1] + h[1], 16); + const b = parseInt(h[2] + h[2], 16); + return { r, g, b }; + } + if (h.length === 6) { + const r = parseInt(h.slice(0,2), 16); + const g = parseInt(h.slice(2,4), 16); + const b = parseInt(h.slice(4,6), 16); + return { r, g, b }; + } + return { r: 0, g: 0, b: 0 }; + } + function luminance(hex) { + const { r, g, b } = hexToRgb(hex || '#000'); + const norm = [r,g,b].map(v => { + const c = v / 255; + return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); + }); + return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2]; + } function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; } function radiusToSizeIndex(r) { let best = 0, bestDiff = Infinity; @@ -312,21 +302,77 @@ toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw')); toolEraseBtn?.setAttribute('aria-pressed', String(mode === 'erase')); toolSelectBtn?.setAttribute('aria-pressed', String(mode === 'select')); + 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'); selectControls?.classList.toggle('hidden', mode !== 'select'); - canvas.style.cursor = (mode === 'erase') ? 'none' : (mode === 'select' ? 'pointer' : 'crosshair'); + + // 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'; + else if (mode === 'select') { + canvas.style.cursor = 'default'; + } + else if (mode === 'eyedropper') canvas.style.cursor = 'cell'; + 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(); persist(); } + // ... (rest of the file) ... + + function updateSelectButtons() { const has = !!selectedBalloonId; if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has; if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has; + if (selectedSizeInput) selectedSizeInput.disabled = !has; + if (bringForwardBtn) bringForwardBtn.disabled = !has; + if (sendBackwardBtn) sendBackwardBtn.disabled = !has; + if (applyColorBtn) applyColorBtn.disabled = !has; + if (has && selectedSizeInput && selectedSizeLabel) { + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (b) { + selectedSizeInput.value = Math.round(b.radius); + selectedSizeLabel.textContent = `${Math.round(b.radius)}`; + } + } } // ====== Pointer Events ====== let pointerDown = false; + let isDragging = false; + let dragStartPos = { x: 0, y: 0 }; + let initialBalloonPos = { x: 0, y: 0 }; canvas.addEventListener('pointerdown', e => { e.preventDefault(); @@ -334,16 +380,74 @@ mouseInside = true; mousePos = getMousePos(e); - if (e.altKey) { pickColorAt(mousePos.x, mousePos.y); return; } - if (mode === 'erase') { pointerDown = true; eraseAt(mousePos.x, mousePos.y); return; } - if (mode === 'select') { selectAt(mousePos.x, mousePos.y); return; } + if (e.altKey || mode === 'eyedropper') { + pickColorAt(mousePos.x, mousePos.y); + if (mode === 'eyedropper') setMode('draw'); // Auto-switch back? or stay? Let's stay for multi-pick, or switch for quick workflow. Let's switch back for now. + return; + } + + if (mode === 'erase') { + pointerDown = true; + pushHistory(); // Save state before erasing + eraseAt(mousePos.x, mousePos.y); + return; + } + + if (mode === 'select') { + const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y); + + if (clickedIdx !== -1) { + // We clicked on a balloon + const b = balloons[clickedIdx]; + if (selectedBalloonId !== b.id) { + selectedBalloonId = b.id; + updateSelectButtons(); + draw(); + } + // Start Dragging + isDragging = true; + pointerDown = true; + dragStartPos = { ...mousePos }; + initialBalloonPos = { x: b.x, y: b.y }; + pushHistory(); // Save state before move + } else { + // Clicked empty space -> deselect + if (selectedBalloonId) { + selectedBalloonId = null; + updateSelectButtons(); + draw(); + } + // Perhaps handle panning here later? + } + return; + } // draw mode: add + pushHistory(); // Save state before add addBalloon(mousePos.x, mousePos.y); + pointerDown = true; // track for potential continuous drawing or other gestures? }, { passive: false }); canvas.addEventListener('pointermove', e => { mousePos = getMousePos(e); + + if (mode === 'select') { + if (isDragging && selectedBalloonId) { + const dx = mousePos.x - dragStartPos.x; + const dy = mousePos.y - dragStartPos.y; + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (b) { + b.x = initialBalloonPos.x + dx; + b.y = initialBalloonPos.y + dy; + draw(); + } + } else { + // Hover cursor + const hoverIdx = findBalloonIndexAt(mousePos.x, mousePos.y); + canvas.style.cursor = (hoverIdx !== -1) ? 'move' : 'default'; + } + } + if (mode === 'erase') { if (pointerDown) eraseAt(mousePos.x, mousePos.y); else draw(); @@ -352,6 +456,7 @@ canvas.addEventListener('pointerup', e => { pointerDown = false; + isDragging = false; canvas.releasePointerCapture?.(e.pointerId); }, { passive: true }); @@ -361,13 +466,31 @@ }, { passive: true }); // ====== Canvas & Drawing ====== + let hasFittedView = false; function resizeCanvas() { - const rect = canvas.getBoundingClientRect(); + const rect = canvas.parentElement?.getBoundingClientRect?.() || 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); - canvas.width = Math.round(rect.width * dpr); - canvas.height = Math.round(rect.height * dpr); + canvas.width = Math.round(Math.min(rect.width, window.innerWidth) * dpr); + canvas.height = Math.round(Math.min(rect.height, window.innerHeight) * dpr); 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(); } function clearCanvasArea() { @@ -422,6 +545,8 @@ } if (isShineEnabled) { + const isBright = luminance(b.color) > 0.75; + const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`; const sx = b.x - b.radius * SHINE_OFFSET; const sy = b.y - b.radius * SHINE_OFFSET; const rx = b.radius * SHINE_RX; @@ -439,7 +564,12 @@ ctx.scale(rx / ry, 1); ctx.arc(0, 0, ry, 0, Math.PI * 2); } - ctx.fillStyle = `rgba(255,255,255,${SHINE_ALPHA})`; + ctx.fillStyle = shineFill; + if (isBright) { + ctx.strokeStyle = 'rgba(0,0,0,0.45)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } ctx.fill(); ctx.restore(); } @@ -452,11 +582,14 @@ ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2); - ctx.setLineDash([6, 4]); - ctx.lineWidth = 2 / view.s; - ctx.strokeStyle = '#2563eb'; + // White halo + ctx.lineWidth = 4 / view.s; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.stroke(); + // Blue ring + ctx.lineWidth = 2 / view.s; + ctx.strokeStyle = '#3b82f6'; ctx.stroke(); - ctx.setLineDash([]); ctx.restore(); } } @@ -472,49 +605,24 @@ ctx.stroke(); ctx.restore(); } + + // eyedropper preview + if (mode === 'eyedropper' && mouseInside) { + ctx.save(); + ctx.beginPath(); + ctx.arc(mousePos.x, mousePos.y, 10 / view.s, 0, Math.PI * 2); + ctx.lineWidth = 2 / view.s; + ctx.strokeStyle = '#fff'; + ctx.stroke(); + ctx.lineWidth = 1 / view.s; + ctx.strokeStyle = '#000'; + ctx.stroke(); + ctx.restore(); + } ctx.restore(); } - // --- Workspace expansion + Fullscreen --- - let expanded = false; - function setExpanded(on) { - expanded = on; - controlsPanel?.classList.toggle('hidden', expanded); - if (canvasPanel) { - canvasPanel.classList.toggle('lg:w-full', expanded); - canvasPanel.classList.toggle('lg:w-2/3', !expanded); - } - if (expanded) { - canvas.classList.remove('aspect-video'); - canvas.style.height = '85vh'; - } else { - canvas.classList.add('aspect-video'); - canvas.style.height = ''; - } - resizeCanvas(); - if (expandBtn) expandBtn.textContent = expanded ? 'Exit expanded view' : 'Expand workspace'; - persist(); - } - function isFullscreen() { return !!(document.fullscreenElement || document.webkitFullscreenElement); } - async function toggleFullscreenPage() { - try { - if (!isFullscreen()) { await document.documentElement.requestFullscreen(); } - else { await document.exitFullscreen(); } - } catch { - // if blocked, just use expanded - setExpanded(true); - } - } - const onFsChange = () => { - if (fullscreenBtn) fullscreenBtn.textContent = isFullscreen() ? 'Exit Fullscreen' : 'Fullscreen'; - resizeCanvas(); - }; - expandBtn?.addEventListener('click', () => setExpanded(!expanded)); - fullscreenBtn?.addEventListener('click', toggleFullscreenPage); - document.addEventListener('fullscreenchange', onFsChange); - document.addEventListener('webkitfullscreenchange', onFsChange); - new ResizeObserver(() => resizeCanvas()).observe(canvas.parentElement); canvas.style.touchAction = 'none'; @@ -523,8 +631,22 @@ function saveAppState() { // Note: isShineEnabled is managed globally. - const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, mode, view, usedSortDesc, expanded }; + const state = { balloons, selectedColorIdx, currentDiameterInches, eraserRadius, mode, view, usedSortDesc }; 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); }; })(); @@ -548,7 +670,6 @@ usedSortDesc = s.usedSortDesc; if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most'; } - if (typeof s.expanded === 'boolean') setExpanded(s.expanded); } catch {} } @@ -570,8 +691,10 @@ const idx = FLAT_COLORS.find(fc => fc.name === c.name && fc.hex === c.hex && fc.family === group.family)?._idx ?? HEX_TO_FIRST_IDX.get(normalizeHex(c.hex)); - const sw = document.createElement('div'); + const sw = document.createElement('button'); + sw.type = 'button'; sw.className = 'swatch'; + sw.setAttribute('aria-label', c.name); if (c.image) { const meta = FLAT_COLORS[idx] || {}; @@ -624,8 +747,12 @@ const row = document.createElement('div'); row.className = 'swatch-row'; used.forEach(item => { - const sw = document.createElement('div'); + const sw = document.createElement('button'); + sw.type = 'button'; sw.className = 'swatch'; + const name = item.name || NAME_BY_HEX.get(item.hex) || item.hex; + sw.setAttribute('aria-label', `${name} - Count: ${item.count}`); + if (item.image) { const meta = FLAT_COLORS[HEX_TO_FIRST_IDX.get(item.hex)] || {}; sw.style.backgroundImage = `url("${item.image}")`; @@ -635,7 +762,7 @@ sw.style.backgroundColor = item.hex; } if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === item.hex) sw.classList.add('active'); - sw.title = `${item.name || NAME_BY_HEX.get(item.hex) || item.hex} — ${item.count}`; + sw.title = `${name} — ${item.count}`; sw.addEventListener('click', () => { selectedColorIdx = HEX_TO_FIRST_IDX.get(item.hex) ?? 0; renderAllowedPalette(); @@ -693,6 +820,53 @@ draw(); } + function moveSelected(dx, dy) { + if (!selectedBalloonId) return; + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (!b) return; + b.x += dx; + b.y += dy; + refreshAll(); + } + + function resizeSelected(newRadius) { + if (!selectedBalloonId) return; + const b = balloons.find(bb => bb.id === selectedBalloonId); + if (!b) return; + b.radius = clamp(newRadius, 5, 200); + refreshAll(); + updateSelectButtons(); + } + + function bringSelectedForward() { + if (!selectedBalloonId) return; + const idx = balloons.findIndex(bb => bb.id === selectedBalloonId); + if (idx === -1 || idx === balloons.length - 1) return; + const [b] = balloons.splice(idx, 1); + balloons.push(b); + refreshAll(); + } + + function sendSelectedBackward() { + if (!selectedBalloonId) return; + const idx = balloons.findIndex(bb => bb.id === selectedBalloonId); + if (idx <= 0) return; + const [b] = balloons.splice(idx, 1); + balloons.unshift(b); + refreshAll(); + } + + function applyColorToSelected() { + if (!selectedBalloonId) return; + const b = balloons.find(bb => bb.id === selectedBalloonId); + const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0]; + if (!b || !meta) return; + b.color = meta.hex; + b.image = meta.image || null; + b.colorIdx = meta._idx; + refreshAll(); + } + function deleteSelected() { if (!selectedBalloonId) return; balloons = balloons.filter(b => b.id !== selectedBalloonId); @@ -792,145 +966,296 @@ }; reader.readAsText(file); } - - // ** NEW ** Rewritten export function to embed images - async function exportPng() { - const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; - if (currentTab === '#tab-classic') { - const svgElement = document.querySelector('#classic-display svg'); - if (!svgElement) { - showModal('Classic design not found. Please create a design first.'); - return; - } + // ====== Export helpers ====== + const DATA_URL_CACHE = new Map(); + const XLINK_NS = 'http://www.w3.org/1999/xlink'; + let lastActiveTab = '#tab-organic'; - // 1. Clone the SVG to avoid modifying the live one - const clonedSvg = svgElement.cloneNode(true); - const imageElements = Array.from(clonedSvg.querySelectorAll('image')); + function getImageHref(el) { + return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); + } + function setImageHref(el, val) { + el.setAttribute('href', val); + el.setAttributeNS(XLINK_NS, 'xlink:href', val); + } + const blobToDataUrl = blob => new Promise((resolve, reject) => { + const r = new FileReader(); + r.onloadend = () => resolve(r.result); + r.onerror = reject; + r.readAsDataURL(blob); + }); - // 2. Create promises to fetch and convert each image to a Data URL - const promises = imageElements.map(async (image) => { - const href = image.getAttribute('href'); - if (!href || href.startsWith('data:')) return; // Skip if no href or already a data URL - - try { - const response = await fetch(href); - const blob = await response.blob(); - const dataUrl = await new Promise(resolve => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); - reader.readAsDataURL(blob); - }); - image.setAttribute('href', dataUrl); - } catch (error) { - console.error(`Could not fetch image ${href}:`, error); - } - }); - - // 3. Wait for all images to be embedded - await Promise.all(promises); - - // 4. Serialize the modified, self-contained SVG - const svgData = new XMLSerializer().serializeToString(clonedSvg); - const img = new Image(); - - img.onload = () => { - const viewBox = svgElement.getAttribute('viewBox').split(' ').map(Number); - const svgWidth = viewBox[2]; - const svgHeight = viewBox[3]; - const scale = 2; // for higher resolution - - const canvasEl = document.createElement('canvas'); - canvasEl.width = svgWidth * scale; - canvasEl.height = svgHeight * scale; - const ctx2 = canvasEl.getContext('2d'); - - ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); - download(canvasEl.toDataURL('image/png'), 'classic_design.png'); - }; - - img.onerror = () => { - showModal("An error occurred while creating the PNG from the SVG."); - }; - - img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgData))); - - } else { - // Organic canvas export (remains the same) - if (balloons.length === 0) { - showModal('Canvas is empty.'); - return; - } - download(canvas.toDataURL('image/png'), 'balloon_design.png'); - } + function imageToDataUrl(img) { + if (!img || !img.complete || img.naturalWidth === 0) return null; + try { + const c = document.createElement('canvas'); + c.width = img.naturalWidth; + c.height = img.naturalHeight; + c.getContext('2d').drawImage(img, 0, 0); + return c.toDataURL('image/png'); + } catch (err) { + console.warn('[Export] imageToDataUrl failed:', err); + return null; + } } + async function imageUrlToDataUrl(src) { + if (!src || src.startsWith('data:')) return src; + if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src); + const cachedImg = IMG_CACHE.get(src); + const cachedUrl = imageToDataUrl(cachedImg); + if (cachedUrl) { + DATA_URL_CACHE.set(src, cachedUrl); + return cachedUrl; + } + const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })(); + let dataUrl = null; + try { + const resp = await fetch(abs); + if (!resp.ok) throw new Error(`Status ${resp.status}`); + dataUrl = await blobToDataUrl(await resp.blob()); + } catch (err) { + console.warn('[Export] Fetch failed for', abs, err); + // Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://) + dataUrl = await new Promise(resolve => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + const c = document.createElement('canvas'); + c.width = img.naturalWidth || 1; + c.height = img.naturalHeight || 1; + c.getContext('2d').drawImage(img, 0, 0); + resolve(c.toDataURL('image/png')); + } catch (e) { + console.error('[Export] Canvas fallback failed for', abs, e); + resolve(null); + } + }; + img.onerror = () => resolve(null); + img.src = abs; + }); + } + if (!dataUrl) dataUrl = abs; + DATA_URL_CACHE.set(src, dataUrl); + return dataUrl; + } - function exportSvg() { - const currentTab = (window.__whichTab && window.__whichTab()) || '#tab-organic'; + async function embedImagesInSvg(svgEl) { + const images = Array.from(svgEl.querySelectorAll('image')); + const hrefs = [...new Set(images.map(getImageHref).filter(h => h && !h.startsWith('data:')))]; + const urlMap = new Map(); + await Promise.all(hrefs.map(async (href) => { + urlMap.set(href, await imageUrlToDataUrl(href)); + })); + images.forEach(img => { + const orig = getImageHref(img); + const val = urlMap.get(orig); + if (val) setImageHref(img, val); + }); + return svgEl; + } - if (currentTab === '#tab-classic') { - const svgElement = document.querySelector('#classic-display svg'); - if (!svgElement) { showModal('Classic design not found.'); return; } - const svgData = new XMLSerializer().serializeToString(svgElement); - const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; - download(url, 'classic_design.svg'); - } else { - // Organic canvas-to-SVG export - if (balloons.length === 0) { - showModal('Canvas is empty. Add some balloons first.'); - return; + async function buildOrganicSvgPayload() { + if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.'); + + const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))]; + const dataUrlMap = new Map(); + await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url)))); + + const bounds = balloonsBounds(); + const pad = 20; + const width = bounds.w + pad * 2; + const height = bounds.h + pad * 2; + const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' '); + + let defs = ''; + let elements = ''; + const patterns = new Map(); + + balloons.forEach(b => { + let fill = b.color; + if (b.image) { + const patternKey = `${b.colorIdx}|${b.image}`; + if (!patterns.has(patternKey)) { + const patternId = `p${patterns.size}`; + patterns.set(patternKey, patternId); + const meta = FLAT_COLORS[b.colorIdx] || {}; + const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); + const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); + const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); + const imgW = zoom, imgH = zoom; + const imgX = 0.5 - (fx * zoom); + const imgY = 0.5 - (fy * zoom); + const imageHref = dataUrlMap.get(b.image) || b.image; + defs += ` + + `; } + fill = `url(#${patterns.get(patternKey)})`; + } + elements += ``; - const bounds = balloonsBounds(); - const pad = 20; - const vb = [bounds.minX - pad, bounds.minY - pad, bounds.w + pad * 2, bounds.h + pad * 2].join(' '); + if (isShineEnabled) { + const sx = b.x - b.radius * SHINE_OFFSET; + const sy = b.y - b.radius * SHINE_OFFSET; + const rx = b.radius * SHINE_RX; + const ry = b.radius * SHINE_RY; + const isBright = luminance(b.color) > 0.75; + const shineFill = isBright ? 'rgba(0,0,0,0.55)' : `rgba(255,255,255,${SHINE_ALPHA})`; + const stroke = isBright ? ' stroke="rgba(0,0,0,0.45)" stroke-width="1.5"' : ''; + elements += ``; + } + }); - let defs = ''; - let elements = ''; - const patterns = new Map(); + const svgString = ` + ${defs} + ${elements} + `; - balloons.forEach(b => { - let fill = b.color; - if (b.image) { - const patternId = `p${b.colorIdx}`; - if (!patterns.has(b.colorIdx)) { - patterns.set(b.colorIdx, patternId); - const meta = FLAT_COLORS[b.colorIdx] || {}; - const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT); - const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x); - const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y); + return { svgString, width, height }; + } - // Calculate image attributes to simulate the canvas crop/zoom - const imgW = zoom; - const imgH = zoom; - const imgX = 0.5 - (fx * zoom); - const imgY = 0.5 - (fy * zoom); + async function buildClassicSvgPayload() { + const svgElement = document.querySelector('#classic-display svg'); + if (!svgElement) throw new Error('Classic design not found. Please create a design first.'); + const clonedSvg = svgElement.cloneNode(true); - defs += ` - - `; - } - fill = `url(#${patternId})`; - } - elements += `\n`; + // Inline pattern images and any other nodes + const allImages = Array.from(clonedSvg.querySelectorAll('image')); + await Promise.all(allImages.map(async img => { + const href = getImageHref(img); + if (!href || href.startsWith('data:')) return; + const dataUrl = await imageUrlToDataUrl(href); + if (dataUrl) setImageHref(img, dataUrl); + })); - if (isShineEnabled) { - const sx = b.x - b.radius * SHINE_OFFSET; - const sy = b.y - b.radius * SHINE_OFFSET; - const rx = b.radius * SHINE_RX; - const ry = b.radius * SHINE_RY; - elements += `\n`; - } - }); + // Ensure required namespaces are present + const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number); + const vbX = isFinite(viewBox[0]) ? viewBox[0] : 0; + const vbY = isFinite(viewBox[1]) ? viewBox[1] : 0; + const vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000); + const vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000); + clonedSvg.setAttribute('width', vbW); + clonedSvg.setAttribute('height', vbH); + if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS); - const svgData = ` - ${defs} - ${elements} - `; - - const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`; - download(url, 'organic_design.svg'); + // Some viewers ignore external styles; bake key style attributes directly + clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => { + if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827'); + if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '2'); + if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill'); + if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke'); + }); + + const svgString = new XMLSerializer().serializeToString(clonedSvg); + return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY }; + } + + async function svgStringToPng(svgString, width, height) { + const img = new Image(); + const scale = 2; + const canvasEl = document.createElement('canvas'); + canvasEl.width = Math.max(1, Math.round(width * scale)); + canvasEl.height = Math.max(1, Math.round(height * scale)); + const ctx2 = canvasEl.getContext('2d'); + const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = () => reject(new Error('Could not rasterize SVG.')); + img.src = dataUrl; + }); + ctx2.drawImage(img, 0, 0, canvasEl.width, canvasEl.height); + return canvasEl.toDataURL('image/png'); + } + + function detectCurrentTab() { + const bodyActive = document.body?.dataset?.activeTab; + const activeBtn = document.querySelector('#mode-tabs .tab-btn.tab-active'); + const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden'); + const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden'); + + let id = bodyActive || activeBtn?.dataset?.target; + if (!id) { + if (classicVisible && !organicVisible) id = '#tab-classic'; + else if (organicVisible && !classicVisible) id = '#tab-organic'; + } + if (!id) id = lastActiveTab || '#tab-organic'; + lastActiveTab = id; + if (document.body) document.body.dataset.activeTab = id; + return id; + } + + function updateSheets(activeId) { + const tab = activeId || detectCurrentTab(); + // Panels should be visible if their tab is active. + // Mobile minimization is handled by the .minimized class, not .hidden. + if (orgSheet) orgSheet.classList.toggle('hidden', tab !== '#tab-organic'); + 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() { + try { + const currentTab = detectCurrentTab(); + + if (currentTab === '#tab-classic') { + const { svgString, width, height } = await buildClassicSvgPayload(); + const pngUrl = await svgStringToPng(svgString, width, height); + download(pngUrl, 'classic_design.png'); + return; + } + + const { svgString, width, height } = await buildOrganicSvgPayload(); + const pngUrl = await svgStringToPng(svgString, width, height); + download(pngUrl, 'balloon_design.png'); + } catch (err) { + console.error('[Export PNG] Failed:', err); + showModal(err.message || 'Could not export PNG. Check console for details.'); + } + } + + function downloadSvg(svgString, filename) { + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + download(url, filename); + setTimeout(() => URL.revokeObjectURL(url), 20000); + } + + async function exportSvg() { + try { + const currentTab = detectCurrentTab(); + + if (currentTab === '#tab-classic') { + const { svgString, width, height } = await buildClassicSvgPayload(); + try { + const pngUrl = await svgStringToPng(svgString, width, height); + const cleanSvg = ` + + `; + downloadSvg(cleanSvg, 'classic_design.svg'); + return; + } catch (pngErr) { + console.warn('[Export SVG] PNG embed failed, falling back to vector-only SVG', pngErr); + downloadSvg(svgString, 'classic_design.svg'); + return; + } + } + + const { svgString } = await buildOrganicSvgPayload(); + downloadSvg(svgString, 'organic_design.svg'); + } catch (err) { + console.error('[Export] A critical error occurred during SVG export:', err); + showModal(err.message || 'An unexpected error occurred during SVG export. Check console for details.'); } } @@ -1008,6 +1333,7 @@ const worldH = ch / view.s; view.tx = (worldW - w) * 0.5 - box.minX; view.ty = (worldH - h) * 0.5 - box.minY; + hasFittedView = true; } function balloonScreenBounds(b) { @@ -1068,6 +1394,19 @@ deleteSelectedBtn?.addEventListener('click', deleteSelected); duplicateSelectedBtn?.addEventListener('click', duplicateSelected); + nudgeSelectedBtns.forEach(btn => btn.addEventListener('click', () => { + const dx = Number(btn.dataset.dx || 0); + const dy = Number(btn.dataset.dy || 0); + moveSelected(dx, dy); + })); + + selectedSizeInput?.addEventListener('input', e => { + resizeSelected(parseFloat(e.target.value) || 0); + }); + + bringForwardBtn?.addEventListener('click', bringSelectedForward); + sendBackwardBtn?.addEventListener('click', sendSelectedBackward); + applyColorBtn?.addEventListener('click', applyColorToSelected); document.addEventListener('keydown', e => { if (document.activeElement && document.activeElement.tagName === 'INPUT') return; @@ -1075,11 +1414,21 @@ else if (e.key === 'v' || e.key === 'V') setMode('draw'); else if (e.key === 's' || e.key === 'S') setMode('select'); else if (e.key === 'Escape') { - selectedBalloonId = null; - updateSelectButtons(); - draw(); + if (selectedBalloonId) { + selectedBalloonId = null; + updateSelectButtons(); + draw(); + } else if (mode !== 'draw') { + setMode('draw'); + } } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedBalloonId) { e.preventDefault(); deleteSelected(); } + } else if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { + e.preventDefault(); + undo(); + } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || e.key === 'Y')) { + e.preventDefault(); + redo(); } }); @@ -1164,8 +1513,9 @@ sizePresetGroup?.appendChild(btn); }); - toggleShineBtn?.addEventListener('click', () => { - window.syncAppShine(!isShineEnabled); + toggleShineCheckbox?.addEventListener('change', e => { + const on = !!e.target.checked; + window.syncAppShine(on); }); renderAllowedPalette(); @@ -1176,16 +1526,7 @@ updateSelectButtons(); populateReplaceTo(); - if (window.matchMedia('(max-width: 768px)').matches) setExpanded(true); - - // Init accordion for the Organic panel - setupAccordionPanel({ - panelId: 'controls-panel', - expandBtnId: 'expand-all', - collapseBtnId: 'collapse-all', - reorderBtnId: 'toggle-reorder', - storagePrefix: 'obd' // Organic Balloon Designer - }); + // default to canvas-first on mobile; no expansion toggles remain // Initialize shine state from localStorage for both panels let initialShineState = true; @@ -1196,7 +1537,7 @@ // Set Organic panel's internal state and UI isShineEnabled = initialShineState; - if (toggleShineBtn) toggleShineBtn.textContent = isShineEnabled ? 'Turn Off Shine' : 'Turn On Shine'; + if (toggleShineCheckbox) toggleShineCheckbox.checked = isShineEnabled; // Set Classic panel's UI checkbox (its script will read this too) const classicCb = document.getElementById('classic-shine-enabled'); @@ -1205,18 +1546,70 @@ // =============================== // ===== TAB SWITCHING (UI) ====== // =============================== - (() => { - const orgSection = document.getElementById('tab-organic'); - const claSection = document.getElementById('tab-classic'); - const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn'); + const orgSection = document.getElementById('tab-organic'); + const claSection = document.getElementById('tab-classic'); + const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn'); + const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; - if (!orgSection || !claSection || tabBtns.length === 0) return; + function updateMobileStacks(tabName) { + const orgPanel = document.getElementById('controls-panel'); + const claPanel = document.getElementById('classic-controls-panel'); + const currentTab = detectCurrentTab(); + const panel = currentTab === '#tab-classic' ? claPanel : orgPanel; + const target = tabName || document.body?.dataset?.mobileTab || 'controls'; + const isHidden = document.body?.dataset?.controlsHidden === '1'; + if (!panel) return; + const stacks = Array.from(panel.querySelectorAll('.control-stack')); + if (!stacks.length) return; + + // If we passed 'all', show everything (Desktop mode) + const showAll = (tabName === 'all'); + + stacks.forEach(stack => { + if (isHidden) { + stack.style.display = 'none'; + } else { + const show = showAll ? true : stack.dataset.mobileTab === target; + // Use flex to match CSS .control-stack + stack.style.display = show ? 'flex' : 'none'; + } + }); + } + function setMobileTab(tab) { + const name = tab || 'controls'; + const isDesktop = window.matchMedia('(min-width: 1024px)').matches; + if (document.body) { + document.body.dataset.mobileTab = name; + delete document.body.dataset.controlsHidden; + } + updateSheets(); + updateMobileStacks(name); + const buttons = document.querySelectorAll('#mobile-tabbar .mobile-tab-btn'); + buttons.forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.mobileTab === name))); + } + window.__setMobileTab = setMobileTab; + + if (orgSection && claSection && tabBtns.length > 0) { let current = '#tab-organic'; - function show(id) { + function setTab(id, isInitial = false) { + if (!id || !document.querySelector(id)) id = '#tab-organic'; + current = id; + lastActiveTab = id; + if (document.body) document.body.dataset.activeTab = id; + + // Reset minimized state on tab switch + orgSheet?.classList.remove('minimized'); + claSheet?.classList.remove('minimized'); + orgSection.classList.toggle('hidden', id !== '#tab-organic'); claSection.classList.toggle('hidden', id !== '#tab-classic'); + updateSheets(id); + + // Ensure Dock is visible + const dock = document.getElementById('mobile-tabbar'); + if (dock) dock.style.display = 'flex'; tabBtns.forEach(btn => { const active = btn.dataset.target === id; @@ -1225,60 +1618,259 @@ btn.setAttribute('aria-pressed', String(active)); }); - current = id; + if (!isInitial) { + try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {} + } + + if (document.body) delete document.body.dataset.controlsHidden; + setMobileTab(document.body?.dataset?.mobileTab || 'controls'); + if (window.updateExportButtonVisibility) window.updateExportButtonVisibility(); } - tabBtns.forEach(btn => btn.addEventListener('click', () => show(btn.dataset.target))); - show('#tab-organic'); // default + tabBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + const button = e.target.closest('button[data-target]'); + if (button) setTab(button.dataset.target); + }); + }); + + let savedTab = null; + try { savedTab = localStorage.getItem(ACTIVE_TAB_KEY); } catch {} + setTab(savedTab || '#tab-organic', true); - // Helper so other code (e.g., export) can know which tab is visible window.__whichTab = () => current; + // ensure mobile default + if (!document.body?.dataset?.mobileTab) document.body.dataset.mobileTab = 'controls'; + setMobileTab(document.body.dataset.mobileTab); + updateSheets(); + 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 ======= + // =============================== + (function initMobileDock() { + if (window.__dockInit) return; + window.__dockInit = true; + + const dockOrganic = document.getElementById('dock-organic'); + const dockClassic = document.getElementById('dock-classic'); + const patternBtns = Array.from(document.querySelectorAll('.classic-pattern-btn')); + const variantBtns = Array.from(document.querySelectorAll('.classic-variant-btn')); + const topperBtns = Array.from(document.querySelectorAll('.classic-topper-btn')); + const drawerPattern = document.getElementById('classic-drawer-pattern'); + const drawerColors = document.getElementById('classic-drawer-colors'); + + function openColorsPanel() { + 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') => { + document.body.dataset.mobileTab = tab; + const panel = document.getElementById('controls-panel'); + panel?.classList.remove('minimized'); + updateSheets('#tab-organic'); + updateMobileStacks(tab); + }; + + 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 + patternBtns.forEach(btn => { + btn.addEventListener('click', () => { + const { variant } = currentPatternParts(); + const base = btn.dataset.patternBase || 'Arch'; + applyPattern(base, variant); + toggleClassicMenu('pattern'); + closeClassicDrawers(); + refreshClassicButtons(); + }); + }); + variantBtns.forEach(btn => { + btn.addEventListener('click', () => { + 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 + document.getElementById('header-export')?.addEventListener('click', () => exportPng()); + document.getElementById('header-undo')?.addEventListener('click', undo); + document.getElementById('header-redo')?.addEventListener('click', redo); + + 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(); })(); }); - - document.addEventListener('DOMContentLoaded', () => { - const modeTabs = document.getElementById('mode-tabs'); - const allPanels = document.querySelectorAll('#tab-organic, #tab-classic'); - const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1'; - - function switchTab(targetId) { - if (!targetId || !document.querySelector(targetId)) return; - - const targetPanel = document.querySelector(targetId); - const targetButton = modeTabs.querySelector(`button[data-target="${targetId}"]`); - - modeTabs.querySelectorAll('button').forEach(btn => { - btn.classList.remove('tab-active'); - btn.classList.add('tab-idle'); - btn.setAttribute('aria-pressed', 'false'); - }); - allPanels.forEach(panel => panel.classList.add('hidden')); - - if (targetButton && targetPanel) { - targetButton.classList.add('tab-active'); - targetButton.classList.remove('tab-idle'); - targetButton.setAttribute('aria-pressed', 'true'); - targetPanel.classList.remove('hidden'); - } - - if (window.updateExportButtonVisibility) { - window.updateExportButtonVisibility(); - } - } - - modeTabs.addEventListener('click', (e) => { - const button = e.target.closest('button[data-target]'); - if (button) { - const targetId = button.dataset.target; - localStorage.setItem(ACTIVE_TAB_KEY, targetId); - switchTab(targetId); - } - }); - - const savedTab = localStorage.getItem(ACTIVE_TAB_KEY); - if (savedTab) { - switchTab(savedTab); - } - }); -})(); \ No newline at end of file +})(); diff --git a/style.css b/style.css index 13147c1..0f3cd75 100644 --- a/style.css +++ b/style.css @@ -12,24 +12,52 @@ body { color: #1f2937; } /* Buttons */ .tool-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; padding: .5rem .75rem; - border: 1px solid #d1d5db; - border-radius: .5rem; + border: 1px solid #e2e8f0; + border-radius: .75rem; background: #fff; + color: #1e293b; + font-weight: 600; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } -.tool-btn[aria-pressed="true"] { background:#1f2937; color:#fff; border-color:#1f2937; } +.tool-btn svg { width: 1.1em; height: 1.1em; fill: currentColor; } +.tool-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 5px rgba(0,0,0,0.05); } +.tool-btn[aria-pressed="true"] { background:#3b82f6; color:#fff; border-color:#3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } -.btn-dark { background:#1f2937; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } -.btn-blue { background:#2563eb; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } -.btn-green { background:#16a34a; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } -.btn-yellow { background:#eab308; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } -.btn-danger { background:#ef4444; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } -.btn-indigo { background:#4f46e5; color:#fff; padding:.6rem .8rem; border-radius:.5rem; } +/* Base button style - Slate Gradient */ +.btn-dark { background: linear-gradient(135deg, #334155, #0f172a); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.15); } +.btn-dark:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(15, 23, 42, 0.25); } + +/* Primary Action - Vibrant Blue/Indigo Gradient */ +.btn-blue { background: linear-gradient(135deg, #6366f1, #3b82f6); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); } +.btn-blue:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4); } + +/* Success/Save - Emerald Gradient */ +.btn-green { background: linear-gradient(135deg, #10b981, #059669); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); } +.btn-green:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(16, 185, 129, 0.4); } + +/* Secondary Action - White Glass */ +.btn-yellow { background: rgba(255,255,255,0.9); color:#334155; border: 1px solid #cbd5e1; padding:.55rem .75rem; border-radius:.75rem; transition: all 0.2s; } +.btn-yellow:hover { background:#fff; border-color:#94a3b8; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } + +/* Destructive - Red Gradient */ +.btn-danger { background: linear-gradient(135deg, #ef4444, #dc2626); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); } +.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4); } + +/* Accent - Indigo/Purple Gradient */ +.btn-indigo { background: linear-gradient(135deg, #8b5cf6, #6366f1); color:#fff; padding:.6rem .8rem; border-radius:.75rem; transition: all 0.2s; box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); } +.btn-indigo:hover { transform: translateY(-1px); box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4); } + +.btn-dark.text-sm { padding:.35rem .55rem; } .copy-message{ opacity:0; transition:opacity .3s; } .copy-message.show{ opacity:1; } -.hint { font-size:.8rem; color:#6b7280; } +.hint { font-size:.8rem; color:#64748b; } /* Palette / Swatches */ .palette-box { @@ -37,24 +65,32 @@ body { color: #1f2937; } flex-direction: column; gap: .5rem; padding: .5rem; - background: #fff; + background: rgba(255,255,255,0.6); /* More transparent */ border: 1px solid #e5e7eb; - border-radius: .5rem; + border-radius: .75rem; + max-height: 260px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .swatch { + appearance: none; + padding: 0; position: relative; width: 2rem; height: 2rem; border-radius: 9999px; - border: 2px solid rgba(0,0,0,.15); - box-shadow: 0 1px 2px rgba(0,0,0,.08); + border: 2px solid rgba(0,0,0,.05); + box-shadow: 0 2px 4px rgba(0,0,0,.05); cursor: pointer; + transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1); } -.swatch.active { outline: 2px solid #3b82f6; outline-offset: 2px; } +.swatch:hover { transform: scale(1.1); z-index: 10; } +.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; } +.swatch.active { outline: 2px solid #6366f1; outline-offset: 2px; } .swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; } -.family-title { font-weight:600; color:#4b5563; margin-top:.25rem; font-size:.95rem; } +.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; } .badge { position:absolute; @@ -62,46 +98,29 @@ body { color: #1f2937; } min-width: 1.25rem; height: 1.25rem; padding: 0 .25rem; - background:#111827; + background:#1e293b; color:#fff; border-radius: 9999px; font-size:.7rem; display:flex; align-items:center; justify-content:center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } /* Selects */ .select { width: 100%; padding: .5rem .6rem; - border: 1px solid #d1d5db; + border: 1px solid #cbd5e1; border-radius: .5rem; background: #fff; + color: #334155; } - -/* hidden by default; only show in reorder mode */ -.drag-handle { - display: none; - width: 28px; height: 28px; margin-right: .25rem; - align-items: center; justify-content: center; - border-radius: .5rem; border: 1px solid #e5e7eb; /* gray-200 */ - background: #f8fafc; /* slate-50 */ - font-size: 18px; line-height: 1; - touch-action: none; /* better touch-drag */ - cursor: grab; - } - .drag-handle:active { cursor: grabbing; } - - .reorder-on .drag-handle { display: inline-flex; } - .reorder-on section { outline: 2px dashed #cbd5e1; outline-offset: 4px; } /* slate-300 */ - .drag-ghost { opacity: .6; } - - #classic-swatch-grid .sw { width: 24px; height: 24px; border-radius: 6px; border: 1px solid rgba(0,0,0,.1); cursor: pointer; } +#classic-swatch-grid .sw { width: 24px; height: 24px; border-radius: 6px; border: 1px solid rgba(0,0,0,.1); cursor: pointer; } #classic-swatch-grid .sw:focus { outline: 2px solid #2563eb; outline-offset: 2px; } -.slot-btn[aria-pressed="true"] { background:#2563eb; color:#fff; } -/* Add these new rules to your stylesheet */ +.slot-btn[aria-pressed="true"] { background:#3b82f6; color:#fff; } .slot-container { display: flex; flex-direction: column; @@ -116,10 +135,10 @@ body { color: #1f2937; } } .slot-swatch { - width: 2.5rem; /* 40px */ - height: 2.5rem; /* 40px */ + width: 2.5rem; + height: 2.5rem; border-radius: 9999px; - border: 3px solid #e5e7eb; /* gray-200 */ + border: 3px solid #e5e7eb; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.1); transition: border-color .2s, transform .2s; @@ -133,15 +152,303 @@ body { color: #1f2937; } } .slot-swatch:hover { - border-color: #9ca3af; /* gray-400 */ + border-color: #9ca3af; } .slot-swatch.active { - border-color: #2563eb; /* blue-600 */ + border-color: #2563eb; transform: scale(1.1); } .slot-label { font-weight: 600; - color: #4b5563; /* gray-600 */ -} \ No newline at end of file + color: #4b5563; +} + +/* Panel styling */ +.panel-heading { + font-weight: 800; + color: #334155; + margin-bottom: .35rem; + letter-spacing: -0.02em; +} +.panel-card { + background: rgba(255,255,255,0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255,255,255,0.6); + border-radius: 0.75rem; + padding: 0.75rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.03); +} +.control-stack { + display: flex; + flex-direction: column; +} + +/* ---------- Control sheet ---------- */ +.control-sheet { + position: fixed; + left: 0; + right: 0; + bottom: 3.8rem; + max-height: 60vh; + background: rgba(255,255,255,0.85); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-top: 1px solid rgba(255,255,255,0.5); + box-shadow: 0 -4px 30px rgba(0,0,0,0.08); + border-radius: 1.25rem 1.25rem 0 0; + padding: 1rem 0.75rem; + overflow-y: auto; + z-index: 30; + -webkit-overflow-scrolling: touch; + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); +} +.control-sheet.hidden { display: none; } +/* .control-sheet.minimized removed from global scope to fix desktop visibility */ +.panel-title { + font-weight: 900; + font-size: 1.1rem; + background: linear-gradient(to right, #4f46e5, #db2777); + -webkit-background-clip: text; + color: transparent; + margin-bottom: .5rem; +} +.panel-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; + margin-bottom: .4rem; +} +.sheet-close-btn { + padding: .45rem .75rem; + border-radius: 999px; + border: 1px solid #e5e7eb; + background: #f3f4f6; + font-weight: 700; + color: #111827; +} + +.control-stack { + display: flex; + flex-direction: column; +} + +@media (max-width: 1023px) { + body { padding-bottom: 88px; } + html, body { height: 100%; } + + .control-sheet.minimized { transform: translateY(100%); } + .control-sheet .control-stack { display: none; } + 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="save"] #controls-panel [data-mobile-tab="save"], + body[data-mobile-tab="controls"] #classic-controls-panel [data-mobile-tab="controls"], + body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"], + body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"] { + display: block; + } +} + +.mobile-tabbar { + position: fixed; + left: 0; + width: 100%; + bottom: 0; + display: flex; + justify-content: space-around; + align-items: stretch; + padding: .55rem .85rem .8rem; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: #fff; + z-index: 9999; + 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; + transition: transform 0.15s ease, box-shadow 0.2s ease; +} +.length-dial .dock-pill.active { + transform: scale(1.02); + box-shadow: 0 6px 18px rgba(37,99,235,0.28); +} +.length-dial .dock-pill.ping { + animation: tap-pulse 260ms ease; +} +@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) { + .control-sheet { + left: 1rem; + top: 7rem; + bottom: auto; + width: 340px; + max-height: calc(100vh - 8rem); + border-radius: 1.5rem; + position: sticky; + overflow-y: auto; + background: rgba(255,255,255,0.6); + border: 1px solid rgba(255,255,255,0.4); + } + body { padding-bottom: 0; overflow: auto; } +}