code.pablomurad.com

emojesis

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

first commit
author
Pablo Murad
date
commit
285cc4a2fb518849ebb0961a1c1ab4ce7fc7c603
changes
12 files +2886 −0
@@ -0,0 +1,15 @@+# OS
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# Editor
+*.swp
+*~
+.vscode/
+.idea/
+
+# Local / accidental
+*.log
+.env
+.env.*
@@ -0,0 +1,30 @@+# Changelog
+
+All notable changes to EMOJESIS. Dates are UTC.
+
+## [1.0.0] — 2026-07-30
+
+First release.
+
+- 110 elements across 12 categories: nature, weather, life, animals,
+  humanity, civilization, culture, technology, internet, occult, cosmos,
+  absurd.
+- 168 recipes with alternate routes, one order-sensitive recipe
+  (the Forbidden Book must be burned *on purpose*), and two night-only
+  discoveries.
+- Combination laboratory: drag & drop, tap-tap, click-to-fill, swap,
+  repeat-last-combination, self-combinations.
+- Collection with search, category filters, sorting, favorites, and
+  undiscovered silhouettes.
+- Journal with per-element lore, known recipes, and vague hints.
+- Daily challenge with deterministic seeded goals and a shareable result.
+- Hint system: 3 stored, one regenerates every 10 minutes, plus a free
+  hint after 90 idle seconds.
+- World view that fills in as you discover.
+- Six secret systems: the IndieWeb chain (personal website → blog → RSS),
+  self-combination secrets, the title-clicking egg, night-only combos,
+  the `>_` terminal (`help`, `sudo`, `netuno`), and cosmic/occult rarities.
+- Synthesized WebAudio blips — no audio files.
+- Light/dark themes, reduced-motion support, keyboard playable,
+  aria-live discovery announcements.
+- Local save with JSON export/import and a confirmed reset.
@@ -0,0 +1,72 @@+EMOJESIS — a tiny universe made of symbols.
+
+Combine four primordial emojis (fire, water, earth, air) and see what
+happens. 110 elements. 168 recipes. No server. No accounts. No bullshit.
+
+
+WHAT IS THIS
+------------
+
+Browser game. HTML + CSS + JavaScript. Zero dependencies. Zero build step.
+Save goes to localStorage. Export/import as JSON if you care.
+
+    🔥 + 💧  →  steam
+    steam + earth  →  time
+    ...and so on, until the absurd.
+
+
+REQUIREMENTS
+------------
+
+- Any modern browser
+- To play offline: serve over HTTP(S) so the service worker can install
+- To validate data: Node.js (any recent version)
+
+
+QUICK START
+-----------
+
+    # option A — just open it
+    open index.html
+
+    # option B — local server
+    python -m http.server 8080
+    npx serve .
+
+    # option C — validate the recipe graph
+    node validate.js
+
+
+FILES
+-----
+
+    index.html      UI shell
+    css/style.css   layout + themes
+    js/data.js      elements + recipes (110 / 168)
+    js/app.js       game logic
+    sw.js           offline cache
+    validate.js     data integrity checks
+    feed.xml        atom feed
+    CHANGELOG.md    release notes
+    deploy/         nginx config for production
+
+
+PRODUCTION (VPS)
+----------------
+
+Copy the site to your web root, then use the nginx snippet in:
+
+    deploy/emojesis.pablomurad.com.conf
+
+Panel placeholders ({{root}}, {{ssl_certificate}}, etc.) are kept as-is.
+`sw.js` is excluded from long-term cache so updates actually ship.
+
+
+AUTHOR
+------
+
+    Pablo Murad
+    Developer — 2026
+    https://github.com/pablomurad/emojesis
+
+License: use it, fork it, learn from it. Have fun.
@@ -0,0 +1,752 @@+/* EMOJESIS — styles. Digital laboratory / creation notebook. */
+
+:root {
+  --bg: #f2efe7;
+  --bg-raised: #faf8f2;
+  --ink: #201d17;
+  --ink-soft: #5c564a;
+  --line: #d8d2c2;
+  --accent: #a3501c;
+  --accent-ink: #fff;
+  --gold: #8a6d1d;
+  --danger: #9c2b1f;
+  --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;
+}
+
+html[data-theme="dark"] {
+  --bg: #14151a;
+  --bg-raised: #1c1e25;
+  --ink: #e9e6dc;
+  --ink-soft: #a39c8d;
+  --line: #33363f;
+  --accent: #e0915a;
+  --accent-ink: #181410;
+  --gold: #d3b155;
+  --danger: #e06c5b;
+  --slot-empty: #23252d;
+  --shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
+}
+
+@media (prefers-color-scheme: dark) {
+  html:not([data-theme="light"]):not([data-theme="dark"]) {
+    --bg: #14151a;
+    --bg-raised: #1c1e25;
+    --ink: #e9e6dc;
+    --ink-soft: #a39c8d;
+    --line: #33363f;
+    --accent: #e0915a;
+    --accent-ink: #181410;
+    --gold: #d3b155;
+    --danger: #e06c5b;
+    --slot-empty: #23252d;
+    --shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
+  }
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+  margin: 0;
+  padding: 0;
+  background: var(--bg);
+  color: var(--ink);
+  font-family: var(--sans);
+  line-height: 1.45;
+}
+
+body {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+
+/* subtle graph-paper lab backdrop */
+body::before {
+  content: "";
+  position: fixed;
+  inset: 0;
+  pointer-events: none;
+  background-image:
+    linear-gradient(var(--line) 1px, transparent 1px),
+    linear-gradient(90deg, var(--line) 1px, transparent 1px);
+  background-size: 44px 44px;
+  opacity: 0.16;
+  z-index: -1;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px; height: 1px;
+  margin: -1px; padding: 0;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  white-space: nowrap;
+  border: 0;
+}
+
+.skip-link {
+  position: absolute;
+  left: -9999px;
+  top: 0;
+  background: var(--accent);
+  color: var(--accent-ink);
+  padding: 0.5rem 1rem;
+  z-index: 100;
+}
+.skip-link:focus { left: 0; }
+
+a { color: var(--accent); }
+
+button {
+  font-family: inherit;
+  color: inherit;
+  cursor: pointer;
+}
+
+:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+}
+
+.mono { font-family: var(--mono); }
+.muted { color: var(--ink-soft); }
+
+/* ---------- header ---------- */
+.site-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 0.7rem 1rem;
+  border-bottom: 2px solid var(--ink);
+  background: var(--bg-raised);
+  flex-wrap: wrap;
+}
+.brand { display: flex; align-items: center; gap: 0.7rem; }
+.title-emoji {
+  font-size: 2rem;
+  background: none;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 0.15rem 0.4rem;
+  line-height: 1.2;
+  min-width: 44px;
+  min-height: 44px;
+}
+.brand-text h1 {
+  margin: 0;
+  font-size: 1.25rem;
+  letter-spacing: 0.22em;
+  font-family: var(--mono);
+}
+.tagline {
+  margin: 0;
+  font-size: 0.75rem;
+  color: var(--ink-soft);
+}
+.header-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
+.hbtn {
+  position: relative;
+  font-size: 1.15rem;
+  background: var(--bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  min-width: 44px;
+  min-height: 44px;
+  padding: 0.3rem 0.55rem;
+}
+.hbtn:hover { border-color: var(--ink); }
+.badge {
+  position: absolute;
+  top: -6px; right: -6px;
+  background: var(--accent);
+  color: var(--accent-ink);
+  border-radius: 999px;
+  font-size: 0.65rem;
+  font-family: var(--mono);
+  padding: 0.05rem 0.35rem;
+}
+.dot {
+  position: absolute;
+  top: 2px; right: 2px;
+  width: 8px; height: 8px;
+  border-radius: 50%;
+  background: var(--accent);
+}
+
+/* ---------- world strip ---------- */
+.world {
+  border-bottom: 1px solid var(--line);
+  background: var(--bg-raised);
+}
+.world-toggle {
+  background: none;
+  border: none;
+  font-family: var(--mono);
+  font-size: 0.7rem;
+  color: var(--ink-soft);
+  padding: 0.3rem 1rem;
+  min-height: 32px;
+}
+.world-strip {
+  display: flex;
+  gap: 0.5rem;
+  align-items: flex-end;
+  overflow-x: auto;
+  padding: 0.2rem 1rem 0.6rem;
+  min-height: 56px;
+}
+.world.collapsed .world-strip { display: none; }
+.w-slot {
+  flex: 0 0 auto;
+  width: 44px;
+  height: 44px;
+  display: grid;
+  place-items: center;
+  border: 1px dashed var(--line);
+  border-radius: 6px;
+  background: var(--bg);
+}
+.w-slot span {
+  font-size: 1.5rem;
+  opacity: 0;
+  transform: scale(0.5);
+  transition: opacity 0.4s ease, transform 0.4s ease;
+}
+.w-slot.found span { opacity: 1; transform: scale(1); }
+.w-sky { border-bottom-style: solid; }
+.w-sea { background: color-mix(in srgb, var(--bg) 80%, #4a7fa5 20%); }
+
+/* ---------- main layout ---------- */
+main {
+  flex: 1;
+  display: grid;
+  grid-template-columns: minmax(300px, 1fr) minmax(320px, 420px);
+  gap: 1rem;
+  padding: 1rem;
+  max-width: 1200px;
+  width: 100%;
+  margin: 0 auto;
+}
+
+/* ---------- creation ---------- */
+.creation {
+  align-self: start;
+  background: var(--bg-raised);
+  border: 2px solid var(--ink);
+  border-radius: 10px;
+  padding: 1.25rem;
+  box-shadow: var(--shadow);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.9rem;
+}
+.slots {
+  display: flex;
+  align-items: center;
+  gap: 0.8rem;
+}
+.slot {
+  width: 104px;
+  height: 104px;
+  border: 2px dashed var(--line);
+  border-radius: 12px;
+  background: var(--slot-empty);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 0.15rem;
+}
+.slot[data-filled="true"] {
+  border-style: solid;
+  border-color: var(--ink);
+  background: var(--bg);
+}
+.slot.drop-hover { border-color: var(--accent); background: var(--bg); }
+.slot-emoji { font-size: 2.6rem; line-height: 1; }
+.slot-name {
+  font-size: 0.7rem;
+  font-family: var(--mono);
+  color: var(--ink-soft);
+}
+.plus { font-size: 1.6rem; color: var(--ink-soft); }
+
+.creation-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+}
+.combine-btn {
+  background: var(--accent);
+  color: var(--accent-ink);
+  border: 2px solid var(--ink);
+  border-radius: 8px;
+  font-size: 1.05rem;
+  font-weight: 600;
+  padding: 0.6rem 1.4rem;
+  min-height: 44px;
+}
+.combine-btn:disabled { opacity: 0.45; cursor: not-allowed; }
+.abtn {
+  background: var(--bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 0.45rem 0.8rem;
+  min-height: 44px;
+  min-width: 44px;
+  font-size: 1rem;
+}
+.abtn:hover:not(:disabled) { border-color: var(--ink); }
+.abtn:disabled { opacity: 0.4; cursor: not-allowed; }
+.abtn.danger { color: var(--danger); border-color: var(--danger); }
+
+.result {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 84px;
+  justify-content: center;
+  gap: 0.2rem;
+}
+.result-emoji { font-size: 3.2rem; line-height: 1; }
+.result-name {
+  font-family: var(--mono);
+  font-size: 0.85rem;
+  color: var(--ink-soft);
+}
+.message {
+  margin: 0;
+  text-align: center;
+  font-size: 0.95rem;
+  color: var(--ink-soft);
+  min-height: 1.4em;
+  max-width: 40ch;
+}
+.message.announce { color: var(--ink); }
+.daily-line {
+  margin: 0;
+  font-size: 0.78rem;
+  font-family: var(--mono);
+  color: var(--gold);
+  text-align: center;
+}
+
+/* combine animation */
+@keyframes fuse {
+  0% { transform: translateX(0) scale(1); }
+  40% { transform: translateX(var(--fuse-x, 10px)) scale(1.08); }
+  60% { transform: translateX(var(--fuse-x, 10px)) scale(1.08); }
+  100% { transform: translateX(0) scale(1); }
+}
+.slot.fusing { animation: fuse 0.5s ease; }
+@keyframes pop {
+  0% { transform: scale(0.3); opacity: 0; }
+  60% { transform: scale(1.15); opacity: 1; }
+  100% { transform: scale(1); }
+}
+.result.reveal { animation: pop 0.45s ease; }
+@keyframes fzzap { 0%,100% { filter: none; } 50% { filter: brightness(1.8) saturate(1.6); } }
+.result.fx-zap { animation: pop 0.45s ease, fzzap 0.5s ease; }
+@keyframes fxflare { 0% { filter: none; } 50% { filter: brightness(1.6) hue-rotate(-20deg); } 100% { filter: none; } }
+.result.fx-flare { animation: pop 0.45s ease, fxflare 0.6s ease; }
+@keyframes fxshake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
+.result.fx-shake { animation: pop 0.45s ease, fxshake 0.45s ease; }
+@keyframes fxcosmic { 0% { filter: none; transform: scale(0.3); } 50% { filter: brightness(1.7) hue-rotate(90deg); transform: scale(1.2); } 100% { filter: none; transform: scale(1); } }
+.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; }
+
+/* ---------- inventory ---------- */
+.inventory {
+  align-self: start;
+  background: var(--bg-raised);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  overflow: hidden;
+}
+.inv-toggle {
+  width: 100%;
+  text-align: left;
+  background: none;
+  border: none;
+  border-bottom: 1px solid var(--line);
+  padding: 0.7rem 0.9rem;
+  font-family: var(--mono);
+  font-size: 0.85rem;
+  min-height: 44px;
+}
+.inv-count { color: var(--ink-soft); }
+.inventory.collapsed .inv-body { display: none; }
+.inv-body { padding: 0.7rem; }
+.inv-tools {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 0.5rem;
+}
+.inv-tools input[type="search"] {
+  flex: 1;
+  min-width: 0;
+  padding: 0.45rem 0.6rem;
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: var(--bg);
+  color: var(--ink);
+  font: inherit;
+  min-height: 44px;
+}
+.inv-tools select {
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  background: var(--bg);
+  color: var(--ink);
+  font: inherit;
+  min-height: 44px;
+}
+.chip {
+  border: 1px solid var(--line);
+  background: var(--bg);
+  border-radius: 999px;
+  padding: 0.2rem 0.6rem;
+  font-size: 0.75rem;
+  font-family: var(--mono);
+  min-height: 30px;
+}
+.chip[aria-pressed="true"] {
+  background: var(--ink);
+  color: var(--bg);
+  border-color: var(--ink);
+}
+.inv-cats {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.3rem;
+  margin-bottom: 0.6rem;
+}
+.inv-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
+  gap: 0.45rem;
+  max-height: 54vh;
+  overflow-y: auto;
+}
+.inv-item {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.15rem;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  background: var(--bg);
+  padding: 0.5rem 0.3rem 0.4rem;
+}
+.inv-item.is-new { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+.inv-main {
+  background: none;
+  border: none;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.15rem;
+  width: 100%;
+  min-height: 44px;
+  cursor: grab;
+}
+.inv-main:active { cursor: grabbing; }
+.inv-emoji { font-size: 1.8rem; line-height: 1; }
+.inv-name {
+  font-size: 0.62rem;
+  font-family: var(--mono);
+  color: var(--ink-soft);
+  text-align: center;
+  overflow-wrap: anywhere;
+}
+.inv-fav, .inv-info {
+  position: absolute;
+  top: 1px;
+  background: none;
+  border: none;
+  font-size: 0.7rem;
+  padding: 0.2rem;
+  min-width: 28px;
+  min-height: 28px;
+  opacity: 0.55;
+}
+.inv-fav { left: 1px; }
+.inv-info { right: 1px; }
+.inv-fav.on { opacity: 1; }
+.inv-item:hover .inv-fav, .inv-item:hover .inv-info,
+.inv-fav:focus-visible, .inv-info:focus-visible { opacity: 1; }
+.inv-empty {
+  grid-column: 1 / -1;
+  text-align: center;
+  color: var(--ink-soft);
+  font-size: 0.85rem;
+  padding: 1rem 0;
+}
+
+@keyframes pulseHint {
+  0%, 100% { box-shadow: 0 0 0 0 var(--accent); }
+  50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 40%, transparent); }
+}
+.inv-item.ob-pulse { animation: pulseHint 1.4s ease infinite; }
+
+/* ---------- footer ---------- */
+.site-footer {
+  border-top: 1px solid var(--line);
+  padding: 0.8rem 1rem;
+  text-align: center;
+  font-size: 0.78rem;
+  color: var(--ink-soft);
+  background: var(--bg-raised);
+}
+.linklike {
+  background: none;
+  border: none;
+  padding: 0;
+  color: var(--accent);
+  text-decoration: underline;
+  font-size: inherit;
+}
+
+/* ---------- modals ---------- */
+.modal {
+  position: fixed;
+  inset: 0;
+  background: rgba(10, 10, 12, 0.55);
+  display: grid;
+  place-items: center;
+  padding: 1rem;
+  z-index: 50;
+}
+.modal[hidden] { display: none; }
+.modal-card {
+  background: var(--bg-raised);
+  border: 2px solid var(--ink);
+  border-radius: 10px;
+  padding: 1.2rem;
+  max-width: 480px;
+  width: 100%;
+  max-height: 85vh;
+  overflow-y: auto;
+}
+.modal-card h2 { margin-top: 0; font-size: 1.1rem; }
+.modal-card h3 { font-size: 0.95rem; margin-bottom: 0.3rem; }
+.modal-actions {
+  display: flex;
+  gap: 0.5rem;
+  justify-content: flex-end;
+  margin-top: 1rem;
+}
+.modal-actions.wrap { flex-wrap: wrap; justify-content: flex-start; }
+.about-links { padding-left: 1.1rem; }
+.settings-grid {
+  display: flex;
+  gap: 1.2rem;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.settings-grid select {
+  font: inherit;
+  background: var(--bg);
+  color: var(--ink);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 0.3rem;
+  min-height: 40px;
+}
+.checkline { display: flex; align-items: center; gap: 0.4rem; min-height: 44px; }
+.reset-confirm {
+  margin-top: 0.8rem;
+  border: 1px solid var(--danger);
+  border-radius: 8px;
+  padding: 0.7rem;
+}
+.daily-goal { font-size: 1.05rem; }
+.share-block {
+  background: var(--bg);
+  border: 1px solid var(--line);
+  border-radius: 6px;
+  padding: 0.6rem;
+  font-family: var(--mono);
+  font-size: 0.8rem;
+  white-space: pre-wrap;
+}
+.stats-list {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: 0.3rem 1rem;
+}
+.stats-list dt { font-family: var(--mono); color: var(--ink-soft); }
+.stats-list dd { margin: 0; text-align: right; }
+
+/* journal */
+.journal-head {
+  display: flex;
+  align-items: center;
+  gap: 0.8rem;
+  margin-bottom: 0.6rem;
+}
+.journal-emoji { font-size: 2.6rem; }
+.journal-head h2 { margin: 0; }
+.journal-meta {
+  font-family: var(--mono);
+  font-size: 0.72rem;
+  color: var(--ink-soft);
+}
+.journal-sec { margin: 0.7rem 0 0.2rem; font-size: 0.85rem; }
+.journal-recipes { margin: 0; padding-left: 1.2rem; font-size: 0.88rem; }
+.journal-hint { color: var(--gold); font-size: 0.85rem; font-style: italic; }
+
+/* terminal */
+.term-card { background: #101216; color: #c9e7c9; border-color: #3a4a3a; }
+.term-card h2 { color: #8fce8f; font-size: 0.9rem; }
+.term-out {
+  min-height: 160px;
+  max-height: 40vh;
+  overflow-y: auto;
+  font-size: 0.8rem;
+  white-space: pre-wrap;
+  margin-bottom: 0.5rem;
+}
+.term-form { display: flex; align-items: center; gap: 0.3rem; }
+.term-form input {
+  flex: 1;
+  background: none;
+  border: none;
+  color: #c9e7c9;
+  font: inherit;
+  outline: none;
+  min-height: 40px;
+}
+
+/* toasts */
+.toasts {
+  position: fixed;
+  bottom: 1rem;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  flex-direction: column;
+  gap: 0.4rem;
+  z-index: 80;
+  pointer-events: none;
+  width: min(92vw, 420px);
+}
+.toast {
+  background: var(--ink);
+  color: var(--bg);
+  border-radius: 8px;
+  padding: 0.55rem 0.9rem;
+  font-size: 0.85rem;
+  text-align: center;
+  animation: toastIn 0.25s ease;
+}
+@keyframes toastIn {
+  from { opacity: 0; transform: translateY(8px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+/* onboarding */
+.onboarding {
+  position: fixed;
+  inset: 0;
+  background: var(--bg);
+  display: grid;
+  place-items: center;
+  z-index: 90;
+  padding: 1rem;
+}
+.onboarding[hidden] { display: none; }
+.ob-card {
+  text-align: center;
+  max-width: 420px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.8rem;
+}
+.ob-card h2 {
+  font-family: var(--mono);
+  letter-spacing: 0.3em;
+  margin: 0;
+}
+.ob-primordials {
+  display: flex;
+  gap: 1rem;
+  margin: 0.6rem 0;
+}
+.ob-el {
+  font-size: 2.4rem;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.ob-el small {
+  font-size: 0.68rem;
+  font-family: var(--mono);
+  color: var(--ink-soft);
+}
+.ob-hint { font-size: 0.85rem; color: var(--ink-soft); }
+
+/* ---------- world animations ---------- */
+@keyframes drift { 0%,100% { transform: translateX(0); } 50% { transform: translateX(7px); } }
+@keyframes twinkle { 0%,100% { opacity: 1; } 50% { opacity: 0.45; } }
+@keyframes sway { 0%,100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } }
+@keyframes bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
+@keyframes flap { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
+@keyframes hover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
+@keyframes fall { 0%,100% { transform: translateY(0); } 50% { transform: translateY(2px); } }
+@keyframes rise { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
+.w-slot.found.anim-drift span { animation: drift 5s ease-in-out infinite; }
+.w-slot.found.anim-twinkle span { animation: twinkle 2.4s ease-in-out infinite; }
+.w-slot.found.anim-sway span { animation: sway 3.6s ease-in-out infinite; transform-origin: bottom center; }
+.w-slot.found.anim-bob span { animation: bob 2.8s ease-in-out infinite; }
+.w-slot.found.anim-flap span { animation: flap 1.6s ease-in-out infinite; }
+.w-slot.found.anim-hover span { animation: hover 3.2s ease-in-out infinite; }
+.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 (max-width: 860px) {
+  main {
+    grid-template-columns: 1fr;
+    padding: 0.7rem;
+  }
+  .inventory {
+    position: fixed;
+    left: 0; right: 0; bottom: 0;
+    z-index: 40;
+    border-radius: 14px 14px 0 0;
+    border: 2px solid var(--ink);
+    border-bottom: none;
+    max-height: 70vh;
+    display: flex;
+    flex-direction: column;
+    transform: translateY(calc(100% - 52px));
+    transition: transform 0.25s ease;
+  }
+  .inventory.open { transform: translateY(0); }
+  .inventory.collapsed .inv-body { display: block; }
+  .inv-body { overflow-y: auto; }
+  .inv-grid { max-height: none; }
+  .inv-toggle { text-align: center; }
+  main { padding-bottom: 64px; }
+}
+
+/* ---------- reduced motion ---------- */
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.001s !important;
+    animation-iteration-count: 1 !important;
+    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; }
+}
@@ -0,0 +1,69 @@+# EMOJESIS — nginx vhost for emojesis.pablomurad.com
+# Static HTML/CSS/JS. Service worker needs sw.js without long cache.
+#
+# Panel placeholders: {{root}}, {{ssl_certificate}}, {{settings}}, etc.
+# Set {{root}} to the directory containing index.html (e.g. /home/.../emojesis/public)
+
+server {
+  listen 80;
+  listen [::]:80;
+  listen 443 quic;
+  listen 443 ssl;
+  listen [::]:443 quic;
+  listen [::]:443 ssl;
+  http2 on;
+  http3 off;
+  {{ssl_certificate_key}}
+  {{ssl_certificate}}
+  server_name emojesis.pablomurad.com;
+  {{root}}
+
+  {{nginx_access_log}}
+  {{nginx_error_log}}
+
+  if ($scheme != "https") {
+    rewrite ^ https://$host$request_uri permanent;
+  }
+
+  location ~ /.well-known {
+    auth_basic off;
+    allow all;
+  }
+
+  {{settings}}
+
+  include /etc/nginx/global_settings;
+
+  index index.html;
+
+  # Service worker — must revalidate or updates never reach clients
+  location = /sw.js {
+    add_header Cache-Control "no-cache, no-store, must-revalidate";
+    add_header Service-Worker-Allowed "/";
+    try_files $uri =404;
+  }
+
+  # Shell — avoid aggressive cache on the entry document
+  location = /index.html {
+    add_header Cache-Control "no-cache, must-revalidate";
+    try_files $uri =404;
+  }
+
+  # Versioned-ish assets (bump sw.js CACHE in app releases)
+  location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|xml|md)$ {
+    add_header Access-Control-Allow-Origin "*";
+    add_header alt-svc 'h3=":443"; ma=86400';
+    expires 7d;
+    access_log off;
+    try_files $uri =404;
+  }
+
+  # Everything else: file if present, else index.html (single-page game)
+  location / {
+    try_files $uri $uri/ /index.html;
+  }
+
+  if (-f $request_filename) {
+    break;
+  }
+}
@@ -0,0 +1,1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="0.9em" font-size="90">🌌</text></svg>
@@ -0,0 +1,19 @@+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <title>EMOJESIS</title>
+  <subtitle>A tiny universe made of symbols — project updates</subtitle>
+  <id>https://github.com/pablomurad/emojesis</id>
+  <link href="https://github.com/pablomurad/emojesis"/>
+  <link rel="self" href="https://github.com/pablomurad/emojesis/feed.xml"/>
+  <updated>2026-07-30T00:00:00Z</updated>
+  <author>
+    <name>Pablo Murad</name>
+  </author>
+  <entry>
+    <title>EMOJESIS 1.0.0 — first release</title>
+    <id>https://github.com/pablomurad/emojesis/releases/tag/v1.0.0</id>
+    <link href="https://github.com/pablomurad/emojesis"/>
+    <updated>2026-07-30T00:00:00Z</updated>
+    <summary>110 elements, 168 recipes, 12 categories, 16 secrets, a daily challenge, and a terminal that listens to the outer dark. No accounts, no tracking, no ads.</summary>
+  </entry>
+</feed>
@@ -0,0 +1,236 @@+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-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="alternate" type="application/atom+xml" title="EMOJESIS updates" href="feed.xml">
+  <link rel="stylesheet" href="css/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#creation">Skip to the laboratory</a>
+
+  <div id="live" class="sr-only" aria-live="polite"></div>
+
+  <header class="site-header">
+    <div class="brand">
+      <button id="titleEmoji" class="title-emoji" aria-label="EMOJESIS logo" title="EMOJESIS">🌌</button>
+      <div class="brand-text">
+        <h1>EMOJESIS</h1>
+        <p class="tagline">A tiny universe made of symbols.</p>
+      </div>
+    </div>
+    <nav class="header-actions" aria-label="Game actions">
+      <button id="btnDaily"  class="hbtn" aria-label="Daily challenge" title="Daily challenge">📅<span id="dailyDot" class="dot" hidden></span></button>
+      <button id="btnHint"   class="hbtn" aria-label="Use a hint" title="Hint"><span aria-hidden="true">💡</span><span id="hintCount" class="badge">3</span></button>
+      <button id="btnStats"  class="hbtn" aria-label="Statistics" title="Statistics">📊</button>
+      <button id="btnAbout"  class="hbtn" aria-label="About and settings" title="About &amp; settings">ℹ️</button>
+      <button id="btnSound"  class="hbtn" aria-label="Toggle sound" title="Sound">🔊</button>
+      <button id="btnTheme"  class="hbtn" aria-label="Toggle theme" title="Theme">🌗</button>
+    </nav>
+  </header>
+
+  <!-- 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>
+  </section>
+
+  <main id="main">
+    <!-- Creation area -->
+    <section id="creation" class="creation" aria-label="Combination laboratory">
+      <div class="slots">
+        <button id="slotA" class="slot" aria-label="First ingredient slot. Empty." data-filled="false">
+          <span class="slot-emoji" aria-hidden="true">·</span>
+          <span class="slot-name">first</span>
+        </button>
+        <span class="plus" aria-hidden="true">+</span>
+        <button id="slotB" class="slot" aria-label="Second ingredient slot. Empty." data-filled="false">
+          <span class="slot-emoji" aria-hidden="true">·</span>
+          <span class="slot-name">second</span>
+        </button>
+      </div>
+      <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>
+      </div>
+      <div id="result" class="result" aria-hidden="true">
+        <span id="resultEmoji" class="result-emoji"></span>
+        <span id="resultName" class="result-name"></span>
+      </div>
+      <p id="message" class="message">Combine two symbols and see what happens.</p>
+      <p id="dailyLine" class="daily-line" hidden></p>
+    </section>
+
+    <!-- Inventory -->
+    <section id="inventory" class="inventory" aria-label="Your collection">
+      <div class="inv-head">
+        <button id="invToggle" class="inv-toggle" aria-expanded="true" aria-controls="invBody">
+          🎒 Collection <span id="invCount" class="inv-count"></span>
+        </button>
+        <div id="invBody" class="inv-body">
+          <div class="inv-tools">
+            <input id="invSearch" type="search" placeholder="Search elements…" aria-label="Search elements by name">
+            <select id="invSort" aria-label="Sort elements">
+              <option value="order">Discovery order</option>
+              <option value="alpha">Alphabetical</option>
+            </select>
+            <button id="invFav" class="chip" aria-pressed="false" title="Show favorites only">⭐</button>
+          </div>
+          <div id="invCats" class="inv-cats" role="group" aria-label="Filter by category"></div>
+          <div id="invGrid" class="inv-grid"></div>
+        </div>
+      </div>
+    </section>
+  </main>
+
+  <footer class="site-footer">
+    <span>EMOJESIS — a tiny universe made of symbols · Pablo Murad, 2026 ·
+      <a href="https://github.com/pablomurad/emojesis" rel="me noopener">source</a> ·
+      <button id="footAbout" class="linklike">about</button> ·
+      <button id="footTerminal" class="linklike mono" aria-label="Open terminal">&gt;_</button>
+    </span>
+  </footer>
+
+  <!-- Daily challenge modal -->
+  <div id="modalDaily" class="modal" role="dialog" aria-modal="true" aria-labelledby="dailyTitle" hidden>
+    <div class="modal-card">
+      <h2 id="dailyTitle">📅 Daily challenge</h2>
+      <p id="dailyGoal" class="daily-goal"></p>
+      <p id="dailyProgress" class="muted"></p>
+      <pre id="dailyShare" class="share-block" aria-label="Shareable result"></pre>
+      <div class="modal-actions">
+        <button id="btnCopyDaily" class="abtn">Copy result</button>
+        <button class="abtn modal-close" data-close="modalDaily">Close</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Stats modal -->
+  <div id="modalStats" class="modal" role="dialog" aria-modal="true" aria-labelledby="statsTitle" hidden>
+    <div class="modal-card">
+      <h2 id="statsTitle">📊 Notebook margins</h2>
+      <dl id="statsList" class="stats-list"></dl>
+      <div class="modal-actions">
+        <button class="abtn modal-close" data-close="modalStats">Close</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Journal (element detail) modal -->
+  <div id="modalJournal" class="modal" role="dialog" aria-modal="true" aria-labelledby="journalTitle" hidden>
+    <div class="modal-card">
+      <div id="journalBody"></div>
+      <div class="modal-actions">
+        <button id="journalUse" class="abtn">Send to slot</button>
+        <button class="abtn modal-close" data-close="modalJournal">Close</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- About / settings modal -->
+  <div id="modalAbout" class="modal" role="dialog" aria-modal="true" aria-labelledby="aboutTitle" hidden>
+    <div class="modal-card">
+      <h2 id="aboutTitle">ℹ️ About EMOJESIS</h2>
+      <p><strong>EMOJESIS</strong> — a tiny universe made of symbols. You begin with four primordial
+      emojis and combine them into weather, life, civilization, and stranger things.</p>
+      <p>Developed by <strong>Pablo Murad</strong> (2026). No accounts, no tracking, no ads, no currencies.
+      Your universe lives in your browser and nowhere else.</p>
+      <ul class="about-links">
+        <li><a href="https://github.com/pablomurad/emojesis" rel="me noopener">Source code</a></li>
+        <li><a href="CHANGELOG.md">Changelog</a></li>
+        <li><a href="feed.xml">Update feed (Atom)</a></li>
+        <li><a href="README.md">README</a></li>
+      </ul>
+      <h3>Settings</h3>
+      <div class="settings-grid">
+        <label>Theme
+          <select id="setTheme">
+            <option value="auto">Follow system</option>
+            <option value="light">Light</option>
+            <option value="dark">Dark</option>
+          </select>
+        </label>
+        <label class="checkline"><input type="checkbox" id="setSound"> Sound effects</label>
+      </div>
+      <div class="modal-actions wrap">
+        <button id="btnExport" class="abtn">Export save</button>
+        <button id="btnImport" class="abtn">Import save</button>
+        <input type="file" id="importFile" accept="application/json,.json" hidden>
+        <button id="btnReset" class="abtn danger">Reset universe</button>
+      </div>
+      <div id="resetConfirm" class="reset-confirm" hidden>
+        <p>Everything you discovered will be forgotten. Really?</p>
+        <button id="btnResetYes" class="abtn danger">Yes, forget it all</button>
+        <button id="btnResetNo" class="abtn">Keep my universe</button>
+      </div>
+      <div class="modal-actions">
+        <button class="abtn modal-close" data-close="modalAbout">Close</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Terminal easter egg -->
+  <div id="modalTerm" class="modal" role="dialog" aria-modal="true" aria-labelledby="termTitle" hidden>
+    <div class="modal-card term-card">
+      <h2 id="termTitle" class="mono">guest@emojesis:~</h2>
+      <div id="termOut" class="term-out mono" aria-live="polite"></div>
+      <form id="termForm" class="term-form mono">
+        <span aria-hidden="true">&gt;&nbsp;</span>
+        <input id="termInput" type="text" autocomplete="off" spellcheck="false" aria-label="Terminal input" placeholder="type 'help'">
+      </form>
+      <div class="modal-actions">
+        <button class="abtn modal-close" data-close="modalTerm">Close</button>
+      </div>
+    </div>
+  </div>
+
+  <!-- Onboarding -->
+  <div id="onboarding" class="onboarding" role="dialog" aria-modal="true" aria-labelledby="obTitle" hidden>
+    <div class="ob-card">
+      <h2 id="obTitle">EMOJESIS</h2>
+      <p class="tagline">A tiny universe made of symbols.</p>
+      <div class="ob-primordials" aria-label="The four primordial elements">
+        <span class="ob-el" data-el="fire">🔥<small>Fire</small></span>
+        <span class="ob-el" data-el="water">💧<small>Water</small></span>
+        <span class="ob-el" data-el="earth">🪨<small>Earth</small></span>
+        <span class="ob-el" data-el="air">💨<small>Air</small></span>
+      </div>
+      <p>Everything begins with four symbols. Combine them and see what happens.</p>
+      <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="toasts" class="toasts" aria-hidden="false"></div>
+
+  <script src="js/data.js"></script>
+  <script src="js/app.js"></script>
+</body>
+</html>
@@ -0,0 +1,1100 @@+/* EMOJESIS — game logic and UI. Vanilla JS, no modules. */
+(function () {
+  'use strict';
+
+  var DATA = globalThis.EMOJESIS_DATA;
+  var ELEMENTS = DATA.ELEMENTS;
+  var RECIPES = DATA.RECIPES;
+  var CATEGORIES = DATA.CATEGORIES;
+  var STARTERS = DATA.STARTERS;
+
+  var SAVE_KEY = 'emojesis-save-v1';
+  var DAY_EPOCH = Date.UTC(2025, 0, 1);
+  var HINT_MAX = 3;
+  var HINT_REGEN_MS = 10 * 60 * 1000;
+  var IDLE_HINT_MS = 90 * 1000;
+
+  var FAILURE_LINES = [
+    'Nothing happened. Probably for the best.',
+    'These symbols refuse to cooperate.',
+    'The universe is not ready for that.',
+    'An interesting idea with no physical consequences.',
+    'The laboratory politely ignores this.',
+    'Somewhere, a physicist felt a disturbance.',
+    'That is not how any of this works.',
+    'The symbols exchanged a look and moved on.'
+  ];
+  var CLUE_SUFFIX = ' (Something about this feels almost right.)';
+
+  /* ---------- recipe indexes ---------- */
+  var recipeByPair = {};   // unordered "a|b" -> recipe
+  var recipeByOrdered = {}; // "a>b" -> recipe
+  var recipesByInput = {};  // elementId -> [recipes using it]
+  var recipesByResult = {}; // elementId -> [recipes creating it]
+
+  RECIPES.forEach(function (r) {
+    if (r.ordered) recipeByOrdered[r.a + '>' + r.b] = r;
+    else recipeByPair[[r.a, r.b].sort().join('|')] = r;
+    (recipesByInput[r.a] = recipesByInput[r.a] || []).push(r);
+    (recipesByInput[r.b] = recipesByInput[r.b] || []).push(r);
+    (recipesByResult[r.result] = recipesByResult[r.result] || []).push(r);
+  });
+
+  function findRecipe(a, b) {
+    var ord = recipeByOrdered[a + '>' + b];
+    if (ord) return ord;
+    return recipeByPair[[a, b].sort().join('|')] || null;
+  }
+
+  /* ---------- tiny helpers ---------- */
+  function $(id) { return document.getElementById(id); }
+  function el(tag, cls, text) {
+    var n = document.createElement(tag);
+    if (cls) n.className = cls;
+    if (text != null) n.textContent = text;
+    return n;
+  }
+  function mulberry32(seed) {
+    var t = seed >>> 0;
+    return function () {
+      t += 0x6D2B79F5;
+      var x = t;
+      x = Math.imul(x ^ (x >>> 15), x | 1);
+      x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
+      return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
+    };
+  }
+  function todayInfo() {
+    var d = new Date();
+    var y = d.getFullYear(), m = d.getMonth(), day = d.getDate();
+    return {
+      dateStr: y + '-' + String(m + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0'),
+      seed: y * 10000 + (m + 1) * 100 + day,
+      dayNum: Math.floor((Date.UTC(y, m, day) - DAY_EPOCH) / 86400000)
+    };
+  }
+  function isNight() {
+    var h = new Date().getHours();
+    return h >= 22 || h < 5;
+  }
+  function reducedMotion() {
+    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+  }
+
+  /* ---------- state ---------- */
+  function defaultState() {
+    var order = {};
+    STARTERS.forEach(function (id, i) { order[id] = i + 1; });
+    return {
+      elements: STARTERS.slice(),
+      order: order,
+      recipes: [],
+      favorites: [],
+      newItems: [],
+      daily: { date: '', goalId: null, fallbackN: 0, count: 0, done: false, usedHint: false },
+      hints: { stored: HINT_MAX, lastRegen: Date.now(), used: 0 },
+      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
+    };
+  }
+
+  var state = load();
+
+  function load() {
+    try {
+      var raw = localStorage.getItem(SAVE_KEY);
+      if (!raw) return defaultState();
+      var s = JSON.parse(raw);
+      var d = defaultState();
+      // shallow-merge known keys, keep nested objects sane
+      var merged = Object.assign(d, s);
+      merged.daily = Object.assign(d.daily, s.daily || {});
+      merged.hints = Object.assign(d.hints, s.hints || {});
+      merged.settings = Object.assign(d.settings, s.settings || {});
+      merged.stats = Object.assign(d.stats, s.stats || {});
+      // drop elements that no longer exist in data
+      merged.elements = merged.elements.filter(function (id) { return ELEMENTS[id]; });
+      STARTERS.forEach(function (id) {
+        if (merged.elements.indexOf(id) === -1) merged.elements.push(id);
+      });
+      return merged;
+    } catch (e) {
+      return defaultState();
+    }
+  }
+
+  function save() {
+    var now = Date.now();
+    var delta = now - (state.lastActive || now);
+    if (delta > 0) state.stats.playMs += Math.min(delta, 5 * 60 * 1000);
+    state.lastActive = now;
+    try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ }
+  }
+
+  function has(id) { return state.elements.indexOf(id) !== -1; }
+
+  /* ---------- audio ---------- */
+  var actx = null;
+  function audioCtx() {
+    if (!actx) {
+      var AC = window.AudioContext || window.webkitAudioContext;
+      if (!AC) return null;
+      actx = new AC();
+    }
+    if (actx.state === 'suspended') actx.resume();
+    return actx;
+  }
+  function tone(freq, delay, dur, type, vol) {
+    if (!state.settings.sound) return;
+    var ctx = audioCtx();
+    if (!ctx) return;
+    var t = ctx.currentTime + delay;
+    var osc = ctx.createOscillator();
+    var gain = ctx.createGain();
+    osc.type = type || 'sine';
+    osc.frequency.setValueAtTime(freq, t);
+    gain.gain.setValueAtTime(0.0001, t);
+    gain.gain.exponentialRampToValueAtTime(vol || 0.08, t + 0.01);
+    gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+    osc.connect(gain).connect(ctx.destination);
+    osc.start(t);
+    osc.stop(t + dur + 0.05);
+  }
+  var SOUNDS = {
+    select: function () { tone(660, 0, 0.06, 'triangle', 0.05); },
+    combine: function () { tone(440, 0, 0.08, 'triangle', 0.06); tone(587, 0.07, 0.1, 'triangle', 0.06); },
+    discover: function () { tone(523, 0, 0.09, 'sine', 0.07); tone(659, 0.08, 0.09, 'sine', 0.07); tone(784, 0.16, 0.14, 'sine', 0.08); },
+    invalid: function () { tone(160, 0, 0.14, 'sawtooth', 0.04); },
+    secret: function () { tone(880, 0, 0.08, 'sine', 0.06); tone(1108, 0.09, 0.08, 'sine', 0.06); tone(1318, 0.18, 0.2, 'sine', 0.07); }
+  };
+  function playSound(name) {
+    if (SOUNDS[name]) SOUNDS[name]();
+  }
+  // WebAudio needs a gesture; warm up on first interaction.
+  document.addEventListener('pointerdown', function () { if (state.settings.sound) audioCtx(); }, { once: true });
+
+  /* ---------- toasts & announcements ---------- */
+  function toast(msg) {
+    var box = $('toasts');
+    var t = el('div', 'toast', msg);
+    box.appendChild(t);
+    setTimeout(function () { t.remove(); }, 3200);
+  }
+  function announce(msg) {
+    $('live').textContent = '';
+    // force re-announcement
+    setTimeout(function () { $('live').textContent = msg; }, 30);
+  }
+  function setMessage(msg, isAnnounce) {
+    var m = $('message');
+    m.textContent = msg;
+    m.classList.toggle('announce', !!isAnnounce);
+    if (isAnnounce) announce(msg);
+  }
+
+  /* ---------- slots & combining ---------- */
+  var slots = [null, null];
+  var lastCombo = null; // {a, b} of last successful combo
+  var lastAction = Date.now();
+
+  function slotEls() { return [$('slotA'), $('slotB')]; }
+
+  function renderSlots() {
+    slotEls().forEach(function (s, i) {
+      var id = slots[i];
+      var emoji = s.querySelector('.slot-emoji');
+      var name = s.querySelector('.slot-name');
+      if (id) {
+        emoji.textContent = ELEMENTS[id].emoji;
+        name.textContent = ELEMENTS[id].name;
+        s.dataset.filled = 'true';
+        s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient: ' + ELEMENTS[id].name + '. Activate to remove.');
+      } else {
+        emoji.textContent = '·';
+        name.textContent = i === 0 ? 'first' : 'second';
+        s.dataset.filled = 'false';
+        s.setAttribute('aria-label', (i === 0 ? 'First' : 'Second') + ' ingredient slot. Empty.');
+      }
+    });
+    var both = slots[0] && slots[1];
+    $('btnCombine').disabled = !both;
+    $('btnSwap').disabled = !(slots[0] || slots[1]);
+    $('btnRepeat').disabled = !(lastCombo && has(lastCombo.a) && has(lastCombo.b));
+  }
+
+  function fillNextSlot(id) {
+    if (!has(id)) return;
+    if (slots[0] === null) slots[0] = id;
+    else if (slots[1] === null) slots[1] = id;
+    else { slots[1] = id; } // both filled: replace second
+    playSound('select');
+    markSeen(id);
+    renderSlots();
+    touch();
+  }
+
+  function clearSlot(i) {
+    if (slots[i] !== null) {
+      slots[i] = null;
+      playSound('select');
+      renderSlots();
+      touch();
+    }
+  }
+
+  function combine() {
+    if (!slots[0] || !slots[1]) return;
+    var a = slots[0], b = slots[1];
+    touch();
+    var r = findRecipe(a, b);
+
+    if (r && r.nightOnly && !isNight()) {
+      state.stats.fails++;
+      save();
+      playSound('invalid');
+      setMessage('Something about this feels nocturnal.');
+      renderSlots();
+      return;
+    }
+
+    if (!r) {
+      state.stats.fails++;
+      bumpCombo(a); bumpCombo(b);
+      save();
+      playSound('invalid');
+      var line = FAILURE_LINES[Math.floor(Math.random() * FAILURE_LINES.length)];
+      if (nearMiss(a, b) && Math.random() < 0.5) line += CLUE_SUFFIX;
+      setMessage(line);
+      renderSlots();
+      return;
+    }
+
+    lastCombo = { a: a, b: b };
+    var resultId = r.result;
+    var already = has(resultId);
+    state.stats.successes++;
+    bumpCombo(a); bumpCombo(b);
+
+    // charming fuse animation, then reveal
+    var sEls = slotEls();
+    if (!reducedMotion()) {
+      sEls[0].classList.add('fusing');
+      sEls[1].classList.add('fusing');
+    }
+    var revealDelay = reducedMotion() ? 0 : 420;
+    setTimeout(function () {
+      sEls[0].classList.remove('fusing');
+      sEls[1].classList.remove('fusing');
+      revealResult(resultId, r);
+      if (already) {
+        setMessage('You already know this one: ' + ELEMENTS[resultId].name + '.', false);
+        playSound('combine');
+      } else {
+        discoverElement(resultId, r);
+      }
+      save();
+      renderSlots();
+    }, revealDelay);
+  }
+
+  function revealResult(resultId, r) {
+    var box = $('result');
+    $('resultEmoji').textContent = ELEMENTS[resultId].emoji;
+    $('resultName').textContent = ELEMENTS[resultId].name;
+    box.setAttribute('aria-hidden', 'false');
+    box.className = 'result';
+    if (!reducedMotion()) {
+      void box.offsetWidth; // restart animation
+      box.classList.add('reveal');
+      if (r.fx) box.classList.add('fx-' + r.fx);
+    }
+  }
+
+  function discoverElement(id, r) {
+    state.elements.push(id);
+    state.order[id] = state.elements.length;
+    state.newItems.push(id);
+    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'); }
+    else playSound('discover');
+
+    var msg = r.msg;
+    if (r.msgs && r.msgs.length) {
+      msg = Math.random() < 0.5 ? r.msg : r.msgs[Math.floor(Math.random() * r.msgs.length)];
+    }
+    setMessage(msg, true);
+
+    updateDaily(id);
+    renderInventory();
+    updateWorld();
+    updateDailyLine();
+  }
+
+  function bumpCombo(id) {
+    state.stats.combos[id] = (state.stats.combos[id] || 0) + 1;
+  }
+
+  // vague failure clue: an undiscovered recipe exists where one input is `a`
+  // and the other input shares a category with `b` (or vice versa)
+  function nearMiss(a, b) {
+    var catB = ELEMENTS[b].category, catA = ELEMENTS[a].category;
+    return RECIPES.some(function (r) {
+      if (has(r.result) || r.secret || r.nightOnly) return false;
+      var inputs = [r.a, r.b];
+      var ia = inputs.indexOf(a), ib = inputs.indexOf(b);
+      if (ia !== -1) {
+        var other = inputs[1 - ia];
+        return other !== b && ELEMENTS[other].category === catB;
+      }
+      if (ib !== -1) {
+        var other2 = inputs[1 - ib];
+        return other2 !== a && ELEMENTS[other2].category === catA;
+      }
+      return false;
+    });
+  }
+
+  function touch() {
+    lastAction = Date.now();
+  }
+
+  /* ---------- daily challenge ---------- */
+  function rollDaily() {
+    var info = todayInfo();
+    if (state.daily.date === info.dateStr) return;
+    var rng = mulberry32(info.seed);
+    var undiscovered = Object.keys(ELEMENTS).filter(function (id) {
+      return STARTERS.indexOf(id) === -1 && !has(id);
+    });
+    // prefer goals that are craftable right now
+    var craftable = undiscovered.filter(function (id) {
+      return (recipesByResult[id] || []).some(function (r) { return has(r.a) && has(r.b) && !r.nightOnly; });
+    });
+    var goalId = null, fallbackN = 0;
+    if (craftable.length) {
+      goalId = craftable[Math.floor(rng() * craftable.length)];
+    } else if (undiscovered.length) {
+      goalId = undiscovered[Math.floor(rng() * undiscovered.length)];
+    } else {
+      fallbackN = 3 + Math.floor(rng() * 3);
+    }
+    state.daily = { date: info.dateStr, goalId: goalId, fallbackN: fallbackN, count: 0, done: false, usedHint: false };
+    save();
+  }
+
+  function updateDaily(newId) {
+    var d = state.daily;
+    if (d.done) return;
+    d.count++;
+    if (d.goalId && newId === d.goalId) completeDaily();
+    else if (!d.goalId && d.fallbackN && d.count >= d.fallbackN) completeDaily();
+  }
+
+  function completeDaily() {
+    state.daily.done = true;
+    state.stats.dailyDone++;
+    toast('Daily challenge complete! ✨');
+    playSound('secret');
+    updateDailyLine();
+  }
+
+  function dailyShareText() {
+    var info = todayInfo();
+    var d = state.daily;
+    var goal = d.goalId ? ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name : d.fallbackN + ' new discoveries';
+    var lines = [
+      'EMOJESIS #' + info.dayNum,
+      'Goal: ' + goal,
+      d.count + ' discoveries · ' + (d.usedHint ? 'with hints 💡' : 'no hints ✨'),
+      d.done ? 'Completed ✅' : 'In progress…'
+    ];
+    return lines.join('\n');
+  }
+
+  function updateDailyLine() {
+    var d = state.daily;
+    var line = $('dailyLine');
+    if (!d.date) { line.hidden = true; return; }
+    line.hidden = false;
+    var goal = d.goalId ? 'Create ' + ELEMENTS[d.goalId].emoji + ' today' : 'Make ' + d.fallbackN + ' new discoveries today';
+    line.textContent = (d.done ? '✅ ' : '📅 ') + goal + ' · ' + d.count + ' so far';
+    $('dailyDot').hidden = !!d.done;
+  }
+
+  /* ---------- hints ---------- */
+  function regenHints() {
+    var now = Date.now();
+    var elapsed = now - (state.hints.lastRegen || now);
+    if (elapsed >= HINT_REGEN_MS && state.hints.stored < HINT_MAX) {
+      var gained = Math.floor(elapsed / HINT_REGEN_MS);
+      state.hints.stored = Math.min(HINT_MAX, state.hints.stored + gained);
+      state.hints.lastRegen = now;
+      save();
+    }
+    $('hintCount').textContent = state.hints.stored;
+  }
+
+  function useHint() {
+    touch();
+    if (state.hints.stored <= 0) {
+      toast('No hints stored. One drifts in every 10 minutes.');
+      playSound('invalid');
+      return;
+    }
+    var candidates = RECIPES.filter(function (r) {
+      return !has(r.result) && !r.secret && !r.nightOnly && has(r.a) && has(r.b);
+    });
+    if (!candidates.length) {
+      toast('No hint available right now — try combining things you haven\u2019t paired yet.');
+      return;
+    }
+    var r = candidates[Math.floor(Math.random() * candidates.length)];
+    state.hints.stored--;
+    state.hints.used++;
+    state.stats.hintsUsed++;
+    state.daily.usedHint = true;
+    save();
+    regenHints();
+    playSound('select');
+    var eA = ELEMENTS[r.a], eB = ELEMENTS[r.b];
+    setMessage('💡 ' + eA.emoji + ' ' + eA.name + ' still has something to teach ' + eB.emoji + ' ' + eB.name + '.', true);
+  }
+
+  // free hint after 90s of inactivity with 0 stored
+  setInterval(function () {
+    if (state.hints.stored === 0 && Date.now() - lastAction >= IDLE_HINT_MS) {
+      state.hints.stored = 1;
+      state.hints.lastRegen = Date.now();
+      save();
+      regenHints();
+      toast('A thought drifted in. (+1 hint)');
+    }
+    regenHints();
+  }, 30000);
+
+  /* ---------- inventory ---------- */
+  var invFilter = { search: '', category: 'all', favOnly: false, showUnknown: false, sort: 'order' };
+
+  function unlockedCategories() {
+    var cats = {};
+    state.elements.forEach(function (id) { cats[ELEMENTS[id].category] = true; });
+    return CATEGORIES.filter(function (c) { return cats[c]; });
+  }
+
+  function renderCategoryChips() {
+    var box = $('invCats');
+    box.innerHTML = '';
+    var mk = function (value, label, pressed) {
+      var b = el('button', 'chip', label);
+      b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
+      b.addEventListener('click', function () {
+        invFilter.category = value;
+        renderInventory();
+      });
+      return b;
+    };
+    box.appendChild(mk('all', 'all ' + state.elements.length, invFilter.category === 'all'));
+    unlockedCategories().forEach(function (c) {
+      var n = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length;
+      box.appendChild(mk(c, c + ' ' + n, invFilter.category === c));
+    });
+  }
+
+  function markSeen(id) {
+    var i = state.newItems.indexOf(id);
+    if (i !== -1) {
+      state.newItems.splice(i, 1);
+      save();
+      var card = document.querySelector('.inv-item[data-id="' + id + '"]');
+      if (card) card.classList.remove('is-new');
+    }
+  }
+
+  function renderInventory() {
+    renderCategoryChips();
+    var grid = $('invGrid');
+    grid.innerHTML = '';
+    $('invCount').textContent = state.elements.length + '/' + Object.keys(ELEMENTS).length;
+
+    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; });
+    }
+    if (invFilter.category !== 'all') {
+      ids = ids.filter(function (id) { return ELEMENTS[id].category === invFilter.category; });
+    }
+    if (invFilter.favOnly) {
+      ids = ids.filter(function (id) { return state.favorites.indexOf(id) !== -1; });
+    }
+    ids.sort(function (x, y) {
+      var fx = state.favorites.indexOf(x) !== -1 ? 0 : 1;
+      var fy = state.favorites.indexOf(y) !== -1 ? 0 : 1;
+      if (fx !== fy) return fx - fy;
+      if (invFilter.sort === 'alpha') return ELEMENTS[x].name.localeCompare(ELEMENTS[y].name);
+      return (state.order[x] || 0) - (state.order[y] || 0);
+    });
+
+    ids.forEach(function (id) { grid.appendChild(buildCard(id)); });
+
+    if (invFilter.showUnknown && !invFilter.search && invFilter.favOnly === false) {
+      Object.keys(ELEMENTS).forEach(function (id) {
+        var e = ELEMENTS[id];
+        if (has(id) || e.secret) return;
+        if (invFilter.category !== 'all' && e.category !== invFilter.category) return;
+        grid.appendChild(buildUnknownCard(e));
+      });
+    }
+
+    if (!grid.children.length) {
+      grid.appendChild(el('p', 'inv-empty', invFilter.favOnly ? 'No favorites yet. Star something you love.' : 'Nothing here yet.'));
+    }
+  }
+
+  function buildCard(id) {
+    var e = ELEMENTS[id];
+    var card = el('div', 'inv-item');
+    card.dataset.id = id;
+    if (state.newItems.indexOf(id) !== -1) card.classList.add('is-new');
+    if (state.firstRun && (id === 'fire' || id === 'water')) card.classList.add('ob-pulse');
+
+    var main = el('button', 'inv-main');
+    main.draggable = true;
+    main.setAttribute('aria-label', e.name + ' (' + e.category + '). Activate to place in a combination slot.');
+    var em = el('span', 'inv-emoji', e.emoji);
+    em.setAttribute('aria-hidden', 'true');
+    main.appendChild(em);
+    main.appendChild(el('span', 'inv-name', e.name));
+    main.addEventListener('click', function () {
+      if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); }
+      fillNextSlot(id);
+    });
+    main.addEventListener('dragstart', function (ev) {
+      ev.dataTransfer.setData('text/plain', id);
+      ev.dataTransfer.effectAllowed = 'copy';
+    });
+
+    var fav = el('button', 'inv-fav', state.favorites.indexOf(id) !== -1 ? '⭐' : '☆');
+    if (state.favorites.indexOf(id) !== -1) fav.classList.add('on');
+    fav.setAttribute('aria-label', 'Toggle favorite for ' + e.name);
+    fav.addEventListener('click', function () { toggleFavorite(id); });
+
+    var info = el('button', 'inv-info', 'ⓘ');
+    info.setAttribute('aria-label', 'Open journal entry for ' + e.name);
+    info.addEventListener('click', function () { openJournal(id); });
+
+    card.appendChild(main);
+    card.appendChild(fav);
+    card.appendChild(info);
+    return card;
+  }
+
+  function buildUnknownCard(e) {
+    var card = el('div', 'inv-item');
+    var main = el('button', 'inv-main');
+    main.setAttribute('aria-label', 'Undiscovered element in category ' + e.category);
+    var em = el('span', 'inv-emoji', '❓');
+    em.setAttribute('aria-hidden', 'true');
+    main.appendChild(em);
+    main.appendChild(el('span', 'inv-name', e.category));
+    main.addEventListener('click', function () {
+      setMessage('Something undiscovered in ' + e.category + '. The laboratory waits.', false);
+    });
+    card.appendChild(main);
+    return card;
+  }
+
+  function toggleFavorite(id) {
+    var i = state.favorites.indexOf(id);
+    if (i === -1) state.favorites.push(id);
+    else state.favorites.splice(i, 1);
+    save();
+    playSound('select');
+    renderInventory();
+  }
+
+  function clearOnboardingPulse() {
+    document.querySelectorAll('.ob-pulse').forEach(function (n) { n.classList.remove('ob-pulse'); });
+  }
+
+  /* ---------- journal ---------- */
+  var journalId = null;
+
+  function openJournal(id) {
+    journalId = id;
+    markSeen(id);
+    var e = ELEMENTS[id];
+    var body = $('journalBody');
+    body.innerHTML = '';
+
+    var head = el('div', 'journal-head');
+    head.appendChild(el('span', 'journal-emoji', e.emoji));
+    var titleWrap = el('div');
+    var h2 = el('h2', null, e.name);
+    h2.id = 'journalTitle';
+    titleWrap.appendChild(h2);
+    titleWrap.appendChild(el('div', 'journal-meta',
+      e.category + ' · discovery #' + (state.order[id] || '?')));
+    head.appendChild(titleWrap);
+    body.appendChild(head);
+    body.appendChild(el('p', null, e.description));
+
+    var madeBy = (recipesByResult[id] || []).filter(function (r) { return recipeDiscovered(r); });
+    if (madeBy.length) {
+      body.appendChild(el('h3', 'journal-sec', 'Created by'));
+      body.appendChild(recipeList(madeBy));
+    }
+    var usedIn = (recipesByInput[id] || []).filter(function (r) { return recipeDiscovered(r) && has(r.result); });
+    if (usedIn.length) {
+      body.appendChild(el('h3', 'journal-sec', 'Used in'));
+      body.appendChild(recipeList(usedIn));
+    }
+
+    // vague hint about undiscovered relationships
+    var unknownCats = {};
+    (recipesByInput[id] || []).forEach(function (r) {
+      if (!has(r.result)) {
+        var other = r.a === id ? r.b : r.a;
+        if (has(other)) unknownCats[ELEMENTS[r.result].category] = true;
+      }
+    });
+    var cats = Object.keys(unknownCats);
+    if (cats.length) {
+      body.appendChild(el('p', 'journal-hint',
+        'It still reacts with something in ' + cats.join(' and ') + '.'));
+    }
+    openModal('modalJournal');
+  }
+
+  function recipeDiscovered(r) {
+    var key = r.ordered ? r.a + '>' + r.b : [r.a, r.b].sort().join('|');
+    return state.recipes.indexOf(key) !== -1;
+  }
+
+  function recipeList(recipes) {
+    var ul = el('ul', 'journal-recipes');
+    recipes.forEach(function (r) {
+      var li = el('li', null,
+        ELEMENTS[r.a].emoji + ' ' + ELEMENTS[r.a].name + ' + ' +
+        ELEMENTS[r.b].emoji + ' ' + ELEMENTS[r.b].name + ' → ' +
+        ELEMENTS[r.result].emoji + ' ' + ELEMENTS[r.result].name);
+      ul.appendChild(li);
+    });
+    return ul;
+  }
+
+  /* ---------- world view ---------- */
+  function updateWorld() {
+    document.querySelectorAll('.w-slot').forEach(function (slot) {
+      slot.classList.toggle('found', has(slot.dataset.el));
+    });
+  }
+
+  /* ---------- stats ---------- */
+  function renderStats() {
+    var dl = $('statsList');
+    dl.innerHTML = '';
+    var totalEl = Object.keys(ELEMENTS).length;
+    var catDone = unlockedCategories().map(function (c) {
+      var total = Object.keys(ELEMENTS).filter(function (id) { return ELEMENTS[id].category === c; }).length;
+      var got = state.elements.filter(function (id) { return ELEMENTS[id].category === c; }).length;
+      return c + ' ' + got + '/' + total;
+    });
+    var favId = null, favN = 0;
+    Object.keys(state.stats.combos).forEach(function (id) {
+      if (state.stats.combos[id] > favN) { favN = state.stats.combos[id]; favId = id; }
+    });
+    var mins = Math.floor(state.stats.playMs / 60000);
+    var rows = [
+      ['elements', state.elements.length + ' / ' + totalEl],
+      ['recipes', state.recipes.length + ' / ' + RECIPES.length],
+      ['categories', catDone.join(' · ')],
+      ['experiments ✓', String(state.stats.successes)],
+      ['experiments ✗', String(state.stats.fails)],
+      ['hints used', String(state.stats.hintsUsed)],
+      ['secrets found', String(state.stats.secrets)],
+      ['daily challenges', String(state.stats.dailyDone)],
+      ['favorites', String(state.favorites.length)],
+      ['most combined', favId ? ELEMENTS[favId].emoji + ' ' + ELEMENTS[favId].name + ' (' + favN + ')' : '—'],
+      ['time in lab', mins + ' min']
+    ];
+    rows.forEach(function (row) {
+      dl.appendChild(el('dt', null, row[0]));
+      dl.appendChild(el('dd', null, row[1]));
+    });
+  }
+
+  /* ---------- modals ---------- */
+  function openModal(id) {
+    var m = $(id);
+    m.hidden = false;
+    var btn = m.querySelector('button, input, select');
+    if (btn) btn.focus();
+  }
+  function closeModal(id) {
+    $(id).hidden = true;
+  }
+  document.addEventListener('click', function (ev) {
+    var closer = ev.target.closest('.modal-close');
+    if (closer) closeModal(closer.dataset.close);
+    var modal = ev.target.classList && ev.target.classList.contains('modal') ? ev.target : null;
+    if (modal) modal.hidden = true;
+  });
+  document.addEventListener('keydown', function (ev) {
+    if (ev.key === 'Escape') {
+      document.querySelectorAll('.modal:not([hidden])').forEach(function (m) { m.hidden = true; });
+    }
+  });
+
+  /* ---------- theme & sound ---------- */
+  function applyTheme() {
+    var t = state.settings.theme;
+    if (t === 'auto') document.documentElement.removeAttribute('data-theme');
+    else document.documentElement.setAttribute('data-theme', t);
+    $('btnTheme').textContent = t === 'dark' ? '🌙' : t === 'light' ? '☀️' : '🌗';
+    $('setTheme').value = t;
+  }
+  function cycleTheme() {
+    var order = ['auto', 'light', 'dark'];
+    var i = order.indexOf(state.settings.theme);
+    state.settings.theme = order[(i + 1) % order.length];
+    save();
+    applyTheme();
+  }
+  function applySound() {
+    $('btnSound').textContent = state.settings.sound ? '🔊' : '🔇';
+    $('btnSound').setAttribute('aria-label', state.settings.sound ? 'Mute sound' : 'Unmute sound');
+    $('setSound').checked = state.settings.sound;
+  }
+
+  /* ---------- save export / import / reset ---------- */
+  function exportSave() {
+    var blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
+    var a = document.createElement('a');
+    a.href = URL.createObjectURL(blob);
+    a.download = 'emojesis-save.json';
+    a.click();
+    setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
+  }
+  function importSave(file) {
+    var reader = new FileReader();
+    reader.onload = function () {
+      try {
+        var s = JSON.parse(reader.result);
+        if (!s || !Array.isArray(s.elements)) throw new Error('bad save');
+        localStorage.setItem(SAVE_KEY, JSON.stringify(s));
+        location.reload();
+      } catch (e) {
+        toast('That file is not an EMOJESIS save.');
+      }
+    };
+    reader.readAsText(file);
+  }
+
+  /* ---------- easter egg: title clicking ---------- */
+  var eggTimer = null;
+  function titleClick() {
+    state.eggClicks++;
+    save();
+    clearTimeout(eggTimer);
+    eggTimer = setTimeout(function () {
+      if (state.eggClicks < 7) { state.eggClicks = 0; save(); }
+    }, 2500);
+    if (state.eggClicks === 7) {
+      toast('Stop that.');
+      playSound('invalid');
+    } else if (state.eggClicks >= 10) {
+      state.eggClicks = 0;
+      if (!has('egg')) {
+        state.elements.push('egg');
+        state.order.egg = state.elements.length;
+        state.newItems.push('egg');
+        state.stats.secrets++;
+        save();
+        renderInventory();
+        updateWorld();
+        playSound('secret');
+        toast('…fine. 🥚');
+      } else {
+        toast('You already have the egg. There is no second egg.');
+      }
+    } else {
+      playSound('select');
+    }
+  }
+
+  /* ---------- easter egg: terminal ---------- */
+  function termPrint(text) {
+    var out = $('termOut');
+    out.textContent += text + '\n';
+    out.scrollTop = out.scrollHeight;
+  }
+  function termRun(cmdRaw) {
+    var cmd = cmdRaw.trim().toLowerCase();
+    if (!cmd) return;
+    termPrint('> ' + cmdRaw);
+    if (cmd === 'help') {
+      termPrint('available commands:\n  help    — this\n  about   — what is this place\n  sudo    — escalate\n  netuno  — listen to the outer dark\n  feed    — subscribe to the signal\n  clear   — wipe the glass\n  exit    — leave');
+    } else if (cmd === 'about') {
+      termPrint('EMOJESIS v1.0 — a tiny universe made of symbols.\nPablo Murad, 2026.\nno accounts. no tracking. no ads. only combinations.');
+    } else if (cmd === 'sudo') {
+      termPrint('permission granted. you were always the admin of this universe.');
+    } else if (cmd === 'netuno') {
+      if (!has('netuno')) {
+        state.elements.push('netuno');
+        state.order.netuno = state.elements.length;
+        state.newItems.push('netuno');
+        state.stats.secrets++;
+        save();
+        renderInventory();
+        updateWorld();
+        playSound('secret');
+        termPrint('🜲 NETUNO: signal received from the outer dark.\n🪐 Netuno has been added to your collection.');
+        toast('🪐 Netuno discovered.');
+      } else {
+        termPrint('🜲 NETUNO: the signal is already within you.');
+      }
+    } else if (cmd === 'feed') {
+      termPrint('the signal broadcasts at ./feed.xml — bring your own reader.');
+    } else if (cmd === 'clear') {
+      $('termOut').textContent = '';
+    } else if (cmd === 'exit') {
+      closeModal('modalTerm');
+    } else if (cmd === 'ls') {
+      termPrint('fire  water  earth  air  …  ' + state.elements.length + ' symbols and counting');
+    } else if (cmd === 'whoami') {
+      termPrint('a person combining symbols at ' + new Date().toLocaleTimeString() + '.');
+    } else {
+      termPrint('command not found: ' + cmd + ' — try \u2019help\u2019');
+    }
+  }
+
+  /* ---------- onboarding ---------- */
+  function showOnboarding() {
+    $('onboarding').hidden = false;
+    $('btnBegin').focus();
+  }
+
+  /* ---------- wiring ---------- */
+  function init() {
+    regenHints();
+    rollDaily();
+    applyTheme();
+    applySound();
+    renderSlots();
+    renderInventory();
+    updateWorld();
+    updateDailyLine();
+
+    // slots: click to clear
+    slotEls().forEach(function (s, i) {
+      s.addEventListener('click', function () { clearSlot(i); });
+      s.addEventListener('dragover', function (ev) {
+        ev.preventDefault();
+        s.classList.add('drop-hover');
+      });
+      s.addEventListener('dragleave', function () { s.classList.remove('drop-hover'); });
+      s.addEventListener('drop', function (ev) {
+        ev.preventDefault();
+        s.classList.remove('drop-hover');
+        var id = ev.dataTransfer.getData('text/plain');
+        if (id && has(id)) {
+          slots[i] = id;
+          playSound('select');
+          markSeen(id);
+          if (state.firstRun) { state.firstRun = false; save(); clearOnboardingPulse(); }
+          renderSlots();
+          touch();
+        }
+      });
+    });
+
+    $('btnCombine').addEventListener('click', combine);
+    $('btnSwap').addEventListener('click', function () {
+      var t = slots[0]; slots[0] = slots[1]; slots[1] = t;
+      playSound('select');
+      renderSlots();
+      touch();
+    });
+    $('btnRepeat').addEventListener('click', function () {
+      if (lastCombo && has(lastCombo.a) && has(lastCombo.b)) {
+        slots[0] = lastCombo.a;
+        slots[1] = lastCombo.b;
+        renderSlots();
+        combine();
+      }
+    });
+
+    // inventory tools
+    $('invSearch').addEventListener('input', function (ev) {
+      invFilter.search = ev.target.value;
+      renderInventory();
+    });
+    $('invSort').addEventListener('change', function (ev) {
+      invFilter.sort = ev.target.value;
+      renderInventory();
+    });
+    $('invFav').addEventListener('click', function () {
+      invFilter.favOnly = !invFilter.favOnly;
+      $('invFav').setAttribute('aria-pressed', invFilter.favOnly ? 'true' : 'false');
+      renderInventory();
+    });
+    var unknownChip = el('button', 'chip', '❓');
+    unknownChip.setAttribute('aria-pressed', 'false');
+    unknownChip.title = 'Show undiscovered silhouettes';
+    unknownChip.setAttribute('aria-label', 'Show undiscovered elements');
+    unknownChip.addEventListener('click', function () {
+      invFilter.showUnknown = !invFilter.showUnknown;
+      unknownChip.setAttribute('aria-pressed', invFilter.showUnknown ? 'true' : 'false');
+      renderInventory();
+    });
+    document.querySelector('.inv-tools').appendChild(unknownChip);
+
+    // 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');
+      } else {
+        inv.classList.toggle('collapsed');
+        $('invToggle').setAttribute('aria-expanded', inv.classList.contains('collapsed') ? 'false' : 'true');
+      }
+    });
+
+    // world toggle
+    $('worldToggle').addEventListener('click', function () {
+      var w = $('world');
+      w.classList.toggle('collapsed');
+      $('worldToggle').setAttribute('aria-expanded', w.classList.contains('collapsed') ? 'false' : 'true');
+    });
+
+    // header buttons
+    $('btnHint').addEventListener('click', useHint);
+    $('btnTheme').addEventListener('click', cycleTheme);
+    $('btnSound').addEventListener('click', function () {
+      state.settings.sound = !state.settings.sound;
+      save();
+      applySound();
+      if (state.settings.sound) playSound('select');
+    });
+    $('btnStats').addEventListener('click', function () { renderStats(); openModal('modalStats'); });
+    $('btnAbout').addEventListener('click', function () { openModal('modalAbout'); });
+    $('footAbout').addEventListener('click', function () { openModal('modalAbout'); });
+    $('btnDaily').addEventListener('click', function () {
+      rollDaily();
+      var d = state.daily;
+      $('dailyGoal').textContent = d.goalId
+        ? 'Create ' + ELEMENTS[d.goalId].emoji + ' ' + ELEMENTS[d.goalId].name + ' today.'
+        : 'Make ' + d.fallbackN + ' new discoveries today.';
+      $('dailyProgress').textContent = d.count + ' discoveries so far' + (d.done ? ' — completed ✅' : '.');
+      $('dailyShare').textContent = dailyShareText();
+      openModal('modalDaily');
+    });
+    $('btnCopyDaily').addEventListener('click', function () {
+      var text = dailyShareText();
+      var doneOk = function () { toast('Copied. Share it somewhere small and personal.'); };
+      if (navigator.clipboard && navigator.clipboard.writeText) {
+        navigator.clipboard.writeText(text).then(doneOk, function () { fallbackCopy(text); doneOk(); });
+      } else {
+        fallbackCopy(text);
+        doneOk();
+      }
+    });
+
+    // journal "send to slot"
+    $('journalUse').addEventListener('click', function () {
+      if (journalId) fillNextSlot(journalId);
+      closeModal('modalJournal');
+    });
+
+    // settings
+    $('setTheme').addEventListener('change', function (ev) {
+      state.settings.theme = ev.target.value;
+      save();
+      applyTheme();
+    });
+    $('setSound').addEventListener('change', function (ev) {
+      state.settings.sound = ev.target.checked;
+      save();
+      applySound();
+    });
+    $('btnExport').addEventListener('click', exportSave);
+    $('btnImport').addEventListener('click', function () { $('importFile').click(); });
+    $('importFile').addEventListener('change', function (ev) {
+      if (ev.target.files && ev.target.files[0]) importSave(ev.target.files[0]);
+    });
+    $('btnReset').addEventListener('click', function () { $('resetConfirm').hidden = false; });
+    $('btnResetNo').addEventListener('click', function () { $('resetConfirm').hidden = true; });
+    $('btnResetYes').addEventListener('click', function () {
+      localStorage.removeItem(SAVE_KEY);
+      location.reload();
+    });
+
+    // easter eggs
+    $('titleEmoji').addEventListener('click', titleClick);
+    $('footTerminal').addEventListener('click', function () {
+      openModal('modalTerm');
+      if (!$('termOut').textContent) {
+        termPrint('EMOJESIS shell v1.0 — type \u2019help\u2019.');
+      }
+      $('termInput').focus();
+    });
+    $('termForm').addEventListener('submit', function (ev) {
+      ev.preventDefault();
+      var v = $('termInput').value;
+      $('termInput').value = '';
+      termRun(v);
+    });
+
+    // onboarding
+    $('btnBegin').addEventListener('click', function () {
+      $('onboarding').hidden = true;
+      touch();
+    });
+    if (state.firstRun && state.elements.length <= STARTERS.length) {
+      showOnboarding();
+    }
+
+    // activity tracking for the idle hint
+    ['pointerdown', 'keydown'].forEach(function (evt) {
+      document.addEventListener(evt, touch, { passive: true });
+    });
+
+    // re-roll daily if the date changes while open
+    setInterval(function () {
+      var info = todayInfo();
+      if (state.daily.date !== info.dateStr) {
+        rollDaily();
+        updateDailyLine();
+        toast('A new day, a new challenge. 📅');
+      }
+    }, 60000);
+  }
+
+  function fallbackCopy(text) {
+    var ta = document.createElement('textarea');
+    ta.value = text;
+    document.body.appendChild(ta);
+    ta.select();
+    try { document.execCommand('copy'); } catch (e) { /* best effort */ }
+    ta.remove();
+  }
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', init);
+  } else {
+    init();
+  }
+
+  // optional offline cache; never registered on file://
+  if ('serviceWorker' in navigator && location.protocol.indexOf('http') === 0) {
+    navigator.serviceWorker.register('sw.js').catch(function () { /* offline cache unavailable */ });
+  }
+})();
@@ -0,0 +1,359 @@+/* EMOJESIS — game data. Pure data, no DOM.
+   Works in the browser (attaches to globalThis.EMOJESIS_DATA)
+   and in Node (module.exports) for validate.js. */
+(function () {
+  'use strict';
+
+  var CATEGORIES = [
+    'nature', 'weather', 'life', 'animals', 'humanity', 'civilization',
+    'culture', 'technology', 'internet', 'occult', 'cosmos', 'absurd'
+  ];
+
+  var STARTERS = ['fire', 'water', 'earth', 'air'];
+
+  /* Element: { id, emoji, name, category, description, secret? } */
+  var ELEMENTS = {
+    /* ---- starters ---- */
+    fire:  { id: 'fire',  emoji: '🔥', name: 'Fire',  category: 'nature', description: 'Warmth, destruction, and the first bad idea.' },
+    water: { id: 'water', emoji: '💧', name: 'Water', category: 'nature', description: 'It remembers every shape it has ever been.' },
+    earth: { id: 'earth', emoji: '🪨', name: 'Earth', category: 'nature', description: 'Patient, heavy, and full of opinions.' },
+    air:   { id: 'air',   emoji: '💨', name: 'Air',   category: 'nature', description: 'Invisible, everywhere, and slightly dramatic.' },
+
+    /* ---- nature ---- */
+    mud:      { id: 'mud',      emoji: '🟤', name: 'Mud',      category: 'nature', description: 'Earth that gave up on posture.' },
+    lava:     { id: 'lava',     emoji: '🌋', name: 'Lava',     category: 'nature', description: 'The planet\u2019s way of thinking out loud.' },
+    ocean:    { id: 'ocean',    emoji: '🌊', name: 'Ocean',    category: 'nature', description: 'Water, ambition edition.' },
+    mountain: { id: 'mountain', emoji: '⛰️', name: 'Mountain', category: 'nature', description: 'A very long argument with the sky.' },
+    wood:     { id: 'wood',     emoji: '🪵', name: 'Wood',     category: 'nature', description: 'A tree, repurposed.' },
+    honey:    { id: 'honey',    emoji: '🍯', name: 'Honey',    category: 'nature', description: 'A lifetime of small errands, bottled.' },
+
+    /* ---- weather ---- */
+    steam:     { id: 'steam',     emoji: '🌫️', name: 'Steam',     category: 'weather', description: 'Opposites, agreeing to disagree upward.' },
+    cloud:     { id: 'cloud',     emoji: '☁️', name: 'Cloud',     category: 'weather', description: 'The sky\u2019s rough draft.' },
+    rain:      { id: 'rain',      emoji: '🌧️', name: 'Rain',      category: 'weather', description: 'The cloud finally admitted something.' },
+    wind:      { id: 'wind',      emoji: '🌬️', name: 'Wind',      category: 'weather', description: 'Air with somewhere to be.' },
+    storm:     { id: 'storm',     emoji: '⛈️', name: 'Storm',     category: 'weather', description: 'Two clouds having it out.' },
+    lightning: { id: 'lightning', emoji: '⚡', name: 'Lightning', category: 'weather', description: 'The sky, briefly honest.' },
+    snow:      { id: 'snow',      emoji: '❄️', name: 'Snow',      category: 'weather', description: 'Rain, but it learned to whisper.' },
+    rainbow:   { id: 'rainbow',   emoji: '🌈', name: 'Rainbow',   category: 'weather', description: 'An apology from the weather.' },
+    fog:       { id: 'fog',       emoji: '🌁', name: 'Fog',       category: 'weather', description: 'A cloud that lost its nerve.' },
+
+    /* ---- life ---- */
+    life:   { id: 'life',   emoji: '🌱', name: 'Life',   category: 'life', description: 'The mud flinched first.' },
+    plant:  { id: 'plant',  emoji: '🌿', name: 'Plant',  category: 'life', description: 'Life, rooted and smug about it.' },
+    tree:   { id: 'tree',   emoji: '🌳', name: 'Tree',   category: 'life', description: 'Patience, made visible.' },
+    flower: { id: 'flower', emoji: '🌸', name: 'Flower', category: 'life', description: 'The plant\u2019s loud opinion.' },
+    wheat:  { id: 'wheat',  emoji: '🌾', name: 'Wheat',  category: 'life', description: 'Grass with ambitions.' },
+    forest: { id: 'forest', emoji: '🌲', name: 'Forest', category: 'life', description: 'Trees discovered crowds.' },
+
+    /* ---- animals ---- */
+    fish:      { id: 'fish',      emoji: '🐟', name: 'Fish',      category: 'animals', description: 'Life learned to swim before it learned to walk. Priorities.' },
+    bird:      { id: 'bird',      emoji: '🐦', name: 'Bird',      category: 'animals', description: 'Life, ignoring gravity\u2019s memo.' },
+    egg:       { id: 'egg',       emoji: '🥚', name: 'Egg',       category: 'animals', description: 'A bird\u2019s most confident decision.' },
+    cat:       { id: 'cat',       emoji: '🐈', name: 'Cat',       category: 'animals', description: 'Something small decided to live with you.' },
+    dog:       { id: 'dog',       emoji: '🐶', name: 'Dog',       category: 'animals', description: 'The wolf made an excellent mistake.' },
+    wolf:      { id: 'wolf',      emoji: '🐺', name: 'Wolf',      category: 'animals', description: 'The mountain learned to hunt.' },
+    bee:       { id: 'bee',       emoji: '🐝', name: 'Bee',       category: 'animals', description: 'A flower\u2019s courier service.' },
+    snake:     { id: 'snake',     emoji: '🐍', name: 'Snake',     category: 'animals', description: 'The garden got interesting.' },
+    whale:     { id: 'whale',     emoji: '🐋', name: 'Whale',     category: 'animals', description: 'Time and water, taken seriously.' },
+    owl:       { id: 'owl',       emoji: '🦉', name: 'Night Owl', category: 'animals', secret: true, description: 'It only interviews at night.' },
+    dinosaur:  { id: 'dinosaur',  emoji: '🦖', name: 'Dinosaur',  category: 'animals', description: 'Some experiments take longer.' },
+    butterfly: { id: 'butterfly', emoji: '🦋', name: 'Butterfly', category: 'animals', description: 'A rumor with wings.' },
+
+    /* ---- humanity ---- */
+    human: { id: 'human', emoji: '🧑', name: 'Human', category: 'humanity', description: 'From mud, something stood up.' },
+    brain: { id: 'brain', emoji: '🧠', name: 'Brain', category: 'humanity', description: 'A library that argues back.' },
+    love:  { id: 'love',  emoji: '❤️', name: 'Love',  category: 'humanity', description: 'A self-combination with consequences.' },
+
+    /* ---- civilization ---- */
+    tool:    { id: 'tool',    emoji: '🔨', name: 'Tool',    category: 'civilization', description: 'The rock was promoted.' },
+    house:   { id: 'house',   emoji: '🏠', name: 'House',   category: 'civilization', description: 'A tree, forgiven.' },
+    village: { id: 'village', emoji: '🏘️', name: 'Village', category: 'civilization', description: 'Houses discovered gossip.' },
+    city:    { id: 'city',    emoji: '🏙️', name: 'City',    category: 'civilization', description: 'A village that stopped sleeping.' },
+    wheel:   { id: 'wheel',   emoji: '🛞', name: 'Wheel',   category: 'civilization', description: 'The circle\u2019s career began.' },
+    bread:   { id: 'bread',   emoji: '🍞', name: 'Bread',   category: 'civilization', description: 'Grain, transformed by impatience.' },
+    coffee:  { id: 'coffee',  emoji: '☕', name: 'Coffee',  category: 'civilization', description: 'The bean demanded fire.' },
+    paper:   { id: 'paper',   emoji: '📄', name: 'Paper',   category: 'civilization', description: 'The tree, flattened into memory.' },
+    book:    { id: 'book',    emoji: '📚', name: 'Book',    category: 'civilization', description: 'Paper with a spine and opinions.' },
+    metal:   { id: 'metal',   emoji: '🔩', name: 'Metal',   category: 'civilization', description: 'The mountain\u2019s hidden temper.' },
+    sword:   { id: 'sword',   emoji: '⚔️', name: 'Sword',   category: 'civilization', description: 'An argument made of metal.' },
+    boat:    { id: 'boat',    emoji: '⛵', name: 'Boat',    category: 'civilization', description: 'The wood forgave the water.' },
+    ship:    { id: 'ship',    emoji: '🚢', name: 'Ship',    category: 'civilization', description: 'A boat that stopped being polite.' },
+    bridge:  { id: 'bridge',  emoji: '🌉', name: 'Bridge',  category: 'civilization', description: 'The mountain\u2019s shortcut.' },
+    castle:  { id: 'castle',  emoji: '🏰', name: 'Castle',  category: 'civilization', description: 'A house with trust issues.' },
+    engine:  { id: 'engine',  emoji: '🚂', name: 'Engine',  category: 'civilization', description: 'Steam, given a job.' },
+    gold:    { id: 'gold',    emoji: '🪙', name: 'Gold',    category: 'civilization', description: 'Shinier with age.' },
+    money:   { id: 'money',   emoji: '💵', name: 'Money',   category: 'civilization', description: 'Gold that fits in a pocket.' },
+    key:     { id: 'key',     emoji: '🗝️', name: 'Key',     category: 'civilization', description: 'It opens exactly one thing. Good luck.' },
+
+    /* ---- culture ---- */
+    writing:       { id: 'writing',       emoji: '✍️', name: 'Writing',        category: 'culture', description: 'Thoughts, taxidermied.' },
+    art:           { id: 'art',           emoji: '🎨', name: 'Art',            category: 'culture', description: 'The rainbow, on purpose.' },
+    music:         { id: 'music',         emoji: '🎵', name: 'Music',          category: 'culture', description: 'The bird\u2019s idea, stolen gracefully.' },
+    philosophy:    { id: 'philosophy',    emoji: '🤔', name: 'Philosophy',     category: 'culture', description: 'Two books arguing forever.' },
+    forbiddenBook: { id: 'forbiddenBook', emoji: '📕', name: 'Forbidden Book', category: 'culture', secret: true, description: 'Some books are burned because they burn back.' },
+    library:       { id: 'library',       emoji: '🏛️', name: 'Library',        category: 'culture', description: 'A house that remembers for you.' },
+    statue:        { id: 'statue',        emoji: '🗿', name: 'Statue',         category: 'culture', description: 'Someone wanted to be remembered badly.' },
+    theatre:       { id: 'theatre',       emoji: '🎭', name: 'Theatre',        category: 'culture', description: 'Art that watches you back.' },
+    idea:          { id: 'idea',          emoji: '💡', name: 'Idea',           category: 'culture', description: 'A dangerous amount of electricity, safely contained.' },
+
+    /* ---- technology ---- */
+    computer:       { id: 'computer',       emoji: '💻', name: 'Computer',        category: 'technology', description: 'An idea with a fan.' },
+    robot:          { id: 'robot',          emoji: '🤖', name: 'Robot',           category: 'technology', description: 'A brain with a warranty.' },
+    ai:             { id: 'ai',             emoji: '💭', name: 'AI',              category: 'technology', description: 'The machine started finishing your sentences.' },
+    developer:      { id: 'developer',      emoji: '👨‍💻', name: 'Developer',       category: 'technology', description: 'Runs on caffeine and unresolved tickets.' },
+    battery:        { id: 'battery',        emoji: '🔋', name: 'Battery',         category: 'technology', description: 'Lightning, grounded.' },
+    antenna:        { id: 'antenna',        emoji: '📡', name: 'Antenna',         category: 'technology', description: 'A metal ear for invisible things.' },
+    rocket:         { id: 'rocket',         emoji: '🚀', name: 'Rocket',          category: 'technology', description: 'A ship that chose violence over water.' },
+    satellite:      { id: 'satellite',      emoji: '🛰️', name: 'Satellite',       category: 'technology', description: 'An antenna that moved out.' },
+    telescope:      { id: 'telescope',      emoji: '🔭', name: 'Telescope',       category: 'technology', description: 'The mountain had questions too.' },
+    phone:          { id: 'phone',          emoji: '📱', name: 'Phone',           category: 'technology', description: 'A computer for your pocket and your every waking moment.' },
+    wifi:           { id: 'wifi',           emoji: '📶', name: 'Wi-Fi',           category: 'technology', description: 'The internet, untethered and unbothered.' },
+    syntheticHeart: { id: 'syntheticHeart', emoji: '🦾', name: 'Synthetic Heart', category: 'technology', secret: true, description: 'It beats. That\u2019s the disturbing part.' },
+
+    /* ---- internet ---- */
+    internet:        { id: 'internet',        emoji: '🌐', name: 'Internet',         category: 'internet', description: 'Two computers started talking. Nobody stopped them.' },
+    link:            { id: 'link',            emoji: '🔗', name: 'Link',             category: 'internet', description: 'A door made of text.' },
+    personalWebsite: { id: 'personalWebsite', emoji: '🛖', name: 'Personal Website', category: 'internet', description: 'You made a home on the web.' },
+    blog:            { id: 'blog',            emoji: '📰', name: 'Blog',             category: 'internet', secret: true, description: 'A diary the whole world is welcome to ignore.' },
+    rss:             { id: 'rss',             emoji: '🔁', name: 'RSS',              category: 'internet', secret: true, description: 'The web, as it was meant to be read.' },
+    internetCat:     { id: 'internetCat',     emoji: '🐱', name: 'Internet Cat',     category: 'internet', description: 'The internet\u2019s true purpose.' },
+    meme:            { id: 'meme',            emoji: '🤣', name: 'Meme',             category: 'internet', description: 'An idea in its final, unstoppable form.' },
+    deadWeb:         { id: 'deadWeb',         emoji: '🕸️', name: 'Dead Web',         category: 'internet', secret: true, description: 'The links still point somewhere. Nothing answers.' },
+    virus:           { id: 'virus',           emoji: '🦠', name: 'Virus',            category: 'internet', description: 'It only wanted to multiply.' },
+    glitch:          { id: 'glitch',          emoji: '👾', name: 'Glitch',           category: 'internet', secret: true, description: 'The machine dreamed of a machine.' },
+
+    /* ---- occult ---- */
+    skull:     { id: 'skull',     emoji: '💀', name: 'Skull',      category: 'occult', description: 'Everyone\u2019s final form.' },
+    ghost:     { id: 'ghost',     emoji: '👻', name: 'Ghost',      category: 'occult', secret: true, description: 'Death, but it left the light on.' },
+    undead:    { id: 'undead',    emoji: '🧟', name: 'Undead',     category: 'occult', description: 'Love, unfortunately, finds a way.' },
+    secret:    { id: 'secret',    emoji: '🔮', name: 'Secret',     category: 'occult', secret: true, description: 'Some doors only open at night.' },
+    blackHole: { id: 'blackHole', emoji: '🕳️', name: 'Black Hole', category: 'occult', secret: true, description: 'The universe\u2019s locked drawer.' },
+    phoenix:   { id: 'phoenix',   emoji: '🐦‍🔥', name: 'Phoenix',    category: 'occult', secret: true, description: 'It read the whole thing and came back louder.' },
+
+    /* ---- cosmos ---- */
+    time:      { id: 'time',      emoji: '⏳', name: 'Time',      category: 'cosmos', description: 'The world learned patience.' },
+    night:     { id: 'night',     emoji: '🌃', name: 'Night',     category: 'cosmos', description: 'Time, left alone, becomes night.' },
+    moon:      { id: 'moon',      emoji: '🌙', name: 'Moon',      category: 'cosmos', description: 'The sky\u2019s oldest rock.' },
+    star:      { id: 'star',      emoji: '⭐', name: 'Star',      category: 'cosmos', description: 'A light that outlived its fire.' },
+    sun:       { id: 'sun',       emoji: '☀️', name: 'Sun',       category: 'cosmos', secret: true, description: 'Fire, squared and glorified.' },
+    galaxy:    { id: 'galaxy',    emoji: '🌌', name: 'Galaxy',    category: 'cosmos', description: 'A city of suns.' },
+    eclipse:   { id: 'eclipse',   emoji: '🌑', name: 'Eclipse',   category: 'cosmos', secret: true, description: 'The sky\u2019s private joke.' },
+    ufo:       { id: 'ufo',       emoji: '🛸', name: 'UFO',       category: 'cosmos', secret: true, description: 'It blinked back.' },
+    alien:     { id: 'alien',     emoji: '👽', name: 'Alien',     category: 'cosmos', description: 'Life, from a different draft.' },
+    netuno:    { id: 'netuno',    emoji: '🪐', name: 'Netuno',    category: 'cosmos', secret: true, description: 'The outer dark has a name, and it\u2019s this one.' },
+    supernova: { id: 'supernova', emoji: '💥', name: 'Supernova', category: 'cosmos', description: 'An ending so bright it counts as a beginning.' },
+
+    /* ---- absurd ---- */
+    hotTake:           { id: 'hotTake',           emoji: '🌶️', name: 'Hot Take',           category: 'absurd', description: 'Everyone has one. No one wants yours.' },
+    ancientInfluencer: { id: 'ancientInfluencer', emoji: '🤳', name: 'Ancient Influencer', category: 'absurd', secret: true, description: 'Two million followers. Zero posts.' },
+    meeting:           { id: 'meeting',           emoji: '📅', name: 'Meeting',            category: 'absurd', description: 'Two developers produced a third meeting.' }
+  };
+
+  /* Recipe: { a, b, result, msg, msgs?, secret?, ordered?, nightOnly?, fx? } */
+  var RECIPES = [
+    /* ---- the teaching chain ---- */
+    { a: 'fire',  b: 'water', result: 'steam',    msg: 'Opposites create atmosphere.' },
+    { a: 'water', b: 'earth', result: 'mud',      msg: 'Earth that gave up on posture.' },
+    { a: 'air',   b: 'water', result: 'cloud',    msg: 'The sky\u2019s rough draft.' },
+    { a: 'cloud', b: 'water', result: 'rain',     msg: 'The cloud finally admitted something.' },
+    { a: 'rain',  b: 'earth', result: 'life',     msg: 'The mud flinched first.' },
+    { a: 'life',  b: 'water', result: 'plant',    msg: 'Life, rooted and smug about it.' },
+    { a: 'plant', b: 'time',  result: 'tree',     msg: 'Patience, made visible.' },
+    { a: 'tree',  b: 'fire',  result: 'wood',     msg: 'A tree, repurposed.' },
+    { a: 'earth', b: 'earth', result: 'mountain', msg: 'A very long argument with the sky.' },
+    { a: 'human', b: 'earth', result: 'tool',     msg: 'The rock was promoted.' },
+    { a: 'steam', b: 'earth', result: 'time',     msg: 'The world learned patience.' },
+
+    /* ---- nature & weather ---- */
+    { a: 'fire',      b: 'earth',    result: 'lava',      msg: 'The planet\u2019s way of thinking out loud.', fx: 'flare' },
+    { a: 'water',     b: 'water',    result: 'ocean',     msg: 'Water, ambition edition.' },
+    { a: 'air',       b: 'air',      result: 'wind',      msg: 'Air with somewhere to be.' },
+    { a: 'cloud',     b: 'cloud',    result: 'storm',     msg: 'Two clouds having it out.', fx: 'shake' },
+    { a: 'storm',     b: 'air',      result: 'lightning', msg: 'The sky, briefly honest.', fx: 'zap' },
+    { a: 'rain',      b: 'wind',     result: 'snow',      msg: 'Rain, but it learned to whisper.' },
+    { a: 'rain',      b: 'sun',      result: 'rainbow',   msg: 'An apology from the weather.' },
+    { a: 'steam',     b: 'cloud',    result: 'fog',       msg: 'A cloud that lost its nerve.' },
+    { a: 'lava',      b: 'water',    result: 'steam',     msg: 'The planet sighed.' },
+    { a: 'storm',     b: 'water',    result: 'rain',      msg: 'The storm, softened.' },
+    { a: 'steam',     b: 'air',      result: 'cloud',     msg: 'Steam, promoted.' },
+    { a: 'cloud',     b: 'wind',     result: 'storm',     msg: 'The cloud learned to move, and regretted it.', fx: 'shake' },
+    { a: 'storm',     b: 'fire',     result: 'lightning', msg: 'The storm struck a match.', fx: 'zap' },
+    { a: 'mountain',  b: 'cloud',    result: 'snow',      msg: 'The mountain collects its mail.' },
+
+    /* ---- life ---- */
+    { a: 'ocean',     b: 'lightning', result: 'life',      msg: 'Primordial soup, stirred violently.', fx: 'zap' },
+    { a: 'mud',       b: 'rain',      result: 'plant',     msg: 'The mud sprouted an opinion.' },
+    { a: 'plant',     b: 'plant',     result: 'tree',      msg: 'Two plants entered a long agreement.' },
+    { a: 'plant',     b: 'sun',       result: 'flower',    msg: 'The plant\u2019s loud opinion.' },
+    { a: 'plant',     b: 'earth',     result: 'wheat',     msg: 'Grass with ambitions.' },
+    { a: 'tree',      b: 'tree',      result: 'forest',    msg: 'Trees discovered crowds.' },
+    { a: 'bee',       b: 'flower',    result: 'honey',     msg: 'A lifetime of small errands, bottled.' },
+
+    /* ---- animals ---- */
+    { a: 'ocean',    b: 'life',   result: 'fish',      msg: 'Life learned to swim before it learned to walk. Priorities.' },
+    { a: 'life',     b: 'air',    result: 'bird',      msg: 'Life, ignoring gravity\u2019s memo.' },
+    { a: 'bird',     b: 'bird',   result: 'egg',       msg: 'A bird\u2019s most confident decision.' },
+    { a: 'house',    b: 'life',   result: 'cat',       msg: 'Something small decided to live with you.' },
+    { a: 'wolf',     b: 'human',  result: 'dog',       msg: 'The wolf made an excellent mistake.' },
+    { a: 'mountain', b: 'life',   result: 'wolf',      msg: 'The mountain learned to hunt.' },
+    { a: 'flower',   b: 'wind',   result: 'bee',       msg: 'A flower\u2019s courier service.' },
+    { a: 'wood',     b: 'life',   result: 'snake',     msg: 'The garden got interesting.' },
+    { a: 'fish',     b: 'time',   result: 'whale',     msg: 'Time and water, taken seriously.' },
+    { a: 'life',     b: 'time',   result: 'dinosaur',  msg: 'Some experiments take longer.' },
+    { a: 'flower',   b: 'air',    result: 'butterfly', msg: 'A rumor with wings.' },
+    { a: 'ocean',    b: 'egg',    result: 'fish',      msg: 'The egg chose water.' },
+    { a: 'egg',      b: 'air',    result: 'bird',      msg: 'The egg chose poorly, then brilliantly.' },
+    { a: 'house',    b: 'fish',   result: 'cat',       msg: 'It came for the fish. It stayed for the couch.' },
+    { a: 'fish',     b: 'fish',   result: 'whale',     msg: 'The fish kept growing its story.' },
+    { a: 'plant',    b: 'mud',    result: 'snake',     msg: 'The garden\u2019s quiet rumor.' },
+    { a: 'dog',      b: 'moon',   result: 'wolf',      msg: 'Something old answers the moon.' },
+    { a: 'egg',      b: 'time',   result: 'dinosaur',  msg: 'The egg waited too well.' },
+    { a: 'flower',   b: 'life',   result: 'butterfly', msg: 'Life, in a decorative mood.' },
+
+    /* ---- humanity ---- */
+    { a: 'life',  b: 'mud',       result: 'human', msg: 'From mud, something stood up.' },
+    { a: 'mud',   b: 'lightning', result: 'human', msg: 'Prometheus would like a word.', fx: 'zap' },
+    { a: 'human', b: 'book',      result: 'brain', msg: 'A library that argues back.' },
+    { a: 'human', b: 'human',     result: 'love',  msg: 'A self-combination with consequences.' },
+    { a: 'human', b: 'star',      result: 'love',  msg: 'A clich\u00e9 that keeps working.' },
+
+    /* ---- civilization ---- */
+    { a: 'wood',     b: 'earth',    result: 'house',   msg: 'A tree, forgiven.' },
+    { a: 'house',    b: 'house',    result: 'village', msg: 'Houses discovered gossip.' },
+    { a: 'village',  b: 'village',  result: 'city',    msg: 'A village that stopped sleeping.' },
+    { a: 'wood',     b: 'wood',     result: 'wheel',   msg: 'The circle\u2019s career began.' },
+    { a: 'wheat',    b: 'fire',     result: 'bread',   msg: 'Grain, transformed by impatience.' },
+    { a: 'plant',    b: 'fire',     result: 'coffee',  msg: 'The bean demanded fire.' },
+    { a: 'wood',     b: 'tool',     result: 'paper',   msg: 'The tree, flattened into memory.' },
+    { a: 'paper',    b: 'paper',    result: 'book',    msg: 'Paper with a spine and opinions.' },
+    { a: 'mountain', b: 'fire',     result: 'metal',   msg: 'The mountain\u2019s hidden temper.' },
+    { a: 'metal',    b: 'fire',     result: 'sword',   msg: 'An argument made of metal.' },
+    { a: 'wood',     b: 'water',    result: 'boat',    msg: 'The wood forgave the water.' },
+    { a: 'boat',     b: 'metal',    result: 'ship',    msg: 'A boat that stopped being polite.' },
+    { a: 'metal',    b: 'mountain', result: 'bridge',  msg: 'The mountain\u2019s shortcut.' },
+    { a: 'house',    b: 'mountain', result: 'castle',  msg: 'A house with trust issues.' },
+    { a: 'steam',    b: 'metal',    result: 'engine',  msg: 'Steam, given a job.' },
+    { a: 'metal',    b: 'time',     result: 'gold',    msg: 'Shinier with age.' },
+    { a: 'gold',     b: 'paper',    result: 'money',   msg: 'Gold that fits in a pocket.' },
+    { a: 'tool',     b: 'metal',    result: 'key',     msg: 'It opens exactly one thing. Good luck.' },
+    { a: 'village',  b: 'money',    result: 'city',    msg: 'The village got expensive.' },
+    { a: 'village',  b: 'mountain', result: 'castle',  msg: 'The village grew walls and opinions.' },
+    { a: 'boat',     b: 'boat',     result: 'ship',    msg: 'Two boats became an argument for a third.' },
+    { a: 'ship',     b: 'mountain', result: 'bridge',  msg: 'The mountain compromised.' },
+    { a: 'steam',    b: 'tool',     result: 'engine',  msg: 'The tool learned to breathe.' },
+    { a: 'gold',     b: 'gold',     result: 'money',   msg: 'Gold that gossiped.' },
+    { a: 'human',    b: 'mountain', result: 'statue',  msg: 'The mountain, flattered.' },
+    { a: 'human',    b: 'wood',     result: 'tool',    msg: 'The branch volunteered.' },
+    { a: 'wood',     b: 'ocean',    result: 'boat',    msg: 'The wood studied the water carefully.' },
+    { a: 'metal',    b: 'rainbow',  result: 'gold',    msg: 'The end of the rainbow has accounting.' },
+
+    /* ---- culture ---- */
+    { a: 'human',   b: 'paper',   result: 'writing',       msg: 'Thoughts, taxidermied.' },
+    { a: 'tool',    b: 'paper',   result: 'writing',       msg: 'The tool learned to leave marks.' },
+    { a: 'human',   b: 'rainbow', result: 'art',           msg: 'The rainbow, on purpose.' },
+    { a: 'human',   b: 'flower',  result: 'art',           msg: 'The flower, framed.' },
+    { a: 'human',   b: 'bird',    result: 'music',         msg: 'The bird\u2019s idea, stolen gracefully.' },
+    { a: 'human',   b: 'whale',   result: 'music',         msg: 'The first album was fifty minutes of ocean.' },
+    { a: 'book',    b: 'book',    result: 'philosophy',    msg: 'Two books arguing forever.' },
+    { a: 'brain',   b: 'book',    result: 'philosophy',    msg: 'The brain read until it looped.' },
+    { a: 'book',    b: 'fire',    result: 'forbiddenBook', msg: 'Some books are burned because they burn back.', secret: true, fx: 'flare' },
+    { a: 'library', b: 'fire',    result: 'forbiddenBook', msg: 'The smoke spelled something.', secret: true, fx: 'flare' },
+    { a: 'book',    b: 'house',   result: 'library',       msg: 'A house that remembers for you.' },
+    { a: 'mountain', b: 'tool',   result: 'statue',        msg: 'Someone wanted to be remembered badly.' },
+    { a: 'art',     b: 'music',   result: 'theatre',       msg: 'Art that watches you back.' },
+    { a: 'music',   b: 'book',    result: 'theatre',       msg: 'The book got a stage and lost its manners.' },
+    { a: 'brain',   b: 'lightning', result: 'idea',        msg: 'A dangerous amount of electricity reached the brain.', fx: 'zap' },
+
+    /* ---- technology ---- */
+    { a: 'idea',      b: 'metal',    result: 'computer',       msg: 'An idea with a fan.' },
+    { a: 'metal',     b: 'brain',    result: 'robot',          msg: 'A brain with a warranty.' },
+    { a: 'computer',  b: 'brain',    result: 'ai',             msg: 'The machine started finishing your sentences.' },
+    { a: 'computer',  b: 'coffee',   result: 'developer',      msg: 'Nothing compiles without caffeine.' },
+    { a: 'human',     b: 'computer', result: 'developer',      msg: 'The first day is mostly installing things.' },
+    { a: 'lightning', b: 'metal',    result: 'battery',        msg: 'Lightning, grounded.' },
+    { a: 'lightning', b: 'gold',     result: 'battery',        msg: 'Expensive lightning, stored politely.' },
+    { a: 'metal',     b: 'air',      result: 'antenna',        msg: 'A metal ear for invisible things.' },
+    { a: 'ship',      b: 'fire',     result: 'rocket',         msg: 'A ship that chose violence over water.', fx: 'flare' },
+    { a: 'engine',    b: 'metal',    result: 'rocket',         msg: 'The engine looked up.', fx: 'flare' },
+    { a: 'rocket',    b: 'antenna',  result: 'satellite',      msg: 'An antenna that moved out.' },
+    { a: 'phone',     b: 'rocket',   result: 'satellite',      msg: 'The phone achieved orbit and still no signal.' },
+    { a: 'book',      b: 'mountain', result: 'telescope',      msg: 'The mountain had questions too.' },
+    { a: 'computer',  b: 'battery',  result: 'phone',          msg: 'A computer that fits in your pocket and your every waking moment.' },
+    { a: 'internet',  b: 'antenna',  result: 'wifi',           msg: 'The internet, untethered and unbothered.' },
+    { a: 'robot',     b: 'love',     result: 'syntheticHeart', msg: 'It beats. That\u2019s the disturbing part.', secret: true },
+    { a: 'computer',  b: 'tool',     result: 'robot',          msg: 'The computer got hands. Mostly harmless.' },
+    { a: 'robot',     b: 'book',     result: 'ai',             msg: 'It read everything. It has notes.' },
+
+    /* ---- internet ---- */
+    { a: 'computer',        b: 'computer', result: 'internet',        msg: 'Two computers started talking. Nobody stopped them.' },
+    { a: 'internet',        b: 'tool',     result: 'link',            msg: 'A door made of text.' },
+    { a: 'internet',        b: 'house',    result: 'personalWebsite', msg: 'You made a home on the web.' },
+    { a: 'internet',        b: 'writing',  result: 'personalWebsite', msg: 'A room of one\u2019s own, with hyperlinks.' },
+    { a: 'personalWebsite', b: 'writing',  result: 'blog',            msg: 'A diary the whole world is welcome to ignore.', secret: true },
+    { a: 'book',            b: 'internet', result: 'blog',            msg: 'Chapters, posted into the void.', secret: true },
+    { a: 'blog',            b: 'antenna',  result: 'rss',             msg: 'The web, as it was meant to be read.', secret: true },
+    { a: 'cat',             b: 'computer', result: 'internetCat',     msg: 'The internet has found its true purpose.' },
+    { a: 'cat',             b: 'internet', result: 'internetCat',     msg: 'The wires learned to purr.' },
+    { a: 'internetCat',     b: 'idea',     result: 'meme',            msg: 'An idea in its final, unstoppable form.' },
+    { a: 'internet',        b: 'art',      result: 'meme',            msg: 'Art, optimized for chaos.' },
+    { a: 'internet',        b: 'computer', result: 'deadWeb',         msg: 'The links still point somewhere. Nothing answers.', secret: true, fx: 'spooky' },
+    { a: 'internet',        b: 'ghost',    result: 'deadWeb',         msg: 'Some pages only load in memory.', secret: true, fx: 'spooky' },
+    { a: 'computer',        b: 'life',     result: 'virus',           msg: 'It only wanted to multiply.' },
+    { a: 'computer',        b: 'bee',      result: 'virus',           msg: 'It only wanted to multiply.' },
+    { a: 'ai',              b: 'ai',       result: 'glitch',          msg: 'The machine dreamed of a machine.', secret: true, fx: 'spooky' },
+    { a: 'robot',           b: 'ghost',    result: 'glitch',          msg: 'Haunted machinery is still machinery.', secret: true, fx: 'spooky' },
+
+    /* ---- occult ---- */
+    { a: 'human',    b: 'time',  result: 'skull',     msg: 'Everyone\u2019s final form.' },
+    { a: 'dinosaur', b: 'time',  result: 'skull',     msg: 'Extinction, personalized.' },
+    { a: 'skull',    b: 'skull', result: 'ghost',     msg: 'Death, but it left the light on.', secret: true, fx: 'spooky' },
+    { a: 'skull',    b: 'air',   result: 'ghost',     msg: 'The skull let go of the heavy parts.', secret: true, fx: 'spooky' },
+    { a: 'skull',    b: 'love',  result: 'undead',    msg: 'Love, unfortunately, finds a way.' },
+    { a: 'ghost',    b: 'love',  result: 'undead',    msg: 'It came back for the company.' },
+    { a: 'moon',     b: 'key',   result: 'secret',    msg: 'Some doors only open at night.', secret: true, fx: 'spooky' },
+    { a: 'star',     b: 'key',   result: 'secret',    msg: 'The key was always pointing up.', secret: true },
+    { a: 'secret',   b: 'galaxy', result: 'blackHole', msg: 'The universe\u2019s locked drawer.', secret: true, fx: 'cosmic' },
+    { a: 'sun',      b: 'time',  result: 'blackHole', msg: 'Even light gets tired.', secret: true, fx: 'cosmic' },
+    { a: 'forbiddenBook', b: 'fire', result: 'phoenix', msg: 'It read the whole thing and came back louder.', secret: true, ordered: true, fx: 'flare' },
+
+    /* ---- cosmos ---- */
+    { a: 'time',      b: 'time',      result: 'night',     msg: 'Time, left alone, becomes night.' },
+    { a: 'time',      b: 'moon',      result: 'night',     msg: 'The moon supervises the dark.' },
+    { a: 'night',     b: 'earth',     result: 'moon',      msg: 'The sky\u2019s oldest rock.' },
+    { a: 'night',     b: 'mountain',  result: 'moon',      msg: 'The mountain\u2019s reflection on the sky.' },
+    { a: 'fire',      b: 'night',     result: 'star',      msg: 'A light that outlived its fire.' },
+    { a: 'night',     b: 'night',     result: 'star',      msg: 'Night, squared, starts to shine.' },
+    { a: 'fire',      b: 'fire',      result: 'sun',       msg: 'Fire, squared and glorified.', secret: true, fx: 'flare' },
+    { a: 'telescope', b: 'star',      result: 'galaxy',    msg: 'A city of suns.', fx: 'cosmic' },
+    { a: 'star',      b: 'star',      result: 'galaxy',    msg: 'Stars discovered crowds.', fx: 'cosmic' },
+    { a: 'sun',       b: 'moon',      result: 'eclipse',   msg: 'The sky\u2019s private joke.', secret: true, fx: 'cosmic' },
+    { a: 'moon',      b: 'star',      result: 'owl',       msg: 'It only interviews at night.', secret: true, nightOnly: true },
+    { a: 'moon',      b: 'telescope', result: 'ufo',       msg: 'It blinked back.', secret: true, nightOnly: true, fx: 'spooky' },
+    { a: 'ufo',       b: 'life',      result: 'alien',     msg: 'Life, from a different draft.' },
+    { a: 'life',      b: 'galaxy',    result: 'alien',     msg: 'Somewhere, life filed a copy.' },
+    { a: 'alien',     b: 'rocket',    result: 'ufo',       msg: 'Return flight.' },
+    { a: 'star',      b: 'secret',    result: 'netuno',    msg: 'The outer dark has a name, and it\u2019s this one.', secret: true, fx: 'cosmic' },
+    { a: 'telescope', b: 'galaxy',    result: 'netuno',    msg: 'You found it by looking too long.', secret: true, fx: 'cosmic' },
+    { a: 'star',      b: 'fire',      result: 'supernova', msg: 'An ending so bright it counts as a beginning.', fx: 'flare' },
+
+    /* ---- absurd ---- */
+    { a: 'internet',  b: 'philosophy', result: 'hotTake',           msg: 'Everyone has one. No one wants yours.' },
+    { a: 'philosophy', b: 'fire',      result: 'hotTake',           msg: 'Someone is wrong somewhere, urgently.', fx: 'flare' },
+    { a: 'statue',    b: 'phone',      result: 'ancientInfluencer', msg: 'Two million followers. Zero posts.', secret: true },
+    { a: 'statue',    b: 'internet',   result: 'ancientInfluencer', msg: 'The followers were always there. Now there\u2019s a count.', secret: true },
+    { a: 'developer', b: 'developer',  result: 'meeting',           msg: 'Two developers produced a third meeting.' }
+  ];
+
+  var DATA = {
+    ELEMENTS: ELEMENTS,
+    RECIPES: RECIPES,
+    CATEGORIES: CATEGORIES,
+    STARTERS: STARTERS
+  };
+
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = DATA;
+  }
+  globalThis.EMOJESIS_DATA = DATA;
+})();
@@ -0,0 +1,44 @@+/* EMOJESIS — minimal offline cache. Bump CACHE on every release. */
+'use strict';
+
+var CACHE = 'emojesis-v1';
+var ASSETS = [
+  './',
+  './index.html',
+  './css/style.css',
+  './js/data.js',
+  './js/app.js',
+  './favicon.svg'
+];
+
+self.addEventListener('install', function (ev) {
+  ev.waitUntil(
+    caches.open(CACHE).then(function (c) { return c.addAll(ASSETS); })
+      .then(function () { return self.skipWaiting(); })
+  );
+});
+
+self.addEventListener('activate', function (ev) {
+  ev.waitUntil(
+    caches.keys().then(function (keys) {
+      return Promise.all(keys.map(function (k) {
+        if (k !== CACHE) return caches.delete(k);
+      }));
+    }).then(function () { return self.clients.claim(); })
+  );
+});
+
+self.addEventListener('fetch', function (ev) {
+  if (ev.request.method !== 'GET' || !ev.request.url.startsWith(self.location.origin)) return;
+  ev.respondWith(
+    caches.match(ev.request).then(function (hit) {
+      return hit || fetch(ev.request).then(function (res) {
+        if (res && res.ok) {
+          var copy = res.clone();
+          caches.open(CACHE).then(function (c) { c.put(ev.request, copy); });
+        }
+        return res;
+      });
+    })
+  );
+});
@@ -0,0 +1,189 @@+/* EMOJESIS data validator + teaching-chain simulation.
+   Run: node validate.js */
+'use strict';
+
+const { ELEMENTS, RECIPES, CATEGORIES, STARTERS } = require('./js/data.js');
+
+let failures = 0;
+const fail = (msg) => { failures++; console.error('  FAIL: ' + msg); };
+const ok = (msg) => console.log('  ok: ' + msg);
+
+const pairKey = (a, b, ordered) =>
+  ordered ? a + '>' + b : [a, b].sort().join('|');
+
+/* 1. Referential integrity + element fields */
+console.log('\n[1] Referential integrity & element fields');
+for (const id of Object.keys(ELEMENTS)) {
+  const e = ELEMENTS[id];
+  if (e.id !== id) fail(`element key "${id}" mismatches e.id "${e.id}"`);
+  for (const f of ['id', 'emoji', 'name', 'category', 'description']) {
+    if (!e[f] || typeof e[f] !== 'string') fail(`element "${id}" missing field "${f}"`);
+  }
+  if (e.category && !CATEGORIES.includes(e.category)) {
+    fail(`element "${id}" has unknown category "${e.category}"`);
+  }
+}
+for (const id of STARTERS) {
+  if (!ELEMENTS[id]) fail(`starter "${id}" missing from ELEMENTS`);
+}
+RECIPES.forEach((r, i) => {
+  const tag = `recipe #${i} (${r.a}+${r.b}=${r.result})`;
+  if (!ELEMENTS[r.a]) fail(`${tag}: input "${r.a}" does not exist`);
+  if (!ELEMENTS[r.b]) fail(`${tag}: input "${r.b}" does not exist`);
+  if (!ELEMENTS[r.result]) fail(`${tag}: result "${r.result}" does not exist`);
+  if (!r.msg || typeof r.msg !== 'string') fail(`${tag}: missing msg`);
+});
+if (failures === 0) ok('all references and fields valid');
+
+/* 2. Duplicate recipes */
+console.log('\n[2] Duplicate recipes');
+{
+  const seen = new Map();
+  RECIPES.forEach((r, i) => {
+    const k = pairKey(r.a, r.b, r.ordered);
+    if (seen.has(k)) {
+      fail(`duplicate recipe pair "${k}" at #${seen.get(k)} and #${i}`);
+    } else {
+      seen.set(k, i);
+    }
+  });
+  if (failures === 0) ok('no duplicate pairs');
+}
+
+/* 3. Reachability BFS to fixpoint (nightOnly ignored — craftable at some hour) */
+console.log('\n[3] Reachability from starters');
+{
+  const reachable = new Set(STARTERS);
+  let changed = true;
+  while (changed) {
+    changed = false;
+    for (const r of RECIPES) {
+      if (!reachable.has(r.result) && reachable.has(r.a) && reachable.has(r.b)) {
+        reachable.add(r.result);
+        changed = true;
+      }
+    }
+  }
+  const unreachable = Object.keys(ELEMENTS).filter((id) => !reachable.has(id));
+  for (const id of unreachable) fail(`element "${id}" is unreachable from starters`);
+  if (unreachable.length === 0) ok(`all ${Object.keys(ELEMENTS).length} elements reachable`);
+}
+
+/* 4. Counts */
+console.log('\n[4] Content counts');
+{
+  const nEl = Object.keys(ELEMENTS).length;
+  const nRe = RECIPES.length;
+  const cats = new Set(Object.values(ELEMENTS).map((e) => e.category));
+  const secrets = new Set();
+  for (const e of Object.values(ELEMENTS)) if (e.secret) secrets.add(e.id);
+  for (const r of RECIPES) if (r.secret) secrets.add(r.result);
+  console.log(`  elements:   ${nEl}`);
+  console.log(`  recipes:    ${nRe}`);
+  console.log(`  categories: ${cats.size} (${[...cats].join(', ')})`);
+  console.log(`  secrets:    ${secrets.size}`);
+  if (nEl < 100) fail(`only ${nEl} elements (need >= 100)`);
+  if (nRe < 160 || nRe > 220) fail(`recipe count ${nRe} outside 160-220`);
+  if (cats.size < 8) fail(`only ${cats.size} categories (need >= 8)`);
+  if (secrets.size < 6) fail(`only ${secrets.size} secrets (need >= 6)`);
+}
+
+/* 5. Teaching chain — exact combos */
+console.log('\n[5] Teaching chain (exact combos)');
+{
+  const chain = [
+    ['fire', 'water', 'steam'],
+    ['water', 'earth', 'mud'],
+    ['air', 'water', 'cloud'],
+    ['cloud', 'water', 'rain'],
+    ['rain', 'earth', 'life'],
+    ['life', 'water', 'plant'],
+    ['plant', 'time', 'tree'],
+    ['tree', 'fire', 'wood'],
+    ['earth', 'earth', 'mountain'],
+    ['human', 'earth', 'tool']
+  ];
+  for (const [a, b, result] of chain) {
+    const hit = RECIPES.find(
+      (r) => !r.ordered && r.result === result &&
+        ((r.a === a && r.b === b) || (r.a === b && r.b === a))
+    );
+    if (!hit) fail(`teaching chain combo ${a}+${b}=${result} not found`);
+  }
+  /* Signature combos */
+  const sig = [
+    ['brain', 'lightning', 'idea'],
+    ['book', 'fire', 'forbiddenBook'],
+    ['computer', 'coffee', 'developer'],
+    ['internet', 'house', 'personalWebsite'],
+    ['skull', 'love', 'undead'],
+    ['cat', 'computer', 'internetCat'],
+    ['robot', 'love', 'syntheticHeart'],
+    ['moon', 'key', 'secret'],
+    ['statue', 'phone', 'ancientInfluencer'],
+    ['personalWebsite', 'writing', 'blog'],
+    ['blog', 'antenna', 'rss']
+  ];
+  for (const [a, b, result] of sig) {
+    const hit = RECIPES.find(
+      (r) => r.result === result &&
+        ((r.a === a && r.b === b) || (!r.ordered && r.a === b && r.b === a))
+    );
+    if (!hit) fail(`signature combo ${a}+${b}=${result} not found`);
+  }
+  if (failures === 0) ok('teaching chain + signature combos present');
+}
+
+/* 6. Daily-challenge target pool for a fresh save */
+console.log('\n[6] Daily challenge pool (fresh save)');
+{
+  const nonStarters = Object.keys(ELEMENTS).filter((id) => !STARTERS.includes(id));
+  if (nonStarters.length === 0) fail('no non-starter elements available as daily goals');
+  else ok(`${nonStarters.length} candidate goals for a fresh save`);
+}
+
+/* 7. Simulation: craft the teaching chain step by step from a fresh save */
+console.log('\n[7] Simulated playthrough of the teaching chain');
+{
+  const owned = new Set(STARTERS);
+  const combine = (a, b) => {
+    const r = RECIPES.find(
+      (x) => !x.ordered &&
+        ((x.a === a && x.b === b) || (x.a === b && x.b === a))
+    );
+    return r ? r.result : null;
+  };
+  const steps = [
+    ['fire', 'water', 'steam'],
+    ['water', 'earth', 'mud'],
+    ['air', 'water', 'cloud'],
+    ['cloud', 'water', 'rain'],
+    ['rain', 'earth', 'life'],
+    ['life', 'water', 'plant'],
+    ['steam', 'earth', 'time'],
+    ['plant', 'time', 'tree'],
+    ['tree', 'fire', 'wood'],
+    ['earth', 'earth', 'mountain'],
+    ['life', 'mud', 'human'],
+    ['human', 'earth', 'tool']
+  ];
+  for (const [a, b, expect] of steps) {
+    if (!owned.has(a) || !owned.has(b)) {
+      fail(`sim: "${a}" or "${b}" not yet discovered when needed`);
+      continue;
+    }
+    const got = combine(a, b);
+    if (got !== expect) fail(`sim: ${a}+${b} gave "${got}", expected "${expect}"`);
+    else owned.add(got);
+  }
+  if (failures === 0) ok(`simulation complete — ${owned.size} elements discovered`);
+}
+
+/* Summary */
+console.log('');
+if (failures > 0) {
+  console.error(`VALIDATION FAILED: ${failures} problem(s).`);
+  process.exit(1);
+} else {
+  console.log('VALIDATION PASSED.');
+}