merge: incorporate v3 version

This commit is contained in:
chris 2025-12-01 09:20:21 -05:00
commit 0070506d92
4 changed files with 505 additions and 168 deletions

View File

@ -11,6 +11,60 @@
</div>`;
};
const normHex = (h) => (String(h || '')).trim().toLowerCase();
const clamp01 = (v) => Math.max(0, Math.min(1, v));
function hexToRgb(hex) {
const h = normHex(hex).replace('#', '');
if (h.length === 3) {
return {
r: parseInt(h[0] + h[0], 16) || 0,
g: parseInt(h[1] + h[1], 16) || 0,
b: parseInt(h[2] + h[2], 16) || 0
};
}
if (h.length === 6) {
return {
r: parseInt(h.slice(0,2), 16) || 0,
g: parseInt(h.slice(2,4), 16) || 0,
b: parseInt(h.slice(4,6), 16) || 0
};
}
return { r: 0, g: 0, b: 0 };
}
function luminance(hex) {
const { r, g, b } = hexToRgb(hex);
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 classicShineStyle(colorInfo) {
const hex = normHex(colorInfo?.hex || colorInfo?.colour || '');
if (hex.startsWith('#')) {
const lum = luminance(hex);
if (lum > 0.7) {
const t = clamp01((lum - 0.7) / 0.3);
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
return {
fill: `rgba(0,0,0,${fillAlpha})`,
opacity: 1,
stroke: null
};
}
}
return { fill: '#ffffff', opacity: 0.45, stroke: null };
}
function textStyleForColor(colorInfo) {
if (!colorInfo) return { color: '#0f172a', shadow: 'none' };
if (colorInfo.image) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.55)' };
const hex = normHex(colorInfo.hex);
if (hex.startsWith('#')) {
const lum = luminance(hex);
if (lum < 0.5) return { color: '#f8fafc', shadow: '0 1px 3px rgba(0,0,0,0.6)' };
return { color: '#0f172a', shadow: '0 1px 2px rgba(255,255,255,0.7)' };
}
return { color: '#0f172a', shadow: 'none' };
}
// -------- persistent color selection (now supports image textures) ----------
const PALETTE_KEY = 'classic:colors:v2';
@ -140,7 +194,7 @@
const balloonSize = (cell)=> (cell.shape.size ?? 1);
const cellScale = (cell)=> balloonSize(cell) * pxUnit;
function cellView(cell, id, explicitFill, model){
function cellView(cell, id, explicitFill, model, colorInfo){
const shape = cell.shape;
const scale = cellScale(cell);
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
@ -163,9 +217,13 @@
const kids = [shapeEl];
const applyShine = model.shineEnabled && (!cell.isTopper || (cell.isTopper && model.topperType === 'round'));
if (applyShine) {
kids.push(svg('ellipse', {
const shine = classicShineStyle(colorInfo);
const shineAttrs = {
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'
fill: shine.fill, opacity: shine.opacity, transform: 'rotate(-25)', 'pointer-events': 'none'
};
kids.push(svg('ellipse', {
...shineAttrs
}));
}
return svg('g', { id, transform }, kids);
@ -216,7 +274,7 @@ function distinctPaletteSlots(palette) {
];
for (let cell of cells) {
let c, fill;
let c, fill, colorInfo;
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;
@ -225,6 +283,7 @@ function distinctPaletteSlots(palette) {
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;
colorInfo = model.topperColor;
} else {
c = gridPos(cell.x, cell.y, cell.shape.zIndex, cell.inflate, pattern, model);
@ -270,14 +329,14 @@ function distinctPaletteSlots(palette) {
const colorCode = rowColorPatterns[rowIndex][cell.balloonIndexInCluster];
cell.colorCode = colorCode;
const colorInfo = model.palette[colorCode];
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);
const v = cellView(cell, `balloon_${cell.x}_${cell.y}`, fill, model, colorInfo);
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);
@ -464,6 +523,7 @@ function distinctPaletteSlots(palette) {
function initClassicColorPicker(onColorChange) {
const slotsContainer = document.getElementById('classic-slots'), topperSwatch = document.getElementById('classic-topper-color-swatch'), swatchGrid = document.getElementById('classic-swatch-grid'), activeLabel = document.getElementById('classic-active-label'), randomizeBtn = document.getElementById('classic-randomize-colors'), addSlotBtn = document.getElementById('classic-add-slot');
const topperBlock = document.getElementById('classic-topper-color-block');
if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return;
topperSwatch.classList.add('tab-btn');
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
@ -504,6 +564,7 @@ function distinctPaletteSlots(palette) {
enforceSlotVisibility();
const buttons = Array.from(slotsContainer.querySelectorAll('.slot-btn'));
[...buttons, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
buttons.forEach(el => el.classList.toggle('slot-active', activeTarget === el.dataset.slot));
buttons.forEach((slot, i) => {
const color = classicColors[i];
@ -512,6 +573,9 @@ function distinctPaletteSlots(palette) {
slot.style.backgroundColor = color.hex;
slot.style.backgroundSize = '200%';
slot.style.backgroundPosition = 'center';
const txt = textStyleForColor(color);
slot.style.color = txt.color;
slot.style.textShadow = txt.shadow;
});
const topperColor = getTopperColor();
@ -519,6 +583,13 @@ function distinctPaletteSlots(palette) {
topperSwatch.style.backgroundColor = topperColor.hex;
topperSwatch.style.backgroundSize = '200%';
topperSwatch.style.backgroundPosition = 'center';
const topperTxt = textStyleForColor(topperColor);
topperSwatch.style.color = topperTxt.color;
topperSwatch.style.textShadow = topperTxt.shadow;
const patName = (document.getElementById('classic-pattern')?.value || '').toLowerCase();
const topperEnabled = document.getElementById('classic-topper-enabled')?.checked;
const showTopperColor = patName.includes('column') && (patName.includes('4') || patName.includes('5')) && topperEnabled;
if (topperBlock) topperBlock.classList.toggle('hidden', !showTopperColor);
const patSelect = document.getElementById('classic-pattern');
const isStacked = (patSelect?.value || '').toLowerCase().includes('stacked');
@ -540,6 +611,8 @@ function distinctPaletteSlots(palette) {
(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.dataset.hex = normHex(colorItem.hex);
if (colorItem.image) sw.dataset.image = colorItem.image;
sw.style.backgroundImage = colorItem.image ? `url("${colorItem.image}")` : 'none';
sw.style.backgroundColor = colorItem.hex;
@ -588,6 +661,7 @@ function distinctPaletteSlots(palette) {
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
});
updateUI();
return updateUI;
}
function initClassic() {
@ -612,6 +686,7 @@ function distinctPaletteSlots(palette) {
};
if (!display) return fail('#classic-display not found');
const GC = GridCalculator(), ctrl = GC.controller(display);
let refreshClassicPaletteUi = null;
const getTopperType = () => topperTypeButtons.find(btn => btn.getAttribute('aria-pressed') === 'true')?.dataset.type || 'round';
const setTopperType = (type) => {
@ -693,6 +768,7 @@ function distinctPaletteSlots(palette) {
}
window.__updateFloatingNudge?.();
if(clusterHint) clusterHint.textContent = `${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
refreshClassicPaletteUi?.();
ctrl.selectPattern(patternName);
}
@ -738,10 +814,11 @@ function distinctPaletteSlots(palette) {
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
topperEnabledCb?.addEventListener('change', updateClassicDesign);
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
initClassicColorPicker(updateClassicDesign);
refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign);
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
setLengthForPattern();
updateClassicDesign();
refreshClassicPaletteUi?.();
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
log('Classic ready');
} catch (e) { fail(e.message || e); }

View File

@ -33,10 +33,12 @@
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
</div>
</div>
<div class="flex items-center gap-4">
<nav id="mode-tabs" class="flex gap-2">
<button type="button" class="tab-btn tab-active" data-target="#tab-organic" aria-pressed="true">Organic</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-classic" aria-pressed="false">Classic (Arch/Column)</button>
</nav>
</div>
</header>
<section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
@ -78,7 +80,7 @@
<div id="eraser-controls" class="hidden flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700">Eraser Size: <span id="eraser-size-label">30</span>px</label>
<input type="range" id="eraser-size" min="10" max="120" value="30" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<p class="hint">Click-drag to erase. Preview circle shows the area.</p>
<p class="hint">Hover to preview. Click-drag to erase.</p>
</div>
<div id="select-controls" class="hidden flex flex-col gap-2">
<div class="flex gap-2">
@ -86,21 +88,12 @@
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
</div>
<div class="mt-2">
<div class="flex items-center gap-2 text-xs text-gray-600 mb-1">
<span class="font-semibold">Move</span>
<span class="hint">↑↓←→</span>
</div>
<div class="grid grid-cols-4 gap-1 max-w-xs">
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="0" data-dy="-5" aria-label="Move Selection Up"></button>
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="5" data-dy="0" aria-label="Move Selection Right"></button>
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="0" data-dy="5" aria-label="Move Selection Down"></button>
<button type="button" class="btn-dark nudge-selected text-sm py-2" data-dx="-5" data-dy="0" aria-label="Move Selection Left"></button>
</div>
<p class="hint">Drag balloons to reposition. Use keyboard arrows for fine nudges.</p>
</div>
<div class="mt-2 flex items-center gap-2 text-xs text-gray-600">
<span class="font-semibold">Resize</span>
<input type="range" id="selected-size" min="5" max="200" value="40" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled>
<span id="selected-size-label" class="text-xs w-10 text-right">0</span>
<input type="range" id="selected-size" min="5" max="32" step="0.5" value="11" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" disabled>
<span id="selected-size-label" class="text-xs w-12 text-right">0\"</span>
</div>
<div class="mt-2 grid grid-cols-2 gap-2">
<button type="button" class="btn-dark text-sm py-2" id="bring-forward" disabled>Bring Forward</button>
@ -117,16 +110,17 @@
<div class="panel-heading mt-4">Size & Shine</div>
<div class="panel-card">
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
<p class="hint mb-3">Global scale lives in <code>PX_PER_INCH</code> (see <code>script.js</code>).</p>
<p class="hint mb-3">Size presets adjust the diameter for new balloons.</p>
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="toggle-shine-checkbox" type="checkbox" class="align-middle" checked>
Enable Shine
</label>
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Used Colors</div>
<div class="panel-heading">Project Palette</div>
<div class="panel-card">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">Built from the current design. Click a swatch to select that color.</span>
@ -135,19 +129,25 @@
<div id="used-palette" class="palette-box min-h-[3rem]"></div>
</div>
<div class="panel-heading mt-4">Allowed Colors</div>
<div class="panel-heading mt-4">Color Library</div>
<div class="panel-card">
<p class="hint mb-2">Alt+Click a balloon on canvas to pick its color.</p>
<p class="hint mb-2">Alt+click on canvas to sample a balloons color.</p>
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-medium text-gray-700">Active Color</span>
<div id="current-color-chip" class="current-color-chip">
<span id="current-color-label" class="text-xs font-semibold text-slate-700"></span>
</div>
</div>
<div id="color-palette" class="palette-box"></div>
</div>
<div class="panel-heading mt-4">Replace Color</div>
<div class="panel-card">
<div class="grid grid-cols-1 gap-2">
<label class="text-sm font-medium">From (used):</label>
<label class="text-sm font-medium">From (in design):</label>
<select id="replace-from" class="select"></select>
<label class="text-sm font-medium">To (allowed):</label>
<label class="text-sm font-medium">To (library):</label>
<select id="replace-to" class="select"></select>
<button id="replace-btn" class="btn-blue">Replace</button>
@ -177,7 +177,7 @@
<div class="flex flex-wrap gap-3 mt-2">
<button class="btn-dark bg-blue-600" data-export="png">Export PNG</button>
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
<p class="hint w-full">SVG currently Classic only.</p>
<p class="hint w-full">SVG export keeps vectors in Classic; Organic embeds textures.</p>
</div>
</div>
</div>
@ -268,6 +268,7 @@
<input id="classic-reverse" type="checkbox" class="align-middle">
Reverse spiral
</label>
<p class="hint">Use stacked for “same color per quad” layouts; reverse flips the spiral.</p>
</div>
</div>
</div>
@ -282,15 +283,17 @@
<div class="text-sm text-gray-600 mb-1">Pick a color for <span id="classic-active-label" class="font-bold">Slot #1</span> (from colors.js):</div>
<div id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div>
<div class="flex flex-wrap gap-2 mt-3">
<button id="classic-randomize-colors" class="btn-dark">Randomize 5</button>
<button id="classic-randomize-colors" class="btn-dark">Randomize</button>
</div>
<div class="panel-heading mt-3">Topper Color</div>
<div id="classic-topper-color-block" class="mt-3 hidden">
<div class="panel-heading">Topper Color</div>
<div class="flex items-center gap-3">
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
<p class="hint">Select a color then click to apply.</p>
</div>
</div>
</div>
</div>
<div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Save & Share</div>
@ -298,9 +301,9 @@
<div class="flex flex-wrap gap-3">
<button class="btn-dark bg-blue-600" data-export="png">Export PNG</button>
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</button>
<p class="hint w-full">SVG recommended for Classic.</p>
<p class="hint w-full">SVG keeps the vector Classic layout; PNG is raster.</p>
</div>
<p class="hint text-red-500">Classic JSON save/load not available yet.</p>
<p class="hint text-red-500">Classic JSON save/load coming soon.</p>
</div>
</div>
</aside>

461
script.js
View File

@ -11,14 +11,15 @@
const SIZE_PRESETS = [24, 18, 11, 9, 5];
// ====== Shine ellipse tuning ======
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.7; // ROT is now in degrees
const SHINE_OFFSET = 0.30, SHINE_RX = 0.40, SHINE_RY = 0.24, SHINE_ROT = -25, SHINE_ALPHA = 0.45; // ROT is now in degrees
let view = { s: 1, tx: 0, ty: 0 };
const FIT_PADDING_PX = 15;
const FIT_PADDING_PX = 30;
// ====== Texture defaults ======
const TEXTURE_ZOOM_DEFAULT = 1.8;
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
const SWATCH_TEXTURE_ZOOM = 2.5;
const PNG_EXPORT_SCALE = 3;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const clamp01 = v => clamp(v, 0, 1);
@ -79,6 +80,8 @@
const toolDrawBtn = document.getElementById('tool-draw');
const toolEraseBtn = document.getElementById('tool-erase');
const toolSelectBtn = document.getElementById('tool-select');
const toolUndoBtn = document.getElementById('tool-undo');
const toolRedoBtn = document.getElementById('tool-redo');
// panels/controls
const eraserControls = document.getElementById('eraser-controls');
@ -93,6 +96,7 @@
const bringForwardBtn = document.getElementById('bring-forward');
const sendBackwardBtn = document.getElementById('send-backward');
const applyColorBtn = document.getElementById('apply-selected-color');
const fitViewBtn = document.getElementById('fit-view-btn');
const sizePresetGroup = document.getElementById('size-preset-group');
const toggleShineBtn = null;
@ -151,12 +155,30 @@
let eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
let mouseInside = false;
let mousePos = { x: 0, y: 0 };
let selectedBalloonId = null;
let selectedIds = new Set();
let usedSortDesc = true;
// History for Undo/Redo
const historyStack = [];
let historyPointer = -1;
function resetHistory() {
historyStack.length = 0;
historyPointer = -1;
pushHistory();
}
function updateHistoryUi() {
const canUndo = historyPointer > 0;
const canRedo = historyPointer < historyStack.length - 1;
if (toolUndoBtn) {
toolUndoBtn.disabled = !canUndo;
toolUndoBtn.title = canUndo ? 'Undo (Ctrl+Z)' : 'Nothing to undo';
}
if (toolRedoBtn) {
toolRedoBtn.disabled = !canRedo;
toolRedoBtn.title = canRedo ? 'Redo (Ctrl+Y)' : 'Nothing to redo';
}
}
function pushHistory() {
// Remove any future history if we are in the middle of the stack
@ -172,35 +194,38 @@
historyStack.shift();
historyPointer--;
}
updateHistoryUi();
}
function undo() {
if (historyPointer > 0) {
historyPointer--;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedBalloonId = null; // clear selection on undo to avoid issues
selectedIds.clear(); // clear selection on undo to avoid issues
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
function redo() {
if (historyPointer < historyStack.length - 1) {
historyPointer++;
balloons = JSON.parse(JSON.stringify(historyStack[historyPointer]));
selectedBalloonId = null;
selectedIds.clear();
updateSelectButtons();
draw();
renderUsedPalette();
persist();
}
updateHistoryUi();
}
// Bind Undo/Redo Buttons
document.getElementById('tool-undo')?.addEventListener('click', undo);
document.getElementById('tool-redo')?.addEventListener('click', redo);
toolUndoBtn?.addEventListener('click', undo);
toolRedoBtn?.addEventListener('click', redo);
// Eyedropper Tool
const toolEyedropperBtn = document.getElementById('tool-eyedropper');
@ -240,7 +265,21 @@
});
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
}
function shineStyle(colorHex) {
const lum = luminance(colorHex);
if (lum > 0.7) {
const t = clamp01((lum - 0.7) / 0.3);
const fillAlpha = 0.22 + (0.10 - 0.22) * t;
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
}
return { fill: `rgba(255,255,255,${SHINE_ALPHA})`, stroke: null };
}
function inchesToRadiusPx(diam) { return (diam * PX_PER_INCH) / 2; }
function radiusPxToInches(r) { return (r * 2) / PX_PER_INCH; }
function fmtInches(val) {
const v = Math.round(val * 10) / 10;
return `${String(v).replace(/\.0$/, '')}"`;
}
function radiusToSizeIndex(r) {
let best = 0, bestDiff = Infinity;
for (let i = 0; i < SIZE_PRESETS.length; i++) {
@ -301,19 +340,45 @@
persist();
}
function selectionArray() { return Array.from(selectedIds); }
function selectionBalloons() {
const set = new Set(selectedIds);
return balloons.filter(b => set.has(b.id));
}
function setSelection(ids, { additive = false } = {}) {
if (!additive) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
updateSelectButtons();
draw();
}
function primarySelection() {
const first = selectedIds.values().next();
return first.done ? null : first.value;
}
function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); }
function updateSelectButtons() {
const has = !!selectedBalloonId;
const has = selectedIds.size > 0;
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
if (duplicateSelectedBtn) duplicateSelectedBtn.disabled = !has;
if (selectedSizeInput) selectedSizeInput.disabled = !has;
if (selectedSizeInput) {
selectedSizeInput.disabled = !has;
selectedSizeInput.min = '5';
selectedSizeInput.max = '32';
selectedSizeInput.step = '0.5';
}
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)}`;
if (selectedSizeInput && selectedSizeLabel) {
if (has) {
const first = balloons.find(bb => selectedIds.has(bb.id));
if (first) {
const diam = radiusPxToInches(first.radius);
selectedSizeInput.value = String(Math.min(32, Math.max(5, diam)));
selectedSizeLabel.textContent = fmtInches(diam);
}
} else {
selectedSizeLabel.textContent = '0"';
}
}
}
@ -323,6 +388,25 @@
let isDragging = false;
let dragStartPos = { x: 0, y: 0 };
let initialBalloonPos = { x: 0, y: 0 };
let eraseChanged = false;
let dragMoved = false;
let resizeChanged = false;
let resizeSaveTimer = null;
let erasingActive = false;
let drawPending = false;
let dragOffsets = [];
let marqueeActive = false;
let marqueeStart = { x: 0, y: 0 };
let marqueeEnd = { x: 0, y: 0 };
function requestDraw() {
if (drawPending) return;
drawPending = true;
requestAnimationFrame(() => {
drawPending = false;
draw();
});
}
canvas.addEventListener('pointerdown', e => {
e.preventDefault();
@ -338,81 +422,120 @@
if (mode === 'erase') {
pointerDown = true;
pushHistory(); // Save state before erasing
eraseAt(mousePos.x, mousePos.y);
erasingActive = true;
eraseChanged = 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;
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
if (e.shiftKey) {
if (selectedIds.has(b.id)) selectedIds.delete(b.id);
else selectedIds.add(b.id);
} else if (!selectedIds.has(b.id)) {
selectedIds.clear();
selectedIds.add(b.id);
}
updateSelectButtons();
draw();
}
// Perhaps handle panning here later?
isDragging = true;
dragStartPos = { ...mousePos };
dragOffsets = selectionBalloons().map(bb => ({ id: bb.id, dx: bb.x - mousePos.x, dy: bb.y - mousePos.y }));
dragMoved = false;
} else {
if (!e.shiftKey) selectedIds.clear();
updateSelectButtons();
marqueeActive = true;
marqueeStart = { ...mousePos };
marqueeEnd = { ...mousePos };
requestDraw();
}
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 => {
mouseInside = true;
mousePos = getMousePos(e);
if (mode === 'select') {
if (isDragging && selectedBalloonId) {
if (isDragging && selectedIds.size) {
const dx = mousePos.x - dragStartPos.x;
const dy = mousePos.y - dragStartPos.y;
const b = balloons.find(bb => bb.id === selectedBalloonId);
dragOffsets.forEach(off => {
const b = balloons.find(bb => bb.id === off.id);
if (b) {
b.x = initialBalloonPos.x + dx;
b.y = initialBalloonPos.y + dy;
draw();
b.x = mousePos.x + off.dx;
b.y = mousePos.y + off.dy;
}
});
requestDraw();
dragMoved = true;
} else if (marqueeActive) {
marqueeEnd = { ...mousePos };
requestDraw();
} 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();
if (pointerDown) {
eraseChanged = eraseAt(mousePos.x, mousePos.y) || eraseChanged;
if (eraseChanged) requestDraw();
} else {
requestDraw();
}
}
}, { passive: true });
canvas.addEventListener('pointerenter', () => {
mouseInside = true;
if (mode === 'erase') requestDraw();
});
canvas.addEventListener('pointerup', e => {
pointerDown = false;
isDragging = false;
if (mode === 'select' && dragMoved) {
refreshAll();
pushHistory();
}
if (mode === 'select' && marqueeActive) {
const minX = Math.min(marqueeStart.x, marqueeEnd.x);
const maxX = Math.max(marqueeStart.x, marqueeEnd.x);
const minY = Math.min(marqueeStart.y, marqueeEnd.y);
const maxY = Math.max(marqueeStart.y, marqueeEnd.y);
const ids = balloons.filter(b => b.x >= minX && b.x <= maxX && b.y >= minY && b.y <= maxY).map(b => b.id);
if (!e.shiftKey) selectedIds.clear();
ids.forEach(id => selectedIds.add(id));
marqueeActive = false;
updateSelectButtons();
requestDraw();
}
if (mode === 'erase' && eraseChanged) {
refreshAll(); // update palette/persist once after the stroke
pushHistory();
}
erasingActive = false;
dragMoved = false;
eraseChanged = false;
marqueeActive = false;
canvas.releasePointerCapture?.(e.pointerId);
}, { passive: true });
canvas.addEventListener('pointerleave', () => {
mouseInside = false;
if (mode === 'erase') draw();
marqueeActive = false;
if (mode === 'erase') requestDraw();
}, { passive: true });
// ====== Canvas & Drawing ======
@ -477,8 +600,7 @@
}
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 { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const sx = b.x - b.radius * SHINE_OFFSET;
const sy = b.y - b.radius * SHINE_OFFSET;
const rx = b.radius * SHINE_RX;
@ -497,8 +619,8 @@
ctx.arc(0, 0, ry, 0, Math.PI * 2);
}
ctx.fillStyle = shineFill;
if (isBright) {
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
if (shineStroke) {
ctx.strokeStyle = shineStroke;
ctx.lineWidth = 1.5;
ctx.stroke();
}
@ -507,23 +629,38 @@
}
});
// selection ring
if (selectedBalloonId) {
const b = balloons.find(bb => bb.id === selectedBalloonId);
if (b) {
// selection ring(s)
if (selectedIds.size) {
ctx.save();
selectedIds.forEach(id => {
const b = balloons.find(bb => bb.id === id);
if (!b) return;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
// 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.restore();
}
// marquee preview
if (mode === 'select' && marqueeActive) {
ctx.save();
ctx.setLineDash([6 / view.s, 4 / view.s]);
ctx.lineWidth = 1.5 / view.s;
ctx.strokeStyle = 'rgba(59,130,246,0.8)';
ctx.fillStyle = 'rgba(59,130,246,0.12)';
const x = Math.min(marqueeStart.x, marqueeEnd.x);
const y = Math.min(marqueeStart.y, marqueeEnd.y);
const w = Math.abs(marqueeStart.x - marqueeEnd.x);
const h = Math.abs(marqueeStart.y - marqueeEnd.y);
ctx.strokeRect(x, y, w, h);
ctx.fillRect(x, y, w, h);
ctx.restore();
}
// eraser preview
@ -587,10 +724,12 @@
usedSortDesc = s.usedSortDesc;
if (sortUsedToggle) sortUsedToggle.textContent = usedSortDesc ? 'Sort: Most → Least' : 'Sort: Least → Most';
}
updateCurrentColorChip();
} catch {}
}
loadAppState();
resetHistory(); // establish initial history state for undo/redo controls
// ====== UI Rendering (Palettes) ======
function renderAllowedPalette() {
@ -628,6 +767,7 @@
sw.addEventListener('click', () => {
selectedColorIdx = idx ?? 0;
renderAllowedPalette();
updateCurrentColorChip();
persist();
});
row.appendChild(sw);
@ -652,6 +792,28 @@
return arr;
}
function updateCurrentColorChip() {
const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
const updateChip = (chipId, labelId) => {
const chip = document.getElementById(chipId);
const label = document.getElementById(labelId);
if (!chip || !meta) return;
if (meta.image) {
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
chip.style.backgroundImage = `url("${meta.image}")`;
chip.style.backgroundSize = `${100 * SWATCH_TEXTURE_ZOOM}%`;
chip.style.backgroundPosition = `${fx * 100}% ${fy * 100}%`;
chip.style.backgroundColor = '#fff';
} else {
chip.style.backgroundImage = 'none';
chip.style.backgroundColor = meta.hex || '#fff';
}
if (label) label.textContent = meta.name || meta.hex || 'Current';
};
updateChip('current-color-chip', 'current-color-label');
}
function renderUsedPalette() {
if (!usedPaletteBox) return;
usedPaletteBox.innerHTML = '';
@ -719,7 +881,8 @@
id: crypto.randomUUID()
});
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
refreshAll();
refreshAll({ autoFit: true });
pushHistory();
}
function findBalloonIndexAt(x, y) {
@ -732,83 +895,107 @@
function selectAt(x, y) {
const i = findBalloonIndexAt(x, y);
selectedBalloonId = (i !== -1) ? balloons[i].id : null;
selectedIds.clear();
if (i !== -1) selectedIds.add(balloons[i].id);
updateSelectButtons();
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;
const sel = selectionBalloons();
if (!sel.length) return;
sel.forEach(b => { b.x += dx; b.y += dy; });
refreshAll();
pushHistory();
}
function resizeSelected(newRadius) {
if (!selectedBalloonId) return;
const b = balloons.find(bb => bb.id === selectedBalloonId);
if (!b) return;
b.radius = clamp(newRadius, 5, 200);
function resizeSelected(newDiamInches) {
const sel = selectionBalloons();
if (!sel.length) return;
const diam = clamp(newDiamInches, 5, 32);
const newRadius = inchesToRadiusPx(diam);
sel.forEach(b => { b.radius = newRadius; });
refreshAll();
updateSelectButtons();
resizeChanged = true;
clearTimeout(resizeSaveTimer);
resizeSaveTimer = setTimeout(() => {
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
}, 200);
}
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();
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const kept = balloons.filter(b => !set.has(b.id));
const moving = balloons.filter(b => set.has(b.id));
balloons = kept.concat(moving);
refreshAll({ autoFit: true });
pushHistory();
}
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();
const sel = selectionArray();
if (!sel.length) return;
const set = new Set(sel);
const moving = balloons.filter(b => set.has(b.id));
const kept = balloons.filter(b => !set.has(b.id));
balloons = moving.concat(kept);
refreshAll({ autoFit: true });
pushHistory();
}
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;
if (!meta) return;
let changed = false;
selectionBalloons().forEach(b => {
b.color = meta.hex;
b.image = meta.image || null;
b.colorIdx = meta._idx;
changed = true;
});
if (!changed) return;
refreshAll();
pushHistory();
}
function deleteSelected() {
if (!selectedBalloonId) return;
balloons = balloons.filter(b => b.id !== selectedBalloonId);
selectedBalloonId = null;
if (!selectedIds.size) return;
balloons = balloons.filter(b => !selectedIds.has(b.id));
selectedIds.clear();
updateSelectButtons();
refreshAll();
refreshAll({ autoFit: true });
pushHistory();
}
function duplicateSelected() {
if (!selectedBalloonId) return;
const b = balloons.find(bb => bb.id === selectedBalloonId);
if (!b) return;
const copy = { ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() };
balloons.push(copy);
selectedBalloonId = copy.id;
refreshAll();
const sel = selectionBalloons();
if (!sel.length) return;
const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: crypto.randomUUID() }));
copies.forEach(c => balloons.push(c));
selectedIds = new Set(copies.map(c => c.id));
refreshAll({ autoFit: true });
updateSelectButtons();
pushHistory();
}
function eraseAt(x, y) {
const before = balloons.length;
balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
if (selectedBalloonId && !balloons.find(b => b.id === selectedBalloonId)) {
selectedBalloonId = null;
updateSelectButtons();
const removed = balloons.length !== before;
if (selectedIds.size) {
const set = new Set(balloons.map(b => b.id));
let changed = false;
selectedIds.forEach(id => { if (!set.has(id)) { selectedIds.delete(id); changed = true; } });
if (changed) updateSelectButtons();
}
refreshAll();
if (removed && !erasingActive) requestDraw();
return removed;
}
function pickColorAt(x, y) {
@ -817,6 +1004,7 @@
selectedColorIdx = HEX_TO_FIRST_IDX.get(normalizeHex(balloons[i].color)) ?? 0;
renderAllowedPalette();
renderUsedPalette();
updateCurrentColorChip();
}
}
@ -873,9 +1061,10 @@
};
})
: [];
selectedBalloonId = null;
selectedIds.clear();
updateSelectButtons();
refreshAll({ refit: true });
resetHistory();
persist();
} catch {
showModal('Error parsing JSON file.');
@ -1019,9 +1208,8 @@
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"' : '';
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1.5"` : '';
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
}
});
@ -1038,6 +1226,19 @@
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);
let bbox = null;
try {
const temp = clonedSvg.cloneNode(true);
temp.style.position = 'absolute';
temp.style.left = '-99999px';
temp.style.top = '-99999px';
temp.style.width = '0';
temp.style.height = '0';
document.body.appendChild(temp);
const target = temp.querySelector('g') || temp;
bbox = target.getBBox();
temp.remove();
} catch {}
// Inline pattern images and any other <image> nodes
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
@ -1050,10 +1251,17 @@
// 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);
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) {
const pad = 10;
vbX = bbox.x - pad;
vbY = bbox.y - pad;
vbW = Math.max(1, bbox.width + pad * 2);
vbH = Math.max(1, bbox.height + pad * 2);
}
clonedSvg.setAttribute('width', vbW);
clonedSvg.setAttribute('height', vbH);
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
@ -1073,11 +1281,15 @@
async function svgStringToPng(svgString, width, height) {
const img = new Image();
const scale = 2;
const scale = PNG_EXPORT_SCALE;
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');
if (ctx2) {
ctx2.imageSmoothingEnabled = true;
ctx2.imageSmoothingQuality = 'high';
}
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
await new Promise((resolve, reject) => {
img.onload = resolve;
@ -1204,7 +1416,9 @@
})
: compactToDesign(data);
refreshAll({ refit: true });
resetHistory();
persist();
updateCurrentColorChip();
showModal('Design loaded from link!');
} catch {
showModal('Could not load design from URL.');
@ -1274,11 +1488,13 @@
view.tx += dx;
view.ty += dy;
return (dx !== 0 || dy !== 0);
}
// ====== Refresh & Events ======
function refreshAll({ refit = false } = {}) {
function refreshAll({ refit = false, autoFit = false } = {}) {
if (refit) fitView();
else if (autoFit) fitView();
draw();
renderUsedPalette();
persist();
@ -1310,10 +1526,22 @@
selectedSizeInput?.addEventListener('input', e => {
resizeSelected(parseFloat(e.target.value) || 0);
});
selectedSizeInput?.addEventListener('pointerdown', () => {
resizeChanged = false;
clearTimeout(resizeSaveTimer);
});
selectedSizeInput?.addEventListener('pointerup', () => {
clearTimeout(resizeSaveTimer);
if (resizeChanged) {
pushHistory();
resizeChanged = false;
}
});
bringForwardBtn?.addEventListener('click', bringSelectedForward);
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
applyColorBtn?.addEventListener('click', applyColorToSelected);
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
document.addEventListener('keydown', e => {
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
@ -1321,29 +1549,31 @@
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') {
if (selectedBalloonId) {
selectedBalloonId = null;
updateSelectButtons();
draw();
if (selectedIds.size) {
clearSelection();
} else if (mode !== 'draw') {
setMode('draw');
}
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedBalloonId) { e.preventDefault(); deleteSelected(); }
if (selectedIds.size) { 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();
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
e.preventDefault();
duplicateSelected();
}
});
clearCanvasBtn?.addEventListener('click', () => {
balloons = [];
selectedBalloonId = null;
selectedIds.clear();
updateSelectButtons();
refreshAll({ refit: true });
pushHistory();
});
saveJsonBtn?.addEventListener('click', saveJson);
@ -1394,6 +1624,7 @@
});
if (count > 0) {
pushHistory();
if (replaceMsg) replaceMsg.textContent = `Replaced ${count} balloon${count === 1 ? '' : 's'}.`;
if (normalizeHex(FLAT_COLORS[selectedColorIdx]?.hex) === normalizeHex(fromHex)) selectedColorIdx = toIdx;
refreshAll();

View File

@ -8,6 +8,7 @@ body { color: #1f2937; }
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -1px rgba(0,0,0,.06);
border: 1px black solid;
margin-top: 0.25rem;
}
/* Buttons */
@ -27,6 +28,7 @@ body { 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); }
.tool-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
/* 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); }
@ -89,6 +91,21 @@ body { color: #1f2937; }
.swatch:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; }
.swatch.active { outline: 2px solid #6366f1; outline-offset: 2px; }
.current-color-chip {
min-width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 2px solid rgba(51,65,85,0.15);
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 .6rem;
background-size: cover;
background-position: center;
background-color: #fff;
}
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; }
.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; }
@ -120,7 +137,14 @@ body { color: #1f2937; }
#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 { position: relative; overflow: hidden; }
.slot-btn[aria-pressed="true"] { background:#3b82f6; color:#fff; }
.slot-btn.slot-active {
box-shadow: 0 0 0 3px rgba(255,255,255,0.95);
outline: 3px solid #f97316;
outline-offset: 3px;
z-index: 1;
}
.slot-container {
display: flex;
flex-direction: column;
@ -149,6 +173,7 @@ body { color: #1f2937; }
color: rgba(0,0,0,0.4);
background-size: cover;
background-position: center;
position: relative;
}
.slot-swatch:hover {
@ -159,6 +184,7 @@ body { color: #1f2937; }
border-color: #2563eb;
transform: scale(1.1);
}
.slot-swatch.active::after { display: none; }
.topper-type-group {
display: flex;