پرامپت Lumora — Independent Design & Engineering Studio
این صفحه فقط برای مشاهده متن پرامپت است. توضیح و پیشنمایش کامل را در صفحه اصلی نمونه ببینید.
# Recreate this site as a single HTML file: Lumora — Design & Engineering Studio
You are an expert creative front-end developer. Produce a **single self-contained `index.html`** that reproduces the project below **exactly** — same layout, sections, visuals, motion, and interaction. Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. Use ES modules with a CDN importmap for the one library actually used (Lenis smooth-scroll). Hardcode every value given here as a fixed constant. All the CSS below lives in one `<style>` block in `<head>`; the JS in one `<script type="module">` block before `</body>`. The spring / text-reveal animations from the original (react-spring + spring-text-engine) must be reproduced with **plain JS** — a tiny rAF spring helper and/or CSS transitions — achieving the same feel.
## What it is
A single-page, light-palette landing site for **"Lumora — Independent Design & Engineering Studio"**. The page is built on a rem-based adaptive grid (the root font-size scales with the viewport), uses Google **Onest** as the only typeface, and reads as near-white surfaces (`#ffffff` page, `#f1f0ee` light fills) punctuated by deep near-black ink cards (`#0a0a0a`) and a single burnt-orange accent (`#b15f2c`). It opens with a **full-screen dark intro loader** that counts `000 → 100` and slides up; only then do the above-the-fold reveals play. The hero is a **full-bleed before/after photo with a "liquid" cursor-reveal** (moving the pointer paints a soft brush trail of a second image over the first), with a giant `LUMORA` watermark, a line-by-line headline, a carousel card, and a partner grid. Below: an About statement, a four-pill "We / Build / → / Better" band, a 4-card Portfolio on black cards, a 4-row Services list with hover-fill rows, a black Stats panel with scroll-driven count-up numbers, and a black footer with a CTA, link columns and a watermark. A header (Menu button + live local clock) overlays the hero; the Menu opens a full-screen dark overlay; every "Contact / Let's Talk / Start a project" CTA opens a **request modal** (stubbed submit). All smooth-scrolling is driven by **Lenis**.
Sections in DOM order: **PageLoader** → **Header** (fixed overlay) → `main`{ **Hero** → **About** → **CreateBand** → **Portfolio** → **Services** → **Stats** } → **Footer** → **NavMenu** (overlay) → **RequestModal** (overlay).
## Page shell & libraries
### `<head>`
```html
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumora — Independent Design & Engineering Studio</title>
<meta name="description" content="Lumora is an independent studio crafting brands, products, and the systems that connect them — bold ideas, shipped with quiet precision." />
<meta name="theme-color" content="#0a0a0a" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&display=swap" rel="stylesheet">
```
### Importmap + module entry (before `</body>`)
```html
<script type="importmap">
{ "imports": { "lenis": "https://unpkg.com/lenis@1.3.23/dist/lenis.mjs" } }
</script>
<script type="module"> /* all JS below */ </script>
```
Lenis import + raf loop (the original instantiates Lenis with smoothWheel and runs a manual raf loop; it also resets scroll to top on load):
```js
import Lenis from 'lenis';
window.scrollTo(0, 0);
const lenis = new Lenis({ smoothWheel: true });
function raf(t){ lenis.raf(t); requestAnimationFrame(raf); }
requestAnimationFrame(raf);
```
**Scroll lock model.** A single boolean `scrollEnabled` gates everything. When something needs to lock scroll (loader, nav overlay, request modal) call a `stopScroll()` that does `lenis.stop()` and sets `html { position:relative; overflow:hidden; height:100% }`; `startScroll()` does `lenis.start()` and removes those three inline styles. The loader stops scroll on mount and starts it when its exit finishes.
**`scrollTo(id)` helper.** Smooth-scroll to an element by id: temporarily disable scroll-state, then after `50ms` `window.scrollTo({ top: <element top + pageYOffset>, behavior: 'smooth' })`, re-enable after `100ms`. Used by the logo, nav links (non-contact), and the hero "View Work" button.
### Global CSS reset / base
```css
*{ box-sizing:border-box; margin:0; padding:0 }
html{ font-size:16px; -webkit-font-smoothing:antialiased }
body{ background:#ffffff; color:#111111; font-family:'Onest',sans-serif; overflow-x:hidden }
a{ color:inherit; text-decoration:none }
button{ font:inherit; color:inherit; background:none; border:none; cursor:pointer }
ul{ list-style:none }
img{ display:block }
.sr-only{ position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0 }
:focus-visible{ outline:2px solid #b15f2c; outline-offset:2px }
@media (prefers-reduced-motion: reduce){ *{ animation:none !important; transition:none !important } }
```
### The rem-based adaptive grid (CRITICAL — bake exactly)
The whole layout is sized in **rem**; the root font-size scales with the viewport so the design stays proportional. The design base is `FONT_BASE = 16px`. Reproduce the original's media queries verbatim (each = `16 * 100 / baseWidth` vw):
```css
@media (max-width:1920px){ html{ font-size:0.833333vw } } /* base 1920 */
@media (max-width:1440px){ html{ font-size:1.111111vw } } /* base 1440 */
@media (max-width:1024px){ html{ font-size:1.5625vw } } /* base 1024 */
@media (max-width:640px){ html{ font-size:4.444444vw } } /* base 360 */
```
**Scale-UP above 1920px** (runtime JS, mirrors the original `AdaptiveGrid`): on load + resize, set `html.style.fontSize` to an interpolated value so the layout keeps growing on large displays. Formula with damping `coef = 0.6666`:
```js
function applyAdaptiveGrid(){
const FONT_BASE = 16, baseWidth = 1920, coef = 0.6666;
const w = window.innerWidth;
const widthReduction = ((baseWidth - w) / baseWidth) * 100; // negative when w > baseWidth
const size = FONT_BASE - (FONT_BASE * (widthReduction * coef)) / 100;
if (size > FONT_BASE) document.documentElement.style.fontSize = size + 'px';
else document.documentElement.style.removeProperty('font-size'); // let media queries drive
}
applyAdaptiveGrid(); addEventListener('resize', applyAdaptiveGrid);
```
Because everything is rem-based, **all sizes below are given in rem/px exactly as Tailwind emitted them** (Tailwind's default scale: `text-sm`=0.875rem, `text-base`=1rem, `text-lg`=1.125rem, `text-xl`=1.25rem, `text-2xl`=1.5rem, `text-3xl`=1.875rem, `text-4xl`=2.25rem, `text-5xl`=3rem, `text-6xl`=3.75rem, `text-7xl`=4.5rem; spacing unit `0.25rem`; `gap-3`=0.75rem etc.). Keep them in rem so the adaptive grid works.
### Shared spring helper (replace react-spring)
Implement one tiny critically-ish-damped spring stepper used for entrance reveals and hovers. react-spring configs are given as `{ tension, friction }`; map them to a stiffness/damping rAF integrator (mass = 1): `accel = tension*(target - x) - friction*v`, integrate at `dt≈1/60`, settle when `|target-x|<0.001 && |v|<0.001`. For entrance reveals you can instead use CSS transitions with the equivalent feel:
- `{ tension:210, friction:26 }` ≈ `cubic-bezier(.22,1,.36,1)` ~0.7s
- `{ tension:200, friction:24 }` / `{180,26}` ≈ `cubic-bezier(.16,1,.3,1)` ~0.8s
- `{ tension:320, friction:18 }` (hovers) ≈ snappy ~0.35s `cubic-bezier(.2,.8,.2,1)`
Either approach is acceptable as long as motion reads springy. Hovers are pointer-driven and disabled on touch (the original disables `Hover` on mobile).
### Text reveal helper (replace spring-text-engine)
Two reveal modes are used; both play **once** when the element scrolls into view (IntersectionObserver, `mode:"once"`), gated additionally on the intro loader being finished for hero text.
- **Line reveal** (`overflow` clip): split the heading into lines (wrap each line in a `span` with `overflow:hidden`; inner span translates `Y 100% → 0%` and `opacity 0 → 1`). Per-line stagger when given (`lineStagger`). Spring/curve ≈ `duration 900ms, easeOutCubic` → `cubic-bezier(0.215,0.61,0.355,1)`.
- **Word reveal** (About statement): split into words; each word `translateY(24px→0)` + `opacity(0→1)`, stagger `35ms` per word, `duration 700ms, easeOutQuart` → `cubic-bezier(0.165,0.84,0.44,1)`.
For simplicity you may split by spaces and treat visual "lines" as the natural wrap; the important part is the staggered slide-up-from-clip feel.
## Fixed palette & tokens (bake these in)
```
--background:#ffffff --foreground:#111111
--ink:#0a0a0a (black cards / pills / overlays)
--muted:#8d8d8d --subtle:#b6b6b6
--line:#e6e5e2 (hairline borders)
--surface:#f1f0ee --surface-2:#e3e2df
--accent:#b15f2c --accent-from:#cf8047 --accent-to:#97501f
--hero-from:#ecebe9 --hero-to:#c9c9c9 (hero section bg = hero-to #c9c9c9)
```
Radii: `--radius-pill:9999px`, `--radius-card:2rem`, `--radius-card-sm:1.25rem`, `--radius-control:0.875rem`. Watermark size: `--text-watermark:13rem`. Page shell max-width: `--container-shell:88rem` (use `max-width:88rem; margin-inline:auto` for `.shell`). Font weights used: 400/500/600/700.
### SVG assets to inline (sized `1em`, `fill`/`stroke` = `currentColor`)
- **LogoMark** (brand 4-point spark), `viewBox="0 0 48 48"`, filled:
`M24 2c2.2 13.8 7.9 19.6 22 22-14.1 2.4-19.8 8.2-22 22-2.2-13.8-7.9-19.6-22-22 14.1-2.4 19.8-8.2 22-22Z`
- **ArrowRight** `viewBox 0 0 24 24` stroke 2 round: `M5 12h14M13 6l6 6-6 6`
- **ArrowUpRight** stroke 2 round: `M7 17 17 7M8 7h9v9`
- **Star** filled: `M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.9l-5.8 3.05 1.1-6.46-4.69-4.58 6.49-.94L12 2.5z`
- **Globe** stroke 1.4: `<circle cx=12 cy=12 r=9.25/>` + `M12 2.75c2.6 2.3 4 5.8 4 9.25s-1.4 6.95-4 9.25c-2.6-2.3-4-5.8-4-9.25s1.4-6.95 4-9.25zM2.75 12h18.5`
- **X / close** stroke 2 round: `M4 4l16 16M20 4 4 20`
- **CircleDot** stroke 1.6: `<circle cx12 cy12 r9/>` + filled `<circle cx12 cy12 r3.2/>`
- **Grid / menu** (three lines) stroke 2 round: `M4 6h16M4 12h16M4 18h16`
---
## The loader / reveal (PageLoader)
A fixed full-screen panel on top of everything from first paint. `position:fixed; inset:0; z-index:120; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2rem; background:#0a0a0a; color:#fff; border-radius:0 0 2rem 2rem` (rounded bottom corners — `rounded-b-card`).
- Locks scroll on mount (`stopScroll()`).
- **Center content** (`gap:1.25rem`, centered text, `opacity 1`): a row `font-weight:600; font-size:1.5rem` (sm: 1.875rem) reading `[LogoMark, 1.875rem, color:#cf8047] Lumora`; below it a `max-width:24ch; font-size:0.875rem; color:rgba(255,255,255,.55)` paragraph: **"Bold ideas, shipped with quiet precision."**
- **Progress block** `width:min(22rem,72vw); gap:0.75rem`: a `height:1px` track `background:rgba(255,255,255,.15)` with an inner fill `height:100%; background:#cf8047; width:<progress>%; transition:width .1s ease-out`. Below it a row `justify-content:space-between; font-size:0.75rem; font-weight:500; text-transform:uppercase; letter-spacing:.05em; color:rgba(255,255,255,.45)` → left `Loading`, right a tabular-nums counter `color:rgba(255,255,255,.8)` showing the number **zero-padded to 3 digits** (`000`…`100`).
- **Count animation:** drive progress `0 → 100` over `FILL_MS = 1300ms`, eased with **easeInOutCubic** (`t<.5 ? 4t³ : 1-((-2t+2)³)/2`), `progress = round(ease(t)*100)`.
- **Exit:** when the count reaches 100, slide the whole panel up: `transform: translateY(0%) → translateY(-100%)` with spring feel `{tension:220,friction:30}` (~`cubic-bezier(.22,1,.36,1)`, ~0.7s); simultaneously fade the center content `opacity 1→0, translateY 0→-12px`. When the slide finishes: set the global **intro `ready` flag true**, `startScroll()`, and remove the loader from the DOM. All hero reveals are gated on `ready` (they only begin after the loader has left).
---
## Layout & sections (in order)
A `.shell` wrapper = `max-width:88rem; margin-inline:auto`. Default horizontal padding `px-5`=1.25rem, `sm:px-8`=2rem (sm breakpoint = 640px). `lg` = 1024px.
### A skip link (first focusable)
`<a href="#main">Skip to content</a>` — visually hidden until focused, then `position:fixed; left:1rem; top:1rem; z-index:60; border-radius:.875rem; background:#0a0a0a; padding:.5rem 1rem; font-size:.875rem; color:#fff`.
---
### 1) Header (fixed overlay, entrance gated on `ready`)
`position:absolute; inset-inline:0; top:0; z-index:50`. Entrance: `opacity 0→1, translateY(-14px→0)`, spring `{210,26}`, delay `150ms` after `ready`.
Inner: `.shell` flex row, `align-items:center; justify-content:space-between; gap:1.5rem; padding:1.25rem` (sm: `2rem 2rem` → `py-6 px-8`).
- **Left — brand button** (`onClick → scrollTo('home')`): a hover-spring `span` (`scale 1→1.04`, `{320,18}`) `display:flex; align-items:center; gap:.5rem; font-size:1.125rem; font-weight:600; letter-spacing:-.01em` → `[LogoMark 1.25rem color:#b15f2c] Lumora`.
- **Center — primary nav** (`display:none` below `lg`, then `flex`): `ul` `gap:2rem; font-size:.875rem; font-weight:500`. Items (each a button; hover lifts the label `translateY(0→-2px)` + `opacity .8→1`, `{320,22}`): **Home** (current, `aria-current=page`), **Work**, **Services** (with a `▾` dropdown caret, `font-size:.75rem opacity:.6`), **Studio**, **Careers**, **Contact**. Click routing: `Home→#home`, `Work→#works`, `Services→#services`, `Studio→#about`, `Careers→#careers`, `Contact→opens request modal`. Non-contact links call `scrollTo(id)`.
- **Right — clock chip + Menu**:
- Clock chip (`display:none` below `md`=768px, then `flex`): `border:1px solid rgba(230,229,226,.8); background:rgba(255,255,255,.4); backdrop-filter:blur(4px); border-radius:.875rem; padding:.5rem .75rem; gap:.75rem; font-size:.75rem; color:rgba(17,17,17,.7)`. Contents: muted label **"Local time"** (`color:rgba(17,17,17,.45)`), then a `min-width:3.5rem; tabular-nums; font-weight:500; color:#111` **live time** (e.g. `9:41am`), a `•` separator (`color:rgba(17,17,17,.3)`), then a `font-weight:500` **live date** (e.g. `12 March, 2025`).
- **Live clock JS:** update every 1s. Time = `H:MM` + lowercase meridiem, no leading zero on hour (`hours%12||12`, minutes padded to 2). Date = `D Month, YYYY` (full month name). Until first tick, show fallbacks `9:41am` / `12 March, 2025`.
- Menu button (`onClick → open NavMenu`): `border:1px solid rgba(230,229,226,.8); background:rgba(255,255,255,.4); backdrop-filter:blur(4px); border-radius:.875rem; hover bg:rgba(255,255,255,.7)`. Inner hover-spring span (`scale 1→1.05`): `padding:.5rem 1rem; font-size:.75rem; font-weight:500; text-transform:uppercase; letter-spacing:.05em` → `[GridIcon .875rem] Menu` (the word "Menu" hidden below `sm`).
---
### 2) Hero (`#home`) — full-bleed before/after liquid reveal
`section#home`: `position:relative; isolation:isolate; overflow:hidden; border-radius:0 0 2rem 2rem; background:#c9c9c9` (hero-to).
**A) LiquidReveal full-bleed background** (`position:absolute; inset:0; z-index:0`). This is the signature effect — reproduce its mechanics exactly:
- A positioned container holds: (1) a `<img>` of the **before** image, `object-fit:cover`, `position:absolute; inset:0; width/height:100%` — always visible, the LCP image; (2) a `<canvas aria-hidden>` `position:absolute; inset:0; width/height:100%; pointer-events:none` that paints the **after** image along the cursor trail.
- **IMPORTANT image mapping (preserve exactly):** `beforeSrc = .../hero/after.jpg` (the always-shown base image) and `afterSrc = .../hero/before.jpg` (the brush-revealed image). I.e. the file named `after.jpg` is shown by default and the file named `before.jpg` is painted on the cursor trail. Do not swap them.
- **Params:** `brushRadius = 143` (CSS px), `decay = 0.016` per frame, `dpr = min(devicePixelRatio, 2)`.
- **Canvas sizing:** size the main canvas to the container rect × dpr; keep CSS size = rect size. On resize (ResizeObserver on the container) re-measure. Build an offscreen **cover** canvas at canvas resolution and draw the `after` image into it with `object-fit:cover` math (scale to fill, center). `radius = brushRadius*dpr`; a **brush** offscreen canvas is `diameter = ceil(radius*2)` square.
- **Pointer trail:** listen to `pointermove` on `window`. Convert client coords to canvas space (×dpr). Ignore points more than `radius` outside the canvas (and reset `last`). Interpolate between the last point and the new point: `step = max(radius*0.3, 1)`, `n = min(ceil(dist/step), 60)` intermediate points pushed into a `points` array.
- **Per-frame tick (rAF):**
- If there are queued points → `idle = 0`; else increment `idle` and bail once `idle > 120`.
- Compute `fade = drawing ? decay : min(decay + idle*0.004, 0.5)`. Apply `globalCompositeOperation:'destination-out'; fillStyle = rgba(0,0,0,fade); fillRect(full)` so the existing trail decays.
- If drawing: for each queued point **stamp** it, then clear the queue. If idle reaches 120 frames: `clearRect(full)` (hard clear so no residue lingers).
- **stamp(x,y):** on the brush canvas, clear, `source-over`, draw a radial gradient centered (`addColorStop 0 → rgba(255,255,255,1)`, `0.55 → rgba(255,255,255,.82)`, `1 → rgba(255,255,255,0)`), fill the square. Then `source-in`, draw the matching region of the **cover** canvas (`drawImage(cover, x-c,y-c,diam,diam, 0,0,diam,diam)`) so only the after-pixels under the soft brush remain. Finally on the main canvas `source-over`, `drawImage(brush, x-c, y-c)`.
- Honor `prefers-reduced-motion: reduce` → skip the canvas entirely, leave only the static base image.
**B) Legibility vignette** `position:absolute; inset:0; z-index:1; pointer-events:none; background:linear-gradient(to bottom, rgba(255,255,255,.35), transparent, rgba(255,255,255,.35))`.
**C) Brand watermark** (`pointer-events:none; position:absolute; inset-inline:0; bottom:7rem; z-index:1; text-align:center; user-select:none; font-weight:700; line-height:1; font-size:13rem; color:rgba(255,255,255,.4)`) text **LUMORA**. Reveal (gated on `ready`): `opacity 0→0.4, translateY(20px→0)`, `{120,30}`, delay `300ms`.
**D) Content grid** `.shell` `position:relative; z-index:20; display:flex; flex-direction:column; gap:2rem; padding:7rem 1.25rem 5rem` (sm px 2rem; lg: `display:grid; min-height:100lvh; grid-template-columns:repeat(12,1fr); gap:2.5rem; padding:9rem 2rem 7rem`).
- **Left column** (`lg:grid-column: span 7`), `display:flex; flex-direction:column; gap:1.75rem`:
- **Eyebrow** (reveal `opacity/translateY(10px)`, delay 200ms): a `font-size:.875rem; font-weight:500; color:rgba(17,17,17,.7); display:inline-flex; align-items:center; gap:.5rem` with a leading `0.375rem` dot (`background:rgba(17,17,17,.5); border-radius:9999px`) → **"Independent Studio"**.
- **H1** (line reveal, gated on `ready`, delay 250ms, `lineStagger 120ms`, easeOutCubic 900ms, `overflow` clip): `max-width:18ch; font-size:2.25rem; font-weight:600; line-height:.98; letter-spacing:-.02em` (sm 3rem, md 3.75rem). Lines: **"Bold ideas,"** / **"shipped with"** / **"quiet precision"**.
- **Rating row** (reveal, delay 650ms): `display:flex; align-items:center; gap:.75rem`. A `color:#b15f2c` span with **5** Star icons (`font-size:1rem`), then `font-size:.875rem; font-weight:500; color:rgba(17,17,17,.7)` text **"200+ brands shipped"**.
- **CTA row** (reveal, delay 750ms): `display:flex; flex-wrap:wrap; gap:.75rem`. Two pill buttons: **"Let's Talk"** (variant `dark`, with arrow → opens request modal) and **"View Work"** (variant `outline` → `scrollTo('works')`).
- **Right column** (`lg:span 5`), `display:flex; flex-direction:column; align-items:flex-start; gap:2rem` (lg: `align-items:flex-end`):
- **HeroCard** (carousel) — reveal `opacity/translateY(16px) scale(.96→1)`, `{200,24}`, delay 400ms. Card: `width:100%; max-wi