Ember No Tables No Waiters Just Fire AI website prompt
Cloud-kitchen landing page with burning WebGL headlines, floating 3D spices, a hover-photo menu, a receipt that prints as cloth, and review cards blowing in the wind, in cream and red.
Now put it in WordPress
You have a site. Now make it yours.
An AI build hands you a front end and stops there. No CMS, no client login, no real ownership: you cannot hand it over, and you cannot change it without going back and re-prompting. UiChemy turns that build into native, editable WordPress you own, in the builder you already use.
Bolt to WordPressClaude to WordPressCodex to WordPressCursor to WordPressLovable to WordPressv0 to WordPressAI to WordPress
Get started free
More prompts
Showing 1 prompts
Frequently asked questions
What does the Ember No Tables No Waiters Just Fire prompt build?
Cloud-kitchen landing page with burning WebGL headlines, floating 3D spices, a hover-photo menu, a receipt that prints as cloth, and review cards blowing in the wind, in cream and red.
Which AI tools does the Ember No Tables No Waiters Just Fire prompt work with?
It's written to work with . Paste it into your AI tool and it builds the site, then move the result into WordPress with UiChemy.
How do I turn the result into a WordPress site?
After the AI builds your site, UiChemy Composer rebuilds it as native, editable WordPress in Elementor or Gutenberg (Bricks coming soon) — you own it and edit it in your builder. See AI to WordPress.
Is the Ember No Tables No Waiters Just Fire prompt free?
Yes — it's free to copy, no account needed.
Can I edit the website after it's built?
Yes. Once it's in WordPress with UiChemy, every section is native and editable — no re-prompting, no lock-in.
Copy prompt
Copy code
Coming soon
Copy SKILL.md
Coming soon
Ember No Tables No Waiters Just Fire · Section prompt
# MASTER PROMPT — Ember — Cloud Kitchen (WordPress / UiChemy edition)
## ▶ HOW TO USE THIS PROMPT
This is the **WordPress build** of Ember, live at `https://demo.uichemy.com/ember-cloud-kitchen/`
(page id **564**, one Composer widget **`5xcky6n`** labelled "Ember", published). It works on the
**frontend and inside the Elementor / UiChemy editor**, and its motion is tunable from the
**Composer Animation panel** (16 controls).
It is the companion to `~/Downloads/EMBER-MASTER-PROMPT.md`, which stays the authority for the
**standalone page** (`index.html` + `fonts/fonts.css` + `vendor/motion.mjs`). This document carries
every WordPress block verbatim and the rules the port has to respect. Four of those rules were
learned the hard way in the real editor.
**INSTRUCTION TO CLAUDE:** build through the `uichemy-demo-wordpress-uichemy-mcp` server and put
each block where its heading says. The section's `js` holds **only** the controller bridge (B7);
the page runs from two code files (B3 loader, B4 scene).
---
## OVERVIEW
- **Stack:** vanilla HTML + CSS + JS, three.js **r169**, Motion 12.43.0 (UMD), Lenis 1.3.26.
The libraries load from the jsDelivr npm CDN; every asset (photos and fonts) is in the WP media library.
- **What it is:** a cream-and-red site for a delivery-only cloud kitchen. One fixed WebGL canvas
draws the burning words (**FIRE**, **HUNGRY?**) over their transparent HTML twins, plus floating 3D
spices. A second canvas prints the cloth-simulated receipt; a third blows the review cards in the
wind. Sections: loader → hero → manifesto → menu (hover plate photo) → how it works → stats →
reviews → CTA → footer.
## ASSET MANIFEST (WordPress media library — 22 files)
| Attachment | URL | Bytes |
|---|---|---|
| 542 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bowl-160.jpg` | 11,360 |
| 543 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bowl-640.jpg` | 128,285 |
| 544 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bricolage-grotesque-latin-ext-standard-normal.woff2` | 53,588 |
| 545 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bricolage-grotesque-latin-standard-normal.woff2` | 131,548 |
| 546 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bun-160.jpg` | 7,717 |
| 547 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bun-640.jpg` | 66,721 |
| 548 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-cake-160.jpg` | 5,853 |
| 549 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-cake-640.jpg` | 44,637 |
| 550 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-corn-160.jpg` | 11,375 |
| 551 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-corn-640.jpg` | 124,669 |
| 552 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-400-normal.woff2` | 14,820 |
| 553 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-500-normal.woff2` | 14,988 |
| 554 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-ext-400-normal.woff2` | 9,552 |
| 555 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-ext-500-normal.woff2` | 9,616 |
| 556 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-400-italic.woff2` | 22,128 |
| 557 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-400-normal.woff2` | 21,032 |
| 558 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-ext-400-italic.woff2` | 12,384 |
| 559 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-ext-400-normal.woff2` | 11,604 |
| 560 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-pizza-160.jpg` | 8,513 |
| 561 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-pizza-640.jpg` | 91,631 |
| 562 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-skewers-160.jpg` | 7,124 |
| 563 | `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-skewers-640.jpg` | 56,496 |
All uploaded through `uichemy-composer/media` (`request-upload`, then PUT). The byte sizes match
the landing-assets originals. **Photos** are built by the scene
(`photoURL = k => …/uploads/2026/09/ember-${k}-${160|640}.jpg`), and **fonts** are `@font-face` in B1.
`upload_images` rewrites neither: both were re-pointed by hand (RULE 5).
## ⚠ RULE 1 — in the Elementor editor the widget appears AFTER site scripts run, and gets re-rendered
The loader (B3) never gives up on a page without the section when it is in the editor
(`?elementor-preview=` or `elementorFrontend.isEditMode()`):
- It starts only after **600 ms without a new copy** appearing, and picks the **newest visible** copy.
- It watches for new copies (MutationObserver + Elementor's `frontend/element_ready/global` hook).
- It retires every older copy: `__emberDead`, then `WEBGL_lose_context` 500 ms later.
- In the editor it skips Lenis, because each re-render would add another instance.
## ⚠ RULE 2 — every lookup is scoped to ONE copy, and stale runs go quiet
The source did `document.querySelector` everywhere. While Elementor swaps copies, a run could bind
to a copy that is hidden or gone, and then never fire its triggers. The scene now reads
`ROOT = window.__emberRoot` (set by the loader), `$`/`$$` default to ROOT, and a generation
token `ROOT.__emberGen` gates module-scope shadows of `requestAnimationFrame` and
`addEventListener`, so an old run stops the moment it is replaced. Do **not** call
`forceContextLoss` on a run whose loop is still going: three throws "reading 'trim'" in
`getProgramInfoLog`.
## ⚠ RULE 3 — in the editor, "scrolled into view" must not rely on IntersectionObserver
In the real UiChemy editor, Motion's `inView` never fired. HUNGRY? sat red and unlit, the receipt
never printed, the review cloth stayed blank, and the menu, stats and reveals stayed at opacity 0.
With `window.__emberEditor` set, `onEnter` measures each target's rect every frame instead
(enter at visible ratio ≥ amount). The frontend keeps Motion's `inView`.
## ⚠ RULE 4 — the editor re-renders PARTS of the widget: restart cleanly, plus a safety net
Every 700 ms the loader compares the elements the run bound (`ROOT.__emberBound`: cta, reviews,
how, menu list, stats, first dish, first stat) with the current ones. If any changed:
- it bumps the generation;
- it loses and removes every canvas the old run added;
- it strips `cloth-on` / `wind-on`;
- it restarts on the same copy.
The scene tolerates this. `splitWords` skips headings that are already split. A missing loader
(`.loader`, `.ld-n`, `.burner__flames`) skips straight to the hero intro.
**Editor-only safety net:** a `.dish/.stat/[data-reveal]/.review` on screen at opacity 0 for
more than 1.2 s is revealed, and a `[data-count]` stuck at 0 for more than 2.5 s shows its value. Each
rescue is logged.
**Diagnostics:** `copy(__emberDiag())` in the console, with the preview iframe selected, returns
the copies, which one runs, lost contexts, and the last 40 log lines (starts, restarts, safety-net
rescues, errors).
## ⚠ RULE 5 — URLs inside JavaScript / site CSS are never rewritten
`upload_images` only touches `src`/`srcset`/`poster`/`url()` in the **section**. The photo
template in B4 and the `@font-face` URLs in B1 point at WP media by hand. Uploads send no CORS
headers, so a local harness keeps the jsDelivr originals.
## ⚠ RULE 6 — three r169 on a site whose importmap is r165
B4 imports three r169 and `RoomEnvironment.js` by full URL. RoomEnvironment's own bare `'three'`
resolves through a **scope** in the site importmap, which the site already carries (Understory added it):
```json
"scopes": {
"https://cdn.jsdelivr.net/npm/[email protected]/": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
}
}
```
Motion is imported for its side effect and read off `globalThis.Motion`, exactly what the source's
`vendor/motion.mjs` did.
## ⚠ RULE 7 — Elementor zeroes `figure` margins inside widgets
`frontend.min.css` has `.elementor .elementor-widget:not(..):not(..) figure{margin:0}` at
specificity (0,4,1). The review cards are `<figure>`s built at runtime, so #reviews came out 28 px
short at every width. B6 ends with `.uichemy-ember-1.uichemy-ember-1.uichemy-ember-1 .review{margin:14px 0 0}`.
## ⚠ RULE 8 — the editor save is lossy, so the loader repairs the markup
An editor save strips `<canvas>` and `<svg>`. `#gl` is not in the markup at all (the loader builds it
after `.grain`). The 16 inline SVGs are restored from a path map: the parent's element index path
**excluding svg and #gl**, the index among siblings, and the tag.class signature with `uich-*` /
`elementor-*` classes ignored. **Heal the SVGs before inserting #gl**; the other order misplaces
the flame-ring SVG and the scene crashes on `.burner__flames`.
## ⚠ RULE 9 — site body code: bump versions with get → replace → set, never append
Cloudflare caches `/wp-content/uichemy-composer/*.js` for 30 days. Every change needs a new `?v=`
on **both** the site body tag (B2) and the `SCENE` constant in B3. The swap is:
`get-site-code` → exact replace → re-read and abort if it changed → `set-site-code` body only →
confirm every other `uichemy-composer/*.js` tag survived.
## CONTROLLER BRIDGE (Composer Animation panel)
B7 declares `uichemy_controller_bridge` and publishes `window.EMBER_MOTION`. The scene's `MM`
merges it onto the defaults, clamps each value to its min/max, and accepts plain values or
`{value}` objects. The plugin runs widget JS before site scripts. Verified: a tuned
`cta_sparks = 150` reached the fire (hero kept its default 90).
| Variable | Default | Group | Drives |
|---|---|---|---|
| `hero_ignite_delay` | 1.2s | Fire | Hero fire: ignition delay (0–5) |
| `hero_ignite_duration` | 4.2s | Fire | Hero fire: climb duration (0.5–10) |
| `cta_ignite_duration` | 3.4s | Fire | HUNGRY? fire: climb duration (0.5–10) |
| `hero_sparks` | 90 | Fire | Hero fire: spark count (0–240) |
| `cta_sparks` | 60 | Fire | HUNGRY? fire: spark count (0–240) |
| `hero_reveal_duration` | 1.5s | Fire | Hero word fade-in (0.1–5) |
| `loader_fill_duration` | 2.1s | Loader | Loader: grill heat-up (0.2–8) |
| `loader_exit_duration` | 1.05s | Loader | Loader: curtain lift (0.2–4) |
| `hero_line_duration` | 1.2s | Text | Hero tagline: word rise (0.1–4) |
| `heading_word_duration` | 1s | Text | Headings: word rise (0.1–4) |
| `heading_word_stagger` | 0.06s | Text | Headings: word stagger (0–0.5) |
| `reveal_duration` | 0.9s | Text | Blocks: reveal duration (0.1–4) |
| `reveal_distance` | 34px | Text | Blocks: rise distance (0–200) |
| `counter_duration` | 1.9s | Text | Stats: count-up duration (0.2–6) |
| `scroll_smoothing` | 0.1 | Feel | Smooth scroll (Lenis lerp, lower = smoother) (0.02–1) |
| `cursor_follow` | 0.24 | Feel | Cursor follow (higher = snappier) (0.02–1) |
---
## WHAT DIFFERS FROM THE STANDALONE SOURCE
**Frontend:** verified live against the source at **1440 / 1920 / 1024 / 768 / 390**. Document height,
15 section boxes, fonts and line-heights, the sticky "how" panel, and the nav state at 4 scroll
positions are **identical**; the loaded font faces are identical, all 22 media return 200 from WP,
and there are 0 errors.
**Editor:** verified by emulation on the live files. The setup: IntersectionObserver dead, the
widget swapped 3×, a stale copy left, SVGs stripped, `uich-composer-hover-target` classes added, then
only the menu and stats re-rendered. The results: every WebGL layer draws (hero, receipt, review
cloth, HUNGRY? ignites), 6/6 dishes and 4/4 stats show, no double-split headings, exactly 3 canvases,
0 errors. With the scene killed outright, the safety net alone still shows the menu, the stats and
their numbers.
1. One wrapper `<div class="uichemy-ember-1">`. `:root` tokens move onto it (the media-query
`--nav-h` override too). html/body rules move to site CSS as `html.ember-page` / `body.ember-page`,
classes the loader adds, plus the source's `body.is-loading`.
2. Element resets go in `:where()`. A guard hands `line-height/color/font-family/letter-spacing/
text-transform` of headings, text and lists back to inheritance, so theme type cannot leak in.
3. 19 keyframes renamed `ember-*`. 3 in-block comments stripped. All 6 media blocks open with the
no-op `--uv-guard` rule (the formatter mangles the first rule of each block).
4. The RULE 7 figure fix at the end of B6.
5. Scene: the RULE 2–4 editor machinery, 16 literals → `MM`, and a try/catch around `renderFrame`
so a bad frame cannot freeze the loop. Everything else is byte-for-byte the source.
---
# APPENDIX B · THE WORDPRESS BUILD
## Build steps
1. `uichemy-composer/read-skill` `{ "name": "code-to-wordpress" }` and `{ …, "part": "animation" }`.
2. **Media:** one `uichemy-composer/media` `request-upload` slot, then PUT all 22 files named
`ember-<original name>`. If any URL differs from the manifest, fix B1 (fonts) and B4 (`photoURL`).
3. **B3 + B4 → code files** (`code-file` `request-upload`, then PUT both from disk).
4. **B1 + B2 → site code:** `platform` `update-site-code` (append) for a first install. Make sure
the importmap carries the RULE 6 scope. For a version bump, follow RULE 9.
5. **The page:** `page` `create` with `title: "Ember — Cloud Kitchen"`, `label: "Ember"`,
`status: "draft"`, `upload_images: true`, `html` = B5, `css` = B6, `js` = B7.
6. **Read back:** `get-section-code` should show 6 `--uv-guard`, no `: hover`, no `astra`/`e-global`
substitutions, and 16 `<svg`.
7. **Publish:** `POST /wp-json/wp/v2/pages/<id>` `{ "status": "publish" }`.
8. **Check:** frontend vs source at five widths, then open the editor. Nothing may be red and unlit
or blank; if anything is, run `__emberDiag()`.
## B1 · site-wide `site_before_head`
```html
<style>
/* EMBER: self-hosted fonts (WordPress media) and the document-level half of
the source's html/body rules. ember.js adds `ember-page` to <html> and
<body> only on a page that holds the section. */
/* Self-hosted fonts (Fontsource builds of the Google Fonts families), latin + latin-ext */
@font-face {
font-family: 'Bricolage Grotesque';
font-style: normal;
font-display: swap;
font-weight: 200 800;
font-stretch: 75% 100%;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bricolage-grotesque-latin-ext-standard-normal.woff2') format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Bricolage Grotesque';
font-style: normal;
font-display: swap;
font-weight: 200 800;
font-stretch: 75% 100%;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-bricolage-grotesque-latin-standard-normal.woff2') format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-ext-400-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-ext-500-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'DM Mono';
font-style: normal;
font-display: swap;
font-weight: 500;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-dm-mono-latin-500-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-ext-400-normal.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Instrument Serif';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-ext-400-italic.woff2') format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Instrument Serif';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url('https://demo.uichemy.com/wp-content/uploads/2026/09/ember-instrument-serif-latin-400-italic.woff2') format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
html.ember-page{-webkit-text-size-adjust:100%;background:#F3EADB;scroll-padding-top:72px}
body.ember-page{margin:0;background:#F3EADB;overflow-x:clip}
body.ember-page.is-loading{overflow:hidden;height:100svh}
@media (max-width:900px){html.ember-page{scroll-padding-top:64px}}
</style>
```
## B2 · site-wide `site_before_body`
```html
<script src="/wp-content/uichemy-composer/ember.js?v=4"></script>
```
## B3 · code file `ember.js` → `/wp-content/uichemy-composer/ember.js`
```js
/* EMBER — loader.
Loaded site-wide (before </body>). On the frontend it does nothing unless the
page holds the section. In the Elementor editor the widget is drawn by
JavaScript AFTER this file runs, so there it stays armed and starts the scene
whenever a copy of the widget appears (and again when an edit re-renders it).
Before the scene runs it repairs what an editor save strips from the section:
the #gl <canvas> and the 16 inline <svg>s.
*/
(function () {
'use strict';
var SEL = '.uichemy-ember-1';
/* Versioned on purpose: Cloudflare caches /wp-content/uichemy-composer/*.js for
30 days and the scene URL is its own cache key. Bump both together. */
var SCENE = '/wp-content/uichemy-composer/ember-scene.js?v=4';
var LENIS = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js';
var IN_EDITOR = /[?&]elementor-preview=/.test(location.search) ||
!!(window.elementorFrontend && elementorFrontend.isEditMode && elementorFrontend.isEditMode());
if (!IN_EDITOR && !document.querySelector(SEL)) return;
window.__emberEditor = IN_EDITOR;
var LOG = function () { (window.__emberLog = window.__emberLog || []).push([Math.round(performance.now()), 'loader'].concat([].slice.call(arguments))); };
LOG('loaded', IN_EDITOR ? 'editor' : 'frontend', 'Motion before import: ' + typeof window.Motion);
window.addEventListener('error', function (e) { LOG('error', String(e.message || e.error), (e.filename || '').split('/').pop() + ':' + e.lineno); });
window.addEventListener('unhandledrejection', function (e) { LOG('rejection', String(e.reason && (e.reason.stack || e.reason.message) || e.reason).slice(0, 300)); });
/* [path to the svg's parent (element indices, svgs not counted), index of the
svg among the parent's children, parent tag.class, the svg itself] */
var SVGS = [[[3, 0, 0], 0, "span.logo", "<svg viewBox=\"0 0 20 25\" aria-hidden=\"true\"><path d=\"M10 .8c1.3 4.4 7.6 7.3 7.6 13.8A7.6 7.6 0 0 1 10 22.2a7.6 7.6 0 0 1-7.6-7.6c0-3.4 1.8-5.6 3.5-7.4.4 2.4 1.6 3.7 3.2 4.2C8.6 8 9 4.1 10 .8z\" fill=\"currentColor\"></path></svg>"], [[3, 1, 0], 0, "div.burner", "<svg viewBox=\"-130 -130 260 260\" aria-hidden=\"true\"><circle class=\"ring\" r=\"66\"></circle><g class=\"burner__flames\"></g></svg>"], [[4, 0], 0, "a.logo", "<svg viewBox=\"0 0 20 25\" aria-hidden=\"true\"><path d=\"M10 .8c1.3 4.4 7.6 7.3 7.6 13.8A7.6 7.6 0 0 1 10 22.2a7.6 7.6 0 0 1-7.6-7.6c0-3.4 1.8-5.6 3.5-7.4.4 2.4 1.6 3.7 3.2 4.2C8.6 8 9 4.1 10 .8z\" fill=\"currentColor\"></path></svg>"], [[4, 2, 0], 0, "span.arrow", "<svg viewBox=\"0 0 12 12\"><path d=\"M1 6h9M6.5 2.5 10 6l-3.5 3.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>"], [[5, 0, 1, 2], 0, "a.badge", "<svg class=\"ring\" viewBox=\"0 0 120 120\" aria-hidden=\"true\"><defs><path id=\"badgeRing\" d=\"M60 60 m-47 0 a47 47 0 1 1 94 0 a47 47 0 1 1 -94 0\"></path></defs>\n <text font-family=\"DM Mono, monospace\" font-size=\"10\" font-weight=\"500\" fill=\"currentColor\"><textPath href=\"#badgeRing\" textLength=\"293\" lengthAdjust=\"spacing\">ORDER NOW \u00b7 OPEN TILL 2AM \u00b7 </textPath></text></svg>"], [[5, 0, 1, 2, 0], 0, "span.badge__core", "<svg viewBox=\"0 0 12 12\" aria-hidden=\"true\"><path d=\"M2.5 9.5 9.5 2.5M4 2.5h5.5V8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>"], [[5, 1, 0, 0], 0, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 0], 2, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 0], 3, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 0], 5, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 1], 0, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 1], 2, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 1], 3, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 1, 0, 1], 5, "span.", "<svg viewBox=\"0 0 20 20\"><path d=\"M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1\" stroke=\"currentColor\" stroke-width=\"3\"></path></svg>"], [[5, 5, 0, 1, 1, 1, 0, 11, 0, 1, 1], 0, "span.eta__bike", "<svg viewBox=\"0 0 30 22\"><circle cx=\"6\" cy=\"16\" r=\"4.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\"></circle><circle cx=\"24\" cy=\"16\" r=\"4.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\"></circle><path d=\"M6 16h8l4-8h5l1 8M13 8h6M9 4h7v4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>"], [[5, 8, 2, 0], 0, "span.arrow", "<svg viewBox=\"0 0 12 12\"><path d=\"M1 6h9M6.5 2.5 10 6l-3.5 3.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"></path></svg>"]];
/* the path counts elements as the saved markup has them: no <svg>s (a save
strips them) and no #gl (this loader adds it) */
function nonSvgChildren(el) {
var out = [];
for (var i = 0; i < el.children.length; i++) {
var c = el.children[i];
if (c.tagName.toLowerCase() !== 'svg' && c.id !== 'gl') out.push(c);
}
return out;
}
function healSvgs(root) {
SVGS.forEach(function (s) {
var el = root;
for (var i = 0; i < s[0].length && el; i++) el = nonSvgChildren(el)[s[0][i]];
if (!el) return;
var sig = el.tagName.toLowerCase() + '.' + (el.getAttribute('class') || '').split(/\s+/).filter(function (c) { return c && !/^(uich-|elementor-)/.test(c); }).join(' ');
if (sig !== s[2]) return; /* markup was edited: leave it */
var want = SVGS.filter(function (o) { return o[0].join('/') === s[0].join('/'); }).length;
var have = 0;
for (var k = 0; k < el.children.length; k++) if (el.children[k].tagName.toLowerCase() === 'svg') have++;
if (have >= want) return;
var tpl = document.createElement('div'); tpl.innerHTML = s[3];
el.insertBefore(tpl.firstChild, el.children[s[1]] || null);
});
}
function heal(root) {
healSvgs(root); /* first: its paths predate #gl */
if (!root.querySelector('#gl')) {
var c = document.createElement('canvas');
c.id = 'gl'; c.setAttribute('aria-hidden', 'true');
var grain = root.querySelector(':scope > .grain');
root.insertBefore(c, grain ? grain.nextSibling : root.firstChild);
}
}
var runs = 0, started = [];
function retire(old) {
if (old.__emberDead) return;
old.__emberDead = true;
setTimeout(function () { /* after its last frame */
var cvs = old.querySelectorAll('canvas');
for (var i = 0; i < cvs.length; i++) {
var gl = null;
try { gl = cvs[i].getContext('webgl2') || cvs[i].getContext('webgl'); } catch (e) {}
var ext = gl && gl.getExtension('WEBGL_lose_context');
if (ext) ext.loseContext();
}
}, 500);
}
function start(root) {
if (root.__emberStarted) return;
root.__emberStarted = true;
/* the source's <body class="is-loading"> plus the page-level half of its
html/body rules, carried by a class so no other page is touched */
document.documentElement.classList.add('ember-page');
document.body.classList.add('ember-page', 'is-loading');
/* Elementor sets html{scroll-behavior:smooth}; the source scrolls instantly */
document.documentElement.style.scrollBehavior = 'auto';
heal(root);
/* Retire every earlier copy: its loops and listeners check this flag (and
whether the copy is still in the page) and go quiet. Their WebGL
contexts are then released, so re-renders never pile up contexts. */
for (var i = 0; i < started.length; i++) if (started[i] !== root) retire(started[i]);
started.push(root);
window.__emberRoot = root;
root.__emberGen = (root.__emberGen || 0) + 1;
LOG('start', runs + 1, 'gen ' + root.__emberGen);
runs++;
import(SCENE + (runs > 1 ? '&i=' + runs : '')).catch(function (e) {
document.body.classList.remove('is-loading');
LOG('scene failed', String(e && (e.stack || e.message) || e).slice(0, 300));
if (window.console) console.error('EMBER scene failed to load:', e);
});
}
function scan() {
var roots = document.querySelectorAll(SEL), pick = null;
for (var i = roots.length - 1; i >= 0; i--) {
var r = roots[i];
if (r.__emberStarted) continue;
if (IN_EDITOR && !r.getClientRects().length) continue; /* a hidden copy */
pick = r; break;
}
if (pick) start(pick);
}
/* In the editor, start only once Elementor has stopped swapping the widget:
600ms without a new copy appearing. */
var pending = 0;
function soon() {
clearTimeout(pending);
pending = setTimeout(function () {
scan();
setTimeout(function () { window.dispatchEvent(new Event('resize')); }, 700);
setTimeout(function () { window.dispatchEvent(new Event('resize')); }, 1800);
}, 600);
}
function boot() {
/* Lenis smooth scroll on the frontend, as in the source. In the editor it
is left out: every re-render would add another instance driving the
preview's scroll, so the editor scrolls natively. */
if (IN_EDITOR) return soon();
if (window.Lenis) return scan();
var s = document.createElement('script');
s.src = LENIS;
s.onload = s.onerror = scan;
document.head.appendChild(s);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
if (IN_EDITOR) {
/* Safety net: in the editor, nothing that is on screen may stay invisible.
A reveal target left at opacity 0 for 1.2s while in view is shown, and a
counter stuck at 0 jumps to its value. Each rescue is logged. */
var seen = new WeakMap();
setInterval(function () {
var live = started[started.length - 1];
if (!live || live.__emberDead || !live.isConnected) return;
var vh = innerHeight, now = Date.now();
var inView = function (el) { var r = el.getBoundingClientRect(); return r.bottom > vh * 0.1 && r.top < vh * 0.9 && r.height > 0; };
[].forEach.call(live.querySelectorAll('.dish, .stat, [data-reveal], .review'), function (el) {
if (el.closest('.rrow') || !inView(el)) { seen.delete(el); return; }
if (+getComputedStyle(el).opacity > 0.05) { seen.delete(el); return; }
if (!seen.has(el)) { seen.set(el, now); return; }
if (now - seen.get(el) < 1200) return;
el.getAnimations().forEach(function (a) { a.cancel(); });
el.style.opacity = '1'; el.style.transform = 'none';
seen.delete(el); LOG('safety net: revealed', el.className.split(' ')[0]);
});
[].forEach.call(live.querySelectorAll('[data-count]'), function (el) {
if (!inView(el) || parseFloat(el.textContent) !== 0) return;
var k = '__c'; if (!el[k]) { el[k] = now; return; }
if (now - el[k] < 2500) return;
var dec = +(el.dataset.dec || 0), pad = +(el.dataset.pad || 0), s = (+el.dataset.count).toFixed(dec);
el.textContent = pad ? s.padStart(pad, '0') : s; el[k] = 0;
LOG('safety net: counter', el.dataset.count);
});
}, 400);
setInterval(function () {
var live = started[started.length - 1];
if (!live || live.__emberDead || !live.isConnected || !live.__emberBound) return;
var b = live.__emberBound;
var same = true;
[['cta', '.cta__word'], ['reviews', '.reviews'], ['how', '.how'], ['menu', '.menu__list'], ['stats', '.stats'], ['dish', '.dish'], ['stat', '.stat']]
.forEach(function (k) { if (live.querySelector(k[1]) !== b[k[0]]) same = false; });
if (same) return;
LOG('inside replaced -> restart');
live.__emberStarted = false; live.__emberBound = null;
started.pop();
/* the old run is retired by the generation bump inside start(); clear what
it added to this copy so the new run starts from a clean slate: every
canvas it created (freed first) and the state classes it set */
live.__emberGen = (live.__emberGen || 0) + 1;
var cvs = [].slice.call(live.querySelectorAll('canvas'));
setTimeout(function () {
cvs.forEach(function (c) {
try { var g = c.getContext('webgl2') || c.getContext('webgl'); var x = g && g.getExtension('WEBGL_lose_context'); if (x) x.loseContext(); } catch (e) {}
c.remove();
});
[].forEach.call(live.querySelectorAll('.cloth-on, .wind-on'), function (el) { el.classList.remove('cloth-on', 'wind-on'); });
start(live);
}, 60);
}, 700);
var watch = function () {
if (!document.body) return setTimeout(watch, 50);
new MutationObserver(function (list) {
for (var i = 0; i < list.length; i++) {
var added = list[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType === 1 && (n.matches(SEL) || n.querySelector(SEL))) return soon();
}
}
}).observe(document.body, { childList: true, subtree: true });
};
watch();
var hook = function () {
if (window.elementorFrontend && elementorFrontend.hooks) {
elementorFrontend.hooks.addAction('frontend/element_ready/global', soon);
return true;
}
};
if (!hook()) window.addEventListener('elementor/frontend/init', hook);
}
/* One-line health check, for the browser console: __emberDiag() */
window.__emberDiag = function () {
var roots = [].slice.call(document.querySelectorAll(SEL));
return JSON.stringify({ inEditor: IN_EDITOR, copies: roots.length,
copiesState: roots.map(function (r) { return { started: !!r.__emberStarted, dead: !!r.__emberDead, visible: !!r.getClientRects().length,
gl: !!r.querySelector('#gl'), svgs: r.querySelectorAll('svg').length,
lostContexts: [].slice.call(r.querySelectorAll('canvas')).filter(function (c) { var g = null; try { g = c.getContext('webgl2'); } catch (e) {} return g && g.isContextLost(); }).length }; }),
runs: runs, scene: !!window.__ember, motion: window.EMBER_MOTION ? Object.keys(window.EMBER_MOTION).length + ' values' : null,
log: (window.__emberLog || []).slice(-40) });
};
})();
```
## B4 · code file `ember-scene.js` → `/wp-content/uichemy-composer/ember-scene.js`
```js
/* EMBER — the page module, from the standalone source. What differs:
- full import URLs (three r169 by URL; RoomEnvironment's bare 'three'
resolves through the site importmap's r169 scope; Motion's UMD read off
globalThis.Motion, exactly what the source's vendor/motion.mjs did);
- menu photographs from the WordPress media library;
- the WordPress preamble below the imports (ROOT scoping, stale-instance
shutdown, MM = Composer Animation panel values), $/$$ default to ROOT;
- 16 literals read from MM (defaults identical), and the main loop survives
a bad frame instead of freezing;
- in the Elementor editor only, the scroll-into-view trigger is measured
each frame instead of using IntersectionObserver;
- a restart after the loader has been removed skips straight to the hero intro.
Everything else is byte-for-byte the source. */
import * as THREE from 'https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js'
import { RoomEnvironment } from 'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/environments/RoomEnvironment.js'
import 'https://cdn.jsdelivr.net/npm/[email protected]/dist/motion.js'
const { animate, scroll, inView, stagger } = globalThis.Motion
/* ── WordPress ─────────────────────────────────────────────────────
ROOT is the copy of the widget this instance drives (ember.js sets it
before importing). Every lookup is scoped to it, and once that copy is
gone (the Elementor editor re-renders widgets) its loops and window
listeners go quiet, so a stale instance can never draw over the live one.
MM holds the Composer Animation panel values (window.EMBER_MOTION, from the
widget's uichemy_controller_bridge); defaults are the source's literals. */
const ROOT = window.__emberRoot || document
const GEN = ROOT === document ? 0 : (ROOT.__emberGen || 0)
const ALIVE = () => ROOT === document || (ROOT.isConnected && !ROOT.__emberDead && (ROOT.__emberGen || 0) === GEN)
const LOG = (...a) => { (window.__emberLog = window.__emberLog || []).push([Math.round(performance.now()), 'gen' + GEN].concat(a)) }
const requestAnimationFrame = cb => ALIVE() ? window.requestAnimationFrame(cb) : 0
const addEventListener = (type, fn, opts) => window.addEventListener(type, function (e) { if (ALIVE()) return fn.call(this, e) }, opts)
const MM = (() => {
const D = {"hero_ignite_delay": 1.2, "hero_ignite_duration": 4.2, "cta_ignite_duration": 3.4, "hero_sparks": 90, "cta_sparks": 60, "hero_reveal_duration": 1.5, "loader_fill_duration": 2.1, "loader_exit_duration": 1.05, "hero_line_duration": 1.2, "heading_word_duration": 1, "heading_word_stagger": 0.06, "reveal_duration": 0.9, "reveal_distance": 34, "counter_duration": 1.9, "scroll_smoothing": 0.1, "cursor_follow": 0.24}, L = {"hero_ignite_delay": [0, 5], "hero_ignite_duration": [0.5, 10], "cta_ignite_duration": [0.5, 10], "hero_sparks": [0, 240], "cta_sparks": [0, 240], "hero_reveal_duration": [0.1, 5], "loader_fill_duration": [0.2, 8], "loader_exit_duration": [0.2, 4], "hero_line_duration": [0.1, 4], "heading_word_duration": [0.1, 4], "heading_word_stagger": [0, 0.5], "reveal_duration": [0.1, 4], "reveal_distance": [0, 200], "counter_duration": [0.2, 6], "scroll_smoothing": [0.02, 1], "cursor_follow": [0.02, 1]}, src = window.EMBER_MOTION || {}
for (const k in D){ const raw = src[k], v = parseFloat(raw && typeof raw === 'object' ? raw.value : raw); if (Number.isFinite(v)) D[k] = Math.min(L[k][1], Math.max(L[k][0], v)) }
return D
})()
/* ══════════════════════════════════════════════════════════════════
UTILITIES
══════════════════════════════════════════════════════════════════ */
const RM = matchMedia('(prefers-reduced-motion: reduce)').matches
const $ = (s, r = ROOT) => r.querySelector(s)
const $$ = (s, r = ROOT) => [...r.querySelectorAll(s)]
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v))
const map = (v, a, b) => clamp((v - a) / (b - a))
const lerp = (a, b, t) => a + (b - a) * t
const EASE = [0.22, 1, 0.36, 1]
const EASE_IO = [0.76, 0, 0.24, 1]
const DPR = Math.min(devicePixelRatio || 1, 1.5)
/* ── paper grain ─────────────────────────────────────────────────── */
{
const c = document.createElement('canvas'); c.width = c.height = 180
const x = c.getContext('2d'), img = x.createImageData(180, 180)
for (let i = 0; i < img.data.length; i += 4){
const v = 120 + Math.random() * 135
img.data[i] = img.data[i + 1] = img.data[i + 2] = v; img.data[i + 3] = 255
}
x.putImageData(img, 0, 0)
$('.grain').style.backgroundImage = `url(${c.toDataURL()})`
}
/* ══════════════════════════════════════════════════════════════════
ILLUSTRATIONS — painted plates. No photos: every dish is drawn with
shaded gradients, a food-grain texture, char, gloss and crumbs, and
sits in the well of a ceramic plate (the disc itself).
══════════════════════════════════════════════════════════════════ */
let uid = 0
const nid = () => 'e' + (uid++)
const srng = seed => () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646 }
/* shared per-svg filters: t = food grain, b = soft blur, B = heavy blur, s = contact shadow */
const FX = i => `<filter id="${i}t" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="1.1" numOctaves="2" seed="7" result="n"/>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 -.22" result="d"/>
<feComposite in="d" in2="SourceGraphic" operator="in" result="dm"/>
<feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="dm"/></feMerge></filter>
<filter id="${i}b" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="1.6"/></filter>
<filter id="${i}B" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4.5"/></filter>
<filter id="${i}s" x="-30%" y="-30%" width="160%" height="170%"><feDropShadow dx="0" dy="4" stdDeviation="3.5" flood-color="#3B0C08" flood-opacity=".42"/></filter>`
const rg = (id, stops, cx = '50%', cy = '50%', r = '60%') =>
`<radialGradient id="${id}" cx="${cx}" cy="${cy}" r="${r}">${stops.map(([o, c, a = 1]) => `<stop offset="${o}" stop-color="${c}" stop-opacity="${a}"/>`).join('')}</radialGradient>`
const lg = (id, stops, x2 = 0, y2 = 1) =>
`<linearGradient id="${id}" x1="0" y1="0" x2="${x2}" y2="${y2}">${stops.map(([o, c, a = 1]) => `<stop offset="${o}" stop-color="${c}" stop-opacity="${a}"/>`).join('')}</linearGradient>`
/* motion layers, driven by CSS keyframes (see .preview__art / .dish__art) */
const STEAM = (i, xs, y = 60) => `<g class="stm" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" filter="url(#${i}b)">${
xs.map((x, k) => `<path d="M${x} ${y + 30}c-7-8 7-14 0-22s7-14 0-22" style="animation-delay:${(-k * 0.9).toFixed(2)}s"/>`).join('')}</g>`
const PARTS = (cls, n, R, [x0, x1], [y0, y1], fill, r = [0.8, 1.8], dur = 2.4) => `<g class="${cls}">${
Array.from({ length: n }, () => `<circle cx="${(x0 + R() * (x1 - x0)).toFixed(1)}" cy="${(y0 + R() * (y1 - y0)).toFixed(1)}" r="${(r[0] + R() * (r[1] - r[0])).toFixed(1)}" fill="${fill}" style="animation-delay:${(-R() * dur).toFixed(2)}s"/>`).join('')}</g>`
const ART = {
/* Firecracker Chicken — two skewers of glazed, charred thigh */
skewers: () => { const i = nid(), R = srng(11)
const chunk = (x, y, r, s) => `<g transform="translate(${x} ${y}) rotate(${r}) scale(${s})">
<path d="M-20 -13C-11 -21 12 -20 20 -12C25 -2 23 10 16 15C4 21-12 20-19 13C-25 4-25-6-20-13Z" fill="url(#${i}m)" filter="url(#${i}t)"/>
<path d="M-20 -13C-11 -21 12 -20 20 -12C25 -2 23 10 16 15C4 21-12 20-19 13C-25 4-25-6-20-13Z" fill="url(#${i}rim)"/>
<g stroke="#1E0703" stroke-linecap="round" filter="url(#${i}b)" opacity=".75">
<path d="M-13 -13L-7 14" stroke-width="4.5"/><path d="M1 -15L6 14" stroke-width="4"/><path d="M13 -10L16 8" stroke-width="3"/></g>
<path class="a-sheen" d="M-14 -11C-6 -16 6 -16 12 -12" stroke="#fff" stroke-width="3.2" stroke-linecap="round" fill="none" opacity=".5" filter="url(#${i}b)"/>
<ellipse cx="7" cy="-8" rx="3" ry="1.6" fill="#fff" opacity=".7"/></g>`
const stick = (x1, y1, x2, y2) => `<path d="M${x1} ${y1}L${x2} ${y2}" stroke="url(#${i}w)" stroke-width="5.5" stroke-linecap="round"/>
<path d="M${x2 - 10} ${y2 + 8}L${x2} ${y2}" stroke="#2A1206" stroke-width="5.5" stroke-linecap="round" opacity=".85"/>`
const seeds = Array.from({ length: 26 }, () => { const x = 34 + R() * 132, y = 40 + R() * 120, a = R() * 180
return `<ellipse cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" rx="2.3" ry="1.2" transform="rotate(${a.toFixed(0)} ${x.toFixed(1)} ${y.toFixed(1)})" fill="#FBF0D6"/>` }).join('')
const onion = Array.from({ length: 9 }, () => { const x = 36 + R() * 128, y = 44 + R() * 116
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${(2.4 + R() * 1.6).toFixed(1)}" fill="none" stroke="#6FAE3C" stroke-width="1.6"/>` }).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
${rg(i + 'm', [[0, '#F7B55A'], [0.45, '#D9561F'], [0.8, '#9E2410'], [1, '#5E1207']], '42%', '34%', '70%')}
${rg(i + 'rim', [[0.6, '#000', 0], [1, '#2A0904', 0.55]])}
${lg(i + 'w', [[0, '#EFD3A0'], [0.5, '#D2A566'], [1, '#A87838']], 0, 1)}</defs>
<g transform="translate(100 100) scale(1.12) translate(-100 -100)">
<path d="M40 150C70 150 120 70 170 56" stroke="#8E1E0C" stroke-width="9" stroke-linecap="round" fill="none" opacity=".22" filter="url(#${i}B)"/>
<g filter="url(#${i}s)">${stick(22, 150, 182, 38)}
${[[52, 128, -36, 1], [86, 104, -32, 1.02], [120, 80, -40, .98], [154, 56, -34, .92]].map(a => chunk(...a)).join('')}</g>
<g filter="url(#${i}s)">${stick(34, 176, 190, 70)}
${[[66, 154, -30, .96], [100, 130, -38, 1], [134, 106, -34, 1.02], [166, 84, -40, .9]].map(a => chunk(...a)).join('')}</g>
${seeds}${onion}${PARTS('a-sizzle', 16, R, [50, 170], [50, 160], '#FFE7B0', [0.8, 1.6], 1.4)}</g>
${PARTS('a-ember', 12, R, [50, 160], [70, 150], '#FF8A2A', [0.8, 1.7])}${STEAM(i, [70, 110, 146], 46)}</svg>` },
/* Smoked Brisket Bun — glossy sesame bun, bark-edged brisket, pink onion */
bun: () => { const i = nid(), R = srng(5)
const seeds = Array.from({ length: 34 }, () => { const a = Math.PI * (1.08 + R() * 0.84), rr = 20 + R() * 40
const x = 100 + Math.cos(a) * rr * 1.55, y = 104 + Math.sin(a) * rr * 1.05, t = (R() * 70 - 35).toFixed(0)
return `<ellipse cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" rx="2.6" ry="1.4" transform="rotate(${t} ${x.toFixed(1)} ${y.toFixed(1)})" fill="url(#${i}se)"/>` }).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
${rg(i + 'top', [[0, '#F9D08C'], [0.35, '#E29A48'], [0.75, '#B8621F'], [1, '#7C3A12']], '46%', '28%', '75%')}
${lg(i + 'bot', [[0, '#E9B06A'], [0.6, '#C98340'], [1, '#8E4E1C']])}
${lg(i + 'meat', [[0, '#2A0C06'], [0.18, '#6A2616'], [0.4, '#B04E38'], [0.7, '#C8705A'], [1, '#5A1E10']])}
${rg(i + 'se', [[0, '#FFFBEF'], [1, '#D9BF8A']], '40%', '35%')}</defs>
<ellipse cx="100" cy="168" rx="72" ry="9" fill="#3B0C08" opacity=".28" filter="url(#${i}B)"/>
<path d="M36 140C36 132 44 128 52 128H148C156 128 164 132 164 140V148C164 160 154 166 140 166H60C46 166 36 160 36 148Z" fill="url(#${i}bot)" filter="url(#${i}t)"/>
<path d="M40 132C60 138 140 138 160 132" stroke="#F5DDB0" stroke-width="3" fill="none" opacity=".7"/>
<g filter="url(#${i}t)">
<path d="M28 128C40 118 52 132 66 122C80 113 90 130 106 121C120 113 132 128 146 119C156 114 166 122 172 126L170 134C152 140 50 142 30 136Z" fill="url(#${i}meat)"/>
<path d="M30 118C44 108 56 122 70 112C84 104 96 120 110 111C124 103 136 118 150 110C160 105 168 112 172 116L170 126C150 132 50 133 30 128Z" fill="url(#${i}meat)"/></g>
<path d="M36 122C60 128 90 120 120 124S160 122 168 120" stroke="#E0907A" stroke-width="1.6" fill="none" opacity=".6"/>
<g fill="none" stroke-width="3.2" stroke-linecap="round">
<path d="M48 112C52 104 64 104 66 112" stroke="#E07BA6"/><path d="M92 110C96 101 110 101 112 110" stroke="#D66A98"/>
<path d="M130 108C134 100 146 100 148 108" stroke="#E68AB2"/></g>
<path d="M40 108C58 98 84 116 100 106C116 97 140 114 162 104L164 110C140 118 60 118 38 114Z" fill="#8FBE4A" opacity=".85"/>
<g class="a-squish"><path d="M30 106C30 64 60 40 100 40S170 64 170 106C170 112 164 114 156 114H44C36 114 30 112 30 106Z" fill="url(#${i}top)" filter="url(#${i}t)"/>
<path d="M60 58C74 48 96 44 116 48" stroke="#fff" stroke-width="7" stroke-linecap="round" fill="none" opacity=".42" filter="url(#${i}b)"/>
${seeds}</g>${STEAM(i, [72, 102, 132], 6)}</svg>` },
/* Blistered Chili Noodles — black stoneware bowl, red chili oil, crisp */
bowl: () => { const i = nid(), R = srng(21)
const strands = Array.from({ length: 22 }, () => {
const a = R() * Math.PI * 2, r0 = R() * 34, x0 = 100 + Math.cos(a) * r0, y0 = 100 + Math.sin(a) * r0
const b = a + (R() - 0.5) * 3, d = 24 + R() * 30
const x1 = x0 + Math.cos(b) * d, y1 = y0 + Math.sin(b) * d, cx = (x0 + x1) / 2 + (R() - 0.5) * 40, cy = (y0 + y1) / 2 + (R() - 0.5) * 40
const p = `M${x0.toFixed(1)} ${y0.toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x1.toFixed(1)} ${y1.toFixed(1)}`
return `<path d="${p}" stroke="#7A3212" stroke-width="7" opacity=".45"/><path d="${p}" stroke="url(#${i}nd)" stroke-width="5"/>
<path d="${p}" stroke="#FFF6DA" stroke-width="1.2" opacity=".7" transform="translate(-.8 -1)"/>` }).join('')
const flakes = Array.from({ length: 60 }, () => { const a = R() * 6.283, r = R() * 58
return `<circle cx="${(100 + Math.cos(a) * r).toFixed(1)}" cy="${(100 + Math.sin(a) * r).toFixed(1)}" r="${(0.8 + R() * 1.8).toFixed(1)}" fill="${R() < 0.6 ? '#7A0E06' : '#2A0703'}"/>` }).join('')
const greens = Array.from({ length: 12 }, () => { const a = R() * 6.283, r = 10 + R() * 44
return `<circle cx="${(100 + Math.cos(a) * r).toFixed(1)}" cy="${(100 + Math.sin(a) * r).toFixed(1)}" r="${(2.4 + R() * 1.5).toFixed(1)}" fill="#A6D46A" stroke="#4E8A26" stroke-width="1.4"/>` }).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
${rg(i + 'bw', [[0.78, '#2C2624'], [0.9, '#4A403C'], [0.96, '#1A1514'], [1, '#0E0B0A']])}
${rg(i + 'oil', [[0, '#E0602A'], [0.55, '#B8300F'], [1, '#6E140A']], '45%', '40%')}
${lg(i + 'nd', [[0, '#FBE3A6'], [1, '#E2B566']], 1, 1)}
${lg(i + 'cs', [[0, '#D9B27A'], [1, '#8A5E2E']], 1, 0)}</defs>
<circle cx="100" cy="106" r="84" fill="#3B0C08" opacity=".3" filter="url(#${i}B)"/>
<circle cx="100" cy="100" r="84" fill="url(#${i}bw)" filter="url(#${i}t)"/>
<path d="M36 62A76 76 0 0 1 120 26" stroke="#fff" stroke-width="3" fill="none" opacity=".28" stroke-linecap="round" filter="url(#${i}b)"/>
<circle cx="100" cy="100" r="66" fill="url(#${i}oil)"/>
<g class="a-lift" fill="none" stroke-linecap="round">${strands}</g>
${flakes}${greens}
<g class="a-sheen" fill="#FFD9A0" opacity=".55">${[[72, 70, 5, 2], [128, 132, 6, 2.4], [60, 124, 4, 1.6], [138, 76, 4.5, 1.8]].map(([x, y, a, b]) => `<ellipse cx="${x}" cy="${y}" rx="${a}" ry="${b}" transform="rotate(-30 ${x} ${y})"/>`).join('')}</g>
<circle class="a-ripple" cx="100" cy="100" r="30" fill="none" stroke="#FFB27A" stroke-width="1.2"/>
<g class="a-chop" filter="url(#${i}s)"><path d="M186 30L96 104" stroke="url(#${i}cs)" stroke-width="7" stroke-linecap="round"/>
<path d="M194 46L104 112" stroke="url(#${i}cs)" stroke-width="7" stroke-linecap="round"/></g>${STEAM(i, [70, 100, 130], 40)}</svg>` },
/* Charred Street Corn — blistered cob, chili dust, a lime wedge */
corn: () => { const i = nid(), R = srng(3)
const kernels = Array.from({ length: 15 }, (_, r) => Array.from({ length: 6 }, (_, c) => {
const f = R(), x = 70 + c * 10.4 + (r % 2) * 2, y = 18 + r * 9.6
const fill = f < 0.2 ? `url(#${i}kb)` : f < 0.42 ? `url(#${i}kc)` : `url(#${i}k)`
return `<rect x="${x}" y="${y}" width="9.4" height="8.8" rx="3.6" fill="${fill}"/>` }).join('')).join('')
const dust = Array.from({ length: 70 }, () => `<circle cx="${(72 + R() * 58).toFixed(1)}" cy="${(20 + R() * 140).toFixed(1)}" r="${(0.5 + R() * 1.1).toFixed(1)}" fill="#B3200E" opacity="${(0.5 + R() * 0.5).toFixed(2)}"/>`).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
<clipPath id="${i}cl"><rect x="68" y="14" width="66" height="150" rx="32"/></clipPath>
${rg(i + 'k', [[0, '#FFF2A8'], [0.55, '#F6C53C'], [1, '#C98A14']], '38%', '32%')}
${rg(i + 'kc', [[0, '#E9A24A'], [1, '#8A4412']], '40%', '35%')}
${rg(i + 'kb', [[0, '#5A2A12'], [1, '#170703']], '40%', '35%')}
${lg(i + 'cyl', [[0, '#000', 0.45], [0.28, '#000', 0], [0.42, '#fff', 0.22], [0.6, '#000', 0], [1, '#000', 0.5]], 1, 0)}
${lg(i + 'hk', [[0, '#E4D29A'], [0.6, '#B59C58'], [1, '#6E5A26']])}
${rg(i + 'lm', [[0, '#F4FAD0'], [0.7, '#C9E07A'], [1, '#5E9A2A']], '50%', '15%', '90%')}</defs>
<ellipse cx="106" cy="176" rx="46" ry="8" fill="#3B0C08" opacity=".3" filter="url(#${i}B)"/>
<g class="a-sway"><g transform="translate(100 96) rotate(32) scale(.9) translate(-100 -96)">
<g filter="url(#${i}t)"><path d="M100 150C70 150 52 172 50 196C70 180 86 176 100 176Z" fill="url(#${i}hk)"/>
<path d="M100 150C130 150 148 172 150 196C130 180 114 176 100 176Z" fill="url(#${i}hk)"/>
<path d="M100 150C92 164 90 180 94 198C98 184 102 170 100 150Z" fill="#8A7236"/></g>
<path d="M58 188C66 180 76 176 84 174" stroke="#2A1206" stroke-width="3" opacity=".6" fill="none"/>
<g filter="url(#${i}s)"><rect x="68" y="14" width="66" height="150" rx="32" fill="#C98A14"/>
<g clip-path="url(#${i}cl)">${kernels}<rect x="68" y="14" width="66" height="150" fill="url(#${i}cyl)"/>${dust}
<path d="M84 30C82 70 82 110 86 150" stroke="#FFFBE8" stroke-width="4" opacity=".55" fill="none" filter="url(#${i}b)"/></g></g></g>
</g>${PARTS('a-fall', 18, R, [60, 150], [40, 150], '#B3200E', [0.6, 1.3], 2)}
<g transform="translate(150 150) rotate(-18)" filter="url(#${i}s)">
<path d="M-24 0A24 24 0 0 0 24 0Z" fill="#3E7A1C"/><path d="M-21 0A21 21 0 0 0 21 0Z" fill="url(#${i}lm)"/>
<g stroke="#F4FAD0" stroke-width="1.2" opacity=".8">${[-60, -30, 0, 30, 60].map(a => `<path d="M0 0L${(Math.sin(a * Math.PI / 180) * 19).toFixed(1)} ${(Math.cos(a * Math.PI / 180) * 19).toFixed(1)}"/>`).join('')}</g></g>${STEAM(i, [74, 104], 20)}</svg>` },
/* Wood-fired Diavola — leopard-spotted crust, cupped pepperoni, hot honey */
pizza: () => { const i = nid(), R = srng(17)
const spots = Array.from({ length: 30 }, () => { const a = R() * 6.283, r = 66 + R() * 10
return `<ellipse cx="${(100 + Math.cos(a) * r).toFixed(1)}" cy="${(100 + Math.sin(a) * r).toFixed(1)}" rx="${(2 + R() * 4).toFixed(1)}" ry="${(1.5 + R() * 2.5).toFixed(1)}" fill="#1E0A04" opacity="${(0.55 + R() * 0.4).toFixed(2)}"/>` }).join('')
const cheese = [[74, 72, 16], [122, 64, 13], [128, 122, 17], [72, 130, 14], [100, 98, 12], [146, 94, 9], [96, 150, 9]]
.map(([x, y, r]) => `<path d="M${x - r} ${y}C${x - r} ${y - r * 0.9} ${x + r * 0.8} ${y - r * 1.1} ${x + r} ${y - r * 0.1}S${x + r * 0.2} ${y + r * 1.1} ${x - r * 0.5} ${y + r * 0.8}S${x - r} ${y + r * 0.4} ${x - r} ${y}Z" fill="url(#${i}ch)"/>`).join('')
const pep = [[86, 58], [142, 110], [100, 128], [58, 100], [120, 152], [116, 84], [70, 150], [148, 72]]
.map(([x, y]) => `<g filter="url(#${i}s)"><circle cx="${x}" cy="${y}" r="11" fill="url(#${i}pp)"/>
<circle cx="${x}" cy="${y}" r="9.5" fill="none" stroke="#4A0A05" stroke-width="2.2" opacity=".8"/>
<ellipse cx="${x - 1}" cy="${y + 1}" rx="5" ry="4" fill="#E8641E" opacity=".55"/>
<ellipse cx="${x - 4}" cy="${y - 4}" rx="2.6" ry="1.4" fill="#fff" opacity=".6"/></g>`).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
${rg(i + 'cr', [[0.72, '#E2A45C'], [0.86, '#EDBF7A'], [0.95, '#C4803C'], [1, '#8C4E1E']])}
${rg(i + 'sa', [[0, '#C8401E'], [0.8, '#A42A12'], [1, '#7E1C0C']])}
${rg(i + 'ch', [[0, '#FFFBEE'], [0.6, '#FBEBC4'], [1, '#EFCB86']], '40%', '35%')}
${rg(i + 'pp', [[0, '#C8321E'], [0.75, '#9E1E12'], [1, '#5E0E08']], '45%', '40%')}</defs>
<circle cx="100" cy="106" r="80" fill="#3B0C08" opacity=".3" filter="url(#${i}B)"/>
<g class="a-spin"><circle cx="100" cy="100" r="78" fill="url(#${i}cr)" filter="url(#${i}t)"/>
<g filter="url(#${i}b)">${spots}</g>
<path d="M100 38C134 38 162 66 162 100C162 134 134 162 100 162C66 162 38 134 38 100C38 66 66 38 100 38Z" fill="url(#${i}sa)" filter="url(#${i}t)"/>
<g filter="url(#${i}b)" opacity=".95">${cheese}</g><g>${cheese}</g>
${pep}
<g fill="#3E7A22">${[[98, 104, 20], [136, 136, -40], [66, 80, 60]].map(([x, y, a]) => `<g transform="translate(${x} ${y}) rotate(${a})"><path d="M-9 0C-5 -6 5 -6 9 0C5 6 -5 6 -9 0Z" fill="#3E8A26"/><path d="M-8 0H8" stroke="#8CC45A" stroke-width=".9"/></g>`).join('')}</g>
<path class="a-drizzle" d="M46 80C70 70 76 96 100 88S134 72 152 90M58 132C80 120 96 142 118 132S146 120 156 128" stroke="#F2B43C" stroke-width="2.2" fill="none" opacity=".85" stroke-linecap="round"/>
</g>${PARTS('a-ember', 8, R, [40, 160], [60, 150], '#FF8A2A', [0.8, 1.6])}${STEAM(i, [76, 106, 136], 44)}
<path d="M52 54A68 68 0 0 1 96 30" stroke="#FFE7B8" stroke-width="3" fill="none" opacity=".5" stroke-linecap="round" filter="url(#${i}b)"/></svg>` },
/* Burnt Basque Cheesecake — a wedge: scorched top, creamy cut face, molten middle */
cake: () => { const i = nid(), R = srng(9)
const cracks = Array.from({ length: 8 }, () => { const u = 0.2 + R() * 0.6, v = 0.2 + R() * 0.6
const x = 60 + u * 100 - v * 20, y = 110 - u * 30 - v * 26
return `<path d="M${x.toFixed(0)} ${y.toFixed(0)}l${(R() * 12 - 6).toFixed(0)} ${(R() * 5 - 1).toFixed(0)}l${(R() * 10 - 5).toFixed(0)} ${(R() * 5).toFixed(0)}" stroke="#0A0301" stroke-width="1.3" fill="none" opacity=".75"/>` }).join('')
const crumbs = Array.from({ length: 12 }, () => `<circle cx="${(30 + R() * 150).toFixed(0)}" cy="${(150 + R() * 22).toFixed(0)}" r="${(0.8 + R() * 1.7).toFixed(1)}" fill="${R() < 0.5 ? '#3A1508' : '#EBCF98'}"/>`).join('')
return `<svg viewBox="0 0 200 200"><defs>${FX(i)}
${rg(i + 'top', [[0, '#9A4E1C'], [0.35, '#5A240C'], [0.7, '#2A0D04'], [1, '#150602']], '38%', '45%', '75%')}
${lg(i + 'face', [[0, '#3A1608'], [0.07, '#9A5222'], [0.14, '#E2B06A'], [0.24, '#F8E3B4'], [0.7, '#FBEBC8'], [0.94, '#EACB8E'], [1, '#B98244']])}
${lg(i + 'end', [[0, '#2A0E04'], [0.12, '#8A4A1C'], [0.3, '#D8B076'], [1, '#C89A5E']])}
${rg(i + 'mol', [[0, '#FFF8E2'], [0.55, '#FBE4AE', 0.85], [1, '#F6D896', 0]])}</defs>
<ellipse cx="108" cy="168" rx="80" ry="12" fill="#3B0C08" opacity=".32" filter="url(#${i}B)"/>
<g class="a-jiggle"><path d="M176 92C180 104 180 128 176 142L180 144C184 128 184 104 180 92Z" fill="url(#${i}end)"/>
<path d="M40 126L176 90L176 142L40 176Z" fill="url(#${i}face)" filter="url(#${i}t)"/>
<path d="M40 126L176 90" stroke="#1A0703" stroke-width="3" opacity=".6"/>
<ellipse cx="96" cy="140" rx="34" ry="11" fill="url(#${i}mol)" transform="rotate(-15 96 140)" class="a-pulse"/>
<path d="M58 158C80 152 104 148 128 140" stroke="#fff" stroke-width="3" opacity=".45" fill="none" stroke-linecap="round" filter="url(#${i}b)"/>
<path d="M40 126L112 54C132 52 150 58 164 68C174 76 180 84 176 90Z" fill="url(#${i}top)" filter="url(#${i}t)"/>
<path d="M112 54C132 52 150 58 164 68C174 76 180 84 176 90" stroke="#0E0402" stroke-width="4" fill="none" opacity=".55" filter="url(#${i}b)"/>
<g fill="#B0621F" opacity=".5" filter="url(#${i}b)"><ellipse cx="96" cy="94" rx="12" ry="4" transform="rotate(-30 96 94)"/><ellipse cx="136" cy="80" rx="9" ry="3" transform="rotate(-20 136 80)"/><ellipse cx="72" cy="114" rx="7" ry="2.6" transform="rotate(-35 72 114)"/></g>
<path d="M84 96C100 80 120 70 140 68" stroke="#E08A3A" stroke-width="2" fill="none" opacity=".35" filter="url(#${i}b)"/>
${cracks}</g>${crumbs}${STEAM(i, [96, 126], 10)}</svg>` },
}
/* heat meter: a tiny glossy chili */
/* real dish photography (Pexels, see assets/CREDITS.md), cropped square for the plate */
const PHOTO = {
skewers: 38138067, bun: 34495427, bowl: 27238146,
corn: 13398481, pizza: 29829308, cake: 38648038,
}
const photoURL = (k, w = 640) => `https://demo.uichemy.com/wp-content/uploads/2026/09/ember-${k}-${w <= 200 ? 160 : 640}.jpg`
/* the photo in the plate's well, with steam rising off it; the painted
plate is the fallback if the photo can't load */
const dishHTML = (k, w, steam = true) => { const i = nid()
return `<span class="ph"><img src="${photoURL(k, w)}" alt="" decoding="async" data-k="${k}"></span>${steam ? `<svg class="ph__steam" viewBox="0 0 200 200"><defs>${FX(i)}</defs>${STEAM(i, [70, 100, 130], 40)}</svg>` : ''}` }
addEventListener('error', e => { const im = e.target
if (im.tagName === 'IMG' && im.dataset.k){ const box = im.closest('.preview__art, .dish__art'); if (box) box.innerHTML = ART[im.dataset.k]() } }, true)
Object.keys(PHOTO).forEach(k => { const im = new Image(); im.src = photoURL(k) }) // warm the cache for hover
const CHILI = `<svg viewBox="0 0 16 20" aria-hidden="true"><path d="M9.6 3.1c.4-1.2 1.2-2 2.4-2.3l.4 1.1c-.8.3-1.3.8-1.5 1.5 1.5.5 2.4 1.9 2.3 3.6-.3 5.4-4.2 10-9.3 11.7-.6.2-1.2-.4-.8-1 2-3.4 3.2-6.9 3.4-10.5.1-2.2 1.4-3.9 3.1-4.1z" fill="currentColor"/><path d="M11.2 1.4c-.9.3-1.3.9-1.5 1.6" stroke="#4E6B2A" stroke-width="1.3" fill="none" stroke-linecap="round"/><path d="M9.3 6.4c-.2 2.6-1 5-2.3 7.1" stroke="#fff" stroke-width="1" fill="none" stroke-linecap="round" opacity=".5"/></svg>`
/* ══════════════════════════════════════════════════════════════════
CONTENT BUILD
══════════════════════════════════════════════════════════════════ */
$$('.dish').forEach(d => {
const heat = +d.dataset.heat
const h = $('.dish__heat', d)
h.innerHTML = Array.from({ length: 4 }, (_, i) => CHILI.replace('<svg', `<svg class="${i < heat ? '' : 'off'}"`)).join('')
h.setAttribute('aria-label', heat ? `Heat ${heat} of 4` : 'Not spicy')
$('.dish__art', d).innerHTML = dishHTML(d.dataset.art, 160, false)
})
const REVIEWS = [
['Tastes like someone actually cared. Arrived hotter than my building\'s hot water.', 'Riya S.', 'Firecracker Chicken'],
['The noodles have no right to travel this well.', 'Arjun M.', 'Chili Noodles'],
['I ordered for one. I lied.', 'Kabir T.', 'Brisket Bun'],
['Burnt cheesecake. Zero regrets. Two forks, one of them mine.', 'Neha P.', 'Basque Cheesecake'],
['Thirty-eight minutes and the crust was still crackling.', 'Dev R.', 'Wood-fired Diavola'],
['A restaurant without the restaurant part, and somehow better for it.', 'Sana K.', 'Street Corn'],
]
const reviewHTML = list => list.map(([q, who, dish]) => `<figure class="review"><q>${q}</q>
<figcaption class="review__who"><span class="review__av">${who[0]}</span><div>${who}<small>${dish}</small></div>
<span class="stars" aria-label="5 stars">★★★★★</span></figcaption></figure>`).join('')
{
const rows = $$('.rrow')
const a = reviewHTML(REVIEWS), b = reviewHTML([...REVIEWS.slice(3), ...REVIEWS.slice(0, 3)])
rows[0].innerHTML = a + a // doubled so the loop is seamless
rows[1].innerHTML = b + b
rows.forEach(r => { r.style.gap = '18px' })
}
/* split into rising words, keeping inline elements like <em> */
function splitWords(el, cls = 'wi'){
if (el.querySelector(cls === 'w' ? '.w' : '.wi')) return // WP: already split by an earlier run on this copy
const walk = node => [...node.childNodes].forEach(n => {
if (n.nodeType === 3){
const frag = document.createDocumentFragment()
n.textContent.split(/(\s+)/).forEach(tok => {
if (!tok) return
if (/^\s+$/.test(tok)){ frag.appendChild(document.createTextNode(' ')); return }
if (cls === 'w'){ const s = document.createElement('span'); s.className = 'w'; s.textContent = tok; frag.appendChild(s); return }
const m = document.createElement('span'); m.className = 'wm'
const i = document.createElement('span'); i.className = 'wi'; i.textContent = tok
m.appendChild(i); frag.appendChild(m)
})
n.replaceWith(frag)
} else if (n.nodeType === 1 && !n.classList.contains('wm')) walk(n)
})
walk(el)
}
$$('[data-split]').forEach(el => splitWords(el))
$$('[data-words]').forEach(el => splitWords(el, 'w'))
/* ══════════════════════════════════════════════════════════════════
WEBGL — one fixed canvas above the page, pointer-events off
══════════════════════════════════════════════════════════════════ */
const canvas = $('#gl')
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' })
renderer.setPixelRatio(DPR)
renderer.setClearColor(0x000000, 0)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.NeutralToneMapping
renderer.setSize(innerWidth, innerHeight, false)
/* pixel-space scene for the burning words: x right, y up, in CSS px */
const sceneF = new THREE.Scene()
const camF = new THREE.OrthographicCamera(0, innerWidth, innerHeight, 0, -10, 10)
const ptr = { x: innerWidth / 2, y: innerHeight / 2, vx: 0, wind: 0, active: false }
addEventListener('pointermove', e => {
ptr.vx = e.clientX - ptr.x; ptr.x = e.clientX; ptr.y = e.clientY; ptr.active = true
}, { passive: true })
/* ── fire ────────────────────────────────────────────────────────────
The word is drawn to a canvas as a mask. A second map, the envelope,
is that mask dragged upward with an exponential falloff — the region
flames are allowed to rise into. The shader wobbles the envelope with
rising noise to cut it into tongues, colours it char→red→orange→gold,
and paints the letters themselves as live embers. On a cream page the
fire is composited with ordinary alpha, not additive light, so every
colour on the ramp stays deep enough to read. */
const NOISE = `
vec2 h2(vec2 p){ p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123); }
float snoise(vec2 p){
const float K1 = 0.366025404, K2 = 0.211324865;
vec2 i = floor(p + (p.x + p.y) * K1), a = p - i + (i.x + i.y) * K2;
float m = step(a.y, a.x); vec2 o = vec2(m, 1.0 - m);
vec2 b = a - o + K2, c = a - 1.0 + 2.0 * K2;
vec3 h = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
vec3 n = h * h * h * h * vec3(dot(a, h2(i)), dot(b, h2(i + o)), dot(c, h2(i + 1.0)));
return dot(n, vec3(70.0));
}
float fbm(vec2 p){ /* 4 octaves: the shaping noise */
float f = 0.0, a = 0.5; mat2 r = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 4; i++){ f += a * snoise(p); p = r * p; a *= 0.5; }
return f;
}
float fbm3(vec2 p){ /* 3 octaves: warp and surfaces */
float f = 0.0, a = 0.5; mat2 r = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 3; i++){ f += a * snoise(p); p = r * p; a *= 0.5; }
return f;
}`
const fireVert = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`
const fireFrag = `
precision highp float;
uniform sampler2D uMask, uEnv;
uniform float uTime, uIgnite, uReveal, uHeat, uWind, uAspect, uSpanB, uSpanH, uFill, uBurn;
uniform vec2 uExt; uniform vec3 uLight;
varying vec2 vUv;
${NOISE}
const vec3 CHAR = vec3(0.30, 0.03, 0.02);
const vec3 DEEP = vec3(0.52, 0.06, 0.035);
const vec3 RED = vec3(0.784, 0.137, 0.102);
const vec3 ORNG = vec3(0.97, 0.36, 0.07);
const vec3 AMBR = vec3(1.0, 0.56, 0.12);
const vec3 GOLD = vec3(1.0, 0.78, 0.32);
const vec3 HOT = vec3(1.0, 0.95, 0.80);
/* temperature → colour: cool deep-red tips up to a near-white core */
vec3 heatRamp(float x){
x = clamp(x, 0.0, 1.0);
vec3 c = mix(DEEP, RED, smoothstep(0.0, 0.2, x));
c = mix(c, ORNG, smoothstep(0.2, 0.46, x));
c = mix(c, AMBR, smoothstep(0.46, 0.66, x));
c = mix(c, GOLD, smoothstep(0.66, 0.86, x));
return mix(c, HOT, smoothstep(0.86, 1.0, x));
}
void main(){
vec2 uv = vUv;
float t = uTime;
vec2 ruv = uv + vec2(0.0, (1.0 - uReveal) * uSpanH * 1.05);
float clipLine = mix(step(uSpanB, uv.y), 1.0, step(0.999, uReveal));
/* work in letter-heights, so the flames keep the same character at
any size of word */
vec2 p = vec2(uv.x * uAspect, uv.y) / max(uSpanH, 0.05);
float lift = max(uv.y - uSpanB, 0.0) / max(uSpanH, 0.05);
/* Slow domain-warped turbulence. The warp field drifts at a walking
pace and the detail rides it upward a little faster — big lazy
rolls with finer licks inside them, which is how real flame moves. */
vec2 fp = p * vec2(1.5, 0.85);
vec2 w = vec2(fbm3(fp * 1.05 + vec2(0.0, -t * 0.32)),
fbm3(fp * 1.05 + vec2(4.3, -t * 0.38)));
float turb = fbm(fp * 2.1 + w * 1.4 + vec2(0.0, -t * 0.78));
float fine = snoise(vec2(p.x * 6.5, p.y * 2.8 - t * 1.5));
/* the burn front climbs from the foot of the letters to the top with a
ragged, licking edge, and the flames around the word grow taller
behind it. uIgnite is how far it has climbed. */
float yl = (uv.y - uSpanB) / max(uSpanH, 0.05); // 0 at the baseline, 1 at the cap line
float fn = fbm3(vec2(p.x * 1.6, p.y * 1.2 - t * 0.7)) * 0.2 + snoise(vec2(p.x * 7.0, t * 2.6)) * 0.035;
float run = uIgnite * 1.5 - 0.12 - (clamp(yl, -0.2, 1.0) * 1.0 + fn);
float lit = smoothstep(0.0, 0.12, run);
float scorch = smoothstep(-0.14, 0.0, run) * (1.0 - lit);
float runF = uIgnite * 3.4 - 0.25 - (max(yl, 0.0) + fn * 1.6);
float litF = smoothstep(0.0, 0.35, runF);
float sway = w.y * 0.12;
/* the envelope, pulled sideways by the warp, swaying with height,
leaning into the wind, and dragged down so tails stretch up */
vec2 off = vec2((w.x * 0.09 + sway * lift - uWind * lift * 0.3) * uSpanH / uAspect,
-(0.07 + 0.06 * (turb * 0.5 + 0.5)) * uSpanH);
vec4 envT = texture2D(uEnv, ruv + off);
/* the halo hugs every side of the word; below the baseline it only
glows a little, so the tagline underneath stays clear */
float below = smoothstep(uSpanB - uSpanH * 0.12, uSpanB + uSpanH * 0.05, uv.y);
float env = max(envT.r, smoothstep(0.04, 0.55, envT.g) * (0.55 + 0.45 * below));
/* erosion grows with height: tongues narrow as they climb and break
into detached licks near the top instead of ending in a wall */
float erode = 0.13 + lift * 0.62;
float flame = env * (1.0 + uHeat * 0.3) + turb * 0.56 + fine * 0.08 - erode;
flame = clamp(flame / 0.72, 0.0, 1.0);
float breathe = 0.92 + 0.08 * snoise(vec2(t * 0.35, 3.7));
float flick = 0.9 + 0.1 * snoise(vec2(p.x * 1.2, t * 2.4));
float gflick = 0.93 + 0.07 * snoise(vec2(t * 1.7, 9.1)) + 0.04 * snoise(vec2(t * 5.3, 2.4));
/* a second, deeper sheet of flame behind the first: bigger, slower,
darker — the parallax between the two is what reads as volume */
vec2 wB = vec2(fbm3(fp * 0.8 + vec2(7.1, -t * 0.24)), fbm3(fp * 0.8 + vec2(2.2, -t * 0.29)));
float turbB = fbm(fp * 1.55 + wB * 1.5 + vec2(3.0, -t * 0.58));
vec2 offB = vec2((wB.x * 0.14 - uWind * lift * 0.22) * uSpanH / uAspect, -(0.1 + 0.08 * (turbB * 0.5 + 0.5)) * uSpanH);
vec4 eB = texture2D(uEnv, ruv + offB);
float envB = max(eB.r, smoothstep(0.03, 0.5, eB.g) * (0.55 + 0.45 * below));
float flameB = clamp((envB * (1.14 + uHeat * 0.35) + turbB * 0.62 - (0.08 + lift * 0.46)) / 0.8, 0.0, 1.0);
flameB *= smoothstep(1.0, 0.72, uv.y) * litF * gflick;
float bodyB = smoothstep(0.12, 0.34, flameB);
vec3 colB = mix(DEEP, heatRamp(flameB * 0.72), 0.75);
/* the fire lights the paper around it: a warm, breathing glow */
float castL = smoothstep(0.02, 0.7, max(envT.g, envT.r * 0.6)) * litF * gflick;
float castA = castL * 0.2 * (1.0 - bodyB);
flame *= breathe * flick * gflick * smoothstep(1.0, 0.78, uv.y) * litF;
float temp = clamp(flame * (1.3 - lift * 0.62), 0.0, 1.0);
float body = smoothstep(0.1, 0.32, flame);
float glow = smoothstep(0.0, 0.26, flame) * 0.26;
vec3 fCol = mix(mix(DEEP, ORNG, temp * 0.8), heatRamp(temp), body);
float fA = max(body, glow) * clipLine;
/* a thin warm smoke above the tips — barely there on cream, but it is
what stops the flames looking pasted on */
float sm = (w.y * 0.6 + turb * 0.4) * 0.5 + 0.5;
float smokeA = smoothstep(0.03, 0.3, env) * (1.0 - body) * smoothstep(0.45, 1.2, lift)
* smoothstep(0.5, 0.85, sm) * 0.13 * litF * smoothstep(1.0, 0.72, uv.y) * clipLine;
vec3 smokeCol = vec3(0.32, 0.22, 0.18);
/* the letters: tinted glass with a dark rim. Fire pours in from the
bottom (uFill) with a bright rolling surface, then keeps churning. */
float m = texture2D(uMask, ruv).r * clipLine;
/* scrolling away burns the word off from the top: a ragged front with a
glowing ember edge and a charred rim eats down through the letters */
float bn = fbm3(p * 2.4 + vec2(0.0, -t * 0.15)) * 0.5 + 0.5;
float bField = (1.0 - clamp(yl, 0.0, 1.0)) * 0.62 + bn * 0.38;
float dB = bField - (uBurn * 1.3 - 0.25);
float alive = smoothstep(-0.01, 0.01, dB);
float emberB = (1.0 - smoothstep(0.0, 0.045, dB)) * alive;
float charB = (1.0 - smoothstep(0.045, 0.11, dB)) * alive * (1.0 - emberB);
float e1 = 0.0032;
float inner = min(min(texture2D(uMask, ruv + vec2(e1 / uAspect, 0.0)).r, texture2D(uMask, ruv - vec2(e1 / uAspect, 0.0)).r),
min(texture2D(uMask, ruv + vec2(0.0, e1)).r, texture2D(uMask, ruv - vec2(0.0, e1)).r));
float rim = clamp(m - inner, 0.0, 1.0);
/* depth: the letter pushed back down-right in slices, like a block */
float ext = 0.0, extK = 0.0;
for (int k = 1; k <= 9; k++){
float fk = float(k);
float mk = texture2D(uMask, ruv + vec2(uExt.x * fk / uAspect, uExt.y * fk)).r; // depth follows the viewer
if (mk > ext){ ext = mk; extK = fk / 9.0; }
}
ext *= clipLine * alive;
/* bevel: the mask's slope near the edges, lit from the upper left */
float e2 = 0.006;
float gx = texture2D(uMask, ruv + vec2(e2 / uAspect, 0.0)).r - texture2D(uMask, ruv - vec2(e2 / uAspect, 0.0)).r;
float gy = texture2D(uMask, ruv + vec2(0.0, e2)).r - texture2D(uMask, ruv - vec2(0.0, e2)).r;
vec3 nrm = normalize(vec3(-gx, -gy, 0.55));
float lamb = dot(nrm, normalize(uLight));
float bevel = clamp(lamb, 0.0, 1.0);
vec2 lp = p * 3.4;
float churn = fbm(lp + w * 0.9 + vec2(0.0, -t * 1.2));
float lick = snoise(vec2(p.x * 9.0, p.y * 4.0 - t * 3.0));
float fireIn = clamp((0.64 + churn * 0.44 + lick * 0.12 + uHeat * 0.12) * gflick, 0.0, 1.0);
fireIn = mix(fireIn * 0.72, fireIn, smoothstep(0.2, 0.9, inner));
vec3 inCol = heatRamp(fireIn);
/* ahead of the front the red chars; on the front a white-hot seam */
vec3 glass = mix(RED, CHAR, scorch * 0.85);
float glassA = 1.0;
float seamF = smoothstep(0.0, 0.05, run) * (1.0 - smoothstep(0.05, 0.16, run));
inCol = mix(inCol, HOT, seamF * 0.85);
float filled = max(lit, seamF);
vec3 letter = mix(glass, inCol, filled);
float letA = mix(glassA, 0.97, filled);
letter = mix(letter, mix(CHAR, DEEP, 0.35), rim); // the rim stays crisp
letA = max(letA, rim * 0.95);
letter = mix(letter, HOT, seamF * (1.0 - lit) * 0.6);
letter *= 0.72 + 0.5 * bevel; // raised face
letter = mix(letter, vec3(1.0, 0.95, 0.85), smoothstep(0.9, 1.0, bevel) * 0.35 * (1.0 - rim));
m *= letA;
letter = mix(letter, CHAR * 0.7, charB * 0.85);
letter = mix(letter, mix(ORNG, HOT, emberB * 0.7), emberB);
m *= alive;
/* back to front: smoke, flame, letters */
/* nothing burns, smokes or glows under the word, and every effect
fades before the edges of the quad — no hard box anywhere */
float under = smoothstep(uSpanB - uSpanH * 0.02, uSpanB + uSpanH * 0.1, uv.y);
float edge = smoothstep(0.0, 0.06, uv.x) * smoothstep(1.0, 0.94, uv.x) * smoothstep(0.96, 0.8, uv.y);
float keep = under * edge;
castA *= keep; smokeA *= keep; fA *= keep; bodyB *= keep;
fA *= 1.0 - uBurn * 0.85; bodyB *= 1.0 - uBurn * 0.85; castA *= 1.0 - uBurn;
vec3 acc = vec3(1.0, 0.62, 0.3) * castA; float a = castA;
acc = smokeCol * smokeA + acc * (1.0 - smokeA); a = smokeA + a * (1.0 - smokeA);
float bA = bodyB * clipLine * 0.9 * (1.0 - step(0.5, m));
acc = colB * bA + acc * (1.0 - bA); a = bA + a * (1.0 - bA);
float fa = fA * (1.0 - m);
acc = fCol * fa + acc * (1.0 - fa); a = fa + a * (1.0 - fa);
/* the block's side: dark, deeper toward the back, warmed by the fire */
float sideA = ext * (1.0 - step(0.5, m));
vec3 side = mix(mix(DEEP, CHAR, 0.3), CHAR * 0.6, extK);
side = mix(side, mix(RED, ORNG, 0.5), lit * (1.0 - extK) * 0.55);
acc = side * sideA + acc * (1.0 - sideA); a = sideA + a * (1.0 - sideA);
acc = letter * m + acc * (1.0 - m); a = m + a * (1.0 - m);
gl_FragColor = vec4(acc / max(a, 1e-4), clamp(a, 0.0, 1.0));
}`
const sparkVert = `attribute float aLife; attribute float aSize; uniform float uPix, uT; varying float vLife, vFl;
void main(){ vLife = aLife; vFl = 0.65 + 0.35 * sin(uT * (9.0 + aSize * 3.0) + aSize * 17.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * 2.2 * uPix * (0.35 + 0.65 * aLife); }`
const sparkFrag = `precision mediump float; varying float vLife, vFl;
void main(){
float r = length(gl_PointCoord - 0.5) * 2.0; if (r > 1.0) discard;
float core = smoothstep(0.42, 0.0, r), halo = smoothstep(1.0, 0.3, r) * 0.28;
vec3 c = mix(vec3(0.62, 0.1, 0.06), vec3(1.0, 0.6, 0.16), vLife);
c = mix(c, vec3(1.0, 0.95, 0.78), core * vLife * vFl);
gl_FragColor = vec4(c, (core + halo) * smoothstep(0.0, 0.3, vLife) * vFl);
}`
class FireText {
constructor(el, { sparks = 70 } = {}){
this.el = el
this.probe = el.parentElement.querySelector('.bl')
this.ignite = 0; this.reveal = 1; this.fill = 1; this.heat = 0; this.visible = false
this.burn = 0; this.burnS = 0; this.tilt = { x: 0, y: 0 }; this.flareT = -9; this.burstN = 0
this.rect = { x: 0, y: 0, w: 0, h: 0 }
this.uniforms = {
uMask: { value: null }, uEnv: { value: null }, uTime: { value: 0 }, uIgnite: { value: 0 },
uReveal: { value: 1 }, uHeat: { value: 0 }, uWind: { value: 0 }, uAspect: { value: 1 },
uSpanB: { value: 0.1 }, uSpanH: { value: 0.5 }, uFill: { value: 1 }, uBurn: { value: 0 },
uExt: { value: new THREE.Vector2(-0.0021, 0.0034) }, uLight: { value: new THREE.Vector3(-0.45, 0.6, 0.66) },
}
this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({
uniforms: this.uniforms, vertexShader: fireVert, fragmentShader: fireFrag,
transparent: true, depthTest: false, depthWrite: false,
}))
this.mesh.frustumCulled = false
sceneF.add(this.mesh)
this.N = sparks
this.sp = { x: new Float32Array(sparks), y: new Float32Array(sparks), vx: new Float32Array(sparks),
vy: new Float32Array(sparks), life: new Float32Array(sparks), max: new Float32Array(sparks),
seed: new Float32Array(sparks) }
const g = new THREE.BufferGeometry()
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sparks * 3), 3))
g.setAttribute('aLife', new THREE.BufferAttribute(new Float32Array(sparks), 1))
const sizes = new Float32Array(sparks)
for (let i = 0; i < sparks; i++){ sizes[i] = 1.4 + Math.pow(Math.random(), 2.2) * 4.2; this.sp.seed[i] = Math.random() * 100 }
g.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1))
this.sparks = new THREE.Points(g, new THREE.ShaderMaterial({
uniforms: { uPix: { value: DPR }, uT: { value: 0 } }, vertexShader: sparkVert, fragmentShader: sparkFrag,
transparent: true, depthTest: false, depthWrite: false,
}))
this.sparks.frustumCulled = false
sceneF.add(this.sparks)
this.edges = []
this.spawnAcc = 0
}
build(){
const r = this.el.getBoundingClientRect()
if (r.width < 4 || r.height < 4) return
const cs = getComputedStyle(this.el)
const h = r.height
const padX = h * 0.3, padT = h * 1.15, padB = h * 0.16
const W = r.width + padX * 2, H = h + padT + padB
const sc = Math.min(1.5, DPR, 2400 / W)
const cw = Math.max(4, Math.round(W * sc)), ch = Math.max(4, Math.round(H * sc))
this.pad = { x: padX, t: padT, b: padB, W, H }
const c = document.createElement('canvas'); c.width = cw; c.height = ch
const x = c.getContext('2d', { willReadFrequently: true })
x.fillStyle = '#000'; x.fillRect(0, 0, cw, ch)
x.fillStyle = '#fff'
x.font = `${cs.fontWeight} ${parseFloat(cs.fontSize) * sc}px ${cs.fontFamily}`
x.textBaseline = 'alphabetic'
const baseline = ((this.probe ? this.probe.getBoundingClientRect().top : r.bottom - h * 0.12) - r.top + padT) * sc
/* Each glyph goes exactly where the browser laid it out (a Range per
character), squeezed to that advance. Canvas text cannot use the
font's optical-size axis, so its glyphs run wider than the page's;
without this the letters pile into each other. */
const tn = [...this.el.childNodes].find(n => n.nodeType === 3)
const lsDom = parseFloat(cs.letterSpacing) || 0
for (let i = 0; i < tn.textContent.length; i++){
const ch = tn.textContent[i]; if (!ch.trim()) continue
const rg = document.createRange(); rg.setStart(tn, i); rg.setEnd(tn, i + 1)
const cr = rg.getBoundingClientRect()
const w = x.measureText(ch).width
const sx = clamp((cr.width - lsDom) * sc / Math.max(1, w), 0.45, 1.25)
x.save(); x.translate((cr.left - r.left + padX) * sc, baseline); x.scale(sx, 1)
x.fillText(ch, 0, 0); x.restore()
}
/* envelope at half resolution: drag each column's mask upward */
const img = x.getImageData(0, 0, cw, ch).data
const ew = Math.ceil(cw / 2), eh = Math.ceil(ch / 2)
const env = new Float32Array(ew * eh)
for (let yy = 0; yy < eh; yy++) for (let xx = 0; xx < ew; xx++)
env[yy * ew + xx] = img[(Math.min(ch - 1, yy * 2) * cw + Math.min(cw - 1, xx * 2)) * 4] / 255
const halo = env.slice()
const k = Math.exp(-1 / Math.max(2, h * sc / 2 * 0.42)) // longer tail: taller tongues
for (let xx = 0; xx < ew; xx++){
let prev = 0
for (let yy = eh - 1; yy >= 0; yy--){ const i = yy * ew + xx; const v = Math.max(env[i], prev * k); env[i] = v; prev = v }
}
const blur = (rad, horiz, buf = env) => {
const env = buf
const out = new Float32Array(env.length), n = horiz ? ew : eh, m = horiz ? eh : ew
for (let a = 0; a < m; a++){
let sum = 0, cnt = 0
const at = b => horiz ? env[a * ew + b] : env[b * ew + a]
for (let b = -rad; b <= rad; b++) if (b >= 0 && b < n){ sum += at(b); cnt++ }
for (let b = 0; b < n; b++){
out[horiz ? a * ew + b : b * ew + a] = sum / cnt
const add = b + rad + 1, rem = b - rad
if (add < n){ sum += at(add); cnt++ }
if (rem >= 0){ sum -= at(rem); cnt-- }
}
}
env.set(out)
}
const rb = Math.max(1, Math.round(h * sc / 2 * 0.045))
blur(rb, true); blur(Math.max(1, rb >> 1), false)
const hr = Math.max(2, Math.round(h * sc / 2 * 0.07))
for (let pass = 0; pass < 2; pass++){ blur(hr, true, halo); blur(hr, false, halo) }
const ec = document.createElement('canvas'); ec.width = ew; ec.height = eh
const ex = ec.getContext('2d'), eimg = ex.createImageData(ew, eh)
for (let i = 0; i < env.length; i++){ const v = Math.min(255, env[i] * 255); const j = i * 4
eimg.data[j] = eimg.data[j + 2] = v; eimg.data[j + 1] = Math.min(255, halo[i] * 2.2 * 255); eimg.data[j + 3] = 255 }
ex.putImageData(eimg, 0, 0)
/* spark sources: the top edges of the letters */
this.edges = []
const st = Math.max(2, Math.round(3 * sc))
for (let py = st * 2; py < ch; py += st) for (let px = 0; px < cw; px += st){
if (img[(py * cw + px) * 4] > 160 && img[((py - st * 2) * cw + px) * 4] < 40) this.edges.push(px / cw, py / ch)
}
for (const key of ['uMask', 'uEnv']) this.uniforms[key].value?.dispose()
const mk = cnv => { const tx = new THREE.CanvasTexture(cnv); tx.colorSpace = THREE.NoColorSpace
tx.minFilter = tx.magFilter = THREE.LinearFilter; tx.generateMipmaps = false; return tx }
this.uniforms.uMask.value = mk(c)
this.uniforms.uEnv.value = mk(ec)
this.uniforms.uAspect.value = W / H
this.uniforms.uSpanB.value = padB / H
this.uniforms.uSpanH.value = h / H
}
/* stoke: the flames surge and a spray of sparks is thrown out */
burst(){ this.flareT = performance.now() / 1000; this.burstN = Math.round(this.N * 0.55) }
update(t, dt){
const r = this.el.getBoundingClientRect()
const p = this.pad
if (!p) return
const x0 = r.left - p.x, y0 = r.top - p.t
this.visible = r.bottom + p.b > 0 && y0 < innerHeight && this.ignite + this.reveal + this.fill > 0.004
this.mesh.visible = this.sparks.visible = this.visible
if (!this.visible) return
this.mesh.position.set(x0 + p.W / 2, innerHeight - (y0 + p.H / 2), 0)
this.mesh.scale.set(p.W, p.H, 1)
/* pointer: closer is hotter, sideways flicks lean the flames */
const cx = r.left + r.width / 2, cy = r.top + r.height / 2
const d = Math.hypot((ptr.x - cx) / (r.width * 0.7), (ptr.y - cy) / (r.height * 1.3))
this.heat = lerp(this.heat, Math.max(ptr.active ? clamp(1.25 - d) : 0, this.boost || 0), 0.035)
const u = this.uniforms
u.uTime.value = t; u.uIgnite.value = this.ignite; u.uReveal.value = this.reveal; u.uFill.value = this.fill
const ig = this.ignite, flare = ig > 0 && ig < 1 ? Math.pow(4 * ig * (1 - ig), 1.5) * 0.55 : 0
const stoke = Math.exp(-Math.max(0, performance.now() / 1000 - this.flareT) * 1.5) * 1.15
u.uHeat.value = Math.min(1.9, this.heat + flare + stoke); u.uWind.value = ptr.wind
/* the block turns toward the viewer: depth and light follow the cursor
(a slow sway on touch screens, where there is no cursor) */
let tx = Math.sin(t * 0.4) * 0.45, ty = Math.sin(t * 0.29 + 1) * 0.3
if (ptr.active){ tx = clamp((ptr.x - cx) / (innerWidth * 0.45), -1, 1); ty = clamp((ptr.y - cy) / (innerHeight * 0.45), -1, 1) }
if (RM){ tx *= 0.3; ty *= 0.3 }
this.tilt.x = lerp(this.tilt.x, tx, 0.06); this.tilt.y = lerp(this.tilt.y, ty, 0.06)
u.uExt.value.set(-0.0021 - this.tilt.x * 0.0026, 0.0034 + this.tilt.y * 0.0026)
u.uLight.value.set(-0.45 + this.tilt.x * 0.5, 0.6 - this.tilt.y * 0.5, 0.66)
this.burnS = lerp(this.burnS, this.burn, 0.12); u.uBurn.value = this.burnS
this.sparks.material.uniforms.uT.value = t
/* sparks */
const s = this.sp, pos = this.sparks.geometry.attributes.position.array
const life = this.sparks.geometry.attributes.aLife.array
const scale = r.height / 300
this.spawnAcc += dt * (RM ? 3 : 9) * Math.min(1, this.ignite * 1.6) * (1 + this.heat * 1.4 + (this.ignite > 0.02 && this.ignite < 0.95 ? 1.2 : 0) + this.burnS * 5)
for (let i = 0; i < this.N; i++){
if (s.life[i] <= 0 && this.burstN > 0 && this.edges.length){
this.burstN--
const e = (Math.random() * (this.edges.length / 2) | 0) * 2
s.x[i] = x0 + this.edges[e] * p.W; s.y[i] = y0 + this.edges[e + 1] * p.H
s.vx[i] = (Math.random() - 0.5) * 300 * scale
s.vy[i] = -(140 + Math.random() * 260) * scale
s.max[i] = s.life[i] = 1.2 + Math.random() * 1.6
}
if (s.life[i] <= 0 && this.spawnAcc >= 1 && this.edges.length){
this.spawnAcc -= 1
const e = (Math.random() * (this.edges.length / 2) | 0) * 2
s.x[i] = x0 + this.edges[e] * p.W; s.y[i] = y0 + this.edges[e + 1] * p.H
s.vx[i] = (Math.random() - 0.5) * 24 * scale
s.vy[i] = -(38 + Math.random() * 70) * scale * (1 + this.heat * 0.5)
s.max[i] = s.life[i] = 1.4 + Math.random() * 2.2
}
if (s.life[i] > 0){
s.life[i] -= dt
const ph = s.seed[i] + t * 0.9
s.vx[i] += (Math.sin(ph * 1.7) * 46 + Math.sin(ph * 4.3 + s.y[i] * 0.02) * 30 - ptr.wind * 110) * scale * dt
s.vx[i] *= Math.pow(0.6, dt)
s.vy[i] -= (14 + Math.sin(ph * 2.1) * 10) * scale * dt
s.x[i] += s.vx[i] * dt; s.y[i] += s.vy[i] * dt
}
pos[i * 3] = s.x[i] + (r.left - p.x - x0)
pos[i * 3 + 1] = innerHeight - s.y[i]
life[i] = Math.max(0, s.life[i] / (s.max[i] || 1))
}
this.spawnAcc = Math.min(this.spawnAcc, 3)
this.sparks.geometry.attributes.position.needsUpdate = true
this.sparks.geometry.attributes.aLife.needsUpdate = true
}
}
const fires = $$('[data-fire]').map(el => new FireText(el, { sparks: el.dataset.fire === 'hero' ? MM.hero_sparks : MM.cta_sparks }))
const fireOf = key => fires.find(f => f.el.dataset.fire === key)
/* ══════════════════════════════════════════════════════════════════
3D INGREDIENTS — glossy chilies, star anise and peppercorns that
float around the hero, dodge the cursor and scroll away with it
══════════════════════════════════════════════════════════════════ */
const scene3 = new THREE.Scene()
{
const pmrem = new THREE.PMREMGenerator(renderer)
scene3.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture
scene3.environmentIntensity = 0.8
}
const FOV = 30, CAMZ = 16
const cam3 = new THREE.PerspectiveCamera(FOV, innerWidth / innerHeight, 0.1, 100)
cam3.position.set(0, 0, CAMZ)
const key = new THREE.DirectionalLight(0xfff0dc, 2.4); key.position.set(-5, 7, 9)
const rim = new THREE.DirectionalLight(0xffb48c, 1.4); rim.position.set(7, -3, -5)
scene3.add(key, rim, new THREE.HemisphereLight(0xfff5e8, 0xd9b99a, 0.5))
const TAU = Math.PI * 2
function rng(seed){ return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0
let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t
return ((t ^ t >>> 14) >>> 0) / 4294967296 } }
const MAT = {
chili: (c, dry) => new THREE.MeshPhysicalMaterial({ color: c, vertexColors: true,
roughness: dry ? 0.62 : 0.26, clearcoat: dry ? 0.15 : 1, clearcoatRoughness: dry ? 0.5 : 0.12,
sheen: dry ? 0.4 : 0, sheenColor: new THREE.Color(0x5a1008) }),
stem: new THREE.MeshStandardMaterial({ color: 0x5E6B34, roughness: 0.78 }),
stemDry: new THREE.MeshStandardMaterial({ color: 0x6B5A2E, roughness: 0.85 }),
anise: new THREE.MeshStandardMaterial({ color: 0x8A3E22, roughness: 0.74, vertexColors: true }),
seed: new THREE.MeshPhysicalMaterial({ color: 0xB9743C, roughness: 0.3, clearcoat: 0.8, clearcoatRoughness: 0.2 }),
pepper: new THREE.MeshStandardMaterial({ color: 0x4A2618, roughness: 0.78, vertexColors: true }),
pepper2: new THREE.MeshStandardMaterial({ color: 0x2E1A12, roughness: 0.8, vertexColors: true }),
card: new THREE.MeshStandardMaterial({ color: 0x9DB062, roughness: 0.72, vertexColors: true }),
cinn: new THREE.MeshStandardMaterial({ color: 0x9A5328, roughness: 0.86, vertexColors: true, side: THREE.DoubleSide }),
}
/* A real pod: tapered and slightly flattened, three shallow lobes, fine
lengthwise creases, a few dents, and a skin that goes a shade lighter at
the shoulder and darker toward the tip. `dry` makes a wrinkled, matte
dried chili instead of a glossy fresh one. */
function makeChili(seed, color, { len = 2.6, rad = 0.3, dry = false } = {}){
const R = rng(seed)
const bend = 0.3 + R() * 0.55, curl = (R() - 0.5) * 0.6
const pts = []
for (let i = 0; i <= 10; i++){ const t = i / 10
pts.push(new THREE.Vector3(t * len, -Math.pow(t, 2) * bend + Math.sin(t * Math.PI) * 0.16, Math.sin(t * Math.PI) * curl)) }
const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal')
const TS = 90, RS = 30
const geo = new THREE.TubeGeometry(curve, TS, 1, RS, false)
const pos = geo.attributes.position, c = new THREE.Vector3(), v = new THREE.Vector3()
const col = new Float32Array(pos.count * 3)
const dents = Array.from({ length: 4 }, () => [0.2 + R() * 0.65, R() * TAU, 0.04 + R() * 0.05])
const ph1 = R() * TAU, ph2 = R() * TAU
for (let i = 0; i <= TS; i++){
const t = i / TS; curve.getPointAt(t, c)
const shoulder = Math.sin(Math.min(t / 0.16, 1) * Math.PI / 2)
const r = Math.max(0.004, rad * (0.64 + 0.36 * shoulder) * Math.pow(1 - t, 0.75))
for (let j = 0; j <= RS; j++){
const kk = i * (RS + 1) + j, ang = j / RS * TAU
let k = 1 + 0.06 * Math.cos(ang * 3 + t * 1.5) // lobes, twisting a little
k *= 1 + 0.012 * Math.sin(ang * 11 + ph1) * (0.4 + t) // fine creases
k *= 1 - 0.1 * Math.max(0, Math.cos(ang)) * 0.35 // a touch flatter on one side
for (const [dt, da, dd] of dents){
const e = Math.exp(-((t - dt) ** 2) / 0.004 - ((Math.atan2(Math.sin(ang - da), Math.cos(ang - da))) ** 2) / 0.25)
k -= dd * e
}
if (dry) k *= 1 + 0.07 * Math.sin(t * 40 + ang * 2 + ph2) * Math.sin(ang * 7 + t * 9)
v.fromBufferAttribute(pos, kk).sub(c).multiplyScalar(r * k).add(c)
pos.setXYZ(kk, v.x, v.y, v.z)
const shade = (1.12 - 0.3 * t) * (0.94 + 0.06 * Math.sin(ang * 3 + t * 5 + ph2))
col[kk * 3] = shade; col[kk * 3 + 1] = shade * (1 + (dry ? 0 : 0.06 * (1 - t))); col[kk * 3 + 2] = shade * 0.96
}
}
geo.setAttribute('color', new THREE.BufferAttribute(col, 3))
geo.computeVertexNormals()
const g = new THREE.Group()
g.add(new THREE.Mesh(geo, MAT.chili(color, dry)))
/* calyx: a lobed cup over the shoulder, then the stem */
const c0 = curve.getPointAt(0), t0 = curve.getTangentAt(0).negate()
const r0 = rad * 0.64
const prof = [[0, 0.02], [r0 * 0.6, 0], [r0 * 1.05, 0.04], [r0 * 1.1, 0.1], [r0 * 0.8, 0.16], [0.09, 0.2], [0.07, 0.24]]
.map(([a, b]) => new THREE.Vector2(a, b))
const cup = new THREE.LatheGeometry(prof, 40)
const cp = cup.attributes.position
for (let i = 0; i < cp.count; i++){
v.fromBufferAttribute(cp, i); const ang = Math.atan2(v.z, v.x)
const k2 = 1 + 0.16 * Math.max(0, Math.cos(ang * 5)) * (v.y / 0.24)
cp.setXYZ(i, v.x * k2, v.y, v.z * k2)
}
cup.computeVertexNormals()
const stemMat = dry ? MAT.stemDry : MAT.stem
const cupM = new THREE.Mesh(cup, stemMat)
cupM.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), t0)
cupM.position.copy(c0).addScaledVector(t0, -0.1)
g.add(cupM)
const up = new THREE.Vector3(0, 1, 0)
const stemCurve = new THREE.CatmullRomCurve3([
c0.clone().addScaledVector(t0, 0.08),
c0.clone().addScaledVector(t0, 0.32).addScaledVector(up, 0.04),
c0.clone().addScaledVector(t0, 0.5).addScaledVector(up, 0.2),
])
g.add(new THREE.Mesh(new THREE.TubeGeometry(stemCurve, 16, 0.05, 10, false), stemMat))
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.052, 12, 8), stemMat)
tip.position.copy(stemCurve.getPointAt(1)); g.add(tip)
const box = new THREE.Box3().setFromObject(g), ctr = box.getCenter(new THREE.Vector3())
g.children.forEach(ch => ch.position.sub(ctr))
return g
}
/* one star-anise pod: a woody boat, pointed at the tip, split open along
the top, finely wrinkled, darker toward the tip */
const podGeo = (() => {
const g = new THREE.SphereGeometry(1, 28, 18), p = g.attributes.position, v = new THREE.Vector3()
const col = new Float32Array(p.count * 3)
for (let i = 0; i < p.count; i++){
v.fromBufferAttribute(p, i)
const t = (v.x + 1) / 2 // 0 = hub end, 1 = tip
const prof = Math.pow(Math.sin(Math.PI * Math.min(1, t * 0.92 + 0.08)), 0.75) * (1 - 0.35 * t)
const groove = Math.exp(-(v.y * v.y) / 0.04) * Math.max(0, v.z) * Math.sin(Math.PI * t)
const wr = 1 + 0.06 * Math.sin(v.x * 26 + v.z * 9) * Math.cos(v.y * 14)
p.setXYZ(i, 0.05 + t * 0.56, v.y * 0.17 * prof * wr, (v.z * 0.15 * prof - groove * 0.13) * wr)
const sh = (0.7 + 0.3 * (1 - t)) * (1 - groove * 0.45) * (0.92 + 0.08 * Math.sin(v.x * 31 + v.y * 7))
col[i * 3] = sh; col[i * 3 + 1] = sh * 0.9; col[i * 3 + 2] = sh * 0.86
}
g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.computeVertexNormals(); return g
})()
const seedGeo = new THREE.SphereGeometry(0.075, 14, 10)
function makeAnise(seed = 3){
const R = rng(seed), g = new THREE.Group(), P = 8
for (let i = 0; i < P; i++){
const pod = new THREE.Group(), sc = 0.88 + R() * 0.22
const m = new THREE.Mesh(podGeo, MAT.anise); m.scale.set(sc, 1, 1); pod.add(m)
if (R() > 0.15){ const sd = new THREE.Mesh(seedGeo, MAT.seed)
sd.position.set(0.3 * sc, 0, 0.05); sd.scale.set(1.3, 0.62, 0.55); pod.add(sd) }
pod.rotation.z = i / P * TAU + (R() - 0.5) * 0.14
pod.rotation.y = (R() - 0.5) * 0.3 - 0.1 // pods droop a little, unevenly
g.add(pod)
}
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.1, 14, 10), MAT.anise); hub.scale.set(1, 1, 0.75); g.add(hub)
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.024, 0.034, 0.24, 8), MAT.stemDry)
stalk.rotation.x = Math.PI / 2; stalk.position.z = -0.15; g.add(stalk)
return g
}
/* peppercorn: a ball covered in a net of wrinkles, darker in the creases */
const pepperGeo = (() => {
const g = new THREE.IcosahedronGeometry(0.15, 5), p = g.attributes.position, v = new THREE.Vector3(), d = new THREE.Vector3()
const col = new Float32Array(p.count * 3)
for (let i = 0; i < p.count; i++){
v.fromBufferAttribute(p, i); d.copy(v).normalize()
const n1 = Math.sin(d.x * 19 + 1.3) * Math.sin(d.y * 17 + 0.4) * Math.sin(d.z * 21 + 2.1)
const n2 = Math.sin(d.x * 37 + 2) * Math.sin(d.y * 41) * Math.sin(d.z * 33 + 1)
const ridge = 1 - Math.abs(n1), rr = ridge * ridge
v.multiplyScalar(0.96 + 0.08 * rr + n2 * 0.022); p.setXYZ(i, v.x, v.y, v.z)
const sh = 0.5 + 0.5 * rr
col[i * 3] = sh; col[i * 3 + 1] = sh * 0.92; col[i * 3 + 2] = sh * 0.88
}
g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.computeVertexNormals(); return g
})()
/* green cardamom: a three-sided pod with fine lengthwise ribs, pointed at
both ends, a stub of stalk at one */
const cardGeo = (() => {
const g = new THREE.SphereGeometry(1, 30, 20), p = g.attributes.position, v = new THREE.Vector3()
const col = new Float32Array(p.count * 3)
for (let i = 0; i < p.count; i++){
v.fromBufferAttribute(p, i)
const ang = Math.atan2(v.z, v.y), taper = Math.pow(Math.max(0, 1 - v.x * v.x), 0.55)
const lobe = 1 + 0.13 * Math.cos(3 * ang), rib = 1 + 0.035 * Math.cos(ang * 15)
const k = lobe * rib * taper / Math.max(1e-3, Math.hypot(v.y, v.z))
p.setXYZ(i, v.x * 0.34, v.y * k * 0.135, v.z * k * 0.135)
const sh = (0.78 + 0.22 * (0.5 + 0.5 * Math.cos(ang * 15))) * (0.85 + 0.15 * Math.cos(3 * ang))
col[i * 3] = sh; col[i * 3 + 1] = sh; col[i * 3 + 2] = sh * 0.9
}
g.setAttribute('color', new THREE.BufferAttribute(col, 3)); g.computeVertexNormals(); return g
})()
function makeCardamom(){
const g = new THREE.Group(); g.add(new THREE.Mesh(cardGeo, MAT.card))
const st = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.02, 0.07, 6), MAT.stemDry)
st.rotation.z = Math.PI / 2; st.position.x = -0.37; g.add(st)
return g
}
/* cinnamon quill: bark rolled into two loose scrolls, ridged along its length */
function makeCinnamon(seed = 5){
const R = rng(seed), g = new THREE.Group()
const roll = (r, start, len) => {
const geo = new THREE.CylinderGeometry(r, r, 1.5, 48, 10, true, start, len)
const p = geo.attributes.position, v = new THREE.Vector3(), col = new Float32Array(p.count * 3)
for (let i = 0; i < p.count; i++){
v.fromBufferAttribute(p, i)
const ang = Math.atan2(v.z, v.x), n = Math.sin(ang * 34 + v.y * 3) * 0.004 + Math.sin(v.y * 9 + ang * 5) * 0.003
const k = 1 + n / r; p.setXYZ(i, v.x * k, v.y, v.z * k)
const sh = 0.82 + 0.18 * Math.sin(ang * 34 + v.y * 3) + (R() - 0.5) * 0.06
col[i * 3] = sh; col[i * 3 + 1] = sh * 0.93; col[i * 3 + 2] = sh * 0.88
}
geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); geo.computeVertexNormals()
return new THREE.Mesh(geo, MAT.cinn)
}
g.add(roll(0.16, 0.4, TAU - 1.1), roll(0.105, 2.2, TAU - 1.4))
return g
}
/* The ingredients orbit the word on a wide, slow ellipse (in viewport
fractions, so it holds at any size). They arrive spiralling in from
outside, drift past on a ~2 minute lap, and fly outward as the hero
scrolls away. Depth rides the orbit, so they swell on the near side. */
const ITEMS = [
{ kind: 'chili', a: 0.3, rx: 1.0, ry: 0.6, z: 1.0, s: 0.6, c: 0xB81B13, rot: [0.4, 0.3, -0.5], m: true },
{ kind: 'green', a: 2.5, rx: 1.06, ry: 0.62, z: -0.4, s: 0.55, c: 0x3F7A1F, rot: [-0.3, 2.4, 0.8], m: true },
{ kind: 'dry', a: 4.3, rx: 0.98, ry: 0.56, z: 1.6, s: 0.47, c: 0x7E120C, rot: [1.1, 0.6, 2.1] },
{ kind: 'chili', a: 5.6, rx: 1.08, ry: 0.6, z: 0.2, s: 0.4, c: 0xC9261A, rot: [0.2, 1.4, -1.2] },
{ kind: 'chili', a: 1.95, rx: 0.94, ry: 0.5, z: 0.8, s: 0.44, c: 0xA8170F, rot: [-0.8, 0.9, 1.6], m: true },
{ kind: 'green', a: 4.95, rx: 1.12, ry: 0.58, z: -0.2, s: 0.4, c: 0x4A8A26, rot: [0.6, -0.4, 2.6] },
{ kind: 'chili', a: 3.35, rx: 1.04, ry: 0.46, z: 1.9, s: 0.34, c: 0xD02A1C, rot: [1.4, 0.2, -2.2] },
{ kind: 'anise', a: 1.4, rx: 1.08, ry: 0.62, z: 0.4, s: 0.62, rot: [0.9, 0.3, 0.2] },
{ kind: 'anise', a: 3.7, rx: 1.02, ry: 0.58, z: 1.4, s: 0.5, rot: [-0.6, 0.8, 1.2], m: true },
{ kind: 'anise', a: 5.95, rx: 0.96, ry: 0.54, z: -0.5, s: 0.45, rot: [0.3, -0.9, 0.7] },
{ kind: 'card', a: 0.62, rx: 1.1, ry: 0.56, z: 1.2, s: 0.95, rot: [0.5, 0.2, 0.9] },
{ kind: 'card', a: 4.62, rx: 0.95, ry: 0.5, z: 0.6, s: 0.8, rot: [-0.4, 1.1, -0.6], m: true },
{ kind: 'cinn', a: 2.85, rx: 1.12, ry: 0.6, z: 0.0, s: 0.52, rot: [0.3, 0.2, 1.1] },
{ kind: 'pepper', a: 0.95, rx: 0.96, ry: 0.56, z: 1.6, s: 0.92, m: true },
{ kind: 'pepper', a: 3.2, rx: 1.1, ry: 0.62, z: 0.2, s: 1.05 },
{ kind: 'pepper', a: 5.1, rx: 1.02, ry: 0.6, z: 2.2, s: 0.8, m: true },
{ kind: 'pepper', a: 3.95, rx: 1.0, ry: 0.52, z: -0.6, s: 0.98 },
{ kind: 'pepper', a: 2.05, rx: 1.04, ry: 0.55, z: 1.2, s: 0.74 },
{ kind: 'pepper', a: 1.12, rx: 1.12, ry: 0.5, z: 0.1, s: 0.86 },
{ kind: 'pepper', a: 5.35, rx: 0.93, ry: 0.47, z: 1.0, s: 0.7 },
]
const kitchen = { items: [], start: -1, shockAt: null }
ITEMS.forEach((it, i) => {
const obj = it.kind === 'chili' ? makeChili(11 + i * 7, it.c)
: it.kind === 'green' ? makeChili(11 + i * 7, it.c, { len: 3.1, rad: 0.24 })
: it.kind === 'dry' ? makeChili(11 + i * 7, it.c, { len: 2.3, rad: 0.26, dry: true })
: it.kind === 'anise' ? makeAnise(7 + i * 3)
: it.kind === 'card' ? makeCardamom()
: it.kind === 'cinn' ? makeCinnamon(11 + i)
: new THREE.Mesh(pepperGeo, i % 2 ? MAT.pepper : MAT.pepper2)
const R = rng(99 + i)
scene3.add(obj)
kitchen.items.push({ ...it, obj, rot: it.rot || [R() * 3, R() * 3, R() * 3],
spin: [(R() - 0.5) * 0.22, (R() - 0.5) * 0.3, (R() - 0.5) * 0.18],
bob: 0.35 + R() * 0.4, ph: R() * TAU, delay: 0.1 * i + R() * 0.2,
push: new THREE.Vector2(), pushV: new THREE.Vector2() })
})
const easeOut = x => 1 - Math.pow(1 - x, 3)
const navEl = ROOT.querySelector('.nav'), navH = () => navEl.offsetHeight || 72
const tmpV = new THREE.Vector3()
kitchen.update = (t, dt, heroR) => {
const aspect = innerWidth / innerHeight
const small = innerWidth < 760
const sp = clamp(-heroR.top / Math.max(1, heroR.height))
for (const it of kitchen.items){
const a = kitchen.start < 0 ? 0 : clamp((t - kitchen.start - it.delay) / 2.2)
const e = easeOut(a)
it.obj.visible = a > 0.001 && !(small && !it.m)
if (!it.obj.visible) continue
const ang = it.a + t * 0.05 + sp * 1.6 + (1 - e) * 1.4
const spread = 1 + sp * 0.55 + (1 - e) * 0.9
const rx = (small ? 1.02 : it.rx) * spread, ry = (small ? 0.5 : it.ry) * spread
const z = it.z + Math.sin(ang) * -1.2
const halfH = Math.tan(FOV * Math.PI / 360) * (CAMZ - z), halfW = halfH * aspect
const wpp = 2 * halfH / innerHeight
/* dodge the cursor, spring back */
tmpV.copy(it.obj.position).project(cam3)
const sx = (tmpV.x + 1) / 2 * innerWidth, sy = (1 - tmpV.y) / 2 * innerHeight
const dx = sx - ptr.x, dy = sy - ptr.y, dist = Math.hypot(dx, dy), R = 170
let fx = 0, fy = 0
if (ptr.active && dist < R && dist > 0.01){ const f = (1 - dist / R) * 2.2; fx = dx / dist * f; fy = -dy / dist * f }
const sh = kitchen.shockAt
if (sh && !sh.done.has(it)){ // a stoke blasts the spices outward
sh.done.add(it)
const qx = sx - sh.x, qy = sy - sh.y, qd = Math.hypot(qx, qy)
if (Number.isFinite(qd) && qd > 1){ const fall = Math.max(0, 1 - qd / 1100)
it.pushV.x += qx / qd * 2.2 * fall; it.pushV.y += -qy / qd * 2.2 * fall }
}
it.pushV.x += (fx - it.push.x) * 9 * dt; it.pushV.y += (fy - it.push.y) * 9 * dt
it.pushV.multiplyScalar(Math.pow(0.02, dt))
it.push.x += it.pushV.x * dt * 6; it.push.y += it.pushV.y * dt * 6
if (!Number.isFinite(it.push.x + it.push.y + it.pushV.x + it.pushV.y)){ it.push.set(0, 0); it.pushV.set(0, 0) } // never let a bad frame poison it
const bob = Math.sin(t * it.bob + it.ph) * 0.1
const parX = (ptr.x / innerWidth - 0.5) * 0.3 * (z + 4) / 4
const parY = (ptr.y / innerHeight - 0.5) * 0.22 * (z + 4) / 4
const scrolled = -heroR.top * wpp * (1 + z * 0.05)
it.obj.position.set(
Math.cos(ang) * rx * halfW + parX + it.push.x * wpp * 60,
Math.min((-0.1 + Math.sin(ang) * ry) * halfH + bob - parY + it.push.y * wpp * 60,
halfH - (navH() + 60) * wpp) + scrolled,
z)
it.obj.rotation.set(it.rot[0] + t * it.spin[0] + sp * 2.2, it.rot[1] + t * it.spin[1] + sp * 1.4, it.rot[2] + t * it.spin[2])
it.obj.scale.setScalar(Math.max(0.001, it.s * (small ? 0.8 : 1) * (0.2 + 0.8 * e)))
}
}
/* ══════════════════════════════════════════════════════════════════
FRAME LOOP
══════════════════════════════════════════════════════════════════ */
const hero = $('.hero')
const clock = { last: performance.now() / 1000 }
function renderFrame(t, dt){
ptr.wind = lerp(ptr.wind, clamp(ptr.vx * 0.05, -1, 1), 0.08); ptr.vx *= 0.85
const heroR = hero.getBoundingClientRect()
renderer.clear()
if (heroR.bottom > 0){ kitchen.update(t, dt, heroR); renderer.render(scene3, cam3) }
let anyFire = false
for (const f of fires){ f.update(t * (RM ? 0.4 : 1), dt); anyFire = anyFire || f.visible }
if (anyFire){ renderer.clearDepth(); renderer.render(sceneF, camF) }
}
function loop(now){
const t = now / 1000, dt = Math.min(0.05, t - clock.last)
clock.last = t
try { renderFrame(t, dt) } catch (e) { if (!loop.warned){ loop.warned = true; console.error('EMBER frame error', e) } }
requestAnimationFrame(loop)
}
function resize(){
renderer.setSize(innerWidth, innerHeight, false)
cam3.aspect = innerWidth / innerHeight; cam3.updateProjectionMatrix()
camF.right = innerWidth; camF.top = innerHeight; camF.updateProjectionMatrix()
fires.forEach(f => f.build())
}
let rsz
addEventListener('resize', () => { clearTimeout(rsz); rsz = setTimeout(resize, 120) })
/* ══════════════════════════════════════════════════════════════════
SMOOTH SCROLL
══════════════════════════════════════════════════════════════════ */
let lenis = null
if (!RM && window.Lenis){
lenis = new Lenis({ lerp: MM.scroll_smoothing })
lenis.stop()
const tick = time => { lenis.raf(time); requestAnimationFrame(tick) }
requestAnimationFrame(tick)
}
$$('a[href^="#"]').forEach(a => a.addEventListener('click', e => {
const id = a.getAttribute('href'), target = id === '#top' ? 0 : $(id)
if (target === null) return
e.preventDefault()
if (lenis) lenis.scrollTo(target, { duration: 1.6, offset: target === 0 ? 0 : -$('.nav').offsetHeight })
else if (target === 0) scrollTo({ top: 0, behavior: 'smooth' })
else target.scrollIntoView({ behavior: 'smooth' })
}))
/* ══════════════════════════════════════════════════════════════════
LOADER — a gas ring that lights flame by flame
══════════════════════════════════════════════════════════════════ */
const flameG = $('.burner__flames') || document.createElementNS('http://www.w3.org/2000/svg', 'g') // WP: a restart runs after the loader was removed
const FLAMES = 30
flameG.innerHTML = Array.from({ length: FLAMES }, (_, i) =>
`<g transform="rotate(${i / FLAMES * 360}) translate(0 -74)"><path class="fl" d="M0 0 C-7 -7 -6 -19 0 -30 C6 -19 7 -7 0 0 Z"/></g>`).join('')
const flameEls = $$('.fl', flameG)
const ldN = $('.ld-n') || document.createElement('span'), ldStep = $('.ld-step') || document.createElement('span')
const STEPS = ['Lighting the charcoal', 'Sharpening the knives', 'Reducing the sauce', 'Firing the first ticket']
let stepShown = 0
function setLoad(v){
if (!ldN.isConnected) return
ldN.textContent = Math.round(v * 100)
const lit = Math.floor(v * FLAMES + 1e-4)
flameEls.forEach((f, i) => f.classList.toggle('on', i < lit))
const s = Math.min(STEPS.length - 1, Math.floor(v * STEPS.length))
if (s !== stepShown){
stepShown = s
animate(ldStep, { y: ['0%', '-110%'] }, { duration: 0.22, ease: EASE_IO }).then(() => {
if (!ldStep.isConnected) return
ldStep.textContent = STEPS[s]
animate(ldStep, { y: ['110%', '0%'] }, { duration: 0.35, ease: EASE })
})
}
}
/* hide what the intro will bring in */
const introEls = ['.nav', '.live', '.hero__foot > *']
introEls.forEach(s => $$(s).forEach(el => { el.style.opacity = 0 }))
$$('.hero__line .wi').forEach(el => { el.style.transform = 'translateY(110%)' })
const heroFire = fireOf('hero'), ctaFire = fireOf('cta')
heroFire.reveal = 0
let heroCtl = null, heroLive = false
const lightHero = (delay = 0) => { heroCtl?.stop()
heroCtl = animate(heroFire.ignite, 1, { duration: MM.hero_ignite_duration * (1 - heroFire.ignite * 0.8), ease: [0.45, 0, 0.35, 1], delay, onUpdate: v => { heroFire.ignite = v } }) }
async function exitLoader(){
if (!$('.loader')){ document.body.classList.remove('is-loading'); lenis?.start(); heroIntro(); return } // WP: restarted without a loader
flameEls.forEach(f => f.classList.add('flare'))
await animate('.loader__mid', { scale: [1, 0.92], opacity: [1, 0] }, { duration: 0.5, ease: EASE_IO })
const loader = $('.loader'), curtain = $('.curtain')
animate(loader, { y: ['0%', '-100%'], borderBottomLeftRadius: ['50% 0%', '50% 18%'], borderBottomRightRadius: ['50% 0%', '50% 18%'] },
{ duration: MM.loader_exit_duration, ease: EASE_IO })
setTimeout(heroIntro, 520)
await animate(curtain, { y: ['0%', '-100%'], borderBottomLeftRadius: ['50% 0%', '50% 18%'], borderBottomRightRadius: ['50% 0%', '50% 18%'] },
{ duration: MM.loader_exit_duration, ease: EASE_IO, delay: 0.14 })
loader.remove(); curtain.remove()
document.body.classList.remove('is-loading')
lenis?.start()
}
function heroIntro(){
kitchen.start = performance.now() / 1000 + 0.9
animate('.nav', { opacity: [0, 1], y: [-24, 0] }, { duration: 1.1, ease: EASE, delay: 0.15 })
animate(0, 1, { duration: MM.hero_reveal_duration, ease: EASE, delay: 0.3, onUpdate: v => { heroFire.reveal = v } })
animate('.live', { opacity: [0, 1], y: [-12, 0] }, { duration: 1, ease: EASE, delay: 0.6 })
animate('.hero__line .wi', { y: ['110%', '0%'] }, { duration: MM.hero_line_duration, ease: EASE, delay: stagger(0.05, { startDelay: 0.8 }) })
animate('.hero__foot > *', { opacity: [0, 1], y: [18, 0] }, { duration: 1.1, ease: EASE, delay: stagger(0.12, { startDelay: 1.1 }) })
/* the word sits red, then fire climbs it from the bottom and engulfs it */
heroFire.ignite = 0; lightHero(MM.hero_ignite_delay); heroLive = true
setTimeout(startTicker, 2600)
}
async function boot(){
requestAnimationFrame(loop)
const fontsReady = Promise.all([
document.fonts.load('800 200px "Bricolage Grotesque"'),
document.fonts.load('italic 60px "Instrument Serif"'),
document.fonts.load('500 12px "DM Mono"'),
]).catch(() => {}).then(() => document.fonts.ready)
let ready = false
fontsReady.then(() => { fires.forEach(f => f.build()); renderer.compile(scene3, cam3); renderer.compile(sceneF, camF); ready = true })
const prog = { v: 0 }
await animate(0, 0.86, { duration: RM ? 0.6 : MM.loader_fill_duration, ease: [0.4, 0, 0.2, 1], onUpdate: v => { prog.v = v; setLoad(v) } })
while (!ready) await new Promise(r => setTimeout(r, 60))
await animate(prog.v, 1, { duration: 0.45, ease: EASE, onUpdate: setLoad })
await new Promise(r => setTimeout(r, 180))
exitLoader()
}
/* ══════════════════════════════════════════════════════════════════
SCROLL MOTION — every reveal replays when you come back to it
══════════════════════════════════════════════════════════════════ */
const IN_EDITOR = !!window.__emberEditor
const pollers = []
const pollInView = (el, cb, { amount = 0.25 } = {}) => {
const st = { el, cb, amount, on: false, leave: null }
pollers.push(st)
if (pollers.length === 1) {
const tick = () => {
const vw = innerWidth, vh = innerHeight
for (const p of pollers) {
if (!p.el || !p.el.isConnected) continue
const r = p.el.getBoundingClientRect(), area = Math.max(1, r.width * r.height)
const w = Math.max(0, Math.min(r.right, vw) - Math.max(r.left, 0)), h = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0))
const ratio = (w * h) / area, inside = p.amount > 0 ? ratio >= p.amount : w * h > 0
if (inside && !p.on) { p.on = true; LOG('enter', p.el.className); p.leave = p.cb() }
else if (!inside && p.on) { p.on = false; if (typeof p.leave === 'function') p.leave() }
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
}
const onEnter = (el, enter, leave, opts = { amount: 0.25 }) =>
(IN_EDITOR ? pollInView : inView)(el, () => { enter(); return () => leave() }, opts)
/* nav: always pinned, a touch more solid once the page moves */
{
const nav = $('.nav')
addEventListener('scroll', () => { nav.classList.toggle('is-solid', scrollY > 30) }, { passive: true })
}
/* headings rise word by word */
$$('[data-split]').forEach(el => {
if (el.closest('.hero')) return
const words = $$('.wi', el)
words.forEach(w => { w.style.transform = 'translateY(110%)' })
onEnter(el,
() => animate(words, { y: ['110%', '0%'] }, { duration: MM.heading_word_duration, ease: EASE, delay: stagger(MM.heading_word_stagger) }),
() => animate(words, { y: '110%' }, { duration: 0.01 }))
})
/* simple fade-ups */
$$('[data-reveal], .dish, .review, .stat').forEach(el => {
if (el.closest('.rrow')) return
el.style.opacity = 0
onEnter(el,
() => animate(el, { opacity: [0, 1], y: [MM.reveal_distance, 0] }, { duration: MM.reveal_duration, ease: EASE }),
() => animate(el, { opacity: 0, y: MM.reveal_distance }, { duration: 0.01 }), { amount: 0.15 })
})
/* manifesto: words light up as you read down */
{
const p = $('.manifesto p'), words = $$('.w', p)
scroll(v => {
const n = v * words.length * 1.1
words.forEach((w, i) => { w.style.opacity = 0.13 + 0.87 * clamp(n - i) })
}, { target: p, offset: ['start 0.85', 'end 0.5'] })
}
/* counters */
$$('[data-count]').forEach(el => {
const to = +el.dataset.count, dec = +(el.dataset.dec || 0), pad = +(el.dataset.pad || 0)
const fmt = v => { const s = v.toFixed(dec); return pad ? s.padStart(pad, '0') : s }
el.textContent = fmt(0)
let ctl
onEnter(el, () => { ctl = animate(0, to, { duration: MM.counter_duration, ease: EASE, onUpdate: v => { el.textContent = fmt(v) } }) },
() => { ctl?.stop(); el.textContent = fmt(0) }, { amount: 0.6 })
})
/* marquee: scroll speed pushes it, scrolling up reverses it */
{
const track = $('.band__track'); let lastY = scrollY, rate = 1
const tickBand = () => {
const a = track.getAnimations()[0]
const dy = scrollY - lastY; lastY = scrollY
const target = (dy < -0.5 ? -1 : 1) * (1 + Math.min(Math.abs(dy) * 0.12, 4))
rate = lerp(rate, target, 0.1)
if (a && !RM) a.playbackRate = rate
requestAnimationFrame(tickBand)
}
requestAnimationFrame(tickBand)
}
/* menu: a plate follows the cursor down the list */
{
const pv = $('.preview'), disc = $('.preview__disc'), art = $('.preview__art')
const st = { x: 0, y: 0, tx: 0, ty: 0, on: false, key: '' }
const canHover = matchMedia('(hover: hover)').matches
$$('.dish').forEach(d => d.addEventListener('pointerenter', e => {
if (!canHover) return
if (st.key !== d.dataset.art){
st.key = d.dataset.art; art.innerHTML = dishHTML(st.key, 640)
animate(art, { rotate: [-18, 0], scale: [0.7, 1] }, { type: 'spring', stiffness: 260, damping: 16 })
}
if (!st.on){ st.on = true; st.x = st.tx = e.clientX; st.y = st.ty = e.clientY
animate(disc, { scale: [0, 1], opacity: [0, 1] }, { type: 'spring', stiffness: 300, damping: 22 }) }
}))
$('.menu__list').addEventListener('pointerleave', () => {
st.on = false; animate(disc, { scale: 0, opacity: 0 }, { duration: 0.35, ease: EASE })
})
addEventListener('pointermove', e => { st.tx = e.clientX; st.ty = e.clientY }, { passive: true })
const follow = () => {
const px = st.x, py = st.y
st.x = lerp(st.x, st.tx, 0.16); st.y = lerp(st.y, st.ty, 0.16)
pv.style.transform = `translate3d(${st.x}px,${st.y}px,0) rotate(${clamp((st.x - px) * 0.6, -14, 14)}deg)`
requestAnimationFrame(follow)
}
requestAnimationFrame(follow)
}
const cloth = { kick: null }
/* draw a word at the browser's position, squeezed to the browser's width if
the canvas renders the font a touch wider (it can't use every font feature) */
const fitText = (x, word, X, Y, w) => {
const cw = x.measureText(word).width
if (cw > w * 1.01 && w > 0){ x.save(); x.translate(X, Y); x.scale(w / cw, 1); x.fillText(word, 0, 0); x.restore() }
else x.fillText(word, X, Y)
}
/* how it works: the ticket prints, gets stamped, goes out the door */
{
const how = $('.how'), ticket = $('.ticket'), lines = $$('.ticket > .tl'), stamp = $('.stamp')
const steps = $$('.step'), bar = $('.eta__track i'), bike = $('.eta__bike'), mins = $('.eta-min')
let stamped = false, active = 0
scroll(p => {
ticket.style.setProperty('--hide', ((1 - map(p, 0.02, 0.5)) * 100).toFixed(2) + '%')
lines.forEach((l, i) => {
const at = 0.05 + i * (0.44 / lines.length), o = map(p, at, at + 0.04)
l.style.opacity = o; l.style.transform = `translateY(${((1 - o) * 8).toFixed(2)}px)`
})
const s = p > 0.58
if (s !== stamped){
stamped = s
if (s){
animate(stamp, { opacity: [0, 1], scale: [2.8, 1], rotate: [-32, -12] }, { type: 'spring', stiffness: 520, damping: 26 })
cloth.kick ? setTimeout(cloth.kick, 80) : animate('.ticket', { rotate: [0, -1.6, 1, 0], x: [0, -5, 3, 0] }, { duration: 0.42, delay: 0.08 })
} else animate(stamp, { opacity: 0, scale: 1.5 }, { duration: 0.2 })
}
const eta = map(p, 0.66, 0.95)
bar.style.width = (eta * 100) + '%'
bike.style.left = (eta * 100) + '%'
mins.textContent = eta >= 1 ? 'At your door' : Math.max(1, Math.round(38 - eta * 37)) + ' min'
const idx = p < 0.34 ? 0 : p < 0.66 ? 1 : 2
if (idx !== active){ active = idx; steps.forEach((st, i) => st.classList.toggle('on', i === idx)) }
}, { target: how, offset: ['start start', 'end end'] })
}
/* ══════════════════════════════════════════════════════════════════
CLOTH RECEIPT — three.js. The paper is a Verlet cloth pinned under the
printer slot, drifting in slow water-like currents; the cursor presses
ripples into it. The printed receipt (lines, stamp, ETA bar) is redrawn
onto its texture whenever the DOM ticket changes. The printer is DOM
and never moves.
══════════════════════════════════════════════════════════════════ */
{
const wrap = $('.ticketwrap'), holder = $('.ticketfloat'), paperEl = $('.paper'), ticket = $('.ticket')
const stampEl = $('.stamp'), sh = $('.ticketshadow')
const cv = document.createElement('canvas'); cv.className = 'cloth'; cv.setAttribute('aria-hidden', 'true')
let r2 = null
try { r2 = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true }) } catch (e) { r2 = null }
if (r2){
holder.appendChild(cv); wrap.classList.add('cloth-on')
r2.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); r2.outputColorSpace = THREE.SRGBColorSpace
r2.setClearColor(0x000000, 0)
const scene = new THREE.Scene()
scene.add(new THREE.HemisphereLight(0xffffff, 0x8a5a4a, 0.9))
const sun = new THREE.DirectionalLight(0xfffaf2, 1.25); sun.position.set(-0.45, 0.75, 1); scene.add(sun)
const cam = new THREE.PerspectiveCamera(30, 1, 1, 10000)
const tc = document.createElement('canvas'), tx = tc.getContext('2d')
const tex = new THREE.CanvasTexture(tc); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 4
/* half the paper's brightness is its own (emissive), half is light — so it
stays receipt-white facing you and only the folds pick up shading */
const mat = new THREE.MeshStandardMaterial({ map: tex, emissive: 0xffffff, emissiveMap: tex, emissiveIntensity: 0.55,
side: THREE.DoubleSide, roughness: 0.9, metalness: 0, alphaTest: 0.5 })
const NX = 16, NY = 22, S = 2, PADX = 120, PADB = 160, TOP = 4
let mesh = null, geo = null, P, Q, R, W = 0, H = 0, CW = 0, CH = 0, cons = [], sig = '', ready = false, kickT = -9
const layout = () => {
const w = ticket.offsetWidth, h = ticket.offsetHeight
if (!w || !h) return false
const L = paperEl.offsetLeft + ticket.offsetLeft, T = paperEl.offsetTop + ticket.offsetTop
W = w; H = h; CW = W + PADX * 2; CH = H + TOP + PADB
Object.assign(cv.style, { left: (L - PADX) + 'px', top: (T - TOP) + 'px', width: CW + 'px', height: CH + 'px' })
r2.setSize(CW, CH, false)
cam.aspect = CW / CH; cam.position.set(0, 0, (CH / 2) / Math.tan(15 * Math.PI / 180)); cam.updateProjectionMatrix()
if (mesh){ scene.remove(mesh); geo.dispose() }
geo = new THREE.PlaneGeometry(W, H, NX - 1, NY - 1)
mesh = new THREE.Mesh(geo, mat); scene.add(mesh)
const n = NX * NY; P = new Float32Array(n * 3); Q = new Float32Array(n * 3); R = new Float32Array(n * 3)
const dx = W / (NX - 1), dy = H / (NY - 1), x0 = -CW / 2 + PADX, y0 = CH / 2 - TOP
for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++){ const k = (j * NX + i) * 3
R[k] = x0 + i * dx; R[k + 1] = y0 - j * dy; R[k + 2] = 0 }
P.set(R); Q.set(R)
cons = []
const add = (a, b) => cons.push(a, b, Math.hypot(R[a * 3] - R[b * 3], R[a * 3 + 1] - R[b * 3 + 1]))
for (let j = 0; j < NY; j++) for (let i = 0; i < NX; i++){ const k = j * NX + i
if (i < NX - 1) add(k, k + 1)
if (j < NY - 1) add(k, k + NX)
if (i < NX - 1 && j < NY - 1){ add(k, k + NX + 1); add(k + 1, k + NX) }
if (i < NX - 2) add(k, k + 2) // bend: paper resists folding
if (j < NY - 2) add(k, k + 2 * NX)
}
tc.width = Math.round(W * S); tc.height = Math.round(H * S); sig = ''
return true
}
/* redraw the printed receipt onto the cloth, from the live DOM ticket */
const bikePath = new Path2D('M6 16h8l4-8h5l1 8M13 8h6M9 4h7v4')
const opacityOf = el => { let o = 1; for (let e = el; e && e !== ticket; e = e.parentElement) o *= +getComputedStyle(e).opacity; return o }
const drawTex = () => {
const tr = ticket.getBoundingClientRect(), z = tr.width / W || 1
const hv = parseFloat(ticket.style.getPropertyValue('--hide')), hide = isNaN(hv) ? 100 : hv
const cut = H * (1 - hide / 100)
const x = tx
x.setTransform(S, 0, 0, S, 0, 0); x.clearRect(0, 0, W, H); x.globalAlpha = 1
if (cut <= 0.5){ tex.needsUpdate = true; return }
x.save(); x.beginPath(); x.rect(0, 0, W, cut); x.clip()
/* thermal paper with the torn zigzag foot */
const zz = 9, zw = 18
x.beginPath(); x.moveTo(0, 0); x.lineTo(W, 0); x.lineTo(W, H - zz)
for (let px = W; px > 0; px -= zw){ x.lineTo(px - zw / 2, H); x.lineTo(Math.max(0, px - zw), H - zz) }
x.closePath(); x.fillStyle = '#FCFAF5'; x.fill()
x.save(); x.clip()
let g = x.createLinearGradient(0, 0, W, 0)
g.addColorStop(0, 'rgba(60,40,20,.09)'); g.addColorStop(0.07, 'rgba(60,40,20,0)'); g.addColorStop(0.93, 'rgba(60,40,20,0)'); g.addColorStop(1, 'rgba(60,40,20,.1)')
x.fillStyle = g; x.fillRect(0, 0, W, H)
x.fillStyle = 'rgba(0,0,0,.014)'; for (let yy = 0; yy < H; yy += 3) x.fillRect(0, yy, W, 1)
x.restore()
/* every word, where the browser laid it out */
const tw = document.createTreeWalker(ticket, NodeFilter.SHOW_TEXT), rg = document.createRange()
x.textBaseline = 'middle'
for (let t = tw.nextNode(); t; t = tw.nextNode()){
const el = t.parentElement
if (!t.textContent.trim() || el.closest('.stamp')) continue
const op = opacityOf(el); if (op < 0.01) continue
const cs = getComputedStyle(el)
x.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
x.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing
x.fillStyle = cs.color; x.globalAlpha = op
for (const mt of t.textContent.matchAll(/\S+/g)){
rg.setStart(t, mt.index); rg.setEnd(t, mt.index + mt[0].length)
const r = rg.getBoundingClientRect()
fitText(x, cs.textTransform === 'uppercase' ? mt[0].toUpperCase() : mt[0], (r.left - tr.left) / z, (r.top - tr.top + r.height / 2) / z, r.width / z)
}
}
x.globalAlpha = 1; x.letterSpacing = '0px'
/* rules, ETA track, bar and bike */
const rel = el => { const r = el.getBoundingClientRect(); return { l: (r.left - tr.left) / z, t: (r.top - tr.top) / z, w: r.width / z, h: r.height / z } }
x.setLineDash([4, 3]); x.lineWidth = 1.5; x.strokeStyle = 'rgba(36,8,5,.35)'
$$('.tl--rule', ticket).forEach(el => { const o = opacityOf(el); if (o < 0.01) return; const r = rel(el)
x.globalAlpha = o; x.beginPath(); x.moveTo(r.l, r.t + 0.75); x.lineTo(r.l + r.w, r.t + 0.75); x.stroke() })
x.setLineDash([])
const trk = $('.eta__track', ticket), eo = opacityOf(trk)
if (eo > 0.01){ x.globalAlpha = eo
const r = rel(trk); x.fillStyle = 'rgba(36,8,5,.14)'; x.fillRect(r.l, r.t, r.w, r.h)
const b = rel($('i', trk)); x.fillStyle = '#C8231A'; x.fillRect(b.l, b.t, b.w, b.h)
const k = rel($('.eta__bike', trk)); x.save(); x.translate(k.l, k.t); x.scale(k.w / 30, k.h / 22)
x.strokeStyle = '#C8231A'; x.lineWidth = 2.2; x.lineCap = x.lineJoin = 'round'
x.beginPath(); x.arc(6, 16, 4.5, 0, Math.PI * 2); x.stroke(); x.beginPath(); x.arc(24, 16, 4.5, 0, Math.PI * 2); x.stroke()
x.stroke(bikePath); x.restore() }
/* the FIRED stamp, with its live spring transform */
const scs = getComputedStyle(stampEl), so = +scs.opacity * opacityOf(stampEl.parentElement)
if (so > 0.01){
const m = scs.transform === 'none' ? new DOMMatrix() : new DOMMatrix(scs.transform)
const w = stampEl.offsetWidth, h = stampEl.offsetHeight
x.save(); x.globalAlpha = so; x.globalCompositeOperation = 'multiply'
x.translate(stampEl.offsetLeft + w / 2, stampEl.offsetTop + h / 2); x.transform(m.a, m.b, m.c, m.d, m.e, m.f)
x.strokeStyle = scs.color; x.lineWidth = 3.5; x.beginPath(); x.roundRect(-w / 2 + 1.75, -h / 2 + 1.75, w - 3.5, h - 3.5, 9); x.stroke()
x.fillStyle = scs.color; x.textAlign = 'center'
x.font = `${scs.fontWeight} ${scs.fontSize} ${scs.fontFamily}`; x.letterSpacing = scs.letterSpacing === 'normal' ? '0px' : scs.letterSpacing
const fs = parseFloat(scs.fontSize), small = $('small', stampEl)
x.fillText('FIRED', 0, -h / 2 + parseFloat(scs.paddingTop) + fs * 0.5)
const ss = getComputedStyle(small)
x.font = `${ss.fontWeight} ${ss.fontSize} ${ss.fontFamily}`; x.letterSpacing = ss.letterSpacing
x.fillText(small.textContent, 0, -h / 2 + small.offsetTop + small.offsetHeight / 2)
x.restore()
}
x.restore()
tex.needsUpdate = true
}
/* the water: slow currents, heavy drag, soft buoyant gravity */
const ptrL = { x: 0, y: 0, on: false }
addEventListener('pointermove', e => {
const r = cv.getBoundingClientRect(); if (!r.width) return
const z = r.width / CW
ptrL.x = (e.clientX - r.left) / z - CW / 2; ptrL.y = CH / 2 - (e.clientY - r.top) / z
ptrL.on = ptrL.x > -CW / 2 && ptrL.x < CW / 2 && ptrL.y > -CH / 2 && ptrL.y < CH / 2
}, { passive: true })
cloth.kick = () => { kickT = performance.now() / 1000 }
cloth.state = () => ({ on, ready, bottomZ: P ? +P[((NY - 1) * NX + 8) * 3 + 2].toFixed(1) : null, bottomDX: P ? +(P[((NY - 1) * NX + 8) * 3] - R[((NY - 1) * NX + 8) * 3]).toFixed(1) : null })
const step = (t, h) => {
const k = RM ? 0.35 : 1, damp = 0.962, G = 180, h2 = h * h
const kick = Math.max(0, 1 - (t - kickT) / 0.6)
for (let j = 1; j < NY; j++){ const f = j / (NY - 1)
for (let i = 0; i < NX; i++){ const q = (j * NX + i) * 3
const px = P[q], py = P[q + 1], pz = P[q + 2]
let ax = (Math.sin(t * 0.62 + j * 0.3) * 272 + Math.sin(t * 1.55 + i * 0.5 + j * 0.2) * 96) * f * k
let ay = -G + Math.sin(t * 0.85 + i * 0.3) * 18 * f * k
let az = (Math.sin(t * 1.05 + i * 0.42 + j * 0.28) * 285 + Math.sin(t * 0.47 - j * 0.22 + i * 0.1) * 210
+ Math.sin(t * 2.2 - j * 0.55 + i * 0.15) * 90) * f * k // + a ripple travelling down the sheet
az += kick * Math.sin(i * 0.9 + j * 0.6) * 900 * f
if (ptrL.on){ const d = Math.hypot(px - ptrL.x, py - ptrL.y)
if (d < 110){ const s2 = (1 - d / 110); az -= s2 * s2 * 1400 } }
P[q] = px + (px - Q[q]) * damp + ax * h2
P[q + 1] = py + (py - Q[q + 1]) * damp + ay * h2
P[q + 2] = pz + (pz - Q[q + 2]) * damp + az * h2
Q[q] = px; Q[q + 1] = py; Q[q + 2] = pz
}
}
for (let it = 0; it < 12; it++){
for (let c = 0; c < cons.length; c += 3){
const a = cons[c] * 3, b = cons[c + 1] * 3, L = cons[c + 2]
const dx = P[b] - P[a], dy = P[b + 1] - P[a + 1], dz = P[b + 2] - P[a + 2]
const d = Math.hypot(dx, dy, dz) || 1e-6, diff = (d - L) / d
const pa = cons[c] < NX, pb = cons[c + 1] < NX
if (pa && pb) continue
const wa = pa ? 0 : pb ? 1 : 0.5, wb = pb ? 0 : pa ? 1 : 0.5
P[a] += dx * diff * wa; P[a + 1] += dy * diff * wa; P[a + 2] += dz * diff * wa
P[b] -= dx * diff * wb; P[b + 1] -= dy * diff * wb; P[b + 2] -= dz * diff * wb
}
for (let i = 0; i < NX * 3; i++) P[i] = R[i] // pinned in the slot
}
}
let on = false, raf = 0, last = 0
const frame = now => {
const t = now / 1000, dt = Math.min(1 / 30, t - (last || t)); last = t
if (!ready) ready = layout()
if (ready){
const sg = ticket.style.cssText + ticket.innerHTML
if (sg !== sig){ sig = sg; drawTex() }
step(t, dt / 2); step(t + dt / 2, dt / 2)
geo.attributes.position.array.set(P); geo.attributes.position.needsUpdate = true; geo.computeVertexNormals()
r2.render(scene, cam)
let mx = 0; for (let i = 0; i < NX; i++) mx += P[((NY - 1) * NX + i) * 3] - R[((NY - 1) * NX + i) * 3]
sh.style.transform = `translateX(${(mx / NX * 0.8).toFixed(1)}px)`
}
if (on) raf = requestAnimationFrame(frame)
}
addEventListener('resize', () => { ready = false })
onEnter($('.how'), () => { if (!on){ on = true; last = 0; raf = requestAnimationFrame(frame) } },
() => { on = false; cancelAnimationFrame(raf) }, { amount: 0 })
}
}
/* ══════════════════════════════════════════════════════════════════
REVIEWS IN THE WIND — every ticket is a sheet taped at its top edge,
rippling in a continuous breeze. The wave is computed per vertex on the
GPU (gusts, travelling ripples, the free edge lifting), lit by its own
normals. Each card is drawn once into a texture from the DOM ticket.
══════════════════════════════════════════════════════════════════ */
{
const sec = $('.reviews'), cards = $$('.review', sec)
const cv = document.createElement('canvas'); cv.className = 'windcv'; cv.setAttribute('aria-hidden', 'true')
let r3 = null
try { r3 = new THREE.WebGLRenderer({ canvas: cv, antialias: true, alpha: true }) } catch (e) { r3 = null }
if (r3 && cards.length){
sec.appendChild(cv)
r3.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); r3.setClearColor(0x000000, 0)
const scene = new THREE.Scene(), cam = new THREE.PerspectiveCamera(30, 1, 1, 20000)
const S = 2, TM = 18, SM = 8, BM = 8
let texOf = new Map(), CW = 0, CH = 0
const geo = new THREE.PlaneGeometry(1, 1, 30, 20)
const vert = `
uniform float uTime, uPhase, uAmp, uPin, uTilt; uniform vec2 uSize;
varying vec2 vUv; varying vec3 vN;
vec3 disp(vec2 q){ // q: px from the sheet's centre, y up
float top = uSize.y * 0.5 - uPin; // the taped line stays put
float hang = clamp((top - q.y) / (uSize.y - uPin), 0.0, 1.0);
float h = pow(hang, 1.35);
float gust = 0.62 + 0.38 * sin(uTime * 0.63 + uPhase) * sin(uTime * 0.27 + uPhase * 2.1);
float w1 = sin(q.x * 0.013 - uTime * 2.4 + uPhase + hang * 1.8);
float w2 = sin(q.x * 0.027 + q.y * 0.018 - uTime * 3.6 + uPhase * 1.7);
float w3 = sin(q.y * 0.03 - uTime * 4.4 + q.x * 0.006 + uPhase);
float z = uAmp * h * gust * (w1 * 0.62 + w2 * 0.26 + w3 * 0.12);
float lift = uAmp * 0.45 * h * h * gust * (0.6 + 0.4 * w1); // the free edge lifts in the breeze
float sway = 9.0 * h * sin(uTime * 1.25 + uPhase) * gust;
return vec3(sway, lift, z);
}
void main(){
vUv = uv;
vec2 q = position.xy * uSize;
vec3 p = vec3(q, 0.0) + disp(q);
vec3 px = vec3(q + vec2(2.0, 0.0), 0.0) + disp(q + vec2(2.0, 0.0));
vec3 py = vec3(q + vec2(0.0, 2.0), 0.0) + disp(q + vec2(0.0, 2.0));
vN = normalize(cross(px - p, py - p));
mat2 rot = mat2(cos(uTilt), sin(uTilt), -sin(uTilt), cos(uTilt));
p.xy = rot * p.xy; vN.xy = rot * vN.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`
const frag = `
uniform sampler2D uTex; varying vec2 vUv; varying vec3 vN;
void main(){
vec4 c = texture2D(uTex, vUv); if (c.a < 0.02) discard;
gl_FragColor = c; // unlit: the paper stays white as it waves
}`
const meshes = cards.map((c, i) => {
const mat = new THREE.ShaderMaterial({ vertexShader: vert, fragmentShader: frag, transparent: true, depthTest: false,
side: THREE.DoubleSide, uniforms: { uTex: { value: null }, uTime: { value: 0 }, uPhase: { value: (i * 1.37) % TAU },
uSize: { value: new THREE.Vector2(1, 1) }, uPin: { value: TM }, uAmp: { value: RM ? 10 : 34 }, uTilt: { value: 0 } } })
const m = new THREE.Mesh(geo, mat); m.frustumCulled = false; m.visible = false; scene.add(m); return m
})
/* the ticket, once, onto a canvas: paper, words where the browser put them, avatar, tape */
const draw = card => {
const key = $('q', card).textContent
if (texOf.has(key)) return texOf.get(key)
const cr = card.getBoundingClientRect(), W = card.offsetWidth, H = card.offsetHeight, z = cr.width / W || 1
const TW = W + SM * 2, TH = H + TM + BM
const c = document.createElement('canvas'); c.width = Math.round(TW * S); c.height = Math.round(TH * S)
const x = c.getContext('2d'); x.scale(S, S); x.translate(SM, TM)
x.save(); x.beginPath(); x.roundRect(0, 0, W, H, 4); x.fillStyle = '#FFF9EF'; x.fill(); x.clip()
const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0, 'rgba(255,255,255,.6)'); g.addColorStop(0.3, 'rgba(255,255,255,0)')
x.fillStyle = g; x.fillRect(0, 0, W, H)
x.fillStyle = 'rgba(0,0,0,.014)'; for (let yy = 0; yy < H; yy += 3) x.fillRect(0, yy, W, 1)
x.restore()
const av = $('.review__av', card), ar = av.getBoundingClientRect()
x.fillStyle = getComputedStyle(av).backgroundColor
x.beginPath(); x.arc((ar.left - cr.left + ar.width / 2) / z, (ar.top - cr.top + ar.height / 2) / z, ar.width / 2 / z, 0, TAU); x.fill()
const tw = document.createTreeWalker(card, NodeFilter.SHOW_TEXT), rg = document.createRange()
x.textBaseline = 'middle'
for (let t = tw.nextNode(); t; t = tw.nextNode()){
if (!t.textContent.trim()) continue
const cs = getComputedStyle(t.parentElement)
x.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
x.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing
x.fillStyle = cs.color; x.globalAlpha = +cs.opacity
const up = cs.textTransform === 'uppercase'
for (const mt of t.textContent.matchAll(/\S+/g)){
rg.setStart(t, mt.index); rg.setEnd(t, mt.index + mt[0].length)
const r = rg.getBoundingClientRect()
fitText(x, up ? mt[0].toUpperCase() : mt[0], (r.left - cr.left) / z, (r.top - cr.top + r.height / 2) / z, r.width / z)
}
}
x.globalAlpha = 1; x.letterSpacing = '0px'
/* translucent tape with torn ends, across the top edge */
x.save(); x.translate(W / 2, 1); x.rotate(-3 * Math.PI / 180)
x.shadowColor = 'rgba(36,8,5,.18)'; x.shadowBlur = 3; x.shadowOffsetY = 1
x.beginPath(); x.moveTo(-42, -12)
for (let k = 0; k <= 6; k++) x.lineTo(42 - (k % 2) * 3, -12 + k * 4)
for (let k = 0; k <= 6; k++) x.lineTo(-42 + (k % 2) * 3, 12 - k * 4)
x.closePath(); x.fillStyle = 'rgba(250,244,226,.82)'; x.fill(); x.restore()
const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.NoColorSpace; tex.anisotropy = 4
const v = { tex, TW, TH }; texOf.set(key, v); return v
}
const layout = () => {
CW = sec.clientWidth; CH = sec.clientHeight
r3.setSize(CW, CH, false)
cam.aspect = CW / CH; cam.position.set(0, 0, (CH / 2) / Math.tan(15 * Math.PI / 180)); cam.updateProjectionMatrix()
}
let on = false, raf = 0, ready = false
const frame = now => {
const t = now / 1000, sr = cv.getBoundingClientRect()
cards.forEach((card, i) => {
const m = meshes[i], r = card.getBoundingClientRect()
if (r.right < sr.left - 60 || r.left > sr.right + 60 || r.bottom < sr.top - 60 || r.top > sr.bottom + 60){ m.visible = false; return }
const d = draw(card), u = m.material.uniforms
u.uTex.value = d.tex; u.uSize.value.set(d.TW, d.TH); u.uTime.value = t
u.uTilt.value = -(parseFloat(getComputedStyle(card).getPropertyValue('--tilt')) || 0) * Math.PI / 180
const cx = r.left - sr.left + r.width / 2, cy = r.top - sr.top - TM + d.TH / 2
m.position.set(cx - CW / 2, CH / 2 - cy, 0); m.visible = true
})
r3.render(scene, cam)
if (on) raf = requestAnimationFrame(frame)
}
document.fonts.ready.then(() => {
layout(); ready = true; sec.classList.add('wind-on')
addEventListener('resize', () => { texOf.forEach(v => v.tex.dispose()); texOf = new Map(); layout() })
onEnter(sec, () => { if (!on){ on = true; raf = requestAnimationFrame(frame) } },
() => { on = false; cancelAnimationFrame(raf) }, { amount: 0 })
})
}
}
/* click or tap the burning word to stoke it: the flames surge, sparks fly,
and the shockwave blows the spices outward */
$('.hero__word').addEventListener('pointerdown', e => {
if (heroFire.ignite < 0.5 || heroFire.burnS > 0.3) return
heroFire.burst(); kitchen.shockAt = { x: e.clientX, y: e.clientY, done: new Set() }
})
/* the hero fire does the same once the intro has run */
onEnter($('.hero__word'),
() => { if (heroLive && heroFire.ignite < 0.98) lightHero(0.15) },
() => { if (!heroLive) return; heroCtl?.stop()
heroCtl = animate(heroFire.ignite, 0, { duration: 0.6, onUpdate: v => { heroFire.ignite = v } }) },
{ amount: 0.3 })
/* the second fire lights when "Hungry?" arrives, and goes out when it leaves */
{
let ctl
const st = { v: 0 }
const set = v => { st.v = v; ctaFire.ignite = v }
set(0)
onEnter($('.cta__word'),
() => { ctl?.stop(); ctl = animate(st.v, 1, { duration: MM.cta_ignite_duration, ease: [0.45, 0, 0.35, 1], onUpdate: set }) },
() => { ctl?.stop(); ctl = animate(st.v, 0, { duration: 0.6, onUpdate: set }) },
{ amount: 0.5 })
}
/* buttons: the dark fill blooms from where you enter */
$$('.btn').forEach(b => b.addEventListener('pointerenter', e => {
const r = b.getBoundingClientRect()
b.style.setProperty('--mx', (e.clientX - r.left) + 'px'); b.style.setProperty('--my', (e.clientY - r.top) + 'px')
}))
/* hero: a live ticket at the top cycles through what's on the grill */
const TICKER = ['Firecracker Chicken', 'Blistered Chili Noodles', 'Smoked Brisket Bun', 'Wood-fired Diavola',
'Charred Street Corn', 'Burnt Basque Cheesecake']
let tickerOn = false
function startTicker(){
if (tickerOn) return
tickerOn = true
const slot = $('.live__slot'), item = $('.live__item')
let i = 0
slot.style.width = item.offsetWidth + 'px'
setInterval(async () => {
i = (i + 1) % TICKER.length
await animate(item, { y: ['0%', '-115%'], opacity: [1, 0] }, { duration: 0.4, ease: EASE_IO })
item.textContent = TICKER[i]
animate(slot, { width: item.offsetWidth + 'px' }, { duration: 0.5, ease: EASE })
animate(item, { y: ['115%', '0%'], opacity: [0, 1] }, { duration: 0.55, ease: EASE })
}, 3200)
}
/* hero: as you scroll it away the word sinks slower than the page, the
line fades, and the fire flares one last time */
{
const center = $('.hero__center'), line = $('.hero__line')
scroll(p => {
center.style.transform = `translate3d(0, ${(p * 26).toFixed(2)}vh, 0)`
line.style.opacity = (1 - map(p, 0.05, 0.4)).toFixed(3)
heroFire.boost = map(p, 0.02, 0.5) * 0.9
heroFire.burn = map(p, 0.1, 0.72)
}, { target: hero, offset: ['start start', 'end start'] })
}
/* the order badge spins faster while the page moves */
{
const ring = $('.badge .ring'); let lastY = scrollY, rate = 1
const tickBadge = () => {
const dy = scrollY - lastY; lastY = scrollY
rate = lerp(rate, 1 + Math.min(Math.abs(dy) * 0.35, 9), 0.08)
const an = ring.getAnimations()[0]
if (an && !RM) an.playbackRate = rate
requestAnimationFrame(tickBadge)
}
requestAnimationFrame(tickBadge)
}
/* cursor */
{
const cur = $('.cursor'), c = { x: -40, y: -40, tx: -40, ty: -40 }
addEventListener('pointermove', e => { c.tx = e.clientX; c.ty = e.clientY }, { passive: true })
const run = () => { c.x = lerp(c.x, c.tx, MM.cursor_follow); c.y = lerp(c.y, c.ty, MM.cursor_follow)
cur.style.transform = `translate3d(${c.x}px,${c.y}px,0)`; requestAnimationFrame(run) }
requestAnimationFrame(run)
$$('a, button, .dish, .hero__word').forEach(el => {
el.addEventListener('pointerenter', () => cur.classList.add('is-big'))
el.addEventListener('pointerleave', () => cur.classList.remove('is-big'))
})
}
boot()
if (ROOT !== document) ROOT.__emberBound = { cta: $('.cta__word'), reviews: $('.reviews'), how: $('.how'), menu: $('.menu__list'), stats: $('.stats'), dish: $('.dish'), stat: $('.stat'), gl: $('#gl') }
LOG('ready')
window.__ember = { cloth, renderFrame, fires, heroFire, ctaFire, kitchen, renderer, scene3, cam3, sceneF, camF, exitLoader, setLoad, resize }
```
## B5 · section `html`
```html
<div class="uichemy-ember-1">
<!-- #gl is not in this markup: an editor save strips <canvas>, so ember.js
builds it (and restores any inline SVG a save removed) before the scene runs. -->
<div class="grain" aria-hidden="true"></div>
<div class="cursor" aria-hidden="true"></div>
<!-- ══ loader ══ -->
<div class="curtain" aria-hidden="true"></div>
<div class="loader" role="status" aria-live="polite">
<div class="loader__top">
<span class="logo"><svg viewBox="0 0 20 25" aria-hidden="true"><path d="M10 .8c1.3 4.4 7.6 7.3 7.6 13.8A7.6 7.6 0 0 1 10 22.2a7.6 7.6 0 0 1-7.6-7.6c0-3.4 1.8-5.6 3.5-7.4.4 2.4 1.6 3.7 3.2 4.2C8.6 8 9 4.1 10 .8z" fill="currentColor"/></svg>ember</span>
<span class="label">Cloud kitchen · 07</span>
</div>
<div class="loader__mid">
<div class="burner">
<svg viewBox="-130 -130 260 260" aria-hidden="true"><circle class="ring" r="66"/><g class="burner__flames"></g></svg>
<div class="burner__num"><span><b class="ld-n">0</b><sup>%</sup></span></div>
</div>
<p class="loader__line">Firing up the grill</p>
<div class="loader__step label"><span class="ld-step">Lighting the charcoal</span></div>
</div>
<div class="loader__bot">
<span class="label">Delivery only</span>
<span class="label">Open 11am — 2am</span>
</div>
</div>
<!-- ══ nav ══ -->
<header class="nav">
<a class="logo" href="#top" aria-label="Ember home"><svg viewBox="0 0 20 25" aria-hidden="true"><path d="M10 .8c1.3 4.4 7.6 7.3 7.6 13.8A7.6 7.6 0 0 1 10 22.2a7.6 7.6 0 0 1-7.6-7.6c0-3.4 1.8-5.6 3.5-7.4.4 2.4 1.6 3.7 3.2 4.2C8.6 8 9 4.1 10 .8z" fill="currentColor"/></svg>ember</a>
<nav class="nav__links" aria-label="Primary">
<a href="#menu">Menu</a><a href="#how">How it works</a><a href="#reviews">Reviews</a><a href="#order">Kitchens</a>
</nav>
<a class="btn" href="#order">Order now <span class="arrow"><svg viewBox="0 0 12 12"><path d="M1 6h9M6.5 2.5 10 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span></a>
</header>
<main>
<!-- ══ hero ══ -->
<section class="hero" id="top">
<div class="hero__center">
<h1 class="hero__title">
<span class="hero__word"><span class="fire" data-fire="hero">FIRE</span><i class="bl"></i></span>
<span class="hero__line" data-split>No tables. No waiters. Just <em>fire</em>, at your door.</span>
</h1>
<div class="live label"><i></i><span>Kitchen 04 · now firing</span>
<span class="live__slot"><span class="live__item">Firecracker Chicken</span></span></div>
</div>
<div class="hero__foot">
<div class="hero__meta label"><span><b>38 min</b>Average to door</span><span><b>4.9★</b>12k reviews</span></div>
<div class="cue label">Scroll<span></span></div>
<a class="badge" href="#menu" aria-label="Order now — see the menu">
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true"><defs><path id="badgeRing" d="M60 60 m-47 0 a47 47 0 1 1 94 0 a47 47 0 1 1 -94 0"/></defs>
<text font-family="DM Mono, monospace" font-size="10" font-weight="500" fill="currentColor"><textPath href="#badgeRing" textLength="293" lengthAdjust="spacing">ORDER NOW · OPEN TILL 2AM · </textPath></text></svg>
<span class="badge__core"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 9.5 9.5 2.5M4 2.5h5.5V8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</a>
</div>
</section>
<!-- ══ marquee ══ -->
<div class="band" aria-hidden="true">
<div class="band__track">
<span>Smoked <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> <em>charred</em> <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> Blistered <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> <em>flame-kissed</em> <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg></span>
<span>Smoked <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> <em>charred</em> <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> Blistered <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg> <em>flame-kissed</em> <svg viewBox="0 0 20 20"><path d="M10 0v20M0 10h20M2.9 2.9l14.2 14.2M17.1 2.9 2.9 17.1" stroke="currentColor" stroke-width="3"/></svg></span>
</div>
</div>
<!-- ══ manifesto ══ -->
<section class="sec manifesto">
<div class="eyebrow label">Why a cloud kitchen</div>
<p data-words>We cut the dining room, the chandeliers and the rent — and spent it on the one thing you actually taste. A grill lit at six every morning, <em>cooking only for your door.</em></p>
<div class="manifesto__foot label" data-reveal>
<span>No reservations</span><span>No service charge</span><span>No warming lamps</span><span>No compromise on heat</span>
</div>
</section>
<!-- ══ menu ══ -->
<section class="sec" id="menu" style="padding-top:0">
<div class="menu__head">
<div>
<div class="eyebrow label">On the fire tonight</div>
<h2 class="h2" data-split>The <em>menu</em></h2>
</div>
<p data-reveal>Six things, done over wood and charcoal until they're right. The menu changes when the season does.</p>
</div>
<div class="menu__list">
<article class="dish" data-art="skewers" data-heat="3">
<span class="dish__n">01</span><span class="dish__art"></span>
<div><span class="dish__name">Firecracker Chicken<span class="dish__tag">Bestseller</span></span>
<span class="dish__desc">Skewered thigh, gochugaru glaze, charred over binchotan.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 420</span></div>
</article>
<article class="dish" data-art="bun" data-heat="1">
<span class="dish__n">02</span><span class="dish__art"></span>
<div><span class="dish__name">Smoked Brisket Bun</span>
<span class="dish__desc">Fourteen-hour brisket, burnt-end jam, quick-pickled onion.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 540</span></div>
</article>
<article class="dish" data-art="bowl" data-heat="4">
<span class="dish__n">03</span><span class="dish__art"></span>
<div><span class="dish__name">Blistered Chili Noodles<span class="dish__tag">Very hot</span></span>
<span class="dish__desc">Hand-pulled, full wok hei, and the chili crisp we make on Sundays.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 380</span></div>
</article>
<article class="dish" data-art="corn" data-heat="2">
<span class="dish__n">04</span><span class="dish__art"></span>
<div><span class="dish__name">Charred Street Corn</span>
<span class="dish__desc">Flame-kissed cob, smoked chili butter, lime and salt.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 220</span></div>
</article>
<article class="dish" data-art="pizza" data-heat="3">
<span class="dish__n">05</span><span class="dish__art"></span>
<div><span class="dish__name">Wood-fired Diavola</span>
<span class="dish__desc">48-hour dough, nduja, hot honey, a properly leopard-spotted crust.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 610</span></div>
</article>
<article class="dish" data-art="cake" data-heat="0">
<span class="dish__n">06</span><span class="dish__art"></span>
<div><span class="dish__name">Burnt Basque Cheesecake</span>
<span class="dish__desc">Scorched on purpose. Molten in the middle.</span></div>
<div class="dish__meta"><span class="dish__heat"></span><span class="dish__price">₹ 290</span></div>
</article>
</div>
</section>
<div class="preview" aria-hidden="true"><div class="preview__disc"><div class="preview__art"></div></div></div>
<!-- ══ how it works ══ -->
<section class="how" id="how">
<div class="how__stick">
<div>
<div class="eyebrow label">How it works</div>
<h2 class="h2">Ticket in.<br><em>Fire on.</em></h2>
<div class="steps">
<div class="step on"><span class="step__n">01</span><h3>You order</h3><p>Tap it in. Your ticket hits our line in under a second.</p></div>
<div class="step"><span class="step__n">02</span><h3>We fire it</h3><p>Straight onto a 900° grill. Nothing waits under a heat lamp.</p></div>
<div class="step"><span class="step__n">03</span><h3>It arrives</h3><p>Sealed, vented and at your door in 38 minutes, give or take.</p></div>
</div>
</div>
<div class="ticketwrap">
<div class="ticketshadow" aria-hidden="true"></div>
<div class="ticketfloat">
<div class="printer"><i></i></div>
<div class="paper">
<div class="ticket">
<div class="tl tl--c tl--big">ember.</div>
<div class="tl tl--c">KITCHEN 04 · ORDER #4471</div>
<div class="tl tl--c">17 SEP · 21:47</div>
<div class="tl tl--rule"></div>
<div class="tl"><span>2 × Firecracker Chicken</span><span>840</span></div>
<div class="tl"><span>1 × Chili Noodles</span><span>380</span></div>
<div class="tl"><span>1 × Street Corn</span><span>220</span></div>
<div class="tl"><span>1 × Basque Cheesecake</span><span>290</span></div>
<div class="tl tl--rule"></div>
<div class="tl tl--total"><span>TOTAL</span><span>₹ 1,730</span></div>
<div class="tl tl--note"><span>"extra crisp, extra fire"</span></div>
<div class="tl eta">
<div style="width:100%">
<div class="eta__row"><span>OUT FOR DELIVERY</span><b class="eta-min">38 min</b></div>
<div class="eta__track"><i></i><span class="eta__bike"><svg viewBox="0 0 30 22"><circle cx="6" cy="16" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="24" cy="16" r="4.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M6 16h8l4-8h5l1 8M13 8h6M9 4h7v4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></div>
</div>
</div>
<div class="stamp">FIRED<small>21:49 · 900°</small></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ══ stats ══ -->
<section class="sec stats-sec">
<div class="eyebrow label">The numbers</div>
<div class="stats">
<div class="stat"><div class="stat__n"><span data-count="900">0</span><sup>°</sup></div><div class="stat__l label">Grill, lit at 6am</div></div>
<div class="stat"><div class="stat__n"><span data-count="38">0</span><sup>min</sup></div><div class="stat__l label">Average to your door</div></div>
<div class="stat"><div class="stat__n"><span data-count="7" data-pad="2">00</span></div><div class="stat__l label">Kitchens, zero chairs</div></div>
<div class="stat"><div class="stat__n"><span data-count="1.2" data-dec="1">0</span><sup>M</sup></div><div class="stat__l label">Orders fired since 2019</div></div>
</div>
</section>
<!-- ══ reviews ══ -->
<section class="reviews" id="reviews">
<div class="menu__head">
<div><div class="eyebrow label">Straight from the door</div><h2 class="h2" data-split>Still <em>crackling.</em></h2></div>
<p data-reveal>12,000 reviews and counting. These are a few we pinned to the pass.</p>
</div>
<div class="rrow"></div>
<div class="rrow"></div>
</section>
<!-- ══ cta ══ -->
<section class="cta" id="order">
<div class="eyebrow label" style="justify-content:center">Open till 2am</div>
<h2 class="cta__title">
<span class="cta__word"><span class="fire" data-fire="cta">HUNGRY?</span><i class="bl"></i></span>
<span class="cta__line">The grill's already lit.</span>
</h2>
<a class="btn" href="#top">Order from Ember <span class="arrow"><svg viewBox="0 0 12 12"><path d="M1 6h9M6.5 2.5 10 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span></a>
<div class="cta__meta label">Also on Swiggy & Zomato · 7 kitchens across the city</div>
</section>
</main>
<footer class="foot">
<div class="foot__grid">
<div><p>Cooked over fire. Eaten wherever you are.</p></div>
<div><h4>Order</h4><ul><li><a href="#order">Ember app</a></li><li><a href="#order">Swiggy</a></li><li><a href="#order">Zomato</a></li></ul></div>
<div><h4>Kitchens</h4><ul><li>01 — Old Town</li><li>04 — Riverside</li><li>07 — Tech Park</li></ul></div>
<div><h4>Say hello</h4><ul><li><a href="#top">[email protected]</a></li><li><a href="#top">Instagram</a></li><li><a href="#top">Careers on the line</a></li></ul></div>
</div>
<div class="foot__big" aria-hidden="true">ember<span>.</span></div>
<div class="foot__legal"><span>© 2026 Ember Kitchens</span><span>FSSAI Lic. 10019022000123</span><span>Made with fire</span></div>
</footer>
</div>
```
## B6 · section `css`
```css
.uichemy-ember-1{
--cream:#F3EADB;
--cream-2:#E9DCC6;
--paper:#FFF9EF;
--red:#C8231A;
--deep:#7A1410;
--ink:#3B0C08;
--char:#240805;
--line:rgba(122,20,16,.16);
--line-cream:rgba(243,234,219,.24);
--pad:clamp(20px,4.2vw,72px);
--display:'Bricolage Grotesque',system-ui,-apple-system,sans-serif;
--serif:'Instrument Serif',Georgia,'Times New Roman',serif;
--mono:'DM Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
--ease:cubic-bezier(.22,1,.36,1);
--nav-h:72px;
color:var(--ink);font-family:var(--display);
font-size:16px;line-height:1.55;-webkit-font-smoothing:antialiased;
-webkit-text-size-adjust:100%;
}
/* A WordPress theme styles headings, paragraphs, lists and links on top of the
UA defaults the source was built on. Hand the inherited type properties
back to inheritance; :where() keeps this below every rule in the section. */
.uichemy-ember-1 :where(h1,h2,h3,h4,h5,h6,p,ul,ol,li,a,span,em,i,b,strong,small){line-height:inherit;color:inherit;font-family:inherit;letter-spacing:inherit;text-transform:inherit}
.uichemy-ember-1.uichemy-ember-1 *,
.uichemy-ember-1.uichemy-ember-1 *::before,
.uichemy-ember-1.uichemy-ember-1 *::after{box-sizing:border-box}
.uichemy-ember-1 *::selection{background:var(--red);color:var(--cream)}
.uichemy-ember-1 :where(a){color:inherit;text-decoration:none}
.uichemy-ember-1 :where(button){font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
.uichemy-ember-1 :where(svg){display:block}
.uichemy-ember-1 .label{font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;text-transform:uppercase}
.uichemy-ember-1 .wm{display:inline-block;overflow:hidden;vertical-align:top;
padding:0 .08em .1em 0;margin:0 -.08em -.1em 0}
.uichemy-ember-1 .wi{display:inline-block;will-change:transform}
.uichemy-ember-1 #gl{position:fixed;inset:0;width:100%;height:100%;z-index:30;pointer-events:none;display:block}
.uichemy-ember-1 .grain{position:fixed;inset:-50%;z-index:36;pointer-events:none;opacity:.075;mix-blend-mode:multiply;
background-size:180px 180px;animation:ember-grain 1s steps(6) infinite}
@keyframes ember-ember-grain{0%,100%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}
40%{transform:translate(2%,-3%)}60%{transform:translate(-2%,-1%)}80%{transform:translate(3%,3%)}}
.uichemy-ember-1 .cursor{position:fixed;left:0;top:0;z-index:90;width:10px;height:10px;margin:-5px 0 0 -5px;border-radius:50%;
background:var(--red);pointer-events:none;transition:width .35s var(--ease),height .35s var(--ease),
margin .35s var(--ease),background-color .35s,box-shadow .35s}
.uichemy-ember-1 .cursor.is-big{width:58px;height:58px;margin:-29px 0 0 -29px;background:rgba(200,35,26,.12);
box-shadow:inset 0 0 0 1.5px var(--red)}
@media (hover:none),(pointer:coarse){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .cursor{display:none}}
.uichemy-ember-1 .curtain{position:fixed;inset:0;z-index:98;background:var(--red)}
.uichemy-ember-1 .loader{position:fixed;inset:0;z-index:99;background:var(--cream);display:grid;
grid-template-rows:auto 1fr auto;padding:22px var(--pad);overflow:hidden}
.uichemy-ember-1 .loader__top,
.uichemy-ember-1 .loader__bot{display:flex;justify-content:space-between;align-items:center;gap:20px;color:var(--deep)}
.uichemy-ember-1 .loader__mid{display:grid;place-items:center;align-content:center;gap:24px}
.uichemy-ember-1 .burner{position:relative;width:clamp(220px,30vmin,320px);aspect-ratio:1}
.uichemy-ember-1 .burner svg{width:100%;height:100%;overflow:visible}
.uichemy-ember-1 .burner .ring{fill:none;stroke:var(--line);stroke-width:1.5}
.uichemy-ember-1 .burner .fl{fill:var(--cream-2);transform-box:fill-box;transform-origin:50% 100%;transition:fill .2s}
.uichemy-ember-1 .burner .fl.on{fill:var(--red);animation:ember-flick .42s ease-in-out infinite alternate}
.uichemy-ember-1 .burner .fl.flare{animation:none;transform:scaleY(1.6)}
@keyframes ember-ember-flick{from{transform:scaleY(.8) scaleX(1.06)}to{transform:scaleY(1.14) scaleX(.93)}}
.uichemy-ember-1 .burner__num{position:absolute;inset:0;display:grid;place-items:center;font-weight:800;
font-size:clamp(48px,7vmin,72px);letter-spacing:-.06em;color:var(--red);font-variant-numeric:tabular-nums}
.uichemy-ember-1 .burner__num sup{font-size:.36em;letter-spacing:0;margin-left:.06em}
.uichemy-ember-1 .loader__line{margin:0;font-family:var(--serif);font-style:italic;font-weight:400;
font-size:clamp(30px,3.6vw,48px);line-height:1;color:var(--deep);text-align:center}
.uichemy-ember-1 .loader__step{height:16px;overflow:hidden;color:var(--red);text-align:center}
.uichemy-ember-1 .loader__step span{display:block}
.uichemy-ember-1 .nav{position:fixed;top:0;left:0;right:0;z-index:60;display:flex;align-items:center;justify-content:space-between;
gap:20px;height:var(--nav-h);padding:0 var(--pad);background:rgba(243,234,219,.92);
backdrop-filter:blur(14px) saturate(1.2);-webkit-backdrop-filter:blur(14px) saturate(1.2);
box-shadow:0 1.5px 0 var(--line);transition:background-color .45s var(--ease),box-shadow .45s}
.uichemy-ember-1 .nav.is-solid{background:rgba(243,234,219,.96);backdrop-filter:blur(14px) saturate(1.2);
-webkit-backdrop-filter:blur(14px) saturate(1.2);box-shadow:0 1.5px 0 var(--line)}
.uichemy-ember-1 .logo{display:flex;align-items:center;gap:8px;font-weight:800;font-size:26px;letter-spacing:-.06em;color:var(--red);line-height:1}
.uichemy-ember-1 .logo svg{width:20px;height:25px}
.uichemy-ember-1 .nav__links{display:flex;align-items:center;gap:32px}
.uichemy-ember-1 .nav__links a{position:relative;font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;
color:var(--deep);padding:4px 0}
.uichemy-ember-1 .nav__links a::after{content:'';position:absolute;left:0;right:0;bottom:0;height:1.5px;background:var(--red);
transform:scaleX(0);transform-origin:right;transition:transform .45s var(--ease)}
.uichemy-ember-1 .nav__links a:hover::after{transform:scaleX(1);transform-origin:left}
.uichemy-ember-1 .btn{position:relative;display:inline-flex;align-items:center;gap:12px;padding:14px 16px 14px 22px;border-radius:999px;
background:var(--red);color:var(--cream);font-family:var(--mono);font-size:12px;font-weight:500;letter-spacing:.1em;
text-transform:uppercase;overflow:hidden;isolation:isolate;transition:color .45s var(--ease);white-space:nowrap}
.uichemy-ember-1 .btn::before{content:'';position:absolute;left:var(--mx,50%);top:var(--my,50%);width:0;height:0;border-radius:50%;
background:var(--char);transform:translate(-50%,-50%);transition:width .6s var(--ease),height .6s var(--ease);z-index:-1}
.uichemy-ember-1 .btn:hover::before{width:560px;height:560px}
.uichemy-ember-1 .btn .arrow{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--cream);
color:var(--red);transition:transform .5s var(--ease)}
.uichemy-ember-1 .btn .arrow svg{width:11px;height:11px}
.uichemy-ember-1 .btn:hover .arrow{transform:rotate(-45deg)}
.uichemy-ember-1 .btn--ghost{background:transparent;color:var(--red);box-shadow:inset 0 0 0 1.5px var(--red)}
.uichemy-ember-1 .btn--ghost::before{background:var(--red)}
.uichemy-ember-1 .btn--ghost:hover{color:var(--cream)}
.uichemy-ember-1 .btn--ghost .arrow{background:var(--red);color:var(--cream)}
.uichemy-ember-1 .btn--ghost:hover .arrow{background:var(--cream);color:var(--red)}
.uichemy-ember-1 .btn--cream{background:var(--cream);color:var(--red)}
.uichemy-ember-1 .btn--cream .arrow{background:var(--red);color:var(--cream)}
.uichemy-ember-1 .btn--cream:hover{color:var(--cream)}
.uichemy-ember-1 .hero{position:relative;min-height:100svh;display:grid;grid-template-rows:1fr auto;justify-items:center;
padding:calc(var(--nav-h) + 12px) var(--pad) clamp(20px,3.4vh,34px);text-align:center;
--word:max(110px,min(27.5vw,calc((100svh - 290px) / 1.45),460px))}
@keyframes ember-ember-pulse{0%,100%{box-shadow:0 0 0 0 rgba(200,35,26,.35)}60%{box-shadow:0 0 0 9px rgba(200,35,26,0)}}
.uichemy-ember-1 .live{display:inline-flex;align-items:center;gap:10px;padding:9px 16px 9px 12px;border-radius:999px;
background:rgba(255,249,239,.55);box-shadow:inset 0 0 0 1.5px var(--line);color:var(--deep);white-space:nowrap}
.uichemy-ember-1 .live i{width:8px;height:8px;border-radius:50%;background:var(--red);flex:none;animation:ember-pulse 1.8s ease-in-out infinite}
.uichemy-ember-1 .live__slot{position:relative;display:inline-block;height:1.25em;overflow:hidden;vertical-align:bottom;text-align:left}
.uichemy-ember-1 .live__item{display:block;color:var(--red)}
.uichemy-ember-1 .hero__center{align-self:end;display:flex;flex-direction:column;align-items:center;
margin-bottom:clamp(18px,4vh,54px);will-change:transform}
.uichemy-ember-1 .hero__center .live{margin-top:clamp(16px,2.4vh,26px)}
.uichemy-ember-1 .hero__title{margin:0;font-weight:inherit;display:flex;flex-direction:column;align-items:center}
.uichemy-ember-1 .hero__word{display:block;position:relative;font-weight:800;font-size:var(--word);line-height:.8;
letter-spacing:-.06em;color:var(--red);white-space:nowrap;padding-left:.06em}
.uichemy-ember-1 .fire{display:inline-block;color:transparent}
.uichemy-ember-1 .hero__word{cursor:pointer;touch-action:manipulation}
.uichemy-ember-1 .bl{display:inline-block;width:0;height:0;vertical-align:baseline}
.uichemy-ember-1 .hero__line{display:block;margin-top:clamp(20px,2.8vw,38px);font-family:var(--serif);font-style:italic;font-weight:400;
font-size:clamp(24px,2.5vw,42px);line-height:1.05;letter-spacing:-.01em;color:var(--deep)}
.uichemy-ember-1 .hero__line em{color:var(--red)}
.uichemy-ember-1 .hero__foot{width:100%;display:grid;grid-template-columns:1fr auto 1fr;align-items:end;gap:20px;text-align:left;color:var(--deep)}
.uichemy-ember-1 .hero__meta{display:flex;gap:clamp(20px,3vw,48px)}
.uichemy-ember-1 .hero__meta b{display:block;margin-bottom:6px;font-family:var(--display);font-weight:800;font-size:clamp(26px,2.3vw,38px);
line-height:1;letter-spacing:-.05em;color:var(--red);text-transform:none}
.uichemy-ember-1 .cue{display:flex;flex-direction:column;align-items:center;gap:10px;padding-bottom:4px}
.uichemy-ember-1 .cue span{position:relative;width:1.5px;height:34px;background:var(--line);overflow:hidden}
.uichemy-ember-1 .cue span::after{content:'';position:absolute;left:0;top:-100%;width:100%;height:100%;background:var(--red);
animation:ember-cue 1.8s var(--ease) infinite}
@keyframes ember-ember-cue{to{top:100%}}
.uichemy-ember-1 .badge{justify-self:end;position:relative;display:grid;place-items:center;width:clamp(104px,8.6vw,132px);
aspect-ratio:1;border-radius:50%;color:var(--red)}
.uichemy-ember-1 .badge .ring{position:absolute;inset:0;width:100%;height:100%;animation:ember-spin 18s linear infinite}
.uichemy-ember-1 .badge__core{display:grid;place-items:center;width:46%;aspect-ratio:1;border-radius:50%;background:var(--red);
color:var(--cream);transition:transform .7s var(--ease),background-color .45s}
.uichemy-ember-1 .badge:hover .badge__core{transform:scale(1.16) rotate(45deg);background:var(--char)}
.uichemy-ember-1 .badge__core svg{width:34%;height:34%}
@keyframes ember-ember-spin{to{transform:rotate(360deg)}}
.uichemy-ember-1 .band{position:relative;z-index:1;background:var(--red);color:var(--cream);overflow:hidden;padding:20px 0}
.uichemy-ember-1 .band__track{display:flex;width:max-content;animation:ember-marq 28s linear infinite}
.uichemy-ember-1 .band__track > span{display:flex;align-items:center;gap:32px;padding-right:32px;font-weight:800;
font-size:clamp(34px,5.4vw,80px);letter-spacing:-.045em;line-height:1;text-transform:uppercase;white-space:nowrap}
.uichemy-ember-1 .band__track em{font-family:var(--serif);font-weight:400;font-style:italic;text-transform:none;letter-spacing:-.01em}
.uichemy-ember-1 .band__track svg{width:.56em;height:.56em;flex:none}
@keyframes ember-ember-marq{to{transform:translateX(-50%)}}
.uichemy-ember-1 .sec{position:relative;padding:clamp(96px,13vw,190px) var(--pad)}
.uichemy-ember-1 .eyebrow{display:flex;align-items:center;gap:12px;color:var(--red);margin-bottom:clamp(24px,3vw,40px)}
.uichemy-ember-1 .eyebrow::before{content:'';width:26px;height:1.5px;background:currentColor}
.uichemy-ember-1 .h2{margin:0;font-weight:800;font-size:clamp(48px,7.6vw,126px);line-height:.86;letter-spacing:-.055em;color:var(--red)}
.uichemy-ember-1 .h2 em{font-family:var(--serif);font-weight:400;font-style:italic;letter-spacing:-.02em;color:var(--deep)}
.uichemy-ember-1 .manifesto p{margin:0;max-width:21ch;font-weight:600;font-size:clamp(34px,5.2vw,88px);line-height:1.02;
letter-spacing:-.045em;color:var(--deep)}
.uichemy-ember-1 .manifesto p em{font-family:var(--serif);font-style:italic;font-weight:400;letter-spacing:-.015em;color:var(--red)}
.uichemy-ember-1 .manifesto .w{opacity:.13}
.uichemy-ember-1 .manifesto__foot{display:flex;justify-content:space-between;flex-wrap:wrap;gap:16px 30px;margin-top:clamp(40px,6vw,86px);
padding-top:18px;border-top:1.5px solid var(--line);color:var(--deep)}
.uichemy-ember-1 .menu__head{display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:24px 40px;margin-bottom:clamp(36px,5vw,64px)}
.uichemy-ember-1 .menu__head p{max-width:34ch;margin:0;color:var(--ink)}
.uichemy-ember-1 .menu__list{border-top:1.5px solid var(--deep)}
.uichemy-ember-1 .dish{position:relative;display:grid;grid-template-columns:64px 1fr auto 110px;align-items:center;gap:24px;
padding:clamp(20px,2.3vw,30px) 0;border-bottom:1.5px solid var(--line);color:var(--deep);isolation:isolate;cursor:pointer}
.uichemy-ember-1 .dish::before{content:'';position:absolute;inset:0 calc(var(--pad) * -1);background:var(--red);transform:scaleY(0);
transform-origin:bottom;transition:transform .55s var(--ease);z-index:-1}
.uichemy-ember-1 .dish:hover::before{transform:scaleY(1);transform-origin:top}
.uichemy-ember-1 .dish:hover{color:var(--cream)}
.uichemy-ember-1 .dish__n{font-family:var(--mono);font-size:12px}
.uichemy-ember-1 .dish__name{display:block;font-weight:800;font-size:clamp(30px,4.3vw,68px);line-height:.95;letter-spacing:-.05em;
transition:transform .6s var(--ease)}
.uichemy-ember-1 .dish:hover .dish__name{transform:translateX(16px)}
.uichemy-ember-1 .dish__desc{display:block;margin-top:9px;font-size:15px;line-height:1.45;opacity:.82;max-width:48ch;
transition:transform .6s var(--ease) .03s}
.uichemy-ember-1 .dish:hover .dish__desc{transform:translateX(16px)}
.uichemy-ember-1 .dish__tag{display:inline-block;vertical-align:middle;margin-left:12px;padding:5px 9px 4px;border-radius:999px;
background:var(--red);color:var(--cream);font-family:var(--mono);font-size:10px;letter-spacing:.1em;
text-transform:uppercase;transform:translateY(-.5em)}
.uichemy-ember-1 .dish:hover .dish__tag{background:var(--cream);color:var(--red)}
.uichemy-ember-1 .dish__heat{display:flex;gap:3px}
.uichemy-ember-1 .dish__heat svg{width:14px;height:18px}
.uichemy-ember-1 .dish__heat .off{opacity:.2}
.uichemy-ember-1 .dish__price{font-family:var(--mono);font-size:15px;text-align:right}
.uichemy-ember-1 .dish__art{display:none}
.uichemy-ember-1 .preview{position:fixed;left:0;top:0;z-index:45;pointer-events:none;will-change:transform}
.uichemy-ember-1 .preview__disc{position:absolute;left:0;top:0;width:clamp(250px,21vw,320px);aspect-ratio:1;margin:-50% 0 0 -50%;
border-radius:50%;opacity:0;transform:scale(0);
background:radial-gradient(circle at 34% 28%,rgba(255,255,255,.9) 0 6%,rgba(255,255,255,0) 22%),
radial-gradient(circle at 50% 50%,#F7F1E6 0 60%,#E9DFCD 71%,#F5EEE2 80%,#D8CAB2 97%,#B9A68A 100%);
box-shadow:0 38px 60px -24px rgba(36,8,5,.6),0 10px 18px -8px rgba(36,8,5,.3),inset 0 -5px 12px rgba(90,60,30,.16)}
.uichemy-ember-1 .preview__disc::before{content:'';position:absolute;inset:13%;border-radius:50%;
background:radial-gradient(circle at 50% 42%,#FFFDF8,#F1E9DB 78%,#E6DAC6);
box-shadow:inset 0 5px 12px rgba(90,60,30,.2),inset 0 -2px 4px rgba(255,255,255,.8),0 1.5px 0 rgba(255,255,255,.9)}
.uichemy-ember-1 .preview__art{position:absolute;inset:9%}
.uichemy-ember-1 .preview__art svg{width:100%;height:100%}
.uichemy-ember-1 .ph{position:absolute;inset:5%;border-radius:50%;overflow:hidden;background:#2a1008;
box-shadow:0 3px 8px rgba(60,30,10,.35)}
.uichemy-ember-1 .ph img{width:100%;height:100%;object-fit:cover;display:block;animation:ember-kb 7s ease-in-out infinite alternate}
.uichemy-ember-1 .ph::after{content:'';position:absolute;inset:0;border-radius:50%;
background:radial-gradient(circle at 34% 26%,rgba(255,255,255,.22),transparent 38%);
box-shadow:inset 0 0 0 1px rgba(255,255,255,.25),inset 0 -10px 22px rgba(0,0,0,.28)}
@keyframes ember-ember-kb{from{transform:scale(1.04) rotate(-2deg)}to{transform:scale(1.14) rotate(2deg)}}
.uichemy-ember-1 .ph__steam{position:absolute;inset:-18% 0 auto;height:80%;pointer-events:none}
.uichemy-ember-1 .dish__art{position:relative}
.uichemy-ember-1 .dish__art .ph{inset:4px}
.uichemy-ember-1 .stm path,
.uichemy-ember-1 .a-sizzle circle,
.uichemy-ember-1 .a-ember circle,
.uichemy-ember-1 .a-fall circle,
.uichemy-ember-1 .a-squish,
.uichemy-ember-1 .a-lift,
.uichemy-ember-1 .a-chop,
.uichemy-ember-1 .a-ripple,
.uichemy-ember-1 .a-sway,
.uichemy-ember-1 .a-spin,
.uichemy-ember-1 .a-jiggle{transform-box:fill-box}
.uichemy-ember-1 .stm path{stroke-dasharray:34 90;opacity:0;animation:ember-steam 2.7s linear infinite}
@keyframes ember-ember-steam{0%{stroke-dashoffset:40;opacity:0;transform:translateY(10px)}25%{opacity:.75}
100%{stroke-dashoffset:-90;opacity:0;transform:translateY(-16px)}}
.uichemy-ember-1 .a-sheen{animation:ember-sheen 1.6s ease-in-out infinite alternate}
@keyframes ember-ember-sheen{from{opacity:.2}to{opacity:.85}}
.uichemy-ember-1 .a-sizzle circle{transform-origin:center;animation:ember-pop 1.4s ease-out infinite}
@keyframes ember-ember-pop{0%,55%{transform:scale(0);opacity:0}70%{transform:scale(1.2);opacity:.9}100%{transform:scale(.2);opacity:0}}
.uichemy-ember-1 .a-ember circle{animation:ember-ember 2.4s linear infinite}
@keyframes ember-ember{0%{transform:translate(0,0);opacity:0}15%{opacity:1}100%{transform:translate(6px,-46px);opacity:0}}
.uichemy-ember-1 .a-fall circle{animation:ember-fall 2s linear infinite}
@keyframes ember-fall{0%{transform:translateY(-8px);opacity:0}20%{opacity:.9}100%{transform:translateY(16px);opacity:0}}
.uichemy-ember-1 .a-squish{transform-origin:50% 100%;animation:ember-squish 2.2s var(--ease) infinite}
@keyframes ember-squish{0%,60%,100%{transform:scale(1,1)}72%{transform:scale(1.03,.92)}84%{transform:scale(.99,1.02)}}
.uichemy-ember-1 .a-lift{transform-origin:70% 20%;animation:ember-lift 2.4s ease-in-out infinite}
.uichemy-ember-1 .a-chop{transform-origin:100% 0%;animation:ember-chop 2.4s ease-in-out infinite}
@keyframes ember-lift{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-5px) rotate(-2deg)}}
@keyframes ember-chop{0%,100%{transform:rotate(0)}50%{transform:rotate(-5deg) translate(2px,-4px)}}
.uichemy-ember-1 .a-ripple{transform-origin:center;animation:ember-ripple 2.4s ease-out infinite}
@keyframes ember-ripple{0%{transform:scale(.4);opacity:.7}100%{transform:scale(2);opacity:0}}
.uichemy-ember-1 .a-sway{transform-origin:50% 60%;animation:ember-sway 3s ease-in-out infinite alternate}
@keyframes ember-sway{from{transform:rotate(-5deg)}to{transform:rotate(5deg)}}
.uichemy-ember-1 .a-spin{transform-origin:center;animation:ember-spin 22s linear infinite}
.uichemy-ember-1 .a-drizzle{stroke-dasharray:260;animation:ember-drizzle 3.4s ease-in-out infinite}
@keyframes ember-drizzle{0%{stroke-dashoffset:260}55%,85%{stroke-dashoffset:0}100%{stroke-dashoffset:-260}}
.uichemy-ember-1 .a-jiggle{transform-origin:50% 100%;animation:ember-jiggle 1.8s ease-in-out infinite}
@keyframes ember-jiggle{0%,100%{transform:scale(1,1)}20%{transform:scale(1.015,.975)}40%{transform:scale(.99,1.015)}60%{transform:scale(1.005,.995)}}
.uichemy-ember-1 .a-pulse{transform-origin:center;animation:ember-sheen 1.4s ease-in-out infinite alternate}
.uichemy-ember-1 .how{position:relative;height:330vh;background:var(--red);color:var(--cream)}
.uichemy-ember-1 .how__stick{position:sticky;top:var(--nav-h);height:calc(100svh - var(--nav-h));display:grid;grid-template-columns:1.05fr .95fr;align-items:center;
gap:clamp(30px,5vw,90px);padding:clamp(18px,4vh,48px) var(--pad) clamp(18px,4vh,44px);overflow:hidden}
.uichemy-ember-1 .how .h2{font-size:min(clamp(48px,7.6vw,126px),13svh)}
.uichemy-ember-1 .how .eyebrow{margin-bottom:clamp(12px,2.4vh,40px)}
.uichemy-ember-1 .how .eyebrow{color:var(--cream)}
.uichemy-ember-1 .how .h2{color:var(--cream)}
.uichemy-ember-1 .how .h2 em{color:var(--char)}
.uichemy-ember-1 .steps{margin-top:clamp(14px,3.4vh,48px)}
.uichemy-ember-1 .step{display:grid;grid-template-columns:52px 1fr;gap:4px 10px;padding:clamp(8px,1.8vh,15px) 0;border-top:1.5px solid var(--line-cream);
opacity:.34;transition:opacity .5s var(--ease)}
.uichemy-ember-1 .step.on{opacity:1}
.uichemy-ember-1 .step__n{font-family:var(--mono);font-size:12px;padding-top:7px}
.uichemy-ember-1 .step h3{margin:0;font-weight:700;font-size:clamp(22px,2.2vw,32px);letter-spacing:-.035em;line-height:1.1}
.uichemy-ember-1 .step p{grid-column:2;margin:0;font-size:15px;line-height:1.45;opacity:.86;max-width:40ch}
.uichemy-ember-1 .ticketwrap{position:relative;justify-self:center;width:min(450px,100%);perspective:1300px}
.uichemy-ember-1 .ticketfloat{position:relative;z-index:1}
.uichemy-ember-1 .paper{position:relative;z-index:1}
.uichemy-ember-1 .cloth{position:absolute;z-index:1;display:block;pointer-events:none}
.uichemy-ember-1 .cloth-on .ticket{opacity:0}
.uichemy-ember-1 .ticketshadow{position:absolute;left:12%;right:12%;bottom:-46px;height:34px;border-radius:50%;
background:radial-gradient(closest-side,rgba(36,8,5,.45),rgba(36,8,5,0));filter:blur(6px);will-change:transform,opacity}
.uichemy-ember-1 .printer{position:relative;z-index:2;height:40px;border-radius:14px 14px 10px 10px;
background:linear-gradient(#4A4240,#2A2422 45%,#171312);
box-shadow:inset 0 1.5px 0 rgba(255,255,255,.18),inset 0 -8px 0 rgba(0,0,0,.45),0 10px 18px -8px rgba(0,0,0,.6)}
.uichemy-ember-1 .printer::before{content:'';position:absolute;left:14px;top:9px;width:54px;height:6px;border-radius:3px;
background:repeating-linear-gradient(90deg,#0c0a09 0 2px,#3a3432 2px 4px);opacity:.8}
.uichemy-ember-1 .printer::after{content:'';position:absolute;left:18px;right:18px;bottom:9px;height:5px;border-radius:3px;background:#000;
box-shadow:0 -1px 0 rgba(255,255,255,.08)}
.uichemy-ember-1 .printer i{position:absolute;right:16px;top:9px;width:6px;height:6px;border-radius:50%;background:#FF5A3C;
box-shadow:0 0 8px #FF5A3C;animation:ember-pulse 1.2s ease-in-out infinite}
.uichemy-ember-1 .ticket{position:relative;z-index:1;margin:-12px 24px 0;padding:34px 28px 40px;color:#2A2220;
background:linear-gradient(90deg,rgba(60,40,20,.08),transparent 7%,transparent 93%,rgba(60,40,20,.1)),
linear-gradient(180deg,rgba(60,40,20,.1),transparent 5%,transparent 70%,rgba(255,255,255,.5) 82%,transparent 90%),
repeating-linear-gradient(0deg,rgba(0,0,0,.012) 0 1px,transparent 1px 3px),#FCFAF5;
text-shadow:0 0 .6px rgba(42,34,32,.55);
font-family:var(--mono);font-size:13.5px;line-height:1.55;clip-path:inset(0 0 var(--hide,100%) 0);
-webkit-mask:conic-gradient(from 135deg at top,#000 90deg,#0000 0) bottom/18px 9px repeat-x,
linear-gradient(#000 0 0) top/100% calc(100% - 9px) no-repeat;
mask:conic-gradient(from 135deg at top,#000 90deg,#0000 0) bottom/18px 9px repeat-x,
linear-gradient(#000 0 0) top/100% calc(100% - 9px) no-repeat}
.uichemy-ember-1 .tl{display:flex;justify-content:space-between;gap:12px;opacity:0}
.uichemy-ember-1 .tl--c{justify-content:center;text-align:center}
.uichemy-ember-1 .tl--big{font-family:var(--display);font-weight:800;font-size:28px;letter-spacing:-.04em;line-height:1.1}
.uichemy-ember-1 .tl--rule{border-top:1.5px dashed rgba(36,8,5,.35);margin:10px 0}
.uichemy-ember-1 .tl--total{font-weight:500;font-size:14px}
.uichemy-ember-1 .tl--note{color:var(--red);font-style:italic}
.uichemy-ember-1 .stamp{position:absolute;right:26px;top:37%;padding:7px 14px 6px;border:3.5px solid var(--red);border-radius:9px;
color:var(--red);font-family:var(--display);font-weight:800;font-size:42px;letter-spacing:-.03em;line-height:1;
text-align:center;opacity:0;mix-blend-mode:multiply;transform:rotate(-12deg)}
.uichemy-ember-1 .stamp small{display:block;font-family:var(--mono);font-weight:500;font-size:10px;letter-spacing:.12em;margin-top:3px}
.uichemy-ember-1 .eta{margin-top:16px}
.uichemy-ember-1 .eta__row{display:flex;justify-content:space-between;align-items:baseline}
.uichemy-ember-1 .eta__row b{font-family:var(--display);font-weight:800;font-size:20px;letter-spacing:-.04em;color:var(--red)}
.uichemy-ember-1 .eta__track{position:relative;height:3px;margin-top:12px;background:rgba(36,8,5,.14);border-radius:2px}
.uichemy-ember-1 .eta__track i{position:absolute;left:0;top:0;bottom:0;width:0;background:var(--red);border-radius:2px}
.uichemy-ember-1 .eta__bike{position:absolute;top:-19px;left:0;width:30px;height:22px;margin-left:-15px;color:var(--red)}
.uichemy-ember-1 .stats-sec{background:var(--cream-2)}
.uichemy-ember-1 .stats{display:grid;grid-template-columns:repeat(4,1fr);border-top:1.5px solid var(--deep)}
.uichemy-ember-1 .stat{padding:clamp(24px,3vw,40px) clamp(12px,2vw,26px) 4px 0;border-right:1.5px solid var(--line)}
.uichemy-ember-1 .stat + .stat{padding-left:clamp(12px,2vw,26px)}
.uichemy-ember-1 .stat:last-child{border-right:0}
.uichemy-ember-1 .stat__n{font-weight:800;font-size:clamp(58px,8.4vw,138px);line-height:.84;letter-spacing:-.065em;color:var(--red);
font-variant-numeric:tabular-nums;white-space:nowrap}
.uichemy-ember-1 .stat__n sup{font-size:.34em;letter-spacing:-.02em;vertical-align:1.3em;margin-left:.05em}
.uichemy-ember-1 .stat__l{margin-top:16px;color:var(--deep)}
.uichemy-ember-1 .reviews{position:relative;overflow:hidden;padding:clamp(90px,11vw,160px) 0}
.uichemy-ember-1 .windcv{position:absolute;inset:0;width:100%;height:100%;z-index:2;pointer-events:none;display:block}
.uichemy-ember-1 .wind-on .review{transform:none;background:transparent;box-shadow:none}
.uichemy-ember-1 .wind-on .review > *{visibility:hidden}
.uichemy-ember-1 .wind-on .review::before{display:none}
.uichemy-ember-1 .reviews .menu__head{padding:0 var(--pad)}
.uichemy-ember-1 .rrow{display:flex;width:max-content;gap:18px;padding:6px 0 10px;animation:ember-marq 70s linear infinite}
.uichemy-ember-1 .rrow + .rrow{margin-top:18px;animation-direction:reverse;animation-duration:80s}
.uichemy-ember-1 .reviews:hover .rrow{animation-play-state:paused}
.uichemy-ember-1 .review{position:relative;width:clamp(280px,26vw,380px);padding:30px 24px 24px;border-radius:4px;color:var(--deep);margin:14px 0 0;
background:linear-gradient(180deg,rgba(255,255,255,.6),transparent 30%),
repeating-linear-gradient(0deg,rgba(0,0,0,.014) 0 1px,transparent 1px 3px),var(--paper);
box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 14px 22px -14px rgba(36,8,5,.45),0 2px 4px rgba(36,8,5,.08);
transform:rotate(var(--tilt,0deg))}
.uichemy-ember-1 .review:nth-child(3n+1){--tilt:-1.2deg}
.uichemy-ember-1 .review:nth-child(3n+2){--tilt:.8deg}
.uichemy-ember-1 .review:nth-child(3n){--tilt:-.4deg}
.uichemy-ember-1 .review::before{content:'';position:absolute;left:50%;top:-11px;width:84px;height:24px;margin-left:-42px;transform:rotate(-3deg);
background:linear-gradient(90deg,rgba(0,0,0,.05),transparent 12%,transparent 88%,rgba(0,0,0,.05)),rgba(250,244,226,.72);
box-shadow:0 1px 3px rgba(36,8,5,.15);clip-path:polygon(0 8%,3% 0,6% 10%,9% 0,100% 0,97% 12%,100% 24%,97% 36%,100% 50%,97% 64%,100% 78%,97% 90%,100% 100%,0 100%,3% 88%,0 76%,3% 62%,0 48%,3% 34%,0 20%)}
.uichemy-ember-1 .review q{display:block;font-family:var(--serif);font-size:clamp(23px,1.9vw,29px);line-height:1.1;quotes:none;color:var(--char)}
.uichemy-ember-1 .review__who{display:flex;align-items:center;gap:12px;margin-top:22px}
.uichemy-ember-1 .review__av{display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:var(--red);
color:var(--cream);font-weight:700;font-size:14px;flex:none}
.uichemy-ember-1 .review__who div{flex:1;line-height:1.3}
.uichemy-ember-1 .review__who small{display:block;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;opacity:.7}
.uichemy-ember-1 .stars{color:var(--red);font-size:13px;letter-spacing:2px}
.uichemy-ember-1 .cta{text-align:center;padding:clamp(110px,13vw,190px) var(--pad) clamp(100px,13vw,180px)}
.uichemy-ember-1 .cta__title{margin:0;font-weight:inherit}
.uichemy-ember-1 .cta .eyebrow{margin-bottom:clamp(96px,15vw,250px)}
.uichemy-ember-1 .cta__word{display:block;position:relative;font-weight:800;font-size:clamp(84px,19vw,320px);line-height:.8;
letter-spacing:-.06em;color:var(--red);white-space:nowrap}
.uichemy-ember-1 .cta__line{display:block;font-family:var(--serif);font-style:italic;font-weight:400;font-size:clamp(30px,3.5vw,54px);
line-height:1;color:var(--deep);margin:30px 0 40px}
.uichemy-ember-1 .cta__meta{margin-top:26px;color:var(--deep)}
.uichemy-ember-1 .foot{position:relative;z-index:1;background:var(--deep);color:var(--cream);overflow:hidden;
padding:clamp(60px,8vw,110px) var(--pad) 24px;border-radius:clamp(26px,4vw,56px) clamp(26px,4vw,56px) 0 0}
.uichemy-ember-1 .foot__grid{display:grid;grid-template-columns:1.5fr repeat(3,1fr);gap:36px}
.uichemy-ember-1 .foot__grid > div > p{margin:0;max-width:30ch;font-family:var(--serif);font-size:clamp(26px,2.4vw,36px);line-height:1.08}
.uichemy-ember-1 .foot h4{margin:0 0 14px;font-family:var(--mono);font-size:11px;font-weight:500;letter-spacing:.14em;
text-transform:uppercase;opacity:.6}
.uichemy-ember-1 .foot ul{list-style:none;margin:0;padding:0;display:grid;gap:7px;font-size:17px}
.uichemy-ember-1 .foot ul a{background:linear-gradient(currentColor 0 0) 0 100%/0 1.5px no-repeat;transition:background-size .45s var(--ease)}
.uichemy-ember-1 .foot ul a:hover{background-size:100% 1.5px}
.uichemy-ember-1 .foot__big{display:flex;align-items:flex-end;margin:clamp(50px,7vw,100px) 0 0 -.05em;font-weight:800;
font-size:clamp(96px,25vw,420px);line-height:.72;letter-spacing:-.05em;white-space:nowrap}
.uichemy-ember-1 .foot__big span{color:var(--red)}
.uichemy-ember-1 .foot__legal{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px 20px;margin-top:22px;padding-top:18px;
border-top:1.5px solid var(--line-cream);font-family:var(--mono);font-size:11px;letter-spacing:.1em;
text-transform:uppercase;opacity:.72}
@media (max-width:900px){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .nav__links{display:none}
.uichemy-ember-1 .hero__word{font-size:max(84px,min(36vw,calc((100svh - 380px) / 1.8)))}
.uichemy-ember-1{--nav-h:64px}
.uichemy-ember-1 .live{max-width:100%;font-size:10px;padding:8px 13px 8px 10px}
.uichemy-ember-1 .live > span:first-of-type{display:none}
.uichemy-ember-1 .live__slot{max-width:100%}
.uichemy-ember-1 .hero__foot{grid-template-columns:1fr auto}
.uichemy-ember-1 .hero__foot .cue{display:none}
.uichemy-ember-1 .hero__meta{flex-direction:column;gap:14px}
.uichemy-ember-1 .how{height:300vh}
.uichemy-ember-1 .how__stick{grid-template-columns:1fr;align-content:center;gap:22px;padding-top:20px}
.uichemy-ember-1 .step p{display:none}
.uichemy-ember-1 .step.on p{display:block}
.uichemy-ember-1 .ticketwrap{width:min(340px,92%);zoom:.78}
.uichemy-ember-1 .how .h2{font-size:min(12vw,8.5svh)}
.uichemy-ember-1 .step:not(.on){display:none}
.uichemy-ember-1 .ticketshadow{bottom:-30px}
.uichemy-ember-1 .stats{grid-template-columns:1fr 1fr}
.uichemy-ember-1 .stat,
.uichemy-ember-1 .stat + .stat{padding:24px 12px 26px 0;border-right:0;border-bottom:1.5px solid var(--line)}
.uichemy-ember-1 .stat:nth-child(2n){padding-left:14px;border-left:1.5px solid var(--line)}
.uichemy-ember-1 .foot__grid{grid-template-columns:1fr 1fr}
.uichemy-ember-1 .foot__grid > div:first-child{grid-column:1 / -1}}
@media (max-width:900px) and (max-height:720px){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .ticketwrap{zoom:.64}}
@media (max-width:700px){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .dish{grid-template-columns:62px 1fr;gap:6px 16px}
.uichemy-ember-1 .dish__n{display:none}
.uichemy-ember-1 .dish__art{display:block;grid-row:span 2;width:62px;aspect-ratio:1;border-radius:50%;padding:4px;
background:radial-gradient(circle,#FFFDF8 0 58%,#E9DFCD 70%,#F5EEE2 80%,#C9B79C 100%);box-shadow:0 6px 12px -6px rgba(36,8,5,.5)}
.uichemy-ember-1 .dish__art svg{width:100%;height:100%}
.uichemy-ember-1 .dish__meta{grid-column:2;display:flex;align-items:center;gap:14px}
.uichemy-ember-1 .dish__price{text-align:left}}
@media (min-width:701px){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .dish__meta{display:contents}}
@media (prefers-reduced-motion:reduce){
.uichemy-ember-1{--uv-guard:0}
.uichemy-ember-1 .band__track,
.uichemy-ember-1 .rrow,
.uichemy-ember-1 .grain,
.uichemy-ember-1 .cue span::after,
.uichemy-ember-1 .live i,
.uichemy-ember-1 .badge .ring,
.uichemy-ember-1 .burner .fl.on,
.uichemy-ember-1 .printer i{animation:none}
.uichemy-ember-1 :is(.preview__art,
.uichemy-ember-1 .dish__art) svg *,
.uichemy-ember-1 .ph img{animation:none!important}}
/* Elementor's frontend.min.css zeroes every figure inside a widget:
.elementor .elementor-widget:not(..):not(..) figure{margin:0}, specificity (0,4,1).
The review cards are figures, so their source margin is restated above it. */
.uichemy-ember-1.uichemy-ember-1.uichemy-ember-1 .review{margin:14px 0 0}
```
## B7 · section `js` (the controller bridge)
```js
const uichemy_controller_bridge = {
"hero_ignite_delay": {"value": 1.2, "label": "Hero fire: ignition delay", "group": "Fire", "min": 0, "max": 5, "step": 0.1, "unit": "s"},
"hero_ignite_duration": {"value": 4.2, "label": "Hero fire: climb duration", "group": "Fire", "min": 0.5, "max": 10, "step": 0.1, "unit": "s"},
"cta_ignite_duration": {"value": 3.4, "label": "HUNGRY? fire: climb duration", "group": "Fire", "min": 0.5, "max": 10, "step": 0.1, "unit": "s"},
"hero_sparks": {"value": 90, "label": "Hero fire: spark count", "group": "Fire", "min": 0, "max": 240, "step": 1},
"cta_sparks": {"value": 60, "label": "HUNGRY? fire: spark count", "group": "Fire", "min": 0, "max": 240, "step": 1},
"hero_reveal_duration": {"value": 1.5, "label": "Hero word fade-in", "group": "Fire", "min": 0.1, "max": 5, "step": 0.1, "unit": "s"},
"loader_fill_duration": {"value": 2.1, "label": "Loader: grill heat-up", "group": "Loader", "min": 0.2, "max": 8, "step": 0.1, "unit": "s"},
"loader_exit_duration": {"value": 1.05, "label": "Loader: curtain lift", "group": "Loader", "min": 0.2, "max": 4, "step": 0.05, "unit": "s"},
"hero_line_duration": {"value": 1.2, "label": "Hero tagline: word rise", "group": "Text", "min": 0.1, "max": 4, "step": 0.05, "unit": "s"},
"heading_word_duration": {"value": 1, "label": "Headings: word rise", "group": "Text", "min": 0.1, "max": 4, "step": 0.05, "unit": "s"},
"heading_word_stagger": {"value": 0.06, "label": "Headings: word stagger", "group": "Text", "min": 0, "max": 0.5, "step": 0.01, "unit": "s"},
"reveal_duration": {"value": 0.9, "label": "Blocks: reveal duration", "group": "Text", "min": 0.1, "max": 4, "step": 0.05, "unit": "s"},
"reveal_distance": {"value": 34, "label": "Blocks: rise distance", "group": "Text", "min": 0, "max": 200, "step": 1, "unit": "px"},
"counter_duration": {"value": 1.9, "label": "Stats: count-up duration", "group": "Text", "min": 0.2, "max": 6, "step": 0.1, "unit": "s"},
"scroll_smoothing": {"value": 0.1, "label": "Smooth scroll (Lenis lerp, lower = smoother)", "group": "Feel", "min": 0.02, "max": 1, "step": 0.01},
"cursor_follow": {"value": 0.24, "label": "Cursor follow (higher = snappier)", "group": "Feel", "min": 0.02, "max": 1, "step": 0.01}
};
/* Ember runs from the site-wide code files ember.js / ember-scene.js. This block
only hands the tuned values over; the scene reads them as MM.<name>. */
window.EMBER_MOTION = Object.assign(window.EMBER_MOTION || {}, {
hero_ignite_delay: uichemy_controller_bridge.hero_ignite_delay,
hero_ignite_duration: uichemy_controller_bridge.hero_ignite_duration,
cta_ignite_duration: uichemy_controller_bridge.cta_ignite_duration,
hero_sparks: uichemy_controller_bridge.hero_sparks,
cta_sparks: uichemy_controller_bridge.cta_sparks,
hero_reveal_duration: uichemy_controller_bridge.hero_reveal_duration,
loader_fill_duration: uichemy_controller_bridge.loader_fill_duration,
loader_exit_duration: uichemy_controller_bridge.loader_exit_duration,
hero_line_duration: uichemy_controller_bridge.hero_line_duration,
heading_word_duration: uichemy_controller_bridge.heading_word_duration,
heading_word_stagger: uichemy_controller_bridge.heading_word_stagger,
reveal_duration: uichemy_controller_bridge.reveal_duration,
reveal_distance: uichemy_controller_bridge.reveal_distance,
counter_duration: uichemy_controller_bridge.counter_duration,
scroll_smoothing: uichemy_controller_bridge.scroll_smoothing,
cursor_follow: uichemy_controller_bridge.cursor_follow
});
```
## Integrity (sha256 of each block's exact text)
| Block | Bytes | sha256 |
|---|---|---|
| B1 site_before_head | 4909 | `3cc12d98dbbeb6007e3317132600e825e98e42c7ba65961150efc4d4faa53d49` |
| B2 site_before_body | 65 | `0d639976c82745f62bf43c8f1117afcae8d4f0d20c884ef4eef43cb6b1292119` |
| B3 ember.js | 15155 | `516cc644d02aadd4670e522e492a8c6fdf46fe6730d91a9a51542574b3cecd76` |
| B4 ember-scene.js | 111640 | `db0366ae48ec63d581174faf692b9015ff7b6f54af56dbedd8093a277ceeac6d` |
| B5 section html | 13876 | `1d48f9a395982ae2d2ff899c43db99f11d69a497dc99fd79c654cafe0217f482` |
| B6 section css | 33662 | `c76d66386749f143a3dc4dd5f0a9feae39674e07d67181e016acc9dc994e9792` |
| B7 section js | 3535 | `a4ab8d9780378c424272b3f2ea5f546b0a25e23574e464f404a0742b65634668` |
B3 and B4 are byte-identical to the live files (`?v=4`). B1's style block is exactly what the site
head carries, and B7 matches the stored widget JS. B5 and B6 are the input for `page create`; the
plugin stores a reformatted copy, so compare the rendered page, not the stored text.
---
# APPENDIX C · VERIFYING A BUILD LIKE THIS
1. **Frontend harness:**
- emulate the plugin (prefix, doubled prefix in `@media`, media hoisted to the end);
- wrap the section in `.elementor > .elementor-widget` with Elementor's figure rule and a hostile theme;
- add the importmap with its r169 scope, and set `EMBER_MOTION = {}` before the loader;
- point at local copies of B3/B4 that use the jsDelivr photo/font URLs (RULE 5).
2. **Compare after the loader finishes:** document height, section boxes, fonts, the sticky panel and
the nav at absolute scroll positions, and the loaded `document.fonts` faces.
3. **Editor harness — assume the editor is hostile:**
- `IntersectionObserver` stubbed dead;
- the widget swapped 3× in 100 ms steps, with one stale copy hidden in the page;
- SVGs stripped and `uich-composer-hover-target` classes added;
- B7 re-run on each render;
- then partial re-renders (menu + stats only).
Assert WebGL pixels with `readPixels` inside a rAF (hero, receipt, reviews, HUNGRY?), that
dishes and stats become visible, no `.wi .wi`, and exactly 3 canvases. Also kill the scene outright:
the safety net alone must still show the menu and stats.
4. **Do not trust a passing emulation over the user's editor:** it passed twice while the real
editor still failed. When it does, get `__emberDiag()` from the preview frame.