Add PWA manifest, world bands with night mode, milestones, touch drag, and lab QoL
- World view: three stacked sky/land/sea bands, night palette 22h-5h
- manifest.webmanifest + theme-color; sw cache bumped to v2
- Touch drag & drop via pointer events (mouse DnD kept)
- Milestone toasts at 25/50/75/100% discovery
- Search matches name, description, and category
- Lab: Enter to combine, clear-slots button, last-5 combo chips
- Secret discoveries: full-screen flash + golden card border
- Category accent colors on cards and filter chips
- Serif display type for results, journal titles, daily goal
- Desktop sticky creation panel; mobile sheet mid snap point;
compact layout for short landscape screens
author
Pablo Murad
date
24 Aug 2026, 01:27 -0300
commit
d41322c65c7bf5a6c3be7f706c870ddb7ead26ed
parent
3119c4f351880bb78fcb0fdbb896f788fcadb77f
changes
7 files +370 −41
@@ -2,6 +2,23 @@ # Changelog
All notable changes to EMOJESIS. Dates are UTC.
+## [Unreleased]
+
+- World view rebuilt as three stacked bands (sky / land / sea) with a
+ night palette between 22h and 5h.
+- Installable PWA: `manifest.webmanifest` plus theme-color.
+- Touch drag & drop via pointer events (HTML5 DnD stays for mouse).
+- Progress milestone toasts at 25%, 50%, 75% and 100% of all elements.
+- Collection search now matches descriptions and categories, not just names.
+- Laboratory: Enter combines, a ✕ button clears both slots, and the last
+ 5 successful combinations appear as refill chips (session-only).
+- Secret discoveries get a full-screen flash and a golden card border.
+- Category accent colors on collection cards and filter chips.
+- Serif display type for results, journal titles, and the daily goal.
+- Desktop: creation panel is sticky; the collection grows with the page.
+- Mobile: inventory sheet gains a half-open snap point; world strip and
+ header compact on short landscape screens.
+
## [1.0.0] — 2026-07-30
First release.
@@ -45,6 +45,7 @@ css/style.css layout + themes js/data.js elements + recipes (110 / 168)
js/app.js game logic
sw.js offline cache
+ manifest.webmanifest installable PWA metadata
validate.js data integrity checks
feed.xml atom feed
CHANGELOG.md release notes
@@ -14,6 +14,20 @@ --slot-empty: #e7e2d3; --shadow: 0 1px 0 rgba(32, 29, 23, 0.08);
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
+ --serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
+ /* category accents */
+ --cat-nature: #5c8a3c;
+ --cat-weather: #5a8fc0;
+ --cat-life: #4ca06a;
+ --cat-animals: #a9743c;
+ --cat-humanity: #b0584e;
+ --cat-civilization: #8a7a4a;
+ --cat-culture: #9a5a8c;
+ --cat-technology: #5a7a9c;
+ --cat-internet: #4a8a8c;
+ --cat-occult: #6a5a8c;
+ --cat-cosmos: #4a5a9c;
+ --cat-absurd: #b0763c;
}
html[data-theme="dark"] {
@@ -177,7 +191,21 @@ border-radius: 50%; background: var(--accent);
}
-/* ---------- world strip ---------- */
+/* category accent resolution — works on cards, chips, anything with data-cat */
+[data-cat="nature"] { --cat: var(--cat-nature); }
+[data-cat="weather"] { --cat: var(--cat-weather); }
+[data-cat="life"] { --cat: var(--cat-life); }
+[data-cat="animals"] { --cat: var(--cat-animals); }
+[data-cat="humanity"] { --cat: var(--cat-humanity); }
+[data-cat="civilization"] { --cat: var(--cat-civilization); }
+[data-cat="culture"] { --cat: var(--cat-culture); }
+[data-cat="technology"] { --cat: var(--cat-technology); }
+[data-cat="internet"] { --cat: var(--cat-internet); }
+[data-cat="occult"] { --cat: var(--cat-occult); }
+[data-cat="cosmos"] { --cat: var(--cat-cosmos); }
+[data-cat="absurd"] { --cat: var(--cat-absurd); }
+
+/* ---------- world strip: three stacked bands (sky / land / sea) ---------- */
.world {
border-bottom: 1px solid var(--line);
background: var(--bg-raised);
@@ -193,13 +221,24 @@ min-height: 32px; }
.world-strip {
display: flex;
+ flex-direction: column;
+ padding-bottom: 0.3rem;
+}
+.world.collapsed .world-strip { display: none; }
+.w-band {
+ display: flex;
gap: 0.5rem;
- align-items: flex-end;
+ align-items: center;
overflow-x: auto;
- padding: 0.2rem 1rem 0.6rem;
- min-height: 56px;
+ padding: 0.3rem 1rem;
+ min-height: 52px;
}
-.world.collapsed .world-strip { display: none; }
+.w-band-sky { background: color-mix(in srgb, var(--bg-raised) 55%, #6fa8d8 45%); }
+.w-band-land { background: color-mix(in srgb, var(--bg-raised) 62%, #7d9a5c 38%); }
+.w-band-sea { background: color-mix(in srgb, var(--bg-raised) 48%, #3d6f9e 52%); }
+.world.night .w-band-sky { background: linear-gradient(#0b1026, #1a2242); }
+.world.night .w-band-land { background: #141a12; }
+.world.night .w-band-sea { background: #0e1c2c; }
.w-slot {
flex: 0 0 auto;
width: 44px;
@@ -315,8 +354,9 @@ gap: 0.2rem; }
.result-emoji { font-size: 3.2rem; line-height: 1; }
.result-name {
- font-family: var(--mono);
- font-size: 0.85rem;
+ font-family: var(--serif);
+ font-size: 1.05rem;
+ font-style: italic;
color: var(--ink-soft);
}
.message {
@@ -361,6 +401,55 @@ .result.fx-cosmic { animation: fxcosmic 0.7s ease; } @keyframes fxspooky { 0% { opacity: 0; } 30% { opacity: 1; } 45% { opacity: 0.3; } 60% { opacity: 1; } 100% { opacity: 1; } }
.result.fx-spooky { animation: pop 0.45s ease, fxspooky 0.7s ease; }
+/* full-screen flash for secret discoveries */
+.secret-flash {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 70;
+ opacity: 0;
+ background: radial-gradient(circle, color-mix(in srgb, var(--gold) 45%, transparent), transparent 70%);
+}
+.secret-flash.fx-cosmic { background: radial-gradient(circle, rgba(120, 80, 200, 0.55), transparent 70%); }
+.secret-flash.fx-flare { background: radial-gradient(circle, rgba(230, 120, 40, 0.5), transparent 70%); }
+.secret-flash.fx-spooky { background: radial-gradient(circle, rgba(90, 140, 110, 0.5), transparent 70%); }
+.secret-flash.fx-zap,
+.secret-flash.fx-shake { background: radial-gradient(circle, rgba(230, 220, 120, 0.45), transparent 70%); }
+.secret-flash.active { animation: secretFlash 0.9s ease; }
+@keyframes secretFlash {
+ 0% { opacity: 0; }
+ 25% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+/* recent combinations */
+.combo-history {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+.combo-history:empty { display: none; }
+.hist-chip {
+ border: 1px solid var(--line);
+ background: var(--bg);
+ border-radius: 999px;
+ padding: 0.2rem 0.55rem;
+ font-size: 0.95rem;
+ min-height: 32px;
+}
+.hist-chip:hover { border-color: var(--ink); }
+
+/* touch drag ghost */
+.drag-ghost {
+ position: fixed;
+ z-index: 90;
+ pointer-events: none;
+ font-size: 2rem;
+ transform: translate(-50%, -50%);
+ opacity: 0.9;
+}
+
/* ---------- inventory ---------- */
.inventory {
align-self: start;
@@ -421,6 +510,16 @@ background: var(--ink); color: var(--bg);
border-color: var(--ink);
}
+.chip[data-cat]::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--cat);
+ margin-right: 0.3rem;
+ vertical-align: 1px;
+}
.inv-cats {
display: flex;
flex-wrap: wrap;
@@ -443,8 +542,20 @@ gap: 0.15rem; border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg);
- padding: 0.5rem 0.3rem 0.4rem;
+ padding: 0.5rem 0.3rem 0.55rem;
}
+.inv-item::after {
+ content: "";
+ position: absolute;
+ bottom: 3px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 14px;
+ height: 3px;
+ border-radius: 2px;
+ background: var(--cat, var(--line));
+}
+.inv-item.secret { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
.inv-item.is-new { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.inv-main {
background: none;
@@ -456,6 +567,7 @@ gap: 0.15rem; width: 100%;
min-height: 44px;
cursor: grab;
+ touch-action: pan-y;
}
.inv-main:active { cursor: grabbing; }
.inv-emoji { font-size: 1.8rem; line-height: 1; }
@@ -567,7 +679,7 @@ border: 1px solid var(--danger); border-radius: 8px;
padding: 0.7rem;
}
-.daily-goal { font-size: 1.05rem; }
+.daily-goal { font-size: 1.05rem; font-family: var(--serif); }
.share-block {
background: var(--bg);
border: 1px solid var(--line);
@@ -593,7 +705,7 @@ gap: 0.8rem; margin-bottom: 0.6rem;
}
.journal-emoji { font-size: 2.6rem; }
-.journal-head h2 { margin: 0; }
+.journal-head h2 { margin: 0; font-family: var(--serif); }
.journal-meta {
font-family: var(--mono);
font-size: 0.72rem;
@@ -713,6 +825,12 @@ .w-slot.found.anim-fall span { animation: fall 2.2s ease-in-out infinite; } .w-slot.found.anim-rise span { animation: rise 2.6s ease-in-out infinite; }
/* ---------- responsive ---------- */
+@media (min-width: 861px) {
+ /* creation panel stays in view while the collection grows with the page */
+ .creation { position: sticky; top: 1rem; }
+ .inv-grid { max-height: none; overflow-y: visible; }
+}
+
@media (max-width: 860px) {
main {
grid-template-columns: 1fr;
@@ -732,6 +850,7 @@ transform: translateY(calc(100% - 52px)); transition: transform 0.25s ease;
}
.inventory.open { transform: translateY(0); }
+ .inventory.mid { transform: translateY(55%); }
.inventory.collapsed .inv-body { display: block; }
.inv-body { overflow-y: auto; }
.inv-grid { max-height: none; }
@@ -739,6 +858,18 @@ .inv-toggle { text-align: center; } main { padding-bottom: 64px; }
}
+/* ---------- short landscape screens ---------- */
+@media (max-height: 500px) and (orientation: landscape) {
+ .site-header { padding: 0.3rem 0.8rem; }
+ .world { display: none; }
+ main { padding: 0.5rem; gap: 0.6rem; }
+ .creation { padding: 0.7rem; gap: 0.5rem; }
+ .slot { width: 72px; height: 72px; }
+ .slot-emoji { font-size: 1.9rem; }
+ .result { min-height: 56px; }
+ .result-emoji { font-size: 2.2rem; }
+}
+
/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
@@ -748,5 +879,5 @@ transition-duration: 0.001s !important; }
.slot.fusing, .result.reveal, .result.fx-zap, .result.fx-flare,
.result.fx-shake, .result.fx-cosmic, .result.fx-spooky,
- .inv-item.ob-pulse { animation: none !important; }
+ .inv-item.ob-pulse, .secret-flash.active { animation: none !important; }
}
@@ -6,6 +6,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <title>EMOJESIS — a tiny universe made of symbols</title>
<meta name="description" content="EMOJESIS: a tiny universe made of symbols. Combine four primordial emojis into weather, life, civilization, and stranger things. No accounts, no tracking, no ads.">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
+ <link rel="manifest" href="manifest.webmanifest">
+ <meta name="theme-color" content="#a3501c">
<link rel="alternate" type="application/atom+xml" title="EMOJESIS updates" href="feed.xml">
<link rel="stylesheet" href="css/style.css">
</head>
@@ -36,28 +38,34 @@ <!-- World view: a quiet sky/land/sea strip that fills in as you discover --> <section id="world" class="world" aria-label="Your world">
<button id="worldToggle" class="world-toggle" aria-expanded="true" aria-controls="worldStrip">🗺️ world</button>
<div id="worldStrip" class="world-strip">
- <div class="w-slot w-sky" data-el="sun"><span>☀️</span></div>
- <div class="w-slot w-sky" data-el="moon"><span>🌙</span></div>
- <div class="w-slot w-sky anim-twinkle" data-el="star"><span>⭐</span></div>
- <div class="w-slot w-sky anim-drift" data-el="cloud"><span>☁️</span></div>
- <div class="w-slot w-sky anim-fall" data-el="rain"><span>🌧️</span></div>
- <div class="w-slot w-sky" data-el="rainbow"><span>🌈</span></div>
- <div class="w-slot w-sky anim-drift" data-el="satellite"><span>🛰️</span></div>
- <div class="w-slot w-sky anim-hover" data-el="ufo"><span>🛸</span></div>
- <div class="w-slot w-air anim-flap" data-el="bird"><span>🐦</span></div>
- <div class="w-slot w-air anim-flap" data-el="owl"><span>🦉</span></div>
- <div class="w-slot w-land" data-el="mountain"><span>⛰️</span></div>
- <div class="w-slot w-land anim-sway" data-el="tree"><span>🌳</span></div>
- <div class="w-slot w-land anim-sway" data-el="forest"><span>🌲</span></div>
- <div class="w-slot w-land anim-sway" data-el="flower"><span>🌸</span></div>
- <div class="w-slot w-land" data-el="house"><span>🏠</span></div>
- <div class="w-slot w-land" data-el="city"><span>🏙️</span></div>
- <div class="w-slot w-land anim-hover" data-el="robot"><span>🤖</span></div>
- <div class="w-slot w-land anim-rise" data-el="rocket"><span>🚀</span></div>
- <div class="w-slot w-sea anim-bob" data-el="fish"><span>🐟</span></div>
- <div class="w-slot w-sea anim-bob" data-el="whale"><span>🐋</span></div>
- <div class="w-slot w-sea" data-el="ocean"><span>🌊</span></div>
- <div class="w-slot w-misc anim-hover" data-el="ghost"><span>👻</span></div>
+ <div class="w-band w-band-sky">
+ <div class="w-slot w-sky" data-el="sun"><span>☀️</span></div>
+ <div class="w-slot w-sky" data-el="moon"><span>🌙</span></div>
+ <div class="w-slot w-sky anim-twinkle" data-el="star"><span>⭐</span></div>
+ <div class="w-slot w-sky anim-drift" data-el="cloud"><span>☁️</span></div>
+ <div class="w-slot w-sky anim-fall" data-el="rain"><span>🌧️</span></div>
+ <div class="w-slot w-sky" data-el="rainbow"><span>🌈</span></div>
+ <div class="w-slot w-sky anim-drift" data-el="satellite"><span>🛰️</span></div>
+ <div class="w-slot w-sky anim-hover" data-el="ufo"><span>🛸</span></div>
+ <div class="w-slot w-air anim-flap" data-el="bird"><span>🐦</span></div>
+ <div class="w-slot w-air anim-flap" data-el="owl"><span>🦉</span></div>
+ </div>
+ <div class="w-band w-band-land">
+ <div class="w-slot w-land" data-el="mountain"><span>⛰️</span></div>
+ <div class="w-slot w-land anim-sway" data-el="tree"><span>🌳</span></div>
+ <div class="w-slot w-land anim-sway" data-el="forest"><span>🌲</span></div>
+ <div class="w-slot w-land anim-sway" data-el="flower"><span>🌸</span></div>
+ <div class="w-slot w-land" data-el="house"><span>🏠</span></div>
+ <div class="w-slot w-land" data-el="city"><span>🏙️</span></div>
+ <div class="w-slot w-land anim-hover" data-el="robot"><span>🤖</span></div>
+ <div class="w-slot w-land anim-rise" data-el="rocket"><span>🚀</span></div>
+ <div class="w-slot w-misc anim-hover" data-el="ghost"><span>👻</span></div>
+ </div>
+ <div class="w-band w-band-sea">
+ <div class="w-slot w-sea anim-bob" data-el="fish"><span>🐟</span></div>
+ <div class="w-slot w-sea anim-bob" data-el="whale"><span>🐋</span></div>
+ <div class="w-slot w-sea" data-el="ocean"><span>🌊</span></div>
+ </div>
</div>
</section>
@@ -79,7 +87,9 @@ <div class="creation-actions"> <button id="btnSwap" class="abtn" aria-label="Swap the two ingredients" title="Swap" disabled>⇄</button>
<button id="btnCombine" class="combine-btn" disabled>Combine ✨</button>
<button id="btnRepeat" class="abtn" aria-label="Repeat last successful combination" title="Repeat last combination" disabled>⟳</button>
+ <button id="btnClear" class="abtn" aria-label="Clear both slots" title="Clear slots" disabled>✕</button>
</div>
+ <div id="comboHistory" class="combo-history" aria-label="Recent combinations"></div>
<div id="result" class="result" aria-hidden="true">
<span id="resultEmoji" class="result-emoji"></span>
<span id="resultName" class="result-name"></span>
@@ -227,6 +237,8 @@ <p class="ob-hint">Try <span class="mono">🔥 + 💧</span> — or don't. The universe is patient.</p> <button id="btnBegin" class="combine-btn">Begin</button>
</div>
</div>
+
+ <div id="secretFlash" class="secret-flash" aria-hidden="true"></div>
<div id="toasts" class="toasts" aria-hidden="false"></div>
@@ -97,7 +97,8 @@ settings: { theme: 'auto', sound: true }, stats: { fails: 0, successes: 0, hintsUsed: 0, secrets: 0, dailyDone: 0, playMs: 0, combos: {} },
lastActive: Date.now(),
firstRun: true,
- eggClicks: 0
+ eggClicks: 0,
+ milestones: []
};
}
@@ -222,6 +223,7 @@ }); var both = slots[0] && slots[1];
$('btnCombine').disabled = !both;
$('btnSwap').disabled = !(slots[0] || slots[1]);
+ $('btnClear').disabled = !(slots[0] || slots[1]);
$('btnRepeat').disabled = !(lastCombo && has(lastCombo.a) && has(lastCombo.b));
}
@@ -245,6 +247,35 @@ touch(); }
}
+ /* ---------- recent combinations (session-only) ---------- */
+ var comboHistory = []; // [{a, b}], most recent first
+
+ function pushHistory(a, b) {
+ comboHistory = comboHistory.filter(function (c) { return !(c.a === a && c.b === b); });
+ comboHistory.unshift({ a: a, b: b });
+ if (comboHistory.length > 5) comboHistory.pop();
+ renderHistory();
+ }
+
+ function renderHistory() {
+ var box = $('comboHistory');
+ box.innerHTML = '';
+ comboHistory.forEach(function (c) {
+ if (!has(c.a) || !has(c.b)) return;
+ var b = el('button', 'hist-chip', ELEMENTS[c.a].emoji + ' + ' + ELEMENTS[c.b].emoji);
+ b.title = ELEMENTS[c.a].name + ' + ' + ELEMENTS[c.b].name;
+ b.setAttribute('aria-label', 'Refill slots with ' + ELEMENTS[c.a].name + ' and ' + ELEMENTS[c.b].name);
+ b.addEventListener('click', function () {
+ slots[0] = c.a;
+ slots[1] = c.b;
+ playSound('select');
+ renderSlots();
+ touch();
+ });
+ box.appendChild(b);
+ });
+ }
+
function combine() {
if (!slots[0] || !slots[1]) return;
var a = slots[0], b = slots[1];
@@ -273,6 +304,7 @@ return; }
lastCombo = { a: a, b: b };
+ pushHistory(a, b);
var resultId = r.result;
var already = has(resultId);
state.stats.successes++;
@@ -321,7 +353,7 @@ var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|'); if (state.recipes.indexOf(key) === -1) state.recipes.push(key);
var isSecret = !!(r.secret || ELEMENTS[id].secret);
- if (isSecret) { state.stats.secrets++; playSound('secret'); }
+ if (isSecret) { state.stats.secrets++; playSound('secret'); secretFlash(r.fx); }
else playSound('discover');
var msg = r.msg;
@@ -331,11 +363,41 @@ } setMessage(msg, true);
updateDaily(id);
+ checkMilestones();
renderInventory();
updateWorld();
updateDailyLine();
}
+ /* full-screen flash ceremony for secret discoveries */
+ function secretFlash(fx) {
+ if (reducedMotion()) return;
+ var f = $('secretFlash');
+ f.className = 'secret-flash';
+ void f.offsetWidth; // restart animation
+ f.classList.add('active');
+ if (fx) f.classList.add('fx-' + fx);
+ setTimeout(function () { f.className = 'secret-flash'; }, 1000);
+ }
+
+ /* ---------- progress milestones ---------- */
+ var MILESTONES = [0.25, 0.5, 0.75, 1];
+
+ function checkMilestones() {
+ var total = Object.keys(ELEMENTS).length;
+ var pct = state.elements.length / total;
+ MILESTONES.forEach(function (m) {
+ if (pct >= m && state.milestones.indexOf(m) === -1) {
+ state.milestones.push(m);
+ var label = m === 1
+ ? '100% — every symbol discovered. The universe is yours. 🌌'
+ : Math.round(m * 100) + '% of all symbols discovered.';
+ toast('✨ ' + label);
+ playSound('secret');
+ }
+ });
+ }
+
function bumpCombo(id) {
state.stats.combos[id] = (state.stats.combos[id] || 0) + 1;
}
@@ -480,6 +542,8 @@ }, 30000);
/* ---------- inventory ---------- */
var invFilter = { search: '', category: 'all', favOnly: false, showUnknown: false, sort: 'order' };
+ var suppressNextClick = false; // set after a touch-drag so the trailing click doesn't also fire
+ var ptrDrag = null; // active pointer drag: { pointerId, x, y, dragging, ghost, id }
function unlockedCategories() {
var cats = {};
@@ -493,6 +557,7 @@ box.innerHTML = ''; var mk = function (value, label, pressed) {
var b = el('button', 'chip', label);
b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
+ if (value !== 'all') b.dataset.cat = value;
b.addEventListener('click', function () {
invFilter.category = value;
renderInventory();
@@ -525,7 +590,12 @@ var ids = state.elements.slice();
if (invFilter.search) {
var q = invFilter.search.toLowerCase();
- ids = ids.filter(function (id) { return ELEMENTS[id].name.toLowerCase().indexOf(q) !== -1; });
+ ids = ids.filter(function (id) {
+ var e = ELEMENTS[id];
+ return e.name.toLowerCase().indexOf(q) !== -1 ||
+ e.description.toLowerCase().indexOf(q) !== -1 ||
+ e.category.toLowerCase().indexOf(q) !== -1;
+ });
}
if (invFilter.category !== 'all') {
ids = ids.filter(function (id) { return ELEMENTS[id].category === invFilter.category; });
@@ -561,6 +631,8 @@ function buildCard(id) { var e = ELEMENTS[id];
var card = el('div', 'inv-item');
card.dataset.id = id;
+ card.dataset.cat = e.category;
+ if (e.secret) card.classList.add('secret');
if (state.newItems.indexOf(id) !== -1) card.classList.add('is-new');
if (state.firstRun && (id === 'fire' || id === 'water')) card.classList.add('ob-pulse');
@@ -572,6 +644,7 @@ em.setAttribute('aria-hidden', 'true'); main.appendChild(em);
main.appendChild(el('span', 'inv-name', e.name));
main.addEventListener('click', function () {
+ if (suppressNextClick) { suppressNextClick = false; return; }
if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); }
fillNextSlot(id);
});
@@ -579,6 +652,7 @@ main.addEventListener('dragstart', function (ev) { ev.dataTransfer.setData('text/plain', id);
ev.dataTransfer.effectAllowed = 'copy';
});
+ wireTouchDrag(main, id, e);
var fav = el('button', 'inv-fav', state.favorites.indexOf(id) !== -1 ? '⭐' : '☆');
if (state.favorites.indexOf(id) !== -1) fav.classList.add('on');
@@ -595,8 +669,57 @@ card.appendChild(info); return card;
}
+ /* touch/pen drag: HTML5 DnD is mouse-only, so mirror it with pointer events */
+ function wireTouchDrag(main, id, e) {
+ main.addEventListener('pointerdown', function (ev) {
+ if (ev.pointerType === 'mouse') return;
+ ptrDrag = { pointerId: ev.pointerId, x: ev.clientX, y: ev.clientY, dragging: false, ghost: null };
+ });
+ main.addEventListener('pointermove', function (ev) {
+ if (!ptrDrag || ev.pointerId !== ptrDrag.pointerId) return;
+ var dx = ev.clientX - ptrDrag.x, dy = ev.clientY - ptrDrag.y;
+ if (!ptrDrag.dragging && Math.sqrt(dx * dx + dy * dy) > 10) {
+ ptrDrag.dragging = true;
+ ptrDrag.ghost = el('div', 'drag-ghost', e.emoji);
+ ptrDrag.ghost.setAttribute('aria-hidden', 'true');
+ document.body.appendChild(ptrDrag.ghost);
+ }
+ if (ptrDrag.dragging) {
+ ptrDrag.ghost.style.left = ev.clientX + 'px';
+ ptrDrag.ghost.style.top = ev.clientY + 'px';
+ var over = document.elementFromPoint(ev.clientX, ev.clientY);
+ slotEls().forEach(function (s) {
+ s.classList.toggle('drop-hover', !!(over && s.contains(over)));
+ });
+ }
+ });
+ var endDrag = function (ev, cancelled) {
+ if (!ptrDrag || ev.pointerId !== ptrDrag.pointerId) return;
+ var wasDragging = ptrDrag.dragging;
+ if (ptrDrag.ghost) ptrDrag.ghost.remove();
+ slotEls().forEach(function (s) { s.classList.remove('drop-hover'); });
+ ptrDrag = null;
+ if (!wasDragging || cancelled) return;
+ suppressNextClick = true;
+ var over = document.elementFromPoint(ev.clientX, ev.clientY);
+ var slot = over && over.closest ? over.closest('.slot') : null;
+ var i = slot ? slotEls().indexOf(slot) : -1;
+ if (i !== -1 && has(id)) {
+ slots[i] = id;
+ playSound('select');
+ markSeen(id);
+ if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); }
+ renderSlots();
+ touch();
+ }
+ };
+ main.addEventListener('pointerup', function (ev) { endDrag(ev, false); });
+ main.addEventListener('pointercancel', function (ev) { endDrag(ev, true); });
+ }
+
function buildUnknownCard(e) {
var card = el('div', 'inv-item');
+ card.dataset.cat = e.category;
var main = el('button', 'inv-main');
main.setAttribute('aria-label', 'Undiscovered element in category ' + e.category);
var em = el('span', 'inv-emoji', '❓');
@@ -694,6 +817,7 @@ function updateWorld() { document.querySelectorAll('.w-slot').forEach(function (slot) {
slot.classList.toggle('found', has(slot.dataset.el));
});
+ $('world').classList.toggle('night', isNight());
}
/* ---------- stats ---------- */
@@ -922,6 +1046,21 @@ playSound('select'); renderSlots();
touch();
});
+ $('btnClear').addEventListener('click', function () {
+ slots[0] = null;
+ slots[1] = null;
+ playSound('select');
+ renderSlots();
+ touch();
+ });
+
+ // Enter combines when both slots are filled and focus isn't on a control
+ document.addEventListener('keydown', function (ev) {
+ if (ev.key !== 'Enter') return;
+ if (ev.target.closest && ev.target.closest('button, input, select, textarea, a, .modal, .onboarding')) return;
+ if (slots[0] && slots[1]) combine();
+ });
+
$('btnRepeat').addEventListener('click', function () {
if (lastCombo && has(lastCombo.a) && has(lastCombo.b)) {
slots[0] = lastCombo.a;
@@ -960,8 +1099,17 @@ // inventory drawer (mobile) / collapse (desktop) $('invToggle').addEventListener('click', function () {
var inv = $('inventory');
if (window.matchMedia('(max-width: 860px)').matches) {
- inv.classList.toggle('open');
- $('invToggle').setAttribute('aria-expanded', inv.classList.contains('open') ? 'true' : 'false');
+ // three snap points: closed → mid → open → closed
+ if (inv.classList.contains('open')) {
+ inv.classList.remove('open');
+ } else if (inv.classList.contains('mid')) {
+ inv.classList.remove('mid');
+ inv.classList.add('open');
+ } else {
+ inv.classList.add('mid');
+ }
+ var expanded = inv.classList.contains('mid') || inv.classList.contains('open');
+ $('invToggle').setAttribute('aria-expanded', expanded ? 'true' : 'false');
} else {
inv.classList.toggle('collapsed');
$('invToggle').setAttribute('aria-expanded', inv.classList.contains('collapsed') ? 'false' : 'true');
@@ -1067,7 +1215,7 @@ ['pointerdown', 'keydown'].forEach(function (evt) { document.addEventListener(evt, touch, { passive: true });
});
- // re-roll daily if the date changes while open
+ // re-roll daily if the date changes while open; keep the world's day/night current
setInterval(function () {
var info = todayInfo();
if (state.daily.date !== info.dateStr) {
@@ -1075,6 +1223,7 @@ rollDaily(); updateDailyLine();
toast('A new day, a new challenge. 📅');
}
+ updateWorld();
}, 60000);
}
@@ -0,0 +1,18 @@ +{
+ "name": "EMOJESIS — a tiny universe made of symbols",
+ "short_name": "EMOJESIS",
+ "description": "Combine four primordial emojis into weather, life, civilization, and stranger things. No accounts, no tracking, no ads.",
+ "start_url": "./",
+ "scope": "./",
+ "display": "standalone",
+ "background_color": "#f2efe7",
+ "theme_color": "#a3501c",
+ "icons": [
+ {
+ "src": "favicon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}
@@ -1,14 +1,15 @@ /* EMOJESIS — minimal offline cache. Bump CACHE on every release. */
'use strict';
-var CACHE = 'emojesis-v1';
+var CACHE = 'emojesis-v2';
var ASSETS = [
'./',
'./index.html',
'./css/style.css',
'./js/data.js',
'./js/app.js',
- './favicon.svg'
+ './favicon.svg',
+ './manifest.webmanifest'
];
self.addEventListener('install', function (ev) {