Compare commits
11 Commits
758bf863ee
...
7a2583c06a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a2583c06a | |||
| d455f8530a | |||
| ef7df3a89d | |||
| 6af31f4c81 | |||
| 0070506d92 | |||
| 70b2af53d1 | |||
| 2ca3487009 | |||
| 70d29cefca | |||
| 9cabe15481 | |||
| a3de8b5ac6 | |||
| f6d8914401 |
725
classic.js
@ -11,11 +11,82 @@
|
||||
</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';
|
||||
const TOPPER_COLOR_KEY = 'classic:topperColor:v2';
|
||||
const CLASSIC_STATE_KEY = 'classic:state:v1';
|
||||
const NUMBER_TINT_COLOR_KEY = 'classic:numberTintColor:v1';
|
||||
const NUMBER_TINT_OPACITY_KEY = 'classic:numberTintOpacity:v1';
|
||||
const NUMBER_IMAGE_MAP = {
|
||||
'0': 'output_webp/0.webp',
|
||||
'1': 'output_webp/1.webp',
|
||||
'2': 'output_webp/2.webp',
|
||||
'3': 'output_webp/3.webp',
|
||||
'4': 'output_webp/4.webp',
|
||||
'5': 'output_webp/5.webp',
|
||||
'6': 'output_webp/6.webp',
|
||||
'7': 'output_webp/7.webp',
|
||||
'8': 'output_webp/8.webp',
|
||||
'9': 'output_webp/9.webp'
|
||||
};
|
||||
|
||||
const MAX_SLOTS = 20;
|
||||
const SLOT_COUNT_KEY = 'classic:slotCount:v1';
|
||||
const defaultColors = () => [
|
||||
{ hex: '#d92e3a', image: null }, { hex: '#ffffff', image: null },
|
||||
{ hex: '#0055a4', image: null }, { hex: '#40e0d0', image: null },
|
||||
@ -31,18 +102,19 @@
|
||||
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 }));
|
||||
arr = saved.slice(0, MAX_SLOTS).map(hex => ({ hex: normHex(hex), image: null }));
|
||||
} else if (typeof saved[0] === 'object' && saved[0] !== null) {
|
||||
arr = saved.slice(0, 5);
|
||||
arr = saved.slice(0, MAX_SLOTS);
|
||||
}
|
||||
}
|
||||
while (arr.length < 5) arr.push({ hex: '#ffffff', image: null });
|
||||
}
|
||||
if (arr.length > MAX_SLOTS) arr = arr.slice(0, MAX_SLOTS);
|
||||
} catch (e) { console.error('Failed to parse classic colors:', e); }
|
||||
return arr;
|
||||
}
|
||||
|
||||
function setClassicColors(arr) {
|
||||
const clean = (arr || []).slice(0, 5).map(c => ({
|
||||
const clean = (arr || []).slice(0, MAX_SLOTS).map(c => ({
|
||||
hex: normHex(c.hex), image: c.image || null
|
||||
}));
|
||||
while (clean.length < 5) clean.push({ hex: '#ffffff', image: null });
|
||||
@ -61,6 +133,43 @@
|
||||
const clean = { hex: normHex(colorObj.hex), image: colorObj.image || null };
|
||||
try { localStorage.setItem(TOPPER_COLOR_KEY, JSON.stringify(clean)); } catch {}
|
||||
}
|
||||
function getNumberTintColor() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(NUMBER_TINT_COLOR_KEY));
|
||||
if (saved && saved.hex) return normHex(saved.hex);
|
||||
} catch {}
|
||||
return '#ffffff';
|
||||
}
|
||||
function setNumberTintColor(hex) {
|
||||
const clean = normHex(hex || '#ffffff');
|
||||
try { localStorage.setItem(NUMBER_TINT_COLOR_KEY, JSON.stringify({ hex: clean })); } catch {}
|
||||
return clean;
|
||||
}
|
||||
function getNumberTintOpacity() {
|
||||
try {
|
||||
const saved = parseFloat(localStorage.getItem(NUMBER_TINT_OPACITY_KEY));
|
||||
if (!isNaN(saved)) return clamp01(saved);
|
||||
} catch {}
|
||||
return 0.5;
|
||||
}
|
||||
function setNumberTintOpacity(v) {
|
||||
const clamped = clamp01(parseFloat(v));
|
||||
try { localStorage.setItem(NUMBER_TINT_OPACITY_KEY, String(clamped)); } catch {}
|
||||
return clamped;
|
||||
}
|
||||
function getTopperTypeSafe() {
|
||||
try { return (window.ClassicDesigner?.lastTopperType) || null; } catch { return null; }
|
||||
}
|
||||
function loadClassicState() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(CLASSIC_STATE_KEY));
|
||||
if (saved && typeof saved === 'object') return saved;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
function saveClassicState(state) {
|
||||
try { localStorage.setItem(CLASSIC_STATE_KEY, JSON.stringify(state || {})); } catch {}
|
||||
}
|
||||
|
||||
function buildClassicPalette() {
|
||||
const colors = getClassicColors();
|
||||
@ -100,7 +209,10 @@
|
||||
let topperOffsetX_Px = 0;
|
||||
let topperOffsetY_Px = 0;
|
||||
let topperSizeMultiplier = 1;
|
||||
let numberTintHex = getNumberTintColor();
|
||||
let numberTintOpacity = getNumberTintOpacity();
|
||||
let shineEnabled = true;
|
||||
let borderEnabled = false;
|
||||
|
||||
const patterns = {};
|
||||
const api = {
|
||||
@ -114,7 +226,10 @@
|
||||
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; }
|
||||
setNumberTintHex(hex) { numberTintHex = setNumberTintColor(hex); },
|
||||
setNumberTintOpacity(val) { numberTintOpacity = setNumberTintOpacity(val); },
|
||||
setShineEnabled(on) { shineEnabled = !!on; },
|
||||
setBorderEnabled(on) { borderEnabled = !!on; }
|
||||
};
|
||||
|
||||
const svg = (tag, attrs, children) => m(tag, attrs, children);
|
||||
@ -137,13 +252,16 @@
|
||||
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 base = shape.base || {};
|
||||
const scale = cellScale(cell);
|
||||
const transform = [(shape.base.transform||''), `scale(${scale})`].join(' ');
|
||||
const transform = [(base.transform||''), `scale(${scale})`].join(' ');
|
||||
const commonAttrs = {
|
||||
'vector-effect': 'non-scaling-stroke', stroke: '#111827',
|
||||
'stroke-width': 2, 'paint-order': 'stroke fill', class: 'balloon',
|
||||
'vector-effect': 'non-scaling-stroke',
|
||||
stroke: borderEnabled ? '#111827' : 'none',
|
||||
'stroke-width': borderEnabled ? 0.6 : 0,
|
||||
'paint-order': 'stroke fill', class: 'balloon',
|
||||
fill: explicitFill || '#cccccc'
|
||||
};
|
||||
if (cell.isTopper) {
|
||||
@ -153,16 +271,45 @@
|
||||
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 = [];
|
||||
const fillRule = base.fillRule || base['fill-rule'] || null;
|
||||
if (base.image) {
|
||||
const w = base.width || 1, h = base.height || 1;
|
||||
kids.push(svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' }));
|
||||
const tintColor = model.numberTintHex || '#ffffff';
|
||||
const tintOpacity = model.numberTintOpacity || 0;
|
||||
if (tintOpacity > 0 && cell.isTopper && (model.topperType || '').startsWith('num-')) {
|
||||
const maskId = `mask-${id}`;
|
||||
kids.push(svg('mask', { id: maskId, maskUnits: 'userSpaceOnUse' }, [
|
||||
svg('image', { href: base.image, x: -w/2, y: -h/2, width: w, height: h, preserveAspectRatio: base.preserveAspectRatio || 'xMidYMid meet', style: 'pointer-events:none' })
|
||||
]));
|
||||
kids.push(svg('rect', {
|
||||
x: -w/2, y: -h/2, width: w, height: h,
|
||||
fill: tintColor, opacity: tintOpacity,
|
||||
mask: `url(#${maskId})`,
|
||||
style: 'mix-blend-mode:multiply; pointer-events:none'
|
||||
}));
|
||||
}
|
||||
} else if (Array.isArray(base.paths)) {
|
||||
base.paths.forEach(p => {
|
||||
kids.push(svg('path', { ...commonAttrs, d: p.d, 'fill-rule': p.fillRule || fillRule || 'nonzero' }));
|
||||
});
|
||||
} else if (base.type === 'path' || base.d) {
|
||||
kids.push(svg('path', { ...commonAttrs, d: base.d, 'fill-rule': fillRule || 'nonzero' }));
|
||||
} else {
|
||||
kids.push(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'));
|
||||
const allowShine = base.allowShine !== false;
|
||||
const applyShine = model.shineEnabled && (!cell.isTopper || allowShine);
|
||||
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);
|
||||
@ -196,6 +343,11 @@ function distinctPaletteSlots(palette) {
|
||||
const balloonsPerCluster = pattern.balloonsPerCluster || 4;
|
||||
const reversed = !!(pattern._reverse || (pattern.parent && pattern.parent._reverse));
|
||||
const rowColorPatterns = {};
|
||||
const stackedSlots = (() => {
|
||||
const slots = distinctPaletteSlots(model.palette);
|
||||
const limit = Math.max(1, Math.min(slots.length, balloonsPerCluster));
|
||||
return slots.slice(0, limit);
|
||||
})();
|
||||
|
||||
const colorBlock4 = [[1, 2, 3, 4], [3, 1, 4, 2], [4, 3, 2, 1], [2, 4, 1, 3]];
|
||||
const colorBlock5 =
|
||||
@ -208,7 +360,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;
|
||||
@ -217,15 +369,22 @@ 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);
|
||||
|
||||
const rowIndex = cell.y;
|
||||
if (!rowColorPatterns[rowIndex]) {
|
||||
const qEff = rowIndex + 1;
|
||||
const totalRows = model.rowCount * (pattern.cellsPerRow || 1);
|
||||
const isRightHalf = false; // mirror mode removed
|
||||
const baseRow = rowIndex;
|
||||
const qEff = baseRow + 1;
|
||||
let pat;
|
||||
|
||||
if (balloonsPerCluster === 5) {
|
||||
if (pattern.colorMode === 'stacked') {
|
||||
const slot = stackedSlots[(rowIndex) % stackedSlots.length] || stackedSlots[0] || 1;
|
||||
pat = new Array(balloonsPerCluster).fill(slot);
|
||||
} else if (balloonsPerCluster === 5) {
|
||||
const base = (qEff - 1) % 5;
|
||||
pat = colorBlock5[base].slice();
|
||||
} else {
|
||||
@ -236,37 +395,34 @@ function distinctPaletteSlots(palette) {
|
||||
}
|
||||
}
|
||||
|
||||
if (reversed && pat.length > 1) {
|
||||
const first = pat.shift();
|
||||
pat.reverse();
|
||||
pat.unshift(first);
|
||||
}
|
||||
|
||||
// --- NEW: swap left/right after every 5 clusters ---
|
||||
const SWAP_EVERY = 5; // clusters per block
|
||||
const blockIndex = Math.floor(rowIndex / SWAP_EVERY);
|
||||
|
||||
// swap on blocks #2, #4, #6, ... (i.e., rows 6–10, 16–20, ...)
|
||||
if (blockIndex % 2 === 1) {
|
||||
// Swap left/right emphasis every 5 clusters to break repetition (per template override)
|
||||
if (balloonsPerCluster === 5) {
|
||||
// [leftMid, leftBack, front, rightBack, rightMid]
|
||||
const SWAP_EVERY = 5;
|
||||
const blockIndex = Math.floor(rowIndex / SWAP_EVERY);
|
||||
if (blockIndex % 2 === 1) {
|
||||
[pat[0], pat[4]] = [pat[4], pat[0]];
|
||||
// [pat[1], pat[3]] = [pat[3], pat[1]];
|
||||
}
|
||||
}
|
||||
|
||||
if (pat.length > 1) {
|
||||
let shouldReverse;
|
||||
shouldReverse = reversed;
|
||||
if (shouldReverse) pat.reverse();
|
||||
}
|
||||
|
||||
rowColorPatterns[rowIndex] = pat;
|
||||
}
|
||||
|
||||
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);
|
||||
@ -307,7 +463,11 @@ if (blockIndex % 2 === 1) {
|
||||
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 model = {
|
||||
patternName: name, pattern, cells: [], rowCount: clusters, palette: buildClassicPalette(),
|
||||
topperColor: getTopperColor(), topperType, shineEnabled,
|
||||
numberTintHex, numberTintOpacity
|
||||
};
|
||||
const rows = pattern.cellsPerRow * model.rowCount, cols = pattern.cellsPerColumn;
|
||||
for (let y=0; y<rows; y++){
|
||||
let balloonIndexInCluster = 0;
|
||||
@ -316,7 +476,7 @@ if (blockIndex % 2 === 1) {
|
||||
if (cellData) model.cells.push({ ...cellData, x, y, balloonIndexInCluster: balloonIndexInCluster++ });
|
||||
}
|
||||
}
|
||||
if (name === 'Column 4' && topperEnabled) {
|
||||
if (name.toLowerCase().includes('column') && topperEnabled) {
|
||||
const shapeName = `topper-${topperType}`;
|
||||
const originalShape = pattern.balloonShapes[shapeName];
|
||||
if (originalShape) {
|
||||
@ -345,12 +505,64 @@ if (blockIndex % 2 === 1) {
|
||||
return d + ' Z';
|
||||
}
|
||||
|
||||
function roundedRectPath(cx, cy, w, h, r = 0.08) {
|
||||
const x0 = cx - w / 2, x1 = cx + w / 2;
|
||||
const y0 = cy - h / 2, y1 = cy + h / 2;
|
||||
const rad = Math.min(r, w / 2, h / 2);
|
||||
return [
|
||||
`M ${x0 + rad} ${y0}`,
|
||||
`H ${x1 - rad}`,
|
||||
`Q ${x1} ${y0} ${x1} ${y0 + rad}`,
|
||||
`V ${y1 - rad}`,
|
||||
`Q ${x1} ${y1} ${x1 - rad} ${y1}`,
|
||||
`H ${x0 + rad}`,
|
||||
`Q ${x0} ${y1} ${x0} ${y1 - rad}`,
|
||||
`V ${y0 + rad}`,
|
||||
`Q ${x0} ${y0} ${x0 + rad} ${y0}`,
|
||||
'Z'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildNumberTopperShapes() {
|
||||
const r = 1.0;
|
||||
const baseTransform = 'scale(0.58)';
|
||||
const fallbackPaths = {
|
||||
'0': { d: 'M 0 -0.7 C 0.38 -0.7 0.62 -0.42 0.62 0 C 0.62 0.42 0.38 0.7 0 0.7 C -0.38 0.7 -0.62 0.42 -0.62 0 C -0.62 -0.42 -0.38 -0.7 0 -0.7 Z M 0 -0.4 C -0.2 -0.4 -0.34 -0.22 -0.34 0 C -0.34 0.24 -0.2 0.42 0 0.42 C 0.2 0.42 0.34 0.24 0.34 0 C 0.34 -0.22 0.2 -0.4 0 -0.4 Z', fillRule: 'evenodd' },
|
||||
'1': { d: 'M -0.12 -0.55 Q 0.1 -0.72 0.28 -0.6 Q 0.36 -0.52 0.34 -0.42 L 0.34 0.65 Q 0.34 0.82 0 0.82 Q -0.34 0.82 -0.34 0.65 L -0.34 -0.18 Q -0.34 -0.32 -0.46 -0.32 Q -0.6 -0.32 -0.62 -0.45 Q -0.64 -0.58 -0.52 -0.65 Z' },
|
||||
'2': { d: 'M -0.55 -0.25 Q -0.55 -0.7 -0.18 -0.9 Q 0.1 -1.05 0.48 -0.98 Q 0.86 -0.9 1 -0.55 Q 1.12 -0.25 0.92 0.06 Q 0.78 0.28 0.36 0.5 Q 0.02 0.68 -0.2 0.88 Q -0.36 1.04 -0.32 1.12 Q -0.28 1.2 -0.12 1.2 L 0.78 1.2 Q 0.98 1.2 0.98 0.94 Q 0.98 0.7 0.78 0.7 L 0.14 0.7 Q 0.02 0.7 0.02 0.6 Q 0.02 0.52 0.24 0.38 Q 0.76 0.08 0.96 -0.22 Q 1.2 -0.58 1 -0.98 Q 0.82 -1.36 0.38 -1.48 Q -0.2 -1.64 -0.7 -1.34 Q -1.1 -1.1 -1.12 -0.6 Q -1.14 -0.38 -0.96 -0.3 Q -0.8 -0.24 -0.68 -0.32 Q -0.55 -0.42 -0.55 -0.25 Z', fillRule: 'nonzero' },
|
||||
'3': { d: 'M -0.42 -0.88 Q -0.1 -1.08 0.26 -1.02 Q 0.7 -0.94 0.94 -0.62 Q 1.16 -0.32 1 -0.02 Q 0.86 0.24 0.58 0.36 Q 0.88 0.5 1 0.76 Q 1.16 1.12 0.88 1.38 Q 0.6 1.64 0.08 1.64 Q -0.3 1.64 -0.62 1.44 Q -0.88 1.26 -0.78 0.98 Q -0.7 0.72 -0.44 0.82 Q -0.06 0.96 0.26 0.88 Q 0.42 0.82 0.42 0.62 Q 0.42 0.38 0.1 0.36 L -0.24 0.34 Q -0.44 0.32 -0.44 0.12 Q -0.44 -0.08 -0.24 -0.12 L 0.08 -0.2 Q 0.32 -0.24 0.4 -0.42 Q 0.48 -0.62 0.26 -0.76 Q -0.02 -0.94 -0.4 -0.78 Q -0.62 -0.7 -0.74 -0.9 Q -0.86 -1.1 -0.68 -1.26 Q -0.58 -1.36 -0.42 -0.88 Z' },
|
||||
'4': { d: 'M 0.42 -0.94 Q 0.64 -0.94 0.7 -0.74 L 0.7 -0.1 L 0.92 -0.1 Q 1.12 -0.08 1.14 0.16 Q 1.16 0.38 0.92 0.46 L 0.7 0.54 L 0.7 0.98 Q 0.7 1.14 0.5 1.18 Q 0.3 1.22 0.18 1.08 L -0.34 0.48 L -0.6 0.48 Q -0.82 0.48 -0.86 0.28 Q -0.88 0.08 -0.7 -0.02 L -0.36 -0.18 L -0.36 -0.76 Q -0.36 -0.96 -0.14 -0.96 Q 0.08 -0.96 0.12 -0.76 L 0.12 -0.36 L 0.08 -0.36 Q 0.28 -0.62 0.42 -0.94 Z' },
|
||||
'5': { d: 'M 0.92 -0.94 Q 0.92 -1.16 0.72 -1.16 L -0.58 -1.16 Q -0.86 -1.16 -0.86 -0.86 Q -0.86 -0.56 -0.58 -0.56 L -0.2 -0.56 Q -0.02 -0.56 0.14 -0.5 Q 0.44 -0.38 0.44 -0.06 Q 0.44 0.18 0.22 0.36 Q 0.06 0.5 -0.16 0.5 L -0.52 0.5 Q -0.8 0.5 -0.8 0.8 Q -0.8 1.12 -0.52 1.12 L 0.24 1.12 Q 0.7 1.12 0.96 0.84 Q 1.2 0.58 1.12 0.24 Q 1.04 -0.02 0.82 -0.16 Q 0.96 -0.38 0.98 -0.62 Q 0.98 -0.86 0.92 -0.94 Z' },
|
||||
'6': { d: 'M 0.94 -0.6 Q 0.88 -0.98 0.52 -1.16 Q 0.06 -1.4 -0.44 -1.1 Q -0.94 -0.82 -1.02 -0.22 Q -1.12 0.4 -0.8 0.96 Q -0.48 1.48 0.14 1.48 Q 0.52 1.48 0.82 1.24 Q 1.12 1.02 1.12 0.66 Q 1.12 0.32 0.86 0.1 Q 0.66 -0.08 0.42 -0.08 Q 0.08 -0.08 -0.12 0.18 Q -0.26 0.36 -0.28 0.6 Q -0.5 0.26 -0.48 -0.18 Q -0.46 -0.66 -0.12 -0.86 Q 0.08 -0.98 0.32 -0.9 Q 0.56 -0.82 0.62 -0.58 Q 0.68 -0.34 0.92 -0.32 Q 1.02 -0.32 0.94 -0.6 Z M -0.06 0.6 C 0.12 0.6 0.26 0.44 0.26 0.26 C 0.26 0.08 0.12 -0.08 -0.06 -0.08 C -0.24 -0.08 -0.38 0.08 -0.38 0.26 C -0.38 0.44 -0.24 0.6 -0.06 0.6 Z', fillRule: 'evenodd' },
|
||||
'7': { d: 'M -0.74 -0.96 Q -0.94 -0.96 -0.94 -0.72 Q -0.94 -0.5 -0.74 -0.5 L 0.2 -0.5 Q 0.46 -0.5 0.52 -0.3 Q 0.58 -0.1 0.42 0.1 L -0.28 1.02 Q -0.42 1.2 -0.22 1.32 Q 0 1.44 0.18 1.28 L 0.98 0.2 Q 1.22 -0.12 1.1 -0.46 Q 0.98 -0.84 0.58 -0.96 Q 0.32 -1.04 0 -1.04 Z' },
|
||||
'8': { d: 'M 0 -1 C 0.44 -1 0.78 -0.72 0.78 -0.34 C 0.78 0.02 0.46 0.28 0.18 0.36 C 0.46 0.44 0.8 0.66 0.8 1.02 C 0.8 1.44 0.44 1.72 0 1.72 C -0.44 1.72 -0.8 1.44 -0.8 1.02 C -0.8 0.66 -0.48 0.44 -0.2 0.36 C -0.48 0.28 -0.8 0.02 -0.8 -0.34 C -0.8 -0.72 -0.44 -1 -0.02 -1 Z M 0 0.48 C 0.2 0.48 0.34 0.64 0.34 0.84 C 0.34 1.04 0.2 1.18 0 1.18 C -0.2 1.18 -0.34 1.04 -0.34 0.84 C -0.34 0.64 -0.2 0.48 0 0.48 Z M 0 -0.46 C 0.18 -0.46 0.3 -0.64 0.3 -0.8 C 0.3 -0.98 0.18 -1.12 0 -1.12 C -0.18 -1.12 -0.3 -0.98 -0.3 -0.8 C -0.3 -0.64 -0.18 -0.46 0 -0.46 Z', fillRule: 'evenodd' },
|
||||
'9': { d: 'M 0 -0.72 C 0.42 -0.72 0.7 -0.44 0.7 -0.08 C 0.7 0.2 0.56 0.46 0.32 0.6 C 0.12 0.72 0.1 0.84 0.1 1.06 C 0.1 1.24 -0.16 1.32 -0.28 1.18 L -0.64 0.72 C -0.92 0.38 -1.08 -0.1 -0.96 -0.54 C -0.82 -1.02 -0.46 -1.26 -0.08 -1.26 C 0.14 -1.26 0.32 -1.18 0.48 -1.04 C 0.62 -0.9 0.62 -0.74 0.5 -0.66 C 0.38 -0.58 0.26 -0.66 0.08 -0.74 C -0.14 -0.84 -0.34 -0.68 -0.42 -0.44 C -0.5 -0.24 -0.46 0.04 -0.32 0.26 C -0.16 0.5 0.14 0.46 0.26 0.26 C 0.38 0.06 0.3 -0.1 0.14 -0.18 C 0.02 -0.24 0 -0.42 0.12 -0.52 C 0.2 -0.58 0.46 -0.62 0.64 -0.42 C 0.82 -0.22 0.86 0.02 0.76 0.24 C 0.6 0.58 0.18 0.76 -0.16 0.7 C -0.36 0.66 -0.54 0.56 -0.68 0.42 C -0.64 0.82 -0.44 1.22 -0.14 1.46 C 0.16 1.7 0.54 1.72 0.86 1.52 C 1.26 1.26 1.42 0.84 1.36 0.38 C 1.26 -0.3 0.72 -0.72 0 -0.72 Z', fillRule: 'evenodd' }
|
||||
};
|
||||
|
||||
const shapes = {};
|
||||
const topperSize = 9.5; // ≈34" foil height when base balloons are ~11"
|
||||
Object.keys({ ...fallbackPaths, ...NUMBER_IMAGE_MAP }).forEach(num => {
|
||||
const img = NUMBER_IMAGE_MAP[num];
|
||||
const hasImage = !!img;
|
||||
shapes[`topper-num-${num}`] = {
|
||||
base: hasImage
|
||||
? { type: 'image', image: img, width: 1, height: 1, radius: 0.9, allowShine: false, transform: 'scale(0.9)' }
|
||||
: { type: 'path', paths: [{ d: fallbackPaths[num].d, fillRule: fallbackPaths[num].fillRule || 'nonzero' }], radius: r, allowShine: true, transform: baseTransform },
|
||||
size: topperSize
|
||||
};
|
||||
});
|
||||
return shapes;
|
||||
}
|
||||
|
||||
const numberTopperShapes = buildNumberTopperShapes();
|
||||
|
||||
// --- 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}
|
||||
'topper-round':{base:{type:'ellipse', radius:0.5, allowShine:true}, size:8}, 'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, 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, allowShine:false}, size:20},
|
||||
...numberTopperShapes
|
||||
},
|
||||
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]; },
|
||||
@ -376,64 +588,48 @@ if (blockIndex % 2 === 1) {
|
||||
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.
|
||||
// --- Column 5 (template geometry) ---
|
||||
patterns['Column 5'] = {
|
||||
baseBalloonSize: 25,
|
||||
_reverse: false,
|
||||
balloonsPerCluster: 5, // Kept this from classic.js to ensure 5-color spiral
|
||||
balloonsPerCluster: 5,
|
||||
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 }
|
||||
"back2": { zIndex:0, base:{radius:0.5}, size:3.0 },
|
||||
'topper-round':{base:{type:'ellipse', radius:0.5, allowShine:true}, size:8},
|
||||
'topper-star':{base:{type:'path', d:roundedStarPath({}), radius:0.5, allowShine:false}, 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, allowShine:false}, size:20},
|
||||
...numberTopperShapes
|
||||
},
|
||||
|
||||
// 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
|
||||
var mid = 0.6;
|
||||
return (0.9) * (col + (0 === col % 5 && -0.5) + (1 === col % 5 && -mid) + (3 === col % 5 && mid) + (4 === col % 5 && 0.5) - 0.5);
|
||||
},
|
||||
|
||||
// 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'];
|
||||
|
||||
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'.
|
||||
// Arch 5 derives from Column 5
|
||||
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);
|
||||
@ -443,34 +639,122 @@ if (blockIndex % 2 === 1) {
|
||||
};
|
||||
// --- END: MODIFIED SECTION ---
|
||||
|
||||
// --- Stacked variants (same geometry, single-color clusters alternating rows) ---
|
||||
patterns['Arch 4 Stacked'] = { deriveFrom: 'Arch 4', colorMode: 'stacked' };
|
||||
patterns['Arch 5 Stacked'] = { deriveFrom: 'Arch 5', colorMode: 'stacked' };
|
||||
patterns['Column 4 Stacked'] = { deriveFrom: 'Column 4', colorMode: 'stacked' };
|
||||
patterns['Column 5 Stacked'] = { deriveFrom: 'Column 5', colorMode: 'stacked' };
|
||||
|
||||
|
||||
Object.keys(patterns).forEach(n => extend(patterns[n]));
|
||||
return api;
|
||||
}
|
||||
|
||||
const patternSlotCount = (name) => ((name || '').includes('5') ? 5 : 4);
|
||||
function getStoredSlotCount() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(SLOT_COUNT_KEY), 10);
|
||||
if (Number.isFinite(saved) && saved > 0) return Math.min(saved, MAX_SLOTS);
|
||||
} catch {}
|
||||
return 5;
|
||||
}
|
||||
function setStoredSlotCount(n) {
|
||||
const v = Math.max(1, Math.min(MAX_SLOTS, n|0));
|
||||
try { localStorage.setItem(SLOT_COUNT_KEY, String(v)); } catch {}
|
||||
return v;
|
||||
}
|
||||
|
||||
function initClassicColorPicker(onColorChange) {
|
||||
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');
|
||||
if (!slots.length || !topperSwatch || !swatchGrid || !activeLabel) return;
|
||||
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 numberTintSlider = document.getElementById('classic-number-tint');
|
||||
const topperBlock = document.getElementById('classic-topper-color-block');
|
||||
if (!slotsContainer || !topperSwatch || !swatchGrid || !activeLabel) return;
|
||||
topperSwatch.classList.add('tab-btn');
|
||||
let classicColors = getClassicColors(), activeTarget = '1';
|
||||
let classicColors = getClassicColors(), activeTarget = '1', slotCount = getStoredSlotCount();
|
||||
|
||||
function visibleSlotCount() {
|
||||
const patSelect = document.getElementById('classic-pattern');
|
||||
const name = patSelect?.value || 'Arch 4';
|
||||
const baseCount = patternSlotCount(name);
|
||||
const isStacked = (name || '').toLowerCase().includes('stacked');
|
||||
if (!isStacked) return baseCount;
|
||||
const lengthInp = document.getElementById('classic-length-ft');
|
||||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||||
return Math.min(Math.max(baseCount, slotCount), maxSlots);
|
||||
}
|
||||
|
||||
function renderSlots() {
|
||||
slotsContainer.innerHTML = '';
|
||||
const count = visibleSlotCount();
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'slot-btn tab-btn';
|
||||
btn.dataset.slot = String(i);
|
||||
btn.textContent = `#${i}`;
|
||||
btn.addEventListener('click', () => { activeTarget = String(i); updateUI(); });
|
||||
slotsContainer.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function enforceSlotVisibility() {
|
||||
const count = visibleSlotCount();
|
||||
if (parseInt(activeTarget, 10) > count) activeTarget = '1';
|
||||
renderSlots();
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
[...slots, topperSwatch].forEach(el => { const id = el.dataset.slot || 'T'; el.classList.toggle('tab-active', activeTarget === id); el.classList.toggle('tab-idle', activeTarget !== id); });
|
||||
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));
|
||||
|
||||
slots.forEach((slot, i) => {
|
||||
buttons.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 txt = textStyleForColor(color);
|
||||
slot.style.color = txt.color;
|
||||
slot.style.textShadow = txt.shadow;
|
||||
});
|
||||
|
||||
const topperColor = getTopperColor();
|
||||
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
|
||||
const tintColor = getNumberTintColor();
|
||||
if (currentType.startsWith('num-') && topperColor.image) {
|
||||
topperSwatch.style.backgroundImage = `linear-gradient(${tintColor}99, ${tintColor}99), url("${topperColor.image}")`;
|
||||
topperSwatch.style.backgroundBlendMode = 'multiply, normal';
|
||||
topperSwatch.style.backgroundSize = '220%';
|
||||
topperSwatch.style.backgroundPosition = 'center';
|
||||
topperSwatch.style.backgroundColor = tintColor;
|
||||
} else {
|
||||
topperSwatch.style.backgroundImage = topperColor.image ? `url("${topperColor.image}")` : 'none';
|
||||
topperSwatch.style.backgroundBlendMode = 'normal';
|
||||
topperSwatch.style.backgroundColor = topperColor.hex;
|
||||
topperSwatch.style.backgroundSize = '200%';
|
||||
topperSwatch.style.backgroundPosition = 'center';
|
||||
}
|
||||
const topperTxt = textStyleForColor({ hex: tintColor || topperColor.hex, image: topperColor.image });
|
||||
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');
|
||||
if (addSlotBtn) {
|
||||
const lengthInp = document.getElementById('classic-length-ft');
|
||||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||||
addSlotBtn.classList.toggle('hidden', !isStacked);
|
||||
addSlotBtn.disabled = !isStacked || slotCount >= maxSlots;
|
||||
}
|
||||
|
||||
activeLabel.textContent = activeTarget === 'T' ? 'Topper' : `Slot #${activeTarget}`;
|
||||
}
|
||||
@ -480,7 +764,10 @@ if (blockIndex % 2 === 1) {
|
||||
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('div'); sw.className = 'swatch'; sw.title = colorItem.name;
|
||||
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;
|
||||
@ -489,10 +776,17 @@ if (blockIndex % 2 === 1) {
|
||||
|
||||
sw.addEventListener('click', () => {
|
||||
const selectedColor = { hex: colorItem.hex, image: colorItem.image };
|
||||
if (activeTarget === 'T') setTopperColor(selectedColor);
|
||||
else {
|
||||
if (activeTarget === 'T') {
|
||||
const currentType = document.querySelector('.topper-type-btn[aria-pressed="true"]')?.dataset.type || 'round';
|
||||
if (currentType.startsWith('num-')) {
|
||||
setNumberTintColor(selectedColor.hex);
|
||||
if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity();
|
||||
} else {
|
||||
setTopperColor(selectedColor);
|
||||
}
|
||||
} else {
|
||||
const index = parseInt(activeTarget, 10) - 1;
|
||||
if (index >= 0 && index < 5) { classicColors[index] = selectedColor; setClassicColors(classicColors); }
|
||||
if (index >= 0 && index < MAX_SLOTS) { classicColors[index] = selectedColor; setClassicColors(classicColors); }
|
||||
}
|
||||
updateUI(); onColorChange();
|
||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||
@ -501,47 +795,225 @@ if (blockIndex % 2 === 1) {
|
||||
});
|
||||
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 = (patterns[document.getElementById('classic-pattern')?.value] || {}).balloonsPerCluster || 5;
|
||||
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();
|
||||
});
|
||||
addSlotBtn?.addEventListener('click', () => {
|
||||
const patSelect = document.getElementById('classic-pattern');
|
||||
const name = patSelect?.value || '';
|
||||
const isStacked = name.toLowerCase().includes('stacked');
|
||||
if (!isStacked) return;
|
||||
const lengthInp = document.getElementById('classic-length-ft');
|
||||
const clusters = Math.max(1, Math.round((parseFloat(lengthInp?.value) || 0) * 2));
|
||||
const maxSlots = Math.min(MAX_SLOTS, clusters);
|
||||
if (slotCount >= maxSlots) return;
|
||||
slotCount = setStoredSlotCount(slotCount + 1);
|
||||
while (classicColors.length < slotCount) {
|
||||
const fallback = allPaletteColors[Math.floor(Math.random() * allPaletteColors.length)] || { hex: '#ffffff', image: null };
|
||||
classicColors.push({ hex: fallback.hex, image: fallback.image });
|
||||
}
|
||||
setClassicColors(classicColors);
|
||||
updateUI(); onColorChange();
|
||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||
});
|
||||
updateUI();
|
||||
return 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'), rebuildBtn = document.getElementById('classic-rerender'), topperControls = document.getElementById('topper-controls'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperTypeSelect = document.getElementById('classic-topper-type'), topperOffsetX_Inp = document.getElementById('classic-topper-offset-x'), topperOffsetY_Inp = document.getElementById('classic-topper-offset-y'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled');
|
||||
const display = document.getElementById('classic-display'), patSel = document.getElementById('classic-pattern'), lengthInp = document.getElementById('classic-length-ft'), clusterHint = document.getElementById('classic-cluster-hint'), reverseCb = document.getElementById('classic-reverse'), topperControls = document.getElementById('topper-controls'), topperToggleRow = document.getElementById('classic-topper-toggle-row'), topperEnabledCb = document.getElementById('classic-topper-enabled'), topperSizeInp = document.getElementById('classic-topper-size'), shineEnabledCb = document.getElementById('classic-shine-enabled'), borderEnabledCb = document.getElementById('classic-border-enabled');
|
||||
const numberTintRow = document.getElementById('classic-number-tint-row'), numberTintSlider = document.getElementById('classic-number-tint');
|
||||
const nudgeOpenBtn = document.getElementById('classic-nudge-open');
|
||||
const fullscreenBtn = document.getElementById('app-fullscreen-toggle');
|
||||
const patternShapeBtns = Array.from(document.querySelectorAll('[data-pattern-shape]'));
|
||||
const patternCountBtns = Array.from(document.querySelectorAll('[data-pattern-count]'));
|
||||
const patternLayoutBtns = Array.from(document.querySelectorAll('[data-pattern-layout]'));
|
||||
const topperNudgeBtns = Array.from(document.querySelectorAll('.nudge-topper'));
|
||||
const topperTypeButtons = Array.from(document.querySelectorAll('.topper-type-btn'));
|
||||
const slotsContainer = document.getElementById('classic-slots');
|
||||
let topperOffsetX = 0, topperOffsetY = 0;
|
||||
let lastPresetKey = null; // 'custom' means user-tweaked; otherwise `${pattern}:${type}`
|
||||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||||
window.ClassicDesigner.lastTopperType = window.ClassicDesigner.lastTopperType || 'round';
|
||||
if (numberTintSlider) numberTintSlider.value = getNumberTintOpacity();
|
||||
const topperPresets = {
|
||||
'Column 4:heart': { enabled: true, offsetX: 3, offsetY: -10.5, size: 1.05 },
|
||||
'Column 4:star': { enabled: true, offsetX: 3, offsetY: -7.5, size: 1.65 },
|
||||
'Column 4:round': { enabled: true, offsetX: 3, offsetY: -2, size: 1.25 },
|
||||
'Column 4:number': { enabled: true, offsetX: 3, offsetY: -7, size: 1.05 },
|
||||
'Column 5:heart': { enabled: true, offsetX: 2, offsetY: -10, size: 1.15 },
|
||||
'Column 5:star': { enabled: true, offsetX: 2.5, offsetY: -7.5, size: 1.75 },
|
||||
'Column 5:round': { enabled: true, offsetX: 2.5, offsetY: -2, size: 1.3 },
|
||||
'Column 5:number': { enabled: true, offsetX: 2.5, offsetY: -6.5, size: 1.05 }
|
||||
};
|
||||
if (!display) return fail('#classic-display not found');
|
||||
if (window.setupAccordionPanel) { window.setupAccordionPanel({ panelId: 'classic-controls-panel', expandBtnId: 'classic-expand-all', collapseBtnId: 'classic-collapse-all', reorderBtnId: 'classic-toggle-reorder', storagePrefix: 'cbd' }); }
|
||||
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) => {
|
||||
topperTypeButtons.forEach(btn => {
|
||||
const active = btn.dataset.type === type;
|
||||
btn.setAttribute('aria-pressed', String(active));
|
||||
btn.classList.toggle('tab-active', active);
|
||||
btn.classList.toggle('tab-idle', !active);
|
||||
});
|
||||
window.ClassicDesigner.lastTopperType = type;
|
||||
};
|
||||
function applyNumberTopperTexture(type) {
|
||||
if (!type || !type.startsWith('num-')) return;
|
||||
const num = type.split('-')[1];
|
||||
if (!num) return;
|
||||
const imgPath = NUMBER_IMAGE_MAP[num];
|
||||
if (imgPath) setTopperColor({ hex: '#ffffff', image: imgPath });
|
||||
else setTopperColor({ hex: '#d4d4d8', image: null }); // fallback silver fill if image missing
|
||||
refreshClassicPaletteUi?.();
|
||||
}
|
||||
|
||||
function resetNonNumberTopperColor(type) {
|
||||
if (type && type.startsWith('num-')) return;
|
||||
const fallback = getTopperColor();
|
||||
// If last topper type was a number, strip image to avoid leaking photo texture.
|
||||
if (fallback?.image && Object.values(NUMBER_IMAGE_MAP).includes(fallback.image)) {
|
||||
setTopperColor({ hex: fallback.hex || '#ffffff', image: null });
|
||||
refreshClassicPaletteUi?.();
|
||||
}
|
||||
}
|
||||
|
||||
function applyTopperPreset(patternName, type) {
|
||||
const presetType = (type || '').startsWith('num-') ? 'number' : type;
|
||||
const key = `${patternName}:${presetType}`;
|
||||
const preset = topperPresets[key];
|
||||
if (!preset) return;
|
||||
if (lastPresetKey === key || lastPresetKey === 'custom') return;
|
||||
topperOffsetX = preset.offsetX;
|
||||
topperOffsetY = preset.offsetY;
|
||||
if (topperSizeInp) topperSizeInp.value = preset.size;
|
||||
if (topperEnabledCb) topperEnabledCb.checked = preset.enabled;
|
||||
setTopperType(type);
|
||||
applyNumberTopperTexture(type);
|
||||
resetNonNumberTopperColor(type);
|
||||
lastPresetKey = key;
|
||||
}
|
||||
|
||||
let patternShape = 'arch', patternCount = 4, patternLayout = 'spiral';
|
||||
const computePatternName = () => {
|
||||
const base = patternShape === 'column' ? 'Column' : 'Arch';
|
||||
const count = patternCount === 5 ? '5' : '4';
|
||||
const layout = patternLayout === 'stacked' ? ' Stacked' : '';
|
||||
return `${base} ${count}${layout}`;
|
||||
};
|
||||
const syncPatternStateFromSelect = () => {
|
||||
const val = (patSel?.value || '').toLowerCase();
|
||||
patternShape = val.includes('column') ? 'column' : 'arch';
|
||||
patternCount = val.includes('5') ? 5 : 4;
|
||||
patternLayout = val.includes('stacked') ? 'stacked' : 'spiral';
|
||||
};
|
||||
const applyPatternButtons = () => {
|
||||
const setActive = (btns, attr, val) => btns.forEach(b => {
|
||||
const active = b.dataset[attr] === val;
|
||||
b.classList.toggle('tab-active', active);
|
||||
b.classList.toggle('tab-idle', !active);
|
||||
b.setAttribute('aria-pressed', String(active));
|
||||
});
|
||||
setActive(patternShapeBtns, 'patternShape', patternShape);
|
||||
setActive(patternCountBtns, 'patternCount', String(patternCount));
|
||||
setActive(patternLayoutBtns, 'patternLayout', patternLayout);
|
||||
};
|
||||
syncPatternStateFromSelect();
|
||||
|
||||
function persistState() {
|
||||
const state = {
|
||||
patternName: patSel?.value || '',
|
||||
length: lengthInp?.value || '',
|
||||
reverse: !!reverseCb?.checked,
|
||||
topperEnabled: !!topperEnabledCb?.checked,
|
||||
topperType: getTopperType(),
|
||||
topperOffsetX,
|
||||
topperOffsetY,
|
||||
topperSize: topperSizeInp?.value || '',
|
||||
numberTint: numberTintSlider ? numberTintSlider.value : getNumberTintOpacity()
|
||||
};
|
||||
saveClassicState(state);
|
||||
}
|
||||
|
||||
function applySavedState() {
|
||||
const saved = loadClassicState();
|
||||
if (!saved) return;
|
||||
if (patSel && saved.patternName) patSel.value = saved.patternName;
|
||||
if (lengthInp && saved.length) lengthInp.value = saved.length;
|
||||
if (reverseCb) reverseCb.checked = !!saved.reverse;
|
||||
if (topperEnabledCb) topperEnabledCb.checked = !!saved.topperEnabled;
|
||||
if (typeof saved.topperOffsetX === 'number') topperOffsetX = saved.topperOffsetX;
|
||||
if (typeof saved.topperOffsetY === 'number') topperOffsetY = saved.topperOffsetY;
|
||||
if (topperSizeInp && saved.topperSize) topperSizeInp.value = saved.topperSize;
|
||||
if (saved.topperType) setTopperType(saved.topperType);
|
||||
if (numberTintSlider && typeof saved.numberTint !== 'undefined') {
|
||||
numberTintSlider.value = saved.numberTint;
|
||||
setNumberTintOpacity(saved.numberTint);
|
||||
}
|
||||
syncPatternStateFromSelect();
|
||||
lastPresetKey = 'custom';
|
||||
}
|
||||
|
||||
applySavedState();
|
||||
applyPatternButtons();
|
||||
|
||||
function updateClassicDesign() {
|
||||
if (!lengthInp || !patSel) return;
|
||||
patSel.value = computePatternName();
|
||||
const patternName = patSel.value || 'Arch 4';
|
||||
const isColumn = patternName.toLowerCase().includes('column');
|
||||
const hasTopper = patternName.includes('4');
|
||||
const hasTopper = patternName.includes('4') || patternName.includes('5');
|
||||
const showToggle = isColumn && hasTopper;
|
||||
if (patternName.toLowerCase().includes('column')) {
|
||||
const baseName = patternName.includes('5') ? 'Column 5' : 'Column 4';
|
||||
applyTopperPreset(baseName, getTopperType());
|
||||
}
|
||||
if (topperToggleRow) topperToggleRow.classList.toggle('hidden', !showToggle);
|
||||
const showTopper = showToggle && topperEnabledCb?.checked;
|
||||
const isNumberTopper = getTopperType().startsWith('num-');
|
||||
|
||||
topperControls.classList.toggle('hidden', !isColumn || !hasTopper);
|
||||
topperTypeSelect.disabled = !topperEnabledCb.checked;
|
||||
topperControls.classList.toggle('hidden', !showTopper);
|
||||
if (numberTintRow) numberTintRow.classList.toggle('hidden', !(showTopper && isNumberTopper));
|
||||
if (nudgeOpenBtn) nudgeOpenBtn.classList.toggle('hidden', !showTopper);
|
||||
|
||||
GC.setTopperEnabled(isColumn && hasTopper && topperEnabledCb.checked);
|
||||
GC.setTopperEnabled(showTopper);
|
||||
GC.setClusters(Math.round((parseFloat(lengthInp.value) || 0) * 2));
|
||||
GC.setReverse(!!reverseCb?.checked);
|
||||
GC.setTopperType(topperTypeSelect.value);
|
||||
GC.setTopperOffsetX(topperOffsetX_Inp?.value);
|
||||
GC.setTopperOffsetY(topperOffsetY_Inp?.value);
|
||||
GC.setTopperType(getTopperType());
|
||||
GC.setNumberTintHex(getNumberTintColor());
|
||||
GC.setNumberTintOpacity(numberTintSlider ? numberTintSlider.value : getNumberTintOpacity());
|
||||
applyNumberTopperTexture(getTopperType());
|
||||
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);
|
||||
GC.setBorderEnabled(!!borderEnabledCb?.checked);
|
||||
if (document.body) {
|
||||
if (showTopper) document.body.dataset.topperOverlay = '1';
|
||||
else delete document.body.dataset.topperOverlay;
|
||||
}
|
||||
window.__updateFloatingNudge?.();
|
||||
if(clusterHint) clusterHint.textContent = `≈ ${Math.round((parseFloat(lengthInp.value) || 0) * 2)} clusters (rule: 2 clusters/ft)`;
|
||||
refreshClassicPaletteUi?.();
|
||||
ctrl.selectPattern(patternName);
|
||||
persistState();
|
||||
}
|
||||
|
||||
const setLengthForPattern = () => {
|
||||
if (!lengthInp || !patSel) return;
|
||||
const isArch = (computePatternName()).toLowerCase().includes('arch');
|
||||
lengthInp.value = isArch ? 20 : 5;
|
||||
};
|
||||
|
||||
window.ClassicDesigner = window.ClassicDesigner || {};
|
||||
window.ClassicDesigner.api = GC;
|
||||
@ -551,16 +1023,79 @@ if (blockIndex % 2 === 1) {
|
||||
|
||||
document.querySelector('#mode-tabs')?.addEventListener('click', () => setTimeout(() => { if (window.updateExportButtonVisibility) window.updateExportButtonVisibility() }, 50));
|
||||
patSel?.addEventListener('change', () => {
|
||||
const isArch = patSel.value.toLowerCase().includes('arch');
|
||||
lengthInp.value = isArch ? 25 : 5;
|
||||
lastPresetKey = null;
|
||||
syncPatternStateFromSelect();
|
||||
applyPatternButtons();
|
||||
setLengthForPattern();
|
||||
updateClassicDesign();
|
||||
});
|
||||
[lengthInp, reverseCb, topperEnabledCb, topperTypeSelect, topperOffsetX_Inp, topperOffsetY_Inp, topperSizeInp, rebuildBtn]
|
||||
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener((el === rebuildBtn) ? 'click' : eventType, updateClassicDesign); });
|
||||
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 {}
|
||||
patternShapeBtns.forEach(btn => btn.addEventListener('click', () => { patternShape = btn.dataset.patternShape; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
|
||||
patternCountBtns.forEach(btn => btn.addEventListener('click', () => { patternCount = Number(btn.dataset.patternCount) === 5 ? 5 : 4; lastPresetKey = null; applyPatternButtons(); setLengthForPattern(); updateClassicDesign(); }));
|
||||
patternLayoutBtns.forEach(btn => btn.addEventListener('click', () => { patternLayout = btn.dataset.patternLayout === 'stacked' ? 'stacked' : 'spiral'; lastPresetKey = null; applyPatternButtons(); updateClassicDesign(); }));
|
||||
topperNudgeBtns.forEach(btn => btn.addEventListener('click', () => {
|
||||
const dx = Number(btn.dataset.dx || 0);
|
||||
const dy = Number(btn.dataset.dy || 0);
|
||||
topperOffsetX += dx;
|
||||
topperOffsetY += dy;
|
||||
lastPresetKey = 'custom';
|
||||
GC.setTopperOffsetX(topperOffsetX);
|
||||
GC.setTopperOffsetY(topperOffsetY);
|
||||
updateClassicDesign();
|
||||
}));
|
||||
topperTypeButtons.forEach(btn => btn.addEventListener('click', () => {
|
||||
setTopperType(btn.dataset.type);
|
||||
applyNumberTopperTexture(btn.dataset.type);
|
||||
resetNonNumberTopperColor(btn.dataset.type);
|
||||
lastPresetKey = null;
|
||||
updateClassicDesign();
|
||||
}));
|
||||
numberTintSlider?.addEventListener('input', () => {
|
||||
GC.setNumberTintOpacity(numberTintSlider.value);
|
||||
updateClassicDesign();
|
||||
});
|
||||
nudgeOpenBtn?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.__showFloatingNudge?.();
|
||||
});
|
||||
const updateFullscreenLabel = () => {
|
||||
if (!fullscreenBtn) return;
|
||||
const active = !!document.fullscreenElement;
|
||||
fullscreenBtn.textContent = active ? 'Exit Fullscreen' : 'Fullscreen';
|
||||
};
|
||||
fullscreenBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen({ navigationUI: 'hide' });
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fullscreen toggle failed', err);
|
||||
} finally {
|
||||
updateFullscreenLabel();
|
||||
}
|
||||
});
|
||||
document.addEventListener('fullscreenchange', updateFullscreenLabel);
|
||||
[lengthInp, reverseCb, topperEnabledCb, topperSizeInp]
|
||||
.forEach(el => { if (!el) return; const eventType = (el.type === 'range' || el.type === 'number') ? 'input' : 'change'; el.addEventListener(eventType, () => { if (el === topperSizeInp || el === topperEnabledCb) lastPresetKey = 'custom'; updateClassicDesign(); }); });
|
||||
topperEnabledCb?.addEventListener('change', updateClassicDesign);
|
||||
shineEnabledCb?.addEventListener('change', (e) => { const on = !!e.target.checked; GC.setShineEnabled(on); updateClassicDesign(); window.syncAppShine?.(on); });
|
||||
borderEnabledCb?.addEventListener('change', (e) => {
|
||||
const on = !!e.target.checked;
|
||||
GC.setBorderEnabled(on);
|
||||
try { localStorage.setItem('classic:borderEnabled:v1', JSON.stringify(on)); } catch {}
|
||||
updateClassicDesign();
|
||||
});
|
||||
refreshClassicPaletteUi = initClassicColorPicker(updateClassicDesign);
|
||||
try { const saved = localStorage.getItem('app:shineEnabled:v1'); if (saved !== null && shineEnabledCb) shineEnabledCb.checked = JSON.parse(saved); } catch {}
|
||||
try {
|
||||
const saved = localStorage.getItem('classic:borderEnabled:v1');
|
||||
if (saved !== null && borderEnabledCb) borderEnabledCb.checked = JSON.parse(saved);
|
||||
else if (borderEnabledCb) borderEnabledCb.checked = true; // default to outline on
|
||||
} catch { if (borderEnabledCb) borderEnabledCb.checked = true; }
|
||||
setLengthForPattern();
|
||||
updateClassicDesign();
|
||||
refreshClassicPaletteUi?.();
|
||||
if (window.updateExportButtonVisibility) window.updateExportButtonVisibility();
|
||||
log('Classic ready');
|
||||
} catch (e) { fail(e.message || e); }
|
||||
|
||||
552
index.html
@ -3,16 +3,18 @@
|
||||
<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>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<script src="https://unpkg.com/mithril/mithril.js" defer></script>
|
||||
|
||||
<script src="colors.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<style>
|
||||
.tab-btn{padding:.5rem .75rem;border-radius:.5rem;font-size:.875rem;font-weight:600;transition:background-color .2s,color .2s,box-shadow .2s}
|
||||
.tab-active{background:#2563eb;color:#fff;box-shadow:0 2px 6px rgba(37,99,235,.35)}
|
||||
@ -21,247 +23,315 @@
|
||||
.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)]">
|
||||
<body class="p-0 md:p-6 flex flex-col items-center justify-start min-h-screen bg-[conic-gradient(at_top_left,_var(--tw-gradient-stops))] from-indigo-100 via-white to-pink-100 text-slate-800 overflow-hidden">
|
||||
<div class="container mx-auto mt-2 p-4 lg:p-6 bg-white/80 lg:backdrop-blur-xl rounded-3xl border border-white/50 shadow-2xl flex flex-col gap-4 max-w-7xl lg:h-[calc(100vh-2rem)] overflow-hidden ring-1 ring-black/5">
|
||||
|
||||
<header class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<div>
|
||||
<div class="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-pink-600 tracking-tight filter drop-shadow-sm">Balloon Studio</div>
|
||||
<div class="text-xs text-indigo-500 font-bold uppercase tracking-wider">Professional Design Tool</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 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 class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1 px-2 py-1 rounded-xl bg-white/70 border border-gray-200 shadow-sm" title="Active Color">
|
||||
<div id="current-color-chip-global" class="current-color-chip">
|
||||
<span id="current-color-label-global" class="text-[10px] font-semibold text-slate-700"></span>
|
||||
</div>
|
||||
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<button id="app-fullscreen-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Toggle fullscreen">Fullscreen</button>
|
||||
<button id="clear-canvas-btn-top" class="btn-danger text-xs px-3 py-2">Start Fresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="control-stack" data-mobile-tab="controls">
|
||||
<div class="panel-heading">Tools</div>
|
||||
<div class="panel-card">
|
||||
<div class="grid grid-cols-4 gap-2 mb-3">
|
||||
<button id="tool-draw" class="tool-btn" aria-pressed="true" title="V">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
<span class="hidden sm:inline">Draw</span>
|
||||
</button>
|
||||
<button id="tool-garland" class="tool-btn" aria-pressed="false" title="G">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 17c3-4 6-6 9-6s5 2 7 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="7" cy="14" r="1.4"/><circle cx="11.5" cy="12.5" r="1.4"/><circle cx="16" cy="13.5" r="1.4"/><circle cx="19" cy="16.5" r="1.4"/></svg>
|
||||
<span class="hidden sm:inline">Path</span>
|
||||
</button>
|
||||
<button id="tool-erase" class="tool-btn" aria-pressed="false" title="E">
|
||||
<svg viewBox="0 0 24 24"><path d="M16.24 3.56l4.95 4.94c.78.79.78 2.05 0 2.84L12 20.53a4.008 4.008 0 0 1-5.66 0L2.81 17c-.78-.79-.78-2.05 0-2.84l10.6-10.6c.79-.78 2.05-.78 2.83 0zM4.22 15.58l3.54 3.53c.78.79 2.04.79 2.83 0l8.48-8.48-3.54-3.54-8.48 8.48c-.79.79-.79 2.05 0 2.84z"/></svg>
|
||||
<span class="hidden sm:inline">Erase</span>
|
||||
</button>
|
||||
<button id="tool-select" class="tool-btn" aria-pressed="false" title="S">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 2l12 11.2-5.8.5 3.3 7.3-2.2.9-3.2-7.4-4.4 4V2z"/></svg>
|
||||
<span class="hidden sm:inline">Select</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<button id="tool-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>
|
||||
<button id="tool-undo" class="tool-btn" title="Ctrl+Z" aria-label="Undo">
|
||||
<svg viewBox="0 0 24 24"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>
|
||||
<span class="hidden sm:inline">Undo</span>
|
||||
</button>
|
||||
<button id="tool-redo" class="tool-btn" title="Ctrl+Y" aria-label="Redo">
|
||||
<svg viewBox="0 0 24 24"><path d="M18.4 10.6C16.55 9 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>
|
||||
<span class="hidden sm:inline">Redo</span>
|
||||
</button>
|
||||
<button id="tool-eyedropper" class="tool-btn" title="Pick Color" aria-label="Eyedropper" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.71 5.63l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-3.12 3.12-1.93-1.91-1.41 1.41 1.42 1.42L3 16.25V21h4.75l8.92-8.92 1.42 1.42 1.41-1.41-1.92-1.92 3.12-3.12c.4-.4.4-1.03.01-1.42zM6.92 19L5 17.08l8.06-8.06 1.92 1.92L6.92 19z"/></svg>
|
||||
<span class="hidden sm:inline">Picker</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint mt-1">Use Path to click-drag a line; balloons will be auto-placed along it.</p>
|
||||
<div id="garland-controls" class="mt-2 flex flex-col gap-3 text-sm text-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="garland-density" class="font-medium w-20">Density</label>
|
||||
<input id="garland-density" type="range" min="0.6" max="1.6" step="0.1" value="1" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<span id="garland-density-label" class="w-10 text-right text-xs text-gray-500">1.0</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main1" class="font-medium w-24">Main A</label>
|
||||
<select id="garland-color-main1" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main1" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main2" class="font-medium w-24">Main B</label>
|
||||
<select id="garland-color-main2" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main2" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main3" class="font-medium w-24">Main C</label>
|
||||
<select id="garland-color-main3" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main3" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-main4" class="font-medium w-24">Main D</label>
|
||||
<select id="garland-color-main4" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-main4" class="swatch tiny"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="garland-color-accent" class="font-medium w-24">5" Accent</label>
|
||||
<select id="garland-color-accent" class="select text-sm flex-1"></select>
|
||||
<span id="garland-swatch-accent" class="swatch tiny"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
<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="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>
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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="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>
|
||||
</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="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>
|
||||
</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 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>
|
||||
<button id="toggle-shine-btn" class="btn-dark">Turn Off Shine</button>
|
||||
<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>
|
||||
<label class="text-sm inline-flex items-center gap-2 font-medium">
|
||||
<input id="toggle-border-checkbox" type="checkbox" class="align-middle">
|
||||
Outline Balloons
|
||||
</label>
|
||||
<button type="button" id="fit-view-btn" class="btn-dark text-sm mt-3 w-full">Fit to Design</button>
|
||||
</div>
|
||||
</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="control-stack" data-mobile-tab="colors">
|
||||
<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>
|
||||
<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 class="panel-heading mt-4">Color Library</div>
|
||||
<div class="panel-card">
|
||||
<p class="hint mb-2">Alt+click on canvas to sample a balloon’s 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 (in design):</label>
|
||||
<select id="replace-from" class="select"></select>
|
||||
|
||||
<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>
|
||||
<p id="replace-msg" class="hint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 w-full">Generate Shareable Link</button>
|
||||
</div>
|
||||
|
||||
<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 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 export keeps vectors in Classic; Organic embeds textures.</p>
|
||||
</div>
|
||||
</div>
|
||||
</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)]">
|
||||
|
||||
<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">
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
</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="border border-t-0 rounded-b-lg bg-white/50 px-3 pb-4 pt-3 space-y-4">
|
||||
<div class="control-stack" data-mobile-tab="controls">
|
||||
<div class="panel-heading">Pattern & Layout</div>
|
||||
<div class="panel-card 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">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-700">Shape</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active pattern-btn" data-pattern-shape="arch" aria-pressed="true">Arch</button>
|
||||
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-shape="column" aria-pressed="false">Column</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-700">Balloon Count</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active pattern-btn" data-pattern-count="4" aria-pressed="true">4 Colors</button>
|
||||
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-count="5" aria-pressed="false">5 Colors</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 space-y-2">
|
||||
<div class="text-sm font-medium text-gray-700">Layout</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="tab-btn tab-active pattern-btn" data-pattern-layout="spiral" aria-pressed="true">Spiral</button>
|
||||
<button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="stacked" aria-pressed="false">Stacked</button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1">
|
||||
<option value="Arch 4">Arch 4 (4-color spiral)</option>
|
||||
<option value="Column 4">Column 4 (quad wrap)</option>
|
||||
<option value="Arch 5">Arch 5 (5-color spiral)</option>
|
||||
<option value="Column 5">Column 5 (5-balloon wrap)</option>
|
||||
<option value="Arch 4 Stacked">Arch 4 (stacked)</option>
|
||||
<option value="Column 4 Stacked">Column 4 (stacked)</option>
|
||||
<option value="Arch 5 Stacked">Arch 5 (stacked)</option>
|
||||
<option value="Column 5 Stacked">Column 5 (stacked)</option>
|
||||
</select>
|
||||
</label>
|
||||
<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 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>
|
||||
<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
|
||||
Add Topper (24")
|
||||
</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:
|
||||
</div>
|
||||
|
||||
<div id="topper-controls" class="hidden grid grid-cols-1 sm:grid-cols-4 gap-3 items-start pt-2">
|
||||
<div class="sm:col-span-2">
|
||||
<div class="text-sm font-medium mb-2">Topper Shape</div>
|
||||
<div id="topper-type-group" class="topper-type-group">
|
||||
<button type="button" class="tab-btn topper-type-btn tab-active" data-type="round" aria-pressed="true"><i class="fa-regular fa-circle-dot"></i><span class="hidden sm:inline">Round</span></button>
|
||||
<button type="button" class="tab-btn topper-type-btn tab-idle" data-type="star" aria-pressed="false"><i class="fa-solid fa-star"></i><span class="hidden sm:inline">Star</span></button>
|
||||
<button type="button" class="tab-btn topper-type-btn tab-idle" data-type="heart" aria-pressed="false"><i class="fa-solid fa-heart"></i><span class="hidden sm:inline">Heart</span></button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="text-xs font-semibold text-gray-600 mb-1">Numbers</div>
|
||||
<div class="topper-number-grid">
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-0" aria-pressed="false">0</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-1" aria-pressed="false">1</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-2" aria-pressed="false">2</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-3" aria-pressed="false">3</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-4" aria-pressed="false">4</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-5" aria-pressed="false">5</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-6" aria-pressed="false">6</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-7" aria-pressed="false">7</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-8" aria-pressed="false">8</button>
|
||||
<button type="button" class="tab-btn topper-type-btn topper-number-btn tab-idle" data-type="num-9" aria-pressed="false">9</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<div class="text-sm font-medium mb-1">Topper Size</div>
|
||||
<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 id="classic-number-tint-row" class="sm:col-span-2 hidden number-tint-row">
|
||||
<div class="number-tint-header">
|
||||
<div class="text-sm font-semibold text-gray-700">Number Tint</div>
|
||||
<span class="text-xs text-gray-500">Soft overlay for photo digits</span>
|
||||
</div>
|
||||
<input id="classic-number-tint" type="range" min="0" max="1" step="0.05" value="0.5" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||
<p class="hint">Pick a color in Classic Colors, select Topper (T), then adjust strength.</p>
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex flex-wrap gap-2 justify-end">
|
||||
<button type="button" id="classic-nudge-open" class="btn-dark text-xs px-3 py-2">Nudge Panel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
@ -269,51 +339,101 @@
|
||||
</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 font-medium">
|
||||
<input id="classic-border-enabled" type="checkbox" class="align-middle">
|
||||
Outline Balloons
|
||||
</label>
|
||||
<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>
|
||||
<p class="hint">Use stacked for “same color per quad” layouts; reverse flips the spiral.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
|
||||
<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 class="control-stack" data-mobile-tab="colors">
|
||||
<div class="panel-heading">Classic Colors</div>
|
||||
<div class="panel-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div id="classic-slots" class="flex items-center gap-2"></div>
|
||||
<button id="classic-add-slot" class="btn-dark text-sm px-3 py-2 hidden" type="button" title="Add color slot">+</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 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 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>
|
||||
</details>
|
||||
|
||||
<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 keeps the vector Classic layout; PNG is raster.</p>
|
||||
</div>
|
||||
<p class="hint text-red-500">Classic JSON save/load coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
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>
|
||||
<div id="floating-topper-nudge" class="floating-nudge hidden">
|
||||
<div class="floating-nudge-header">
|
||||
<div class="panel-heading">Nudge Topper</div>
|
||||
<button type="button" id="floating-nudge-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Close nudge panel">×</button>
|
||||
</div>
|
||||
<div class="floating-nudge-body">
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div></div>
|
||||
<button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up">↑</button>
|
||||
<div></div>
|
||||
<button type="button" class="btn-dark nudge-topper" data-dx="-0.5" data-dy="0" aria-label="Move Topper Left">←</button>
|
||||
<div></div>
|
||||
<button type="button" class="btn-dark nudge-topper" data-dx="0.5" data-dy="0" aria-label="Move Topper Right">→</button>
|
||||
<div></div>
|
||||
<button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="-0.5" aria-label="Move Topper Down">↓</button>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="mobile-tabbar" class="mobile-tabbar">
|
||||
<button type="button" class="mobile-tab-btn" data-mobile-tab="controls" aria-pressed="true" aria-label="Tools">
|
||||
<i class="mobile-tab-icon fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
|
||||
<span class="sr-only">Tools</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-tab-btn" data-mobile-tab="colors" aria-pressed="false" aria-label="Colors">
|
||||
<i class="mobile-tab-icon fa-solid fa-palette" aria-hidden="true"></i>
|
||||
<span class="sr-only">Colors</span>
|
||||
</button>
|
||||
<button type="button" class="mobile-tab-btn" data-mobile-tab="save" aria-pressed="false" aria-label="Save and Share">
|
||||
<i class="mobile-tab-icon fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
|
||||
<span class="sr-only">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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,7 +441,6 @@
|
||||
<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>
|
||||
|
||||
@ -329,8 +448,5 @@
|
||||
|
||||
<script src="classic.js" defer></script>
|
||||
|
||||
<script>
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
output_webp/0.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
output_webp/1.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
output_webp/2.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
output_webp/3.webp
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
output_webp/4.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
output_webp/5.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
output_webp/6.webp
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
output_webp/7.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
output_webp/8.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
output_webp/9.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
370
style.css
@ -8,28 +8,58 @@ 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 */
|
||||
.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); }
|
||||
.tool-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.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 +67,48 @@ 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: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.tiny { width: 1.4rem; height: 1.4rem; border-width: 1px; box-shadow: none; }
|
||||
|
||||
.current-color-chip {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
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.active { outline: 2px solid #3b82f6; 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 +116,36 @@ 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: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 { 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;
|
||||
@ -116,10 +160,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;
|
||||
@ -130,18 +174,256 @@ body { color: #1f2937; }
|
||||
color: rgba(0,0,0,0.4);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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-swatch.active::after { display: none; }
|
||||
|
||||
.topper-type-group {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.topper-type-btn {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4rem;
|
||||
}
|
||||
.topper-type-btn i { font-size: 1rem; }
|
||||
.topper-number-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.topper-number-btn {
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.number-tint-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(255,255,255,0.7);
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
.number-tint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.floating-nudge {
|
||||
position: fixed;
|
||||
right: 0.9rem;
|
||||
bottom: 4.6rem;
|
||||
background: rgba(255,255,255,0.95);
|
||||
border: 1px solid rgba(148,163,184,0.3);
|
||||
border-radius: 1rem;
|
||||
padding: 0.55rem;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18);
|
||||
width: 180px;
|
||||
max-width: 85vw;
|
||||
z-index: 35;
|
||||
touch-action: none;
|
||||
}
|
||||
.floating-nudge-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .35rem;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.floating-nudge-body.collapsed { display: none; }
|
||||
.floating-nudge.collapsed .floating-nudge-body { display: none; }
|
||||
.floating-nudge.collapsed #floating-nudge-toggle { opacity: 0.8; }
|
||||
.floating-nudge.dragging { cursor: grabbing; }
|
||||
.floating-nudge .panel-heading { font-size: 0.95rem; margin-bottom: 0; }
|
||||
.floating-nudge .btn-dark { padding: 0.35rem 0.5rem; font-size: 0.8rem; }
|
||||
.floating-nudge-tab { display: none; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.floating-nudge { bottom: 6rem; left: auto; right: 0.9rem; }
|
||||
}
|
||||
|
||||
.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: 1rem;
|
||||
padding: 1rem;
|
||||
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.5rem 1.5rem 0 0;
|
||||
padding: 1.25rem 1rem;
|
||||
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 { transform: translateY(100%); }
|
||||
.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: 0; overflow: auto; }
|
||||
html, body { height: auto; overflow: auto; }
|
||||
|
||||
.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;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: .6rem .9rem .9rem;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(224,242,254,0.92));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: #0f172a;
|
||||
z-index: 40;
|
||||
gap: .4rem;
|
||||
box-shadow: 0 -6px 30px rgba(15, 23, 42, 0.12);
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.mobile-tabbar .mobile-tab-btn {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .35rem;
|
||||
text-align: center;
|
||||
padding: .7rem .5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: rgba(255,255,255,0.8);
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
font-size: .9rem;
|
||||
letter-spacing: 0.01em;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
.mobile-tabbar .mobile-tab-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mobile-tabbar .mobile-tab-btn[aria-pressed="true"] {
|
||||
background: linear-gradient(135deg, #2563eb, #0ea5e9);
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
/* Compact viewport fallback */
|
||||
@media (max-height: 760px) {
|
||||
body { overflow: auto; }
|
||||
.control-sheet {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.floating-nudge {
|
||||
bottom: 1rem;
|
||||
}
|
||||
}
|
||||