Compare commits

..

No commits in common. "7e6ac4cf4b6150abe830dedcc177ae76f8040da5" and "f22319737eadade20ad9b12d71c8c4699e7920c6" have entirely different histories.

7 changed files with 2163 additions and 4612 deletions

View File

@ -1103,69 +1103,4 @@ function distinctPaletteSlots(palette) {
window.ClassicDesigner = window.ClassicDesigner || { init: initClassic, api: null, redraw: null };
document.addEventListener('DOMContentLoaded', () => { if (document.getElementById('classic-display') && !window.__classicInit) { window.__classicInit = true; initClassic(); } });
// Export helper for tab-level routing
(function setupClassicExport() {
const { imageUrlToDataUrl, XLINK_NS } = window.shared || {};
if (!imageUrlToDataUrl || !XLINK_NS) return;
function getImageHref(el) { return el.getAttribute('href') || el.getAttributeNS(XLINK_NS, 'href'); }
function setImageHref(el, val) {
el.setAttribute('href', val);
el.setAttributeNS(XLINK_NS, 'xlink:href', val);
}
async function buildClassicSvgPayload() {
const svgElement = document.querySelector('#classic-display svg');
if (!svgElement) throw new Error('Classic design not found. Please create a design first.');
const clonedSvg = svgElement.cloneNode(true);
let bbox = null;
try {
const temp = clonedSvg.cloneNode(true);
temp.style.position = 'absolute';
temp.style.left = '-99999px';
temp.style.top = '-99999px';
temp.style.width = '0';
temp.style.height = '0';
document.body.appendChild(temp);
const target = temp.querySelector('g') || temp;
bbox = target.getBBox();
temp.remove();
} catch {}
const allImages = Array.from(clonedSvg.querySelectorAll('image'));
await Promise.all(allImages.map(async img => {
const href = getImageHref(img);
if (!href || href.startsWith('data:')) return;
const dataUrl = await imageUrlToDataUrl(href);
if (dataUrl) setImageHref(img, dataUrl);
}));
const viewBox = (clonedSvg.getAttribute('viewBox') || '0 0 1000 1000').split(/\s+/).map(Number);
let vbX = isFinite(viewBox[0]) ? viewBox[0] : 0;
let vbY = isFinite(viewBox[1]) ? viewBox[1] : 0;
let vbW = isFinite(viewBox[2]) ? viewBox[2] : (svgElement.clientWidth || 1000);
let vbH = isFinite(viewBox[3]) ? viewBox[3] : (svgElement.clientHeight || 1000);
if (bbox && isFinite(bbox.x) && isFinite(bbox.y) && isFinite(bbox.width) && isFinite(bbox.height)) {
const pad = 10;
vbX = bbox.x - pad;
vbY = bbox.y - pad;
vbW = Math.max(1, bbox.width + pad * 2);
vbH = Math.max(1, bbox.height + pad * 2);
}
clonedSvg.setAttribute('width', vbW);
clonedSvg.setAttribute('height', vbH);
if (!clonedSvg.getAttribute('xmlns')) clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!clonedSvg.getAttribute('xmlns:xlink')) clonedSvg.setAttribute('xmlns:xlink', XLINK_NS);
clonedSvg.querySelectorAll('g.balloon, path.balloon, ellipse.balloon, circle.balloon').forEach(el => {
if (!el.getAttribute('stroke')) el.setAttribute('stroke', '#111827');
if (!el.getAttribute('stroke-width')) el.setAttribute('stroke-width', '1');
if (!el.getAttribute('paint-order')) el.setAttribute('paint-order', 'stroke fill');
if (!el.getAttribute('vector-effect')) el.setAttribute('vector-effect', 'non-scaling-stroke');
});
const svgString = new XMLSerializer().serializeToString(clonedSvg);
return { svgString, width: vbW, height: vbH, minX: vbX, minY: vbY };
}
window.ClassicExport = { buildClassicSvgPayload };
})();
})();

View File

@ -26,7 +26,7 @@
<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 id="app-header" class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
<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>
@ -38,7 +38,6 @@
<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>
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
</nav>
<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">
@ -196,17 +195,14 @@
</div>
<div class="panel-heading mt-4">Replace Color</div>
<div class="panel-card space-y-3">
<div class="flex items-center gap-2 replace-row">
<button type="button" class="replace-chip" id="replace-from-chip" aria-label="Pick color to replace"></button>
<span class="text-xs font-semibold text-slate-500"></span>
<button type="button" class="replace-chip" id="replace-to-chip" aria-label="Pick replacement color"></button>
<span id="replace-count" class="text-xs text-slate-500 ml-auto"></span>
</div>
<div class="panel-card">
<div class="grid grid-cols-1 gap-2">
<p class="hint text-xs">Tap a chip to choose colors. “From” shows only colors used on canvas.</p>
<select id="replace-from" class="sr-only"></select>
<select id="replace-to" class="sr-only"></select>
<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>
@ -422,130 +418,6 @@
</section>
</section>
<section id="tab-wall" class="hidden flex flex-col lg:flex-row gap-4 lg:h-[calc(100vh-10rem)]">
<aside id="wall-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">Wall Controls</h2>
</div>
<div class="control-stack" data-mobile-tab="controls">
<div class="panel-heading">Grid</div>
<div class="panel-card grid grid-cols-2 gap-3">
<label class="text-sm font-medium flex flex-col gap-1">Columns
<input id="wall-cols" type="number" min="2" max="20" step="1" value="9" class="w-full px-2 py-1 border rounded">
</label>
<label class="text-sm font-medium flex flex-col gap-1">Rows
<input id="wall-rows" type="number" min="2" max="20" step="1" value="7" class="w-full px-2 py-1 border rounded">
</label>
<label class="text-sm font-medium flex flex-col gap-1 col-span-2">Pattern
<select id="wall-pattern" class="select">
<option value="grid">Square Grid</option>
<option value="x">X / Diamond</option>
</select>
</label>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-show-wire" type="checkbox" class="align-middle" checked>
Show wireframe for empty spots
</label>
<label class="text-sm font-medium inline-flex items-center gap-2 col-span-2">
<input id="wall-outline" type="checkbox" class="align-middle">
Outline balloons
</label>
</div>
<div class="panel-heading mt-4">Tools</div>
<div class="panel-card">
<div class="wall-toolbar">
<button type="button" id="wall-tool-paint" class="tool-btn" aria-pressed="true">
<i class="fa-solid fa-brush"></i>
<span>Paint</span>
</button>
<button type="button" id="wall-tool-erase" class="tool-btn" aria-pressed="false">
<i class="fa-solid fa-eraser"></i>
<span>Erase</span>
</button>
</div>
<p class="hint mt-2 text-xs">Paint applies the active color; Erase clears. Hold Shift/Ctrl for temporary erase.</p>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading mt-4">Active Color</div>
<div class="panel-card">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700">Current</span>
<div id="wall-active-color-chip" class="current-color-chip">
<span id="wall-active-color-label" class="text-[10px] font-semibold text-slate-700"></span>
</div>
</div>
<p class="hint mt-2">Tap a swatch to set. Tap a balloon to paint; tap again (same color) to clear. Alt+click (desktop) to pick.</p>
</div>
<div class="panel-heading mt-4">Used Colors</div>
<div class="panel-card">
<div class="flex items-center justify-between mb-2">
<div class="text-xs text-gray-600">Click to pick. Remove unused clears empty/transparent entries.</div>
<button type="button" id="wall-remove-unused" class="btn-yellow text-xs px-2 py-1">Remove Unused</button>
</div>
<div id="wall-used-palette" class="palette-box min-h-[2.4rem]"></div>
</div>
<div class="panel-heading mt-4">Wall Palette</div>
<div class="panel-card">
<div id="wall-palette" class="palette-box min-h-[3rem]"></div>
</div>
<div class="panel-heading mt-4">Replace Colors</div>
<div class="panel-card space-y-3">
<div class="flex items-center gap-2 replace-row">
<button type="button" class="replace-chip" id="wall-replace-from-chip" aria-label="Pick wall color to replace"></button>
<span class="text-xs font-semibold text-slate-500"></span>
<button type="button" class="replace-chip" id="wall-replace-to-chip" aria-label="Pick wall replacement color"></button>
<span id="wall-replace-count" class="text-xs text-slate-500 ml-auto"></span>
</div>
<div class="grid grid-cols-1 gap-2">
<p class="hint text-xs">Tap a chip to choose colors. “Replace” shows only colors used in this wall.</p>
<select id="wall-replace-from" class="sr-only"></select>
<select id="wall-replace-to" class="sr-only"></select>
<button type="button" id="wall-replace-btn" class="btn-dark text-sm">Replace</button>
<div id="wall-replace-msg" class="text-xs text-gray-500"></div>
</div>
</div>
</div>
<div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Save & Export</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">Exports the current wall view.</p>
</div>
<div>
<div class="panel-heading text-sm">Quick Paint (uses active color)</div>
<div class="grid grid-cols-2 gap-2 mt-2">
<button type="button" id="wall-paint-links" class="btn-blue text-xs px-2 py-2">Paint Links</button>
<button type="button" id="wall-paint-small" class="btn-blue text-xs px-2 py-2">Paint 5&quot; Nodes</button>
<button type="button" id="wall-paint-gaps" class="btn-blue text-xs px-2 py-2">Paint 11&quot; Gaps</button>
</div>
<p class="hint mt-2">Fills only that group; pattern-aware for Grid vs X.</p>
</div>
<div class="flex flex-wrap gap-3">
<button type="button" id="wall-clear" class="btn-danger text-sm px-3 py-2 flex-1">Clear</button>
<button type="button" id="wall-fill-all" class="btn-blue text-sm px-3 py-2 flex-1">Fill All</button>
</div>
</div>
</div>
</aside>
<section id="wall-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">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-white/70">
<div class="text-base font-semibold text-slate-700">Balloon Wall</div>
<div class="text-sm text-gray-500">Columns/Rows: <span id="wall-grid-label">9 × 7</span></div>
</div>
<div id="wall-display" class="flex-1 bg-white relative overflow-auto">
<div class="p-6 text-gray-500 text-sm">Wall designer will load here.</div>
</div>
</section>
</section>
</div>
<div id="mobile-tabbar" class="mobile-tabbar">
@ -563,71 +435,6 @@
</button>
</div>
<!-- Mobile sticky action bar -->
<div id="mobile-action-bar" class="mobile-action-bar hidden">
<div class="mobile-action-chip" id="mobile-active-color-chip" title="Active color"></div>
<div class="mobile-action-row">
<button type="button" class="mobile-action-btn" id="mobile-act-undo" aria-label="Undo">
<i class="fa-solid fa-rotate-left" aria-hidden="true"></i>
<span>Undo</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-redo" aria-label="Redo">
<i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
<span>Redo</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-eyedrop" aria-label="Eyedropper">
<i class="fa-solid fa-eye-dropper" aria-hidden="true"></i>
<span>Pick</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-erase" aria-label="Toggle Erase">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
<span>Erase</span>
</button>
<button type="button" class="mobile-action-btn danger" id="mobile-act-clear" aria-label="Clear canvas">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span>Clear</span>
</button>
<button type="button" class="mobile-action-btn" id="mobile-act-export" aria-label="Export PNG">
<i class="fa-solid fa-download" aria-hidden="true"></i>
<span>Export</span>
</button>
</div>
</div>
<!-- Color picker modal -->
<div id="color-picker-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="color-picker-title">
<div class="color-modal-backdrop"></div>
<div class="color-modal-card">
<div class="color-modal-header">
<div>
<div id="color-picker-title" class="color-modal-title">Choose a color</div>
<div id="color-picker-subtitle" class="color-modal-subtitle"></div>
</div>
<button type="button" id="color-picker-close" class="color-modal-close" aria-label="Close">&times;</button>
</div>
<div id="color-picker-grid" class="color-modal-grid"></div>
</div>
</div>
<!-- Export modal -->
<div id="export-modal" class="color-modal hidden" role="dialog" aria-modal="true" aria-labelledby="export-modal-title">
<div class="color-modal-backdrop"></div>
<div class="color-modal-card">
<div class="color-modal-header">
<div>
<div id="export-modal-title" class="color-modal-title">Export design</div>
<div class="color-modal-subtitle">Choose a format to download</div>
</div>
<button type="button" id="export-modal-close" class="color-modal-close" aria-label="Close">&times;</button>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="button" class="btn-blue flex-1" data-export-choice="png">Export PNG</button>
<button type="button" class="btn-dark flex-1" data-export-choice="svg">Export SVG</button>
</div>
<p class="hint mt-2 text-xs text-slate-500">SVG keeps vector shapes where possible (textures/images stay raster). PNG renders a high-res snapshot.</p>
</div>
</div>
<div id="message-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-gray-900 bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg max-w-sm text-center">
<p id="modal-text" class="text-gray-800 text-lg"></p>
@ -637,11 +444,8 @@
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script>
<!-- Palette must load before shared.js; it is already included in the <head>. -->
<script src="shared.js" defer></script>
<script src="script.js" defer></script>
<script src="organic.js" defer></script>
<script src="wall.js" defer></script>
<script src="classic.js" defer></script>
</body>

2146
organic.js

File diff suppressed because it is too large Load Diff

2551
script.js

File diff suppressed because it is too large Load Diff

211
shared.js
View File

@ -1,211 +0,0 @@
(() => {
'use strict';
// Shared helpers & palette flattening
document.addEventListener('DOMContentLoaded', () => {
const PX_PER_INCH = 4;
const SIZE_PRESETS = [24, 18, 11, 9, 5];
const TEXTURE_ZOOM_DEFAULT = 1.8;
const TEXTURE_FOCUS_DEFAULT = { x: 0.5, y: 0.5 };
const SWATCH_TEXTURE_ZOOM = 2.5;
const PNG_EXPORT_SCALE = 3;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
const clamp01 = v => clamp(v, 0, 1);
const normalizeHex = h => (h || '').toLowerCase();
function hexToRgb(hex) {
const h = normalizeHex(hex).replace('#','');
if (h.length === 3) {
const r = parseInt(h[0] + h[0], 16);
const g = parseInt(h[1] + h[1], 16);
const b = parseInt(h[2] + h[2], 16);
return { r, g, b };
}
if (h.length === 6) {
const r = parseInt(h.slice(0,2), 16);
const g = parseInt(h.slice(2,4), 16);
const b = parseInt(h.slice(4,6), 16);
return { r, g, b };
}
return { r: 0, g: 0, b: 0 };
}
function luminance(hex) {
const { r, g, b } = hexToRgb(hex || '#000');
const norm = [r,g,b].map(v => {
const c = v / 255;
return c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
});
return 0.2126*norm[0] + 0.7152*norm[1] + 0.0722*norm[2];
}
function shineStyle(colorHex) {
const hex = normalizeHex(colorHex);
const isRetroWhite = hex === '#e8e3d9';
const isPureWhite = hex === '#ffffff';
const lum = luminance(hex);
if (isPureWhite || isRetroWhite) {
return { fill: 'rgba(220,220,220,0.22)', stroke: null };
}
if (lum > 0.7) {
const t = clamp01((lum - 0.7) / 0.3);
const fillAlpha = 0.08 + (0.04 - 0.08) * t;
return { fill: `rgba(0,0,0,${fillAlpha})`, stroke: null };
}
const base = 0.20;
const softened = lum > 0.4 ? base * 0.7 : base;
const finalAlpha = isRetroWhite ? softened * 0.6 : softened;
return { fill: `rgba(255,255,255,${finalAlpha})`, stroke: null };
}
const FLAT_COLORS = [];
const NAME_BY_HEX = new Map();
const HEX_TO_FIRST_IDX = new Map();
const allowedSet = new Set();
(function buildFlat() {
if (!Array.isArray(window.PALETTE)) return;
window.PALETTE.forEach(group => {
(group.colors || []).forEach(c => {
if (!c?.hex) return;
const item = { ...c, family: group.family };
item.imageZoom = Number.isFinite(c.imageZoom) ? Math.max(1, c.imageZoom) : TEXTURE_ZOOM_DEFAULT;
item.imageFocus = {
x: clamp01(c.imageFocusX ?? c.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x),
y: clamp01(c.imageFocusY ?? c.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y)
};
item._idx = FLAT_COLORS.length;
FLAT_COLORS.push(item);
const key = (c.hex || '').toLowerCase();
if (!NAME_BY_HEX.has(key)) NAME_BY_HEX.set(key, c.name);
if (!HEX_TO_FIRST_IDX.has(key)) HEX_TO_FIRST_IDX.set(key, item._idx);
allowedSet.add(key);
});
});
})();
const IMG_CACHE = new Map();
function getImage(path, onLoad) {
if (!path) return null;
let img = IMG_CACHE.get(path);
if (!img) {
img = new Image();
// Avoid CORS issues on file:// by only setting crossOrigin for http/https
const href = (() => { try { return new URL(path, window.location.href); } catch { return null; } })();
const isFile = href?.protocol === 'file:' || window.location.protocol === 'file:';
if (!isFile) img.crossOrigin = 'anonymous';
img.decoding = 'async';
img.loading = 'eager';
img.src = path;
if (onLoad) img.onload = onLoad;
IMG_CACHE.set(path, img);
}
return img;
}
const DATA_URL_CACHE = new Map();
const XLINK_NS = 'http://www.w3.org/1999/xlink';
const blobToDataUrl = blob => new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
function imageToDataUrl(img) {
if (!img || !img.complete || img.naturalWidth === 0) return null;
// On file:// origins, drawing may be blocked; return null to fall back to original href.
if (window.location.protocol === 'file:') return null;
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
return c.toDataURL('image/png');
} catch (err) {
console.warn('[Export] imageToDataUrl failed:', err);
return null;
}
}
async function imageUrlToDataUrl(src) {
if (!src || src.startsWith('data:')) return src;
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
const isFile = urlObj?.protocol === 'file:';
const cachedImg = IMG_CACHE.get(src);
const cachedUrl = imageToDataUrl(cachedImg);
if (cachedUrl) { DATA_URL_CACHE.set(src, cachedUrl); return cachedUrl; }
let dataUrl = null;
try {
if (isFile) {
// On file:// we cannot safely read pixels; return the original path.
dataUrl = src;
} else {
const resp = await fetch(abs);
if (!resp.ok) throw new Error(`Status ${resp.status}`);
dataUrl = await blobToDataUrl(await resp.blob());
}
} catch (err) {
if (!isFile) console.warn('[Export] Fetch failed for', abs, err);
dataUrl = await new Promise(resolve => {
const img = new Image();
if (!isFile) img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth || 1;
c.height = img.naturalHeight || 1;
c.getContext('2d').drawImage(img, 0, 0);
resolve(c.toDataURL('image/png'));
} catch (e) {
console.error('[Export] Canvas fallback failed for', abs, e);
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = abs;
});
}
if (!dataUrl) dataUrl = abs;
DATA_URL_CACHE.set(src, dataUrl);
return dataUrl;
}
function download(href, suggestedFilename) {
const a = document.createElement('a');
a.href = href;
a.download = suggestedFilename || 'download';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
window.shared = {
PX_PER_INCH,
SIZE_PRESETS,
TEXTURE_ZOOM_DEFAULT,
TEXTURE_FOCUS_DEFAULT,
SWATCH_TEXTURE_ZOOM,
PNG_EXPORT_SCALE,
clamp,
clamp01,
normalizeHex,
hexToRgb,
shineStyle,
luminance,
FLAT_COLORS,
NAME_BY_HEX,
HEX_TO_FIRST_IDX,
allowedSet,
getImage,
DATA_URL_CACHE,
XLINK_NS,
blobToDataUrl,
imageToDataUrl,
imageUrlToDataUrl,
download,
};
});
})();

282
style.css
View File

@ -1,33 +1,5 @@
/* Minimal extras (Tailwind handles most styling) */
body { color: #1f2937; }
body[data-active-tab="#tab-classic"] #clear-canvas-btn-top,
body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
#app-header {
position: sticky;
top: 0;
z-index: 35;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: .75rem 0.5rem;
border-radius: 1.25rem;
box-shadow: 0 8px 24px rgba(15,23,42,0.08);
}
#balloon-canvas { touch-action: none; }
@ -40,31 +12,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
}
/* Buttons */
.btn-dark,
.btn-blue,
.btn-green,
.btn-yellow,
.btn-danger,
.btn-indigo {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .35rem;
font-weight: 700;
letter-spacing: -0.01em;
border: 0;
font-size: 0.95rem;
}
.btn-dark:focus-visible,
.btn-blue:focus-visible,
.btn-green:focus-visible,
.btn-yellow:focus-visible,
.btn-danger:focus-visible,
.btn-indigo:focus-visible,
.tool-btn:focus-visible {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.tool-btn {
display: flex;
align-items: center;
@ -120,14 +67,12 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
flex-direction: column;
gap: .5rem;
padding: .5rem;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(226,232,240,0.9);
border-radius: .9rem;
background: rgba(255,255,255,0.6); /* More transparent */
border: 1px solid #e5e7eb;
border-radius: .75rem;
max-height: 260px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
touch-action: pan-y;
}
.swatch {
@ -164,8 +109,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; }
.family-title { font-weight:700; color:#334155; margin-top:.25rem; font-size:.9rem; letter-spacing: -0.01em; }
#wall-display { min-height: 60vh; }
#wall-display svg { width: 100%; height: 100%; display: block; }
.badge {
position:absolute;
@ -244,34 +187,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
}
.slot-swatch.active::after { display: none; }
.replace-row {
padding: 0.35rem;
background: rgba(248,250,252,0.9);
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
}
.replace-chip {
width: 40px;
height: 40px;
border-radius: 12px;
border: 2px solid rgba(51,65,85,0.18);
box-shadow: 0 3px 8px rgba(0,0,0,0.06);
background-size: cover;
background-position: center;
background-color: #fff;
cursor: pointer;
}
.wall-toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.wall-toolbar .tool-btn {
width: 100%;
justify-content: center;
}
.topper-type-group {
display: flex;
gap: .5rem;
@ -357,13 +272,13 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
letter-spacing: -0.02em;
}
.panel-card {
background: rgba(255,255,255,0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(226,232,240,0.9);
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 12px 30px rgba(15,23,42,0.06);
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
}
.control-stack {
display: flex;
@ -423,188 +338,16 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
@media (max-width: 1023px) {
body { padding-bottom: 0; overflow: auto; }
html, body { height: auto; overflow: auto; }
#current-color-chip-global { display: none; }
#clear-canvas-btn-top { display: none !important; }
/* Add breathing room under canvases so sheets/tabbar dont cover content */
#classic-display,
#wall-display,
#balloon-canvas {
margin-bottom: 5rem;
}
/* Stack switching: show only the active mobile tab stack across panels */
.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="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"],
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
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;
}
.control-sheet { bottom: 4.5rem; max-height: 55vh; }
.control-sheet.minimized { transform: translateY(95%); }
/* Larger tap targets and spacing */
.tool-btn,
.btn-dark,
.btn-blue,
.btn-green,
.btn-yellow,
.btn-danger,
.btn-indigo {
min-height: 44px;
padding: 0.75rem 0.85rem;
font-size: 1rem;
}
.swatch { width: 2.4rem; height: 2.4rem; }
.swatch.tiny { width: 1.8rem; height: 1.8rem; }
.select { min-height: 44px; }
.panel-card { padding: 0.85rem; }
}
.mobile-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 4.75rem;
padding: 0.35rem 0.75rem 0.7rem;
background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-top: 1px solid rgba(226,232,240,0.9);
box-shadow: 0 -10px 30px rgba(15,23,42,0.08);
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 45;
}
.color-modal {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
}
.color-modal.hidden { display: none; }
.color-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(15,23,42,0.35);
backdrop-filter: blur(4px);
}
.color-modal-card {
position: relative;
width: min(640px, 92vw);
max-height: 80vh;
background: #fff;
border-radius: 1.25rem;
padding: 1.1rem 1.1rem 1.25rem;
box-shadow: 0 24px 60px rgba(15,23,42,0.2);
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1;
}
.color-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.color-modal-title { font-size: 1.1rem; font-weight: 800; color: #0f172a; letter-spacing: -0.01em; }
.color-modal-subtitle { font-size: 0.9rem; color: #475569; }
.color-modal-close {
background: #e2e8f0;
border: none;
width: 36px;
height: 36px;
border-radius: 12px;
font-size: 1.4rem;
color: #0f172a;
}
.color-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 0.75rem;
overflow-y: auto;
padding: 0.25rem;
}
.color-option {
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: center;
justify-content: center;
cursor: pointer;
background: #fff;
box-shadow: 0 4px 12px rgba(15,23,42,0.05);
transition: transform 0.1s ease;
}
.color-option:hover { transform: translateY(-1px); }
.color-option .swatch {
width: 2.4rem;
height: 2.4rem;
border-width: 2px;
}
.color-option .label {
font-size: 0.78rem;
font-weight: 700;
color: #0f172a;
text-align: center;
line-height: 1.1;
}
.color-option .meta {
font-size: 0.72rem;
color: #475569;
text-align: center;
}
.mobile-action-bar.hidden { display: none; }
.mobile-action-chip {
width: 44px;
height: 44px;
border-radius: 14px;
border: 2px solid rgba(51,65,85,0.18);
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
background-size: cover;
background-position: center;
background-color: #fff;
}
.mobile-action-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.35rem;
width: 100%;
}
.mobile-action-btn {
background: rgba(255,255,255,0.92);
border: 1px solid rgba(226,232,240,0.9);
border-radius: 14px;
padding: 0.55rem 0.35rem;
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
box-shadow: 0 4px 14px rgba(15,23,42,0.08);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
}
.mobile-action-btn i { font-size: 1rem; }
.mobile-action-btn.danger { color: #dc2626; border-color: rgba(248,113,113,0.35); }
.mobile-action-btn:active { transform: translateY(1px); }
.mobile-action-btn.active {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.18), 0 6px 16px rgba(37,99,235,0.2);
}
.mobile-tabbar {
@ -671,7 +414,6 @@ body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
border: 1px solid rgba(255,255,255,0.4);
}
body { padding-bottom: 0; overflow: auto; }
.mobile-action-bar { display: none !important; }
}
/* Compact viewport fallback */

1306
wall.js

File diff suppressed because it is too large Load Diff