Compare commits

...

16 Commits

Author SHA1 Message Date
4b9fee0a3e Merge pull request 'exploded-classic' (#1) from exploded-classic into main
Reviewed-on: #1
2025-12-19 09:18:58 -05:00
c20ceccab7 Thicken adaptive outline for number toppers 2025-12-19 09:16:58 -05:00
0f325ca1d8 Improve number topper outlines and allow textured colors 2025-12-19 09:10:48 -05:00
8bb46389f3 Make number toppers tint via SVG masks 2025-12-18 15:26:06 -05:00
7ba62b3d2b Wall: unify paint toggle and consistent outlines; arch spacing tweaks 2025-12-18 11:53:02 -05:00
346f6ff917 Add manual replace palette and fix shine for bright colors 2025-12-18 10:05:04 -05:00
99d943643b Use modal picker for organic and wall palettes 2025-12-17 17:50:32 -05:00
f546662143 Use modal color picker for classic palette 2025-12-17 17:40:28 -05:00
540acedcab Adjust classic manual selection behavior 2025-12-17 16:59:01 -05:00
5adff65791 Remove classic mobile quick actions 2025-12-04 13:34:47 -05:00
54024d6bcd Add active color chip pickers 2025-12-04 13:21:14 -05:00
a5016b4fa7 Refine mobile UI and simplify tool help 2025-12-04 13:10:48 -05:00
7e6ac4cf4b Improve mobile layout, wall tools, replace picker, and export modal 2025-12-04 12:21:42 -05:00
242a9f1ab0 Fix wall click painting and gap rendering 2025-12-04 11:01:49 -05:00
57423a1d88 Checkpoint: export fixes and mobile controls 2025-12-02 16:25:36 -05:00
22075cadb4 Refactor shared logic and wall updates 2025-12-02 10:31:36 -05:00
19 changed files with 7082 additions and 2439 deletions

1819
classic.js

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@ const PALETTE = [
]}, ]},
{ family: "Oranges & Browns & Yellows", colors: [ { family: "Oranges & Browns & Yellows", colors: [
{name:"Pastel Yellow",hex:"#fcfd96"},{name:"Yellow",hex:"#f5e812"},{name:"Goldenrod",hex:"#f7b615"}, {name:"Pastel Yellow",hex:"#fcfd96"},{name:"Yellow",hex:"#f5e812"},{name:"Goldenrod",hex:"#f7b615"},
{name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"} {name:"Orange",hex:"#ef6b24"},{name:"Coffee",hex:"#957461"},{name:"Burnt Orange",hex:"#9d4223"},
{name:"Blended Brown",hex:"#c9aea0"}
]}, ]},
{ family: "Greens", colors: [ { family: "Greens", colors: [
{name:"Eucalyptus",hex:"#a3bba3"},{name:"Pastel Green",hex:"#acdba7"},{name:"Lime Green",hex:"#8fc73e"}, {name:"Eucalyptus",hex:"#a3bba3"},{name:"Pastel Green",hex:"#acdba7"},{name:"Lime Green",hex:"#8fc73e"},
@ -63,4 +64,4 @@ const PALETTE = [
]; ];
window.CLASSIC_COLORS = ['#D92E3A', '#FFFFFF', '#0055A4', '#40E0D0']; window.CLASSIC_COLORS = ['#D92E3A', '#FFFFFF', '#0055A4', '#40E0D0'];
window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []); window.PALETTE = window.PALETTE || (typeof PALETTE !== "undefined" ? PALETTE : []);

View File

@ -24,9 +24,9 @@
</style> </style>
</head> </head>
<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"> <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"> <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(97vh-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 class="flex items-center gap-3">
<div> <div>
@ -37,14 +37,10 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<nav id="mode-tabs" class="flex gap-2"> <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-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-classic" aria-pressed="false">Classic</button>
<button type="button" class="tab-btn tab-idle" data-target="#tab-wall" aria-pressed="false">Wall</button>
</nav> </nav>
<div class="flex items-center gap-3"> <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>
</div>
<button id="app-fullscreen-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Toggle fullscreen">Fullscreen</button> <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> <button id="clear-canvas-btn-top" class="btn-danger text-xs px-3 py-2">Start Fresh</button>
</div> </div>
@ -78,11 +74,11 @@
</button> </button>
</div> </div>
<div class="grid grid-cols-3 gap-2 mb-3"> <div class="grid grid-cols-3 gap-2 mb-3">
<button id="tool-undo" class="tool-btn" title="Ctrl+Z" aria-label="Undo"> <button id="tool-undo" class="tool-btn" 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> <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> <span class="hidden sm:inline">Undo</span>
</button> </button>
<button id="tool-redo" class="tool-btn" title="Ctrl+Y" aria-label="Redo"> <button id="tool-redo" class="tool-btn" 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> <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> <span class="hidden sm:inline">Redo</span>
</button> </button>
@ -98,31 +94,17 @@
<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"> <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> <span id="garland-density-label" class="w-10 text-right text-xs text-gray-500">1.0</span>
</div> </div>
<div class="grid grid-cols-1 gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center justify-between">
<label for="garland-color-main1" class="font-medium w-24">Main A</label> <span class="font-medium text-sm text-gray-700">Main Colors</span>
<select id="garland-color-main1" class="select text-sm flex-1"></select> <button type="button" id="garland-add-color" class="btn-blue text-xs px-3 py-1">+ Add</button>
<span id="garland-swatch-main1" class="swatch tiny"></span>
</div> </div>
<div id="garland-main-chips" class="flex flex-wrap gap-2"></div>
<p class="hint text-xs">Tap a chip to change it. You can add up to 10 main colors.</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<label for="garland-color-main2" class="font-medium w-24">Main B</label> <span class="font-medium text-sm text-gray-700">Accent</span>
<select id="garland-color-main2" class="select text-sm flex-1"></select> <button type="button" id="garland-accent-chip" class="replace-chip" aria-label="Pick accent color"></button>
<span id="garland-swatch-main2" class="swatch tiny"></span> <button type="button" id="garland-accent-clear" class="btn-yellow text-xs px-3 py-1">Clear</button>
</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&quot; 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>
</div> </div>
@ -137,7 +119,7 @@
<button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button> <button id="duplicate-selected" class="btn-dark" disabled>Duplicate</button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<p class="hint">Drag balloons to reposition. Use keyboard arrows for fine nudges.</p> <p class="hint">Drag balloons to reposition. Use arrows/touches for fine nudges.</p>
</div> </div>
<div class="mt-2 flex items-center gap-2 text-xs text-gray-600"> <div class="mt-2 flex items-center gap-2 text-xs text-gray-600">
<span class="font-semibold">Resize</span> <span class="font-semibold">Resize</span>
@ -173,38 +155,44 @@
</div> </div>
<div class="control-stack" data-mobile-tab="colors"> <div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Project Palette</div> <div class="panel-heading">Organic Colors</div>
<div class="panel-card"> <div class="panel-card space-y-4">
<div class="flex items-center justify-between mb-2"> <div class="space-y-2">
<span class="text-sm text-gray-600">Built from the current design. Click a swatch to select that color.</span> <div class="flex items-center gap-3">
<button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button> <span class="text-sm font-medium text-gray-700">Active color</span>
</div> <div id="current-color-chip" class="current-color-chip">
<div id="used-palette" class="palette-box min-h-[3rem]"></div> <span id="current-color-label" class="text-xs font-semibold text-slate-700"></span>
</div> </div>
<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 balloons color.</p>
<div class="flex items-center gap-3 mb-2">
<span class="text-sm font-medium text-gray-700">Active Color</span>
<div id="current-color-chip" class="current-color-chip">
<span id="current-color-label" class="text-xs font-semibold text-slate-700"></span>
</div> </div>
</div> </div>
<div id="color-palette" class="palette-box"></div>
</div>
<div class="panel-heading mt-4">Replace Color</div> <div class="space-y-2">
<div class="panel-card"> <div class="flex items-center justify-between">
<div class="grid grid-cols-1 gap-2"> <span class="text-sm font-semibold text-gray-700">Project Palette</span>
<label class="text-sm font-medium">From (in design):</label> <button id="sort-used-toggle" class="text-sm underline">Sort: Most → Least</button>
<select id="replace-from" class="select"></select> </div>
<div id="used-palette" class="palette-box min-h-[3rem]"></div>
</div>
<label class="text-sm font-medium">To (library):</label> <div class="space-y-2">
<select id="replace-to" class="select"></select> <div class="text-sm font-semibold text-gray-700">Replace Color</div>
<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">
<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>
</div>
<button id="replace-btn" class="btn-blue">Replace</button> <div class="space-y-2">
<p id="replace-msg" class="hint"></p> <div class="text-sm font-semibold text-gray-700">Color Library</div>
<div id="color-palette" class="palette-box"></div>
</div> </div>
</div> </div>
</div> </div>
@ -266,9 +254,24 @@
</div> </div>
<div class="md:col-span-2 space-y-2"> <div class="md:col-span-2 space-y-2">
<div class="text-sm font-medium text-gray-700">Layout</div> <div class="text-sm font-medium text-gray-700">Layout</div>
<div class="flex gap-2"> <div class="flex gap-2 flex-wrap">
<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-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> <button type="button" class="tab-btn tab-idle pattern-btn" data-pattern-layout="stacked" aria-pressed="false">Stacked</button>
<button type="button" class="tab-btn tab-idle" id="classic-manual-btn" aria-pressed="false">Manual paint</button>
</div>
<div id="classic-expanded-row" class="flex items-center gap-2 hidden">
<label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-expanded-toggle" type="checkbox" class="align-middle" checked>
Expanded spacing
</label>
<p class="hint m-0">Separate clusters for easier taps.</p>
</div>
<div id="classic-focus-row" class="flex items-center gap-2 hidden">
<button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-focus-prev" aria-hidden="true" tabindex="-1">◀ Prev</button>
<span id="classic-focus-label" class="text-sm text-gray-700">Clusters 18</span>
<button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-focus-next" aria-hidden="true" tabindex="-1">Next ▶</button>
<button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-focus-zoomout" aria-hidden="true" tabindex="-1">Zoom Out</button>
<button type="button" class="btn-dark text-xs px-3 py-2 hidden" id="classic-quad-reset" aria-hidden="true" tabindex="-1">Reset Quad</button>
</div> </div>
</div> </div>
<select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1"> <select id="classic-pattern" class="select align-middle hidden" aria-hidden="true" tabindex="-1">
@ -289,7 +292,7 @@
<div id="classic-topper-toggle-row" class="flex items-center gap-3 pt-2 border-t border-gray-200 hidden"> <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"> <label class="text-sm inline-flex items-center gap-2 font-medium">
<input id="classic-topper-enabled" type="checkbox" class="align-middle"> <input id="classic-topper-enabled" type="checkbox" class="align-middle">
Add Topper (24") Add Topper
</label> </label>
</div> </div>
@ -317,18 +320,6 @@
</div> </div>
</div> </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">
</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"> <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> <button type="button" id="classic-nudge-open" class="btn-dark text-xs px-3 py-2">Nudge Panel</button>
</div> </div>
@ -359,49 +350,106 @@
<div class="control-stack" data-mobile-tab="colors"> <div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Classic Colors</div> <div class="panel-heading">Classic Colors</div>
<div class="panel-card"> <div class="panel-card">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div id="classic-slots" class="flex items-center gap-2"></div> <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> <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="flex items-center gap-3 mb-2">
<span class="text-sm text-gray-700 classic-label">Active color</span>
<div id="classic-active-chip" class="current-color-chip cursor-pointer" title="Tap to scroll to palette">
<span id="classic-active-label" class="text-xs font-semibold text-slate-700"></span>
</div> </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>
<div id="classic-swatch-grid" class="palette-box min-h-[3rem]"></div> <div id="classic-topper-color-block" class="mb-3 hidden">
<div class="flex flex-wrap gap-2 mt-3"> <div class="text-sm text-gray-700 classic-label">Topper Color</div>
<button id="classic-randomize-colors" class="btn-dark">Randomize</button> <div class="flex items-center gap-3 mt-1">
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button>
</div> </div>
<div id="classic-topper-color-block" class="mt-3 hidden"> </div>
<div class="panel-heading">Topper Color</div> <div id="classic-project-block" class="space-y-1">
<div class="flex items-center gap-3"> <div class="text-sm text-gray-700 classic-label">Project Palette</div>
<button id="classic-topper-color-swatch" class="slot-swatch" title="Click to change topper color">T</button> <div id="classic-project-palette" class="palette-box min-h-[2.4rem]"></div>
<p class="hint">Select a color then click to apply.</p> </div>
<div id="classic-replace-block" class="mt-2">
<div class="text-sm text-gray-700 classic-label">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="classic-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="classic-replace-to-chip" aria-label="Pick replacement color"></button>
<span id="classic-replace-count" class="text-xs text-slate-500 ml-auto"></span>
</div>
<div class="grid grid-cols-1 gap-2">
<select id="classic-replace-from" class="sr-only"></select>
<select id="classic-replace-to" class="sr-only"></select>
<button id="classic-replace-btn" class="btn-blue">Replace</button>
<p id="classic-replace-msg" class="hint"></p>
</div> </div>
</div> </div>
</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</button>
</div>
</div> </div>
</div> </div>
<div class="control-stack" data-mobile-tab="save"> <div class="control-stack" data-mobile-tab="save">
<div class="panel-heading">Save & Share</div> <div class="panel-heading">Save & Share</div>
<div class="panel-card space-y-3"> <div class="panel-card space-y-3">
<div class="flex flex-wrap gap-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-600" data-export="png">Export PNG</button>
<button class="btn-dark bg-blue-700" data-export="svg">Export SVG</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> <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> </div>
<p class="hint text-red-500">Classic JSON save/load coming soon.</p>
</div>
</div>
</aside> </aside>
<section id="classic-canvas-panel" <section id="classic-canvas-panel"
class="order-1 w-full lg:flex-1 flex flex-col items-stretch shadow-x3 rounded-2xl overflow-hidden bg-white"> class="order-1 w-full lg:flex-1 grid grid-rows-[1fr] lg:grid-rows-[minmax(0,1fr)] gap-2 shadow-x3 rounded-2xl overflow-hidden bg-white">
<div id="classic-display" <div id="classic-display"
class="rounded-xl" class="rounded-xl"
style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div> style="width:100%;height:72vh;border:1px solid #e5e7eb;background:#fff;overflow:auto;"></div>
<div id="classic-mobile-bar" class="mobile-action-bar hidden">
<div class="mobile-action-chip" id="classic-active-chip-floating" title="Active">
<span class="color-dot" id="classic-active-dot-floating"></span>
</div>
<div class="mobile-action-row">
<button type="button" class="mobile-action-btn" id="classic-undo-manual" 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="classic-redo-manual" 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="classic-pick-manual" aria-label="Eyedropper" aria-pressed="false">
<i class="fa-solid fa-eye-dropper" aria-hidden="true"></i>
<span>Pick</span>
</button>
<button type="button" class="mobile-action-btn" id="classic-erase-manual" aria-label="Toggle Erase" aria-pressed="false">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
<span>Erase</span>
</button>
<button type="button" class="mobile-action-btn danger" id="classic-clear-manual" aria-label="Clear">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
<span>Clear</span>
</button>
<button type="button" class="mobile-action-btn" id="classic-export-manual" aria-label="Export">
<i class="fa-solid fa-download" aria-hidden="true"></i>
<span>Export</span>
</button>
</div>
</div>
<div id="floating-topper-nudge" class="floating-nudge hidden"> <div id="floating-topper-nudge" class="floating-nudge hidden">
<div class="floating-nudge-header"> <div class="floating-nudge-header">
<div class="panel-heading">Nudge Topper</div> <div class="panel-heading">Nudge & Size Topper</div>
<button type="button" id="floating-nudge-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Close nudge panel">×</button> <button type="button" id="floating-nudge-toggle" class="btn-dark text-xs px-3 py-2" aria-label="Close nudge panel">×</button>
</div> </div>
<div class="floating-nudge-body"> <div class="floating-nudge-body space-y-3">
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div></div> <div></div>
<button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up"></button> <button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="0.5" aria-label="Move Topper Up"></button>
@ -413,11 +461,140 @@
<button type="button" class="btn-dark nudge-topper" data-dx="0" data-dy="-0.5" aria-label="Move Topper Down"></button> <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> </div>
<div>
<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">
</div>
</div> </div>
</div> </div>
</section> </section>
</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" checked>
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 modifier on desktop to erase temporarily.</p>
</div>
</div>
<div class="control-stack" data-mobile-tab="colors">
<div class="panel-heading">Wall Colors</div>
<div class="panel-card space-y-4">
<div class="space-y-2">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-700">Active color</span>
<div id="wall-active-color-chip" class="current-color-chip">
<span id="wall-active-color-label" class="text-xs font-semibold text-slate-700"></span>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">Project Palette</span>
</div>
<div id="wall-used-palette" class="palette-box min-h-[2.4rem]"></div>
</div>
<div class="space-y-2">
<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">
<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 class="space-y-2">
<div class="text-sm font-semibold text-gray-700">Color Library</div>
<div id="wall-palette" class="palette-box min-h-[3rem]"></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"
style="height:92%;">
<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>
<div id="mobile-tabbar" class="mobile-tabbar"> <div id="mobile-tabbar" class="mobile-tabbar">
@ -435,6 +612,71 @@
</button> </button>
</div> </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 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"> <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> <p id="modal-text" class="text-gray-800 text-lg"></p>
@ -444,9 +686,25 @@
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js" defer></script> <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="script.js" defer></script>
<script src="organic.js" defer></script>
<script src="wall.js" defer></script>
<script src="classic.js" defer></script> <script src="classic.js" defer></script>
<div id="classic-quad-modal" class="quad-modal hidden" aria-hidden="true">
<div class="quad-modal-backdrop"></div>
<div class="quad-modal-panel" role="dialog" aria-modal="true" aria-label="Quad detail">
<div class="quad-modal-header">
<div class="quad-modal-title">Quad Detail</div>
<button type="button" id="classic-quad-modal-close" class="btn-dark text-xs px-3 py-2">Close</button>
</div>
<div class="quad-modal-body">
<div id="classic-quad-modal-display" class="quad-modal-display"></div>
</div>
</div>
</div>
</body> </body>
</html> </html>

2166
organic.js Normal file

File diff suppressed because it is too large Load Diff

58
output_webp/0.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

59
output_webp/1.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

58
output_webp/2.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

58
output_webp/3.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

57
output_webp/4.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

57
output_webp/5.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

58
output_webp/6.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

57
output_webp/7.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

57
output_webp/8.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

57
output_webp/9.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

2528
script.js

File diff suppressed because it is too large Load Diff

233
shared.js Normal file
View File

@ -0,0 +1,233 @@
(() => {
'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();
const ACTIVE_COLOR_KEY = 'app:activeColor:v1';
let ACTIVE_COLOR_CACHE = null;
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 getActiveColor() {
if (ACTIVE_COLOR_CACHE) return ACTIVE_COLOR_CACHE;
try {
const saved = JSON.parse(localStorage.getItem(ACTIVE_COLOR_KEY));
if (saved && saved.hex) {
ACTIVE_COLOR_CACHE = { hex: normalizeHex(saved.hex), image: saved.image || null };
return ACTIVE_COLOR_CACHE;
}
} catch {}
ACTIVE_COLOR_CACHE = { hex: '#ff6b6b', image: null };
return ACTIVE_COLOR_CACHE;
}
function setActiveColor(color) {
const clean = { hex: normalizeHex(color?.hex || '#ffffff'), image: color?.image || null };
ACTIVE_COLOR_CACHE = clean;
try { localStorage.setItem(ACTIVE_COLOR_KEY, JSON.stringify(clean)); } catch {}
return clean;
}
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,
getActiveColor,
setActiveColor
};
});
})();

407
style.css
View File

@ -1,7 +1,43 @@
/* Minimal extras (Tailwind handles most styling) */ /* Minimal extras (Tailwind handles most styling) */
body { color: #1f2937; } 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;
height: 95%}
.classic-expanded-canvas {
height: 130vh !important;
min-height: 130vh;
overflow: auto;
}
#balloon-canvas { touch-action: none; }
.balloon-canvas { .balloon-canvas {
background: #fff; background: #fff;
@ -12,6 +48,31 @@ body { color: #1f2937; }
} }
/* Buttons */ /* 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 { .tool-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@ -67,12 +128,14 @@ body { color: #1f2937; }
flex-direction: column; flex-direction: column;
gap: .5rem; gap: .5rem;
padding: .5rem; padding: .5rem;
background: rgba(255,255,255,0.6); /* More transparent */ background: rgba(255,255,255,0.82);
border: 1px solid #e5e7eb; border: 1px solid rgba(226,232,240,0.9);
border-radius: .75rem; border-radius: .9rem;
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
touch-action: pan-y;
} }
.swatch { .swatch {
@ -109,6 +172,8 @@ body { color: #1f2937; }
.swatch-row { display:flex; flex-wrap:wrap; gap:.5rem; } .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; } .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 { .badge {
position:absolute; position:absolute;
@ -187,6 +252,34 @@ body { color: #1f2937; }
} }
.slot-swatch.active::after { display: none; } .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 { .topper-type-group {
display: flex; display: flex;
gap: .5rem; gap: .5rem;
@ -272,13 +365,13 @@ body { color: #1f2937; }
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.panel-card { .panel-card {
background: rgba(255,255,255,0.7); background: rgba(255,255,255,0.82);
backdrop-filter: blur(12px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.6); border: 1px solid rgba(226,232,240,0.9);
border-radius: 1rem; border-radius: 1rem;
padding: 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 { .control-stack {
display: flex; display: flex;
@ -303,6 +396,7 @@ body { color: #1f2937; }
z-index: 30; z-index: 30;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1); transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
height: 92%;
} }
.control-sheet.hidden { display: none; } .control-sheet.hidden { display: none; }
.control-sheet.minimized { transform: translateY(100%); } .control-sheet.minimized { transform: translateY(100%); }
@ -338,18 +432,243 @@ body { color: #1f2937; }
@media (max-width: 1023px) { @media (max-width: 1023px) {
body { padding-bottom: 0; overflow: auto; } body { padding-bottom: 0; overflow: auto; }
html, body { height: auto; 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: 6.5rem;
}
/* Stack switching: show only the active mobile tab stack across panels */
.control-sheet .control-stack { display: none; } .control-sheet .control-stack { display: none; }
body[data-mobile-tab="controls"] #controls-panel [data-mobile-tab="controls"], 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="colors"] #controls-panel [data-mobile-tab="colors"],
body[data-mobile-tab="save"] #controls-panel [data-mobile-tab="save"], 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="controls"] #classic-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #classic-controls-panel [data-mobile-tab="colors"], 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="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; display: block;
} }
.control-sheet { bottom: 4.5rem; max-height: 55vh; }
.control-sheet.minimized { transform: translateY(115%); }
/* 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; }
.manual-hub { position: sticky; top: 0; z-index: 6; }
.manual-detail-stage { min-height: 260px; }
} }
.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: 20; /* below control sheets (30) and modals (60) */
}
.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);
}
.manual-hub {
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(240,249,255,0.92));
border: 1px solid rgba(226,232,240,0.9);
border-radius: 1rem;
padding: 0.9rem 1rem;
box-shadow: 0 12px 28px rgba(15,23,42,0.08);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.manual-hub-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.manual-hub-title { font-weight: 800; color: #0f172a; letter-spacing: -0.015em; }
.manual-hub-subtitle { font-size: 0.85rem; color: #475569; line-height: 1.3; }
.manual-hub-actions { display: flex; gap: 0.35rem; }
.manual-hub-track {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.55rem;
}
.manual-hub-count { grid-column: 1 / -1; text-align: right; }
.manual-range {
width: 100%;
accent-color: #2563eb;
}
.manual-detail {
display: none;
}
.manual-detail-stage { display: none; }
.manual-detail-empty { display: none; }
.manual-hub-hint { line-height: 1.4; }
.chip-btn {
background: rgba(255,255,255,0.85);
border: 1px solid rgba(148,163,184,0.4);
border-radius: 999px;
padding: 0.45rem 0.75rem;
font-weight: 700;
font-size: 0.85rem;
color: #0f172a;
box-shadow: 0 6px 16px rgba(15,23,42,0.08);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.chip-btn:active { transform: translateY(1px); }
.chip-btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.mobile-tabbar { .mobile-tabbar {
position: fixed; position: fixed;
inset-inline: 0; inset-inline: 0;
@ -367,6 +686,32 @@ body { color: #1f2937; }
box-shadow: 0 -6px 30px rgba(15, 23, 42, 0.12); box-shadow: 0 -6px 30px rgba(15, 23, 42, 0.12);
border-top: 1px solid rgba(148, 163, 184, 0.25); border-top: 1px solid rgba(148, 163, 184, 0.25);
} }
.mobile-tabbar.hidden { display: none; }
@media (min-width: 1024px) {
.mobile-tabbar { display: none !important; }
}
@media (max-width: 1023px) {
/* Tuck canvases above the tabbar */
#classic-display,
#wall-display,
#balloon-canvas {
margin-bottom: 0;
height: calc(100vh - 190px) !important; /* tie to viewport minus header/controls */
max-height: calc(100vh - 190px) !important;
}
#classic-display{
height: 92%;
}
/* Keep the main canvas panels above the tabbar/action bar */
#canvas-panel,
#classic-canvas-panel {
padding-bottom: 12vh;
}
#classic-canvas-panel {
padding-bottom: 14vh; /* leave space for action bar */
}
}
.mobile-tabbar .mobile-tab-btn { .mobile-tabbar .mobile-tab-btn {
flex: 1 1 0; flex: 1 1 0;
display: flex; display: flex;
@ -399,6 +744,39 @@ body { color: #1f2937; }
transform: translateY(-2px); transform: translateY(-2px);
} }
.canvas-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: .6rem .85rem;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(90deg, #f8fafc, #fff);
position: sticky;
top: 0;
z-index: 5;
}
.canvas-toolbar .toolbar-left,
.canvas-toolbar .toolbar-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .5rem;
}
.canvas-toolbar button { white-space: nowrap; }
.quad-modal{position:fixed;inset:0;z-index:999;display:flex;align-items:center;justify-content:center;pointer-events:none;}
.quad-modal.hidden{display:none;}
.quad-modal:not(.hidden){pointer-events:auto;}
.quad-modal-backdrop{position:absolute;inset:0;background:rgba(15,23,42,0.45);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);}
.quad-modal-panel{position:relative;z-index:1;pointer-events:auto;background:#fff;border-radius:1rem;padding:1rem;box-shadow:0 22px 50px rgba(15,23,42,0.25);width:min(560px,90vw);}
.quad-modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem;}
.quad-modal-title{font-weight:700;font-size:1rem;color:#0f172a;}
.quad-modal-body{border:1px solid #e5e7eb;border-radius:.75rem;overflow:hidden;background:#f8fafc;}
.quad-modal-display{width:100%;height:360px;max-height:70vh;position:relative;background:#fff;perspective:1200px;}
.quad-modal-display svg{transform-style:preserve-3d;transition:transform 240ms ease, opacity 200ms ease;}
.quad-modal-display svg{width:100%;height:100%;}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.control-sheet { .control-sheet {
@ -406,7 +784,7 @@ body { color: #1f2937; }
top: 7rem; top: 7rem;
bottom: auto; bottom: auto;
width: 340px; width: 340px;
max-height: calc(100vh - 8rem); max-height: calc(93vh - 8rem);
border-radius: 1.5rem; border-radius: 1.5rem;
position: sticky; position: sticky;
overflow-y: auto; overflow-y: auto;
@ -414,6 +792,7 @@ body { color: #1f2937; }
border: 1px solid rgba(255,255,255,0.4); border: 1px solid rgba(255,255,255,0.4);
} }
body { padding-bottom: 0; overflow: auto; } body { padding-bottom: 0; overflow: auto; }
.mobile-action-bar { display: none !important; }
} }
/* Compact viewport fallback */ /* Compact viewport fallback */

18
svg.sh Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Backup originals once
mkdir -p output_webp_backup
cp --no-clobber output_webp/*.svg output_webp_backup/ || true
# Convert strokes to filled shapes and force a solid fill
FILL="#ffffff" # solid white interior for mask; stroke stays black for outline
for f in output_webp/*.svg; do
echo "Fixing $f"
inkscape "$f" --batch-process \
--actions="select-all;object-stroke-to-path;object-to-path;object-set-attribute:fill,$FILL;object-set-attribute:stroke,#000000;object-set-attribute:fill-rule,evenodd;object-set-attribute:style," \
--export-type=svg --export-filename="$f" --export-overwrite
done
echo "Done. Originals in output_webp_backup/"

1309
wall.js Normal file

File diff suppressed because it is too large Load Diff