Compare commits

..

4 Commits

7 changed files with 4600 additions and 2151 deletions

View File

@ -1103,4 +1103,69 @@ 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 class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 px-1 lg:px-0">
<header id="app-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,6 +38,7 @@
<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">
@ -195,14 +196,17 @@
</div>
<div class="panel-heading mt-4">Replace Color</div>
<div class="panel-card">
<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="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>
<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>
<button id="replace-btn" class="btn-blue">Replace</button>
<p id="replace-msg" class="hint"></p>
</div>
@ -418,6 +422,130 @@
</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">
@ -435,6 +563,71 @@
</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>
@ -444,8 +637,11 @@
<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 Normal file

File diff suppressed because it is too large Load Diff

2527
script.js

File diff suppressed because it is too large Load Diff

211
shared.js Normal file
View File

@ -0,0 +1,211 @@
(() => {
'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,5 +1,33 @@
/* 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; }
@ -12,6 +40,31 @@ body { color: #1f2937; }
}
/* 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;
@ -67,12 +120,14 @@ body { color: #1f2937; }
flex-direction: column;
gap: .5rem;
padding: .5rem;
background: rgba(255,255,255,0.6); /* More transparent */
border: 1px solid #e5e7eb;
border-radius: .75rem;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(226,232,240,0.9);
border-radius: .9rem;
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 {
@ -109,6 +164,8 @@ body { color: #1f2937; }
.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;
@ -187,6 +244,34 @@ body { color: #1f2937; }
}
.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;
@ -272,13 +357,13 @@ body { color: #1f2937; }
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);
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);
border-radius: 1rem;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
box-shadow: 0 12px 30px rgba(15,23,42,0.06);
}
.control-stack {
display: flex;
@ -338,16 +423,188 @@ body { color: #1f2937; }
@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="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"] {
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 {
@ -414,6 +671,7 @@ body { color: #1f2937; }
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 Normal file

File diff suppressed because it is too large Load Diff