code.pablomurad.com

emojesis

Tiny browser game: mix fire, water, earth and air into 110 elements

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
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"
+    }
+  ]
+}
M sw.js sw.js
@@ -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) {