chore: snapshot new version

This commit is contained in:
chris 2025-12-01 09:17:17 -05:00
parent f6d8914401
commit a3de8b5ac6
4 changed files with 2385 additions and 682 deletions

750
classic.js Normal file
View File

@ -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 = `<div style="padding:1rem;color:#b91c1c;font-family:system-ui,Arial">
<strong>Classic failed:</strong> ${String(msg)}
</div>`;
};
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 610, 1620, ...)
if (blockIndex % 2 === 1) {
if (balloonsPerCluster === 5) {
// [leftMid, leftBack, front, rightBack, rightMid]
[pat[0], pat[4]] = [pat[4], pat[0]];
// [pat[1], pat[3]] = [pat[3], pat[1]];
}
}
rowColorPatterns[rowIndex] = pat;
}
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<rows; y++){
let balloonIndexInCluster = 0;
for (let x=0; x<cols; x++) {
const cellData = pattern.createCell(x,y);
if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ });
}
}
if (name === 'Column 4' && topperEnabled) {
const shapeName = `topper-${topperType}`;
const originalShape = pattern.balloonShapes[shapeName];
if (originalShape) {
const shape = {...originalShape};
shape.size *= topperSizeMultiplier;
model.cells.push({ isTopper: true, shape, inflate: 0, x:0, y:rows });
}
}
return model;
}
function selectPattern(name){
const m = buildModel(name); models.push(m);
newGrid(m.pattern, m.cells, displayEl, m); return m;
}
return { selectPattern };
}
function roundedStarPath({ points = 5, outerR = 0.5, innerR = 0.22, round = 0.28, rotate = -90 }) {
const toRad = Math.PI / 180; const rot = rotate * toRad; const verts = [];
for (let i = 0; i < points * 2; i++) { const ang = rot + i * Math.PI / points; const R = (i % 2 === 0) ? outerR : innerR; verts.push([Math.cos(ang) * R, Math.sin(ang) * R]); }
const t = Math.max(0, Math.min(0.49, round));
const lerp = (a, b, u) => [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(); } });
})();

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Balloon Designer — Organic & Classic</title>
<title>Balloon Studio — Organic & Classic</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
@ -21,50 +21,42 @@
.copy-message{opacity:0;pointer-events:none;transition:opacity .2s}.copy-message.show{opacity:1}
</style>
</head>
<body class="p-4 md:p-8 flex flex-col items-center justify-center min-h-screen bg-gray-100 text-gray-900">
<div class="container mx-auto p-6 bg-white rounded-2xl shadow-x3 flex flex-col gap-6 max-w-7xl lg:h-[calc(100vh-4rem)]">
<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 id="global-export-bar" class="p-3 bg-gray-100/80 backdrop-blur-sm border border-gray-200 rounded-lg flex flex-wrap items-center justify-center gap-4 sticky top-4 z-20">
<h3 class="text-base font-semibold text-gray-700 mr-2 hidden sm:block">Export Design:</h3>
<button id="export-png-btn" class="btn-dark">Export as PNG</button>
<button id="export-svg-btn" class="btn-dark">Export as SVG</button>
<p class="text-xs text-gray-500 w-full text-center mt-1">(PNG for both modes, SVG for Classic mode)</p>
</div>
<body class="flex flex-col min-h-screen p-0 md:p-6 items-center justify-start bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800">
<section id="tab-organic" class="flex flex-col lg:flex-row gap-8 lg:h-[calc(100vh-12rem)]">
<aside id="controls-panel"
class="w-full lg:w-1/3 p-6 bg-gray-50 rounded-xl shadow-md flex flex-col gap-4
lg:min-h-0 lg:h-full lg:overflow-y-auto lg:pr-2">
<h1 class="text-3xl font-bold text-center text-blue-800">Organic Balloon Designer</h1>
<p class="text-gray-600 text-sm text-center">Click adds. Double-click deletes. Tools below for erase/select.</p>
<div id="controls-toolbar"
class="sticky top-0 z-10 -mx-6 px-6 pb-2 bg-gray-50/95 backdrop-blur supports-[backdrop-filter]:bg-gray-50/70 flex flex-wrap items-center gap-2">
<button id="expand-all" class="btn-dark">Expand all</button>
<button id="collapse-all" class="btn-dark">Collapse all</button>
<button id="toggle-reorder" class="btn-dark" aria-pressed="false">Reorder panels</button>
<header class="flex items-center justify-between gap-3 px-2 lg:px-0 py-2">
<div class="flex items-center gap-3">
<div>
<div class="text-xl font-black text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-pink-600 tracking-tight filter drop-shadow-sm">Balloon Studio</div>
</div>
</div>
<details class="group" data-acc-id="tools" open>
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Tools</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<div class="grid grid-cols-3 gap-2 mb-3">
<button id="tool-draw" class="tool-btn" aria-pressed="true" title="V">Draw</button>
<button id="tool-erase" class="tool-btn" aria-pressed="false" title="E">Eraser</button>
<button id="tool-select" class="tool-btn" aria-pressed="false" title="S">Select</button>
</div>
<!-- Mode Switcher Restored -->
<nav id="mode-tabs" class="flex gap-1 bg-slate-100 p-1 rounded-lg">
<button type="button" class="tab-btn tab-active text-xs !py-1 !px-2" data-target="#tab-organic" aria-pressed="true">Organic</button>
<button type="button" class="tab-btn tab-idle text-xs !py-1 !px-2" data-target="#tab-classic" aria-pressed="false">Classic</button>
</nav>
<div class="flex items-center gap-2">
<button id="header-undo" class="tool-btn !p-2" title="Undo">
<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
</button>
<button id="header-redo" class="tool-btn !p-2" title="Redo">
<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 9 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>
</button>
<button id="header-export" class="btn-blue text-xs font-bold !py-2 !px-3" data-export="png">Export</button>
</div>
</header>
<section id="tab-organic" class="flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row">
<h2 class="panel-title">Organic Controls</h2>
<button type="button" class="sheet-close-btn" data-sheet-toggle="controls-panel">Hide</button>
</div>
<div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Selection Options</div>
<div class="panel-card">
<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">
@ -72,248 +64,315 @@
</div>
<div id="select-controls" class="hidden flex flex-col gap-2">
<div class="flex gap-2">
<button id="delete-selected" class="btn-danger" disabled>Delete Selected</button>
<button id="delete-selected" class="btn-danger" disabled>Delete</button>
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
</div>
<p class="hint">Click a balloon to select. <kbd>Del</kbd>/<kbd>Backspace</kbd> removes. <kbd>Esc</kbd> clears.</p>
<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>
</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>
</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>
<button type="button" class="btn-dark text-sm py-2" id="send-backward" disabled>Send Backward</button>
</div>
<div class="mt-2">
<button type="button" class="btn-blue w-full" id="apply-selected-color" disabled>Apply Current Color</button>
<p class="hint">Uses the color/texture currently picked in the palette.</p>
</div>
<p class="hint">Click a balloon to select. Del/Backspace removes. Esc clears.</p>
</div>
<!-- Fallback hint if nothing selected/erasing -->
<p id="controls-empty-hint" class="hint mt-2 hidden">Select a balloon or choose the Eraser tool to see options.</p>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Balloon Size</div>
<div class="panel-card">
<div id="size-preset-group" class="grid grid-cols-5 gap-2 mb-2"></div>
<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>
</div>
<div class="panel-heading mt-4">Palette in Use</div>
<div class="panel-card">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-600">Colors currently on your canvas.</span>
<button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button>
</div>
<div id="used-palette" class="palette-box min-h-[3rem]"></div>
</div>
<div class="panel-heading mt-4">Color Library</div>
<div class="panel-card">
<p class="hint mb-2">Pick a color to draw with.</p>
<div id="color-palette" class="palette-box"></div>
</div>
<div class="panel-heading mt-4">Swap Colors</div>
<div class="panel-card">
<div class="grid grid-cols-1 gap-2">
<label class="text-sm font-medium">Change this color:</label>
<select id="replace-from" class="select"></select>
<label class="text-sm font-medium">To this new color:</label>
<select id="replace-to" class="select"></select>
<button id="replace-btn" class="btn-blue">Swap All</button>
<p id="replace-msg" class="hint"></p>
</div>
</div>
</details>
</div>
<details class="group" data-acc-id="share">
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Share</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Share</div>
<div class="panel-card">
<div class="relative mb-3">
<input type="text" id="share-link-output" class="w-full p-3 pr-10 border border-gray-300 rounded-lg text-sm text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500" readonly placeholder="Click 'Generate Link' to create a shareable URL">
<div id="copy-message" class="copy-message absolute right-2 top-1/2 -translate-y-1/2 bg-blue-500 text-white px-2 py-1 text-xs rounded-full">Copied!</div>
</div>
<button id="generate-link-btn" class="btn-indigo">Generate Shareable Link</button>
<button id="generate-link-btn" class="btn-indigo w-full">Generate Shareable Link</button>
</div>
</details>
<details class="group" data-acc-id="save">
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Save & Load</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<div class="panel-heading mt-4">Save & Load</div>
<div class="panel-card">
<div class="flex flex-wrap gap-3 mb-3">
<button id="clear-canvas-btn" class="btn-danger">Clear Canvas</button>
<button id="save-json-btn" class="btn-green">Save Design</button>
<label for="load-json-input" class="btn-yellow text-center cursor-pointer">Load JSON</label>
<input type="file" id="load-json-input" class="hidden" accept=".json">
</div>
</div>
</details>
<details class="group" data-acc-id="allowed" open>
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Colors</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<p class="hint mb-2">Alt+Click a balloon on canvas to pick its color.</p>
<div id="color-palette" class="palette-box"></div>
</div>
</details>
<details class="group" data-acc-id="replace">
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Replace Color</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<div class="grid grid-cols-1 gap-2">
<label class="text-sm font-medium">From (used):</label>
<select id="replace-from" class="select"></select>
<label class="text-sm font-medium">To (allowed):</label>
<select id="replace-to" class="select"></select>
<button id="replace-btn" class="btn-blue">Replace</button>
<p id="replace-msg" class="hint"></p>
<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>
</div>
</div>
</details>
<details class="group" data-acc-id="size">
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Balloon Size (Diameter)</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<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>
<button id="toggle-shine-btn" class="btn-dark">Turn Off Shine</button>
</div>
</details>
<details class="group" data-acc-id="used" open>
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Color Palette</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<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>
<button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button>
</div>
<div id="used-palette" class="palette-box min-h-[3rem]"></div>
</div>
</details>
</div>
</aside>
<section id="canvas-panel" class="w-full lg:flex-1 flex flex-col items-stretch lg:sticky lg:top-8 lg:self-start shadow-x3">
<div class="flex gap-2 mb-3">
<button id="expand-workspace-btn" class="bg-gray-700 text-white px-3 py-2 rounded">Expand workspace</button>
<button id="fullscreen-btn" class="bg-gray-700 text-white px-3 py-2 rounded">Fullscreen</button>
</div>
<canvas id="balloon-canvas" class="balloon-canvas w-full aspect-video"></canvas>
<section id="canvas-panel" class="order-1 lg:order-2 w-full lg:flex-1 flex flex-col items-stretch rounded-2xl overflow-hidden bg-white/50 shadow-inner ring-1 ring-black/5">
<canvas id="balloon-canvas" class="balloon-canvas w-full min-h-[65vh]"></canvas>
</section>
</section>
<section id="tab-classic" class="hidden flex flex-col lg:flex-row gap-8 lg:h-[calc(100vh-12rem)]">
<section id="tab-classic" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="classic-controls-panel" class="control-sheet lg:static lg:w-[360px] lg:max-h-none lg:overflow-y-auto">
<div class="panel-header-row">
<h2 class="panel-title">Classic Controls</h2>
<button type="button" class="sheet-close-btn" data-sheet-toggle="classic-controls-panel">Hide</button>
</div>
<div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Pattern & Layout</div>
<div class="panel-card space-y-4">
<div class="flex flex-wrap items-center gap-3">
<span class="classic-quick-label">Colors</span>
<div class="dock-pill-group">
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="4">4 colors</button>
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="5">5 colors</button>
</div>
</div>
<select id="classic-pattern" class="select hidden">
<option value="Arch 4">Arch 4 (4-color spiral)</option>
<option value="Column 4">Column 4 (quad wrap)</option>
<option value="Arch 5">Arch 5 (5-color spiral)</option>
<option value="Column 5">Column 5 (5-balloon wrap)</option>
</select>
<aside id="classic-controls-panel"
class="w-full lg:w-1/3 p-6 bg-gray-50 rounded-xl shadow-md flex flex-col gap-4
lg:min-h-0 lg:h-full lg:overflow-y-auto lg:pr-2">
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="classic-quick-label">Length</span>
<span class="text-xs text-gray-500" id="classic-length-label">ft</span>
</div>
<div id="classic-length-presets" class="length-dial"></div>
<input id="classic-length-ft" type="number" min="1" max="100" step="0.5" value="5" class="hidden">
</div>
<h2 class="text-2xl font-bold text-blue-800">Classic Designer (Arch / Column)</h2>
<p class="text-gray-600 text-sm">Quad-wrap column or arch with a 4-color spiral.</p>
<div id="classic-topper-toggle-row" class="flex items-center gap-3 pt-2 border-t border-gray-200 hidden">
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-topper-enabled" type="checkbox" class="align-middle">
Add Topper (24")
</label>
</div>
<div id="classic-toolbar"
class="sticky top-0 z-10 -mx-6 px-6 pb-2 bg-gray-50/95 backdrop-blur supports-[backdrop-filter]:bg-gray-50/70
flex flex-wrap items-center gap-2">
<button id="classic-expand-all" class="btn-dark">Expand all</button>
<button id="classic-collapse-all" class="btn-dark">Collapse all</button>
<button id="classic-toggle-reorder" class="btn-dark" aria-pressed="false">Reorder panels</button>
</div>
<div id="topper-controls" class="hidden grid grid-cols-1 sm:grid-cols-4 gap-3 items-end pt-2">
<label class="text-sm sm:col-span-2">Topper Type:
<select id="classic-topper-type" class="select align-middle" disabled>
<option value="round">24" Round</option>
<option value="star">24" Star</option>
<option value="heart">24" Heart</option>
</select>
</label>
<label class="text-sm">Topper Color:
<div id="classic-topper-color-swatch" class="slot-swatch mx-auto" title="Click to change topper color">T</div>
</label>
<div class="col-span-full">
<div class="panel-heading mt-2 mb-2">Topper Nudge</div>
<div class="grid grid-cols-3 gap-2 max-w-xs justify-items-center">
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0.5" data-dy="0" aria-label="Move Topper Right"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="-0.5" aria-label="Move Topper Down"></button>
<button type="button" class="btn-nudge nudge-topper col-span-3" data-dx="-0.5" data-dy="0" aria-label="Move Topper Left"></button>
</div>
</div>
<label class="text-sm">Topper Size:
<input id="classic-topper-size" type="range" min="0.5" max="2" step="0.05" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</label>
</div>
<details class="group" data-acc-id="classic-layout" open>
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Pattern & Layout</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
<div class="text-xs text-gray-500">
<span id="classic-cluster-hint">≈ 10 clusters (rule: 2 clusters/ft)</span>
</div>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="text-sm">Pattern:
<select id="classic-pattern" class="select align-middle">
<option value="Arch 4">Arch 4 (4-color spiral)</option>
<option value="Column 4">Column 4 (quad wrap)</option>
<option value="Arch 5">Arch 5 (5-color spiral)</option>
<option value="Column 5">Column 5 (5-balloon wrap)</option>
</select>
</label>
<label class="text-sm">Length (ft):
<input id="classic-length-ft" type="number" min="1" max="100" step="0.5" value="5" class="w-full px-2 py-1 border rounded align-middle">
</label>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 pt-2 border-t border-gray-200">
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-shine-enabled" type="checkbox" class="align-middle" checked>
Enable Shine
</label>
<label class="text-sm inline-flex items-center gap-2">
<input id="classic-reverse" type="checkbox" class="align-middle">
Reverse spiral
</label>
</div>
</div>
</div>
<div id="topper-controls" class="hidden grid grid-cols-1 sm:grid-cols-4 gap-3 items-end pt-2 border-t border-gray-200">
<label class="text-sm sm:col-span-2">Topper Type:
<select id="classic-topper-type" class="select align-middle" disabled>
<option value="round">24" Round</option>
<option value="star">24" Star</option>
<option value="heart">24" Heart</option>
</select>
</label>
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-topper-enabled" type="checkbox" class="align-middle">
Add Topper
</label>
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-shine-enabled" type="checkbox" class="align-middle" checked>
Enable Shine
</label>
<label class="text-sm">Topper Color:
<div id="classic-topper-color-swatch" class="slot-swatch mx-auto" title="Click to change topper color">T</div>
</label>
<label class="text-sm">X Offset:
<input id="classic-topper-offset-x" type="number" step="0.5" value="0" class="w-full px-2 py-1 border rounded align-middle">
</label>
<label class="text-sm">Y Offset:
<input id="classic-topper-offset-y" type="number" step="0.5" value="0" class="w-full px-2 py-1 border rounded align-middle">
</label>
<label class="text-sm">Topper Size:
<input id="classic-topper-size" type="range" min="0.5" max="2" step="0.05" value="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</label>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Classic Colors</div>
<div class="panel-card">
<div id="classic-slots" class="flex items-center gap-2 mb-3">
<button type="button" class="slot-btn tab-btn" data-slot="1">#1</button>
<button type="button" class="slot-btn tab-btn" data-slot="2">#2</button>
<button type="button" class="slot-btn tab-btn" data-slot="3">#3</button>
<button type="button" class="slot-btn tab-btn" data-slot="4">#4</button>
<button type="button" class="slot-btn tab-btn" data-slot="5">#5</button>
</div>
<div 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>
</div>
</div>
</div>
<div class="text-xs text-gray-500">
<span id="classic-cluster-hint">≈ 10 clusters (rule: 2 clusters/ft)</span>
</div>
<div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Save & Share</div>
<div class="panel-card space-y-3">
<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>
</div>
<p class="hint text-red-500">Classic JSON save/load not available yet.</p>
</div>
</div>
</aside>
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 pt-2 border-t border-gray-200">
<label class="text-sm inline-flex items-center gap-2">
<input id="classic-reverse" type="checkbox" class="align-middle">
Reverse spiral
</label>
<button id="classic-rerender" class="btn-blue ml-auto">Rebuild</button>
</div>
</div>
</details>
<section id="classic-canvas-panel"
class="order-1 w-full lg:flex-1 flex flex-col items-stretch shadow-x3 rounded-2xl overflow-hidden bg-white">
<div id="classic-display"
class="rounded-xl"
style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div>
</section>
<details class="group" data-acc-id="classic-colors" open>
<summary class="cursor-pointer select-none flex items-center justify-between rounded-lg bg-white/70 px-3 py-2 shadow-sm hover:bg-white">
<span class="flex items-center gap-2">
<span class="drag-handle text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing" draggable="true" title="Drag to reorder">⋮⋮</span>
<span class="text-lg font-semibold text-gray-800">Classic Colors</span>
</span>
<svg class="h-4 w-4 transition-transform group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11.086l3.71-3.855a.75.75 0 111.08 1.04l-4.24 4.41a.75.75 0 01-1.08 0l-4.24-4.41a.75.75 0 01.02-1.06z"/></svg>
</summary>
</section>
<div class="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3">
<div id="classic-slots" class="flex items-center gap-2 mb-3">
<button type="button" class="slot-btn tab-btn" data-slot="1">#1</button>
<button type="button" class="slot-btn tab-btn" data-slot="2">#2</button>
<button type="button" class="slot-btn tab-btn" data-slot="3">#3</button>
<button type="button" class="slot-btn tab-btn" data-slot="4">#4</button>
<button type="button" class="slot-btn tab-btn" data-slot="5">#5</button>
</div>
<div 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>
</div>
</div>
</details>
</aside>
<div id="mobile-tabbar" class="mobile-tabbar justify-center gap-6 !py-4 !bg-slate-900/95 backdrop-blur-xl border-t border-white/10">
<div id="dock-organic" class="flex items-center gap-3">
<button id="dock-draw" class="mobile-tool-btn active" data-dock="organic" title="Draw">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button id="dock-erase" class="mobile-tool-btn" data-dock="organic" title="Erase">
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l8.48-8.48-3.54-3.54-8.48 8.48c-.79.79-.79 2.05 0 2.84z"/></svg>
</button>
<button id="dock-color-trigger" class="dock-color-btn" style="background-color: #2563eb;" aria-label="Open Colors"></button>
<button id="dock-select" class="mobile-tool-btn" data-dock="organic" title="Select">
<svg viewBox="0 0 24 24"><path d="M7 2l12 11.2-5.8.5 3.3 7.3-2.2.9-3.2-7.4-4.4 4V2z"/></svg>
</button>
<button id="dock-picker" class="mobile-tool-btn" data-dock="organic" title="Picker">
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
</button>
</div>
<section id="classic-canvas-panel"
class="w-full lg:flex-1 flex flex-col items-stretch lg:sticky lg:top-8 lg:self-start shadow-x3">
<div id="classic-display"
class="rounded-xl"
style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div>
</section>
</section>
<div id="dock-classic" class="hidden flex items-center justify-center gap-3">
<button id="dock-arch" class="mobile-tool-btn classic-pattern-btn" data-pattern-base="Arch" title="Arch Pattern">
<svg viewBox="0 0 24 24"><path d="M4 18a8 8 0 0 1 16 0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<button id="dock-classic-color" class="dock-color-btn" style="background-color:#2563eb;" aria-label="Open Colors"></button>
<button id="dock-column" class="mobile-tool-btn classic-pattern-btn" data-pattern-base="Column" title="Column Pattern">
<svg viewBox="0 0 24 24"><path d="M8 4v16M16 4v16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<!-- Classic drawers for mobile -->
<div id="classic-drawer-pattern" class="classic-drawer hidden">
<div class="drawer-row">
<div class="drawer-pill-group">
<button type="button" class="dock-pill classic-pattern-btn" data-pattern-base="Arch">Arch</button>
<button type="button" class="dock-pill classic-pattern-btn" data-pattern-base="Column">Column</button>
</div>
<div class="drawer-pill-group">
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="4">4 colors</button>
<button type="button" class="dock-pill classic-variant-btn" data-pattern-variant="5">5 colors</button>
</div>
</div>
<div class="drawer-row dial-row">
<span class="drawer-label">Length</span>
<div id="classic-length-drawer" class="length-dial"></div>
</div>
<div class="drawer-row topper-row">
<button type="button" class="dock-pill classic-topper-btn" data-topper-toggle>Topper</button>
<div class="topper-inline hidden" id="topper-inline">
<select id="classic-topper-type-inline" class="select select-xs">
<option value="round">Round</option>
<option value="star">Star</option>
<option value="heart">Heart</option>
</select>
<div id="classic-topper-color-swatch-inline" class="slot-swatch" title="Topper color">T</div>
<div class="nudge-pad">
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="0.5"></button>
<div class="flex gap-2">
<button type="button" class="btn-nudge nudge-topper" data-dx="-0.5" data-dy="0"></button>
<button type="button" class="btn-nudge nudge-topper" data-dx="0.5" data-dy="0"></button>
</div>
<button type="button" class="btn-nudge nudge-topper" data-dx="0" data-dy="-0.5"></button>
</div>
<div class="topper-size-wrap">
<label class="text-xs text-slate-600">Size</label>
<input id="classic-topper-size-inline" type="range" min="0.5" max="2" step="0.05" value="1">
</div>
</div>
</div>
</div>
<div id="classic-drawer-colors" class="classic-drawer hidden">
<div class="drawer-row">
<div id="classic-slots-drawer" class="flex items-center gap-2"></div>
<div class="flex-1 text-right">
<button id="classic-randomize-colors-inline" class="dock-pill">Shuffle 5</button>
</div>
</div>
<div id="classic-swatch-drawer" class="palette-box"></div>
</div>
<div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg max-w-sm text-center">
@ -321,17 +380,12 @@
<button id="modal-close-btn" class="mt-4 btn-blue">OK</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
<!-- <script src="script.js" defer></script> -->
<script src="app.js" defer></script>
<script src="organic.js" defer></script>
<script src="script.js" defer></script>
<script src="classic.js" defer></script>
<script>
</script>
</body>
</html>

1348
script.js

File diff suppressed because it is too large Load Diff

397
style.css
View File

@ -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 */
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; }
}