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
@@ -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.
@@ -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) {