# Cinematic site — the founder's hand-off homepage (Main · Public site)

**Portal:** Main · **Routes:** `GET /` (`landing`), `GET /partners` (`main.site.partners`), `GET /about`
(`main.site.cinematic-about`), `GET /contact` (`main.site.contact`), `GET /preview/{cinematic,partners,about,contact}`
and the two form posts `POST /partners/apply`, `POST /contact` — **all only when `site.cinematic_home`
is on** (wk) · **Nav:** the pages carry their own header
and footer; the portal sidebar is not involved · **Gated by:** nothing, it is **PUBLIC** ·
**Writes:** `partner_applications`, `enquiries` (+ an email to support@propertylab.tech).

## What it does

A scroll-driven cinematic homepage (hero + five chapters — Discover, Evidence, AI + Plan, Connect,
Beyond the keys — with Next/Back stepping, then How it works, the app, Partners, About and the footer)
and three inner pages: Partners (with an application form), About, and Contact (six enquiry topics,
`?type=careers` pre-selects one). It arrived as a finished, approved package
(`propertylab-cinematic-v10.0-laravel.zip`, 2026-10-03) and went live as wk's front door the same day.

## How it works

- **The pages are generated, frozen output.** `resources/views/cinematic/home.blade.php` and
  `resources/views/pages/*.blade.php` are produced by the package's `source/build.py` and
  `source/pages/pages.py`; **never hand-edit them** — change the package source and rebuild, then copy
  the output over. Design, copy, timing and assets are frozen by the package's own `CLAUDE.md`.
  The package is not in the repo; ask the founder for the current zip.
- **Plain HTML, not Inertia.** No Vite build, no external JS libraries; Google Fonts (Sora, Inter,
  Cormorant Garamond) only. Every route runs the `full-page` middleware
  ([`ForceFullPageVisit`](/app/Http/Middleware/ForceFullPageVisit.php)): an Inertia visit (a `<Link>`,
  or a redirect an Inertia XHR follows, e.g. `redirect()->route('landing')`) gets a 409 +
  `X-Inertia-Location` and the browser does a full load, instead of Inertia painting the HTML in a modal.
- **Behind a per-deployment flag.** `SITE_CINEMATIC_HOME=true` → `config('site.cinematic_home')`
  swaps `/` from `Main\SiteController@home` to the cinematic view and adds `/partners`, `/about`,
  `/contact`, the `/preview/*` copies and the form posts. Off by default, so merging the code changes no other site's homepage. The Inertia home
  stays reachable at `/home` and `/{country}/home` either way. Routes are evaluated at
  `route:cache` time — after flipping the flag run `config:cache` **then** `route:cache`.
- **Assets** live in `public/cinematic/assets/` (~95 MB: 12 scroll-scrubbed films, Duo phone frames,
  partner logos), referenced by absolute `/cinematic/assets/...` paths. **`public/cinematic/` is
  gitignored** — it is copied from the package (`public/cinematic/` → `public/cinematic/`) onto the
  deployment that sets the flag, which is why every route, `/preview/*` included, sits behind it.
  wk's copy came from the v10.0 zip uploaded at `/file/ab914e36-64dc-41a8-b4f2-9115e2e269ce`. Phones and desktops load
  different files on purpose (iPhone Safari memory). Do not rename, re-encode or move them.
  - **Films need HTTP byte ranges (206)** or they freeze on frame one — Apache's static serving does it.
  - `public/.htaccess` sets `Cache-Control: public, max-age=31536000, immutable` on
    `/cinematic/assets/*` (the package renames a file when it changes). Apache's deflate gzips the
    `.json` bundles and leaves `.mp4` / `.webp` alone, as the package requires.
- **Forms** post with `fetch()` + `X-CSRF-TOKEN` (each view prints `<meta name="csrf-token">`) to
  [`EnquiryController`](/app/Http/Controllers/EnquiryController.php) — the package's starter
  controller, kept as shipped: inline validation, a raw `DB::table()` insert of a JSON `payload`, then
  `Mail::raw()` to support. ⚠️ It does not follow GUIDELINES (no Form Request / Repository) and the
  email is sent **after** the insert, so a mail failure returns a 500 with the row already saved
  (2026-10-03: Mailgun had disabled `mg.propertylab.com.my` for spam). Port it to the CRM before
  relying on it.
- **Funnel slugs** `partners`, `about`, `contact`, `preview`, `cinematic` are reserved
  (`Funnels\StoreRequest::RESERVED_SLUGS`) so a funnel can never shadow these pages or be shadowed.
- **Acceptance tests** live in the package (`tests/e2e/cinematic.spec.ts`, Playwright, five tests,
  run against `/preview/*`). Run them from a scratch folder, never by adding Playwright to
  `package.json`: `BASE_URL=https://wk.propertylab.com.my npx playwright test`.

### Local patches on top of v10.0 — re-apply after any rebuild from the founder's source

The home page's changes are in [`template.html.patch`](/docs/modules_handbook/main/cinematic-site/template.html.patch)
(a diff against `source/template.html`; `patch -p1` from the package root, rebuild with `source/build.py laravel`,
then wrap the output in the Blade header + `@verbatim` as shipped). The inner pages' changes are in
[`pages.py.patch`](/docs/modules_handbook/main/cinematic-site/pages.py.patch), a
unified diff against the package's `source/pages/pages.py`. From the package root:
`patch -p1 < pages.py.patch && cd source/pages && python3 pages.py laravel <out>`, then copy
`<out>/*.blade.php` to `resources/views/pages/`. v10.0 + the patch reproduces the committed pages byte
for byte. A new package version from the founder may not apply cleanly; re-do each change by hand.

- **`source/template.html` (home): scroll-scrubbed films no longer blink back to the previous scene**
  (2026-10-03, *"when I scroll down using mouse, sometimes the scene is like blinking with earlier
  scenes"*). The scene switches were gated on the film's live `readyState >= 2`, but Chromium drops
  `readyState` to 1 for the length of every seek, and scrubbing seeks on almost every frame. Each scroll step
  therefore swapped in the scene underneath for a frame: the closed-laptop hero over the lid-opening film,
  and the Connect film over *Beyond the keys*. A mouse wheel (big jumps, long seeks) made it worst. The
  fix: a `hasFrame(v)` latch (true once the film has painted a frame, reset only if it empties); the
  browser keeps that frame on screen while it seeks. `lidReady()` is latched the same way, so a dip
  can no longer clamp the scroll back to the closed lid. Measured in headless Chrome with 185 wheel steps
  through the story: 20–38 scene flips per pass before the fix, 0 after.
- **`source/pages/pages.py`: `.dark::before` / `.dark::after` → `section.dark::before` /
  `section.dark::after`** (2026-10-03). The drifting blurred glow meant for dark SECTIONS also hit every
  `btn dark` button; a button is `position: static`, so its 864 px blur(40px) blob escaped to the
  fixed nav / hero and drifted behind the frosted nav and the hero's glass card, which a GPU re-blurs
  every frame — the Partners hero "kept blinking" (About / Contact carry the same nav button).
  Rebuilt with `python3 pages.py laravel <out>` — the unpatched source reproduces the shipped Blade
  byte for byte, so the patch is the only difference. Send it upstream so the next package has it.
- **`source/pages/pages.py`: added `.frow>.ff+.ff{margin-top:0}`** right after the
  `.frow+.frow,.ff+.ff,… {margin-top:16px}` rule (2026-10-03). That stacking margin also hit the second
  field of every two-column `.frow`, so on desktop the right-hand field (Contact person, Phone, Company
  website, Licence; Contact's form too) sat 16 px below its neighbour, and on phones the row's own
  16 px gap doubled. The row's `gap` already spaces its fields in both layouts.

- **Partners → "Custom AI for partners" (`#ai`) redesigned at the owner's request** (2026-10-03: *"this
  section uiux is not first class"*). Was: a narrow column (headline broken over four lines, a plain
  dot list) beside a small floating image, and no call to action. Now: a header row (headline on
  two lines | lead + **Talk to us about custom AI**), then a full-height image carrying an illustrative
  AI-assistant chat card beside the four features as a 2×2 grid of cards with the page's gradient
  icon tiles (icon-beside-text rows on phones). Copy unchanged; the CTA jumps to `#apply` and the
  shared JS pre-ticks *Yes, tell me more* (`[data-ai-yes]`).
  ⚠️ **Trap, package-wide:** the reveal script adds class `in` to every `.rv` on scroll — the same
  name as the `.in` container (auto side margins + 56 px side padding). A `.rv` grid item therefore
  shrinks to fit its content once revealed (the image box collapsed to 114 px) and every revealed
  block gains 56 px side padding. The redesign resets it for its own elements only; the other
  sections were approved with the side effect baked in, so they are left alone.

- **About → Leadership: the founder's tile is his photo** (2026-10-03), not the "WK" initials.
  `lead_tile()` + `LEAD_PHOTOS` in `pages.py` — add a name → path there to give another leader a photo.
  The image lives in the COMMITTED [`public/main/images/about/`](/public/main/images/about/) (256×256
  webp, cropped from the owner's portrait), not the gitignored `public/cinematic/`.

### Open before a real launch (from the package's HANDOFF)

- Placeholder links: `#login`, `#app-store`, `#google-play`, footer `#learn` / `#help` / `#press` /
  `#privacy` / `#terms` / `#pdpa`, the four social icons; the EN / 中文 toggle is visual only.
- Claims to confirm: the Cyberport and REACH credential cards, leadership names, the KL address, the
  "reply within one business day" promise; illustrative figures baked into the films.
- Test 01 fails on the shipped page: it has two `<h1>` (the laptop mock-up's "Analyze Property" title
  and the real headline) — fix in `source/template.html`.

## Related files

**Backend**
- [routes/main.php](/routes/main.php) — the flag-gated block at the top (every route above, declared
  before the `/{slug}` and `/{funnel}/{slot}` catch-alls).
- [config/site.php](/config/site.php) — `cinematic_home` (`SITE_CINEMATIC_HOME`).
- [app/Http/Middleware/ForceFullPageVisit.php](/app/Http/Middleware/ForceFullPageVisit.php) —
  registered as `full-page` in [app/Http/Kernel.php](/app/Http/Kernel.php).
- [app/Http/Controllers/EnquiryController.php](/app/Http/Controllers/EnquiryController.php) — as shipped.
- [app/Http/Requests/Manage/Events/Funnels/StoreRequest.php](/app/Http/Requests/Manage/Events/Funnels/StoreRequest.php) — reserved slugs.

**Frontend (generated — do not edit)**
- [resources/views/cinematic/home.blade.php](/resources/views/cinematic/home.blade.php)
- [resources/views/pages/partners.blade.php](/resources/views/pages/partners.blade.php),
  [about.blade.php](/resources/views/pages/about.blade.php),
  [contact.blade.php](/resources/views/pages/contact.blade.php)
- `public/cinematic/index.html` (static copy of the home, for QA) + `public/cinematic/assets/`
- [public/.htaccess](/public/.htaccess) — the asset cache rule.
- [pages.py.patch](/docs/modules_handbook/main/cinematic-site/pages.py.patch) and
  [template.html.patch](/docs/modules_handbook/main/cinematic-site/template.html.patch) — our changes to the package source (see *Local patches*).

**Migrations**
- [2026_10_03_000001_create_enquiry_tables.php](/database/migrations/2026_10_03_000001_create_enquiry_tables.php) — `partner_applications`, `enquiries` (as shipped).

Related: the earlier candidate design [Company](/docs/modules_handbook/main/company/readMe.md)
(`/{country}/company`, Inertia, still unlinked) and the Inertia home it replaced at `/` on wk
([Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md)).
