Kinoform Pictures Commercials, Film and Branded Work AI website prompt
A cinematic production studio website showcasing featured films, directors, production processes, global studios, and project enquiries.
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
More prompts
Showing 6 prompts
Frequently asked questions
What does the Kinoform Pictures Commercials, Film and Branded Work prompt build?
A cinematic production studio website showcasing featured films, directors, production processes, global studios, and project enquiries.
Which AI tools does the Kinoform Pictures Commercials, Film and Branded Work prompt work with?
It's written for Claude. Paste the whole prompt into Claude and it builds the complete site for you, then bring the result into WordPress with UiChemy. This prompt also has a WordPress version: with the UiChemy MCP connected, Claude builds the page straight into WordPress.
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 Kinoform Pictures Commercials, Film and Branded Work 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
Kinoform Pictures Commercials, Film and Branded Work · Section prompt
# MASTER PROMPT — Kinoform Pictures — Commercials, Film & Branded Work
### Plain HTML, CSS and JavaScript. No WordPress, no page builder, no framework, no build step.
## ▶ HOW TO USE THIS PROMPT
**Paste this whole document into Claude. Nothing to attach.** It carries the complete source of the site plus the address of every image, so Claude can assemble a folder that owns its own assets and hand it back as a zip.
**INSTRUCTION TO CLAUDE — BUILD IT, DO NOT ASK.** Every decision is already made in this document. Do not stop to ask the user questions, do not offer options, and do not ask for confirmation before starting. Write the files, zip them, hand over the archive, then report what you built.
1. Create a folder named `kinoform` and write out every file in the appendix below **exactly as written** — do not reformat, rename, shorten, reorder or "improve" anything.
2. **Download the 20 images into `assets/`.** The code already points at `assets/…` — nothing in it references a remote host. Run this from inside the folder; it reads the ASSET MANIFEST at the end of this document:
```bash
mkdir -p assets
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d1.jpg' -o 'assets/d1.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d2.jpg' -o 'assets/d2.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d3.jpg' -o 'assets/d3.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d4.jpg' -o 'assets/d4.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d5.jpg' -o 'assets/d5.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d6.jpg' -o 'assets/d6.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f1.jpg' -o 'assets/f1.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f2.jpg' -o 'assets/f2.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f3.jpg' -o 'assets/f3.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f4.jpg' -o 'assets/f4.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f5.jpg' -o 'assets/f5.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f6.jpg' -o 'assets/f6.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f7.jpg' -o 'assets/f7.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f8.jpg' -o 'assets/f8.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p1.jpg' -o 'assets/p1.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p2.jpg' -o 'assets/p2.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p3.jpg' -o 'assets/p3.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p4.jpg' -o 'assets/p4.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p5.jpg' -o 'assets/p5.jpg'
curl -fsSL 'https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p6.jpg' -o 'assets/p6.jpg'
```
Then check every file arrived and none is a zero-byte or HTML error page:
```bash
ls assets | wc -l # expect 20 files
find assets -size -1k # expect no output
```
If any download fails, retry it — hosts occasionally drop a request. Do **not** fall back to hotlinking the remote URL: the whole point is a folder that works offline.
3. **Zip that folder and give the user the archive.** Its contents:
```
kinoform/
index.html
sections/01-hero.html
sections/02-reel.html
sections/03-canvas.html
sections/04-directors.html
sections/05-contact.html
template.html
assets/
d1.jpg
d2.jpg
d3.jpg
d4.jpg
… 16 more
```
```bash
zip -r kinoform.zip kinoform -x '*.DS_Store'
```
Then hand over `kinoform.zip` as a file the user can download, not as text in the chat.
4. Before handing it over, serve the folder and confirm it runs:
```bash
cd kinoform && python3 -m http.server 8000 # then open http://localhost:8000
```
Check the page renders, the images load from `assets/` with the network throttled or offline, and scrolling drives the animations.
The finished folder needs no network for its images — they are in `assets/`; only the motion libraries load from a CDN.
Do not rebuild the design from the description below. The description is there so a person can understand and edit the code. The appendix is the deliverable.
---
## ▶ WHAT YOU GET
Kinoform Pictures is an independent production company making commercials, short film and branded work from studios in Lisbon, Seoul, Mexico City, Berlin and Toronto.
**7 code files plus an `assets/` folder holding 20 images, delivered as `kinoform.zip`.**
| File | What it is |
|---|---|
| `index.html` | a page, 194 KB |
| `sections/01-hero.html` | a page, 34 KB |
| `sections/02-reel.html` | a page, 29 KB |
| `sections/03-canvas.html` | a page, 40 KB |
| `sections/04-directors.html` | a page, 41 KB |
| `sections/05-contact.html` | a page, 40 KB |
| `template.html` | a page, 7 KB |
| `assets/` | 20 images, downloaded at build time |
**Libraries, from a CDN, already referenced in the HTML:** `gsap.min.js`, `ScrollTrigger.min.js`, `lenis.min.js`. No npm install, no bundler, no framework.
**Sections (5):** `#top` · `#work` · `#process` · `#directors` · `#contact`.
---
## ▶ WHERE THE MEDIA COMES FROM
All 20 files (20 images) live in `assets/` inside the folder, and every reference in the markup, the CSS and the script is a relative `assets/…` path. Once the folder is assembled it owns its own media: no CDN, no media library, no external image host. Open it on a laptop with the wifi off and it still renders.
They are fetched **once, at build time**, from the WordPress media library on `demo.uichemy.com` — the same `kinoform__` files the WordPress build of this page uses. That is only how they travel into the folder; nothing in the delivered code points back at that host. Replacing an image means dropping a new file into `assets/` under the same name.
The manifest at the end of this document lists every source URL against the filename it is saved as, so the download step is reproducible and checkable.
---
## ▶ WHAT THIS PROMPT DELIBERATELY DOES NOT CONTAIN
- No WordPress, no page builder, and none of the wrapper classes or compatibility CSS a builder needs. The WordPress build is a separate prompt.
- No framework, no TypeScript, no bundler, no `package.json`.
- No backend. Any form here validates and replies in place; it posts nowhere.
- No CMS. The content lives in the files.
- No external image host. The images ship inside the zip; nothing in the code calls out to a media library at runtime.
---
## ASSET MANIFEST — download these into `assets/`
Source is the media library on `demo.uichemy.com`. Save each one under the name in the second column; the code already refers to it by that name.
| Download from | Save as |
|---|---|
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d1.jpg` | `assets/d1.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d2.jpg` | `assets/d2.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d3.jpg` | `assets/d3.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d4.jpg` | `assets/d4.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d5.jpg` | `assets/d5.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-d6.jpg` | `assets/d6.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f1.jpg` | `assets/f1.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f2.jpg` | `assets/f2.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f3.jpg` | `assets/f3.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f4.jpg` | `assets/f4.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f5.jpg` | `assets/f5.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f6.jpg` | `assets/f6.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f7.jpg` | `assets/f7.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-f8.jpg` | `assets/f8.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p1.jpg` | `assets/p1.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p2.jpg` | `assets/p2.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p3.jpg` | `assets/p3.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p4.jpg` | `assets/p4.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p5.jpg` | `assets/p5.jpg` |
| `https://demo.uichemy.com/wp-content/uploads/2026/09/kinoform__img-p6.jpg` | `assets/p6.jpg` |
---
# APPENDIX · VERBATIM SOURCE
The complete source of every file. **Write them out exactly** — do not change a value, a comment, a class name, or the order of a declaration. Recreate this file layout:
```
index.html
sections/01-hero.html
sections/02-reel.html
sections/03-canvas.html
sections/04-directors.html
sections/05-contact.html
template.html
```
## `index.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Kinoform Pictures — Commercials, Film & Branded Work</title>
<meta name="description" content="Kinoform Pictures is an independent production company making commercials, short film and branded work from studios in Lisbon, Seoul, Mexico City, Berlin and Toronto.">
<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=Archivo:wdth,[email protected],400..900&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
<style>
/* ---------- KINOFORM locked palette ---------- */
:root{
--bg:#F3F2F7; /* cool fog page */
--paper:#FFFFFF; /* cards / panels */
--rose:#D11E6B; /* primary (white text OK) */
--rose-deep:#A3124F; /* primary hover */
--rose-soft:#F8D9E6; /* primary tint */
--sky:#7CC7FF; /* accent (ink text only) */
--sky-deep:#4FAEF2; /* accent hover */
--sky-soft:#DDF0FF;
--ink:#1B1530; /* text only — never a page/section/card background */
--mute:#625C78; /* secondary text */
--line:#DCD9E6; /* borders */
--shadow:0 30px 60px -32px rgba(27,21,48,.45);
--shadow-rose:0 24px 50px -24px rgba(209,30,107,.55);
--display:'Archivo', system-ui, sans-serif; /* use font-stretch:125% for the wide display cut */
--mono:'Geist Mono', ui-monospace, monospace;
--gutter:clamp(16px, 4vw, 56px);
--ease:cubic-bezier(.2,.7,.1,1);
}
*,*::before,*::after{box-sizing:border-box}
html.lenis,html.lenis body{height:auto}
.lenis.lenis-smooth{scroll-behavior:auto !important}
.lenis.lenis-stopped{overflow:hidden}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--display);font-size:16px;line-height:1.55;overflow-x:hidden;-webkit-font-smoothing:antialiased}
img{display:block;max-width:100%}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
/* ---------- shared atoms ---------- */
.wrap{max-width:1440px;margin-inline:auto;padding-inline:var(--gutter)}
.sec{padding-block:clamp(80px, 11vw, 160px)}
.label{font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.label--rose{color:var(--rose)}
.display{font-family:var(--display);font-stretch:125%;font-weight:800;line-height:.92;letter-spacing:-.035em;margin:0}
.display em{font-style:normal;color:var(--rose)}
.lede{font-size:clamp(16px,1.25vw,19px);color:var(--mute);max-width:50ch}
.btn{display:inline-flex;align-items:center;gap:.6em;padding:14px 24px;border-radius:999px;font-weight:700;font-size:15px;
background:var(--rose);color:#fff;transition:background .25s var(--ease),transform .25s var(--ease)}
.btn:hover{background:var(--rose-deep);transform:translateY(-2px)}
.btn--sky{background:var(--sky);color:var(--ink)}
.btn--sky:hover{background:var(--sky-deep)}
.btn--ghost{background:transparent;color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--ink)}
.btn--ghost:hover{background:var(--rose);color:#fff;box-shadow:none}
.card{background:var(--paper);border-radius:18px;box-shadow:var(--shadow)}
:focus-visible{outline:2px solid var(--rose);outline-offset:3px}
/* KF.split masks */
.kf-m{display:inline-block;overflow:hidden;vertical-align:top;padding-bottom:.08em;margin-bottom:-.08em}
.kf-i{display:inline-block;will-change:transform}
.kf-w{display:inline-block;white-space:nowrap}
</style>
</head>
<body>
<script>
/* shared runtime: smooth scroll + helpers every section can use */
(function(){
gsap.registerPlugin(ScrollTrigger);
const reduce = matchMedia('(prefers-reduced-motion: reduce) and (update: none)').matches;
const KF = window.KF = { reduce };
if(!reduce && window.Lenis){
const lenis = KF.lenis = new Lenis({ lerp:.1 });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(t => lenis.raf(t*1000));
gsap.ticker.lagSmoothing(0);
}
let rt; KF.refresh = () => { clearTimeout(rt); rt = setTimeout(() => ScrollTrigger.refresh(), 120); };
KF.later = fn => (window.requestIdleCallback || (f => setTimeout(f, 60)))(fn);
/* shared motion language: expo.out / power3.out, 0.9–1.2s, 0.04–0.08 stagger */
KF.ease = { out:'expo.out', soft:'power3.out', io:'power2.inOut' };
/* KF.split(el, 'chars'|'words'|'lines') -> array of inner spans, each wrapped in an overflow mask.
Keeps <em>/<br> structure, sets aria-label so screen readers read the original text once. */
KF.split = function(el, mode){
mode = mode || 'words';
if(el._kfSplit) return el._kfSplit;
if(!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.textContent.replace(/\s+/g,' ').trim());
const out = [];
const mk = (txt, parent) => {
const m = document.createElement('span'); m.className = 'kf-m'; m.setAttribute('aria-hidden','true');
const i = document.createElement('span'); i.className = 'kf-i'; i.textContent = txt;
m.appendChild(i); parent.appendChild(m); out.push(i);
};
const walk = (node, parent) => {
[...node.childNodes].forEach(n => {
if(n.nodeType === 3){
const parts = n.textContent.split(/(\s+)/);
parts.forEach(p => {
if(!p) return;
if(/^\s+$/.test(p)){ parent.appendChild(document.createTextNode(' ')); return; }
if(mode === 'chars'){
const w = document.createElement('span'); w.className = 'kf-w'; w.setAttribute('aria-hidden','true');
[...p].forEach(ch => mk(ch, w)); parent.appendChild(w);
} else mk(p, parent);
});
} else if(n.nodeType === 1){
const c = n.cloneNode(false); parent.appendChild(c);
if(n.tagName !== 'BR') walk(n, c);
}
});
};
const frag = document.createDocumentFragment();
walk(el, frag); el.textContent = ''; el.appendChild(frag);
return (el._kfSplit = out);
};
/* KF.reveal(el, opts): split + rise-in when scrolled into view (once). Returns the tween. */
KF.reveal = function(el, o){
o = Object.assign({ mode:'words', start:'top 85%', stagger:null, delay:0 }, o);
const parts = KF.split(el, o.mode);
if(reduce) return null;
gsap.set(parts, { yPercent:115, rotate: o.mode === 'chars' ? 6 : 3 });
return gsap.to(parts, { yPercent:0, rotate:0, duration:1.1, ease:KF.ease.out, delay:o.delay,
stagger: o.stagger != null ? o.stagger : (o.mode === 'chars' ? .03 : .06),
scrollTrigger:{ trigger:el, start:o.start, once:true } });
};
/* KF.magnetic(el, strength): element leans toward the pointer, springs back on leave (mouse only). */
KF.magnetic = function(el, k){
k = k || .35;
if(reduce || !matchMedia('(hover:hover) and (pointer:fine)').matches) return;
const xT = gsap.quickTo(el, 'x', { duration:.6, ease:'power3.out' });
const yT = gsap.quickTo(el, 'y', { duration:.6, ease:'power3.out' });
el.addEventListener('pointermove', e => {
const r = el.getBoundingClientRect();
xT((e.clientX - r.left - r.width/2) * k); yT((e.clientY - r.top - r.height/2) * k);
});
el.addEventListener('pointerleave', () => { gsap.to(el, { x:0, y:0, duration:.9, ease:'elastic.out(1,.4)' }); });
};
/* every .btn on the page is magnetic */
addEventListener('DOMContentLoaded', () => document.querySelectorAll('.btn').forEach(b => KF.magnetic(b, .25)));
addEventListener('load', KF.refresh);
})();
</script>
<header class="kh-head" id="kh-head">
<div class="kh-bar">
<a class="kh-logo" href="#top" aria-label="Kinoform Pictures, back to top">
<span class="kh-word" aria-hidden="true"><span class="kh-m"><span class="kh-c">K</span></span><span class="kh-m"><span class="kh-c">i</span></span><span class="kh-m"><span class="kh-c">n</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">f</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">r</span></span><span class="kh-m"><span class="kh-c">m</span></span></span>
</a>
<nav class="kh-nav" aria-label="Primary">
<ul class="kh-links">
<li><a class="kh-link" href="#work">Work</a></li>
<li><a class="kh-link" href="#process">Process</a></li>
<li><a class="kh-link" href="#directors">Directors</a></li>
<li><a class="kh-link" href="#contact">Contact</a></li>
</ul>
<a class="kh-pill" href="#contact">Start a project</a>
</nav>
<button class="kh-toggle" type="button" aria-expanded="false" aria-controls="kh-panel" aria-label="Open menu">
<span class="kh-toggle-line"></span><span class="kh-toggle-line"></span>
</button>
</div>
<div class="kh-panel" id="kh-panel" inert>
<nav aria-label="Mobile">
<ol class="kh-plinks">
<li><a class="kh-plink" href="#work"><span class="kh-pnum">01</span>Work</a></li>
<li><a class="kh-plink" href="#process"><span class="kh-pnum">02</span>Process</a></li>
<li><a class="kh-plink" href="#directors"><span class="kh-pnum">03</span>Directors</a></li>
<li><a class="kh-plink" href="#contact"><span class="kh-pnum">04</span>Contact</a></li>
</ol>
</nav>
<div class="kh-pfoot">
<a class="btn kh-pbtn" href="#contact">Start a project</a>
<p class="kh-pmail">[email protected]<br>Lisbon · Seoul · Mexico City · Berlin · Toronto</p>
</div>
</div>
</header>
<section class="kh-hero" id="top" aria-labelledby="kh-title">
<div class="kh-atmo" aria-hidden="true">
<span class="kh-blob kh-blob--a"></span>
<span class="kh-blob kh-blob--b"></span>
<span class="kh-blob kh-blob--c"></span>
<span class="kh-grain"></span>
</div>
<div class="kh-top wrap">
<p class="kh-intro">Kinoform Pictures is an independent production company making commercials, film and branded work from Lisbon, Seoul, Mexico City, Berlin and Toronto.</p>
<p class="kh-meta label"><span class="kh-rec"><span class="kh-rec-dot" aria-hidden="true"></span>Rec</span> Reel <span class="kh-meta-rose">2026</span> · 5 studios</p>
</div>
<h1 class="kh-mark" id="kh-title">
<span class="kh-sr">Kinoform Pictures</span>
<span class="kh-word kh-giant" aria-hidden="true"><span class="kh-m"><span class="kh-c">K</span></span><span class="kh-m"><span class="kh-c">i</span></span><span class="kh-m"><span class="kh-c">n</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">f</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">r</span></span><span class="kh-m"><span class="kh-c">m</span></span></span>
</h1>
<div class="kh-wall" role="group" aria-label="Frames from recent Kinoform productions">
<div class="kh-floor" aria-hidden="true"></div>
<div class="kh-ring">
<figure class="kh-card"><img src="assets/f1.jpg" alt="Film crew rigging a cinema camera on set" width="400" height="600" loading="eager" fetchpriority="high" decoding="async"><figcaption class="kh-cap"><b>On set</b><span>Camera crew</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p2.jpg" alt="Still from Pink Hour, a fashion film for Maison Oriel" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Maison Oriel</b><span>Pink Hour · 2026</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f3.jpg" alt="Neon-lit street at night" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Neon night</b><span>Street frame</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p1.jpg" alt="Still from Long Way Round, a commercial for Halcyon Motors" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Halcyon Motors</b><span>Long Way Round · 2026</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f4.jpg" alt="Rider on horseback along a beach" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Shoreline</b><span>Horse & rider</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p3.jpg" alt="Still from The Climb, a commercial for Velo Norte" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Velo Norte</b><span>The Climb · 2025</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f2.jpg" alt="Clapperboard marked for the next take" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Next take</b><span>Slate & sound</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p5.jpg" alt="Still from The City Is a Stage, a commercial for Stride Athletic" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Stride Athletic</b><span>The City Is a Stage</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f5.jpg" alt="Pink flowers in soft daylight" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>In bloom</b><span>Colour study</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p4.jpg" alt="Still from Last Light, a branded short for Salt and Current" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Salt & Current</b><span>Last Light · 2025</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f6.jpg" alt="City skyline at dusk" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Blue hour</b><span>Skyline</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p6.jpg" alt="Still from Second Nature, a product film for Aurel Horology" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Aurel Horology</b><span>Second Nature · 2024</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f7.jpg" alt="Perfume bottle lit on a tabletop set" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Tabletop</b><span>Fragrance</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f8.jpg" alt="Sneakers styled on colour blocks" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Colour block</b><span>Sneaker study</span></figcaption></figure>
</div>
<button class="kh-badge" type="button" aria-label="Play the 2026 showreel: jump to the work">
<svg class="kh-bring" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs><path id="kh-bpath" d="M60,60 m-46,0 a46,46 0 1,1 92,0 a46,46 0 1,1 -92,0"/></defs>
<text class="kh-btext"><textPath href="#kh-bpath" textLength="286" lengthAdjust="spacing">SHOWREEL 2026 • PLAY • SHOWREEL 2026 • PLAY • </textPath></text>
</svg>
<span class="kh-bplay" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M8.5 5.8v12.4L18.6 12z"/></svg></span>
</button>
</div>
<div class="kh-cta">
<p class="kh-cta-copy">We make pictures that move people. Five studios, one crew, any format.</p>
<div class="kh-btns">
<a class="btn" href="#work">See the work</a>
<a class="btn btn--ghost" href="#directors">Meet the directors</a>
</div>
</div>
<div class="kh-marq">
<p class="kh-sr">We shoot commercials, fashion film, branded shorts, music video, product film and documentary.</p>
<div class="kh-mtrack" aria-hidden="true">
<div class="kh-mgroup">
<span class="kh-mi kh-mi--o">Commercials</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Fashion film</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi kh-mi--o">Branded shorts</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Music video</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi kh-mi--o">Product film</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Documentary</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
</div>
</div>
</div>
</section>
<style>
/* ---------- 01 hero + fixed header (kh-) ---------- */
.kh-head{position:fixed;top:0;left:0;right:0;z-index:50;height:64px;--kh-lo:0}
.kh-bar{position:relative;z-index:2;height:64px;display:flex;align-items:center;justify-content:space-between;gap:24px;
padding-inline:var(--gutter);transition:background-color .35s var(--ease),box-shadow .35s var(--ease)}
.kh-head.is-solid .kh-bar{background:rgba(243,242,247,.8);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:inset 0 -1px 0 var(--line)}
.kh-head.is-open .kh-bar{background:transparent;-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
.kh-word{display:inline-block;white-space:nowrap;line-height:1}
.kh-m{display:inline-block;vertical-align:top;margin-right:-.035em;clip-path:inset(-30% -30% 0 -30%)}
.kh-c{display:inline-block;will-change:transform}
.kh-c--o{color:var(--rose)}
.kh-logo{font-family:var(--display);font-stretch:125%;font-weight:800;font-size:20px;line-height:1;color:var(--ink);
opacity:var(--kh-lo);display:inline-flex;align-items:center;min-height:44px}
.kh-logo:focus-visible,.kh-head.is-open .kh-logo,.kh-head--static .kh-logo{opacity:1}
.kh-nav{display:flex;align-items:center;gap:clamp(18px,2.4vw,36px)}
.kh-links{display:flex;gap:clamp(16px,2.2vw,32px);list-style:none;margin:0;padding:0}
.kh-link{position:relative;font-family:var(--mono);font-size:13px;letter-spacing:.02em;color:var(--ink);padding:6px 0}
.kh-link::after{content:"";position:absolute;left:0;right:0;bottom:2px;height:1.5px;background:var(--rose);transform:scaleX(0);transform-origin:right;transition:transform .35s var(--ease)}
.kh-link:hover::after,.kh-link:focus-visible::after{transform:scaleX(1);transform-origin:left}
.kh-pill{display:inline-flex;align-items:center;padding:9px 16px;border-radius:999px;background:var(--rose);color:var(--paper);
font-size:13px;font-weight:700;box-shadow:var(--shadow-rose);transition:background .25s var(--ease),transform .25s var(--ease)}
.kh-pill:hover{background:var(--rose-deep);transform:translateY(-1px)}
.kh-toggle{display:none;width:44px;height:44px;border-radius:999px;position:relative;box-shadow:inset 0 0 0 1.5px var(--line);background:var(--paper)}
.kh-toggle-line{position:absolute;left:13px;right:13px;height:2px;border-radius:2px;background:var(--ink);transition:transform .35s var(--ease)}
.kh-toggle-line:first-child{top:18px}
.kh-toggle-line:last-child{top:24px}
.kh-head.is-open .kh-toggle-line:first-child{transform:translateY(3px) rotate(45deg)}
.kh-head.is-open .kh-toggle-line:last-child{transform:translateY(-3px) rotate(-45deg)}
.kh-panel{position:fixed;inset:0;z-index:1;background:var(--paper);padding:104px var(--gutter) 32px;display:flex;flex-direction:column;
justify-content:space-between;overflow-y:auto;visibility:hidden;opacity:0;transition:opacity .35s var(--ease),visibility 0s linear .35s}
.kh-head.is-open .kh-panel{visibility:visible;opacity:1;transition:opacity .35s var(--ease)}
.kh-plinks{list-style:none;margin:0;padding:0}
.kh-plinks li{border-bottom:1px solid var(--line)}
.kh-plink{display:flex;align-items:baseline;gap:14px;padding:14px 0;font-family:var(--display);font-stretch:125%;font-weight:800;
font-size:clamp(34px,10vw,48px);line-height:1;letter-spacing:-.035em;color:var(--ink)}
.kh-plink:hover{color:var(--rose)}
.kh-pnum{font-family:var(--mono);font-size:12px;font-weight:400;letter-spacing:.06em;color:var(--rose)}
.kh-pfoot{display:flex;flex-direction:column;gap:18px;padding-top:32px}
.kh-pbtn{align-self:flex-start}
.kh-pmail{margin:0;font-family:var(--mono);font-size:12px;line-height:1.7;color:var(--mute)}
/* hero */
.kh-hero{position:relative;min-height:100vh;min-height:100svh;padding-top:64px;display:flex;flex-direction:column;background:var(--bg);isolation:isolate}
.kh-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* atmosphere: drifting colour glows + film grain, all on the light fog */
.kh-atmo{position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none}
.kh-blob{position:absolute;border-radius:50%;will-change:transform}
.kh-blob--a{width:62vw;height:62vw;left:-14vw;top:18%;background:radial-gradient(closest-side,var(--rose-soft),rgba(248,217,230,.55) 45%,rgba(248,217,230,0))}
.kh-blob--b{width:66vw;height:66vw;right:-18vw;top:4%;background:radial-gradient(closest-side,var(--sky-soft),rgba(221,240,255,.6) 45%,rgba(221,240,255,0))}
.kh-blob--c{width:40vw;height:40vw;left:34vw;top:46%;background:radial-gradient(closest-side,rgba(209,30,107,.13),rgba(209,30,107,0))}
.kh-grain{position:absolute;inset:-80px;opacity:.32;mix-blend-mode:multiply;will-change:transform;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .106 0 0 0 0 .082 0 0 0 0 .188 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");background-size:180px 180px}
.kh-top{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;gap:24px;width:100%;padding-top:clamp(18px,3vw,40px)}
.kh-intro{margin:0;max-width:min(700px,62vw);font-size:clamp(15px,1.2vw,18px);line-height:1.45;font-weight:500;color:var(--ink)}
.kh-meta{margin:4px 0 0;white-space:nowrap;display:flex;align-items:center;gap:.5em}
.kh-meta-rose{color:var(--rose)}
.kh-rec{display:inline-flex;align-items:center;gap:6px;padding:3px 8px 3px 7px;border-radius:999px;background:var(--paper);color:var(--rose);box-shadow:inset 0 0 0 1px var(--rose-soft)}
.kh-rec-dot{width:7px;height:7px;border-radius:50%;background:var(--rose);box-shadow:0 0 0 3px rgba(209,30,107,.18)}
.kh-mark{position:relative;z-index:3;margin:clamp(10px,2vh,30px) 0 0;text-align:center;font-family:var(--display);font-stretch:125%;
font-weight:800;font-size:15.4vw;line-height:1;color:var(--ink);pointer-events:none}
.kh-giant{transform-origin:0 0;will-change:transform}
/* the curved wall: .kh-wall owns perspective, .kh-ring is the 3D stage that tilts as one */
.kh-wall{--kh-cw:clamp(118px,11.5vw,200px);--kh-ch:calc(var(--kh-cw) * 1.5);--kh-bs:128px;position:relative;z-index:1;flex:1 0 auto;
min-height:calc(var(--kh-ch) * 1.72);margin-top:calc(var(--kh-ch) * -.22);overflow:hidden;perspective:1000px}
@supports (overflow:clip){.kh-wall{overflow:visible;overflow-x:clip}}
.kh-ring{position:absolute;left:0;right:0;top:calc(50% - var(--kh-ch) * .86);height:calc(var(--kh-ch) * 1.5);transform-style:preserve-3d;will-change:transform}
.kh-card{position:absolute;left:50%;top:50%;width:var(--kh-cw);height:var(--kh-ch);margin:calc(var(--kh-ch) / -2) 0 0 calc(var(--kh-cw) / -2);
border-radius:14px;overflow:hidden;background:var(--paper);box-shadow:0 28px 44px -26px rgba(27,21,48,.45);
-webkit-box-reflect:below 5px linear-gradient(to bottom,rgba(27,21,48,0) 62%,rgba(27,21,48,.26));
-webkit-backface-visibility:hidden;backface-visibility:hidden;will-change:transform,opacity;opacity:0}
.kh-card img{width:100%;height:100%;object-fit:cover;transform-origin:50% 60%}
.kh-cap{position:absolute;left:8px;right:8px;bottom:8px;margin:0;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,.9);
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:0 10px 24px -14px rgba(27,21,48,.5);
display:flex;flex-direction:column;gap:2px;opacity:0;visibility:hidden;pointer-events:none}
.kh-cap b{font-size:12px;line-height:1.15;font-weight:800;color:var(--ink);letter-spacing:-.01em}
.kh-cap span{font-family:var(--mono);font-size:10px;line-height:1.3;letter-spacing:.03em;color:var(--rose)}
.kh-card.is-cap .kh-cap{opacity:1;visibility:visible}
/* glossy floor the frames stand on */
.kh-floor{position:absolute;left:0;right:0;top:calc(50% + var(--kh-ch) * .26);height:calc(var(--kh-ch) * .75);pointer-events:none;
background:radial-gradient(52% 100% at 50% 0,rgba(255,255,255,.85),rgba(255,255,255,0) 72%)}
.kh-floor::before{content:"";position:absolute;left:8%;right:8%;top:0;height:1px;background:linear-gradient(90deg,rgba(220,217,230,0),var(--line) 30%,var(--line) 70%,rgba(220,217,230,0))}
/* rotating showreel badge */
.kh-badge{position:absolute;z-index:5;width:var(--kh-bs);height:var(--kh-bs);left:calc(50% + var(--kh-cw) * .7);top:calc(50% - var(--kh-ch) * .11 - var(--kh-bs) * .9);
border-radius:50%;background:rgba(255,255,255,.78);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
box-shadow:var(--shadow),inset 0 0 0 1px rgba(255,255,255,.9)}
.kh-bring{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.kh-btext{font-family:var(--mono);font-size:9.6px;font-weight:500;fill:var(--ink);letter-spacing:.06em}
.kh-bplay{position:absolute;inset:31%;border-radius:50%;background:var(--rose);box-shadow:var(--shadow-rose);display:grid;place-items:center;transition:background .25s var(--ease)}
.kh-bplay svg{width:42%;height:42%;fill:var(--paper)}
.kh-badge:hover .kh-bplay{background:var(--rose-deep)}
.kh-badge:focus-visible{outline-offset:5px}
.kh-cta{position:relative;z-index:2;text-align:center;padding:clamp(8px,1.4vw,20px) var(--gutter) clamp(22px,3vw,40px)}
.kh-cta-copy{margin:0 auto 18px;max-width:42ch;font-size:clamp(15px,1.1vw,17px);color:var(--mute)}
.kh-btns{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}
/* discipline marquee */
.kh-marq{position:relative;z-index:2;overflow:hidden;border-block:1px solid var(--line);padding-block:clamp(10px,1.2vw,18px);background:rgba(255,255,255,.35)}
.kh-mtrack{display:flex;width:max-content;will-change:transform}
.kh-mgroup{display:flex;align-items:center;flex:none}
.kh-mi{font-family:var(--display);font-stretch:125%;font-weight:800;font-size:clamp(34px,5.2vw,86px);line-height:1;letter-spacing:-.035em;
white-space:nowrap;color:var(--rose);padding-inline:.28em}
.kh-mi--o{color:transparent;-webkit-text-stroke:1.5px var(--ink)}
.kh-star{flex:none;width:clamp(18px,2.2vw,34px);height:clamp(18px,2.2vw,34px);fill:var(--rose);margin-inline:.35em}
@media (max-width:767px){
.kh-nav{display:none}
.kh-toggle{display:block}
.kh-meta{display:none}
.kh-intro{max-width:none;font-size:15px}
.kh-mark{margin-top:28px}
.kh-wall{--kh-cw:27vw;--kh-bs:80px;min-height:calc(var(--kh-ch) * 1.62);margin-top:-6px}
.kh-badge{left:auto;right:10px;top:calc(50% - var(--kh-ch) * .11 - var(--kh-bs) * 1.25)}
.kh-btext{font-size:10px}
.kh-card:nth-child(n+9){display:none}
.kh-btns .btn{padding:13px 20px}
.kh-mi--o{-webkit-text-stroke-width:1.2px}
.kh-cap{left:5px;right:5px;bottom:5px;padding:6px 7px;border-radius:8px}
.kh-cap b{font-size:11px}
.kh-cap span{font-size:9px;letter-spacing:0}
}
@media (min-width:768px){ .kh-panel{display:none} }
@media (min-width:1024px){
.kh-cta{display:flex;align-items:center;justify-content:center;gap:28px;margin-top:calc(var(--kh-ch) * -.3)}
.kh-cta-copy{margin:0;max-width:34ch;text-align:left}
}
@media (prefers-reduced-motion: reduce) and (update: none){
.kh-head .kh-logo{opacity:1}
.kh-bar,.kh-panel,.kh-link::after,.kh-pill,.kh-toggle-line,.kh-bplay{transition:none}
.kh-card{opacity:1}
}
</style>
<script>
(function(){
const head = document.getElementById('kh-head');
const hero = document.getElementById('top');
if(!head || !hero) return;
const KF = window.KF || {};
const reduce = !!KF.reduce;
const EASE = KF.ease || { out:'expo.out', soft:'power3.out', io:'power2.inOut' };
const mqMobile = matchMedia('(max-width: 767px)');
const toggle = head.querySelector('.kh-toggle');
const panel = document.getElementById('kh-panel');
const logoWord = head.querySelector('.kh-logo .kh-word');
const mark = hero.querySelector('.kh-mark');
const giant = hero.querySelector('.kh-giant');
const top = hero.querySelector('.kh-top');
const intro = hero.querySelector('.kh-intro');
const wall = hero.querySelector('.kh-wall');
const ring = wall.querySelector('.kh-ring');
const badge = wall.querySelector('.kh-badge');
const cards = Array.from(ring.querySelectorAll('.kh-card'));
const mtrack = hero.querySelector('.kh-mtrack');
const mgroup = mtrack.querySelector('.kh-mgroup');
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
if(reduce) head.classList.add('kh-head--static');
/* ---------- mobile menu ---------- */
let menuOpen = false;
function setMenu(v, restoreFocus){
if(v === menuOpen) return;
menuOpen = v;
head.classList.toggle('is-open', v);
toggle.setAttribute('aria-expanded', String(v));
toggle.setAttribute('aria-label', v ? 'Close menu' : 'Open menu');
panel.inert = !v;
document.documentElement.style.overflow = v ? 'hidden' : '';
if(KF.lenis) v ? KF.lenis.stop() : KF.lenis.start();
if(v){ const f = panel.querySelector('a'); f && f.focus({ preventScroll:true }); }
else if(restoreFocus) toggle.focus({ preventScroll:true });
}
toggle.addEventListener('click', () => setMenu(!menuOpen, true));
document.addEventListener('keydown', e => { if(e.key === 'Escape' && menuOpen) setMenu(false, true); });
mqMobile.addEventListener('change', () => { if(!mqMobile.matches) setMenu(false, false); });
/* in-page links: close the menu, then glide with Lenis when it is running */
function goTo(id){
const target = id === '#top' ? hero : document.querySelector(id);
if(!target) return;
if(KF.lenis) KF.lenis.scrollTo(target, { duration:1.2 });
else target.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
history.replaceState(null, '', id);
}
head.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', onAnchor));
hero.querySelectorAll('.kh-cta a[href^="#"]').forEach(a => a.addEventListener('click', onAnchor));
function onAnchor(e){
const id = this.getAttribute('href');
if(menuOpen) setMenu(false, false);
if(KF.lenis && (id === '#top' || document.querySelector(id))){ e.preventDefault(); goTo(id); }
}
badge.addEventListener('click', () => goTo('#work'));
/* ---------- giant wordmark -> header logo ---------- */
let G = { x:0, y:0, w:1 }, L = { x:0, y:0, w:1 }, S = innerHeight * .6, wallReady = false;
function fit(){
mark.style.fontSize = '100px';
const w = giant.offsetWidth || 1;
mark.style.fontSize = (100 * document.documentElement.clientWidth * .92 / w).toFixed(3) + 'px';
}
function measure(){
const prev = giant.style.transform;
giant.style.transform = 'none';
const r = giant.getBoundingClientRect();
G = { x:r.left, y:r.top + window.scrollY, w:r.width || 1 };
const l = logoWord.getBoundingClientRect();
L = { x:l.left, y:l.top, w:l.width || 1 };
S = innerHeight * .6;
giant.style.transform = prev;
}
function onScroll(y){
head.classList.toggle('is-solid', reduce ? y > 8 : y > S * .98);
if(reduce) return;
const p = clamp(y / S), e = easeInOut(p);
const s = lerp(1, L.w / G.w, e);
const tx = lerp(G.x, L.x, e) - G.x;
const ty = lerp(G.y, L.y, e) - (G.y - y);
giant.style.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) scale(${s.toFixed(4)})`;
const hand = clamp((p - .9) / .1);
giant.style.opacity = String(1 - hand);
giant.style.visibility = p >= 1 ? 'hidden' : '';
head.style.setProperty('--kh-lo', hand.toFixed(3));
top.style.opacity = String(clamp(1 - p * 1.8));
}
fit(); measure(); onScroll(window.scrollY);
ScrollTrigger.addEventListener('refreshInit', () => { fit(); measure(); });
ScrollTrigger.addEventListener('refresh', () => { measure(); onScroll(window.scrollY); if(wallReady) layout(); });
const heroST = ScrollTrigger.create({ trigger:hero, start:'top top', end:'bottom top', onUpdate:self => onScroll(self.scroll()) });
if(document.fonts && document.fonts.ready) document.fonts.ready.then(() => KF.refresh && KF.refresh());
/* ---------- curved wall of frames (inside of a cylinder) ---------- */
// st.slow: drift multiplier (hover eases it to .15) · st.kick: smoothed scroll velocity -1..1
// st.open: scrubbed hero progress, bends the cylinder · st.dim: fades the frames you are not looking at
const st = { base:0, ptr:0, pty:0, open:0, kick:0, slow:1, dim:0 };
const DRIFT = 8; // degrees per second
cards.forEach(c => {
c._s = { in: reduce ? 1 : 0, h:0 };
c._img = c.querySelector('img');
c._cap = c.querySelector('.kh-cap');
c._v = -1;
});
let active = [], W = 1, H = 1, P = 800, R = 400, D = 200, CW = 150, spc = 18, period = 252, mobile = false, GW = 1;
const slotOf = i => i === 0 ? 0 : (i % 2 ? (i + 1) / 2 : -i / 2); // 0, +1, -1, +2, -2 ... first DOM cards sit mid-wall
const RAD = Math.PI / 180;
function layout(){
mobile = mqMobile.matches;
active = cards.slice(0, mobile ? 8 : 14);
W = wall.clientWidth || innerWidth;
H = innerHeight;
CW = active[0].offsetWidth || 150;
P = W * (mobile ? .62 : .55);
R = W * (mobile ? .42 : .437);
D = W * (mobile ? .21 : .223);
wall.style.perspective = P.toFixed(1) + 'px';
spc = Math.max((CW * 1.16 / R) / RAD, 200 / active.length);
period = spc * active.length;
GW = mgroup.offsetWidth || 1;
render();
}
function wrap(a){ a = (a + period / 2) % period; if(a < 0) a += period; return a - period / 2; }
function render(){
const curve = 1 + .5 * st.open; // scroll bends the wall into more of a cylinder
const Re = R / curve, De = D - R + Re; // keep the centre frame at the same depth
const half = W / 2;
for(let i = 0; i < active.length; i++){
const c = active[i], s = c._s;
const a0 = wrap(slotOf(i) * spc + st.base);
const a = (a0 + st.ptr * 8) * curve;
if(Math.abs(a) > 88 || s.in <= 0){ if(c._v !== 0){ c.style.visibility = 'hidden'; c._v = 0; } continue; }
if(c._v !== 1){ c.style.visibility = ''; c._v = 1; }
const r = a * RAD, inv = 1 - s.in;
const x = Re * Math.sin(r);
const z = De - Re * Math.cos(r) + s.h * W * .07 - inv * W * 2.4;
const sp = P / (P - z);
const xs = Math.abs(x * sp);
let o = clamp((half + CW * .45 * sp - xs) / (CW * .7 * sp)) * clamp((88 - Math.abs(a)) / 12);
o *= clamp(s.in * 2.2) * (1 - .42 * st.dim * (1 - s.h));
const ry = -a + inv * (a0 < 0 ? -150 : 150);
c.style.opacity = o.toFixed(3);
c.style.transform = `translate3d(${x.toFixed(2)}px,${(inv * -H * .12).toFixed(2)}px,${z.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg) scale(${(1 + .08 * s.h).toFixed(4)})`;
}
const rx = st.pty * -5 + st.kick * 7;
ring.style.transform = `translate3d(0,${(-st.open * H * .1).toFixed(2)}px,${(st.open * W * .05).toFixed(2)}px) rotateX(${rx.toFixed(3)}deg) skewX(${(st.kick * -3).toFixed(3)}deg)`;
}
/* marquee position lives in the same ticker (no extra loop) */
let mx = 0, mdir = 1;
const mSet = gsap.quickSetter(mtrack, 'x', 'px');
const kickTo = gsap.quickTo(st, 'kick', { duration:.9, ease:'power3.out' });
const velocity = () => KF.lenis ? (KF.lenis.velocity || 0) * 60 : heroST.getVelocity();
function tick(time, dtMs){
const dt = Math.min(dtMs || 16, 50) / 1000;
kickTo(clamp(velocity() / 2400, -1, 1));
st.base += dt * (DRIFT * st.slow + st.kick * 55);
st.base = ((st.base % period) + period) % period;
if(Math.abs(st.kick) > .04) mdir = st.kick > 0 ? 1 : -1;
mx -= dt * mdir * (GW * .02 + Math.abs(st.kick) * GW * .5);
mx = -((((-mx) % GW) + GW) % GW);
mSet(mx);
render();
}
/* hover / tap a frame: drift slows, frame steps forward, caption chip slides up */
let hovered = null, running = false, lastPT = 'mouse';
const idle = () => { if(!running) render(); };
const T = v => reduce ? 0 : v;
gsap.set(cards.map(c => c._cap), { yPercent:130 });
function focusCard(c, on){
c.classList.toggle('is-cap', on);
gsap.to(c._s, { h:on ? 1 : 0, duration:T(on ? .8 : .6), ease:on ? EASE.out : EASE.soft, overwrite:true, onUpdate:idle });
gsap.to(c._img, { scale:on ? 1.1 : 1, duration:T(1), ease:EASE.soft, overwrite:true });
gsap.to(c._cap, { yPercent:on ? 0 : 130, autoAlpha:on ? 1 : 0, duration:T(on ? .6 : .3), delay:T(on ? .06 : 0), ease:on ? EASE.out : 'power2.in', overwrite:true });
}
function setHover(c){
if(c === hovered) return;
if(hovered) focusCard(hovered, false);
hovered = c;
if(c) focusCard(c, true);
gsap.to(st, { slow:c ? .15 : 1, dim:c ? 1 : 0, duration:T(c ? .6 : 1.2), ease:'power2.out', overwrite:'auto', onUpdate:idle });
}
document.addEventListener('pointerdown', e => { lastPT = e.pointerType; }, { passive:true });
wall.addEventListener('pointerover', e => { if(e.pointerType === 'mouse') setHover(e.target.closest('.kh-card')); });
wall.addEventListener('pointerleave', e => { if(e.pointerType === 'mouse') setHover(null); });
document.addEventListener('click', e => {
if(lastPT === 'mouse') return;
const c = e.target.closest ? e.target.closest('.kh-card') : null;
setHover(c && c !== hovered && wall.contains(c) ? c : null);
});
let rt;
addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(layout, 100); });
mqMobile.addEventListener('change', () => { cards.forEach(c => { c._v = -1; c.style.visibility = ''; }); setHover(null); layout(); });
wallReady = true;
layout();
if(reduce) return; // static wall, static marquee + badge, big wordmark, visible header logo
/* pointer: x turns the ring, y tilts it (mouse only) */
const ptrTo = gsap.quickTo(st, 'ptr', { duration:1.4, ease:EASE.soft });
const ptyTo = gsap.quickTo(st, 'pty', { duration:1.4, ease:EASE.soft });
hero.addEventListener('pointermove', e => {
if(mobile || e.pointerType !== 'mouse') return;
ptrTo(clamp((e.clientX / innerWidth - .5) * 2, -1, 1));
ptyTo(clamp((e.clientY / innerHeight - .5) * 2, -1, 1));
});
hero.addEventListener('pointerleave', () => { ptrTo(0); ptyTo(0); });
/* scrubbed: the wall curls tighter, rises and pushes in while the wordmark flies to the logo */
gsap.to(st, { open:1, ease:'none', scrollTrigger:{ trigger:hero, start:'top top', end:'bottom top', scrub:.7 } });
/* ambient loops, paused together with the wall when the hero is offscreen */
const loops = [];
const drift = [[9, 6, 1.12, 13], [-8, 7, .9, 16], [12, -9, 1.25, 11]];
hero.querySelectorAll('.kh-blob').forEach((b, i) => {
const d = drift[i];
loops.push(gsap.to(b, { xPercent:d[0], yPercent:d[1], scale:d[2], duration:d[3], ease:'sine.inOut', yoyo:true, repeat:-1 }));
});
loops.push(gsap.fromTo(hero.querySelector('.kh-grain'), { x:0, y:0 }, { x:-62, y:41, duration:.9, ease:'steps(7)', repeat:-1 }));
loops.push(gsap.to(hero.querySelector('.kh-rec-dot'), { opacity:.15, duration:.55, ease:'steps(1)', yoyo:true, repeat:-1 }));
const spin = gsap.to(badge.querySelector('.kh-bring'), { rotation:360, duration:14, ease:'none', repeat:-1, transformOrigin:'50% 50%' });
loops.push(spin);
badge.addEventListener('pointerenter', () => gsap.to(spin, { timeScale:4, duration:.6, ease:EASE.soft, overwrite:true }));
badge.addEventListener('pointerleave', () => gsap.to(spin, { timeScale:1, duration:1.2, ease:EASE.soft, overwrite:true }));
badge.addEventListener('focus', () => gsap.to(spin, { timeScale:3, duration:.6, overwrite:true }));
badge.addEventListener('blur', () => gsap.to(spin, { timeScale:1, duration:1, overwrite:true }));
if(KF.magnetic) KF.magnetic(badge, .4);
/* seamless marquee: a second copy of the group trails the first */
mtrack.appendChild(mgroup.cloneNode(true));
const run = on => {
if(on === running) return;
running = on;
on ? gsap.ticker.add(tick) : gsap.ticker.remove(tick);
loops.forEach(t => on ? t.resume() : t.pause());
};
if('IntersectionObserver' in window){
new IntersectionObserver(es => run(es[0].isIntersecting), { rootMargin:'80px 0px' }).observe(hero);
} else run(true);
/* ---------- load intro ---------- */
const chars = giant.querySelectorAll('.kh-c');
gsap.set(chars, { yPercent:110 });
const words = KF.split ? KF.split(intro, 'words') : [];
const lineIx = [];
let lastTop = null, line = -1;
words.forEach(w => { const t = w.parentNode.offsetTop; if(t !== lastTop){ line++; lastTop = t; } lineIx.push(line); });
gsap.set(words, { yPercent:115, rotate:3 });
render();
gsap.timeline({ delay:.1 })
.to(chars, { yPercent:0, duration:1.15, ease:EASE.out, stagger:.055 }, 0)
.to(cards.map(c => c._s), { in:1, duration:1.7, ease:EASE.out, stagger:.07, onUpdate:idle }, .1)
.to(words, { yPercent:0, rotate:0, duration:1.1, ease:EASE.out, stagger:i => lineIx[i] * .1 + (i % 14) * .005 }, .25)
.from(hero.querySelector('.kh-meta'), { y:12, autoAlpha:0, duration:.9, ease:EASE.soft }, .45)
.from(badge, { scale:0, rotation:-120, duration:1.2, ease:'back.out(1.6)' }, .9)
.from([hero.querySelector('.kh-cta-copy'), ...hero.querySelectorAll('.kh-btns .btn')],
{ y:16, autoAlpha:0, duration:.9, ease:EASE.soft, stagger:.07 }, .6)
.from(mtrack, { yPercent:110, duration:1.2, ease:EASE.out }, .75);
})();
</script>
<section class="kr" id="work" aria-labelledby="kr-heading">
<div class="wrap kr-intro">
<div class="kr-intro__main">
<p class="label label--rose kr-intro__label">Selected work 2024–26</p>
<h2 class="display kr-intro__title" id="kr-heading">Six films,<br><em>one long exposure.</em></h2>
</div>
<div class="kr-intro__side">
<p class="kr-intro__count" aria-label="Six projects">06</p>
<p class="kr-intro__note">Scroll through the stack. Open any frame for the full credits.</p>
</div>
</div>
<div class="kr-stack">
<article class="kr-panel" data-i="0">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p1.jpg" alt="A lone car on an empty coastal road at golden hour" loading="lazy" width="1880" height="1255"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">01 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Halcyon Motors</span><span class="kr-title">Long Way Round</span></h3>
<p class="kr-info"><span>dir. Tomás Ferreira</span><span>2026 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Long Way Round for Halcyon Motors"></button>
</div>
</article>
<article class="kr-panel" data-i="1">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p2.jpg" alt="Model in soft pink evening light for a fashion film" loading="lazy" width="1880" height="1057"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">02 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Maison Oriel</span><span class="kr-title">Pink Hour</span></h3>
<p class="kr-info"><span>dir. Ada Kwon</span><span>2026 · Fashion film</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Pink Hour for Maison Oriel"></button>
</div>
</article>
<article class="kr-panel" data-i="2">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p3.jpg" alt="Cyclist climbing a steep mountain road" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">03 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Velo Norte</span><span class="kr-title">The Climb</span></h3>
<p class="kr-info"><span>dir. Jun Park</span><span>2025 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: The Climb for Velo Norte"></button>
</div>
</article>
<article class="kr-panel" data-i="3">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p4.jpg" alt="The sea at dusk with the last light on the water" loading="lazy" width="1625" height="1300"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">04 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Salt & Current</span><span class="kr-title">Last Light</span></h3>
<p class="kr-info"><span>dir. Noor Haddad</span><span>2025 · Branded short</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Last Light for Salt and Current"></button>
</div>
</article>
<article class="kr-panel" data-i="4">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p5.jpg" alt="Runner mid-stride on a city street" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">05 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Stride Athletic</span><span class="kr-title">The City Is a Stage</span></h3>
<p class="kr-info"><span>dir. Lena Brandt</span><span>2025 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: The City Is a Stage for Stride Athletic"></button>
</div>
</article>
<article class="kr-panel" data-i="5">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p6.jpg" alt="Close-up of a mechanical wristwatch" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">06 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Aurel Horology</span><span class="kr-title">Second Nature</span></h3>
<p class="kr-info"><span>dir. Walter Imrie</span><span>2024 · Product film</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Second Nature for Aurel Horology"></button>
</div>
</article>
</div>
<div class="kr-prog" aria-hidden="true">
<div class="kr-prog__ticks">
<span class="kr-prog__tick is-on"></span><span class="kr-prog__tick"></span><span class="kr-prog__tick"></span>
<span class="kr-prog__tick"></span><span class="kr-prog__tick"></span><span class="kr-prog__tick"></span>
</div>
<span class="kr-prog__num">01</span>
</div>
<div class="kr-ring" aria-hidden="true"></div>
<div class="kr-cursor" aria-hidden="true"><span class="kr-cursor__dot"><span class="kr-cursor__txt">View</span></span></div>
<div class="kr-modal" id="kr-modal" role="dialog" aria-modal="true" aria-labelledby="kr-m-title" aria-hidden="true">
<div class="kr-modal__backdrop" data-kr-close></div>
<div class="kr-dialog" data-lenis-prevent>
<button class="kr-close" type="button" data-kr-close aria-label="Close project details">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 5l14 14M19 5L5 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<figure class="kr-dialog__still"><img id="kr-m-img" src="" alt=""></figure>
<div class="kr-dialog__body">
<p class="label label--rose" id="kr-m-index"></p>
<p class="kr-dialog__client" id="kr-m-client"></p>
<h3 class="kr-dialog__title" id="kr-m-title"></h3>
<dl class="kr-facts">
<div><dt class="label">Director</dt><dd id="kr-m-dir"></dd></div>
<div><dt class="label">Year</dt><dd id="kr-m-year"></dd></div>
<div><dt class="label">Category</dt><dd id="kr-m-cat"></dd></div>
</dl>
<p class="kr-dialog__syn" id="kr-m-syn"></p>
<p class="label kr-credits__head">Credits</p>
<dl class="kr-credits" id="kr-m-credits"></dl>
</div>
</div>
</div>
</section>
<style>
.kr{background:var(--bg);position:relative;z-index:1}
.kr-intro{display:flex;align-items:flex-end;justify-content:space-between;gap:32px;
padding-top:clamp(80px,11vw,160px);padding-bottom:clamp(40px,5vw,72px)}
.kr-intro__label{margin:0 0 20px}
.kr-intro__title{font-size:clamp(44px,7.4vw,120px);color:var(--ink)}
.kr-intro__side{display:flex;flex-direction:column;align-items:flex-end;gap:10px;text-align:right;flex:none}
.kr-intro__count{margin:0;font-family:var(--display);font-stretch:125%;font-weight:800;font-size:clamp(56px,8vw,128px);
line-height:.85;letter-spacing:-.04em;color:var(--rose)}
.kr-intro__note{margin:0;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--mute);max-width:26ch}
/* ---- sticky stack ---- */
.kr-stack{position:relative}
.kr-panel{position:sticky;top:0;height:100vh;height:100svh;width:100%}
.kr-card{position:absolute;inset:0;overflow:hidden;transform-origin:50% 40%;will-change:transform;border-radius:0}
.kr-frame{position:absolute;inset:0;overflow:hidden}
.kr-media{position:absolute;inset:-6% 0;will-change:transform}
.kr-media img{width:100%;height:100%;object-fit:cover;transform-origin:50% 70%}
.kr-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.kr-scrim{position:absolute;inset:0;pointer-events:none;
background:linear-gradient(to top,rgba(27,21,48,.78) 0%,rgba(27,21,48,.42) 30%,rgba(27,21,48,0) 58%),
linear-gradient(to bottom,rgba(27,21,48,.35) 0%,rgba(27,21,48,0) 18%)}
.kr-dim{position:absolute;inset:0;background:rgba(27,21,48,1);opacity:0;pointer-events:none}
.kr-index{position:absolute;top:calc(64px + 20px);left:var(--gutter);margin:0;font-family:var(--mono);font-size:12px;
letter-spacing:.1em;color:#fff}
.kr-meta{position:absolute;left:var(--gutter);right:var(--gutter);bottom:clamp(24px,5vw,64px);
display:flex;align-items:flex-end;justify-content:space-between;gap:24px;color:#fff;pointer-events:none}
.kr-name{margin:0;display:flex;flex-direction:column;gap:clamp(6px,1vw,14px);min-width:0}
.kr-client{font-family:var(--display);font-weight:500;font-size:clamp(17px,1.8vw,26px);letter-spacing:-.01em;line-height:1.1}
.kr-title{font-family:var(--display);font-stretch:125%;font-weight:700;font-size:clamp(40px,7.6vw,110px);
line-height:.9;letter-spacing:-.035em;max-width:14ch;text-wrap:balance}
.kr-info{margin:0;display:flex;flex-direction:column;align-items:flex-end;gap:6px;text-align:right;flex:none;
font-family:var(--mono);font-size:13px;letter-spacing:.04em;color:#fff;opacity:.9}
.kr-hit{position:absolute;inset:0;width:100%;height:100%;z-index:2;border-radius:inherit}
.kr-hit:focus-visible{outline:3px solid var(--rose);outline-offset:-10px}
/* ---- custom cursor (enabled by JS on fine pointers) ---- */
.kr-cursor{position:fixed;left:0;top:0;width:96px;height:96px;margin:-48px 0 0 -48px;z-index:45;pointer-events:none;
display:none;will-change:transform}
.kr.kr-has-cursor .kr-cursor{display:block}
.kr.kr-has-cursor .kr-hit{cursor:none}
.kr-cursor__dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--rose);color:#fff;
font-family:var(--mono);font-size:13px;letter-spacing:.08em;text-transform:uppercase;box-shadow:var(--shadow-rose);
transform:scale(0);opacity:0}
.kr-cursor__txt{display:inline-block;will-change:transform}
/* trailing ring: sits under the dot, only shows while the pointer is moving */
.kr-ring{position:fixed;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;z-index:44;pointer-events:none;
border-radius:50%;box-shadow:inset 0 0 0 1.5px #fff,0 0 0 1px rgba(27,21,48,.14);display:none;opacity:0;will-change:transform}
.kr.kr-has-cursor .kr-ring{display:block}
/* ---- reel progress (fixed, desktop only) ---- */
.kr-prog{position:fixed;left:clamp(14px,1.5vw,26px);top:0;bottom:0;margin-block:auto;height:fit-content;z-index:44;
display:flex;flex-direction:column;align-items:center;gap:14px;pointer-events:none;visibility:hidden;opacity:0}
.kr-prog__ticks{display:flex;flex-direction:column;align-items:center;gap:11px}
.kr-prog__tick{display:block;width:3px;height:12px;border-radius:2px;background:rgba(255,255,255,.62);
box-shadow:0 0 0 1px rgba(27,21,48,.16);transform-origin:50% 50%;
transition:transform .55s var(--ease),background-color .3s var(--ease)}
.kr-prog__tick.is-on{background:var(--rose);transform:scaleY(1.9)}
.kr-prog__num{font-family:var(--mono);font-size:11px;letter-spacing:.08em;color:#fff;text-shadow:0 1px 6px rgba(27,21,48,.55)}
/* ---- modal (animated by a GSAP timeline) ---- */
.kr-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:clamp(16px,3vw,40px);visibility:hidden}
.kr-modal.is-open{visibility:visible}
.kr-modal__backdrop{position:absolute;inset:0;background:rgba(27,21,48,.35);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.kr-dialog{position:relative;width:min(1180px,100%);max-height:calc(100svh - 2*clamp(16px,3vw,40px));overflow:auto;overscroll-behavior:contain;
background:var(--paper);border-radius:24px;box-shadow:var(--shadow);display:grid;grid-template-columns:1.25fr 1fr}
.kr-dialog__still{margin:0;min-height:100%;background:var(--rose-soft);overflow:hidden}
.kr-dialog__still img{width:100%;height:100%;min-height:320px;object-fit:cover}
.kr-dialog__body{padding:clamp(24px,3.4vw,48px);display:flex;flex-direction:column;gap:14px}
.kr-dialog__body > .label{margin:0}
.kr-dialog__client{margin:8px 0 0;font-weight:500;font-size:18px;color:var(--mute)}
.kr-dialog__title{margin:0;font-stretch:125%;font-weight:800;font-size:clamp(32px,3.6vw,52px);line-height:.95;letter-spacing:-.035em;color:var(--ink)}
.kr-facts{margin:10px 0 0;display:grid;grid-template-columns:repeat(3,auto);gap:16px;justify-content:start;
padding:16px 0;border-block:1px solid var(--line)}
.kr-facts div{min-width:0}
.kr-facts dd{margin:4px 0 0;font-weight:600;font-size:15px;color:var(--ink)}
.kr-dialog__syn{margin:6px 0 0;color:var(--mute);font-size:16px;line-height:1.6}
.kr-credits__head{margin:8px 0 0}
.kr-credits{margin:0;display:grid;grid-template-columns:auto 1fr;gap:8px 20px;font-size:15px}
.kr-credits dt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);padding-top:2px}
.kr-credits dd{margin:0;color:var(--ink);font-weight:500}
.kr-close{position:absolute;top:14px;right:14px;z-index:2;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;
background:var(--paper);color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--line);transition:background .25s var(--ease),color .25s var(--ease)}
.kr-close:hover{background:var(--rose);color:#fff;box-shadow:none}
@media (max-width:860px){
.kr-dialog{grid-template-columns:1fr}
.kr-dialog__still img{min-height:0;aspect-ratio:3/2;height:auto}
.kr-close{background:var(--paper)}
}
@media (max-width:720px){
.kr-intro{flex-direction:column;align-items:flex-start;gap:20px}
.kr-intro__side{flex-direction:row;align-items:flex-end;text-align:left;gap:16px}
.kr-panel{height:85vh;height:85svh}
.kr-index{top:calc(64px + 14px)}
.kr-meta{flex-direction:column;align-items:flex-start;gap:14px;bottom:24px}
.kr-title{font-size:clamp(34px,10.5vw,56px)}
.kr-info{flex-direction:row;flex-wrap:wrap;gap:4px 14px;align-items:flex-start;text-align:left;font-size:12px}
.kr-facts{grid-template-columns:1fr 1fr}
.kr-prog{display:none}
}
@media (prefers-reduced-motion: reduce) and (update: none){
.kr-card,.kr-media,.kr-media img{transform:none !important;border-radius:0 !important}
.kr-frame{clip-path:none !important}
.kr-dim{opacity:0 !important}
.kr-prog__tick{transition:none}
.kr-ring{display:none !important}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('work');
if (!root) return;
const panels = [...root.querySelectorAll('.kr-panel')];
const reduce = KF.reduce;
const DATA = [
{ client:'Halcyon Motors', title:'Long Way Round', dir:'Tomás Ferreira', year:'2026', cat:'Commercial', img:'assets/p1.jpg',
alt:'A lone car on an empty coastal road at golden hour',
syn:'A retired engineer takes the slow road home and lets the coastline set the pace. We shot over nine days along the Atlantic, chasing one continuous hour of low sun. No voiceover, just the engine and the wind doing the talking.',
credits:[['Production','Kinoform Pictures'],['DOP','Inês Valadares'],['Editor','Marco Seidel'],['Colour','Priya Anand']] },
{ client:'Maison Oriel', title:'Pink Hour', dir:'Ada Kwon', year:'2026', cat:'Fashion film', img:'assets/p2.jpg',
alt:'Model in soft pink evening light for a fashion film',
syn:'Twelve minutes between sunset and streetlight, stretched into a three-minute film. Ada built the whole piece around a single colour and a single glance. The collection is worn, never shown off.',
credits:[['Production','Kinoform Pictures'],['DOP','Seo-yeon Baek'],['Editor','Clara Montiel'],['Colour','Hugo Lindqvist']] },
{ client:'Velo Norte', title:'The Climb', dir:'Jun Park', year:'2025', cat:'Commercial', img:'assets/p3.jpg',
alt:'Cyclist climbing a steep mountain road',
syn:'One rider, one mountain pass, and every metre of it earned. Cameras rode on a motorbike, a drone and a borrowed farm truck to keep the gradient honest. The cut breathes with the pedal stroke.',
credits:[['Production','Kinoform Pictures'],['DOP','Tae-min Oh'],['Editor','Rafaela Couto'],['Colour','Priya Anand']] },
{ client:'Salt & Current', title:'Last Light', dir:'Noor Haddad', year:'2025', cat:'Branded short', img:'assets/p4.jpg',
alt:'The sea at dusk with the last light on the water',
syn:'A fishing family on the Pacific coast prepares for a season that may not come. Noor spent a month in the village before the camera came out. The brand appears once, in the final frame, and earns it.',
credits:[['Production','Kinoform Pictures'],['DOP','Emiliano Ruiz'],['Editor','Nadia Okafor'],['Colour','Hugo Lindqvist']] },
{ client:'Stride Athletic', title:'The City Is a Stage', dir:'Lena Brandt', year:'2025', cat:'Commercial', img:'assets/p5.jpg',
alt:'Runner mid-stride on a city street',
syn:'A morning run choreographed like a dance across Berlin, crosswalks as cues and traffic as tempo. Lena cast working dancers, not athletes, and let rhythm lead the edit. Every step lands on the beat.',
credits:[['Production','Kinoform Pictures'],['DOP','Jonas Albrecht'],['Editor','Marco Seidel'],['Colour','Mira Castellanos']] },
{ client:'Aurel Horology', title:'Second Nature', dir:'Walter Imrie', year:'2024', cat:'Product film', img:'assets/p6.jpg',
alt:'Close-up of a mechanical wristwatch',
syn:'Two hundred and eleven parts, filmed at a scale where a screw looks like architecture. Walter built a motion-control rig small enough to orbit a balance wheel. Time slows down so you can watch it being made.',
credits:[['Production','Kinoform Pictures'],['DOP','Callum Reid'],['Editor','Nadia Okafor'],['Colour','Mira Castellanos']] }
];
const E = KF.ease;
const stack = root.querySelector('.kr-stack');
/* panel height, cached per refresh (panels are sticky, so their own rects move; the stack's don't) */
let PH = panels[0].offsetHeight, VH = innerHeight;
ScrollTrigger.addEventListener('refreshInit', () => { PH = panels[0].offsetHeight; VH = innerHeight; });
/* "top+=offset <viewport>" on the non-sticky stack = panel i's resting flow position */
const at = (i, vp) => () => `top+=${i * PH} ${vp}`;
/* ---------- intro ---------- */
const introTitle = root.querySelector('.kr-intro__title');
const countEl = root.querySelector('.kr-intro__count');
const introFade = [root.querySelector('.kr-intro__label'), root.querySelector('.kr-intro__note')];
KF.reveal(introTitle, { mode: 'chars', stagger: .022, start: 'top 85%' });
if (!reduce) {
countEl.textContent = '00';
gsap.set(countEl, { autoAlpha: 0 });
ScrollTrigger.create({
trigger: countEl, start: 'top 90%', once: true,
onEnter: () => {
const o = { v: 0 };
gsap.to(o, { v: 6, duration: 1.4, ease: 'power2.out', delay: .25,
onUpdate: () => { countEl.textContent = String(Math.round(o.v)).padStart(2, '0'); } });
gsap.fromTo(countEl, { yPercent: 30, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, duration: 1.1, ease: E.out, delay: .15 });
}
});
gsap.from(introFade, { y: 18, autoAlpha: 0, duration: 1, ease: E.soft, stagger: .12, delay: .3,
scrollTrigger: { trigger: root.querySelector('.kr-intro'), start: 'top 80%', once: true } });
}
/* ---------- panels ---------- */
panels.forEach((panel, i) => {
const card = panel.querySelector('.kr-card');
const frame = panel.querySelector('.kr-frame');
const media = panel.querySelector('.kr-media');
const img = media.querySelector('img');
const dim = panel.querySelector('.kr-dim');
const client = panel.querySelector('.kr-client');
const title = panel.querySelector('.kr-title');
const info = panel.querySelector('.kr-info');
const idx = panel.querySelector('.kr-index');
if (reduce) return;
/* keep the heading readable: hidden plain copy for assistive tech, split chars are aria-hidden */
const sr = document.createElement('span');
sr.className = 'kr-sr'; sr.textContent = title.textContent.trim();
title.after(sr);
const chars = KF.split(title, 'chars');
title.removeAttribute('aria-label');
/* photo wipes up from the bottom edge + settles from 1.15, scrubbed to the panel's arrival */
const inTl = gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i, 'bottom'), end: at(i, 'top'), scrub: true }
});
inTl.fromTo(frame, { clipPath: 'inset(42% 6% 0% 6% round 28px)' },
{ clipPath: 'inset(0% 0% 0% 0% round 0px)', ease: 'none' }, 0)
.fromTo(img, { scale: 1.15 }, { scale: 1, ease: 'none' }, 0);
if (i > 0) inTl.fromTo(media, { yPercent: -5 }, { yPercent: 0, ease: 'none' }, 0);
/* copy: plays on the way in, rewinds when scrolled back above, replays on return */
gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i, '45%'), toggleActions: 'play none none reverse' }
})
.from(idx, { y: -14, autoAlpha: 0, duration: .8, ease: E.soft }, 0)
.from(client, { y: 26, autoAlpha: 0, duration: .9, ease: E.soft }, .05)
.from(chars, { yPercent: 115, rotate: 6, duration: 1.1, ease: E.out, stagger: .028 }, .1)
.from(info.children, { y: 16, autoAlpha: 0, duration: .8, ease: E.soft, stagger: .08 }, .35);
/* being covered by the next panel: recede, round, dim */
if (i < panels.length - 1) {
gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i + 1, 'bottom'), end: at(i + 1, 'top'), scrub: true }
})
.fromTo(card, { scale: 1, borderRadius: 0 }, { scale: .92, borderRadius: 24, ease: 'none' }, 0)
.fromTo(dim, { opacity: 0 }, { opacity: .38, ease: 'none' }, 0);
}
});
/* ---------- progress ticks ---------- */
const prog = root.querySelector('.kr-prog');
const ticks = [...prog.querySelectorAll('.kr-prog__tick')];
const progNum = prog.querySelector('.kr-prog__num');
let cur = 0;
const setActive = n => {
if (n === cur) return;
ticks[cur].classList.remove('is-on'); ticks[n].classList.add('is-on');
progNum.textContent = String(n + 1).padStart(2, '0');
cur = n;
};
gsap.set(prog, { x: reduce ? 0 : -14 });
ScrollTrigger.create({
trigger: stack, start: 'top 55%', end: 'bottom 45%',
onToggle: s => gsap.to(prog, { autoAlpha: s.isActive ? 1 : 0, x: s.isActive || reduce ? 0 : -14,
duration: reduce ? 0 : .5, ease: E.soft, overwrite: true }),
/* panel i is "current" once its top passes 30% of the viewport; pure arithmetic, no layout reads */
onUpdate: s => {
const n = Math.floor((s.scroll() - s.start - VH * .25) / PH);
setActive(Math.max(0, Math.min(panels.length - 1, n)));
}
});
panels.forEach(p => {
const img = p.querySelector('img');
if (!img.complete) img.addEventListener('load', KF.refresh, { once: true });
});
KF.refresh();
/* ---------- custom cursor ---------- */
const cursor = root.querySelector('.kr-cursor');
const dotEl = cursor.querySelector('.kr-cursor__dot');
const txtEl = cursor.querySelector('.kr-cursor__txt');
const ring = root.querySelector('.kr-ring');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
let px = -200, py = -200, on = false, modalOpen = false, chk = 0;
const setOn = v => {
if (v === on) return;
on = v;
if (v) {
gsap.to(dotEl, { scale: 1, autoAlpha: 1, duration: reduce ? 0 : .6, ease: E.out, overwrite: true });
if (!reduce) {
gsap.fromTo(txtEl, { rotate: -100, yPercent: 60, autoAlpha: 0 },
{ rotate: 0, yPercent: 0, autoAlpha: 1, duration: .7, ease: 'back.out(1.7)', delay: .06, overwrite: true });
gsap.to(ring, { autoAlpha: 1, scale: 1, duration: .5, ease: E.soft, overwrite: 'auto' });
}
} else {
gsap.to(dotEl, { scale: 0, autoAlpha: 0, duration: reduce ? 0 : .35, ease: 'power3.in', overwrite: true });
if (!reduce) gsap.to(ring, { autoAlpha: 0, scale: .4, duration: .3, ease: 'power2.in', overwrite: 'auto' });
}
};
const overPanel = () => {
const el = document.elementFromPoint(px, py);
return !!(el && el.closest && el.closest('#work .kr-hit'));
};
if (fine.matches) {
root.classList.add('kr-has-cursor');
gsap.set(dotEl, { scale: 0, autoAlpha: 0 });
gsap.set(ring, { scale: .4, autoAlpha: 0 });
const q = (el, p, d) => gsap.quickTo(el, p, { duration: reduce ? .01 : d, ease: 'power3.out' });
const cx = q(cursor, 'x', .35), cy = q(cursor, 'y', .35);
const rx = q(ring, 'x', .8), ry = q(ring, 'y', .8);
addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse') { setOn(false); return; }
px = e.clientX; py = e.clientY;
if (!on) { cx(px, px); cy(py, py); rx(px, px); ry(py, py); }
else { cx(px); cy(py); rx(px); ry(py); }
setOn(!modalOpen && !!e.target.closest?.('#work .kr-hit'));
}, { passive: true });
document.addEventListener('pointerleave', () => setOn(false));
/* the panel under a still pointer changes while scrolling: hit-test at most every ~90ms */
addEventListener('scroll', () => {
if (px <= -200 || chk) return;
chk = setTimeout(() => { chk = 0; setOn(!modalOpen && overPanel()); }, 90);
}, { passive: true });
}
/* ---------- modal ---------- */
const modal = document.getElementById('kr-modal');
/* lift the dialog out of the section's stacking context so it sits above the fixed header */
document.body.appendChild(modal);
const dialog = modal.querySelector('.kr-dialog');
const backdrop = modal.querySelector('.kr-modal__backdrop');
const mImg = document.getElementById('kr-m-img');
const mBody = modal.querySelector('.kr-dialog__body');
const closeBtn = modal.querySelector('.kr-close');
const $ = id => document.getElementById(id);
const focusables = () => [...modal.querySelectorAll('button,[href],[tabindex]:not([tabindex="-1"])')].filter(el => !el.disabled && el.offsetParent !== null);
let opener = null, mtl = null;
KF.magnetic(closeBtn, .3);
const open = (i, btn) => {
const d = DATA[i];
opener = btn;
mImg.src = d.img; mImg.alt = d.alt;
$('kr-m-index').textContent = `${String(i + 1).padStart(2, '0')} / 06`;
$('kr-m-client').textContent = d.client;
$('kr-m-title').textContent = d.title;
$('kr-m-dir').textContent = d.dir;
$('kr-m-year').textContent = d.year;
$('kr-m-cat').textContent = d.cat;
$('kr-m-syn').textContent = d.syn;
const cr = $('kr-m-credits'); cr.innerHTML = '';
d.credits.forEach(([role, name]) => {
const dt = document.createElement('dt'); dt.textContent = role;
const dd = document.createElement('dd'); dd.textContent = name;
cr.append(dt, dd);
});
dialog.scrollTop = 0;
modal.style.pointerEvents = '';
modal.setAttribute('aria-hidden', 'false');
modal.classList.add('is-open');
modalOpen = true; setOn(false);
if (KF.lenis) KF.lenis.stop(); else document.documentElement.style.overflow = 'hidden';
if (!reduce) {
/* opacity (not autoAlpha) so the close button stays focusable from frame one */
if (mtl) mtl.kill();
mtl = gsap.timeline({ defaults: { ease: E.out } })
.fromTo(backdrop, { opacity: 0 }, { opacity: 1, duration: .5, ease: 'power2.out' }, 0)
.fromTo(dialog, { y: () => Math.min(innerHeight * .55, 420) }, { y: 0, duration: 1.05 }, .04)
.fromTo(dialog, { opacity: 0 }, { opacity: 1, duration: .3, ease: 'none' }, .04)
.fromTo(mImg, { clipPath: 'inset(100% 0% 0% 0%)', scale: 1.22 },
{ clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 1.15 }, .22)
.fromTo(mBody.children, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: .85, stagger: .06, ease: E.soft }, .32)
.fromTo(closeBtn, { scale: .5, opacity: 0 }, { scale: 1, opacity: 1, duration: .6, ease: 'back.out(2)' }, .45);
}
requestAnimationFrame(() => closeBtn.focus({ preventScroll: true }));
};
const finishClose = () => { modal.classList.remove('is-open'); modal.style.pointerEvents = ''; };
const close = () => {
if (!modalOpen) return;
modalOpen = false;
modal.setAttribute('aria-hidden', 'true');
if (KF.lenis) KF.lenis.start(); else document.documentElement.style.overflow = '';
if (opener) opener.focus({ preventScroll: true });
if (mtl && !reduce) {
modal.style.pointerEvents = 'none';
mtl.eventCallback('onReverseComplete', finishClose);
mtl.timeScale(1.9).reverse();
} else finishClose();
};
panels.forEach((p, i) => {
p.querySelector('.kr-hit').addEventListener('click', e => open(i, e.currentTarget));
});
modal.querySelectorAll('[data-kr-close]').forEach(el => el.addEventListener('click', close));
document.addEventListener('keydown', e => {
if (!modalOpen) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Tab') {
const f = focusables();
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (!modal.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
});
</script>
<section class="sec kc" id="process" aria-labelledby="kc-title">
<div class="wrap">
<header class="kc-head">
<div class="kc-head-main">
<p class="label label--rose kc-kicker">Process</p>
<h2 class="display kc-title" id="kc-title">From brief<br>to <em>final grade.</em></h2>
</div>
<p class="lede kc-lede">Six stages, one crew carrying the film from the first line of the brief to the last frame of colour. Pull the board apart — this is how the work really moves.</p>
</header>
<div class="kc-bar">
<div class="kc-tabs" role="tablist" aria-label="Who the film is for">
<span class="kc-pill" aria-hidden="true"></span>
<button class="kc-tab" role="tab" id="kc-tab-0" aria-selected="true" aria-controls="kc-panel" tabindex="0" data-kc-tab="0">Agencies</button>
<button class="kc-tab" role="tab" id="kc-tab-1" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="1">Brands</button>
<button class="kc-tab" role="tab" id="kc-tab-2" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="2">Filmmakers</button>
<button class="kc-tab" role="tab" id="kc-tab-3" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="3">Musicians</button>
</div>
<p class="kc-desc" data-kc-desc>You bring the idea and the client; we bring the director, the crew and a treatment that wins the room. We slot into your approval rounds without stretching the calendar.</p>
</div>
<div class="kc-panel" id="kc-panel" role="tabpanel" aria-labelledby="kc-tab-0">
<div class="kc-persp" data-kc-persp>
<span class="kc-float kc-float--a" aria-hidden="true" data-kc-float><i></i>4K RAW</span>
<span class="kc-float kc-float--b" aria-hidden="true" data-kc-float><i></i>Dolby 5.1</span>
<span class="kc-float kc-float--c" aria-hidden="true" data-kc-float><i></i>Rec.709</span>
<span class="kc-float kc-float--d" aria-hidden="true" data-kc-float><i></i>24 fps</span>
<div class="card kc-board">
<span class="kc-grid" aria-hidden="true" data-kc-grid></span>
<div class="kc-tool">
<span class="kc-tool-name"><span class="kc-dot" aria-hidden="true"></span>kinoform / pipeline.board</span>
<span class="kc-tool-hint">Drag nodes · click to inspect</span>
<button class="kc-reset" type="button" data-kc-reset>Reset layout</button>
</div>
<div class="kc-stage" data-kc-stage>
<svg class="kc-wires" aria-hidden="true" focusable="false" data-kc-svg></svg>
<ol class="kc-nodes">
<li class="kc-node" data-kc-node="0">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>01</span> · <span class="kc-dur" data-kc-dur>2 days</span></span>
<span class="kc-brief" aria-hidden="true">
<span class="kc-brief-k">brief.txt</span>
<span class="kc-brief-t"><span data-kc-type>Launch spot, 30s + 6s, dusk light, air date locked.</span><span class="kc-caret"></span></span>
</span>
<span class="kc-name" data-kc-name>Agency brief</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Agency-led</span>
</button>
</li>
<li class="kc-node" data-kc-node="1">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>02</span> · <span class="kc-dur" data-kc-dur>1 week</span></span>
<span class="kc-thumb"><img src="assets/f2.jpg" alt="Clapperboard held up before a take" loading="lazy" draggable="false" style="object-position:50% 45%"></span>
<span class="kc-name" data-kc-name>Treatment</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>Pitch deck</span>
</button>
</li>
<li class="kc-node" data-kc-node="2">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>03</span> · <span class="kc-dur" data-kc-dur>2 weeks</span></span>
<span class="kc-thumb"><img src="assets/f6.jpg" alt="City skyline at dusk, scouted as a location" loading="lazy" draggable="false" style="object-position:50% 60%"></span>
<span class="kc-name" data-kc-name>Casting & Location</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Client review</span>
</button>
</li>
<li class="kc-node" data-kc-node="3">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>04</span> · <span class="kc-dur" data-kc-dur>3 days</span></span>
<span class="kc-thumb"><img src="assets/f1.jpg" alt="Camera crew rigging a cinema camera on set" loading="lazy" draggable="false" style="object-position:50% 40%"></span>
<span class="kc-name" data-kc-name>Shoot</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>On set</span>
</button>
</li>
<li class="kc-node" data-kc-node="4">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>05</span> · <span class="kc-dur" data-kc-dur>3 weeks</span></span>
<span class="kc-thumb kc-thumb--grade"><img src="assets/f5.jpg" alt="Pink flowers used as a colour-grade reference" loading="lazy" draggable="false" style="object-position:50% 50%"><span class="kc-swatch" aria-hidden="true"><i></i><i></i><i></i></span></span>
<span class="kc-name" data-kc-name>Edit & Grade</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>Agency cut</span>
</button>
</li>
<li class="kc-node" data-kc-node="5">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>06</span> · <span class="kc-dur" data-kc-dur>1 week</span></span>
<span class="kc-thumb"><img src="assets/f7.jpg" alt="Perfume bottle, the finished product shot" loading="lazy" draggable="false" style="object-position:50% 50%"></span>
<span class="kc-name" data-kc-name>Delivery</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Cut-downs</span>
</button>
</li>
</ol>
</div>
</div>
</div>
<p class="kc-detail" aria-live="polite">
<span class="label label--rose kc-detail-k" data-kc-detail-k>Select a stage</span>
<span class="kc-detail-t" data-kc-detail-t>Click any node to see what happens there. The rose path shows where this kind of project spends its energy.</span>
</p>
</div>
</div>
</section>
<style>
.kc{position:relative}
.kc-head{display:grid;grid-template-columns:1fr;gap:24px;align-items:end;margin-bottom:clamp(36px,5vw,64px)}
.kc-head-main .label{margin:0 0 18px}
.kc-title{font-size:clamp(42px,7.2vw,112px)}
.kc-lede{margin:0}
@media (min-width:960px){
.kc-head{grid-template-columns:1.35fr 1fr}
.kc-lede{justify-self:end}
}
.kc-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* tabs: segmented track + sliding rose pill */
.kc-bar{display:flex;flex-direction:column;gap:18px;margin-bottom:28px}
.kc-tabs{position:relative;display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:4px;border-radius:20px;
background:var(--paper);box-shadow:inset 0 0 0 1px var(--line)}
.kc-pill{position:absolute;left:0;top:0;width:0;height:0;border-radius:999px;background:var(--rose);box-shadow:var(--shadow-rose);
pointer-events:none;z-index:0;opacity:0}
.kc-tabs.kc-has-pill .kc-pill{opacity:1}
.kc-tab{position:relative;z-index:1;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:11px 16px;border-radius:999px;
color:var(--mute);transition:color .25s var(--ease) .06s}
.kc-tab:hover{color:var(--ink)}
.kc-tab[aria-selected="true"]{color:#fff}
.kc-tabs:not(.kc-has-pill) .kc-tab[aria-selected="true"]{background:var(--rose)}
.kc-desc{margin:0;color:var(--mute);max-width:56ch;font-size:15px;line-height:1.6}
@media (min-width:600px){
.kc-tabs{display:inline-flex;align-self:flex-start;border-radius:999px}
}
@media (min-width:960px){
.kc-bar{flex-direction:row;align-items:center;justify-content:space-between;gap:40px}
.kc-tabs{align-self:auto;flex:none}
.kc-desc{text-align:right}
}
/* board + 3D wrapper */
.kc-persp{position:relative;perspective:1600px;perspective-origin:50% 30%}
.kc-board{position:relative;border-radius:28px;overflow:hidden;background-color:var(--paper);transform-origin:50% 40%;will-change:transform}
.kc-grid{position:absolute;left:0;right:0;top:-60px;bottom:-60px;z-index:0;pointer-events:none;
background-image:radial-gradient(circle, var(--line) 1.3px, transparent 1.6px);background-size:22px 22px;background-position:11px 11px;will-change:transform}
.kc-tool{position:relative;z-index:1;display:flex;align-items:center;gap:16px;padding:14px 20px;border-bottom:1px solid var(--line);background:var(--paper);
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.kc-tool-name{display:flex;align-items:center;gap:10px;color:var(--ink)}
.kc-dot{width:8px;height:8px;border-radius:50%;background:var(--rose);box-shadow:0 0 0 4px var(--rose-soft)}
.kc-tool-hint{margin-left:auto}
.kc-reset{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:7px 12px;border-radius:999px;
box-shadow:inset 0 0 0 1px var(--line);color:var(--ink)}
.kc-reset:hover{background:var(--sky-soft);box-shadow:inset 0 0 0 1px var(--sky-deep)}
/* floating spec chips (desktop only) */
.kc-float{display:none}
@media (min-width:1024px){
.kc-float{position:absolute;z-index:3;display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;will-change:transform;
box-shadow:0 18px 34px -22px rgba(27,21,48,.45)}
.kc-float i{width:7px;height:7px;border-radius:50%}
.kc-float--a{top:-18px;left:17%;background:var(--sky-soft);color:var(--ink)}
.kc-float--a i{background:var(--sky-deep)}
.kc-float--b{top:31%;right:-26px;background:var(--rose-soft);color:var(--rose-deep)}
.kc-float--b i{background:var(--rose)}
.kc-float--c{bottom:-12px;left:39%;background:var(--rose-soft);color:var(--rose-deep)}
.kc-float--c i{background:var(--rose)}
.kc-float--d{top:57%;left:-30px;background:var(--sky-soft);color:var(--ink)}
.kc-float--d i{background:var(--sky-deep)}
}
.kc-stage{position:relative;z-index:1;height:600px}
.kc-wires{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;overflow:visible}
.kc-w-base{fill:none;stroke:var(--line);stroke-width:2;stroke-linecap:round}
.kc-w-hi{fill:none;stroke:var(--rose);stroke-width:2;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:1}
.kc-w-sel{fill:none;stroke:var(--rose-deep);stroke-width:3.5;stroke-linecap:round;opacity:0;transition:opacity .3s var(--ease)}
.kc-w-sel.kc-on{opacity:1}
.kc-w-sig{fill:none;stroke:var(--rose);stroke-width:4;stroke-linecap:round;stroke-dasharray:.1 2;stroke-dashoffset:.1}
.kc-port{fill:var(--paper);stroke:var(--line);stroke-width:2}
.kc-port.kc-on{fill:var(--rose);stroke:var(--rose)}
.kc-ring{fill:none;stroke:var(--rose);stroke-width:1.5;opacity:0}
.kc-nodes{list-style:none;margin:0;padding:0;position:absolute;inset:0;z-index:1}
.kc-node{position:absolute;left:0;top:0;width:clamp(172px,15.5vw,214px)}
.kc-node--drag{z-index:5}
.kc-card{position:relative;display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%;padding:12px;text-align:left;
background:var(--paper);border-radius:14px;cursor:grab;user-select:none;-webkit-user-select:none;touch-action:none;
box-shadow:0 0 0 1px var(--line),0 18px 34px -24px rgba(27,21,48,.45);
opacity:.58;transition:opacity .35s var(--ease)}
/* lift shadow lives on a pseudo layer so hover/drag only fades opacity */
.kc-card::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;
box-shadow:0 34px 50px -24px rgba(27,21,48,.5);transition:opacity .3s var(--ease)}
.kc-card.kc-lift::after,.kc-node--drag .kc-card::after{opacity:1}
.kc-node--focus .kc-card{opacity:1;box-shadow:0 0 0 1.5px var(--rose-soft),0 22px 40px -26px rgba(209,30,107,.45)}
.kc-node--sel .kc-card{opacity:1;box-shadow:0 0 0 2px var(--rose),var(--shadow-rose)}
.kc-node--drag .kc-card{cursor:grabbing;box-shadow:0 0 0 1.5px var(--rose),var(--shadow)}
.kc-card:focus-visible{outline:2px solid var(--rose);outline-offset:3px}
.kc-meta{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kc-dur{display:inline-block}
.kc-node--focus .kc-meta [data-kc-num]{color:var(--rose)}
.kc-thumb{position:relative;display:block;height:88px;border-radius:9px;overflow:hidden;background:var(--sky-soft)}
.kc-thumb img{width:100%;height:100%;object-fit:cover;pointer-events:none}
.kc-swatch{position:absolute;left:8px;bottom:8px;display:flex;gap:4px;padding:4px;border-radius:999px;background:rgba(255,255,255,.88)}
.kc-swatch i{width:10px;height:10px;border-radius:50%;background:var(--rose)}
.kc-swatch i:nth-child(2){background:var(--rose-soft)}
.kc-swatch i:nth-child(3){background:var(--sky)}
.kc-brief{display:flex;flex-direction:column;gap:6px;height:88px;padding:10px;border-radius:9px;background:var(--bg);
box-shadow:inset 0 0 0 1px var(--line);font-family:var(--mono);font-size:11px;line-height:1.45;color:var(--ink);overflow:hidden}
.kc-brief-k{color:var(--mute);letter-spacing:.06em;text-transform:uppercase;font-size:10px}
.kc-caret{display:inline-block;width:6px;height:12px;margin-left:2px;vertical-align:-2px;background:var(--rose);animation:kc-blink 1s steps(1) infinite}
.kc-idle .kc-caret{animation-play-state:paused}
@keyframes kc-blink{50%{opacity:0}}
.kc-name{font-weight:750;font-size:16px;line-height:1.15;letter-spacing:-.01em;font-stretch:112%}
.kc-chip{align-self:flex-start;font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;padding:5px 9px;border-radius:999px}
.kc-chip--rose{background:var(--rose-soft);color:var(--rose-deep)}
.kc-chip--sky{background:var(--sky-soft);color:var(--ink)}
/* detail line */
.kc-detail{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 16px;margin:26px 0 0;padding:0 4px;min-height:3.2em}
.kc-detail-k{flex:none;display:inline-block}
.kc-detail-t{color:var(--ink);font-size:15px;max-width:72ch}
/* tablet */
@media (min-width:768px) and (max-width:1023px){
.kc-stage{height:640px}
.kc-thumb,.kc-brief{height:76px}
}
/* mobile: vertical pipeline, no dragging */
@media (max-width:767px){
.kc-tool-hint,.kc-reset{display:none}
.kc-tool{padding:12px 16px}
.kc-board{border-radius:22px}
.kc-grid{top:-30px;bottom:-30px}
.kc-stage{height:auto;padding:20px 16px 22px}
.kc-wires{display:none}
.kc-nodes{position:relative;inset:auto;display:flex;flex-direction:column;gap:34px}
.kc-node{position:relative;left:auto !important;top:auto !important;width:100%}
.kc-node + .kc-node::before{content:"";position:absolute;left:50%;top:-34px;width:2px;height:34px;margin-left:-1px;background:var(--line);transition:background .3s var(--ease)}
.kc-node + .kc-node::after{content:"";position:absolute;left:50%;top:-5px;width:10px;height:10px;margin-left:-5px;border-radius:50%;background:var(--paper);box-shadow:inset 0 0 0 2px var(--line);z-index:2}
.kc-node.kc-in-hi::before{background:var(--rose)}
.kc-node.kc-in-sel::before{background:var(--rose-deep);width:3px;margin-left:-1.5px}
.kc-node.kc-in-hi::after,.kc-node.kc-in-sel::after{background:var(--rose);box-shadow:none}
.kc-card{display:grid;grid-template-columns:84px 1fr;grid-template-rows:auto auto 1fr;column-gap:14px;row-gap:6px;cursor:pointer;touch-action:auto}
.kc-thumb,.kc-brief{grid-column:1;grid-row:1 / span 3;height:84px}
.kc-brief{padding:8px;font-size:9.5px;gap:3px}
.kc-brief-k{font-size:9px}
.kc-meta,.kc-name,.kc-chip{grid-column:2}
.kc-chip{align-self:end;justify-self:start}
.kc-swatch{left:6px;bottom:6px}
}
@media (prefers-reduced-motion: reduce) and (update: none){
.kc-card,.kc-card::after,.kc-tab,.kc-w-sel{transition:none}
.kc-caret{animation:none}
.kc-w-sig{display:none}
.kc-board,.kc-grid,.kc-float{will-change:auto}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('process');
if (!root) return;
const reduce = KF.reduce;
const mq = matchMedia('(max-width: 767px)');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
const NS = 'http://www.w3.org/2000/svg';
const PAD = 16;
const stage = root.querySelector('[data-kc-stage]');
const svg = root.querySelector('[data-kc-svg]');
const board = root.querySelector('.kc-board');
const persp = root.querySelector('[data-kc-persp]');
const grid = root.querySelector('[data-kc-grid]');
const floats = [...root.querySelectorAll('[data-kc-float]')];
const nodes = [...root.querySelectorAll('[data-kc-node]')];
const cards = nodes.map(n => n.querySelector('.kc-card'));
const tabsEl = root.querySelector('.kc-tabs');
const pill = root.querySelector('.kc-pill');
const tabs = [...root.querySelectorAll('[data-kc-tab]')];
const panel = root.querySelector('#kc-panel');
const descEl = root.querySelector('[data-kc-desc]');
const typeEl = root.querySelector('[data-kc-type]');
const detailK = root.querySelector('[data-kc-detail-k]');
const detailT = root.querySelector('[data-kc-detail-t]');
const DETAIL = [
'We read it twice, then pick up the phone. Every question asked here saves a day on set.',
'The director’s written and visual pitch: tone, references, key frames and a first pass at the schedule.',
'Casting rooms, recce trips and permits across our five cities — and anywhere else the story needs to go.',
'Crew, kit and a first AD who keeps the day honest. Rushes are backed up twice before the lights go down.',
'Offline, sound and colour under one roof, so the grade is part of the story rather than a last coat of paint.',
'Masters, cut-downs and captions for every placement, checked frame by frame before anything leaves the building.'
];
const IDLE_K = 'Select a stage';
const IDLE_T = 'Click any node to see what happens there. The rose path shows where this kind of project spends its energy.';
// per-client view: description, typed brief, focus path, and per-node [title, duration, chip, tone]
const TABS = [
{ desc: 'You bring the idea and the client; we bring the director, the crew and a treatment that wins the room. We slot into your approval rounds without stretching the calendar.',
brief: 'Launch spot, 30s + 6s, dusk light, air date locked.',
focus: [1, 2, 3, 4],
n: [['Agency brief','2 days','Agency-led','sky'],['Treatment','1 week','Pitch deck','rose'],['Casting & Location','2 weeks','Client review','sky'],['Shoot','3 days','On set','rose'],['Edit & Grade','3 weeks','Agency cut','rose'],['Delivery','1 week','Cut-downs','sky']] },
{ desc: 'Coming to us direct? We shape the brief with you before a single frame is planned. One producer owns the budget from the first call to the final cut-downs.',
brief: 'People should feel the fabric before they see it.',
focus: [0, 1, 4, 5],
n: [['Brand brief','1 week','Co-written','rose'],['Treatment','1 week','Moodboard','rose'],['Casting & Location','2 weeks','Locked','sky'],['Shoot','2 days','Lean crew','sky'],['Edit & Grade','2 weeks','Brand colour','rose'],['Delivery','Ongoing','Every format','rose']] },
{ desc: 'Got a script and a stubborn vision? We build the production around your voice, from open casting calls to festival-grade colour.',
brief: 'Two sisters, one car, the last ferry out of town.',
focus: [1, 2, 3, 4],
n: [['Script','3 days','Your words','sky'],['Treatment','2 weeks','Lookbook','rose'],['Casting & Location','4 weeks','Open call','rose'],['Shoot','6 days','35mm','rose'],['Edit & Grade','5 weeks','Film grade','rose'],['Delivery','Festival','DCP','sky']] },
{ desc: 'Your track sets the tempo. We turn a song into a shoot plan, move fast on the day, and cut to the beat for every screen it lands on.',
brief: '3:42, one continuous move, chorus hits at sunrise.',
focus: [0, 3, 4, 5],
n: [['Track','1 day','Track first','rose'],['Treatment','4 days','One-pager','sky'],['Casting & Location','1 week','Quick scout','sky'],['Shoot','1 day','One take','rose'],['Edit & Grade','10 days','Beat-cut','rose'],['Delivery','Release day','9:16 + 16:9','rose']] }
];
// zig-zag layout as fractions of the free space inside the stage
const LAYOUT = [[0, .06], [.2, .94], [.4, .04], [.6, .9], [.8, .1], [1, .92]];
let pos = LAYOUT.map(p => p.slice());
let tab = 0, sel = -1, revealed = reduce, suppressClick = false, dragIdx = -1;
const isMobile = () => mq.matches;
/* ---------- heading ---------- */
KF.reveal(root.querySelector('.kc-title'), { mode: 'chars', stagger: .022, start: 'top 82%' });
if (!reduce) {
const intro = [root.querySelector('.kc-kicker'), root.querySelector('.kc-lede'), tabsEl, descEl];
gsap.set(intro, { autoAlpha: 0, y: 28 });
gsap.to(intro, { autoAlpha: 1, y: 0, duration: 1, ease: KF.ease.soft, stagger: .09, delay: .15,
scrollTrigger: { trigger: root.querySelector('.kc-head'), start: 'top 82%', once: true } });
}
/* ---------- build wires + ports ---------- */
const mk = (tag, attrs) => { const el = document.createElementNS(NS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; };
// a port is a translated group, so its ring can scale from 0,0 no matter where the node is dragged
const mkPort = () => {
const g = mk('g', { class: 'kc-pg' });
const ring = mk('circle', { class: 'kc-ring', r: 5, cx: 0, cy: 0 });
const dot = mk('circle', { class: 'kc-port', r: 5, cx: 0, cy: 0 });
g.append(ring, dot);
return { g, ring, dot };
};
const wires = [];
const gW = mk('g', {}), gP = mk('g', {});
for (let i = 0; i < nodes.length - 1; i++) {
const base = mk('path', { class: 'kc-w-base', pathLength: 1 });
const hi = mk('path', { class: 'kc-w-hi', pathLength: 1 });
const selP = mk('path', { class: 'kc-w-sel' });
const sig = mk('path', { class: 'kc-w-sig', pathLength: 1 });
const pa = mkPort(), pb = mkPort();
gW.append(base, hi, selP, sig); gP.append(pa.g, pb.g);
wires.push({ base, hi, selP, sig, pa, pb, on: false });
}
svg.append(gW, gP);
// ports that touch node i: incoming (end of wire i-1) and outgoing (start of wire i)
const portsOf = i => [i > 0 ? wires[i - 1].pb : null, i < wires.length ? wires[i].pa : null].filter(Boolean);
const box = n => ({ x: n.offsetLeft, y: n.offsetTop, w: n.offsetWidth, h: n.offsetHeight });
// pick ports by relative position so wires stay readable wherever nodes are dragged
function wirePath(A, B) {
const gap = 28;
let ax, ay, bx, by, horiz = true;
if (B.x >= A.x + A.w + gap) { ax = A.x + A.w; ay = A.y + A.h / 2; bx = B.x; by = B.y + B.h / 2; }
else if (B.x + B.w <= A.x - gap) { ax = A.x; ay = A.y + A.h / 2; bx = B.x + B.w; by = B.y + B.h / 2; }
else {
horiz = false;
if (B.y >= A.y + A.h / 2) { ax = A.x + A.w / 2; ay = A.y + A.h; bx = B.x + B.w / 2; by = B.y; }
else { ax = A.x + A.w / 2; ay = A.y; bx = B.x + B.w / 2; by = B.y + B.h; }
}
let d;
if (horiz) { const c = Math.max(50, Math.abs(bx - ax) * .5) * Math.sign(bx - ax || 1); d = `M${ax},${ay} C${ax + c},${ay} ${bx - c},${by} ${bx},${by}`; }
else { const c = Math.max(40, Math.abs(by - ay) * .5) * Math.sign(by - ay || 1); d = `M${ax},${ay} C${ax},${ay + c} ${bx},${by - c} ${bx},${by}`; }
return { d, ax, ay, bx, by };
}
function drawWires() {
if (isMobile()) return;
const W = stage.clientWidth, H = stage.clientHeight;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
wires.forEach((w, i) => {
const p = wirePath(box(nodes[i]), box(nodes[i + 1]));
w.base.setAttribute('d', p.d); w.hi.setAttribute('d', p.d); w.selP.setAttribute('d', p.d); w.sig.setAttribute('d', p.d);
w.pa.g.setAttribute('transform', `translate(${p.ax},${p.ay})`);
w.pb.g.setAttribute('transform', `translate(${p.bx},${p.by})`);
});
}
function place() {
if (isMobile()) return;
const W = stage.clientWidth, H = stage.clientHeight;
nodes.forEach((n, i) => {
const fw = Math.max(0, W - n.offsetWidth - PAD * 2), fh = Math.max(0, H - n.offsetHeight - PAD * 2);
n.style.left = (PAD + pos[i][0] * fw) + 'px';
n.style.top = (PAD + pos[i][1] * fh) + 'px';
});
drawWires();
}
/* ---------- state ---------- */
// tl (optional): wire draws are added to that timeline at `at` instead of running on their own
function applyState(animate, tl, at) {
const f = new Set(TABS[tab].focus);
nodes.forEach((n, i) => {
n.classList.toggle('kc-node--focus', f.has(i));
n.classList.toggle('kc-node--sel', sel === i);
cards[i].setAttribute('aria-pressed', sel === i ? 'true' : 'false');
});
let k = 0;
wires.forEach((w, i) => {
const on = f.has(i) && f.has(i + 1);
const s = sel === i || sel === i + 1;
w.selP.classList.toggle('kc-on', s);
w.pa.dot.classList.toggle('kc-on', on || s); w.pb.dot.classList.toggle('kc-on', on || s);
nodes[i + 1].classList.toggle('kc-in-hi', on);
nodes[i + 1].classList.toggle('kc-in-sel', s);
if (!revealed) { w.on = on; return; }
if (reduce || !animate) { if (on !== w.on) gsap.set(w.hi, { strokeDashoffset: on ? 0 : 1, overwrite: true }); }
else if (on !== w.on || on) {
// retract everything, then redraw the rose path one wire after another along the pipeline
const t0 = tl ? (on ? at + .12 * k : Math.max(0, at - .25)) : 0;
const host = tl || gsap;
if (on) host.fromTo(w.hi, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: .5, ease: 'power2.inOut', delay: tl ? 0 : .12 * k, overwrite: true, immediateRender: false }, t0);
else host.to(w.hi, { strokeDashoffset: 1, duration: .3, ease: 'power2.in', overwrite: true }, t0);
if (on) k++;
}
w.on = on;
});
}
/* ---------- detail line: masked word reveal ---------- */
function setDetail(kText, tText, animate) {
detailK.textContent = kText;
if (reduce || !animate) { detailT.textContent = tText; return; }
detailT.textContent = '';
const sr = document.createElement('span'); sr.className = 'kc-sr'; sr.textContent = tText;
const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true');
const parts = [];
tText.split(/\s+/).forEach((w, i) => {
if (i) vis.appendChild(document.createTextNode(' '));
const m = document.createElement('span'); m.className = 'kf-m';
const inner = document.createElement('span'); inner.className = 'kf-i'; inner.textContent = w;
m.appendChild(inner); vis.appendChild(m); parts.push(inner);
});
detailT.append(sr, vis);
gsap.timeline()
.fromTo(detailK, { autoAlpha: 0, x: -10 }, { autoAlpha: 1, x: 0, duration: .45, ease: KF.ease.soft })
.fromTo(parts, { yPercent: 115 }, { yPercent: 0, duration: .7, ease: KF.ease.out, stagger: .016 }, .05);
}
const detailKey = i => { const d = TABS[tab].n[i]; return `${String(i + 1).padStart(2, '0')} · ${d[0]} · ${d[1]}`; };
function select(i) {
sel = (sel === i) ? -1 : i;
if (sel < 0) setDetail(IDLE_K, IDLE_T, true);
else setDetail(detailKey(sel), DETAIL[sel], true);
applyState(false);
if (sel >= 0 && !reduce) gsap.fromTo(nodes[sel], { scale: .94 }, { scale: 1, duration: .55, ease: 'back.out(3)', clearProps: 'transform' });
}
/* ---------- typed brief ---------- */
let typeTween;
function typeBrief(text) {
if (typeTween) typeTween.kill();
if (reduce || !revealed) { typeEl.textContent = text; return; }
const o = { n: 0 };
typeEl.textContent = '';
typeTween = gsap.to(o, { n: text.length, duration: text.length * .035, ease: 'none', delay: .2,
onUpdate: () => { typeEl.textContent = text.slice(0, Math.round(o.n)); } });
}
/* ---------- tab pill ---------- */
let pillTl;
const tabRect = b => ({ x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth, h: b.offsetHeight });
function snapPill() {
const r = tabRect(tabs[tab]);
gsap.set(pill, { x: r.x, y: r.y, width: r.w, height: r.h });
}
snapPill();
tabsEl.classList.add('kc-has-pill');
if ('ResizeObserver' in window) new ResizeObserver(() => { if (!pillTl || !pillTl.isActive()) snapPill(); }).observe(tabsEl);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(snapPill);
/* ---------- tabs ---------- */
function setTab(t, focusTab) {
if (t === tab) return;
const prev = tab;
tab = t;
tabs.forEach((b, i) => { const on = i === t; b.setAttribute('aria-selected', on); b.tabIndex = on ? 0 : -1; });
panel.setAttribute('aria-labelledby', tabs[t].id);
if (focusTab) tabs[t].focus();
const data = TABS[t];
const swaps = nodes.map((n, i) => {
const [title, dur, chip, tone] = data.n[i];
const nameEl = n.querySelector('[data-kc-name]'), durEl = n.querySelector('[data-kc-dur]'), chipEl = n.querySelector('[data-kc-chip]');
return { els: [durEl, nameEl, chipEl], apply: () => {
nameEl.textContent = title; durEl.textContent = dur; chipEl.textContent = chip;
chipEl.classList.toggle('kc-chip--rose', tone === 'rose'); chipEl.classList.toggle('kc-chip--sky', tone === 'sky');
} };
});
const applyAll = () => { swaps.forEach(s => s.apply()); descEl.textContent = data.desc; drawWires(); };
const detailNow = () => { if (sel >= 0) detailK.textContent = detailKey(sel); };
if (reduce) {
applyAll(); detailNow(); snapPill(); applyState(false); typeBrief(data.brief);
return;
}
if (pillTl) pillTl.progress(1).kill();
const a = tabRect(tabs[prev]), b = tabRect(tabs[t]);
const tl = pillTl = gsap.timeline({ defaults: { overwrite: 'auto' } });
// pill: stretch across both tabs, then settle onto the new one (same row); glide when it changes rows
if (Math.abs(a.y - b.y) < 2) {
const l = Math.min(a.x, b.x), r = Math.max(a.x + a.w, b.x + b.w);
tl.to(pill, { x: l, width: r - l, duration: .22, ease: 'power2.in' }, 0)
.to(pill, { x: b.x, width: b.w, duration: .55, ease: 'expo.out' }, .22);
} else {
tl.to(pill, { x: b.x, y: b.y, width: b.w, height: b.h, duration: .6, ease: 'expo.out' }, 0);
}
// labels: slide up + out, swap text, slide in from below
const els = swaps.flatMap(s => s.els);
tl.to(els.concat(descEl), { autoAlpha: 0, y: -8, duration: .18, ease: 'power2.in', stagger: .008 }, 0)
.add(() => { applyAll(); detailNow(); }, .26)
.fromTo(els.concat(descEl), { autoAlpha: 0, y: 10 }, { autoAlpha: 1, y: 0, duration: .45, ease: KF.ease.soft, stagger: .012, immediateRender: false }, .27);
if (revealed) {
applyState(true, tl, .3);
tl.fromTo(data.focus.map(i => nodes[i]), { y: 8 }, { y: 0, duration: .55, stagger: .08, ease: 'back.out(2.5)', clearProps: 'transform', immediateRender: false }, .3);
} else applyState(false);
typeBrief(data.brief);
}
tabs.forEach((b, i) => {
b.addEventListener('click', () => setTab(i));
b.addEventListener('keydown', e => {
let j = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') j = 0; else if (e.key === 'End') j = tabs.length - 1;
if (j !== null) { e.preventDefault(); setTab(j, true); }
});
});
/* ---------- hover: lift, tilt toward pointer, pulse ports (mouse only) ---------- */
const tilt = cards.map(c => {
if (reduce) return null;
gsap.set(c, { transformPerspective: 700 });
return {
rx: gsap.quickTo(c, 'rotationX', { duration: .5, ease: 'power3.out' }),
ry: gsap.quickTo(c, 'rotationY', { duration: .5, ease: 'power3.out' }),
y: gsap.quickTo(c, 'y', { duration: .5, ease: 'power3.out' })
};
});
const pulses = new Map();
function pulsePorts(i, on) {
portsOf(i).forEach(p => {
const old = pulses.get(p); if (old) { old.kill(); pulses.delete(p); }
if (on) {
gsap.to(p.dot, { scale: 1.35, transformOrigin: '50% 50%', duration: .3, ease: 'back.out(3)' });
pulses.set(p, gsap.fromTo(p.ring, { scale: 1, opacity: .9, transformOrigin: '50% 50%' },
{ scale: 2.8, opacity: 0, duration: 1, ease: 'power2.out', repeat: -1, repeatDelay: .15 }));
} else {
gsap.to(p.dot, { scale: 1, duration: .3, ease: 'power2.out' });
gsap.to(p.ring, { opacity: 0, duration: .2 });
}
});
}
const canHover = () => !reduce && fine.matches && !isMobile();
nodes.forEach((n, i) => {
const c = cards[i];
c.addEventListener('pointerenter', e => {
if (e.pointerType !== 'mouse' || !canHover() || dragIdx >= 0) return;
c.classList.add('kc-lift'); tilt[i].y(-6); pulsePorts(i, true);
});
c.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || !canHover() || dragIdx >= 0) return;
const r = c.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
tilt[i].ry(px * 14); tilt[i].rx(-py * 12);
});
c.addEventListener('pointerleave', () => {
if (!tilt[i] || dragIdx === i) return;
c.classList.remove('kc-lift'); tilt[i].rx(0); tilt[i].ry(0); tilt[i].y(0); pulsePorts(i, false);
});
});
/* ---------- drag (desktop/tablet only) ---------- */
function moveTo(i, l, t) {
const n = nodes[i], W = stage.clientWidth, H = stage.clientHeight;
const maxL = W - n.offsetWidth - PAD, maxT = H - n.offsetHeight - PAD;
l = Math.min(Math.max(l, PAD), Math.max(PAD, maxL));
t = Math.min(Math.max(t, PAD), Math.max(PAD, maxT));
n.style.left = l + 'px'; n.style.top = t + 'px';
pos[i] = [maxL > PAD ? (l - PAD) / (maxL - PAD) : 0, maxT > PAD ? (t - PAD) / (maxT - PAD) : 0];
drawWires();
}
nodes.forEach((n, i) => {
const card = cards[i];
card.addEventListener('pointerdown', e => {
if (isMobile() || e.button !== 0) return;
const sx = e.clientX, sy = e.clientY, sl = n.offsetLeft, st = n.offsetTop;
let dragging = false;
const move = ev => {
const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!dragging && Math.hypot(dx, dy) > 5) {
dragging = true; dragIdx = i; n.classList.add('kc-node--drag');
nodes.forEach(o => { o.style.zIndex = o === n ? 2 : 1; }); // last-dragged node stays on top
if (!reduce) {
gsap.to(card, { rotation: -1.5, scale: 1.04, duration: .25, ease: 'power3.out' });
tilt[i].rx(0); tilt[i].ry(0); tilt[i].y(-4);
}
}
if (dragging) { ev.preventDefault(); moveTo(i, sl + dx, st + dy); }
};
const up = () => {
removeEventListener('pointermove', move);
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
if (dragging) {
n.classList.remove('kc-node--drag'); dragIdx = -1;
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
if (!reduce) {
const still = card.matches(':hover');
gsap.to(card, { rotation: 0, scale: 1, duration: .6, ease: 'elastic.out(1,.5)' });
tilt[i].y(still ? -6 : 0);
if (!still) { card.classList.remove('kc-lift'); pulsePorts(i, false); }
}
}
};
addEventListener('pointermove', move, { passive: false });
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
});
card.addEventListener('click', e => {
if (suppressClick) { e.preventDefault(); return; }
select(i);
});
// keyboard nudge as the non-pointer alternative to dragging
card.addEventListener('keydown', e => {
if (isMobile()) return;
const step = e.shiftKey ? 48 : 16;
const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
if (!d) return;
e.preventDefault();
moveTo(i, n.offsetLeft + d[0], n.offsetTop + d[1]);
});
});
cards.forEach(c => c.setAttribute('aria-describedby', 'kc-hint'));
const hint = document.createElement('span');
hint.id = 'kc-hint'; hint.hidden = true;
hint.textContent = 'Press to inspect this stage. Arrow keys move the node on the board.';
root.appendChild(hint);
stage.addEventListener('click', e => { if (e.target === stage || e.target.classList.contains('kc-nodes')) { if (sel >= 0) select(sel); } });
root.querySelector('[data-kc-reset]').addEventListener('click', () => {
const from = pos.map(p => p.slice());
const o = { t: 0 };
const done = () => { pos = LAYOUT.map(p => p.slice()); place(); };
if (reduce) return done();
gsap.to(o, { t: 1, duration: .7, ease: 'power3.inOut',
onUpdate: () => { pos = from.map((p, i) => [p[0] + (LAYOUT[i][0] - p[0]) * o.t, p[1] + (LAYOUT[i][1] - p[1]) * o.t]); place(); },
onComplete: done });
});
/* ---------- layout lifecycle ---------- */
place();
if ('ResizeObserver' in window) new ResizeObserver(() => place()).observe(stage);
else addEventListener('resize', place);
const onMq = () => { place(); applyState(false); KF.refresh(); };
mq.addEventListener ? mq.addEventListener('change', onMq) : mq.addListener(onMq);
// thumbnails can change node heights once decoded
root.querySelectorAll('.kc-thumb img').forEach(img => { if (!img.complete) img.addEventListener('load', place, { once: true }); });
applyState(false);
/* ---------- scroll depth: board tilt, grid drift, floating chips ---------- */
if (!reduce) {
const mm = gsap.matchMedia();
mm.add({ desk: '(min-width: 768px)', big: '(min-width: 1024px)' }, ctx => {
const { desk, big } = ctx.conditions;
gsap.fromTo(board, { rotationX: desk ? 25 : 14, scale: desk ? .88 : .94, y: desk ? 80 : 40 },
{ rotationX: 0, scale: 1, y: 0, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'center center', scrub: .6 } });
gsap.fromTo(grid, { y: desk ? -44 : -24 }, { y: desk ? 44 : 24, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'bottom top', scrub: true } });
if (big) {
const speed = [[70, -90, -6], [-40, 60, 5], [30, -50, -4], [-80, 110, 7]];
floats.forEach((f, i) => {
const [a, b, r] = speed[i];
gsap.fromTo(f, { y: a, rotation: -r }, { y: b, rotation: r, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'bottom top', scrub: .4 + i * .25 } });
});
}
});
}
/* ---------- signal loop (paused offscreen, along with the caret blink) ---------- */
let signal = null;
if (!reduce) {
signal = gsap.timeline({ repeat: -1, paused: true, repeatDelay: .6 });
wires.forEach(w => signal.fromTo(w.sig, { strokeDashoffset: .1 }, { strokeDashoffset: -1, duration: .85, ease: 'none', immediateRender: false }));
}
root.classList.add('kc-idle');
ScrollTrigger.create({ trigger: board, start: 'top bottom', end: 'bottom top',
onToggle: s => {
root.classList.toggle('kc-idle', !s.isActive);
if (!signal || !revealed) return;
s.isActive ? signal.play() : signal.pause();
} });
/* ---------- reveal: one timeline, nodes pop along the pipeline, wires draw in sequence ---------- */
if (reduce) {
wires.forEach(w => gsap.set(w.hi, { strokeDashoffset: w.on ? 0 : 1 }));
} else {
gsap.set(nodes, { autoAlpha: 0, scale: .6, y: 26, rotation: i => (i % 2 ? 7 : -7) });
gsap.set(wires.map(w => w.base), { strokeDasharray: 1, strokeDashoffset: 1 });
gsap.set(wires.flatMap(w => [w.pa.g, w.pb.g]), { opacity: 0 });
typeEl.textContent = '';
const tl = gsap.timeline({ paused: true, onComplete: () => {
revealed = true;
applyState(true);
typeBrief(TABS[tab].brief);
if (ScrollTrigger.isInViewport(board)) signal.play();
} });
nodes.forEach((n, i) => {
const at = i * .2;
tl.to(n, { autoAlpha: 1, scale: 1, y: 0, rotation: 0, duration: .7, ease: 'back.out(2)', clearProps: 'transform' }, at);
if (i < wires.length) {
const w = wires[i];
tl.to(w.pa.g, { opacity: 1, duration: .2 }, at + .28)
.fromTo(w.pa.dot, { scale: 0, transformOrigin: '50% 50%' }, { scale: 1, duration: .35, ease: 'back.out(3)' }, at + .28)
.to(w.base, { strokeDashoffset: 0, duration: .42, ease: 'power2.inOut' }, at + .32)
.to(w.pb.g, { opacity: 1, duration: .2 }, at + .66)
.fromTo(w.pb.dot, { scale: 0, transformOrigin: '50% 50%' }, { scale: 1, duration: .35, ease: 'back.out(3)' }, at + .66);
}
});
ScrollTrigger.create({ trigger: board, start: 'top 72%', once: true, onEnter: () => tl.play() });
}
KF.refresh();
});
</script>
<section id="directors" class="kd sec" aria-labelledby="kd-title">
<div class="wrap kd-wrap">
<header class="kd-head">
<div class="kd-intro">
<p class="label label--rose">Roster</p>
<h2 class="display kd-title" id="kd-title">Six directors.<br><em>Six ways of seeing.</em></h2>
<p class="lede kd-lede">Each one signed for how they look at the world, not how neatly they fit a reel. Filter by the kind of work you are making, then open a row to meet them.</p>
</div>
<div class="kd-filters" role="group" aria-label="Filter directors by discipline">
<span class="kd-pill" aria-hidden="true"></span>
<button class="kd-chip" type="button" data-filter="all" aria-pressed="true">All</button>
<button class="kd-chip" type="button" data-filter="commercial" aria-pressed="false">Commercial</button>
<button class="kd-chip" type="button" data-filter="fashion" aria-pressed="false">Fashion</button>
<button class="kd-chip" type="button" data-filter="documentary" aria-pressed="false">Documentary</button>
</div>
</header>
<p class="kd-sr" aria-live="polite"></p>
<div class="kd-stage">
<div class="kd-bg" aria-hidden="true">
<div class="kd-bg-row">Tomás Ferreira · Ada Kwon · Jun Park · Lena Brandt · Walter Imrie · Noor Haddad · Tomás Ferreira · Ada Kwon · Jun Park · Lena Brandt · Walter Imrie · Noor Haddad · </div>
<div class="kd-bg-row">Noor Haddad · Walter Imrie · Lena Brandt · Jun Park · Ada Kwon · Tomás Ferreira · Noor Haddad · Walter Imrie · Lena Brandt · Jun Park · Ada Kwon · Tomás Ferreira · </div>
</div>
<ul class="kd-list">
<!-- d1 -->
<li class="kd-item" data-tags="commercial documentary" data-img="assets/d1.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b1" aria-expanded="false" aria-controls="kd-p1">
<img class="kd-thumb" src="assets/d1.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">01</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Tomás Ferreira</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Lisbon · Cars & landscape · 14 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p1" role="region" aria-labelledby="kd-b1" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d1.jpg" alt="Portrait of director Tomás Ferreira" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Tomás grew up on the coast road north of Lisbon and still shoots it like a first love: low sun, long lenses, the car as one small thing inside a very big country. He prefers real roads to the volume stage and will happily wait three days for the right fog.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Long Way Round</span><span class="kd-wc">Halcyon Motors</span><span class="kd-wy">2026</span></a></li>
<li><span class="kd-wt">Atlantic Hours</span><span class="kd-wc">Spec spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Serra</span><span class="kd-wc">Short documentary</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Tomás <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d2 -->
<li class="kd-item" data-tags="fashion commercial" data-img="assets/d2.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b2" aria-expanded="false" aria-controls="kd-p2">
<img class="kd-thumb" src="assets/d2.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">02</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Ada Kwon</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Seoul · Fashion & portrait · 9 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p2" role="region" aria-labelledby="kd-b2" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d2.jpg" alt="Portrait of director Ada Kwon" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Ada came to film from still portraiture, and her frames keep that stillness: faces held a beat longer than feels comfortable, colour pushed until it hums. She casts off the street as often as off the agency book.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Pink Hour</span><span class="kd-wc">Maison Oriel</span><span class="kd-wy">2026</span></a></li>
<li><span class="kd-wt">Glasshouse</span><span class="kd-wc">Lookbook film</span><span class="kd-wy">2025</span></li>
<li><span class="kd-wt">Soft Signal</span><span class="kd-wc">Music video</span><span class="kd-wy">2024</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Ada <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d3 -->
<li class="kd-item" data-tags="commercial" data-img="assets/d3.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b3" aria-expanded="false" aria-controls="kd-p3">
<img class="kd-thumb" src="assets/d3.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">03</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Jun Park</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Seoul · Sport & kinetic · 11 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p3" role="region" aria-labelledby="kd-b3" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d3.jpg" alt="Portrait of director Jun Park" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Jun builds pictures around momentum: cameras on cables, rigs bolted to handlebars, cuts that land on the beat of a racing pulse. A former track cyclist, he knows exactly where the pain lives in a climb and points the lens straight at it.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">The Climb</span><span class="kd-wc">Velo Norte</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Split Second</span><span class="kd-wc">Sportswear spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Night Laps</span><span class="kd-wc">Spec spot</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Jun <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d4 -->
<li class="kd-item" data-tags="commercial fashion" data-img="assets/d4.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b4" aria-expanded="false" aria-controls="kd-p4">
<img class="kd-thumb" src="assets/d4.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">04</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Lena Brandt</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Berlin · Dance & music · 12 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p4" role="region" aria-labelledby="kd-b4" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d4.jpg" alt="Portrait of director Lena Brandt" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Lena blocks the camera as carefully as the performers, so bodies and lens move to a single count. Her sets feel like rehearsal rooms, loose and loud right up until the take, then precise to the frame.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">The City Is a Stage</span><span class="kd-wc">Stride Athletic</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Tempo Rubato</span><span class="kd-wc">Music film</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Floorwork</span><span class="kd-wc">Dance short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Lena <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d5 -->
<li class="kd-item" data-tags="commercial" data-img="assets/d5.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b5" aria-expanded="false" aria-controls="kd-p5">
<img class="kd-thumb" src="assets/d5.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">05</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Walter Imrie</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Toronto · Product & tabletop · 17 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p5" role="region" aria-labelledby="kd-b5" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d5.jpg" alt="Portrait of director Walter Imrie" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Walter treats objects like actors, lighting a watch face the way others light a close-up and moving the camera a millimetre at a time. His motion-control work is patient, exact and, when you look twice, quietly funny.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Second Nature</span><span class="kd-wc">Aurel Horology</span><span class="kd-wy">2024</span></a></li>
<li><span class="kd-wt">Pour</span><span class="kd-wc">Beverage spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Small Weather</span><span class="kd-wc">Tabletop short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Walter <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d6 -->
<li class="kd-item" data-tags="documentary" data-img="assets/d6.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b6" aria-expanded="false" aria-controls="kd-p6">
<img class="kd-thumb" src="assets/d6.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">06</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Noor Haddad</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Mexico City · Documentary & branded short · 8 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p6" role="region" aria-labelledby="kd-b6" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d6.jpg" alt="Portrait of director Noor Haddad" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Noor starts every job by listening, spending weeks in conversation before a single frame so the people on screen are collaborators rather than subjects. Her branded shorts play like documentaries because, in every way that matters, they are.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Last Light</span><span class="kd-wc">Salt & Current</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Mercado</span><span class="kd-wc">Documentary short</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">The Weavers</span><span class="kd-wc">Branded short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Noor <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
<span class="kd-endline" aria-hidden="true"></span>
</div>
</div>
<div class="kd-float" aria-hidden="true">
<div class="kd-float-tilt">
<div class="kd-float-clip">
<img class="kd-fimg" src="assets/d1.jpg" alt="" width="280" height="420">
<img class="kd-fimg" src="assets/d1.jpg" alt="" width="280" height="420">
</div>
</div>
</div>
</section>
<style>
.kd{position:relative;overflow-x:clip}
.kd-wrap{position:relative;z-index:1}
.kd-head{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:28px 48px;margin-bottom:clamp(40px,6vw,88px)}
.kd-intro{max-width:880px}
.kd-title{font-size:clamp(40px,6.2vw,100px);margin-top:18px}
.kd-lede{margin:22px 0 0}
.kd-sr,.kd-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* filter chips + sliding rose pill */
.kd-filters{position:relative;isolation:isolate;display:flex;flex-wrap:wrap;gap:8px}
.kd-pill{position:absolute;left:0;top:0;width:0;height:0;border-radius:999px;background:var(--rose);box-shadow:var(--shadow-rose);pointer-events:none}
.kd-chip{position:relative;font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;padding:10px 16px;border-radius:999px;
background:transparent;color:var(--ink);transition:color .3s var(--ease)}
.kd-chip::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:var(--paper);box-shadow:inset 0 0 0 1px var(--line);
transition:opacity .3s var(--ease),box-shadow .25s var(--ease)}
.kd-chip[aria-pressed="false"]:hover{color:var(--rose)}
.kd-chip[aria-pressed="false"]:hover::before{box-shadow:inset 0 0 0 1px var(--rose)}
.kd-chip[aria-pressed="true"]{color:#fff;transition-delay:.12s}
.kd-chip[aria-pressed="true"]::before{opacity:0;transition-delay:.08s}
/* outline name marquee behind the list (desktop) */
.kd-stage{position:relative;isolation:isolate}
.kd-bg{position:absolute;left:50%;width:100vw;margin-left:-50vw;top:-2%;bottom:-2%;z-index:-1;display:flex;flex-direction:column;justify-content:space-around;
overflow:hidden;pointer-events:none;user-select:none;
-webkit-mask-image:linear-gradient(rgba(27,21,48,0),rgba(27,21,48,1) 18%,rgba(27,21,48,1) 82%,rgba(27,21,48,0));mask-image:linear-gradient(rgba(27,21,48,0),rgba(27,21,48,1) 18%,rgba(27,21,48,1) 82%,rgba(27,21,48,0))}
.kd-bg-row{width:max-content;white-space:nowrap;font-stretch:125%;font-weight:800;font-size:clamp(120px,15vw,260px);line-height:1;letter-spacing:-.04em;
color:transparent;-webkit-text-stroke:1.5px var(--line);will-change:transform}
.kd-bg-row:nth-child(2){margin-left:-40vw}
.kd-list{position:relative;list-style:none;margin:0;padding:0}
.kd-item{position:relative}
.kd-item.is-out{display:none}
.kd-inner{position:relative}
.kd-line,.kd-endline{display:block;height:1px;background:var(--line);transform-origin:0 50%}
.kd-line{position:absolute;left:0;right:0;top:0}
.kd-h{margin:0;font:inherit}
.kd-row{width:100%;display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px 24px;
padding:clamp(18px,2.2vw,30px) 0;text-align:left;border-radius:6px}
.kd-row:focus-visible{outline-offset:-2px}
.kd-thumb{display:none;width:56px;height:56px;border-radius:50%;object-fit:cover;background:var(--rose-soft)}
.kd-num{font-family:var(--mono);font-size:12px;color:var(--mute);align-self:start;padding-top:.9em}
.kd-mask{display:block;min-width:0;clip-path:inset(-2px -60vw 0 -2px);padding-bottom:.04em;will-change:transform}
.kd-rise{display:block}
.kd-name{display:inline-flex;align-items:center;gap:.22em;max-width:100%;font-stretch:125%;font-weight:700;
font-size:clamp(34px,6.4vw,96px);line-height:.98;letter-spacing:-.035em;color:var(--ink);
transition:transform .55s var(--ease),color .35s var(--ease)}
.kd-nm{min-width:0}
/* two-layer char roll: ink glyph on top, rose copy parked below inside the same mask */
.kd-nm .kf-m{padding-top:.14em;margin-top:-.14em}
.kd-nm .kf-i{position:relative}
.kd-ch2{position:absolute;left:0;top:0;transform:translateY(125%);color:var(--rose)}
.kd-arrow{flex:none;width:.5em;height:.5em;color:var(--rose);opacity:0;transform:translateX(-.3em);
transition:opacity .35s var(--ease),transform .55s var(--ease)}
.kd-meta{font-family:var(--mono);font-size:12.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--mute);
text-align:right;white-space:nowrap;transition:color .35s var(--ease)}
.kd-row[aria-expanded="true"] .kd-name{color:var(--rose)}
.kd-row:focus-visible .kd-arrow,.kd-row[aria-expanded="true"] .kd-arrow{opacity:1;transform:none}
.kd-row[aria-expanded="true"] .kd-arrow{transform:rotate(90deg)}
.kd-row:focus-visible .kd-meta,.kd-row[aria-expanded="true"] .kd-meta{color:var(--ink)}
@media (hover:hover){
.kd-row:hover .kd-name{transform:translateX(24px)}
.kd-row:hover .kd-arrow{opacity:1;transform:none}
.kd-row[aria-expanded="true"]:hover .kd-arrow{transform:rotate(90deg)}
.kd-row:hover .kd-meta{color:var(--ink)}
}
@media (min-width:761px){
.kd-row:focus-visible .kd-name,.kd-row[aria-expanded="true"] .kd-name{transform:translateX(24px)}
}
/* accordion (height driven by GSAP) */
.kd-panel{height:0;overflow:hidden}
.kd-panel.is-open{height:auto}
.kd-panel.is-open.is-settled{overflow:visible}
.kd-panel-in{display:flow-root}
.kd-card{display:grid;grid-template-columns:minmax(0,240px) minmax(0,1fr);gap:clamp(20px,3.4vw,56px);
padding:clamp(14px,1.6vw,22px);margin:4px 0 clamp(26px,3vw,40px)}
.kd-portrait{width:100%;height:auto;aspect-ratio:2/3;object-fit:cover;border-radius:12px;background:var(--rose-soft)}
.kd-body{display:flex;flex-direction:column;align-items:flex-start;gap:14px;padding:clamp(4px,1.4vw,20px) clamp(0px,1.4vw,20px) 6px 0}
.kd-bio{margin:0 0 10px;font-size:clamp(17px,1.5vw,22px);line-height:1.45;color:var(--ink);max-width:48ch}
.kd-cta{display:inline-block;transform-origin:0 50%}
.kd-works{list-style:none;margin:0 0 14px;padding:0;width:100%;max-width:560px}
.kd-works li{border-top:1px solid var(--line)}
.kd-works li:last-child{border-bottom:1px solid var(--line)}
.kd-works a,.kd-works li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px 16px;align-items:baseline;padding:11px 0}
.kd-works li:has(a){padding:0}
.kd-works a{padding:11px 0;transition:color .25s var(--ease)}
.kd-works a:hover .kd-wt{color:var(--rose)}
.kd-works a .kd-wt{text-decoration:underline;text-decoration-color:var(--rose);text-underline-offset:4px;text-decoration-thickness:1.5px}
.kd-wt{font-weight:700;transition:color .25s var(--ease)}
.kd-wc{color:var(--mute);font-size:14px}
.kd-wy{font-family:var(--mono);font-size:12px;color:var(--mute)}
/* floating cursor portrait (fine pointers only): float = position, tilt = velocity lean, clip = show/hide wipe */
.kd-float{position:fixed;left:0;top:0;z-index:40;width:clamp(200px,19vw,280px);aspect-ratio:2/3;pointer-events:none;visibility:hidden;will-change:transform}
.kd-float-tilt{width:100%;height:100%;filter:drop-shadow(0 26px 28px rgba(27,21,48,.32));will-change:transform;transform-origin:50% 0}
.kd-float-clip{position:relative;width:100%;height:100%;border-radius:14px;overflow:hidden;clip-path:inset(100% 0% 0% 0% round 14px);background:var(--rose-soft)}
.kd-fimg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.kd-fimg + .kd-fimg{clip-path:inset(100% 0% 0% 0%)}
/* touch + small screens: no floating image or marquee, round thumbnail instead */
@media (hover:none),(max-width:760px){
.kd-float,.kd-bg{display:none}
.kd-row{grid-template-columns:56px minmax(0,1fr);gap:8px 14px;align-items:start}
.kd-thumb{display:block;grid-row:1 / span 2}
.kd-num{display:none}
.kd-meta{grid-column:2;text-align:left;white-space:normal;font-size:11.5px;line-height:1.5}
.kd-name{font-size:clamp(34px,9vw,64px)}
}
@media (max-width:640px){
.kd-card{grid-template-columns:1fr}
.kd-portrait{width:min(180px,55%)}
.kd-body{padding:0}
.kd-works a,.kd-works li{grid-template-columns:minmax(0,1fr) auto}
.kd-wc{grid-column:1;grid-row:2}
.kd-wy{grid-column:2;grid-row:1}
}
@media (prefers-reduced-motion: reduce) and (update: none){
.kd-name,.kd-arrow,.kd-chip,.kd-chip::before{transition:none !important}
.kd-bg{display:none}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('directors');
if (!root) return;
const reduce = !!KF.reduce;
const E = KF.ease || { out: 'expo.out', soft: 'power3.out', io: 'power2.inOut' };
const items = [...root.querySelectorAll('.kd-item')];
const list = root.querySelector('.kd-list');
const status = root.querySelector('p.kd-sr');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
/* preload every portrait so the floating image swaps instantly */
items.forEach(it => { const im = new Image(); im.src = it.dataset.img; });
/* visually-hidden text copy for assistive tech, then the visible node can be split freely */
const srCopy = el => {
const vh = document.createElement(el.tagName === 'P' ? 'p' : 'span');
vh.className = 'kd-vh'; vh.textContent = el.textContent.replace(/\s+/g, ' ').trim();
el.before(vh);
};
const splitHidden = (el, mode) => {
srCopy(el);
const parts = KF.split(el, mode);
el.removeAttribute('aria-label'); el.setAttribute('aria-hidden', 'true');
return parts;
};
/* ---------- names: chars with a rose twin parked under each glyph ---------- */
const nameChars = items.map(it => {
const chars = splitHidden(it.querySelector('.kd-nm'), 'chars');
chars.forEach(c => {
const twin = document.createElement('span');
twin.className = 'kd-ch2'; twin.textContent = c.textContent;
c.appendChild(twin);
});
return chars;
});
const roll = (i, on) => {
if (reduce) return;
gsap.to(nameChars[i], {
yPercent: on ? -125 : 0, duration: on ? 0.6 : 0.5, ease: on ? E.out : 'power3.inOut',
stagger: 0.022, overwrite: true
});
};
/* bios are split into words once; lines are worked out on each open so resizes stay correct */
const bioWords = new Map();
items.forEach(it => { const bio = it.querySelector('.kd-bio'); bioWords.set(it, splitHidden(bio, 'words')); });
const lineIndex = words => {
let top = null, line = -1;
return words.map(w => { const t = w.parentNode.offsetTop; if (top === null || t > top + 4) { top = t; line++; } return line; });
};
/* ---------- heading + intro ---------- */
KF.reveal(root.querySelector('.kd-title'), { mode: 'chars', stagger: 0.022 });
const chips = [...root.querySelectorAll('.kd-chip')];
const pill = root.querySelector('.kd-pill');
if (!reduce) {
const intro = [root.querySelector('.kd-intro .label'), root.querySelector('.kd-lede')];
gsap.set(intro, { autoAlpha: 0, y: 28 });
gsap.set(chips, { autoAlpha: 0, yPercent: 90 });
gsap.set(pill, { autoAlpha: 0 });
gsap.timeline({ scrollTrigger: { trigger: root.querySelector('.kd-head'), start: 'top 85%', once: true } })
.to(intro, { autoAlpha: 1, y: 0, duration: 1, ease: E.soft, stagger: 0.12 }, 0)
.to(chips, { autoAlpha: 1, yPercent: 0, duration: 0.9, ease: E.out, stagger: 0.07 }, 0.35)
.to(pill, { autoAlpha: 1, duration: 0.5, ease: 'power2.out' }, 0.6);
}
/* ---------- accordion ---------- */
function panelParts(it) {
const panel = it.querySelector('.kd-panel');
return {
btn: it.querySelector('.kd-row'), panel, inner: panel.firstElementChild,
card: panel.querySelector('.kd-card'), portrait: panel.querySelector('.kd-portrait'),
words: bioWords.get(it), cta: panel.querySelector('.kd-cta'),
rest: [panel.querySelector('.kd-body > .label'), ...panel.querySelectorAll('.kd-works li')]
};
}
function setOpen(it, open, instant) {
const p = panelParts(it);
const { btn, panel, inner, card, portrait, words, cta, rest } = p;
if ((btn.getAttribute('aria-expanded') === 'true') === open) return;
btn.setAttribute('aria-expanded', String(open));
panel.inert = !open;
if (panel._tl) panel._tl.kill();
panel.classList.remove('is-settled');
const all = [card, portrait, cta, ...words, ...rest];
if (instant || reduce) {
panel.classList.toggle('is-open', open);
gsap.set(panel, { clearProps: 'height' });
gsap.set(all, { clearProps: 'opacity,visibility,transform,clipPath' });
if (open) panel.classList.add('is-settled');
KF.refresh();
return;
}
const from = panel.offsetHeight;
if (open) {
panel.classList.add('is-open');
const to = inner.offsetHeight;
const lines = lineIndex(words);
panel._tl = gsap.timeline({ onComplete() { gsap.set(panel, { clearProps: 'height' }); panel.classList.add('is-settled'); KF.refresh(); } })
.fromTo(panel, { height: from }, { height: to, duration: 0.85, ease: 'power3.inOut' }, 0)
.fromTo(card, { autoAlpha: 0, y: 28 }, { autoAlpha: 1, y: 0, duration: 0.8, ease: E.out }, 0.12)
.fromTo(portrait, { clipPath: 'inset(100% 0% 0% 0% round 12px)', scale: 1.18 },
{ clipPath: 'inset(0% 0% 0% 0% round 12px)', scale: 1, duration: 1.1, ease: E.out }, 0.22)
.fromTo(words, { yPercent: 115 }, { yPercent: 0, duration: 0.85, ease: E.out, stagger: i => lines[i] * 0.08 }, 0.3)
.fromTo(rest, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.6, ease: E.soft, stagger: 0.06 }, 0.5)
.fromTo(cta, { autoAlpha: 0, scale: 0.4 }, { autoAlpha: 1, scale: 1, duration: 0.75, ease: 'back.out(2.4)' }, 0.72);
} else {
panel._tl = gsap.timeline({ onComplete() { panel.classList.remove('is-open'); gsap.set(panel, { clearProps: 'height' }); KF.refresh(); } })
.to(cta, { autoAlpha: 0, scale: 0.5, duration: 0.25, ease: 'power2.in' }, 0)
.to(rest, { autoAlpha: 0, y: 10, duration: 0.25, ease: 'power2.in', stagger: { each: 0.03, from: 'end' } }, 0)
.to(words, { yPercent: -115, duration: 0.35, ease: 'power2.in', stagger: 0.003 }, 0)
.to(portrait, { clipPath: 'inset(0% 0% 100% 0% round 12px)', duration: 0.45, ease: 'power3.in' }, 0.05)
.to(card, { autoAlpha: 0, y: -12, duration: 0.35, ease: 'power2.in' }, 0.2)
.fromTo(panel, { height: from }, { height: 0, duration: 0.6, ease: 'power3.inOut' }, 0.25);
}
}
/* ---------- filter: hand-rolled flip (first / last / invert / play) + sliding pill ---------- */
const placePill = (chip, animate) => {
const v = { x: chip.offsetLeft, y: chip.offsetTop, width: chip.offsetWidth, height: chip.offsetHeight };
if (!animate || reduce) gsap.set(pill, v);
else gsap.to(pill, Object.assign(v, { duration: 0.7, ease: E.out, overwrite: 'auto' }));
};
const pressed = () => chips.find(c => c.getAttribute('aria-pressed') === 'true') || chips[0];
placePill(pressed(), false);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => placePill(pressed(), false));
addEventListener('resize', () => placePill(pressed(), false), { passive: true });
const leaving = new Set();
function applyFilter(f) {
const want = items.map(it => f === 'all' || it.dataset.tags.split(' ').includes(f));
if (reduce) {
items.forEach((it, i) => { if (!want[i]) setOpen(it, false, true); it.classList.toggle('is-out', !want[i]); it.inert = !want[i]; });
return;
}
/* FIRST: where every on-screen row is right now (mid-flight transforms included) */
const lt0 = list.getBoundingClientRect().top;
const h0 = list.getBoundingClientRect().height;
const first = new Map();
items.forEach(it => { if (!it.classList.contains('is-out')) first.set(it, it.getBoundingClientRect().top - lt0); });
/* settle anything still animating from a previous click */
gsap.killTweensOf([list, ...items]);
const wasLeaving = new Set(leaving);
leaving.forEach(it => { it.classList.add('is-out'); setOpen(it, false, true); });
leaving.clear();
gsap.set([list, ...items], { clearProps: 'all' });
const stay = [], enter = [], exit = [];
items.forEach((it, i) => {
const wasIn = first.has(it);
if (want[i]) { it.classList.remove('is-out'); it.inert = false; (wasIn ? stay : enter).push(it); }
else if (wasIn && !wasLeaving.has(it)) exit.push(it);
else { it.classList.add('is-out'); it.inert = true; }
});
/* exits leave the flow but stay painted where they were */
exit.forEach(it => {
leaving.add(it); it.inert = true;
gsap.set(it, { position: 'absolute', top: first.get(it), left: 0, right: 0, zIndex: 0 });
});
/* LAST + INVERT + PLAY */
const lt1 = list.getBoundingClientRect().top;
const h1 = list.getBoundingClientRect().height;
stay.forEach(it => {
const dy = first.get(it) - (it.getBoundingClientRect().top - lt1);
if (Math.abs(dy) > 0.5) gsap.fromTo(it, { y: dy }, { y: 0, duration: 0.9, ease: E.out, clearProps: 'transform' });
});
enter.forEach((it, k) => {
gsap.fromTo(it, { autoAlpha: 0, y: 40, clipPath: 'inset(0% 0% 100% 0%)' },
{ autoAlpha: 1, y: 0, clipPath: 'inset(0% 0% 0% 0%)', duration: 0.9, delay: 0.15 + k * 0.06, ease: E.out,
clearProps: 'opacity,visibility,transform,clipPath' });
});
exit.forEach(it => {
gsap.to(it, { autoAlpha: 0, y: -16, clipPath: 'inset(0% 0% 100% 0%)', duration: 0.5, ease: 'power3.in',
onComplete() { leaving.delete(it); it.classList.add('is-out'); gsap.set(it, { clearProps: 'all' }); setOpen(it, false, true); KF.refresh(); } });
});
if (Math.abs(h1 - h0) > 0.5) gsap.fromTo(list, { height: h0 }, { height: h1, duration: 0.9, ease: E.out, clearProps: 'height', onComplete: KF.refresh });
}
chips.forEach(chip => chip.addEventListener('click', () => {
if (chip.getAttribute('aria-pressed') === 'true') return;
chips.forEach(c => c.setAttribute('aria-pressed', String(c === chip)));
placePill(chip, true);
applyFilter(chip.dataset.filter);
const shown = items.filter(it => !it.classList.contains('is-out') && !leaving.has(it)).length;
status.textContent = `Showing ${shown} director${shown === 1 ? '' : 's'}`;
hideFloat();
KF.refresh();
}));
/* ---------- floating portrait: quickTo follow, velocity lean, two-image wipe ---------- */
const float = root.querySelector('.kd-float');
const tilt = float.querySelector('.kd-float-tilt');
const clip = float.querySelector('.kd-float-clip');
const imgs = [...clip.querySelectorAll('.kd-fimg')];
const fine = matchMedia('(hover: hover) and (pointer: fine) and (min-width: 761px)');
const xTo = gsap.quickTo(float, 'x', { duration: 0.55, ease: 'power3.out' });
const yTo = gsap.quickTo(float, 'y', { duration: 0.55, ease: 'power3.out' });
const rTo = gsap.quickTo(tilt, 'rotation', { duration: 0.6, ease: 'power3.out' });
const kTo = gsap.quickTo(tilt, 'skewX', { duration: 0.6, ease: 'power3.out' });
let front = 0, active = null, shown = false, ticking = false;
let lx = -1, ly = -1, px = 0, vx = 0, tx = 0, ty = 0;
function aim(cx, cy) {
const w = float.offsetWidth, h = float.offsetHeight, gap = 36;
tx = cx + gap + w > innerWidth - 16 ? cx - gap - w : cx + gap;
ty = clamp(cy - h / 2, 76, Math.max(76, innerHeight - h - 16));
}
function move(jump) {
if (reduce || jump) { gsap.set(float, { x: tx, y: ty }); xTo(tx, tx); yTo(ty, ty); }
else { xTo(tx); yTo(ty); }
}
function lean() {
if (lx < 0) return;
vx += ((lx - px) - vx) * 0.35; px = lx;
rTo(clamp(vx * 0.4, -12, 12));
kTo(clamp(-vx * 0.28, -9, 9));
}
function swap(src) {
const cur = imgs[front], nxt = imgs[1 - front];
if (cur.getAttribute('src') === src) return;
gsap.killTweensOf(nxt);
nxt.setAttribute('src', src);
gsap.set(cur, { zIndex: 1 }); gsap.set(nxt, { zIndex: 2 });
if (reduce) gsap.set(nxt, { clipPath: 'inset(0% 0% 0% 0%)', scale: 1 });
else gsap.fromTo(nxt, { clipPath: 'inset(100% 0% 0% 0%)', scale: 1.25 },
{ clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 0.75, ease: E.out });
front = 1 - front;
}
function show(it, cx, cy) {
if (!fine.matches) return;
if (!shown) {
shown = true;
const cur = imgs[front], other = imgs[1 - front];
gsap.killTweensOf([cur, other]);
cur.setAttribute('src', it.dataset.img);
gsap.set(cur, { zIndex: 2, clipPath: 'inset(0% 0% 0% 0%)' });
gsap.set(other, { zIndex: 1, clipPath: 'inset(100% 0% 0% 0%)' });
aim(cx, cy); move(true);
gsap.set(float, { visibility: 'visible' });
px = cx; vx = 0;
if (reduce) gsap.set(clip, { clipPath: 'inset(0% 0% 0% 0% round 14px)' });
else {
gsap.to(clip, { clipPath: 'inset(0% 0% 0% 0% round 14px)', duration: 0.65, ease: E.out, overwrite: true });
gsap.fromTo(cur, { scale: 1.2 }, { scale: 1, duration: 0.9, ease: E.out });
if (!ticking) { ticking = true; gsap.ticker.add(lean); }
}
} else swap(it.dataset.img);
active = it;
}
function hideFloat() {
active = null;
if (!shown) return;
shown = false;
if (ticking) { ticking = false; gsap.ticker.remove(lean); }
if (reduce) { gsap.set(clip, { clipPath: 'inset(100% 0% 0% 0% round 14px)' }); gsap.set(float, { visibility: 'hidden' }); return; }
rTo(0); kTo(0);
gsap.to(clip, { clipPath: 'inset(0% 0% 100% 0% round 14px)', duration: 0.45, ease: 'power3.in', overwrite: true,
onComplete() { if (!shown) { gsap.set(clip, { clipPath: 'inset(100% 0% 0% 0% round 14px)' }); gsap.set(float, { visibility: 'hidden' }); } } });
}
items.forEach((it, i) => {
const row = it.querySelector('.kd-row');
row.addEventListener('pointerenter', e => {
if (e.pointerType !== 'mouse') return;
roll(i, true); show(it, e.clientX, e.clientY);
});
row.addEventListener('pointerleave', e => {
if (e.pointerType === 'mouse' && !row.matches(':focus-visible')) roll(i, false);
requestAnimationFrame(() => { if (active === it) hideFloat(); });
});
row.addEventListener('focus', () => { if (row.matches(':focus-visible')) roll(i, true); });
row.addEventListener('blur', () => { if (!row.matches(':hover')) roll(i, false); });
row.addEventListener('click', () => {
const open = row.getAttribute('aria-expanded') !== 'true';
items.forEach(o => { if (o !== it) setOpen(o, false); });
setOpen(it, open);
});
});
list.addEventListener('pointermove', e => {
lx = e.clientX; ly = e.clientY;
if (active) { aim(lx, ly); move(false); }
});
addEventListener('scroll', () => {
if (!active || lx < 0) return;
const el = document.elementFromPoint(lx, ly);
const row = el && el.closest('#directors .kd-row');
if (!row) hideFloat();
else if (row.closest('.kd-item') !== active) show(row.closest('.kd-item'), lx, ly);
}, { passive: true });
fine.addEventListener && fine.addEventListener('change', hideFloat);
/* never leave the portrait hanging once the roster is off screen */
ScrollTrigger.create({ trigger: list, start: 'top bottom', end: 'bottom top', onLeave: hideFloat, onLeaveBack: hideFloat });
/* ---------- scroll-in: border draws, then the name rises out of its mask ---------- */
if (!reduce) {
const lines = items.map(it => it.querySelector('.kd-line'));
const rises = items.map(it => it.querySelector('.kd-rise'));
const extras = items.map(it => it.querySelectorAll('.kd-num, .kd-meta, .kd-thumb'));
const endline = root.querySelector('.kd-endline');
gsap.set([...lines, endline], { scaleX: 0 });
gsap.set(rises, { yPercent: 118 });
gsap.set(extras.flatMap(n => [...n]), { autoAlpha: 0, y: 10 });
const tl = gsap.timeline({ scrollTrigger: { trigger: list, start: 'top 82%', once: true } });
items.forEach((it, i) => {
const at = i * 0.12;
tl.to(lines[i], { scaleX: 1, duration: 0.9, ease: 'power3.inOut' }, at)
.to(rises[i], { yPercent: 0, duration: 1, ease: 'power4.out' }, at + 0.32)
.to(extras[i], { autoAlpha: 1, y: 0, duration: 0.6, ease: 'power2.out', stagger: 0.05 }, at + 0.5);
});
tl.to(endline, { scaleX: 1, duration: 0.9, ease: 'power3.inOut' }, items.length * 0.12);
/* scrubbed drift: alternate names slide opposite ways while crossing the viewport */
const amp = () => Math.min(innerWidth * 0.03, 44);
items.forEach((it, i) => {
const d = i % 2 ? -1 : 1;
gsap.fromTo(it.querySelector('.kd-mask'), { x: () => -d * amp() }, {
x: () => d * amp(), ease: 'none',
scrollTrigger: { trigger: it, start: 'top bottom', end: 'bottom top', scrub: 0.8, invalidateOnRefresh: true }
});
});
/* outline name marquee behind the list, desktop only */
const mm = gsap.matchMedia();
mm.add('(min-width: 761px) and (hover: hover)', () => {
const rows = root.querySelectorAll('.kd-bg-row');
const st = { trigger: root, start: 'top bottom', end: 'bottom top', scrub: 1 };
gsap.fromTo(rows[0], { xPercent: 0 }, { xPercent: -22, ease: 'none', scrollTrigger: st });
gsap.fromTo(rows[1], { xPercent: -22 }, { xPercent: 0, ease: 'none', scrollTrigger: Object.assign({}, st) });
});
}
KF.refresh();
});
</script>
<section class="kf sec" id="contact" aria-labelledby="kf-title">
<div class="wrap">
<div class="kf-panel" data-kf-panel>
<div class="kf-orbs" aria-hidden="true">
<span class="kf-orb kf-orb--1" data-kf-depth="34"><i></i></span>
<span class="kf-orb kf-orb--2" data-kf-depth="-22"><i></i></span>
<span class="kf-orb kf-orb--3" data-kf-depth="52"><i></i></span>
<span class="kf-orb kf-orb--4" data-kf-depth="-40"><i></i></span>
<span class="kf-orb kf-orb--5" data-kf-depth="18"><i></i></span>
</div>
<div class="kf-head">
<p class="label kf-label">Start a project</p>
<h2 class="display kf-title" id="kf-title">Got a film<br>in you?</h2>
</div>
<div class="kf-body">
<div class="kf-aside">
<p class="kf-intro">Tell us what you want people to feel. We'll bring the crew, the director and the plan to get it on screen.</p>
<dl class="kf-facts">
<div><dt class="label">Write to us</dt><dd><a class="kf-mail" href="mailto:[email protected]">[email protected]</a></dd></div>
<div><dt class="label">Reply time</dt><dd>One working day</dd></div>
<div><dt class="label">We take on</dt><dd>Commercials, short film, branded work</dd></div>
</dl>
</div>
<div class="kf-formwrap">
<form class="kf-form" novalidate data-kf-form>
<div class="kf-grid">
<div class="kf-field">
<label for="kf-name">Name</label>
<div class="kf-ctl">
<input id="kf-name" name="name" type="text" autocomplete="name" placeholder="Your full name" required aria-describedby="kf-name-err">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-name-err"></p>
</div>
<div class="kf-field">
<label for="kf-email">Email</label>
<div class="kf-ctl">
<input id="kf-email" name="email" type="email" autocomplete="email" inputmode="email" placeholder="[email protected]" required aria-describedby="kf-email-err">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-email-err"></p>
</div>
<div class="kf-field">
<label for="kf-company">Company <span class="kf-opt">(optional)</span></label>
<div class="kf-ctl">
<input id="kf-company" name="company" type="text" autocomplete="organization" placeholder="Brand or agency">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
</div>
<div class="kf-field">
<label for="kf-budget">Budget range</label>
<div class="kf-ctl kf-select">
<select id="kf-budget" name="budget" required aria-describedby="kf-budget-err">
<option value="" selected disabled>Choose a range</option>
<option value="lt50"><€50k</option>
<option value="50-150">€50–150k</option>
<option value="150-500">€150–500k</option>
<option value="500plus">€500k+</option>
</select>
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-budget-err"></p>
</div>
<div class="kf-field kf-field--full">
<label for="kf-msg">The brief</label>
<div class="kf-ctl">
<textarea id="kf-msg" name="message" rows="4" placeholder="What are we making, who is it for, and when does it need to land?" required aria-describedby="kf-msg-err"></textarea>
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-msg-err"></p>
</div>
</div>
<div class="kf-submit">
<button class="btn btn--sky kf-send" type="submit">Send brief <span class="kf-arr" aria-hidden="true"><span>→</span><span>→</span></span></button>
<p class="kf-note">A producer reads every brief personally.</p>
</div>
</form>
<div class="kf-done" data-kf-done role="status" tabindex="-1" hidden>
<span class="kf-done-mark" aria-hidden="true">
<svg viewBox="0 0 64 64" width="64" height="64" focusable="false">
<circle class="kf-done-disc" cx="32" cy="32" r="27"/>
<circle class="kf-done-ring" cx="32" cy="32" r="30"/>
<path class="kf-done-tick" d="M20 33.5l8 8 16-17"/>
</svg>
</span>
<p class="kf-done-text">Thanks — a producer will reply within one working day.</p>
<button class="kf-again" type="button" data-kf-again>Send another brief</button>
</div>
</div>
</div>
</div>
<div class="kf-offices">
<div class="kf-offices-head">
<p class="label label--rose">Studios</p>
<p class="kf-offices-line">Five cities, one crew. Someone is always awake.</p>
</div>
<ul class="kf-list" role="list">
<li class="kf-office" data-tz="Europe/Lisbon">
<h3 class="kf-city"><span class="kf-sr">Lisbon</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Lisbon</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Travessa do Lume 14<br>1200-221 Lisboa</address>
</li>
<li class="kf-office" data-tz="Asia/Seoul">
<h3 class="kf-city"><span class="kf-sr">Seoul</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Seoul</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">12 Hanul-gil, Seongdong-gu<br>Seoul 04782</address>
</li>
<li class="kf-office" data-tz="America/Mexico_City">
<h3 class="kf-city"><span class="kf-sr">Mexico City</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Mexico City</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Calle Aurora 207, Roma Nte.<br>06700 CDMX</address>
</li>
<li class="kf-office" data-tz="Europe/Berlin">
<h3 class="kf-city"><span class="kf-sr">Berlin</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Berlin</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Lichtgasse 9, Hof 2<br>10999 Berlin</address>
</li>
<li class="kf-office" data-tz="America/Toronto">
<h3 class="kf-city"><span class="kf-sr">Toronto</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Toronto</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">88 Foundry Lane, Unit 3<br>Toronto ON M5V 2K1</address>
</li>
</ul>
</div>
</div>
</section>
<footer class="kf-foot" aria-label="Site footer">
<div class="kf-marq">
<p class="kf-sr">Now booking Q1 2027 in Lisbon, Seoul, Mexico City, Berlin and Toronto.</p>
<div class="kf-marq-track" aria-hidden="true" data-kf-marq><span class="kf-marq-grp">Now booking Q1 2027 <b>✦</b> Lisbon <b>✦</b> Seoul <b>✦</b> Mexico City <b>✦</b> Berlin <b>✦</b> Toronto <b>✦</b> </span></div>
</div>
<div class="wrap kf-foot-in">
<div class="kf-hello">
<p class="label kf-rv" data-kf-rv>Say hello — we read everything</p>
<div class="kf-rv" data-kf-rv>
<a class="kf-fm" href="mailto:[email protected]" data-kf-fm><span class="kf-fm-base">[email protected]</span><span class="kf-fm-fill" aria-hidden="true">[email protected]</span></a>
</div>
</div>
<div class="kf-row">
<nav class="kf-social kf-rv" aria-label="Social" data-kf-rv>
<a href="#"><span class="kf-sr">Instagram</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Instagram</span></a>
<a href="#"><span class="kf-sr">Vimeo</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Vimeo</span></a>
<a href="#"><span class="kf-sr">LinkedIn</span><span class="kf-roll" aria-hidden="true" data-kf-roll>LinkedIn</span></a>
</nav>
<p class="kf-copy kf-rv" data-kf-rv>© 2026 Kinoform Pictures</p>
<div class="kf-rv" data-kf-rv>
<button class="kf-top" type="button" data-kf-top>Back to top <span class="kf-top-arr" aria-hidden="true"><span>↑</span></span></button>
</div>
</div>
</div>
<div class="kf-mark-clip" aria-hidden="true">
<span class="kf-mark" data-kf-mark>Kinoform</span>
</div>
</footer>
<style>
/* ---------- 05 contact / offices / footer (kf-) ---------- */
.kf{overflow-x:clip}
.kf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.kf-panel{position:relative;background:var(--rose);color:#fff;border-radius:28px;padding:clamp(24px,5vw,80px);
box-shadow:var(--shadow-rose);transform-origin:50% 0;will-change:transform}
.kf-head,.kf-body{position:relative;z-index:1}
/* floating tint circles (decor, behind content) */
.kf-orbs{position:absolute;inset:0;overflow:hidden;border-radius:inherit;pointer-events:none;z-index:0}
.kf-orb{position:absolute;display:block;will-change:transform}
.kf-orb i{display:block;width:100%;height:100%;border-radius:50%;will-change:transform}
.kf-orb--1{width:clamp(220px,34vw,540px);aspect-ratio:1;top:-14%;right:-9%}
.kf-orb--1 i{background:radial-gradient(circle at 38% 36%,rgba(221,240,255,.42),rgba(221,240,255,.12) 52%,rgba(221,240,255,0) 70%)}
.kf-orb--2{width:clamp(140px,17vw,260px);aspect-ratio:1;top:34%;left:30%}
.kf-orb--2 i{box-shadow:inset 0 0 0 1.5px rgba(248,217,230,.4)}
.kf-orb--3{width:clamp(56px,6vw,96px);aspect-ratio:1;bottom:16%;left:5%}
.kf-orb--3 i{background:rgba(221,240,255,.26)}
.kf-orb--4{width:clamp(200px,24vw,380px);aspect-ratio:1;bottom:-16%;right:24%}
.kf-orb--4 i{background:radial-gradient(circle,rgba(248,217,230,.34),rgba(248,217,230,0) 68%)}
.kf-orb--5{width:clamp(26px,2.6vw,40px);aspect-ratio:1;top:18%;left:52%}
.kf-orb--5 i{background:rgba(248,217,230,.5)}
.kf-label{color:rgba(255,255,255,.8)}
.kf-title{font-size:clamp(40px,10.5vw,176px);margin-top:18px;color:#fff}
.kf-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.6fr);gap:clamp(32px,6vw,96px);margin-top:clamp(40px,6vw,88px)}
.kf-intro{font-size:clamp(17px,1.35vw,20px);line-height:1.5;margin:0;max-width:34ch;color:rgba(255,255,255,.92)}
.kf-facts{margin:32px 0 0;display:grid;gap:18px}
.kf-facts div{border-top:1px solid rgba(255,255,255,.3);padding-top:12px}
.kf-facts dt{color:rgba(255,255,255,.75)}
.kf-facts dd{margin:4px 0 0;font-weight:600}
.kf-mail{position:relative;overflow-wrap:anywhere}
.kf-mail::after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:1px;background:currentColor;transform-origin:100% 50%;transition:transform .35s var(--ease)}
.kf-mail:hover::after{transform:scaleX(0)}
.kf-panel :focus-visible{outline-color:#fff}
/* form */
.kf-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px 24px}
.kf-field{display:flex;flex-direction:column;min-width:0}
.kf-field--full{grid-column:1/-1}
.kf-field label{display:block;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.85);margin-bottom:8px}
.kf-opt{text-transform:none;letter-spacing:0;color:rgba(255,255,255,.7)}
.kf-ctl{position:relative}
.kf-field input,.kf-field select,.kf-field textarea{display:block;width:100%;font:inherit;font-size:16px;color:#fff;background:rgba(255,255,255,.12);
border:0;border-radius:10px 10px 0 0;padding:13px 14px;outline:none;transition:background .2s var(--ease)}
.kf-field textarea{resize:vertical;min-height:120px;line-height:1.5}
.kf-field ::placeholder{color:rgba(255,255,255,.7);opacity:1}
.kf-field input:hover,.kf-field select:hover,.kf-field textarea:hover,
.kf-field input:focus,.kf-field select:focus,.kf-field textarea:focus{background:rgba(255,255,255,.17)}
.kf-ul,.kf-ulf{position:absolute;left:0;right:0;bottom:0;pointer-events:none;transform-origin:0 50%}
.kf-ul{height:1px;background:#fff}
.kf-ulf{height:3px;background:var(--sky);transform:scaleX(0)}
.kf-ctl.is-bad .kf-ul{height:2px;background:var(--sky-soft)}
.kf-select::after{content:"";position:absolute;right:16px;top:50%;width:8px;height:8px;border-right:1.5px solid #fff;border-bottom:1.5px solid #fff;
transform:translateY(-70%) rotate(45deg);pointer-events:none}
.kf-field select{appearance:none;-webkit-appearance:none;padding-right:40px;cursor:pointer}
.kf-field select:invalid{color:rgba(255,255,255,.7)}
.kf-field select option{color:var(--ink);background:var(--paper)}
.kf-err{margin:6px 0 0;min-height:1.2em;font-family:var(--mono);font-size:12px;line-height:1.3;color:#fff}
.kf-err:not(:empty)::before{content:"! ";color:var(--sky)}
.kf-submit{display:flex;flex-wrap:wrap;align-items:center;gap:14px 22px;margin-top:22px}
.kf-note{margin:0;font-size:14px;color:rgba(255,255,255,.8)}
.kf-submit .btn:focus-visible{outline-color:#fff}
.kf-arr{display:inline-grid;overflow:hidden;line-height:1}
.kf-arr>span{grid-area:1/1;display:block;padding:0 1px}
.kf-arr>span+span{transform:translateX(-120%)}
/* success */
.kf-done{display:flex;flex-direction:column;align-items:flex-start;gap:18px;padding:clamp(28px,4vw,48px);border-radius:18px;
background:rgba(255,255,255,.12);border-bottom:1px solid #fff;outline:none}
.kf-done[hidden]{display:none}
.kf-done-mark{display:block;width:64px;height:64px}
.kf-done-mark svg{display:block;width:100%;height:100%;overflow:visible}
.kf-done-disc{fill:var(--paper);transform-origin:32px 32px;transform-box:view-box}
.kf-done-ring{fill:none;stroke:var(--sky);stroke-width:3;stroke-linecap:round;transform-origin:32px 32px;transform:rotate(-90deg)}
.kf-done-tick{fill:none;stroke:var(--rose);stroke-width:5;stroke-linecap:round;stroke-linejoin:round}
.kf-done-text{margin:0;font-stretch:125%;font-weight:700;font-size:clamp(22px,2.4vw,34px);line-height:1.15;letter-spacing:-.02em;max-width:22ch}
.kf-again{position:relative;font-family:var(--mono);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:#fff;padding:4px 0}
.kf-again::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:currentColor;transform-origin:0 50%;transition:transform .35s var(--ease)}
.kf-again:hover::after{transform:scaleX(.3)}
/* char roll (cities, social links) */
.kf-roll{display:inline-block}
.kf-rw{display:inline-block;white-space:nowrap}
.kf-rc{display:inline-block;overflow:hidden;vertical-align:top}
.kf-rc>span{display:inline-block;position:relative;padding-bottom:.14em;will-change:transform}
.kf-rc>span::after{content:attr(data-c);position:absolute;left:0;top:100%}
/* offices */
.kf-offices{margin-top:clamp(72px,9vw,140px)}
.kf-offices-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px 24px;margin-bottom:28px}
.kf-offices-line{margin:0;color:var(--mute);font-size:clamp(15px,1.2vw,18px)}
.kf-list{--kf-rs:1;position:relative;list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr))}
.kf-list::before{content:"";position:absolute;left:0;right:0;top:0;height:1px;background:var(--line);transform-origin:0 50%;transform:scaleX(var(--kf-rs))}
.kf-office{--kf-ls:1;position:relative;padding:24px clamp(12px,1.4vw,24px) 8px 0;min-width:0}
.kf-office+.kf-office{padding-left:clamp(12px,1.4vw,24px)}
.kf-office::after{content:"";position:absolute;right:0;top:0;width:1px;height:100%;background:var(--line);transform-origin:0 0;transform:scale(var(--kf-ls))}
.kf-office:last-child::after{display:none}
.kf-city{position:relative;display:inline-block;margin:0;font-stretch:125%;font-weight:800;letter-spacing:-.03em;line-height:1.02;font-size:clamp(22px,2.2vw,36px);color:var(--ink)}
.kf-city::after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:3px;background:var(--rose);transform:scaleX(0);transform-origin:100% 50%;transition:transform .45s var(--ease)}
.kf-office:hover .kf-city::after{transform:scaleX(1);transform-origin:0 50%}
.kf-time{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;margin:16px 0 0;font-family:var(--mono);font-size:15px}
.kf-time time{font-size:20px;font-weight:500;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.kf-digits{display:inline-flex}
.kf-dg{display:inline-block;overflow:hidden;vertical-align:top;line-height:1.2}
.kf-dg>span{display:inline-block;will-change:transform}
.kf-state{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.kf-dot{width:8px;height:8px;border-radius:50%;background:var(--line);flex:none;transition:background .3s}
.kf-office.is-open .kf-dot{background:var(--sky);box-shadow:0 0 0 3px var(--sky-soft)}
.kf-addr{margin-top:12px;font-style:normal;font-size:14px;line-height:1.5;color:var(--mute)}
/* ---------- footer ---------- */
.kf-foot{position:relative;background:var(--bg);overflow:hidden}
.kf-marq{position:relative;overflow:hidden;border-block:1px solid var(--line);padding-block:12px;
font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
.kf-marq-track{display:flex;width:max-content;will-change:transform}
.kf-marq-grp{flex:none;white-space:pre;padding-right:.6em}
.kf-marq b{font-weight:400;color:var(--rose);margin-inline:.5em}
.kf-foot-in{padding-top:clamp(56px,8vw,120px)}
.kf-hello .label{margin:0 0 14px}
.kf-fm{position:relative;display:inline-block;font-weight:800;font-stretch:112%;letter-spacing:-.035em;line-height:1.05;
font-size:clamp(24px,6.4vw,112px);color:var(--ink);overflow-wrap:anywhere}
.kf-fm-base,.kf-fm-fill{display:block}
.kf-fm-fill{position:absolute;inset:0;color:var(--rose);clip-path:inset(0 100% 0 0)}
.kf-fm::after{content:"";position:absolute;left:0;right:0;bottom:-.04em;height:max(2px,.035em);background:var(--line)}
.kf-rv{will-change:transform}
.kf-row{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px 32px;margin-top:clamp(40px,5vw,72px);padding-block:22px;
border-top:1px solid var(--line);font-size:15px}
.kf-social{display:flex;flex-wrap:wrap;gap:8px 26px}
.kf-social a{color:var(--mute);font-weight:600;transition:color .2s}
.kf-social a:hover,.kf-social a:focus-visible{color:var(--rose)}
.kf-copy{margin:0;font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute)}
.kf-top{display:inline-flex;align-items:center;gap:10px;padding:8px 8px 8px 20px;border-radius:999px;font-weight:700;font-size:14px;
box-shadow:inset 0 0 0 1.5px var(--ink);transition:background .25s var(--ease),color .25s var(--ease),box-shadow .25s var(--ease)}
.kf-top:hover,.kf-top:focus-visible{background:var(--rose);color:#fff;box-shadow:none}
.kf-top-arr{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:var(--sky);color:var(--ink)}
.kf-top-arr>span{display:block;line-height:1}
/* wordmark: font-size fitted to width by JS (vw fallback), clip sits just under the baseline */
.kf-mark-clip{--kf-fs:19.4vw;font-size:var(--kf-fs);height:.86em;overflow:hidden;padding-inline:var(--gutter);margin-top:clamp(8px,2vw,24px)}
.kf-mark{display:block;width:max-content;font-family:var(--display);font-stretch:125%;font-weight:800;line-height:1;letter-spacing:-.045em;
color:var(--ink);white-space:nowrap;margin-top:-.07em}
.kf-mc{display:inline-block;transform-origin:50% 100%;will-change:transform}
.kf-mc--o{color:var(--rose);transform-origin:50% 58%}
@media (max-width:1100px){
.kf-list{grid-template-columns:1fr}
.kf-office,.kf-office+.kf-office{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr) minmax(0,1.2fr);align-items:baseline;gap:6px 24px;padding:20px 0}
.kf-office::after,.kf-office:last-child::after{display:block;top:auto;bottom:0;left:0;right:0;width:auto;height:1px}
.kf-city{justify-self:start}
.kf-time,.kf-addr{margin:0}
}
@media (max-width:860px){
.kf-body{grid-template-columns:1fr}
.kf-intro{max-width:46ch}
}
@media (max-width:600px){
.kf-panel{border-radius:22px}
.kf-grid{grid-template-columns:1fr}
.kf-office,.kf-office+.kf-office{grid-template-columns:1fr auto}
.kf-addr{grid-column:1/-1}
.kf-time{justify-content:flex-end}
.kf-row{flex-direction:column;align-items:flex-start}
.kf-fm{font-stretch:100%}
.kf-orb--2{left:auto;right:-10%}
}
@media (prefers-reduced-motion: reduce) and (update: none){
.kf-panel,.kf-orb,.kf-orb i,.kf-mc,.kf-rv,.kf-marq-track{transform:none !important;will-change:auto}
.kf *,.kf-foot *,.kf-foot *::after{transition:none !important}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('contact');
const foot = document.querySelector('.kf-foot');
if (!root) return;
const reduce = KF.reduce;
const fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
/* ---------- helper: char roll (two stacked copies per char, roll up on hover) ---------- */
const rollify = el => {
const txt = el.textContent.trim(); el.textContent = '';
const chars = [];
txt.split(/\s+/).forEach((word, wi) => {
if (wi) el.appendChild(document.createTextNode(' '));
const w = document.createElement('span'); w.className = 'kf-rw';
[...word].forEach(ch => {
const c = document.createElement('span'); c.className = 'kf-rc';
const i = document.createElement('span'); i.textContent = ch; i.dataset.c = ch;
c.appendChild(i); w.appendChild(c); chars.push(i);
});
el.appendChild(w);
});
return chars;
};
const roll = chars => gsap.fromTo(chars, { yPercent: 0 }, { yPercent: -100, duration: .55, ease: 'power3.inOut', stagger: .022, overwrite: true });
const bindRoll = (host, rollEl) => {
const chars = rollify(rollEl);
if (reduce) return;
host.addEventListener('pointerenter', () => roll(chars));
host.addEventListener('focus', () => roll(chars));
};
/* ---------- form: validation + in-place success ---------- */
const form = root.querySelector('[data-kf-form]');
const done = root.querySelector('[data-kf-done]');
const again = root.querySelector('[data-kf-again]');
const doneText = done && done.querySelector('.kf-done-text');
const doneWords = doneText ? KF.split(doneText, 'words') : [];
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const rules = {
name: v => v.trim().length >= 2 ? '' : 'Please tell us your name.',
email: v => !v.trim() ? 'We need an email to reply to.' : (emailRx.test(v.trim()) ? '' : 'That email doesn\'t look quite right.'),
budget: v => v ? '' : 'Pick the closest range — it helps us plan.',
message: v => v.trim().length >= 20 ? '' : 'A few more words, please (20 characters minimum).'
};
let tried = false;
const check = el => {
const rule = rules[el.name]; if (!rule) return true;
const msg = rule(el.value);
const err = document.getElementById(el.getAttribute('aria-describedby'));
if (err) err.textContent = msg;
el.setAttribute('aria-invalid', msg ? 'true' : 'false');
el.closest('.kf-ctl')?.classList.toggle('is-bad', !!msg);
return !msg;
};
const showDone = () => {
form.hidden = true;
done.hidden = false;
done.focus({ preventScroll: true });
KF.refresh();
if (reduce) return;
const ring = done.querySelector('.kf-done-ring');
const tick = done.querySelector('.kf-done-tick');
const rl = ring.getTotalLength(), tl = tick.getTotalLength();
gsap.timeline()
.fromTo(done, { clipPath: 'inset(0 0 100% 0 round 18px)', y: 16 }, { clipPath: 'inset(0 0 0% 0 round 18px)', y: 0, duration: .7, ease: KF.ease.out })
.fromTo(done.querySelector('.kf-done-disc'), { scale: 0 }, { scale: 1, duration: .6, ease: 'back.out(2)' }, .1)
.fromTo(ring, { strokeDasharray: rl, strokeDashoffset: rl }, { strokeDashoffset: 0, duration: .9, ease: KF.ease.io }, .2)
.fromTo(tick, { strokeDasharray: tl, strokeDashoffset: tl }, { strokeDashoffset: 0, duration: .5, ease: 'power2.out' }, .55)
.fromTo(doneWords, { yPercent: 115, rotate: 3 }, { yPercent: 0, rotate: 0, duration: 1, ease: KF.ease.out, stagger: .05 }, .3)
.fromTo(again, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: .6, ease: KF.ease.soft }, .8);
};
if (form) {
const fields = [...form.elements].filter(el => rules[el.name]);
fields.forEach(el => {
el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', () => { if (tried || el.getAttribute('aria-invalid') === 'true') check(el); });
el.addEventListener('blur', () => { if (tried) check(el); });
});
/* focus: sky underline draws in from the left, leaves to the right */
form.querySelectorAll('.kf-ctl').forEach(ctl => {
const f = ctl.querySelector('.kf-ulf');
const ctrl = ctl.querySelector('input,select,textarea');
ctrl.addEventListener('focus', () => gsap.to(f, { scaleX: 1, transformOrigin: '0% 50%', duration: reduce ? 0 : .6, ease: KF.ease.out, overwrite: true }));
ctrl.addEventListener('blur', () => gsap.to(f, { scaleX: 0, transformOrigin: '100% 50%', duration: reduce ? 0 : .45, ease: KF.ease.io, overwrite: true }));
});
/* submit arrow: slides out right, a twin slides in from the left */
const send = form.querySelector('.kf-send');
const arrows = send ? [...send.querySelectorAll('.kf-arr > span')] : [];
let cur = 0;
const swap = () => {
if (reduce || arrows.length < 2) return;
const out = arrows[cur], inn = arrows[1 - cur]; cur = 1 - cur;
gsap.to(out, { xPercent: 120, duration: .4, ease: 'power3.in', overwrite: true });
gsap.fromTo(inn, { xPercent: -120 }, { xPercent: 0, duration: .5, delay: .12, ease: KF.ease.out, overwrite: true });
};
if (send) {
gsap.set(arrows[1], { xPercent: -120 });
send.addEventListener('pointerenter', swap);
send.addEventListener('pointerleave', swap);
send.addEventListener('focus', swap);
}
form.addEventListener('submit', e => {
e.preventDefault();
tried = true;
const bad = fields.filter(el => !check(el));
if (bad.length) {
bad[0].focus();
if (!reduce) gsap.fromTo(bad.map(b => b.closest('.kf-field')), { x: 0 }, { x: 0, keyframes: { x: [0, -8, 7, -4, 2, 0] }, duration: .45, ease: 'none', stagger: .04 });
return;
}
if (reduce) { showDone(); return; }
/* collapse the form: fields lift away, then the whole block wipes upward */
gsap.timeline({ onComplete: showDone })
.to(form.querySelectorAll('.kf-field, .kf-submit'), { y: -18, opacity: 0, duration: .35, ease: 'power2.in', stagger: .035 })
.to(form, { clipPath: 'inset(0 0 100% 0)', duration: .45, ease: KF.ease.io }, '-=.15');
});
again && again.addEventListener('click', () => {
form.reset(); tried = false;
fields.forEach(el => {
el.removeAttribute('aria-invalid');
el.closest('.kf-ctl')?.classList.remove('is-bad');
const err = document.getElementById(el.getAttribute('aria-describedby')); if (err) err.textContent = '';
});
gsap.set([form, ...form.querySelectorAll('.kf-field, .kf-submit')], { clearProps: 'clipPath,opacity,transform' });
done.hidden = true; form.hidden = false;
if (!reduce) gsap.from(form.querySelectorAll('.kf-field, .kf-submit'), { y: 24, opacity: 0, duration: .7, ease: KF.ease.soft, stagger: .05 });
form.querySelector('input')?.focus();
KF.refresh();
});
}
/* ---------- offices: live local time with flipping digits + open/closed ---------- */
const offices = [...root.querySelectorAll('.kf-office')].map(li => {
const tz = li.dataset.tz;
const digits = li.querySelector('[data-kf-digits]');
digits.textContent = '';
const slots = [...'00:00'].map(() => {
const s = document.createElement('span'); s.className = 'kf-dg';
const i = document.createElement('span'); s.appendChild(i); digits.appendChild(s);
return i;
});
bindRoll(li, li.querySelector('[data-kf-roll]'));
return {
li, tz, slots, prev: '',
time: li.querySelector('[data-kf-time]'),
sr: li.querySelector('[data-kf-tsr]'),
state: li.querySelector('[data-kf-state]'),
fmt: new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
parts: new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', hour: 'numeric', hourCycle: 'h23' })
};
});
const flip = (el, ch, i) => {
gsap.timeline()
.to(el, { yPercent: -110, opacity: 0, duration: .32, ease: 'power2.in', delay: i * .06 })
.add(() => { el.textContent = ch; })
.fromTo(el, { yPercent: 110, opacity: 0 }, { yPercent: 0, opacity: 1, duration: .55, ease: KF.ease.out });
};
const tick = () => {
const now = new Date();
offices.forEach(o => {
const p = Object.fromEntries(o.parts.formatToParts(now).map(x => [x.type, x.value]));
const h = parseInt(p.hour, 10) % 24;
const open = !['Sat', 'Sun'].includes(p.weekday) && h >= 9 && h < 19;
const str = o.fmt.format(now);
if (str !== o.prev) {
[...str].forEach((ch, i) => {
const el = o.slots[i]; if (!el || el.textContent === ch) return;
if (!o.prev || reduce) el.textContent = ch; else flip(el, ch, i);
});
o.prev = str;
o.sr.textContent = str;
}
o.time.setAttribute('datetime', now.toISOString());
o.state.textContent = open ? 'Open now' : 'Closed';
o.li.classList.toggle('is-open', open);
});
};
tick();
setInterval(tick, 5000);
/* ---------- footer: rolls, back to top, email wipe ---------- */
foot && foot.querySelectorAll('.kf-social a').forEach(a => bindRoll(a, a.querySelector('[data-kf-roll]')));
const topBtn = foot && foot.querySelector('[data-kf-top]');
if (topBtn) {
topBtn.addEventListener('click', () => {
if (KF.lenis) KF.lenis.scrollTo(0, { duration: 1.8, easing: t => 1 - Math.pow(1 - t, 4) });
else window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
});
if (!reduce) {
KF.magnetic(topBtn, .4);
const arr = topBtn.querySelector('.kf-top-arr');
const spin = () => gsap.to(arr, { rotate: '+=360', duration: .9, ease: 'expo.inOut', overwrite: 'auto' });
topBtn.addEventListener('pointerenter', spin);
topBtn.addEventListener('focus', spin);
/* arrow leans slightly more as you near the bottom */
gsap.fromTo(arr.firstElementChild, { y: 6 }, { y: 0, ease: 'none', scrollTrigger: { trigger: foot, start: 'top bottom', end: 'bottom bottom', scrub: .5 } });
}
}
const fm = foot && foot.querySelector('[data-kf-fm]');
if (fm && !reduce) {
const fill = fm.querySelector('.kf-fm-fill');
const wipeIn = () => gsap.fromTo(fill, { clipPath: 'inset(0% 100% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: .75, ease: KF.ease.out, overwrite: true });
const wipeOut = () => gsap.to(fill, { clipPath: 'inset(0% 0% 0% 100%)', duration: .6, ease: KF.ease.io, overwrite: true });
fm.addEventListener('pointerenter', wipeIn); fm.addEventListener('focus', wipeIn);
fm.addEventListener('pointerleave', wipeOut); fm.addEventListener('blur', wipeOut);
}
/* ---------- wordmark: split to chars, fit to the full inner width ---------- */
const clip = foot && foot.querySelector('.kf-mark-clip');
const mark = foot && foot.querySelector('[data-kf-mark]');
const markChars = [];
if (mark) {
const t = mark.textContent.trim(); mark.textContent = '';
[...t].forEach(ch => {
const s = document.createElement('span');
s.className = 'kf-mc' + (ch.toLowerCase() === 'o' ? ' kf-mc--o' : '');
s.textContent = ch; mark.appendChild(s); markChars.push(s);
});
}
const fit = () => {
if (!clip || !mark) return;
const cs = getComputedStyle(clip);
const inner = clip.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
clip.style.setProperty('--kf-fs', '100px');
const w = mark.offsetWidth;
if (w) clip.style.setProperty('--kf-fs', (100 * inner / w * .995).toFixed(2) + 'px');
};
fit();
/* ---------- marquee: GSAP ticker loop, direction follows scroll ---------- */
const track = foot && foot.querySelector('[data-kf-marq]');
let marq = null;
if (track) {
const grp = track.firstElementChild;
const build = () => {
track.querySelectorAll('.kf-marq-grp + .kf-marq-grp').forEach(n => n.remove());
/* repeat inside the group until it covers the viewport, then clone the group once */
const base = grp.dataset.kfBase || (grp.dataset.kfBase = grp.innerHTML);
grp.innerHTML = base;
while (grp.offsetWidth < innerWidth + 40) grp.innerHTML += base;
track.appendChild(grp.cloneNode(true));
return grp.offsetWidth;
};
let gw = build();
if (!reduce) {
let x = 0, dir = -1, boost = 0;
const setX = gsap.quickSetter(track, 'x', 'px');
const wrapX = v => gsap.utils.wrap(-gw, 0, v);
const step = (time, dt) => {
x = wrapX(x + dir * (1 + boost) * .06 * Math.min(dt, 50));
boost *= .93;
setX(x);
};
let on = false;
const play = v => { if (v === on) return; on = v; v ? gsap.ticker.add(step) : gsap.ticker.remove(step); };
marq = ScrollTrigger.create({
trigger: track.parentElement, start: 'top bottom', end: 'bottom top',
onToggle: s => play(s.isActive),
onUpdate: s => { dir = s.direction > 0 ? -1 : 1; boost = Math.min(Math.abs(s.getVelocity()) / 220, 9); }
});
marq.rebuild = () => { gw = build(); x = wrapX(x); };
}
}
let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { fit(); marq && marq.rebuild && marq.rebuild(); KF.refresh(); }, 150); });
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => { fit(); marq && marq.rebuild && marq.rebuild(); KF.refresh(); });
if (reduce) { KF.refresh(); return; }
/* ================= motion ================= */
const panel = root.querySelector('[data-kf-panel]');
/* panel scales + rounds in as it enters (scrubbed) */
gsap.fromTo(panel, { scale: .88, borderRadius: 64 }, {
scale: 1, borderRadius: 28, ease: 'none',
scrollTrigger: { trigger: panel, start: 'top bottom', end: 'top 30%', scrub: .6 }
});
/* display line: chars rise in */
KF.reveal(root.querySelector('.kf-title'), { mode: 'chars', start: 'top 82%', stagger: .028 });
gsap.from(root.querySelector('.kf-label'), { y: 16, opacity: 0, duration: .8, ease: KF.ease.soft,
scrollTrigger: { trigger: panel, start: 'top 80%', once: true } });
gsap.from(root.querySelectorAll('.kf-intro, .kf-facts > div'), { y: 26, opacity: 0, duration: .9, ease: KF.ease.soft, stagger: .08,
scrollTrigger: { trigger: root.querySelector('.kf-aside'), start: 'top 85%', once: true } });
/* form fields: label, then underline draws */
if (form) {
const tl = gsap.timeline({ scrollTrigger: { trigger: form, start: 'top 85%', once: true } });
form.querySelectorAll('.kf-field').forEach((f, i) => {
const at = i * .09;
tl.from(f.querySelector('label'), { yPercent: 60, opacity: 0, duration: .7, ease: KF.ease.out }, at)
.from(f.querySelector('.kf-ctl'), { opacity: 0, duration: .6, ease: 'power1.out' }, at + .08)
.from(f.querySelector('.kf-ul'), { scaleX: 0, duration: 1, ease: KF.ease.out }, at + .16);
});
tl.from(form.querySelector('.kf-submit'), { y: 20, opacity: 0, duration: .8, ease: KF.ease.soft }, '-=.7');
}
/* orbs: slow idle drift + pointer parallax on desktop; paused offscreen */
const orbs = [...root.querySelectorAll('.kf-orb')];
const idle = orbs.map((o, i) => gsap.to(o.firstElementChild, {
x: gsap.utils.random(-30, 30), y: gsap.utils.random(-36, 36), scale: gsap.utils.random(.92, 1.08),
duration: gsap.utils.random(5, 8), ease: 'sine.inOut', yoyo: true, repeat: -1, delay: i * -1.3, paused: true
}));
ScrollTrigger.create({ trigger: panel, start: 'top bottom', end: 'bottom top', onToggle: s => idle.forEach(t => s.isActive ? t.play() : t.pause()) });
if (fine) {
const movers = orbs.map(o => ({ d: +o.dataset.kfDepth || 20,
x: gsap.quickTo(o, 'x', { duration: 1.4, ease: 'power3.out' }), y: gsap.quickTo(o, 'y', { duration: 1.4, ease: 'power3.out' }) }));
panel.addEventListener('pointermove', e => {
const r = panel.getBoundingClientRect();
const nx = (e.clientX - r.left) / r.width - .5, ny = (e.clientY - r.top) / r.height - .5;
movers.forEach(m => { m.x(nx * m.d * 2); m.y(ny * m.d * 2); });
});
panel.addEventListener('pointerleave', () => movers.forEach(m => { m.x(0); m.y(0); }));
}
/* offices: columns rise, rules draw */
const list = root.querySelector('.kf-list');
gsap.timeline({ scrollTrigger: { trigger: list, start: 'top 88%', once: true } })
.fromTo(list, { '--kf-rs': 0 }, { '--kf-rs': 1, duration: 1.2, ease: KF.ease.out })
.from(root.querySelectorAll('.kf-offices-head > *'), { y: 18, opacity: 0, duration: .8, ease: KF.ease.soft, stagger: .08 }, 0)
.from(offices.flatMap(o => [...o.li.children]), { y: 34, opacity: 0, duration: .9, ease: KF.ease.out, stagger: .025 }, .15)
.fromTo(offices.map(o => o.li), { '--kf-ls': 0 }, { '--kf-ls': 1, duration: 1.1, ease: KF.ease.io, stagger: .08 }, .25);
/* ---------- FOOTER ---------- */
if (foot) {
/* content slides up in a staggered, scrubbed wave as the page end arrives */
const rv = [...foot.querySelectorAll('[data-kf-rv]')];
const ftl = gsap.timeline({ scrollTrigger: { trigger: foot, start: 'top bottom', end: 'bottom bottom', scrub: .8 } });
ftl.fromTo(rv, { y: 110, opacity: 0 }, { y: 0, opacity: 1, ease: 'power3.out', duration: .5, stagger: .07 }, 0);
/* wordmark: each char rises on its own speed, like a wave; the rose o's turn */
if (markChars.length) {
const n = markChars.length;
const wtl = gsap.timeline({ scrollTrigger: { trigger: foot, start: 'top 85%', end: 'bottom bottom', scrub: 1 } });
markChars.forEach((c, i) => {
const wave = Math.sin(i / (n - 1) * Math.PI); /* middle chars lag a little */
const start = i * .06 + wave * .12;
const dur = .55 + ((i * 37) % 5) * .07; /* uneven speeds */
wtl.fromTo(c, { yPercent: 108 }, { yPercent: 0, ease: 'power3.out', duration: dur }, start);
if (c.classList.contains('kf-mc--o')) wtl.fromTo(c, { rotate: i % 2 ? 40 : -40 }, { rotate: i % 2 ? -8 : 8, ease: 'power2.out', duration: dur + .3 }, start);
});
/* pointer: chars lean + swell toward the cursor (one quickTo per prop per char) */
if (fine) {
const q = markChars.map(c => ({ c, cx: 0,
x: gsap.quickTo(c, 'x', { duration: .7, ease: 'power3.out' }),
sk: gsap.quickTo(c, 'skewX', { duration: .7, ease: 'power3.out' }),
s: gsap.quickTo(c, 'scale', { duration: .7, ease: 'power3.out' }) }));
let cw = 1;
const measure = () => { q.forEach(o => { const r = o.c.getBoundingClientRect(); o.cx = r.left + r.width / 2; }); cw = (clip.clientWidth || 1000) / n; };
foot.addEventListener('pointerenter', measure);
foot.addEventListener('pointermove', e => {
q.forEach(o => {
const dx = e.clientX - o.cx, f = Math.exp(-(dx * dx) / (2 * (cw * 1.4) ** 2));
o.x(gsap.utils.clamp(-14, 14, dx * .05));
o.sk(gsap.utils.clamp(-10, 10, -dx / cw * 4) * (1 - f * .4));
o.s(1 + f * .07);
});
});
foot.addEventListener('pointerleave', () => q.forEach(o => { o.x(0); o.sk(0); o.s(1); }));
}
}
}
KF.refresh();
});
</script>
</body>
</html>
```
## `sections/01-hero.html`
```html
<header class="kh-head" id="kh-head">
<div class="kh-bar">
<a class="kh-logo" href="#top" aria-label="Kinoform Pictures, back to top">
<span class="kh-word" aria-hidden="true"><span class="kh-m"><span class="kh-c">K</span></span><span class="kh-m"><span class="kh-c">i</span></span><span class="kh-m"><span class="kh-c">n</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">f</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">r</span></span><span class="kh-m"><span class="kh-c">m</span></span></span>
</a>
<nav class="kh-nav" aria-label="Primary">
<ul class="kh-links">
<li><a class="kh-link" href="#work">Work</a></li>
<li><a class="kh-link" href="#process">Process</a></li>
<li><a class="kh-link" href="#directors">Directors</a></li>
<li><a class="kh-link" href="#contact">Contact</a></li>
</ul>
<a class="kh-pill" href="#contact">Start a project</a>
</nav>
<button class="kh-toggle" type="button" aria-expanded="false" aria-controls="kh-panel" aria-label="Open menu">
<span class="kh-toggle-line"></span><span class="kh-toggle-line"></span>
</button>
</div>
<div class="kh-panel" id="kh-panel" inert>
<nav aria-label="Mobile">
<ol class="kh-plinks">
<li><a class="kh-plink" href="#work"><span class="kh-pnum">01</span>Work</a></li>
<li><a class="kh-plink" href="#process"><span class="kh-pnum">02</span>Process</a></li>
<li><a class="kh-plink" href="#directors"><span class="kh-pnum">03</span>Directors</a></li>
<li><a class="kh-plink" href="#contact"><span class="kh-pnum">04</span>Contact</a></li>
</ol>
</nav>
<div class="kh-pfoot">
<a class="btn kh-pbtn" href="#contact">Start a project</a>
<p class="kh-pmail">[email protected]<br>Lisbon · Seoul · Mexico City · Berlin · Toronto</p>
</div>
</div>
</header>
<section class="kh-hero" id="top" aria-labelledby="kh-title">
<div class="kh-atmo" aria-hidden="true">
<span class="kh-blob kh-blob--a"></span>
<span class="kh-blob kh-blob--b"></span>
<span class="kh-blob kh-blob--c"></span>
<span class="kh-grain"></span>
</div>
<div class="kh-top wrap">
<p class="kh-intro">Kinoform Pictures is an independent production company making commercials, film and branded work from Lisbon, Seoul, Mexico City, Berlin and Toronto.</p>
<p class="kh-meta label"><span class="kh-rec"><span class="kh-rec-dot" aria-hidden="true"></span>Rec</span> Reel <span class="kh-meta-rose">2026</span> · 5 studios</p>
</div>
<h1 class="kh-mark" id="kh-title">
<span class="kh-sr">Kinoform Pictures</span>
<span class="kh-word kh-giant" aria-hidden="true"><span class="kh-m"><span class="kh-c">K</span></span><span class="kh-m"><span class="kh-c">i</span></span><span class="kh-m"><span class="kh-c">n</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">f</span></span><span class="kh-m"><span class="kh-c kh-c--o">o</span></span><span class="kh-m"><span class="kh-c">r</span></span><span class="kh-m"><span class="kh-c">m</span></span></span>
</h1>
<div class="kh-wall" role="group" aria-label="Frames from recent Kinoform productions">
<div class="kh-floor" aria-hidden="true"></div>
<div class="kh-ring">
<figure class="kh-card"><img src="assets/f1.jpg" alt="Film crew rigging a cinema camera on set" width="400" height="600" loading="eager" fetchpriority="high" decoding="async"><figcaption class="kh-cap"><b>On set</b><span>Camera crew</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p2.jpg" alt="Still from Pink Hour, a fashion film for Maison Oriel" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Maison Oriel</b><span>Pink Hour · 2026</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f3.jpg" alt="Neon-lit street at night" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Neon night</b><span>Street frame</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p1.jpg" alt="Still from Long Way Round, a commercial for Halcyon Motors" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Halcyon Motors</b><span>Long Way Round · 2026</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f4.jpg" alt="Rider on horseback along a beach" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Shoreline</b><span>Horse & rider</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p3.jpg" alt="Still from The Climb, a commercial for Velo Norte" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Velo Norte</b><span>The Climb · 2025</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f2.jpg" alt="Clapperboard marked for the next take" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Next take</b><span>Slate & sound</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p5.jpg" alt="Still from The City Is a Stage, a commercial for Stride Athletic" width="400" height="600" loading="eager" decoding="async"><figcaption class="kh-cap"><b>Stride Athletic</b><span>The City Is a Stage</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f5.jpg" alt="Pink flowers in soft daylight" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>In bloom</b><span>Colour study</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p4.jpg" alt="Still from Last Light, a branded short for Salt and Current" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Salt & Current</b><span>Last Light · 2025</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f6.jpg" alt="City skyline at dusk" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Blue hour</b><span>Skyline</span></figcaption></figure>
<figure class="kh-card"><img src="assets/p6.jpg" alt="Still from Second Nature, a product film for Aurel Horology" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Aurel Horology</b><span>Second Nature · 2024</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f7.jpg" alt="Perfume bottle lit on a tabletop set" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Tabletop</b><span>Fragrance</span></figcaption></figure>
<figure class="kh-card"><img src="assets/f8.jpg" alt="Sneakers styled on colour blocks" width="400" height="600" loading="eager" fetchpriority="low" decoding="async"><figcaption class="kh-cap"><b>Colour block</b><span>Sneaker study</span></figcaption></figure>
</div>
<button class="kh-badge" type="button" aria-label="Play the 2026 showreel: jump to the work">
<svg class="kh-bring" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs><path id="kh-bpath" d="M60,60 m-46,0 a46,46 0 1,1 92,0 a46,46 0 1,1 -92,0"/></defs>
<text class="kh-btext"><textPath href="#kh-bpath" textLength="286" lengthAdjust="spacing">SHOWREEL 2026 • PLAY • SHOWREEL 2026 • PLAY • </textPath></text>
</svg>
<span class="kh-bplay" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M8.5 5.8v12.4L18.6 12z"/></svg></span>
</button>
</div>
<div class="kh-cta">
<p class="kh-cta-copy">We make pictures that move people. Five studios, one crew, any format.</p>
<div class="kh-btns">
<a class="btn" href="#work">See the work</a>
<a class="btn btn--ghost" href="#directors">Meet the directors</a>
</div>
</div>
<div class="kh-marq">
<p class="kh-sr">We shoot commercials, fashion film, branded shorts, music video, product film and documentary.</p>
<div class="kh-mtrack" aria-hidden="true">
<div class="kh-mgroup">
<span class="kh-mi kh-mi--o">Commercials</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Fashion film</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi kh-mi--o">Branded shorts</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Music video</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi kh-mi--o">Product film</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
<span class="kh-mi">Documentary</span><svg class="kh-star" viewBox="0 0 24 24" focusable="false"><path d="M12 0c.7 6.3 5.7 11.3 12 12-6.3.7-11.3 5.7-12 12-.7-6.3-5.7-11.3-12-12C6.3 11.3 11.3 6.3 12 0z"/></svg>
</div>
</div>
</div>
</section>
<style>
/* ---------- 01 hero + fixed header (kh-) ---------- */
.kh-head{position:fixed;top:0;left:0;right:0;z-index:50;height:64px;--kh-lo:0}
.kh-bar{position:relative;z-index:2;height:64px;display:flex;align-items:center;justify-content:space-between;gap:24px;
padding-inline:var(--gutter);transition:background-color .35s var(--ease),box-shadow .35s var(--ease)}
.kh-head.is-solid .kh-bar{background:rgba(243,242,247,.8);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:inset 0 -1px 0 var(--line)}
.kh-head.is-open .kh-bar{background:transparent;-webkit-backdrop-filter:none;backdrop-filter:none;box-shadow:none}
.kh-word{display:inline-block;white-space:nowrap;line-height:1}
.kh-m{display:inline-block;vertical-align:top;margin-right:-.035em;clip-path:inset(-30% -30% 0 -30%)}
.kh-c{display:inline-block;will-change:transform}
.kh-c--o{color:var(--rose)}
.kh-logo{font-family:var(--display);font-stretch:125%;font-weight:800;font-size:20px;line-height:1;color:var(--ink);
opacity:var(--kh-lo);display:inline-flex;align-items:center;min-height:44px}
.kh-logo:focus-visible,.kh-head.is-open .kh-logo,.kh-head--static .kh-logo{opacity:1}
.kh-nav{display:flex;align-items:center;gap:clamp(18px,2.4vw,36px)}
.kh-links{display:flex;gap:clamp(16px,2.2vw,32px);list-style:none;margin:0;padding:0}
.kh-link{position:relative;font-family:var(--mono);font-size:13px;letter-spacing:.02em;color:var(--ink);padding:6px 0}
.kh-link::after{content:"";position:absolute;left:0;right:0;bottom:2px;height:1.5px;background:var(--rose);transform:scaleX(0);transform-origin:right;transition:transform .35s var(--ease)}
.kh-link:hover::after,.kh-link:focus-visible::after{transform:scaleX(1);transform-origin:left}
.kh-pill{display:inline-flex;align-items:center;padding:9px 16px;border-radius:999px;background:var(--rose);color:var(--paper);
font-size:13px;font-weight:700;box-shadow:var(--shadow-rose);transition:background .25s var(--ease),transform .25s var(--ease)}
.kh-pill:hover{background:var(--rose-deep);transform:translateY(-1px)}
.kh-toggle{display:none;width:44px;height:44px;border-radius:999px;position:relative;box-shadow:inset 0 0 0 1.5px var(--line);background:var(--paper)}
.kh-toggle-line{position:absolute;left:13px;right:13px;height:2px;border-radius:2px;background:var(--ink);transition:transform .35s var(--ease)}
.kh-toggle-line:first-child{top:18px}
.kh-toggle-line:last-child{top:24px}
.kh-head.is-open .kh-toggle-line:first-child{transform:translateY(3px) rotate(45deg)}
.kh-head.is-open .kh-toggle-line:last-child{transform:translateY(-3px) rotate(-45deg)}
.kh-panel{position:fixed;inset:0;z-index:1;background:var(--paper);padding:104px var(--gutter) 32px;display:flex;flex-direction:column;
justify-content:space-between;overflow-y:auto;visibility:hidden;opacity:0;transition:opacity .35s var(--ease),visibility 0s linear .35s}
.kh-head.is-open .kh-panel{visibility:visible;opacity:1;transition:opacity .35s var(--ease)}
.kh-plinks{list-style:none;margin:0;padding:0}
.kh-plinks li{border-bottom:1px solid var(--line)}
.kh-plink{display:flex;align-items:baseline;gap:14px;padding:14px 0;font-family:var(--display);font-stretch:125%;font-weight:800;
font-size:clamp(34px,10vw,48px);line-height:1;letter-spacing:-.035em;color:var(--ink)}
.kh-plink:hover{color:var(--rose)}
.kh-pnum{font-family:var(--mono);font-size:12px;font-weight:400;letter-spacing:.06em;color:var(--rose)}
.kh-pfoot{display:flex;flex-direction:column;gap:18px;padding-top:32px}
.kh-pbtn{align-self:flex-start}
.kh-pmail{margin:0;font-family:var(--mono);font-size:12px;line-height:1.7;color:var(--mute)}
/* hero */
.kh-hero{position:relative;min-height:100vh;min-height:100svh;padding-top:64px;display:flex;flex-direction:column;background:var(--bg);isolation:isolate}
.kh-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* atmosphere: drifting colour glows + film grain, all on the light fog */
.kh-atmo{position:absolute;inset:0;z-index:0;overflow:hidden;pointer-events:none}
.kh-blob{position:absolute;border-radius:50%;will-change:transform}
.kh-blob--a{width:62vw;height:62vw;left:-14vw;top:18%;background:radial-gradient(closest-side,var(--rose-soft),rgba(248,217,230,.55) 45%,rgba(248,217,230,0))}
.kh-blob--b{width:66vw;height:66vw;right:-18vw;top:4%;background:radial-gradient(closest-side,var(--sky-soft),rgba(221,240,255,.6) 45%,rgba(221,240,255,0))}
.kh-blob--c{width:40vw;height:40vw;left:34vw;top:46%;background:radial-gradient(closest-side,rgba(209,30,107,.13),rgba(209,30,107,0))}
.kh-grain{position:absolute;inset:-80px;opacity:.32;mix-blend-mode:multiply;will-change:transform;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .106 0 0 0 0 .082 0 0 0 0 .188 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");background-size:180px 180px}
.kh-top{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;gap:24px;width:100%;padding-top:clamp(18px,3vw,40px)}
.kh-intro{margin:0;max-width:min(700px,62vw);font-size:clamp(15px,1.2vw,18px);line-height:1.45;font-weight:500;color:var(--ink)}
.kh-meta{margin:4px 0 0;white-space:nowrap;display:flex;align-items:center;gap:.5em}
.kh-meta-rose{color:var(--rose)}
.kh-rec{display:inline-flex;align-items:center;gap:6px;padding:3px 8px 3px 7px;border-radius:999px;background:var(--paper);color:var(--rose);box-shadow:inset 0 0 0 1px var(--rose-soft)}
.kh-rec-dot{width:7px;height:7px;border-radius:50%;background:var(--rose);box-shadow:0 0 0 3px rgba(209,30,107,.18)}
.kh-mark{position:relative;z-index:3;margin:clamp(10px,2vh,30px) 0 0;text-align:center;font-family:var(--display);font-stretch:125%;
font-weight:800;font-size:15.4vw;line-height:1;color:var(--ink);pointer-events:none}
.kh-giant{transform-origin:0 0;will-change:transform}
/* the curved wall: .kh-wall owns perspective, .kh-ring is the 3D stage that tilts as one */
.kh-wall{--kh-cw:clamp(118px,11.5vw,200px);--kh-ch:calc(var(--kh-cw) * 1.5);--kh-bs:128px;position:relative;z-index:1;flex:1 0 auto;
min-height:calc(var(--kh-ch) * 1.72);margin-top:calc(var(--kh-ch) * -.22);overflow:hidden;perspective:1000px}
@supports (overflow:clip){.kh-wall{overflow:visible;overflow-x:clip}}
.kh-ring{position:absolute;left:0;right:0;top:calc(50% - var(--kh-ch) * .86);height:calc(var(--kh-ch) * 1.5);transform-style:preserve-3d;will-change:transform}
.kh-card{position:absolute;left:50%;top:50%;width:var(--kh-cw);height:var(--kh-ch);margin:calc(var(--kh-ch) / -2) 0 0 calc(var(--kh-cw) / -2);
border-radius:14px;overflow:hidden;background:var(--paper);box-shadow:0 28px 44px -26px rgba(27,21,48,.45);
-webkit-box-reflect:below 5px linear-gradient(to bottom,rgba(27,21,48,0) 62%,rgba(27,21,48,.26));
-webkit-backface-visibility:hidden;backface-visibility:hidden;will-change:transform,opacity;opacity:0}
.kh-card img{width:100%;height:100%;object-fit:cover;transform-origin:50% 60%}
.kh-cap{position:absolute;left:8px;right:8px;bottom:8px;margin:0;padding:8px 10px;border-radius:10px;background:rgba(255,255,255,.9);
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:0 10px 24px -14px rgba(27,21,48,.5);
display:flex;flex-direction:column;gap:2px;opacity:0;visibility:hidden;pointer-events:none}
.kh-cap b{font-size:12px;line-height:1.15;font-weight:800;color:var(--ink);letter-spacing:-.01em}
.kh-cap span{font-family:var(--mono);font-size:10px;line-height:1.3;letter-spacing:.03em;color:var(--rose)}
.kh-card.is-cap .kh-cap{opacity:1;visibility:visible}
/* glossy floor the frames stand on */
.kh-floor{position:absolute;left:0;right:0;top:calc(50% + var(--kh-ch) * .26);height:calc(var(--kh-ch) * .75);pointer-events:none;
background:radial-gradient(52% 100% at 50% 0,rgba(255,255,255,.85),rgba(255,255,255,0) 72%)}
.kh-floor::before{content:"";position:absolute;left:8%;right:8%;top:0;height:1px;background:linear-gradient(90deg,rgba(220,217,230,0),var(--line) 30%,var(--line) 70%,rgba(220,217,230,0))}
/* rotating showreel badge */
.kh-badge{position:absolute;z-index:5;width:var(--kh-bs);height:var(--kh-bs);left:calc(50% + var(--kh-cw) * .7);top:calc(50% - var(--kh-ch) * .11 - var(--kh-bs) * .9);
border-radius:50%;background:rgba(255,255,255,.78);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
box-shadow:var(--shadow),inset 0 0 0 1px rgba(255,255,255,.9)}
.kh-bring{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.kh-btext{font-family:var(--mono);font-size:9.6px;font-weight:500;fill:var(--ink);letter-spacing:.06em}
.kh-bplay{position:absolute;inset:31%;border-radius:50%;background:var(--rose);box-shadow:var(--shadow-rose);display:grid;place-items:center;transition:background .25s var(--ease)}
.kh-bplay svg{width:42%;height:42%;fill:var(--paper)}
.kh-badge:hover .kh-bplay{background:var(--rose-deep)}
.kh-badge:focus-visible{outline-offset:5px}
.kh-cta{position:relative;z-index:2;text-align:center;padding:clamp(8px,1.4vw,20px) var(--gutter) clamp(22px,3vw,40px)}
.kh-cta-copy{margin:0 auto 18px;max-width:42ch;font-size:clamp(15px,1.1vw,17px);color:var(--mute)}
.kh-btns{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}
/* discipline marquee */
.kh-marq{position:relative;z-index:2;overflow:hidden;border-block:1px solid var(--line);padding-block:clamp(10px,1.2vw,18px);background:rgba(255,255,255,.35)}
.kh-mtrack{display:flex;width:max-content;will-change:transform}
.kh-mgroup{display:flex;align-items:center;flex:none}
.kh-mi{font-family:var(--display);font-stretch:125%;font-weight:800;font-size:clamp(34px,5.2vw,86px);line-height:1;letter-spacing:-.035em;
white-space:nowrap;color:var(--rose);padding-inline:.28em}
.kh-mi--o{color:transparent;-webkit-text-stroke:1.5px var(--ink)}
.kh-star{flex:none;width:clamp(18px,2.2vw,34px);height:clamp(18px,2.2vw,34px);fill:var(--rose);margin-inline:.35em}
@media (max-width:767px){
.kh-nav{display:none}
.kh-toggle{display:block}
.kh-meta{display:none}
.kh-intro{max-width:none;font-size:15px}
.kh-mark{margin-top:28px}
.kh-wall{--kh-cw:27vw;--kh-bs:80px;min-height:calc(var(--kh-ch) * 1.62);margin-top:-6px}
.kh-badge{left:auto;right:10px;top:calc(50% - var(--kh-ch) * .11 - var(--kh-bs) * 1.25)}
.kh-btext{font-size:10px}
.kh-card:nth-child(n+9){display:none}
.kh-btns .btn{padding:13px 20px}
.kh-mi--o{-webkit-text-stroke-width:1.2px}
.kh-cap{left:5px;right:5px;bottom:5px;padding:6px 7px;border-radius:8px}
.kh-cap b{font-size:11px}
.kh-cap span{font-size:9px;letter-spacing:0}
}
@media (min-width:768px){ .kh-panel{display:none} }
@media (min-width:1024px){
.kh-cta{display:flex;align-items:center;justify-content:center;gap:28px;margin-top:calc(var(--kh-ch) * -.3)}
.kh-cta-copy{margin:0;max-width:34ch;text-align:left}
}
@media (prefers-reduced-motion: reduce){
.kh-head .kh-logo{opacity:1}
.kh-bar,.kh-panel,.kh-link::after,.kh-pill,.kh-toggle-line,.kh-bplay{transition:none}
.kh-card{opacity:1}
}
</style>
<script>
(function(){
const head = document.getElementById('kh-head');
const hero = document.getElementById('top');
if(!head || !hero) return;
const KF = window.KF || {};
const reduce = !!KF.reduce;
const EASE = KF.ease || { out:'expo.out', soft:'power3.out', io:'power2.inOut' };
const mqMobile = matchMedia('(max-width: 767px)');
const toggle = head.querySelector('.kh-toggle');
const panel = document.getElementById('kh-panel');
const logoWord = head.querySelector('.kh-logo .kh-word');
const mark = hero.querySelector('.kh-mark');
const giant = hero.querySelector('.kh-giant');
const top = hero.querySelector('.kh-top');
const intro = hero.querySelector('.kh-intro');
const wall = hero.querySelector('.kh-wall');
const ring = wall.querySelector('.kh-ring');
const badge = wall.querySelector('.kh-badge');
const cards = Array.from(ring.querySelectorAll('.kh-card'));
const mtrack = hero.querySelector('.kh-mtrack');
const mgroup = mtrack.querySelector('.kh-mgroup');
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
if(reduce) head.classList.add('kh-head--static');
/* ---------- mobile menu ---------- */
let menuOpen = false;
function setMenu(v, restoreFocus){
if(v === menuOpen) return;
menuOpen = v;
head.classList.toggle('is-open', v);
toggle.setAttribute('aria-expanded', String(v));
toggle.setAttribute('aria-label', v ? 'Close menu' : 'Open menu');
panel.inert = !v;
document.documentElement.style.overflow = v ? 'hidden' : '';
if(KF.lenis) v ? KF.lenis.stop() : KF.lenis.start();
if(v){ const f = panel.querySelector('a'); f && f.focus({ preventScroll:true }); }
else if(restoreFocus) toggle.focus({ preventScroll:true });
}
toggle.addEventListener('click', () => setMenu(!menuOpen, true));
document.addEventListener('keydown', e => { if(e.key === 'Escape' && menuOpen) setMenu(false, true); });
mqMobile.addEventListener('change', () => { if(!mqMobile.matches) setMenu(false, false); });
/* in-page links: close the menu, then glide with Lenis when it is running */
function goTo(id){
const target = id === '#top' ? hero : document.querySelector(id);
if(!target) return;
if(KF.lenis) KF.lenis.scrollTo(target, { duration:1.2 });
else target.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
history.replaceState(null, '', id);
}
head.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', onAnchor));
hero.querySelectorAll('.kh-cta a[href^="#"]').forEach(a => a.addEventListener('click', onAnchor));
function onAnchor(e){
const id = this.getAttribute('href');
if(menuOpen) setMenu(false, false);
if(KF.lenis && (id === '#top' || document.querySelector(id))){ e.preventDefault(); goTo(id); }
}
badge.addEventListener('click', () => goTo('#work'));
/* ---------- giant wordmark -> header logo ---------- */
let G = { x:0, y:0, w:1 }, L = { x:0, y:0, w:1 }, S = innerHeight * .6, wallReady = false;
function fit(){
mark.style.fontSize = '100px';
const w = giant.offsetWidth || 1;
mark.style.fontSize = (100 * document.documentElement.clientWidth * .92 / w).toFixed(3) + 'px';
}
function measure(){
const prev = giant.style.transform;
giant.style.transform = 'none';
const r = giant.getBoundingClientRect();
G = { x:r.left, y:r.top + window.scrollY, w:r.width || 1 };
const l = logoWord.getBoundingClientRect();
L = { x:l.left, y:l.top, w:l.width || 1 };
S = innerHeight * .6;
giant.style.transform = prev;
}
function onScroll(y){
head.classList.toggle('is-solid', reduce ? y > 8 : y > S * .98);
if(reduce) return;
const p = clamp(y / S), e = easeInOut(p);
const s = lerp(1, L.w / G.w, e);
const tx = lerp(G.x, L.x, e) - G.x;
const ty = lerp(G.y, L.y, e) - (G.y - y);
giant.style.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) scale(${s.toFixed(4)})`;
const hand = clamp((p - .9) / .1);
giant.style.opacity = String(1 - hand);
giant.style.visibility = p >= 1 ? 'hidden' : '';
head.style.setProperty('--kh-lo', hand.toFixed(3));
top.style.opacity = String(clamp(1 - p * 1.8));
}
fit(); measure(); onScroll(window.scrollY);
ScrollTrigger.addEventListener('refreshInit', () => { fit(); measure(); });
ScrollTrigger.addEventListener('refresh', () => { measure(); onScroll(window.scrollY); if(wallReady) layout(); });
const heroST = ScrollTrigger.create({ trigger:hero, start:'top top', end:'bottom top', onUpdate:self => onScroll(self.scroll()) });
if(document.fonts && document.fonts.ready) document.fonts.ready.then(() => KF.refresh && KF.refresh());
/* ---------- curved wall of frames (inside of a cylinder) ---------- */
// st.slow: drift multiplier (hover eases it to .15) · st.kick: smoothed scroll velocity -1..1
// st.open: scrubbed hero progress, bends the cylinder · st.dim: fades the frames you are not looking at
const st = { base:0, ptr:0, pty:0, open:0, kick:0, slow:1, dim:0 };
const DRIFT = 8; // degrees per second
cards.forEach(c => {
c._s = { in: reduce ? 1 : 0, h:0 };
c._img = c.querySelector('img');
c._cap = c.querySelector('.kh-cap');
c._v = -1;
});
let active = [], W = 1, H = 1, P = 800, R = 400, D = 200, CW = 150, spc = 18, period = 252, mobile = false, GW = 1;
const slotOf = i => i === 0 ? 0 : (i % 2 ? (i + 1) / 2 : -i / 2); // 0, +1, -1, +2, -2 ... first DOM cards sit mid-wall
const RAD = Math.PI / 180;
function layout(){
mobile = mqMobile.matches;
active = cards.slice(0, mobile ? 8 : 14);
W = wall.clientWidth || innerWidth;
H = innerHeight;
CW = active[0].offsetWidth || 150;
P = W * (mobile ? .62 : .55);
R = W * (mobile ? .42 : .437);
D = W * (mobile ? .21 : .223);
wall.style.perspective = P.toFixed(1) + 'px';
spc = Math.max((CW * 1.16 / R) / RAD, 200 / active.length);
period = spc * active.length;
GW = mgroup.offsetWidth || 1;
render();
}
function wrap(a){ a = (a + period / 2) % period; if(a < 0) a += period; return a - period / 2; }
function render(){
const curve = 1 + .5 * st.open; // scroll bends the wall into more of a cylinder
const Re = R / curve, De = D - R + Re; // keep the centre frame at the same depth
const half = W / 2;
for(let i = 0; i < active.length; i++){
const c = active[i], s = c._s;
const a0 = wrap(slotOf(i) * spc + st.base);
const a = (a0 + st.ptr * 8) * curve;
if(Math.abs(a) > 88 || s.in <= 0){ if(c._v !== 0){ c.style.visibility = 'hidden'; c._v = 0; } continue; }
if(c._v !== 1){ c.style.visibility = ''; c._v = 1; }
const r = a * RAD, inv = 1 - s.in;
const x = Re * Math.sin(r);
const z = De - Re * Math.cos(r) + s.h * W * .07 - inv * W * 2.4;
const sp = P / (P - z);
const xs = Math.abs(x * sp);
let o = clamp((half + CW * .45 * sp - xs) / (CW * .7 * sp)) * clamp((88 - Math.abs(a)) / 12);
o *= clamp(s.in * 2.2) * (1 - .42 * st.dim * (1 - s.h));
const ry = -a + inv * (a0 < 0 ? -150 : 150);
c.style.opacity = o.toFixed(3);
c.style.transform = `translate3d(${x.toFixed(2)}px,${(inv * -H * .12).toFixed(2)}px,${z.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg) scale(${(1 + .08 * s.h).toFixed(4)})`;
}
const rx = st.pty * -5 + st.kick * 7;
ring.style.transform = `translate3d(0,${(-st.open * H * .1).toFixed(2)}px,${(st.open * W * .05).toFixed(2)}px) rotateX(${rx.toFixed(3)}deg) skewX(${(st.kick * -3).toFixed(3)}deg)`;
}
/* marquee position lives in the same ticker (no extra loop) */
let mx = 0, mdir = 1;
const mSet = gsap.quickSetter(mtrack, 'x', 'px');
const kickTo = gsap.quickTo(st, 'kick', { duration:.9, ease:'power3.out' });
const velocity = () => KF.lenis ? (KF.lenis.velocity || 0) * 60 : heroST.getVelocity();
function tick(time, dtMs){
const dt = Math.min(dtMs || 16, 50) / 1000;
kickTo(clamp(velocity() / 2400, -1, 1));
st.base += dt * (DRIFT * st.slow + st.kick * 55);
st.base = ((st.base % period) + period) % period;
if(Math.abs(st.kick) > .04) mdir = st.kick > 0 ? 1 : -1;
mx -= dt * mdir * (GW * .02 + Math.abs(st.kick) * GW * .5);
mx = -((((-mx) % GW) + GW) % GW);
mSet(mx);
render();
}
/* hover / tap a frame: drift slows, frame steps forward, caption chip slides up */
let hovered = null, running = false, lastPT = 'mouse';
const idle = () => { if(!running) render(); };
const T = v => reduce ? 0 : v;
gsap.set(cards.map(c => c._cap), { yPercent:130 });
function focusCard(c, on){
c.classList.toggle('is-cap', on);
gsap.to(c._s, { h:on ? 1 : 0, duration:T(on ? .8 : .6), ease:on ? EASE.out : EASE.soft, overwrite:true, onUpdate:idle });
gsap.to(c._img, { scale:on ? 1.1 : 1, duration:T(1), ease:EASE.soft, overwrite:true });
gsap.to(c._cap, { yPercent:on ? 0 : 130, autoAlpha:on ? 1 : 0, duration:T(on ? .6 : .3), delay:T(on ? .06 : 0), ease:on ? EASE.out : 'power2.in', overwrite:true });
}
function setHover(c){
if(c === hovered) return;
if(hovered) focusCard(hovered, false);
hovered = c;
if(c) focusCard(c, true);
gsap.to(st, { slow:c ? .15 : 1, dim:c ? 1 : 0, duration:T(c ? .6 : 1.2), ease:'power2.out', overwrite:'auto', onUpdate:idle });
}
document.addEventListener('pointerdown', e => { lastPT = e.pointerType; }, { passive:true });
wall.addEventListener('pointerover', e => { if(e.pointerType === 'mouse') setHover(e.target.closest('.kh-card')); });
wall.addEventListener('pointerleave', e => { if(e.pointerType === 'mouse') setHover(null); });
document.addEventListener('click', e => {
if(lastPT === 'mouse') return;
const c = e.target.closest ? e.target.closest('.kh-card') : null;
setHover(c && c !== hovered && wall.contains(c) ? c : null);
});
let rt;
addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(layout, 100); });
mqMobile.addEventListener('change', () => { cards.forEach(c => { c._v = -1; c.style.visibility = ''; }); setHover(null); layout(); });
wallReady = true;
layout();
if(reduce) return; // static wall, static marquee + badge, big wordmark, visible header logo
/* pointer: x turns the ring, y tilts it (mouse only) */
const ptrTo = gsap.quickTo(st, 'ptr', { duration:1.4, ease:EASE.soft });
const ptyTo = gsap.quickTo(st, 'pty', { duration:1.4, ease:EASE.soft });
hero.addEventListener('pointermove', e => {
if(mobile || e.pointerType !== 'mouse') return;
ptrTo(clamp((e.clientX / innerWidth - .5) * 2, -1, 1));
ptyTo(clamp((e.clientY / innerHeight - .5) * 2, -1, 1));
});
hero.addEventListener('pointerleave', () => { ptrTo(0); ptyTo(0); });
/* scrubbed: the wall curls tighter, rises and pushes in while the wordmark flies to the logo */
gsap.to(st, { open:1, ease:'none', scrollTrigger:{ trigger:hero, start:'top top', end:'bottom top', scrub:.7 } });
/* ambient loops, paused together with the wall when the hero is offscreen */
const loops = [];
const drift = [[9, 6, 1.12, 13], [-8, 7, .9, 16], [12, -9, 1.25, 11]];
hero.querySelectorAll('.kh-blob').forEach((b, i) => {
const d = drift[i];
loops.push(gsap.to(b, { xPercent:d[0], yPercent:d[1], scale:d[2], duration:d[3], ease:'sine.inOut', yoyo:true, repeat:-1 }));
});
loops.push(gsap.fromTo(hero.querySelector('.kh-grain'), { x:0, y:0 }, { x:-62, y:41, duration:.9, ease:'steps(7)', repeat:-1 }));
loops.push(gsap.to(hero.querySelector('.kh-rec-dot'), { opacity:.15, duration:.55, ease:'steps(1)', yoyo:true, repeat:-1 }));
const spin = gsap.to(badge.querySelector('.kh-bring'), { rotation:360, duration:14, ease:'none', repeat:-1, transformOrigin:'50% 50%' });
loops.push(spin);
badge.addEventListener('pointerenter', () => gsap.to(spin, { timeScale:4, duration:.6, ease:EASE.soft, overwrite:true }));
badge.addEventListener('pointerleave', () => gsap.to(spin, { timeScale:1, duration:1.2, ease:EASE.soft, overwrite:true }));
badge.addEventListener('focus', () => gsap.to(spin, { timeScale:3, duration:.6, overwrite:true }));
badge.addEventListener('blur', () => gsap.to(spin, { timeScale:1, duration:1, overwrite:true }));
if(KF.magnetic) KF.magnetic(badge, .4);
/* seamless marquee: a second copy of the group trails the first */
mtrack.appendChild(mgroup.cloneNode(true));
const run = on => {
if(on === running) return;
running = on;
on ? gsap.ticker.add(tick) : gsap.ticker.remove(tick);
loops.forEach(t => on ? t.resume() : t.pause());
};
if('IntersectionObserver' in window){
new IntersectionObserver(es => run(es[0].isIntersecting), { rootMargin:'80px 0px' }).observe(hero);
} else run(true);
/* ---------- load intro ---------- */
const chars = giant.querySelectorAll('.kh-c');
gsap.set(chars, { yPercent:110 });
const words = KF.split ? KF.split(intro, 'words') : [];
const lineIx = [];
let lastTop = null, line = -1;
words.forEach(w => { const t = w.parentNode.offsetTop; if(t !== lastTop){ line++; lastTop = t; } lineIx.push(line); });
gsap.set(words, { yPercent:115, rotate:3 });
render();
gsap.timeline({ delay:.1 })
.to(chars, { yPercent:0, duration:1.15, ease:EASE.out, stagger:.055 }, 0)
.to(cards.map(c => c._s), { in:1, duration:1.7, ease:EASE.out, stagger:.07, onUpdate:idle }, .1)
.to(words, { yPercent:0, rotate:0, duration:1.1, ease:EASE.out, stagger:i => lineIx[i] * .1 + (i % 14) * .005 }, .25)
.from(hero.querySelector('.kh-meta'), { y:12, autoAlpha:0, duration:.9, ease:EASE.soft }, .45)
.from(badge, { scale:0, rotation:-120, duration:1.2, ease:'back.out(1.6)' }, .9)
.from([hero.querySelector('.kh-cta-copy'), ...hero.querySelectorAll('.kh-btns .btn')],
{ y:16, autoAlpha:0, duration:.9, ease:EASE.soft, stagger:.07 }, .6)
.from(mtrack, { yPercent:110, duration:1.2, ease:EASE.out }, .75);
})();
</script>
```
## `sections/02-reel.html`
```html
<section class="kr" id="work" aria-labelledby="kr-heading">
<div class="wrap kr-intro">
<div class="kr-intro__main">
<p class="label label--rose kr-intro__label">Selected work 2024–26</p>
<h2 class="display kr-intro__title" id="kr-heading">Six films,<br><em>one long exposure.</em></h2>
</div>
<div class="kr-intro__side">
<p class="kr-intro__count" aria-label="Six projects">06</p>
<p class="kr-intro__note">Scroll through the stack. Open any frame for the full credits.</p>
</div>
</div>
<div class="kr-stack">
<article class="kr-panel" data-i="0">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p1.jpg" alt="A lone car on an empty coastal road at golden hour" loading="lazy" width="1880" height="1255"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">01 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Halcyon Motors</span><span class="kr-title">Long Way Round</span></h3>
<p class="kr-info"><span>dir. Tomás Ferreira</span><span>2026 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Long Way Round for Halcyon Motors"></button>
</div>
</article>
<article class="kr-panel" data-i="1">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p2.jpg" alt="Model in soft pink evening light for a fashion film" loading="lazy" width="1880" height="1057"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">02 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Maison Oriel</span><span class="kr-title">Pink Hour</span></h3>
<p class="kr-info"><span>dir. Ada Kwon</span><span>2026 · Fashion film</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Pink Hour for Maison Oriel"></button>
</div>
</article>
<article class="kr-panel" data-i="2">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p3.jpg" alt="Cyclist climbing a steep mountain road" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">03 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Velo Norte</span><span class="kr-title">The Climb</span></h3>
<p class="kr-info"><span>dir. Jun Park</span><span>2025 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: The Climb for Velo Norte"></button>
</div>
</article>
<article class="kr-panel" data-i="3">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p4.jpg" alt="The sea at dusk with the last light on the water" loading="lazy" width="1625" height="1300"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">04 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Salt & Current</span><span class="kr-title">Last Light</span></h3>
<p class="kr-info"><span>dir. Noor Haddad</span><span>2025 · Branded short</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Last Light for Salt and Current"></button>
</div>
</article>
<article class="kr-panel" data-i="4">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p5.jpg" alt="Runner mid-stride on a city street" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">05 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Stride Athletic</span><span class="kr-title">The City Is a Stage</span></h3>
<p class="kr-info"><span>dir. Lena Brandt</span><span>2025 · Commercial</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: The City Is a Stage for Stride Athletic"></button>
</div>
</article>
<article class="kr-panel" data-i="5">
<div class="kr-card">
<div class="kr-frame">
<div class="kr-media"><img src="assets/p6.jpg" alt="Close-up of a mechanical wristwatch" loading="lazy" width="1880" height="1253"></div>
<div class="kr-scrim" aria-hidden="true"></div>
<p class="kr-index" aria-hidden="true">06 / 06</p>
</div>
<div class="kr-meta">
<h3 class="kr-name"><span class="kr-client">Aurel Horology</span><span class="kr-title">Second Nature</span></h3>
<p class="kr-info"><span>dir. Walter Imrie</span><span>2024 · Product film</span></p>
</div>
<div class="kr-dim" aria-hidden="true"></div>
<button class="kr-hit" type="button" aria-haspopup="dialog" aria-label="Open project: Second Nature for Aurel Horology"></button>
</div>
</article>
</div>
<div class="kr-prog" aria-hidden="true">
<div class="kr-prog__ticks">
<span class="kr-prog__tick is-on"></span><span class="kr-prog__tick"></span><span class="kr-prog__tick"></span>
<span class="kr-prog__tick"></span><span class="kr-prog__tick"></span><span class="kr-prog__tick"></span>
</div>
<span class="kr-prog__num">01</span>
</div>
<div class="kr-ring" aria-hidden="true"></div>
<div class="kr-cursor" aria-hidden="true"><span class="kr-cursor__dot"><span class="kr-cursor__txt">View</span></span></div>
<div class="kr-modal" id="kr-modal" role="dialog" aria-modal="true" aria-labelledby="kr-m-title" aria-hidden="true">
<div class="kr-modal__backdrop" data-kr-close></div>
<div class="kr-dialog" data-lenis-prevent>
<button class="kr-close" type="button" data-kr-close aria-label="Close project details">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 5l14 14M19 5L5 19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<figure class="kr-dialog__still"><img id="kr-m-img" src="" alt=""></figure>
<div class="kr-dialog__body">
<p class="label label--rose" id="kr-m-index"></p>
<p class="kr-dialog__client" id="kr-m-client"></p>
<h3 class="kr-dialog__title" id="kr-m-title"></h3>
<dl class="kr-facts">
<div><dt class="label">Director</dt><dd id="kr-m-dir"></dd></div>
<div><dt class="label">Year</dt><dd id="kr-m-year"></dd></div>
<div><dt class="label">Category</dt><dd id="kr-m-cat"></dd></div>
</dl>
<p class="kr-dialog__syn" id="kr-m-syn"></p>
<p class="label kr-credits__head">Credits</p>
<dl class="kr-credits" id="kr-m-credits"></dl>
</div>
</div>
</div>
</section>
<style>
.kr{background:var(--bg);position:relative;z-index:1}
.kr-intro{display:flex;align-items:flex-end;justify-content:space-between;gap:32px;
padding-top:clamp(80px,11vw,160px);padding-bottom:clamp(40px,5vw,72px)}
.kr-intro__label{margin:0 0 20px}
.kr-intro__title{font-size:clamp(44px,7.4vw,120px);color:var(--ink)}
.kr-intro__side{display:flex;flex-direction:column;align-items:flex-end;gap:10px;text-align:right;flex:none}
.kr-intro__count{margin:0;font-family:var(--display);font-stretch:125%;font-weight:800;font-size:clamp(56px,8vw,128px);
line-height:.85;letter-spacing:-.04em;color:var(--rose)}
.kr-intro__note{margin:0;font-family:var(--mono);font-size:12px;letter-spacing:.04em;color:var(--mute);max-width:26ch}
/* ---- sticky stack ---- */
.kr-stack{position:relative}
.kr-panel{position:sticky;top:0;height:100vh;height:100svh;width:100%}
.kr-card{position:absolute;inset:0;overflow:hidden;transform-origin:50% 40%;will-change:transform;border-radius:0}
.kr-frame{position:absolute;inset:0;overflow:hidden}
.kr-media{position:absolute;inset:-6% 0;will-change:transform}
.kr-media img{width:100%;height:100%;object-fit:cover;transform-origin:50% 70%}
.kr-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.kr-scrim{position:absolute;inset:0;pointer-events:none;
background:linear-gradient(to top,rgba(27,21,48,.78) 0%,rgba(27,21,48,.42) 30%,rgba(27,21,48,0) 58%),
linear-gradient(to bottom,rgba(27,21,48,.35) 0%,rgba(27,21,48,0) 18%)}
.kr-dim{position:absolute;inset:0;background:rgba(27,21,48,1);opacity:0;pointer-events:none}
.kr-index{position:absolute;top:calc(64px + 20px);left:var(--gutter);margin:0;font-family:var(--mono);font-size:12px;
letter-spacing:.1em;color:#fff}
.kr-meta{position:absolute;left:var(--gutter);right:var(--gutter);bottom:clamp(24px,5vw,64px);
display:flex;align-items:flex-end;justify-content:space-between;gap:24px;color:#fff;pointer-events:none}
.kr-name{margin:0;display:flex;flex-direction:column;gap:clamp(6px,1vw,14px);min-width:0}
.kr-client{font-family:var(--display);font-weight:500;font-size:clamp(17px,1.8vw,26px);letter-spacing:-.01em;line-height:1.1}
.kr-title{font-family:var(--display);font-stretch:125%;font-weight:700;font-size:clamp(40px,7.6vw,110px);
line-height:.9;letter-spacing:-.035em;max-width:14ch;text-wrap:balance}
.kr-info{margin:0;display:flex;flex-direction:column;align-items:flex-end;gap:6px;text-align:right;flex:none;
font-family:var(--mono);font-size:13px;letter-spacing:.04em;color:#fff;opacity:.9}
.kr-hit{position:absolute;inset:0;width:100%;height:100%;z-index:2;border-radius:inherit}
.kr-hit:focus-visible{outline:3px solid var(--rose);outline-offset:-10px}
/* ---- custom cursor (enabled by JS on fine pointers) ---- */
.kr-cursor{position:fixed;left:0;top:0;width:96px;height:96px;margin:-48px 0 0 -48px;z-index:45;pointer-events:none;
display:none;will-change:transform}
.kr.kr-has-cursor .kr-cursor{display:block}
.kr.kr-has-cursor .kr-hit{cursor:none}
.kr-cursor__dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--rose);color:#fff;
font-family:var(--mono);font-size:13px;letter-spacing:.08em;text-transform:uppercase;box-shadow:var(--shadow-rose);
transform:scale(0);opacity:0}
.kr-cursor__txt{display:inline-block;will-change:transform}
/* trailing ring: sits under the dot, only shows while the pointer is moving */
.kr-ring{position:fixed;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;z-index:44;pointer-events:none;
border-radius:50%;box-shadow:inset 0 0 0 1.5px #fff,0 0 0 1px rgba(27,21,48,.14);display:none;opacity:0;will-change:transform}
.kr.kr-has-cursor .kr-ring{display:block}
/* ---- reel progress (fixed, desktop only) ---- */
.kr-prog{position:fixed;left:clamp(14px,1.5vw,26px);top:0;bottom:0;margin-block:auto;height:fit-content;z-index:44;
display:flex;flex-direction:column;align-items:center;gap:14px;pointer-events:none;visibility:hidden;opacity:0}
.kr-prog__ticks{display:flex;flex-direction:column;align-items:center;gap:11px}
.kr-prog__tick{display:block;width:3px;height:12px;border-radius:2px;background:rgba(255,255,255,.62);
box-shadow:0 0 0 1px rgba(27,21,48,.16);transform-origin:50% 50%;
transition:transform .55s var(--ease),background-color .3s var(--ease)}
.kr-prog__tick.is-on{background:var(--rose);transform:scaleY(1.9)}
.kr-prog__num{font-family:var(--mono);font-size:11px;letter-spacing:.08em;color:#fff;text-shadow:0 1px 6px rgba(27,21,48,.55)}
/* ---- modal (animated by a GSAP timeline) ---- */
.kr-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:clamp(16px,3vw,40px);visibility:hidden}
.kr-modal.is-open{visibility:visible}
.kr-modal__backdrop{position:absolute;inset:0;background:rgba(27,21,48,.35);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}
.kr-dialog{position:relative;width:min(1180px,100%);max-height:calc(100svh - 2*clamp(16px,3vw,40px));overflow:auto;overscroll-behavior:contain;
background:var(--paper);border-radius:24px;box-shadow:var(--shadow);display:grid;grid-template-columns:1.25fr 1fr}
.kr-dialog__still{margin:0;min-height:100%;background:var(--rose-soft);overflow:hidden}
.kr-dialog__still img{width:100%;height:100%;min-height:320px;object-fit:cover}
.kr-dialog__body{padding:clamp(24px,3.4vw,48px);display:flex;flex-direction:column;gap:14px}
.kr-dialog__body > .label{margin:0}
.kr-dialog__client{margin:8px 0 0;font-weight:500;font-size:18px;color:var(--mute)}
.kr-dialog__title{margin:0;font-stretch:125%;font-weight:800;font-size:clamp(32px,3.6vw,52px);line-height:.95;letter-spacing:-.035em;color:var(--ink)}
.kr-facts{margin:10px 0 0;display:grid;grid-template-columns:repeat(3,auto);gap:16px;justify-content:start;
padding:16px 0;border-block:1px solid var(--line)}
.kr-facts div{min-width:0}
.kr-facts dd{margin:4px 0 0;font-weight:600;font-size:15px;color:var(--ink)}
.kr-dialog__syn{margin:6px 0 0;color:var(--mute);font-size:16px;line-height:1.6}
.kr-credits__head{margin:8px 0 0}
.kr-credits{margin:0;display:grid;grid-template-columns:auto 1fr;gap:8px 20px;font-size:15px}
.kr-credits dt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);padding-top:2px}
.kr-credits dd{margin:0;color:var(--ink);font-weight:500}
.kr-close{position:absolute;top:14px;right:14px;z-index:2;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;
background:var(--paper);color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--line);transition:background .25s var(--ease),color .25s var(--ease)}
.kr-close:hover{background:var(--rose);color:#fff;box-shadow:none}
@media (max-width:860px){
.kr-dialog{grid-template-columns:1fr}
.kr-dialog__still img{min-height:0;aspect-ratio:3/2;height:auto}
.kr-close{background:var(--paper)}
}
@media (max-width:720px){
.kr-intro{flex-direction:column;align-items:flex-start;gap:20px}
.kr-intro__side{flex-direction:row;align-items:flex-end;text-align:left;gap:16px}
.kr-panel{height:85vh;height:85svh}
.kr-index{top:calc(64px + 14px)}
.kr-meta{flex-direction:column;align-items:flex-start;gap:14px;bottom:24px}
.kr-title{font-size:clamp(34px,10.5vw,56px)}
.kr-info{flex-direction:row;flex-wrap:wrap;gap:4px 14px;align-items:flex-start;text-align:left;font-size:12px}
.kr-facts{grid-template-columns:1fr 1fr}
.kr-prog{display:none}
}
@media (prefers-reduced-motion: reduce){
.kr-card,.kr-media,.kr-media img{transform:none !important;border-radius:0 !important}
.kr-frame{clip-path:none !important}
.kr-dim{opacity:0 !important}
.kr-prog__tick{transition:none}
.kr-ring{display:none !important}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('work');
if (!root) return;
const panels = [...root.querySelectorAll('.kr-panel')];
const reduce = KF.reduce;
const DATA = [
{ client:'Halcyon Motors', title:'Long Way Round', dir:'Tomás Ferreira', year:'2026', cat:'Commercial', img:'assets/p1.jpg',
alt:'A lone car on an empty coastal road at golden hour',
syn:'A retired engineer takes the slow road home and lets the coastline set the pace. We shot over nine days along the Atlantic, chasing one continuous hour of low sun. No voiceover, just the engine and the wind doing the talking.',
credits:[['Production','Kinoform Pictures'],['DOP','Inês Valadares'],['Editor','Marco Seidel'],['Colour','Priya Anand']] },
{ client:'Maison Oriel', title:'Pink Hour', dir:'Ada Kwon', year:'2026', cat:'Fashion film', img:'assets/p2.jpg',
alt:'Model in soft pink evening light for a fashion film',
syn:'Twelve minutes between sunset and streetlight, stretched into a three-minute film. Ada built the whole piece around a single colour and a single glance. The collection is worn, never shown off.',
credits:[['Production','Kinoform Pictures'],['DOP','Seo-yeon Baek'],['Editor','Clara Montiel'],['Colour','Hugo Lindqvist']] },
{ client:'Velo Norte', title:'The Climb', dir:'Jun Park', year:'2025', cat:'Commercial', img:'assets/p3.jpg',
alt:'Cyclist climbing a steep mountain road',
syn:'One rider, one mountain pass, and every metre of it earned. Cameras rode on a motorbike, a drone and a borrowed farm truck to keep the gradient honest. The cut breathes with the pedal stroke.',
credits:[['Production','Kinoform Pictures'],['DOP','Tae-min Oh'],['Editor','Rafaela Couto'],['Colour','Priya Anand']] },
{ client:'Salt & Current', title:'Last Light', dir:'Noor Haddad', year:'2025', cat:'Branded short', img:'assets/p4.jpg',
alt:'The sea at dusk with the last light on the water',
syn:'A fishing family on the Pacific coast prepares for a season that may not come. Noor spent a month in the village before the camera came out. The brand appears once, in the final frame, and earns it.',
credits:[['Production','Kinoform Pictures'],['DOP','Emiliano Ruiz'],['Editor','Nadia Okafor'],['Colour','Hugo Lindqvist']] },
{ client:'Stride Athletic', title:'The City Is a Stage', dir:'Lena Brandt', year:'2025', cat:'Commercial', img:'assets/p5.jpg',
alt:'Runner mid-stride on a city street',
syn:'A morning run choreographed like a dance across Berlin, crosswalks as cues and traffic as tempo. Lena cast working dancers, not athletes, and let rhythm lead the edit. Every step lands on the beat.',
credits:[['Production','Kinoform Pictures'],['DOP','Jonas Albrecht'],['Editor','Marco Seidel'],['Colour','Mira Castellanos']] },
{ client:'Aurel Horology', title:'Second Nature', dir:'Walter Imrie', year:'2024', cat:'Product film', img:'assets/p6.jpg',
alt:'Close-up of a mechanical wristwatch',
syn:'Two hundred and eleven parts, filmed at a scale where a screw looks like architecture. Walter built a motion-control rig small enough to orbit a balance wheel. Time slows down so you can watch it being made.',
credits:[['Production','Kinoform Pictures'],['DOP','Callum Reid'],['Editor','Nadia Okafor'],['Colour','Mira Castellanos']] }
];
const E = KF.ease;
const stack = root.querySelector('.kr-stack');
/* panel height, cached per refresh (panels are sticky, so their own rects move; the stack's don't) */
let PH = panels[0].offsetHeight, VH = innerHeight;
ScrollTrigger.addEventListener('refreshInit', () => { PH = panels[0].offsetHeight; VH = innerHeight; });
/* "top+=offset <viewport>" on the non-sticky stack = panel i's resting flow position */
const at = (i, vp) => () => `top+=${i * PH} ${vp}`;
/* ---------- intro ---------- */
const introTitle = root.querySelector('.kr-intro__title');
const countEl = root.querySelector('.kr-intro__count');
const introFade = [root.querySelector('.kr-intro__label'), root.querySelector('.kr-intro__note')];
KF.reveal(introTitle, { mode: 'chars', stagger: .022, start: 'top 85%' });
if (!reduce) {
countEl.textContent = '00';
gsap.set(countEl, { autoAlpha: 0 });
ScrollTrigger.create({
trigger: countEl, start: 'top 90%', once: true,
onEnter: () => {
const o = { v: 0 };
gsap.to(o, { v: 6, duration: 1.4, ease: 'power2.out', delay: .25,
onUpdate: () => { countEl.textContent = String(Math.round(o.v)).padStart(2, '0'); } });
gsap.fromTo(countEl, { yPercent: 30, autoAlpha: 0 }, { yPercent: 0, autoAlpha: 1, duration: 1.1, ease: E.out, delay: .15 });
}
});
gsap.from(introFade, { y: 18, autoAlpha: 0, duration: 1, ease: E.soft, stagger: .12, delay: .3,
scrollTrigger: { trigger: root.querySelector('.kr-intro'), start: 'top 80%', once: true } });
}
/* ---------- panels ---------- */
panels.forEach((panel, i) => {
const card = panel.querySelector('.kr-card');
const frame = panel.querySelector('.kr-frame');
const media = panel.querySelector('.kr-media');
const img = media.querySelector('img');
const dim = panel.querySelector('.kr-dim');
const client = panel.querySelector('.kr-client');
const title = panel.querySelector('.kr-title');
const info = panel.querySelector('.kr-info');
const idx = panel.querySelector('.kr-index');
if (reduce) return;
/* keep the heading readable: hidden plain copy for assistive tech, split chars are aria-hidden */
const sr = document.createElement('span');
sr.className = 'kr-sr'; sr.textContent = title.textContent.trim();
title.after(sr);
const chars = KF.split(title, 'chars');
title.removeAttribute('aria-label');
/* photo wipes up from the bottom edge + settles from 1.15, scrubbed to the panel's arrival */
const inTl = gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i, 'bottom'), end: at(i, 'top'), scrub: true }
});
inTl.fromTo(frame, { clipPath: 'inset(42% 6% 0% 6% round 28px)' },
{ clipPath: 'inset(0% 0% 0% 0% round 0px)', ease: 'none' }, 0)
.fromTo(img, { scale: 1.15 }, { scale: 1, ease: 'none' }, 0);
if (i > 0) inTl.fromTo(media, { yPercent: -5 }, { yPercent: 0, ease: 'none' }, 0);
/* copy: plays on the way in, rewinds when scrolled back above, replays on return */
gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i, '45%'), toggleActions: 'play none none reverse' }
})
.from(idx, { y: -14, autoAlpha: 0, duration: .8, ease: E.soft }, 0)
.from(client, { y: 26, autoAlpha: 0, duration: .9, ease: E.soft }, .05)
.from(chars, { yPercent: 115, rotate: 6, duration: 1.1, ease: E.out, stagger: .028 }, .1)
.from(info.children, { y: 16, autoAlpha: 0, duration: .8, ease: E.soft, stagger: .08 }, .35);
/* being covered by the next panel: recede, round, dim */
if (i < panels.length - 1) {
gsap.timeline({
scrollTrigger: { trigger: stack, start: at(i + 1, 'bottom'), end: at(i + 1, 'top'), scrub: true }
})
.fromTo(card, { scale: 1, borderRadius: 0 }, { scale: .92, borderRadius: 24, ease: 'none' }, 0)
.fromTo(dim, { opacity: 0 }, { opacity: .38, ease: 'none' }, 0);
}
});
/* ---------- progress ticks ---------- */
const prog = root.querySelector('.kr-prog');
const ticks = [...prog.querySelectorAll('.kr-prog__tick')];
const progNum = prog.querySelector('.kr-prog__num');
let cur = 0;
const setActive = n => {
if (n === cur) return;
ticks[cur].classList.remove('is-on'); ticks[n].classList.add('is-on');
progNum.textContent = String(n + 1).padStart(2, '0');
cur = n;
};
gsap.set(prog, { x: reduce ? 0 : -14 });
ScrollTrigger.create({
trigger: stack, start: 'top 55%', end: 'bottom 45%',
onToggle: s => gsap.to(prog, { autoAlpha: s.isActive ? 1 : 0, x: s.isActive || reduce ? 0 : -14,
duration: reduce ? 0 : .5, ease: E.soft, overwrite: true }),
/* panel i is "current" once its top passes 30% of the viewport; pure arithmetic, no layout reads */
onUpdate: s => {
const n = Math.floor((s.scroll() - s.start - VH * .25) / PH);
setActive(Math.max(0, Math.min(panels.length - 1, n)));
}
});
panels.forEach(p => {
const img = p.querySelector('img');
if (!img.complete) img.addEventListener('load', KF.refresh, { once: true });
});
KF.refresh();
/* ---------- custom cursor ---------- */
const cursor = root.querySelector('.kr-cursor');
const dotEl = cursor.querySelector('.kr-cursor__dot');
const txtEl = cursor.querySelector('.kr-cursor__txt');
const ring = root.querySelector('.kr-ring');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
let px = -200, py = -200, on = false, modalOpen = false, chk = 0;
const setOn = v => {
if (v === on) return;
on = v;
if (v) {
gsap.to(dotEl, { scale: 1, autoAlpha: 1, duration: reduce ? 0 : .6, ease: E.out, overwrite: true });
if (!reduce) {
gsap.fromTo(txtEl, { rotate: -100, yPercent: 60, autoAlpha: 0 },
{ rotate: 0, yPercent: 0, autoAlpha: 1, duration: .7, ease: 'back.out(1.7)', delay: .06, overwrite: true });
gsap.to(ring, { autoAlpha: 1, scale: 1, duration: .5, ease: E.soft, overwrite: 'auto' });
}
} else {
gsap.to(dotEl, { scale: 0, autoAlpha: 0, duration: reduce ? 0 : .35, ease: 'power3.in', overwrite: true });
if (!reduce) gsap.to(ring, { autoAlpha: 0, scale: .4, duration: .3, ease: 'power2.in', overwrite: 'auto' });
}
};
const overPanel = () => {
const el = document.elementFromPoint(px, py);
return !!(el && el.closest && el.closest('#work .kr-hit'));
};
if (fine.matches) {
root.classList.add('kr-has-cursor');
gsap.set(dotEl, { scale: 0, autoAlpha: 0 });
gsap.set(ring, { scale: .4, autoAlpha: 0 });
const q = (el, p, d) => gsap.quickTo(el, p, { duration: reduce ? .01 : d, ease: 'power3.out' });
const cx = q(cursor, 'x', .35), cy = q(cursor, 'y', .35);
const rx = q(ring, 'x', .8), ry = q(ring, 'y', .8);
addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse') { setOn(false); return; }
px = e.clientX; py = e.clientY;
if (!on) { cx(px, px); cy(py, py); rx(px, px); ry(py, py); }
else { cx(px); cy(py); rx(px); ry(py); }
setOn(!modalOpen && !!e.target.closest?.('#work .kr-hit'));
}, { passive: true });
document.addEventListener('pointerleave', () => setOn(false));
/* the panel under a still pointer changes while scrolling: hit-test at most every ~90ms */
addEventListener('scroll', () => {
if (px <= -200 || chk) return;
chk = setTimeout(() => { chk = 0; setOn(!modalOpen && overPanel()); }, 90);
}, { passive: true });
}
/* ---------- modal ---------- */
const modal = document.getElementById('kr-modal');
/* lift the dialog out of the section's stacking context so it sits above the fixed header */
document.body.appendChild(modal);
const dialog = modal.querySelector('.kr-dialog');
const backdrop = modal.querySelector('.kr-modal__backdrop');
const mImg = document.getElementById('kr-m-img');
const mBody = modal.querySelector('.kr-dialog__body');
const closeBtn = modal.querySelector('.kr-close');
const $ = id => document.getElementById(id);
const focusables = () => [...modal.querySelectorAll('button,[href],[tabindex]:not([tabindex="-1"])')].filter(el => !el.disabled && el.offsetParent !== null);
let opener = null, mtl = null;
KF.magnetic(closeBtn, .3);
const open = (i, btn) => {
const d = DATA[i];
opener = btn;
mImg.src = d.img; mImg.alt = d.alt;
$('kr-m-index').textContent = `${String(i + 1).padStart(2, '0')} / 06`;
$('kr-m-client').textContent = d.client;
$('kr-m-title').textContent = d.title;
$('kr-m-dir').textContent = d.dir;
$('kr-m-year').textContent = d.year;
$('kr-m-cat').textContent = d.cat;
$('kr-m-syn').textContent = d.syn;
const cr = $('kr-m-credits'); cr.innerHTML = '';
d.credits.forEach(([role, name]) => {
const dt = document.createElement('dt'); dt.textContent = role;
const dd = document.createElement('dd'); dd.textContent = name;
cr.append(dt, dd);
});
dialog.scrollTop = 0;
modal.style.pointerEvents = '';
modal.setAttribute('aria-hidden', 'false');
modal.classList.add('is-open');
modalOpen = true; setOn(false);
if (KF.lenis) KF.lenis.stop(); else document.documentElement.style.overflow = 'hidden';
if (!reduce) {
/* opacity (not autoAlpha) so the close button stays focusable from frame one */
if (mtl) mtl.kill();
mtl = gsap.timeline({ defaults: { ease: E.out } })
.fromTo(backdrop, { opacity: 0 }, { opacity: 1, duration: .5, ease: 'power2.out' }, 0)
.fromTo(dialog, { y: () => Math.min(innerHeight * .55, 420) }, { y: 0, duration: 1.05 }, .04)
.fromTo(dialog, { opacity: 0 }, { opacity: 1, duration: .3, ease: 'none' }, .04)
.fromTo(mImg, { clipPath: 'inset(100% 0% 0% 0%)', scale: 1.22 },
{ clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 1.15 }, .22)
.fromTo(mBody.children, { y: 28, opacity: 0 }, { y: 0, opacity: 1, duration: .85, stagger: .06, ease: E.soft }, .32)
.fromTo(closeBtn, { scale: .5, opacity: 0 }, { scale: 1, opacity: 1, duration: .6, ease: 'back.out(2)' }, .45);
}
requestAnimationFrame(() => closeBtn.focus({ preventScroll: true }));
};
const finishClose = () => { modal.classList.remove('is-open'); modal.style.pointerEvents = ''; };
const close = () => {
if (!modalOpen) return;
modalOpen = false;
modal.setAttribute('aria-hidden', 'true');
if (KF.lenis) KF.lenis.start(); else document.documentElement.style.overflow = '';
if (opener) opener.focus({ preventScroll: true });
if (mtl && !reduce) {
modal.style.pointerEvents = 'none';
mtl.eventCallback('onReverseComplete', finishClose);
mtl.timeScale(1.9).reverse();
} else finishClose();
};
panels.forEach((p, i) => {
p.querySelector('.kr-hit').addEventListener('click', e => open(i, e.currentTarget));
});
modal.querySelectorAll('[data-kr-close]').forEach(el => el.addEventListener('click', close));
document.addEventListener('keydown', e => {
if (!modalOpen) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key === 'Tab') {
const f = focusables();
if (!f.length) return;
const first = f[0], last = f[f.length - 1];
if (!modal.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
});
</script>
```
## `sections/03-canvas.html`
```html
<section class="sec kc" id="process" aria-labelledby="kc-title">
<div class="wrap">
<header class="kc-head">
<div class="kc-head-main">
<p class="label label--rose kc-kicker">Process</p>
<h2 class="display kc-title" id="kc-title">From brief<br>to <em>final grade.</em></h2>
</div>
<p class="lede kc-lede">Six stages, one crew carrying the film from the first line of the brief to the last frame of colour. Pull the board apart — this is how the work really moves.</p>
</header>
<div class="kc-bar">
<div class="kc-tabs" role="tablist" aria-label="Who the film is for">
<span class="kc-pill" aria-hidden="true"></span>
<button class="kc-tab" role="tab" id="kc-tab-0" aria-selected="true" aria-controls="kc-panel" tabindex="0" data-kc-tab="0">Agencies</button>
<button class="kc-tab" role="tab" id="kc-tab-1" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="1">Brands</button>
<button class="kc-tab" role="tab" id="kc-tab-2" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="2">Filmmakers</button>
<button class="kc-tab" role="tab" id="kc-tab-3" aria-selected="false" aria-controls="kc-panel" tabindex="-1" data-kc-tab="3">Musicians</button>
</div>
<p class="kc-desc" data-kc-desc>You bring the idea and the client; we bring the director, the crew and a treatment that wins the room. We slot into your approval rounds without stretching the calendar.</p>
</div>
<div class="kc-panel" id="kc-panel" role="tabpanel" aria-labelledby="kc-tab-0">
<div class="kc-persp" data-kc-persp>
<span class="kc-float kc-float--a" aria-hidden="true" data-kc-float><i></i>4K RAW</span>
<span class="kc-float kc-float--b" aria-hidden="true" data-kc-float><i></i>Dolby 5.1</span>
<span class="kc-float kc-float--c" aria-hidden="true" data-kc-float><i></i>Rec.709</span>
<span class="kc-float kc-float--d" aria-hidden="true" data-kc-float><i></i>24 fps</span>
<div class="card kc-board">
<span class="kc-grid" aria-hidden="true" data-kc-grid></span>
<div class="kc-tool">
<span class="kc-tool-name"><span class="kc-dot" aria-hidden="true"></span>kinoform / pipeline.board</span>
<span class="kc-tool-hint">Drag nodes · click to inspect</span>
<button class="kc-reset" type="button" data-kc-reset>Reset layout</button>
</div>
<div class="kc-stage" data-kc-stage>
<svg class="kc-wires" aria-hidden="true" focusable="false" data-kc-svg></svg>
<ol class="kc-nodes">
<li class="kc-node" data-kc-node="0">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>01</span> · <span class="kc-dur" data-kc-dur>2 days</span></span>
<span class="kc-brief" aria-hidden="true">
<span class="kc-brief-k">brief.txt</span>
<span class="kc-brief-t"><span data-kc-type>Launch spot, 30s + 6s, dusk light, air date locked.</span><span class="kc-caret"></span></span>
</span>
<span class="kc-name" data-kc-name>Agency brief</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Agency-led</span>
</button>
</li>
<li class="kc-node" data-kc-node="1">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>02</span> · <span class="kc-dur" data-kc-dur>1 week</span></span>
<span class="kc-thumb"><img src="assets/f2.jpg" alt="Clapperboard held up before a take" loading="lazy" draggable="false" style="object-position:50% 45%"></span>
<span class="kc-name" data-kc-name>Treatment</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>Pitch deck</span>
</button>
</li>
<li class="kc-node" data-kc-node="2">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>03</span> · <span class="kc-dur" data-kc-dur>2 weeks</span></span>
<span class="kc-thumb"><img src="assets/f6.jpg" alt="City skyline at dusk, scouted as a location" loading="lazy" draggable="false" style="object-position:50% 60%"></span>
<span class="kc-name" data-kc-name>Casting & Location</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Client review</span>
</button>
</li>
<li class="kc-node" data-kc-node="3">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>04</span> · <span class="kc-dur" data-kc-dur>3 days</span></span>
<span class="kc-thumb"><img src="assets/f1.jpg" alt="Camera crew rigging a cinema camera on set" loading="lazy" draggable="false" style="object-position:50% 40%"></span>
<span class="kc-name" data-kc-name>Shoot</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>On set</span>
</button>
</li>
<li class="kc-node" data-kc-node="4">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>05</span> · <span class="kc-dur" data-kc-dur>3 weeks</span></span>
<span class="kc-thumb kc-thumb--grade"><img src="assets/f5.jpg" alt="Pink flowers used as a colour-grade reference" loading="lazy" draggable="false" style="object-position:50% 50%"><span class="kc-swatch" aria-hidden="true"><i></i><i></i><i></i></span></span>
<span class="kc-name" data-kc-name>Edit & Grade</span>
<span class="kc-chip kc-chip--rose" data-kc-chip>Agency cut</span>
</button>
</li>
<li class="kc-node" data-kc-node="5">
<button class="kc-card" type="button" aria-pressed="false">
<span class="kc-meta"><span data-kc-num>06</span> · <span class="kc-dur" data-kc-dur>1 week</span></span>
<span class="kc-thumb"><img src="assets/f7.jpg" alt="Perfume bottle, the finished product shot" loading="lazy" draggable="false" style="object-position:50% 50%"></span>
<span class="kc-name" data-kc-name>Delivery</span>
<span class="kc-chip kc-chip--sky" data-kc-chip>Cut-downs</span>
</button>
</li>
</ol>
</div>
</div>
</div>
<p class="kc-detail" aria-live="polite">
<span class="label label--rose kc-detail-k" data-kc-detail-k>Select a stage</span>
<span class="kc-detail-t" data-kc-detail-t>Click any node to see what happens there. The rose path shows where this kind of project spends its energy.</span>
</p>
</div>
</div>
</section>
<style>
.kc{position:relative}
.kc-head{display:grid;grid-template-columns:1fr;gap:24px;align-items:end;margin-bottom:clamp(36px,5vw,64px)}
.kc-head-main .label{margin:0 0 18px}
.kc-title{font-size:clamp(42px,7.2vw,112px)}
.kc-lede{margin:0}
@media (min-width:960px){
.kc-head{grid-template-columns:1.35fr 1fr}
.kc-lede{justify-self:end}
}
.kc-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* tabs: segmented track + sliding rose pill */
.kc-bar{display:flex;flex-direction:column;gap:18px;margin-bottom:28px}
.kc-tabs{position:relative;display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:4px;border-radius:20px;
background:var(--paper);box-shadow:inset 0 0 0 1px var(--line)}
.kc-pill{position:absolute;left:0;top:0;width:0;height:0;border-radius:999px;background:var(--rose);box-shadow:var(--shadow-rose);
pointer-events:none;z-index:0;opacity:0}
.kc-tabs.kc-has-pill .kc-pill{opacity:1}
.kc-tab{position:relative;z-index:1;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:11px 16px;border-radius:999px;
color:var(--mute);transition:color .25s var(--ease) .06s}
.kc-tab:hover{color:var(--ink)}
.kc-tab[aria-selected="true"]{color:#fff}
.kc-tabs:not(.kc-has-pill) .kc-tab[aria-selected="true"]{background:var(--rose)}
.kc-desc{margin:0;color:var(--mute);max-width:56ch;font-size:15px;line-height:1.6}
@media (min-width:600px){
.kc-tabs{display:inline-flex;align-self:flex-start;border-radius:999px}
}
@media (min-width:960px){
.kc-bar{flex-direction:row;align-items:center;justify-content:space-between;gap:40px}
.kc-tabs{align-self:auto;flex:none}
.kc-desc{text-align:right}
}
/* board + 3D wrapper */
.kc-persp{position:relative;perspective:1600px;perspective-origin:50% 30%}
.kc-board{position:relative;border-radius:28px;overflow:hidden;background-color:var(--paper);transform-origin:50% 40%;will-change:transform}
.kc-grid{position:absolute;left:0;right:0;top:-60px;bottom:-60px;z-index:0;pointer-events:none;
background-image:radial-gradient(circle, var(--line) 1.3px, transparent 1.6px);background-size:22px 22px;background-position:11px 11px;will-change:transform}
.kc-tool{position:relative;z-index:1;display:flex;align-items:center;gap:16px;padding:14px 20px;border-bottom:1px solid var(--line);background:var(--paper);
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.kc-tool-name{display:flex;align-items:center;gap:10px;color:var(--ink)}
.kc-dot{width:8px;height:8px;border-radius:50%;background:var(--rose);box-shadow:0 0 0 4px var(--rose-soft)}
.kc-tool-hint{margin-left:auto}
.kc-reset{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:7px 12px;border-radius:999px;
box-shadow:inset 0 0 0 1px var(--line);color:var(--ink)}
.kc-reset:hover{background:var(--sky-soft);box-shadow:inset 0 0 0 1px var(--sky-deep)}
/* floating spec chips (desktop only) */
.kc-float{display:none}
@media (min-width:1024px){
.kc-float{position:absolute;z-index:3;display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;will-change:transform;
box-shadow:0 18px 34px -22px rgba(27,21,48,.45)}
.kc-float i{width:7px;height:7px;border-radius:50%}
.kc-float--a{top:-18px;left:17%;background:var(--sky-soft);color:var(--ink)}
.kc-float--a i{background:var(--sky-deep)}
.kc-float--b{top:31%;right:-26px;background:var(--rose-soft);color:var(--rose-deep)}
.kc-float--b i{background:var(--rose)}
.kc-float--c{bottom:-12px;left:39%;background:var(--rose-soft);color:var(--rose-deep)}
.kc-float--c i{background:var(--rose)}
.kc-float--d{top:57%;left:-30px;background:var(--sky-soft);color:var(--ink)}
.kc-float--d i{background:var(--sky-deep)}
}
.kc-stage{position:relative;z-index:1;height:600px}
.kc-wires{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;overflow:visible}
.kc-w-base{fill:none;stroke:var(--line);stroke-width:2;stroke-linecap:round}
.kc-w-hi{fill:none;stroke:var(--rose);stroke-width:2;stroke-linecap:round;stroke-dasharray:1;stroke-dashoffset:1}
.kc-w-sel{fill:none;stroke:var(--rose-deep);stroke-width:3.5;stroke-linecap:round;opacity:0;transition:opacity .3s var(--ease)}
.kc-w-sel.kc-on{opacity:1}
.kc-w-sig{fill:none;stroke:var(--rose);stroke-width:4;stroke-linecap:round;stroke-dasharray:.1 2;stroke-dashoffset:.1}
.kc-port{fill:var(--paper);stroke:var(--line);stroke-width:2}
.kc-port.kc-on{fill:var(--rose);stroke:var(--rose)}
.kc-ring{fill:none;stroke:var(--rose);stroke-width:1.5;opacity:0}
.kc-nodes{list-style:none;margin:0;padding:0;position:absolute;inset:0;z-index:1}
.kc-node{position:absolute;left:0;top:0;width:clamp(172px,15.5vw,214px)}
.kc-node--drag{z-index:5}
.kc-card{position:relative;display:flex;flex-direction:column;align-items:stretch;gap:8px;width:100%;padding:12px;text-align:left;
background:var(--paper);border-radius:14px;cursor:grab;user-select:none;-webkit-user-select:none;touch-action:none;
box-shadow:0 0 0 1px var(--line),0 18px 34px -24px rgba(27,21,48,.45);
opacity:.58;transition:opacity .35s var(--ease)}
/* lift shadow lives on a pseudo layer so hover/drag only fades opacity */
.kc-card::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;
box-shadow:0 34px 50px -24px rgba(27,21,48,.5);transition:opacity .3s var(--ease)}
.kc-card.kc-lift::after,.kc-node--drag .kc-card::after{opacity:1}
.kc-node--focus .kc-card{opacity:1;box-shadow:0 0 0 1.5px var(--rose-soft),0 22px 40px -26px rgba(209,30,107,.45)}
.kc-node--sel .kc-card{opacity:1;box-shadow:0 0 0 2px var(--rose),var(--shadow-rose)}
.kc-node--drag .kc-card{cursor:grabbing;box-shadow:0 0 0 1.5px var(--rose),var(--shadow)}
.kc-card:focus-visible{outline:2px solid var(--rose);outline-offset:3px}
.kc-meta{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.kc-dur{display:inline-block}
.kc-node--focus .kc-meta [data-kc-num]{color:var(--rose)}
.kc-thumb{position:relative;display:block;height:88px;border-radius:9px;overflow:hidden;background:var(--sky-soft)}
.kc-thumb img{width:100%;height:100%;object-fit:cover;pointer-events:none}
.kc-swatch{position:absolute;left:8px;bottom:8px;display:flex;gap:4px;padding:4px;border-radius:999px;background:rgba(255,255,255,.88)}
.kc-swatch i{width:10px;height:10px;border-radius:50%;background:var(--rose)}
.kc-swatch i:nth-child(2){background:var(--rose-soft)}
.kc-swatch i:nth-child(3){background:var(--sky)}
.kc-brief{display:flex;flex-direction:column;gap:6px;height:88px;padding:10px;border-radius:9px;background:var(--bg);
box-shadow:inset 0 0 0 1px var(--line);font-family:var(--mono);font-size:11px;line-height:1.45;color:var(--ink);overflow:hidden}
.kc-brief-k{color:var(--mute);letter-spacing:.06em;text-transform:uppercase;font-size:10px}
.kc-caret{display:inline-block;width:6px;height:12px;margin-left:2px;vertical-align:-2px;background:var(--rose);animation:kc-blink 1s steps(1) infinite}
.kc-idle .kc-caret{animation-play-state:paused}
@keyframes kc-blink{50%{opacity:0}}
.kc-name{font-weight:750;font-size:16px;line-height:1.15;letter-spacing:-.01em;font-stretch:112%}
.kc-chip{align-self:flex-start;font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;padding:5px 9px;border-radius:999px}
.kc-chip--rose{background:var(--rose-soft);color:var(--rose-deep)}
.kc-chip--sky{background:var(--sky-soft);color:var(--ink)}
/* detail line */
.kc-detail{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 16px;margin:26px 0 0;padding:0 4px;min-height:3.2em}
.kc-detail-k{flex:none;display:inline-block}
.kc-detail-t{color:var(--ink);font-size:15px;max-width:72ch}
/* tablet */
@media (min-width:768px) and (max-width:1023px){
.kc-stage{height:640px}
.kc-thumb,.kc-brief{height:76px}
}
/* mobile: vertical pipeline, no dragging */
@media (max-width:767px){
.kc-tool-hint,.kc-reset{display:none}
.kc-tool{padding:12px 16px}
.kc-board{border-radius:22px}
.kc-grid{top:-30px;bottom:-30px}
.kc-stage{height:auto;padding:20px 16px 22px}
.kc-wires{display:none}
.kc-nodes{position:relative;inset:auto;display:flex;flex-direction:column;gap:34px}
.kc-node{position:relative;left:auto !important;top:auto !important;width:100%}
.kc-node + .kc-node::before{content:"";position:absolute;left:50%;top:-34px;width:2px;height:34px;margin-left:-1px;background:var(--line);transition:background .3s var(--ease)}
.kc-node + .kc-node::after{content:"";position:absolute;left:50%;top:-5px;width:10px;height:10px;margin-left:-5px;border-radius:50%;background:var(--paper);box-shadow:inset 0 0 0 2px var(--line);z-index:2}
.kc-node.kc-in-hi::before{background:var(--rose)}
.kc-node.kc-in-sel::before{background:var(--rose-deep);width:3px;margin-left:-1.5px}
.kc-node.kc-in-hi::after,.kc-node.kc-in-sel::after{background:var(--rose);box-shadow:none}
.kc-card{display:grid;grid-template-columns:84px 1fr;grid-template-rows:auto auto 1fr;column-gap:14px;row-gap:6px;cursor:pointer;touch-action:auto}
.kc-thumb,.kc-brief{grid-column:1;grid-row:1 / span 3;height:84px}
.kc-brief{padding:8px;font-size:9.5px;gap:3px}
.kc-brief-k{font-size:9px}
.kc-meta,.kc-name,.kc-chip{grid-column:2}
.kc-chip{align-self:end;justify-self:start}
.kc-swatch{left:6px;bottom:6px}
}
@media (prefers-reduced-motion: reduce){
.kc-card,.kc-card::after,.kc-tab,.kc-w-sel{transition:none}
.kc-caret{animation:none}
.kc-w-sig{display:none}
.kc-board,.kc-grid,.kc-float{will-change:auto}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('process');
if (!root) return;
const reduce = KF.reduce;
const mq = matchMedia('(max-width: 767px)');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
const NS = 'http://www.w3.org/2000/svg';
const PAD = 16;
const stage = root.querySelector('[data-kc-stage]');
const svg = root.querySelector('[data-kc-svg]');
const board = root.querySelector('.kc-board');
const persp = root.querySelector('[data-kc-persp]');
const grid = root.querySelector('[data-kc-grid]');
const floats = [...root.querySelectorAll('[data-kc-float]')];
const nodes = [...root.querySelectorAll('[data-kc-node]')];
const cards = nodes.map(n => n.querySelector('.kc-card'));
const tabsEl = root.querySelector('.kc-tabs');
const pill = root.querySelector('.kc-pill');
const tabs = [...root.querySelectorAll('[data-kc-tab]')];
const panel = root.querySelector('#kc-panel');
const descEl = root.querySelector('[data-kc-desc]');
const typeEl = root.querySelector('[data-kc-type]');
const detailK = root.querySelector('[data-kc-detail-k]');
const detailT = root.querySelector('[data-kc-detail-t]');
const DETAIL = [
'We read it twice, then pick up the phone. Every question asked here saves a day on set.',
'The director’s written and visual pitch: tone, references, key frames and a first pass at the schedule.',
'Casting rooms, recce trips and permits across our five cities — and anywhere else the story needs to go.',
'Crew, kit and a first AD who keeps the day honest. Rushes are backed up twice before the lights go down.',
'Offline, sound and colour under one roof, so the grade is part of the story rather than a last coat of paint.',
'Masters, cut-downs and captions for every placement, checked frame by frame before anything leaves the building.'
];
const IDLE_K = 'Select a stage';
const IDLE_T = 'Click any node to see what happens there. The rose path shows where this kind of project spends its energy.';
// per-client view: description, typed brief, focus path, and per-node [title, duration, chip, tone]
const TABS = [
{ desc: 'You bring the idea and the client; we bring the director, the crew and a treatment that wins the room. We slot into your approval rounds without stretching the calendar.',
brief: 'Launch spot, 30s + 6s, dusk light, air date locked.',
focus: [1, 2, 3, 4],
n: [['Agency brief','2 days','Agency-led','sky'],['Treatment','1 week','Pitch deck','rose'],['Casting & Location','2 weeks','Client review','sky'],['Shoot','3 days','On set','rose'],['Edit & Grade','3 weeks','Agency cut','rose'],['Delivery','1 week','Cut-downs','sky']] },
{ desc: 'Coming to us direct? We shape the brief with you before a single frame is planned. One producer owns the budget from the first call to the final cut-downs.',
brief: 'People should feel the fabric before they see it.',
focus: [0, 1, 4, 5],
n: [['Brand brief','1 week','Co-written','rose'],['Treatment','1 week','Moodboard','rose'],['Casting & Location','2 weeks','Locked','sky'],['Shoot','2 days','Lean crew','sky'],['Edit & Grade','2 weeks','Brand colour','rose'],['Delivery','Ongoing','Every format','rose']] },
{ desc: 'Got a script and a stubborn vision? We build the production around your voice, from open casting calls to festival-grade colour.',
brief: 'Two sisters, one car, the last ferry out of town.',
focus: [1, 2, 3, 4],
n: [['Script','3 days','Your words','sky'],['Treatment','2 weeks','Lookbook','rose'],['Casting & Location','4 weeks','Open call','rose'],['Shoot','6 days','35mm','rose'],['Edit & Grade','5 weeks','Film grade','rose'],['Delivery','Festival','DCP','sky']] },
{ desc: 'Your track sets the tempo. We turn a song into a shoot plan, move fast on the day, and cut to the beat for every screen it lands on.',
brief: '3:42, one continuous move, chorus hits at sunrise.',
focus: [0, 3, 4, 5],
n: [['Track','1 day','Track first','rose'],['Treatment','4 days','One-pager','sky'],['Casting & Location','1 week','Quick scout','sky'],['Shoot','1 day','One take','rose'],['Edit & Grade','10 days','Beat-cut','rose'],['Delivery','Release day','9:16 + 16:9','rose']] }
];
// zig-zag layout as fractions of the free space inside the stage
const LAYOUT = [[0, .06], [.2, .94], [.4, .04], [.6, .9], [.8, .1], [1, .92]];
let pos = LAYOUT.map(p => p.slice());
let tab = 0, sel = -1, revealed = reduce, suppressClick = false, dragIdx = -1;
const isMobile = () => mq.matches;
/* ---------- heading ---------- */
KF.reveal(root.querySelector('.kc-title'), { mode: 'chars', stagger: .022, start: 'top 82%' });
if (!reduce) {
const intro = [root.querySelector('.kc-kicker'), root.querySelector('.kc-lede'), tabsEl, descEl];
gsap.set(intro, { autoAlpha: 0, y: 28 });
gsap.to(intro, { autoAlpha: 1, y: 0, duration: 1, ease: KF.ease.soft, stagger: .09, delay: .15,
scrollTrigger: { trigger: root.querySelector('.kc-head'), start: 'top 82%', once: true } });
}
/* ---------- build wires + ports ---------- */
const mk = (tag, attrs) => { const el = document.createElementNS(NS, tag); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; };
// a port is a translated group, so its ring can scale from 0,0 no matter where the node is dragged
const mkPort = () => {
const g = mk('g', { class: 'kc-pg' });
const ring = mk('circle', { class: 'kc-ring', r: 5, cx: 0, cy: 0 });
const dot = mk('circle', { class: 'kc-port', r: 5, cx: 0, cy: 0 });
g.append(ring, dot);
return { g, ring, dot };
};
const wires = [];
const gW = mk('g', {}), gP = mk('g', {});
for (let i = 0; i < nodes.length - 1; i++) {
const base = mk('path', { class: 'kc-w-base', pathLength: 1 });
const hi = mk('path', { class: 'kc-w-hi', pathLength: 1 });
const selP = mk('path', { class: 'kc-w-sel' });
const sig = mk('path', { class: 'kc-w-sig', pathLength: 1 });
const pa = mkPort(), pb = mkPort();
gW.append(base, hi, selP, sig); gP.append(pa.g, pb.g);
wires.push({ base, hi, selP, sig, pa, pb, on: false });
}
svg.append(gW, gP);
// ports that touch node i: incoming (end of wire i-1) and outgoing (start of wire i)
const portsOf = i => [i > 0 ? wires[i - 1].pb : null, i < wires.length ? wires[i].pa : null].filter(Boolean);
const box = n => ({ x: n.offsetLeft, y: n.offsetTop, w: n.offsetWidth, h: n.offsetHeight });
// pick ports by relative position so wires stay readable wherever nodes are dragged
function wirePath(A, B) {
const gap = 28;
let ax, ay, bx, by, horiz = true;
if (B.x >= A.x + A.w + gap) { ax = A.x + A.w; ay = A.y + A.h / 2; bx = B.x; by = B.y + B.h / 2; }
else if (B.x + B.w <= A.x - gap) { ax = A.x; ay = A.y + A.h / 2; bx = B.x + B.w; by = B.y + B.h / 2; }
else {
horiz = false;
if (B.y >= A.y + A.h / 2) { ax = A.x + A.w / 2; ay = A.y + A.h; bx = B.x + B.w / 2; by = B.y; }
else { ax = A.x + A.w / 2; ay = A.y; bx = B.x + B.w / 2; by = B.y + B.h; }
}
let d;
if (horiz) { const c = Math.max(50, Math.abs(bx - ax) * .5) * Math.sign(bx - ax || 1); d = `M${ax},${ay} C${ax + c},${ay} ${bx - c},${by} ${bx},${by}`; }
else { const c = Math.max(40, Math.abs(by - ay) * .5) * Math.sign(by - ay || 1); d = `M${ax},${ay} C${ax},${ay + c} ${bx},${by - c} ${bx},${by}`; }
return { d, ax, ay, bx, by };
}
function drawWires() {
if (isMobile()) return;
const W = stage.clientWidth, H = stage.clientHeight;
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
wires.forEach((w, i) => {
const p = wirePath(box(nodes[i]), box(nodes[i + 1]));
w.base.setAttribute('d', p.d); w.hi.setAttribute('d', p.d); w.selP.setAttribute('d', p.d); w.sig.setAttribute('d', p.d);
w.pa.g.setAttribute('transform', `translate(${p.ax},${p.ay})`);
w.pb.g.setAttribute('transform', `translate(${p.bx},${p.by})`);
});
}
function place() {
if (isMobile()) return;
const W = stage.clientWidth, H = stage.clientHeight;
nodes.forEach((n, i) => {
const fw = Math.max(0, W - n.offsetWidth - PAD * 2), fh = Math.max(0, H - n.offsetHeight - PAD * 2);
n.style.left = (PAD + pos[i][0] * fw) + 'px';
n.style.top = (PAD + pos[i][1] * fh) + 'px';
});
drawWires();
}
/* ---------- state ---------- */
// tl (optional): wire draws are added to that timeline at `at` instead of running on their own
function applyState(animate, tl, at) {
const f = new Set(TABS[tab].focus);
nodes.forEach((n, i) => {
n.classList.toggle('kc-node--focus', f.has(i));
n.classList.toggle('kc-node--sel', sel === i);
cards[i].setAttribute('aria-pressed', sel === i ? 'true' : 'false');
});
let k = 0;
wires.forEach((w, i) => {
const on = f.has(i) && f.has(i + 1);
const s = sel === i || sel === i + 1;
w.selP.classList.toggle('kc-on', s);
w.pa.dot.classList.toggle('kc-on', on || s); w.pb.dot.classList.toggle('kc-on', on || s);
nodes[i + 1].classList.toggle('kc-in-hi', on);
nodes[i + 1].classList.toggle('kc-in-sel', s);
if (!revealed) { w.on = on; return; }
if (reduce || !animate) { if (on !== w.on) gsap.set(w.hi, { strokeDashoffset: on ? 0 : 1, overwrite: true }); }
else if (on !== w.on || on) {
// retract everything, then redraw the rose path one wire after another along the pipeline
const t0 = tl ? (on ? at + .12 * k : Math.max(0, at - .25)) : 0;
const host = tl || gsap;
if (on) host.fromTo(w.hi, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: .5, ease: 'power2.inOut', delay: tl ? 0 : .12 * k, overwrite: true, immediateRender: false }, t0);
else host.to(w.hi, { strokeDashoffset: 1, duration: .3, ease: 'power2.in', overwrite: true }, t0);
if (on) k++;
}
w.on = on;
});
}
/* ---------- detail line: masked word reveal ---------- */
function setDetail(kText, tText, animate) {
detailK.textContent = kText;
if (reduce || !animate) { detailT.textContent = tText; return; }
detailT.textContent = '';
const sr = document.createElement('span'); sr.className = 'kc-sr'; sr.textContent = tText;
const vis = document.createElement('span'); vis.setAttribute('aria-hidden', 'true');
const parts = [];
tText.split(/\s+/).forEach((w, i) => {
if (i) vis.appendChild(document.createTextNode(' '));
const m = document.createElement('span'); m.className = 'kf-m';
const inner = document.createElement('span'); inner.className = 'kf-i'; inner.textContent = w;
m.appendChild(inner); vis.appendChild(m); parts.push(inner);
});
detailT.append(sr, vis);
gsap.timeline()
.fromTo(detailK, { autoAlpha: 0, x: -10 }, { autoAlpha: 1, x: 0, duration: .45, ease: KF.ease.soft })
.fromTo(parts, { yPercent: 115 }, { yPercent: 0, duration: .7, ease: KF.ease.out, stagger: .016 }, .05);
}
const detailKey = i => { const d = TABS[tab].n[i]; return `${String(i + 1).padStart(2, '0')} · ${d[0]} · ${d[1]}`; };
function select(i) {
sel = (sel === i) ? -1 : i;
if (sel < 0) setDetail(IDLE_K, IDLE_T, true);
else setDetail(detailKey(sel), DETAIL[sel], true);
applyState(false);
if (sel >= 0 && !reduce) gsap.fromTo(nodes[sel], { scale: .94 }, { scale: 1, duration: .55, ease: 'back.out(3)', clearProps: 'transform' });
}
/* ---------- typed brief ---------- */
let typeTween;
function typeBrief(text) {
if (typeTween) typeTween.kill();
if (reduce || !revealed) { typeEl.textContent = text; return; }
const o = { n: 0 };
typeEl.textContent = '';
typeTween = gsap.to(o, { n: text.length, duration: text.length * .035, ease: 'none', delay: .2,
onUpdate: () => { typeEl.textContent = text.slice(0, Math.round(o.n)); } });
}
/* ---------- tab pill ---------- */
let pillTl;
const tabRect = b => ({ x: b.offsetLeft, y: b.offsetTop, w: b.offsetWidth, h: b.offsetHeight });
function snapPill() {
const r = tabRect(tabs[tab]);
gsap.set(pill, { x: r.x, y: r.y, width: r.w, height: r.h });
}
snapPill();
tabsEl.classList.add('kc-has-pill');
if ('ResizeObserver' in window) new ResizeObserver(() => { if (!pillTl || !pillTl.isActive()) snapPill(); }).observe(tabsEl);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(snapPill);
/* ---------- tabs ---------- */
function setTab(t, focusTab) {
if (t === tab) return;
const prev = tab;
tab = t;
tabs.forEach((b, i) => { const on = i === t; b.setAttribute('aria-selected', on); b.tabIndex = on ? 0 : -1; });
panel.setAttribute('aria-labelledby', tabs[t].id);
if (focusTab) tabs[t].focus();
const data = TABS[t];
const swaps = nodes.map((n, i) => {
const [title, dur, chip, tone] = data.n[i];
const nameEl = n.querySelector('[data-kc-name]'), durEl = n.querySelector('[data-kc-dur]'), chipEl = n.querySelector('[data-kc-chip]');
return { els: [durEl, nameEl, chipEl], apply: () => {
nameEl.textContent = title; durEl.textContent = dur; chipEl.textContent = chip;
chipEl.classList.toggle('kc-chip--rose', tone === 'rose'); chipEl.classList.toggle('kc-chip--sky', tone === 'sky');
} };
});
const applyAll = () => { swaps.forEach(s => s.apply()); descEl.textContent = data.desc; drawWires(); };
const detailNow = () => { if (sel >= 0) detailK.textContent = detailKey(sel); };
if (reduce) {
applyAll(); detailNow(); snapPill(); applyState(false); typeBrief(data.brief);
return;
}
if (pillTl) pillTl.progress(1).kill();
const a = tabRect(tabs[prev]), b = tabRect(tabs[t]);
const tl = pillTl = gsap.timeline({ defaults: { overwrite: 'auto' } });
// pill: stretch across both tabs, then settle onto the new one (same row); glide when it changes rows
if (Math.abs(a.y - b.y) < 2) {
const l = Math.min(a.x, b.x), r = Math.max(a.x + a.w, b.x + b.w);
tl.to(pill, { x: l, width: r - l, duration: .22, ease: 'power2.in' }, 0)
.to(pill, { x: b.x, width: b.w, duration: .55, ease: 'expo.out' }, .22);
} else {
tl.to(pill, { x: b.x, y: b.y, width: b.w, height: b.h, duration: .6, ease: 'expo.out' }, 0);
}
// labels: slide up + out, swap text, slide in from below
const els = swaps.flatMap(s => s.els);
tl.to(els.concat(descEl), { autoAlpha: 0, y: -8, duration: .18, ease: 'power2.in', stagger: .008 }, 0)
.add(() => { applyAll(); detailNow(); }, .26)
.fromTo(els.concat(descEl), { autoAlpha: 0, y: 10 }, { autoAlpha: 1, y: 0, duration: .45, ease: KF.ease.soft, stagger: .012, immediateRender: false }, .27);
if (revealed) {
applyState(true, tl, .3);
tl.fromTo(data.focus.map(i => nodes[i]), { y: 8 }, { y: 0, duration: .55, stagger: .08, ease: 'back.out(2.5)', clearProps: 'transform', immediateRender: false }, .3);
} else applyState(false);
typeBrief(data.brief);
}
tabs.forEach((b, i) => {
b.addEventListener('click', () => setTab(i));
b.addEventListener('keydown', e => {
let j = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') j = 0; else if (e.key === 'End') j = tabs.length - 1;
if (j !== null) { e.preventDefault(); setTab(j, true); }
});
});
/* ---------- hover: lift, tilt toward pointer, pulse ports (mouse only) ---------- */
const tilt = cards.map(c => {
if (reduce) return null;
gsap.set(c, { transformPerspective: 700 });
return {
rx: gsap.quickTo(c, 'rotationX', { duration: .5, ease: 'power3.out' }),
ry: gsap.quickTo(c, 'rotationY', { duration: .5, ease: 'power3.out' }),
y: gsap.quickTo(c, 'y', { duration: .5, ease: 'power3.out' })
};
});
const pulses = new Map();
function pulsePorts(i, on) {
portsOf(i).forEach(p => {
const old = pulses.get(p); if (old) { old.kill(); pulses.delete(p); }
if (on) {
gsap.to(p.dot, { scale: 1.35, transformOrigin: '50% 50%', duration: .3, ease: 'back.out(3)' });
pulses.set(p, gsap.fromTo(p.ring, { scale: 1, opacity: .9, transformOrigin: '50% 50%' },
{ scale: 2.8, opacity: 0, duration: 1, ease: 'power2.out', repeat: -1, repeatDelay: .15 }));
} else {
gsap.to(p.dot, { scale: 1, duration: .3, ease: 'power2.out' });
gsap.to(p.ring, { opacity: 0, duration: .2 });
}
});
}
const canHover = () => !reduce && fine.matches && !isMobile();
nodes.forEach((n, i) => {
const c = cards[i];
c.addEventListener('pointerenter', e => {
if (e.pointerType !== 'mouse' || !canHover() || dragIdx >= 0) return;
c.classList.add('kc-lift'); tilt[i].y(-6); pulsePorts(i, true);
});
c.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || !canHover() || dragIdx >= 0) return;
const r = c.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - .5, py = (e.clientY - r.top) / r.height - .5;
tilt[i].ry(px * 14); tilt[i].rx(-py * 12);
});
c.addEventListener('pointerleave', () => {
if (!tilt[i] || dragIdx === i) return;
c.classList.remove('kc-lift'); tilt[i].rx(0); tilt[i].ry(0); tilt[i].y(0); pulsePorts(i, false);
});
});
/* ---------- drag (desktop/tablet only) ---------- */
function moveTo(i, l, t) {
const n = nodes[i], W = stage.clientWidth, H = stage.clientHeight;
const maxL = W - n.offsetWidth - PAD, maxT = H - n.offsetHeight - PAD;
l = Math.min(Math.max(l, PAD), Math.max(PAD, maxL));
t = Math.min(Math.max(t, PAD), Math.max(PAD, maxT));
n.style.left = l + 'px'; n.style.top = t + 'px';
pos[i] = [maxL > PAD ? (l - PAD) / (maxL - PAD) : 0, maxT > PAD ? (t - PAD) / (maxT - PAD) : 0];
drawWires();
}
nodes.forEach((n, i) => {
const card = cards[i];
card.addEventListener('pointerdown', e => {
if (isMobile() || e.button !== 0) return;
const sx = e.clientX, sy = e.clientY, sl = n.offsetLeft, st = n.offsetTop;
let dragging = false;
const move = ev => {
const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!dragging && Math.hypot(dx, dy) > 5) {
dragging = true; dragIdx = i; n.classList.add('kc-node--drag');
nodes.forEach(o => { o.style.zIndex = o === n ? 2 : 1; }); // last-dragged node stays on top
if (!reduce) {
gsap.to(card, { rotation: -1.5, scale: 1.04, duration: .25, ease: 'power3.out' });
tilt[i].rx(0); tilt[i].ry(0); tilt[i].y(-4);
}
}
if (dragging) { ev.preventDefault(); moveTo(i, sl + dx, st + dy); }
};
const up = () => {
removeEventListener('pointermove', move);
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
if (dragging) {
n.classList.remove('kc-node--drag'); dragIdx = -1;
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
if (!reduce) {
const still = card.matches(':hover');
gsap.to(card, { rotation: 0, scale: 1, duration: .6, ease: 'elastic.out(1,.5)' });
tilt[i].y(still ? -6 : 0);
if (!still) { card.classList.remove('kc-lift'); pulsePorts(i, false); }
}
}
};
addEventListener('pointermove', move, { passive: false });
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
});
card.addEventListener('click', e => {
if (suppressClick) { e.preventDefault(); return; }
select(i);
});
// keyboard nudge as the non-pointer alternative to dragging
card.addEventListener('keydown', e => {
if (isMobile()) return;
const step = e.shiftKey ? 48 : 16;
const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
if (!d) return;
e.preventDefault();
moveTo(i, n.offsetLeft + d[0], n.offsetTop + d[1]);
});
});
cards.forEach(c => c.setAttribute('aria-describedby', 'kc-hint'));
const hint = document.createElement('span');
hint.id = 'kc-hint'; hint.hidden = true;
hint.textContent = 'Press to inspect this stage. Arrow keys move the node on the board.';
root.appendChild(hint);
stage.addEventListener('click', e => { if (e.target === stage || e.target.classList.contains('kc-nodes')) { if (sel >= 0) select(sel); } });
root.querySelector('[data-kc-reset]').addEventListener('click', () => {
const from = pos.map(p => p.slice());
const o = { t: 0 };
const done = () => { pos = LAYOUT.map(p => p.slice()); place(); };
if (reduce) return done();
gsap.to(o, { t: 1, duration: .7, ease: 'power3.inOut',
onUpdate: () => { pos = from.map((p, i) => [p[0] + (LAYOUT[i][0] - p[0]) * o.t, p[1] + (LAYOUT[i][1] - p[1]) * o.t]); place(); },
onComplete: done });
});
/* ---------- layout lifecycle ---------- */
place();
if ('ResizeObserver' in window) new ResizeObserver(() => place()).observe(stage);
else addEventListener('resize', place);
const onMq = () => { place(); applyState(false); KF.refresh(); };
mq.addEventListener ? mq.addEventListener('change', onMq) : mq.addListener(onMq);
// thumbnails can change node heights once decoded
root.querySelectorAll('.kc-thumb img').forEach(img => { if (!img.complete) img.addEventListener('load', place, { once: true }); });
applyState(false);
/* ---------- scroll depth: board tilt, grid drift, floating chips ---------- */
if (!reduce) {
const mm = gsap.matchMedia();
mm.add({ desk: '(min-width: 768px)', big: '(min-width: 1024px)' }, ctx => {
const { desk, big } = ctx.conditions;
gsap.fromTo(board, { rotationX: desk ? 25 : 14, scale: desk ? .88 : .94, y: desk ? 80 : 40 },
{ rotationX: 0, scale: 1, y: 0, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'center center', scrub: .6 } });
gsap.fromTo(grid, { y: desk ? -44 : -24 }, { y: desk ? 44 : 24, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'bottom top', scrub: true } });
if (big) {
const speed = [[70, -90, -6], [-40, 60, 5], [30, -50, -4], [-80, 110, 7]];
floats.forEach((f, i) => {
const [a, b, r] = speed[i];
gsap.fromTo(f, { y: a, rotation: -r }, { y: b, rotation: r, ease: 'none',
scrollTrigger: { trigger: persp, start: 'top bottom', end: 'bottom top', scrub: .4 + i * .25 } });
});
}
});
}
/* ---------- signal loop (paused offscreen, along with the caret blink) ---------- */
let signal = null;
if (!reduce) {
signal = gsap.timeline({ repeat: -1, paused: true, repeatDelay: .6 });
wires.forEach(w => signal.fromTo(w.sig, { strokeDashoffset: .1 }, { strokeDashoffset: -1, duration: .85, ease: 'none', immediateRender: false }));
}
root.classList.add('kc-idle');
ScrollTrigger.create({ trigger: board, start: 'top bottom', end: 'bottom top',
onToggle: s => {
root.classList.toggle('kc-idle', !s.isActive);
if (!signal || !revealed) return;
s.isActive ? signal.play() : signal.pause();
} });
/* ---------- reveal: one timeline, nodes pop along the pipeline, wires draw in sequence ---------- */
if (reduce) {
wires.forEach(w => gsap.set(w.hi, { strokeDashoffset: w.on ? 0 : 1 }));
} else {
gsap.set(nodes, { autoAlpha: 0, scale: .6, y: 26, rotation: i => (i % 2 ? 7 : -7) });
gsap.set(wires.map(w => w.base), { strokeDasharray: 1, strokeDashoffset: 1 });
gsap.set(wires.flatMap(w => [w.pa.g, w.pb.g]), { opacity: 0 });
typeEl.textContent = '';
const tl = gsap.timeline({ paused: true, onComplete: () => {
revealed = true;
applyState(true);
typeBrief(TABS[tab].brief);
if (ScrollTrigger.isInViewport(board)) signal.play();
} });
nodes.forEach((n, i) => {
const at = i * .2;
tl.to(n, { autoAlpha: 1, scale: 1, y: 0, rotation: 0, duration: .7, ease: 'back.out(2)', clearProps: 'transform' }, at);
if (i < wires.length) {
const w = wires[i];
tl.to(w.pa.g, { opacity: 1, duration: .2 }, at + .28)
.fromTo(w.pa.dot, { scale: 0, transformOrigin: '50% 50%' }, { scale: 1, duration: .35, ease: 'back.out(3)' }, at + .28)
.to(w.base, { strokeDashoffset: 0, duration: .42, ease: 'power2.inOut' }, at + .32)
.to(w.pb.g, { opacity: 1, duration: .2 }, at + .66)
.fromTo(w.pb.dot, { scale: 0, transformOrigin: '50% 50%' }, { scale: 1, duration: .35, ease: 'back.out(3)' }, at + .66);
}
});
ScrollTrigger.create({ trigger: board, start: 'top 72%', once: true, onEnter: () => tl.play() });
}
KF.refresh();
});
</script>
```
## `sections/04-directors.html`
```html
<section id="directors" class="kd sec" aria-labelledby="kd-title">
<div class="wrap kd-wrap">
<header class="kd-head">
<div class="kd-intro">
<p class="label label--rose">Roster</p>
<h2 class="display kd-title" id="kd-title">Six directors.<br><em>Six ways of seeing.</em></h2>
<p class="lede kd-lede">Each one signed for how they look at the world, not how neatly they fit a reel. Filter by the kind of work you are making, then open a row to meet them.</p>
</div>
<div class="kd-filters" role="group" aria-label="Filter directors by discipline">
<span class="kd-pill" aria-hidden="true"></span>
<button class="kd-chip" type="button" data-filter="all" aria-pressed="true">All</button>
<button class="kd-chip" type="button" data-filter="commercial" aria-pressed="false">Commercial</button>
<button class="kd-chip" type="button" data-filter="fashion" aria-pressed="false">Fashion</button>
<button class="kd-chip" type="button" data-filter="documentary" aria-pressed="false">Documentary</button>
</div>
</header>
<p class="kd-sr" aria-live="polite"></p>
<div class="kd-stage">
<div class="kd-bg" aria-hidden="true">
<div class="kd-bg-row">Tomás Ferreira · Ada Kwon · Jun Park · Lena Brandt · Walter Imrie · Noor Haddad · Tomás Ferreira · Ada Kwon · Jun Park · Lena Brandt · Walter Imrie · Noor Haddad · </div>
<div class="kd-bg-row">Noor Haddad · Walter Imrie · Lena Brandt · Jun Park · Ada Kwon · Tomás Ferreira · Noor Haddad · Walter Imrie · Lena Brandt · Jun Park · Ada Kwon · Tomás Ferreira · </div>
</div>
<ul class="kd-list">
<!-- d1 -->
<li class="kd-item" data-tags="commercial documentary" data-img="assets/d1.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b1" aria-expanded="false" aria-controls="kd-p1">
<img class="kd-thumb" src="assets/d1.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">01</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Tomás Ferreira</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Lisbon · Cars & landscape · 14 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p1" role="region" aria-labelledby="kd-b1" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d1.jpg" alt="Portrait of director Tomás Ferreira" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Tomás grew up on the coast road north of Lisbon and still shoots it like a first love: low sun, long lenses, the car as one small thing inside a very big country. He prefers real roads to the volume stage and will happily wait three days for the right fog.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Long Way Round</span><span class="kd-wc">Halcyon Motors</span><span class="kd-wy">2026</span></a></li>
<li><span class="kd-wt">Atlantic Hours</span><span class="kd-wc">Spec spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Serra</span><span class="kd-wc">Short documentary</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Tomás <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d2 -->
<li class="kd-item" data-tags="fashion commercial" data-img="assets/d2.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b2" aria-expanded="false" aria-controls="kd-p2">
<img class="kd-thumb" src="assets/d2.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">02</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Ada Kwon</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Seoul · Fashion & portrait · 9 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p2" role="region" aria-labelledby="kd-b2" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d2.jpg" alt="Portrait of director Ada Kwon" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Ada came to film from still portraiture, and her frames keep that stillness: faces held a beat longer than feels comfortable, colour pushed until it hums. She casts off the street as often as off the agency book.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Pink Hour</span><span class="kd-wc">Maison Oriel</span><span class="kd-wy">2026</span></a></li>
<li><span class="kd-wt">Glasshouse</span><span class="kd-wc">Lookbook film</span><span class="kd-wy">2025</span></li>
<li><span class="kd-wt">Soft Signal</span><span class="kd-wc">Music video</span><span class="kd-wy">2024</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Ada <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d3 -->
<li class="kd-item" data-tags="commercial" data-img="assets/d3.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b3" aria-expanded="false" aria-controls="kd-p3">
<img class="kd-thumb" src="assets/d3.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">03</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Jun Park</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Seoul · Sport & kinetic · 11 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p3" role="region" aria-labelledby="kd-b3" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d3.jpg" alt="Portrait of director Jun Park" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Jun builds pictures around momentum: cameras on cables, rigs bolted to handlebars, cuts that land on the beat of a racing pulse. A former track cyclist, he knows exactly where the pain lives in a climb and points the lens straight at it.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">The Climb</span><span class="kd-wc">Velo Norte</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Split Second</span><span class="kd-wc">Sportswear spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Night Laps</span><span class="kd-wc">Spec spot</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Jun <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d4 -->
<li class="kd-item" data-tags="commercial fashion" data-img="assets/d4.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b4" aria-expanded="false" aria-controls="kd-p4">
<img class="kd-thumb" src="assets/d4.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">04</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Lena Brandt</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Berlin · Dance & music · 12 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p4" role="region" aria-labelledby="kd-b4" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d4.jpg" alt="Portrait of director Lena Brandt" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Lena blocks the camera as carefully as the performers, so bodies and lens move to a single count. Her sets feel like rehearsal rooms, loose and loud right up until the take, then precise to the frame.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">The City Is a Stage</span><span class="kd-wc">Stride Athletic</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Tempo Rubato</span><span class="kd-wc">Music film</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Floorwork</span><span class="kd-wc">Dance short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Lena <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d5 -->
<li class="kd-item" data-tags="commercial" data-img="assets/d5.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b5" aria-expanded="false" aria-controls="kd-p5">
<img class="kd-thumb" src="assets/d5.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">05</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Walter Imrie</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Toronto · Product & tabletop · 17 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p5" role="region" aria-labelledby="kd-b5" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d5.jpg" alt="Portrait of director Walter Imrie" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Walter treats objects like actors, lighting a watch face the way others light a close-up and moving the camera a millimetre at a time. His motion-control work is patient, exact and, when you look twice, quietly funny.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Second Nature</span><span class="kd-wc">Aurel Horology</span><span class="kd-wy">2024</span></a></li>
<li><span class="kd-wt">Pour</span><span class="kd-wc">Beverage spot</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">Small Weather</span><span class="kd-wc">Tabletop short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Walter <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
<!-- d6 -->
<li class="kd-item" data-tags="documentary" data-img="assets/d6.jpg">
<div class="kd-inner">
<span class="kd-line" aria-hidden="true"></span>
<h3 class="kd-h">
<button class="kd-row" type="button" id="kd-b6" aria-expanded="false" aria-controls="kd-p6">
<img class="kd-thumb" src="assets/d6.jpg" alt="" loading="lazy" width="56" height="56">
<span class="kd-num" aria-hidden="true">06</span>
<span class="kd-mask"><span class="kd-rise"><span class="kd-name"><span class="kd-nm">Noor Haddad</span><svg class="kd-arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span></span>
<span class="kd-meta">Mexico City · Documentary & branded short · 8 films</span>
</button>
</h3>
<div class="kd-panel" id="kd-p6" role="region" aria-labelledby="kd-b6" inert>
<div class="kd-panel-in">
<div class="kd-card card">
<img class="kd-portrait" src="assets/d6.jpg" alt="Portrait of director Noor Haddad" loading="lazy" width="480" height="720">
<div class="kd-body">
<p class="kd-bio">Noor starts every job by listening, spending weeks in conversation before a single frame so the people on screen are collaborators rather than subjects. Her branded shorts play like documentaries because, in every way that matters, they are.</p>
<p class="label">Selected work</p>
<ul class="kd-works">
<li><a href="#work"><span class="kd-wt">Last Light</span><span class="kd-wc">Salt & Current</span><span class="kd-wy">2025</span></a></li>
<li><span class="kd-wt">Mercado</span><span class="kd-wc">Documentary short</span><span class="kd-wy">2024</span></li>
<li><span class="kd-wt">The Weavers</span><span class="kd-wc">Branded short</span><span class="kd-wy">2023</span></li>
</ul>
<span class="kd-cta"><a class="btn" href="#contact">Book Noor <svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></a></span>
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
<span class="kd-endline" aria-hidden="true"></span>
</div>
</div>
<div class="kd-float" aria-hidden="true">
<div class="kd-float-tilt">
<div class="kd-float-clip">
<img class="kd-fimg" src="assets/d1.jpg" alt="" width="280" height="420">
<img class="kd-fimg" src="assets/d1.jpg" alt="" width="280" height="420">
</div>
</div>
</div>
</section>
<style>
.kd{position:relative;overflow-x:clip}
.kd-wrap{position:relative;z-index:1}
.kd-head{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:28px 48px;margin-bottom:clamp(40px,6vw,88px)}
.kd-intro{max-width:880px}
.kd-title{font-size:clamp(40px,6.2vw,100px);margin-top:18px}
.kd-lede{margin:22px 0 0}
.kd-sr,.kd-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* filter chips + sliding rose pill */
.kd-filters{position:relative;isolation:isolate;display:flex;flex-wrap:wrap;gap:8px}
.kd-pill{position:absolute;left:0;top:0;width:0;height:0;border-radius:999px;background:var(--rose);box-shadow:var(--shadow-rose);pointer-events:none}
.kd-chip{position:relative;font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;padding:10px 16px;border-radius:999px;
background:transparent;color:var(--ink);transition:color .3s var(--ease)}
.kd-chip::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:var(--paper);box-shadow:inset 0 0 0 1px var(--line);
transition:opacity .3s var(--ease),box-shadow .25s var(--ease)}
.kd-chip[aria-pressed="false"]:hover{color:var(--rose)}
.kd-chip[aria-pressed="false"]:hover::before{box-shadow:inset 0 0 0 1px var(--rose)}
.kd-chip[aria-pressed="true"]{color:#fff;transition-delay:.12s}
.kd-chip[aria-pressed="true"]::before{opacity:0;transition-delay:.08s}
/* outline name marquee behind the list (desktop) */
.kd-stage{position:relative;isolation:isolate}
.kd-bg{position:absolute;left:50%;width:100vw;margin-left:-50vw;top:-2%;bottom:-2%;z-index:-1;display:flex;flex-direction:column;justify-content:space-around;
overflow:hidden;pointer-events:none;user-select:none;
-webkit-mask-image:linear-gradient(rgba(27,21,48,0),rgba(27,21,48,1) 18%,rgba(27,21,48,1) 82%,rgba(27,21,48,0));mask-image:linear-gradient(rgba(27,21,48,0),rgba(27,21,48,1) 18%,rgba(27,21,48,1) 82%,rgba(27,21,48,0))}
.kd-bg-row{width:max-content;white-space:nowrap;font-stretch:125%;font-weight:800;font-size:clamp(120px,15vw,260px);line-height:1;letter-spacing:-.04em;
color:transparent;-webkit-text-stroke:1.5px var(--line);will-change:transform}
.kd-bg-row:nth-child(2){margin-left:-40vw}
.kd-list{position:relative;list-style:none;margin:0;padding:0}
.kd-item{position:relative}
.kd-item.is-out{display:none}
.kd-inner{position:relative}
.kd-line,.kd-endline{display:block;height:1px;background:var(--line);transform-origin:0 50%}
.kd-line{position:absolute;left:0;right:0;top:0}
.kd-h{margin:0;font:inherit}
.kd-row{width:100%;display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px 24px;
padding:clamp(18px,2.2vw,30px) 0;text-align:left;border-radius:6px}
.kd-row:focus-visible{outline-offset:-2px}
.kd-thumb{display:none;width:56px;height:56px;border-radius:50%;object-fit:cover;background:var(--rose-soft)}
.kd-num{font-family:var(--mono);font-size:12px;color:var(--mute);align-self:start;padding-top:.9em}
.kd-mask{display:block;min-width:0;clip-path:inset(-2px -60vw 0 -2px);padding-bottom:.04em;will-change:transform}
.kd-rise{display:block}
.kd-name{display:inline-flex;align-items:center;gap:.22em;max-width:100%;font-stretch:125%;font-weight:700;
font-size:clamp(34px,6.4vw,96px);line-height:.98;letter-spacing:-.035em;color:var(--ink);
transition:transform .55s var(--ease),color .35s var(--ease)}
.kd-nm{min-width:0}
/* two-layer char roll: ink glyph on top, rose copy parked below inside the same mask */
.kd-nm .kf-m{padding-top:.14em;margin-top:-.14em}
.kd-nm .kf-i{position:relative}
.kd-ch2{position:absolute;left:0;top:0;transform:translateY(125%);color:var(--rose)}
.kd-arrow{flex:none;width:.5em;height:.5em;color:var(--rose);opacity:0;transform:translateX(-.3em);
transition:opacity .35s var(--ease),transform .55s var(--ease)}
.kd-meta{font-family:var(--mono);font-size:12.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--mute);
text-align:right;white-space:nowrap;transition:color .35s var(--ease)}
.kd-row[aria-expanded="true"] .kd-name{color:var(--rose)}
.kd-row:focus-visible .kd-arrow,.kd-row[aria-expanded="true"] .kd-arrow{opacity:1;transform:none}
.kd-row[aria-expanded="true"] .kd-arrow{transform:rotate(90deg)}
.kd-row:focus-visible .kd-meta,.kd-row[aria-expanded="true"] .kd-meta{color:var(--ink)}
@media (hover:hover){
.kd-row:hover .kd-name{transform:translateX(24px)}
.kd-row:hover .kd-arrow{opacity:1;transform:none}
.kd-row[aria-expanded="true"]:hover .kd-arrow{transform:rotate(90deg)}
.kd-row:hover .kd-meta{color:var(--ink)}
}
@media (min-width:761px){
.kd-row:focus-visible .kd-name,.kd-row[aria-expanded="true"] .kd-name{transform:translateX(24px)}
}
/* accordion (height driven by GSAP) */
.kd-panel{height:0;overflow:hidden}
.kd-panel.is-open{height:auto}
.kd-panel.is-open.is-settled{overflow:visible}
.kd-panel-in{display:flow-root}
.kd-card{display:grid;grid-template-columns:minmax(0,240px) minmax(0,1fr);gap:clamp(20px,3.4vw,56px);
padding:clamp(14px,1.6vw,22px);margin:4px 0 clamp(26px,3vw,40px)}
.kd-portrait{width:100%;height:auto;aspect-ratio:2/3;object-fit:cover;border-radius:12px;background:var(--rose-soft)}
.kd-body{display:flex;flex-direction:column;align-items:flex-start;gap:14px;padding:clamp(4px,1.4vw,20px) clamp(0px,1.4vw,20px) 6px 0}
.kd-bio{margin:0 0 10px;font-size:clamp(17px,1.5vw,22px);line-height:1.45;color:var(--ink);max-width:48ch}
.kd-cta{display:inline-block;transform-origin:0 50%}
.kd-works{list-style:none;margin:0 0 14px;padding:0;width:100%;max-width:560px}
.kd-works li{border-top:1px solid var(--line)}
.kd-works li:last-child{border-bottom:1px solid var(--line)}
.kd-works a,.kd-works li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px 16px;align-items:baseline;padding:11px 0}
.kd-works li:has(a){padding:0}
.kd-works a{padding:11px 0;transition:color .25s var(--ease)}
.kd-works a:hover .kd-wt{color:var(--rose)}
.kd-works a .kd-wt{text-decoration:underline;text-decoration-color:var(--rose);text-underline-offset:4px;text-decoration-thickness:1.5px}
.kd-wt{font-weight:700;transition:color .25s var(--ease)}
.kd-wc{color:var(--mute);font-size:14px}
.kd-wy{font-family:var(--mono);font-size:12px;color:var(--mute)}
/* floating cursor portrait (fine pointers only): float = position, tilt = velocity lean, clip = show/hide wipe */
.kd-float{position:fixed;left:0;top:0;z-index:40;width:clamp(200px,19vw,280px);aspect-ratio:2/3;pointer-events:none;visibility:hidden;will-change:transform}
.kd-float-tilt{width:100%;height:100%;filter:drop-shadow(0 26px 28px rgba(27,21,48,.32));will-change:transform;transform-origin:50% 0}
.kd-float-clip{position:relative;width:100%;height:100%;border-radius:14px;overflow:hidden;clip-path:inset(100% 0% 0% 0% round 14px);background:var(--rose-soft)}
.kd-fimg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.kd-fimg + .kd-fimg{clip-path:inset(100% 0% 0% 0%)}
/* touch + small screens: no floating image or marquee, round thumbnail instead */
@media (hover:none),(max-width:760px){
.kd-float,.kd-bg{display:none}
.kd-row{grid-template-columns:56px minmax(0,1fr);gap:8px 14px;align-items:start}
.kd-thumb{display:block;grid-row:1 / span 2}
.kd-num{display:none}
.kd-meta{grid-column:2;text-align:left;white-space:normal;font-size:11.5px;line-height:1.5}
.kd-name{font-size:clamp(34px,9vw,64px)}
}
@media (max-width:640px){
.kd-card{grid-template-columns:1fr}
.kd-portrait{width:min(180px,55%)}
.kd-body{padding:0}
.kd-works a,.kd-works li{grid-template-columns:minmax(0,1fr) auto}
.kd-wc{grid-column:1;grid-row:2}
.kd-wy{grid-column:2;grid-row:1}
}
@media (prefers-reduced-motion: reduce){
.kd-name,.kd-arrow,.kd-chip,.kd-chip::before{transition:none !important}
.kd-bg{display:none}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('directors');
if (!root) return;
const reduce = !!KF.reduce;
const E = KF.ease || { out: 'expo.out', soft: 'power3.out', io: 'power2.inOut' };
const items = [...root.querySelectorAll('.kd-item')];
const list = root.querySelector('.kd-list');
const status = root.querySelector('p.kd-sr');
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
/* preload every portrait so the floating image swaps instantly */
items.forEach(it => { const im = new Image(); im.src = it.dataset.img; });
/* visually-hidden text copy for assistive tech, then the visible node can be split freely */
const srCopy = el => {
const vh = document.createElement(el.tagName === 'P' ? 'p' : 'span');
vh.className = 'kd-vh'; vh.textContent = el.textContent.replace(/\s+/g, ' ').trim();
el.before(vh);
};
const splitHidden = (el, mode) => {
srCopy(el);
const parts = KF.split(el, mode);
el.removeAttribute('aria-label'); el.setAttribute('aria-hidden', 'true');
return parts;
};
/* ---------- names: chars with a rose twin parked under each glyph ---------- */
const nameChars = items.map(it => {
const chars = splitHidden(it.querySelector('.kd-nm'), 'chars');
chars.forEach(c => {
const twin = document.createElement('span');
twin.className = 'kd-ch2'; twin.textContent = c.textContent;
c.appendChild(twin);
});
return chars;
});
const roll = (i, on) => {
if (reduce) return;
gsap.to(nameChars[i], {
yPercent: on ? -125 : 0, duration: on ? 0.6 : 0.5, ease: on ? E.out : 'power3.inOut',
stagger: 0.022, overwrite: true
});
};
/* bios are split into words once; lines are worked out on each open so resizes stay correct */
const bioWords = new Map();
items.forEach(it => { const bio = it.querySelector('.kd-bio'); bioWords.set(it, splitHidden(bio, 'words')); });
const lineIndex = words => {
let top = null, line = -1;
return words.map(w => { const t = w.parentNode.offsetTop; if (top === null || t > top + 4) { top = t; line++; } return line; });
};
/* ---------- heading + intro ---------- */
KF.reveal(root.querySelector('.kd-title'), { mode: 'chars', stagger: 0.022 });
const chips = [...root.querySelectorAll('.kd-chip')];
const pill = root.querySelector('.kd-pill');
if (!reduce) {
const intro = [root.querySelector('.kd-intro .label'), root.querySelector('.kd-lede')];
gsap.set(intro, { autoAlpha: 0, y: 28 });
gsap.set(chips, { autoAlpha: 0, yPercent: 90 });
gsap.set(pill, { autoAlpha: 0 });
gsap.timeline({ scrollTrigger: { trigger: root.querySelector('.kd-head'), start: 'top 85%', once: true } })
.to(intro, { autoAlpha: 1, y: 0, duration: 1, ease: E.soft, stagger: 0.12 }, 0)
.to(chips, { autoAlpha: 1, yPercent: 0, duration: 0.9, ease: E.out, stagger: 0.07 }, 0.35)
.to(pill, { autoAlpha: 1, duration: 0.5, ease: 'power2.out' }, 0.6);
}
/* ---------- accordion ---------- */
function panelParts(it) {
const panel = it.querySelector('.kd-panel');
return {
btn: it.querySelector('.kd-row'), panel, inner: panel.firstElementChild,
card: panel.querySelector('.kd-card'), portrait: panel.querySelector('.kd-portrait'),
words: bioWords.get(it), cta: panel.querySelector('.kd-cta'),
rest: [panel.querySelector('.kd-body > .label'), ...panel.querySelectorAll('.kd-works li')]
};
}
function setOpen(it, open, instant) {
const p = panelParts(it);
const { btn, panel, inner, card, portrait, words, cta, rest } = p;
if ((btn.getAttribute('aria-expanded') === 'true') === open) return;
btn.setAttribute('aria-expanded', String(open));
panel.inert = !open;
if (panel._tl) panel._tl.kill();
panel.classList.remove('is-settled');
const all = [card, portrait, cta, ...words, ...rest];
if (instant || reduce) {
panel.classList.toggle('is-open', open);
gsap.set(panel, { clearProps: 'height' });
gsap.set(all, { clearProps: 'opacity,visibility,transform,clipPath' });
if (open) panel.classList.add('is-settled');
KF.refresh();
return;
}
const from = panel.offsetHeight;
if (open) {
panel.classList.add('is-open');
const to = inner.offsetHeight;
const lines = lineIndex(words);
panel._tl = gsap.timeline({ onComplete() { gsap.set(panel, { clearProps: 'height' }); panel.classList.add('is-settled'); KF.refresh(); } })
.fromTo(panel, { height: from }, { height: to, duration: 0.85, ease: 'power3.inOut' }, 0)
.fromTo(card, { autoAlpha: 0, y: 28 }, { autoAlpha: 1, y: 0, duration: 0.8, ease: E.out }, 0.12)
.fromTo(portrait, { clipPath: 'inset(100% 0% 0% 0% round 12px)', scale: 1.18 },
{ clipPath: 'inset(0% 0% 0% 0% round 12px)', scale: 1, duration: 1.1, ease: E.out }, 0.22)
.fromTo(words, { yPercent: 115 }, { yPercent: 0, duration: 0.85, ease: E.out, stagger: i => lines[i] * 0.08 }, 0.3)
.fromTo(rest, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.6, ease: E.soft, stagger: 0.06 }, 0.5)
.fromTo(cta, { autoAlpha: 0, scale: 0.4 }, { autoAlpha: 1, scale: 1, duration: 0.75, ease: 'back.out(2.4)' }, 0.72);
} else {
panel._tl = gsap.timeline({ onComplete() { panel.classList.remove('is-open'); gsap.set(panel, { clearProps: 'height' }); KF.refresh(); } })
.to(cta, { autoAlpha: 0, scale: 0.5, duration: 0.25, ease: 'power2.in' }, 0)
.to(rest, { autoAlpha: 0, y: 10, duration: 0.25, ease: 'power2.in', stagger: { each: 0.03, from: 'end' } }, 0)
.to(words, { yPercent: -115, duration: 0.35, ease: 'power2.in', stagger: 0.003 }, 0)
.to(portrait, { clipPath: 'inset(0% 0% 100% 0% round 12px)', duration: 0.45, ease: 'power3.in' }, 0.05)
.to(card, { autoAlpha: 0, y: -12, duration: 0.35, ease: 'power2.in' }, 0.2)
.fromTo(panel, { height: from }, { height: 0, duration: 0.6, ease: 'power3.inOut' }, 0.25);
}
}
/* ---------- filter: hand-rolled flip (first / last / invert / play) + sliding pill ---------- */
const placePill = (chip, animate) => {
const v = { x: chip.offsetLeft, y: chip.offsetTop, width: chip.offsetWidth, height: chip.offsetHeight };
if (!animate || reduce) gsap.set(pill, v);
else gsap.to(pill, Object.assign(v, { duration: 0.7, ease: E.out, overwrite: 'auto' }));
};
const pressed = () => chips.find(c => c.getAttribute('aria-pressed') === 'true') || chips[0];
placePill(pressed(), false);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => placePill(pressed(), false));
addEventListener('resize', () => placePill(pressed(), false), { passive: true });
const leaving = new Set();
function applyFilter(f) {
const want = items.map(it => f === 'all' || it.dataset.tags.split(' ').includes(f));
if (reduce) {
items.forEach((it, i) => { if (!want[i]) setOpen(it, false, true); it.classList.toggle('is-out', !want[i]); it.inert = !want[i]; });
return;
}
/* FIRST: where every on-screen row is right now (mid-flight transforms included) */
const lt0 = list.getBoundingClientRect().top;
const h0 = list.getBoundingClientRect().height;
const first = new Map();
items.forEach(it => { if (!it.classList.contains('is-out')) first.set(it, it.getBoundingClientRect().top - lt0); });
/* settle anything still animating from a previous click */
gsap.killTweensOf([list, ...items]);
const wasLeaving = new Set(leaving);
leaving.forEach(it => { it.classList.add('is-out'); setOpen(it, false, true); });
leaving.clear();
gsap.set([list, ...items], { clearProps: 'all' });
const stay = [], enter = [], exit = [];
items.forEach((it, i) => {
const wasIn = first.has(it);
if (want[i]) { it.classList.remove('is-out'); it.inert = false; (wasIn ? stay : enter).push(it); }
else if (wasIn && !wasLeaving.has(it)) exit.push(it);
else { it.classList.add('is-out'); it.inert = true; }
});
/* exits leave the flow but stay painted where they were */
exit.forEach(it => {
leaving.add(it); it.inert = true;
gsap.set(it, { position: 'absolute', top: first.get(it), left: 0, right: 0, zIndex: 0 });
});
/* LAST + INVERT + PLAY */
const lt1 = list.getBoundingClientRect().top;
const h1 = list.getBoundingClientRect().height;
stay.forEach(it => {
const dy = first.get(it) - (it.getBoundingClientRect().top - lt1);
if (Math.abs(dy) > 0.5) gsap.fromTo(it, { y: dy }, { y: 0, duration: 0.9, ease: E.out, clearProps: 'transform' });
});
enter.forEach((it, k) => {
gsap.fromTo(it, { autoAlpha: 0, y: 40, clipPath: 'inset(0% 0% 100% 0%)' },
{ autoAlpha: 1, y: 0, clipPath: 'inset(0% 0% 0% 0%)', duration: 0.9, delay: 0.15 + k * 0.06, ease: E.out,
clearProps: 'opacity,visibility,transform,clipPath' });
});
exit.forEach(it => {
gsap.to(it, { autoAlpha: 0, y: -16, clipPath: 'inset(0% 0% 100% 0%)', duration: 0.5, ease: 'power3.in',
onComplete() { leaving.delete(it); it.classList.add('is-out'); gsap.set(it, { clearProps: 'all' }); setOpen(it, false, true); KF.refresh(); } });
});
if (Math.abs(h1 - h0) > 0.5) gsap.fromTo(list, { height: h0 }, { height: h1, duration: 0.9, ease: E.out, clearProps: 'height', onComplete: KF.refresh });
}
chips.forEach(chip => chip.addEventListener('click', () => {
if (chip.getAttribute('aria-pressed') === 'true') return;
chips.forEach(c => c.setAttribute('aria-pressed', String(c === chip)));
placePill(chip, true);
applyFilter(chip.dataset.filter);
const shown = items.filter(it => !it.classList.contains('is-out') && !leaving.has(it)).length;
status.textContent = `Showing ${shown} director${shown === 1 ? '' : 's'}`;
hideFloat();
KF.refresh();
}));
/* ---------- floating portrait: quickTo follow, velocity lean, two-image wipe ---------- */
const float = root.querySelector('.kd-float');
const tilt = float.querySelector('.kd-float-tilt');
const clip = float.querySelector('.kd-float-clip');
const imgs = [...clip.querySelectorAll('.kd-fimg')];
const fine = matchMedia('(hover: hover) and (pointer: fine) and (min-width: 761px)');
const xTo = gsap.quickTo(float, 'x', { duration: 0.55, ease: 'power3.out' });
const yTo = gsap.quickTo(float, 'y', { duration: 0.55, ease: 'power3.out' });
const rTo = gsap.quickTo(tilt, 'rotation', { duration: 0.6, ease: 'power3.out' });
const kTo = gsap.quickTo(tilt, 'skewX', { duration: 0.6, ease: 'power3.out' });
let front = 0, active = null, shown = false, ticking = false;
let lx = -1, ly = -1, px = 0, vx = 0, tx = 0, ty = 0;
function aim(cx, cy) {
const w = float.offsetWidth, h = float.offsetHeight, gap = 36;
tx = cx + gap + w > innerWidth - 16 ? cx - gap - w : cx + gap;
ty = clamp(cy - h / 2, 76, Math.max(76, innerHeight - h - 16));
}
function move(jump) {
if (reduce || jump) { gsap.set(float, { x: tx, y: ty }); xTo(tx, tx); yTo(ty, ty); }
else { xTo(tx); yTo(ty); }
}
function lean() {
if (lx < 0) return;
vx += ((lx - px) - vx) * 0.35; px = lx;
rTo(clamp(vx * 0.4, -12, 12));
kTo(clamp(-vx * 0.28, -9, 9));
}
function swap(src) {
const cur = imgs[front], nxt = imgs[1 - front];
if (cur.getAttribute('src') === src) return;
gsap.killTweensOf(nxt);
nxt.setAttribute('src', src);
gsap.set(cur, { zIndex: 1 }); gsap.set(nxt, { zIndex: 2 });
if (reduce) gsap.set(nxt, { clipPath: 'inset(0% 0% 0% 0%)', scale: 1 });
else gsap.fromTo(nxt, { clipPath: 'inset(100% 0% 0% 0%)', scale: 1.25 },
{ clipPath: 'inset(0% 0% 0% 0%)', scale: 1, duration: 0.75, ease: E.out });
front = 1 - front;
}
function show(it, cx, cy) {
if (!fine.matches) return;
if (!shown) {
shown = true;
const cur = imgs[front], other = imgs[1 - front];
gsap.killTweensOf([cur, other]);
cur.setAttribute('src', it.dataset.img);
gsap.set(cur, { zIndex: 2, clipPath: 'inset(0% 0% 0% 0%)' });
gsap.set(other, { zIndex: 1, clipPath: 'inset(100% 0% 0% 0%)' });
aim(cx, cy); move(true);
gsap.set(float, { visibility: 'visible' });
px = cx; vx = 0;
if (reduce) gsap.set(clip, { clipPath: 'inset(0% 0% 0% 0% round 14px)' });
else {
gsap.to(clip, { clipPath: 'inset(0% 0% 0% 0% round 14px)', duration: 0.65, ease: E.out, overwrite: true });
gsap.fromTo(cur, { scale: 1.2 }, { scale: 1, duration: 0.9, ease: E.out });
if (!ticking) { ticking = true; gsap.ticker.add(lean); }
}
} else swap(it.dataset.img);
active = it;
}
function hideFloat() {
active = null;
if (!shown) return;
shown = false;
if (ticking) { ticking = false; gsap.ticker.remove(lean); }
if (reduce) { gsap.set(clip, { clipPath: 'inset(100% 0% 0% 0% round 14px)' }); gsap.set(float, { visibility: 'hidden' }); return; }
rTo(0); kTo(0);
gsap.to(clip, { clipPath: 'inset(0% 0% 100% 0% round 14px)', duration: 0.45, ease: 'power3.in', overwrite: true,
onComplete() { if (!shown) { gsap.set(clip, { clipPath: 'inset(100% 0% 0% 0% round 14px)' }); gsap.set(float, { visibility: 'hidden' }); } } });
}
items.forEach((it, i) => {
const row = it.querySelector('.kd-row');
row.addEventListener('pointerenter', e => {
if (e.pointerType !== 'mouse') return;
roll(i, true); show(it, e.clientX, e.clientY);
});
row.addEventListener('pointerleave', e => {
if (e.pointerType === 'mouse' && !row.matches(':focus-visible')) roll(i, false);
requestAnimationFrame(() => { if (active === it) hideFloat(); });
});
row.addEventListener('focus', () => { if (row.matches(':focus-visible')) roll(i, true); });
row.addEventListener('blur', () => { if (!row.matches(':hover')) roll(i, false); });
row.addEventListener('click', () => {
const open = row.getAttribute('aria-expanded') !== 'true';
items.forEach(o => { if (o !== it) setOpen(o, false); });
setOpen(it, open);
});
});
list.addEventListener('pointermove', e => {
lx = e.clientX; ly = e.clientY;
if (active) { aim(lx, ly); move(false); }
});
addEventListener('scroll', () => {
if (!active || lx < 0) return;
const el = document.elementFromPoint(lx, ly);
const row = el && el.closest('#directors .kd-row');
if (!row) hideFloat();
else if (row.closest('.kd-item') !== active) show(row.closest('.kd-item'), lx, ly);
}, { passive: true });
fine.addEventListener && fine.addEventListener('change', hideFloat);
/* never leave the portrait hanging once the roster is off screen */
ScrollTrigger.create({ trigger: list, start: 'top bottom', end: 'bottom top', onLeave: hideFloat, onLeaveBack: hideFloat });
/* ---------- scroll-in: border draws, then the name rises out of its mask ---------- */
if (!reduce) {
const lines = items.map(it => it.querySelector('.kd-line'));
const rises = items.map(it => it.querySelector('.kd-rise'));
const extras = items.map(it => it.querySelectorAll('.kd-num, .kd-meta, .kd-thumb'));
const endline = root.querySelector('.kd-endline');
gsap.set([...lines, endline], { scaleX: 0 });
gsap.set(rises, { yPercent: 118 });
gsap.set(extras.flatMap(n => [...n]), { autoAlpha: 0, y: 10 });
const tl = gsap.timeline({ scrollTrigger: { trigger: list, start: 'top 82%', once: true } });
items.forEach((it, i) => {
const at = i * 0.12;
tl.to(lines[i], { scaleX: 1, duration: 0.9, ease: 'power3.inOut' }, at)
.to(rises[i], { yPercent: 0, duration: 1, ease: 'power4.out' }, at + 0.32)
.to(extras[i], { autoAlpha: 1, y: 0, duration: 0.6, ease: 'power2.out', stagger: 0.05 }, at + 0.5);
});
tl.to(endline, { scaleX: 1, duration: 0.9, ease: 'power3.inOut' }, items.length * 0.12);
/* scrubbed drift: alternate names slide opposite ways while crossing the viewport */
const amp = () => Math.min(innerWidth * 0.03, 44);
items.forEach((it, i) => {
const d = i % 2 ? -1 : 1;
gsap.fromTo(it.querySelector('.kd-mask'), { x: () => -d * amp() }, {
x: () => d * amp(), ease: 'none',
scrollTrigger: { trigger: it, start: 'top bottom', end: 'bottom top', scrub: 0.8, invalidateOnRefresh: true }
});
});
/* outline name marquee behind the list, desktop only */
const mm = gsap.matchMedia();
mm.add('(min-width: 761px) and (hover: hover)', () => {
const rows = root.querySelectorAll('.kd-bg-row');
const st = { trigger: root, start: 'top bottom', end: 'bottom top', scrub: 1 };
gsap.fromTo(rows[0], { xPercent: 0 }, { xPercent: -22, ease: 'none', scrollTrigger: st });
gsap.fromTo(rows[1], { xPercent: -22 }, { xPercent: 0, ease: 'none', scrollTrigger: Object.assign({}, st) });
});
}
KF.refresh();
});
</script>
```
## `sections/05-contact.html`
```html
<section class="kf sec" id="contact" aria-labelledby="kf-title">
<div class="wrap">
<div class="kf-panel" data-kf-panel>
<div class="kf-orbs" aria-hidden="true">
<span class="kf-orb kf-orb--1" data-kf-depth="34"><i></i></span>
<span class="kf-orb kf-orb--2" data-kf-depth="-22"><i></i></span>
<span class="kf-orb kf-orb--3" data-kf-depth="52"><i></i></span>
<span class="kf-orb kf-orb--4" data-kf-depth="-40"><i></i></span>
<span class="kf-orb kf-orb--5" data-kf-depth="18"><i></i></span>
</div>
<div class="kf-head">
<p class="label kf-label">Start a project</p>
<h2 class="display kf-title" id="kf-title">Got a film<br>in you?</h2>
</div>
<div class="kf-body">
<div class="kf-aside">
<p class="kf-intro">Tell us what you want people to feel. We'll bring the crew, the director and the plan to get it on screen.</p>
<dl class="kf-facts">
<div><dt class="label">Write to us</dt><dd><a class="kf-mail" href="mailto:[email protected]">[email protected]</a></dd></div>
<div><dt class="label">Reply time</dt><dd>One working day</dd></div>
<div><dt class="label">We take on</dt><dd>Commercials, short film, branded work</dd></div>
</dl>
</div>
<div class="kf-formwrap">
<form class="kf-form" novalidate data-kf-form>
<div class="kf-grid">
<div class="kf-field">
<label for="kf-name">Name</label>
<div class="kf-ctl">
<input id="kf-name" name="name" type="text" autocomplete="name" placeholder="Your full name" required aria-describedby="kf-name-err">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-name-err"></p>
</div>
<div class="kf-field">
<label for="kf-email">Email</label>
<div class="kf-ctl">
<input id="kf-email" name="email" type="email" autocomplete="email" inputmode="email" placeholder="[email protected]" required aria-describedby="kf-email-err">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-email-err"></p>
</div>
<div class="kf-field">
<label for="kf-company">Company <span class="kf-opt">(optional)</span></label>
<div class="kf-ctl">
<input id="kf-company" name="company" type="text" autocomplete="organization" placeholder="Brand or agency">
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
</div>
<div class="kf-field">
<label for="kf-budget">Budget range</label>
<div class="kf-ctl kf-select">
<select id="kf-budget" name="budget" required aria-describedby="kf-budget-err">
<option value="" selected disabled>Choose a range</option>
<option value="lt50"><€50k</option>
<option value="50-150">€50–150k</option>
<option value="150-500">€150–500k</option>
<option value="500plus">€500k+</option>
</select>
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-budget-err"></p>
</div>
<div class="kf-field kf-field--full">
<label for="kf-msg">The brief</label>
<div class="kf-ctl">
<textarea id="kf-msg" name="message" rows="4" placeholder="What are we making, who is it for, and when does it need to land?" required aria-describedby="kf-msg-err"></textarea>
<span class="kf-ul" aria-hidden="true"></span><span class="kf-ulf" aria-hidden="true"></span>
</div>
<p class="kf-err" id="kf-msg-err"></p>
</div>
</div>
<div class="kf-submit">
<button class="btn btn--sky kf-send" type="submit">Send brief <span class="kf-arr" aria-hidden="true"><span>→</span><span>→</span></span></button>
<p class="kf-note">A producer reads every brief personally.</p>
</div>
</form>
<div class="kf-done" data-kf-done role="status" tabindex="-1" hidden>
<span class="kf-done-mark" aria-hidden="true">
<svg viewBox="0 0 64 64" width="64" height="64" focusable="false">
<circle class="kf-done-disc" cx="32" cy="32" r="27"/>
<circle class="kf-done-ring" cx="32" cy="32" r="30"/>
<path class="kf-done-tick" d="M20 33.5l8 8 16-17"/>
</svg>
</span>
<p class="kf-done-text">Thanks — a producer will reply within one working day.</p>
<button class="kf-again" type="button" data-kf-again>Send another brief</button>
</div>
</div>
</div>
</div>
<div class="kf-offices">
<div class="kf-offices-head">
<p class="label label--rose">Studios</p>
<p class="kf-offices-line">Five cities, one crew. Someone is always awake.</p>
</div>
<ul class="kf-list" role="list">
<li class="kf-office" data-tz="Europe/Lisbon">
<h3 class="kf-city"><span class="kf-sr">Lisbon</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Lisbon</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Travessa do Lume 14<br>1200-221 Lisboa</address>
</li>
<li class="kf-office" data-tz="Asia/Seoul">
<h3 class="kf-city"><span class="kf-sr">Seoul</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Seoul</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">12 Hanul-gil, Seongdong-gu<br>Seoul 04782</address>
</li>
<li class="kf-office" data-tz="America/Mexico_City">
<h3 class="kf-city"><span class="kf-sr">Mexico City</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Mexico City</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Calle Aurora 207, Roma Nte.<br>06700 CDMX</address>
</li>
<li class="kf-office" data-tz="Europe/Berlin">
<h3 class="kf-city"><span class="kf-sr">Berlin</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Berlin</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">Lichtgasse 9, Hof 2<br>10999 Berlin</address>
</li>
<li class="kf-office" data-tz="America/Toronto">
<h3 class="kf-city"><span class="kf-sr">Toronto</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Toronto</span></h3>
<p class="kf-time"><time data-kf-time><span class="kf-sr" data-kf-tsr>--:--</span><span class="kf-digits" aria-hidden="true" data-kf-digits>--:--</span></time><span class="kf-state"><i class="kf-dot" aria-hidden="true"></i><span data-kf-state>—</span></span></p>
<address class="kf-addr">88 Foundry Lane, Unit 3<br>Toronto ON M5V 2K1</address>
</li>
</ul>
</div>
</div>
</section>
<footer class="kf-foot" aria-label="Site footer">
<div class="kf-marq">
<p class="kf-sr">Now booking Q1 2027 in Lisbon, Seoul, Mexico City, Berlin and Toronto.</p>
<div class="kf-marq-track" aria-hidden="true" data-kf-marq><span class="kf-marq-grp">Now booking Q1 2027 <b>✦</b> Lisbon <b>✦</b> Seoul <b>✦</b> Mexico City <b>✦</b> Berlin <b>✦</b> Toronto <b>✦</b> </span></div>
</div>
<div class="wrap kf-foot-in">
<div class="kf-hello">
<p class="label kf-rv" data-kf-rv>Say hello — we read everything</p>
<div class="kf-rv" data-kf-rv>
<a class="kf-fm" href="mailto:[email protected]" data-kf-fm><span class="kf-fm-base">[email protected]</span><span class="kf-fm-fill" aria-hidden="true">[email protected]</span></a>
</div>
</div>
<div class="kf-row">
<nav class="kf-social kf-rv" aria-label="Social" data-kf-rv>
<a href="#"><span class="kf-sr">Instagram</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Instagram</span></a>
<a href="#"><span class="kf-sr">Vimeo</span><span class="kf-roll" aria-hidden="true" data-kf-roll>Vimeo</span></a>
<a href="#"><span class="kf-sr">LinkedIn</span><span class="kf-roll" aria-hidden="true" data-kf-roll>LinkedIn</span></a>
</nav>
<p class="kf-copy kf-rv" data-kf-rv>© 2026 Kinoform Pictures</p>
<div class="kf-rv" data-kf-rv>
<button class="kf-top" type="button" data-kf-top>Back to top <span class="kf-top-arr" aria-hidden="true"><span>↑</span></span></button>
</div>
</div>
</div>
<div class="kf-mark-clip" aria-hidden="true">
<span class="kf-mark" data-kf-mark>Kinoform</span>
</div>
</footer>
<style>
/* ---------- 05 contact / offices / footer (kf-) ---------- */
.kf{overflow-x:clip}
.kf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.kf-panel{position:relative;background:var(--rose);color:#fff;border-radius:28px;padding:clamp(24px,5vw,80px);
box-shadow:var(--shadow-rose);transform-origin:50% 0;will-change:transform}
.kf-head,.kf-body{position:relative;z-index:1}
/* floating tint circles (decor, behind content) */
.kf-orbs{position:absolute;inset:0;overflow:hidden;border-radius:inherit;pointer-events:none;z-index:0}
.kf-orb{position:absolute;display:block;will-change:transform}
.kf-orb i{display:block;width:100%;height:100%;border-radius:50%;will-change:transform}
.kf-orb--1{width:clamp(220px,34vw,540px);aspect-ratio:1;top:-14%;right:-9%}
.kf-orb--1 i{background:radial-gradient(circle at 38% 36%,rgba(221,240,255,.42),rgba(221,240,255,.12) 52%,rgba(221,240,255,0) 70%)}
.kf-orb--2{width:clamp(140px,17vw,260px);aspect-ratio:1;top:34%;left:30%}
.kf-orb--2 i{box-shadow:inset 0 0 0 1.5px rgba(248,217,230,.4)}
.kf-orb--3{width:clamp(56px,6vw,96px);aspect-ratio:1;bottom:16%;left:5%}
.kf-orb--3 i{background:rgba(221,240,255,.26)}
.kf-orb--4{width:clamp(200px,24vw,380px);aspect-ratio:1;bottom:-16%;right:24%}
.kf-orb--4 i{background:radial-gradient(circle,rgba(248,217,230,.34),rgba(248,217,230,0) 68%)}
.kf-orb--5{width:clamp(26px,2.6vw,40px);aspect-ratio:1;top:18%;left:52%}
.kf-orb--5 i{background:rgba(248,217,230,.5)}
.kf-label{color:rgba(255,255,255,.8)}
.kf-title{font-size:clamp(40px,10.5vw,176px);margin-top:18px;color:#fff}
.kf-body{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.6fr);gap:clamp(32px,6vw,96px);margin-top:clamp(40px,6vw,88px)}
.kf-intro{font-size:clamp(17px,1.35vw,20px);line-height:1.5;margin:0;max-width:34ch;color:rgba(255,255,255,.92)}
.kf-facts{margin:32px 0 0;display:grid;gap:18px}
.kf-facts div{border-top:1px solid rgba(255,255,255,.3);padding-top:12px}
.kf-facts dt{color:rgba(255,255,255,.75)}
.kf-facts dd{margin:4px 0 0;font-weight:600}
.kf-mail{position:relative;overflow-wrap:anywhere}
.kf-mail::after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:1px;background:currentColor;transform-origin:100% 50%;transition:transform .35s var(--ease)}
.kf-mail:hover::after{transform:scaleX(0)}
.kf-panel :focus-visible{outline-color:#fff}
/* form */
.kf-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px 24px}
.kf-field{display:flex;flex-direction:column;min-width:0}
.kf-field--full{grid-column:1/-1}
.kf-field label{display:block;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.85);margin-bottom:8px}
.kf-opt{text-transform:none;letter-spacing:0;color:rgba(255,255,255,.7)}
.kf-ctl{position:relative}
.kf-field input,.kf-field select,.kf-field textarea{display:block;width:100%;font:inherit;font-size:16px;color:#fff;background:rgba(255,255,255,.12);
border:0;border-radius:10px 10px 0 0;padding:13px 14px;outline:none;transition:background .2s var(--ease)}
.kf-field textarea{resize:vertical;min-height:120px;line-height:1.5}
.kf-field ::placeholder{color:rgba(255,255,255,.7);opacity:1}
.kf-field input:hover,.kf-field select:hover,.kf-field textarea:hover,
.kf-field input:focus,.kf-field select:focus,.kf-field textarea:focus{background:rgba(255,255,255,.17)}
.kf-ul,.kf-ulf{position:absolute;left:0;right:0;bottom:0;pointer-events:none;transform-origin:0 50%}
.kf-ul{height:1px;background:#fff}
.kf-ulf{height:3px;background:var(--sky);transform:scaleX(0)}
.kf-ctl.is-bad .kf-ul{height:2px;background:var(--sky-soft)}
.kf-select::after{content:"";position:absolute;right:16px;top:50%;width:8px;height:8px;border-right:1.5px solid #fff;border-bottom:1.5px solid #fff;
transform:translateY(-70%) rotate(45deg);pointer-events:none}
.kf-field select{appearance:none;-webkit-appearance:none;padding-right:40px;cursor:pointer}
.kf-field select:invalid{color:rgba(255,255,255,.7)}
.kf-field select option{color:var(--ink);background:var(--paper)}
.kf-err{margin:6px 0 0;min-height:1.2em;font-family:var(--mono);font-size:12px;line-height:1.3;color:#fff}
.kf-err:not(:empty)::before{content:"! ";color:var(--sky)}
.kf-submit{display:flex;flex-wrap:wrap;align-items:center;gap:14px 22px;margin-top:22px}
.kf-note{margin:0;font-size:14px;color:rgba(255,255,255,.8)}
.kf-submit .btn:focus-visible{outline-color:#fff}
.kf-arr{display:inline-grid;overflow:hidden;line-height:1}
.kf-arr>span{grid-area:1/1;display:block;padding:0 1px}
.kf-arr>span+span{transform:translateX(-120%)}
/* success */
.kf-done{display:flex;flex-direction:column;align-items:flex-start;gap:18px;padding:clamp(28px,4vw,48px);border-radius:18px;
background:rgba(255,255,255,.12);border-bottom:1px solid #fff;outline:none}
.kf-done[hidden]{display:none}
.kf-done-mark{display:block;width:64px;height:64px}
.kf-done-mark svg{display:block;width:100%;height:100%;overflow:visible}
.kf-done-disc{fill:var(--paper);transform-origin:32px 32px;transform-box:view-box}
.kf-done-ring{fill:none;stroke:var(--sky);stroke-width:3;stroke-linecap:round;transform-origin:32px 32px;transform:rotate(-90deg)}
.kf-done-tick{fill:none;stroke:var(--rose);stroke-width:5;stroke-linecap:round;stroke-linejoin:round}
.kf-done-text{margin:0;font-stretch:125%;font-weight:700;font-size:clamp(22px,2.4vw,34px);line-height:1.15;letter-spacing:-.02em;max-width:22ch}
.kf-again{position:relative;font-family:var(--mono);font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:#fff;padding:4px 0}
.kf-again::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:currentColor;transform-origin:0 50%;transition:transform .35s var(--ease)}
.kf-again:hover::after{transform:scaleX(.3)}
/* char roll (cities, social links) */
.kf-roll{display:inline-block}
.kf-rw{display:inline-block;white-space:nowrap}
.kf-rc{display:inline-block;overflow:hidden;vertical-align:top}
.kf-rc>span{display:inline-block;position:relative;padding-bottom:.14em;will-change:transform}
.kf-rc>span::after{content:attr(data-c);position:absolute;left:0;top:100%}
/* offices */
.kf-offices{margin-top:clamp(72px,9vw,140px)}
.kf-offices-head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px 24px;margin-bottom:28px}
.kf-offices-line{margin:0;color:var(--mute);font-size:clamp(15px,1.2vw,18px)}
.kf-list{--kf-rs:1;position:relative;list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr))}
.kf-list::before{content:"";position:absolute;left:0;right:0;top:0;height:1px;background:var(--line);transform-origin:0 50%;transform:scaleX(var(--kf-rs))}
.kf-office{--kf-ls:1;position:relative;padding:24px clamp(12px,1.4vw,24px) 8px 0;min-width:0}
.kf-office+.kf-office{padding-left:clamp(12px,1.4vw,24px)}
.kf-office::after{content:"";position:absolute;right:0;top:0;width:1px;height:100%;background:var(--line);transform-origin:0 0;transform:scale(var(--kf-ls))}
.kf-office:last-child::after{display:none}
.kf-city{position:relative;display:inline-block;margin:0;font-stretch:125%;font-weight:800;letter-spacing:-.03em;line-height:1.02;font-size:clamp(22px,2.2vw,36px);color:var(--ink)}
.kf-city::after{content:"";position:absolute;left:0;right:0;bottom:-2px;height:3px;background:var(--rose);transform:scaleX(0);transform-origin:100% 50%;transition:transform .45s var(--ease)}
.kf-office:hover .kf-city::after{transform:scaleX(1);transform-origin:0 50%}
.kf-time{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;margin:16px 0 0;font-family:var(--mono);font-size:15px}
.kf-time time{font-size:20px;font-weight:500;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.kf-digits{display:inline-flex}
.kf-dg{display:inline-block;overflow:hidden;vertical-align:top;line-height:1.2}
.kf-dg>span{display:inline-block;will-change:transform}
.kf-state{display:inline-flex;align-items:center;gap:6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.kf-dot{width:8px;height:8px;border-radius:50%;background:var(--line);flex:none;transition:background .3s}
.kf-office.is-open .kf-dot{background:var(--sky);box-shadow:0 0 0 3px var(--sky-soft)}
.kf-addr{margin-top:12px;font-style:normal;font-size:14px;line-height:1.5;color:var(--mute)}
/* ---------- footer ---------- */
.kf-foot{position:relative;background:var(--bg);overflow:hidden}
.kf-marq{position:relative;overflow:hidden;border-block:1px solid var(--line);padding-block:12px;
font-family:var(--mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
.kf-marq-track{display:flex;width:max-content;will-change:transform}
.kf-marq-grp{flex:none;white-space:pre;padding-right:.6em}
.kf-marq b{font-weight:400;color:var(--rose);margin-inline:.5em}
.kf-foot-in{padding-top:clamp(56px,8vw,120px)}
.kf-hello .label{margin:0 0 14px}
.kf-fm{position:relative;display:inline-block;font-weight:800;font-stretch:112%;letter-spacing:-.035em;line-height:1.05;
font-size:clamp(24px,6.4vw,112px);color:var(--ink);overflow-wrap:anywhere}
.kf-fm-base,.kf-fm-fill{display:block}
.kf-fm-fill{position:absolute;inset:0;color:var(--rose);clip-path:inset(0 100% 0 0)}
.kf-fm::after{content:"";position:absolute;left:0;right:0;bottom:-.04em;height:max(2px,.035em);background:var(--line)}
.kf-rv{will-change:transform}
.kf-row{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px 32px;margin-top:clamp(40px,5vw,72px);padding-block:22px;
border-top:1px solid var(--line);font-size:15px}
.kf-social{display:flex;flex-wrap:wrap;gap:8px 26px}
.kf-social a{color:var(--mute);font-weight:600;transition:color .2s}
.kf-social a:hover,.kf-social a:focus-visible{color:var(--rose)}
.kf-copy{margin:0;font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mute)}
.kf-top{display:inline-flex;align-items:center;gap:10px;padding:8px 8px 8px 20px;border-radius:999px;font-weight:700;font-size:14px;
box-shadow:inset 0 0 0 1.5px var(--ink);transition:background .25s var(--ease),color .25s var(--ease),box-shadow .25s var(--ease)}
.kf-top:hover,.kf-top:focus-visible{background:var(--rose);color:#fff;box-shadow:none}
.kf-top-arr{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;background:var(--sky);color:var(--ink)}
.kf-top-arr>span{display:block;line-height:1}
/* wordmark: font-size fitted to width by JS (vw fallback), clip sits just under the baseline */
.kf-mark-clip{--kf-fs:19.4vw;font-size:var(--kf-fs);height:.86em;overflow:hidden;padding-inline:var(--gutter);margin-top:clamp(8px,2vw,24px)}
.kf-mark{display:block;width:max-content;font-family:var(--display);font-stretch:125%;font-weight:800;line-height:1;letter-spacing:-.045em;
color:var(--ink);white-space:nowrap;margin-top:-.07em}
.kf-mc{display:inline-block;transform-origin:50% 100%;will-change:transform}
.kf-mc--o{color:var(--rose);transform-origin:50% 58%}
@media (max-width:1100px){
.kf-list{grid-template-columns:1fr}
.kf-office,.kf-office+.kf-office{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr) minmax(0,1.2fr);align-items:baseline;gap:6px 24px;padding:20px 0}
.kf-office::after,.kf-office:last-child::after{display:block;top:auto;bottom:0;left:0;right:0;width:auto;height:1px}
.kf-city{justify-self:start}
.kf-time,.kf-addr{margin:0}
}
@media (max-width:860px){
.kf-body{grid-template-columns:1fr}
.kf-intro{max-width:46ch}
}
@media (max-width:600px){
.kf-panel{border-radius:22px}
.kf-grid{grid-template-columns:1fr}
.kf-office,.kf-office+.kf-office{grid-template-columns:1fr auto}
.kf-addr{grid-column:1/-1}
.kf-time{justify-content:flex-end}
.kf-row{flex-direction:column;align-items:flex-start}
.kf-fm{font-stretch:100%}
.kf-orb--2{left:auto;right:-10%}
}
@media (prefers-reduced-motion: reduce){
.kf-panel,.kf-orb,.kf-orb i,.kf-mc,.kf-rv,.kf-marq-track{transform:none !important;will-change:auto}
.kf *,.kf-foot *,.kf-foot *::after{transition:none !important}
}
</style>
<script>
KF.later(() => {
const root = document.getElementById('contact');
const foot = document.querySelector('.kf-foot');
if (!root) return;
const reduce = KF.reduce;
const fine = matchMedia('(hover:hover) and (pointer:fine)').matches;
/* ---------- helper: char roll (two stacked copies per char, roll up on hover) ---------- */
const rollify = el => {
const txt = el.textContent.trim(); el.textContent = '';
const chars = [];
txt.split(/\s+/).forEach((word, wi) => {
if (wi) el.appendChild(document.createTextNode(' '));
const w = document.createElement('span'); w.className = 'kf-rw';
[...word].forEach(ch => {
const c = document.createElement('span'); c.className = 'kf-rc';
const i = document.createElement('span'); i.textContent = ch; i.dataset.c = ch;
c.appendChild(i); w.appendChild(c); chars.push(i);
});
el.appendChild(w);
});
return chars;
};
const roll = chars => gsap.fromTo(chars, { yPercent: 0 }, { yPercent: -100, duration: .55, ease: 'power3.inOut', stagger: .022, overwrite: true });
const bindRoll = (host, rollEl) => {
const chars = rollify(rollEl);
if (reduce) return;
host.addEventListener('pointerenter', () => roll(chars));
host.addEventListener('focus', () => roll(chars));
};
/* ---------- form: validation + in-place success ---------- */
const form = root.querySelector('[data-kf-form]');
const done = root.querySelector('[data-kf-done]');
const again = root.querySelector('[data-kf-again]');
const doneText = done && done.querySelector('.kf-done-text');
const doneWords = doneText ? KF.split(doneText, 'words') : [];
const emailRx = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const rules = {
name: v => v.trim().length >= 2 ? '' : 'Please tell us your name.',
email: v => !v.trim() ? 'We need an email to reply to.' : (emailRx.test(v.trim()) ? '' : 'That email doesn\'t look quite right.'),
budget: v => v ? '' : 'Pick the closest range — it helps us plan.',
message: v => v.trim().length >= 20 ? '' : 'A few more words, please (20 characters minimum).'
};
let tried = false;
const check = el => {
const rule = rules[el.name]; if (!rule) return true;
const msg = rule(el.value);
const err = document.getElementById(el.getAttribute('aria-describedby'));
if (err) err.textContent = msg;
el.setAttribute('aria-invalid', msg ? 'true' : 'false');
el.closest('.kf-ctl')?.classList.toggle('is-bad', !!msg);
return !msg;
};
const showDone = () => {
form.hidden = true;
done.hidden = false;
done.focus({ preventScroll: true });
KF.refresh();
if (reduce) return;
const ring = done.querySelector('.kf-done-ring');
const tick = done.querySelector('.kf-done-tick');
const rl = ring.getTotalLength(), tl = tick.getTotalLength();
gsap.timeline()
.fromTo(done, { clipPath: 'inset(0 0 100% 0 round 18px)', y: 16 }, { clipPath: 'inset(0 0 0% 0 round 18px)', y: 0, duration: .7, ease: KF.ease.out })
.fromTo(done.querySelector('.kf-done-disc'), { scale: 0 }, { scale: 1, duration: .6, ease: 'back.out(2)' }, .1)
.fromTo(ring, { strokeDasharray: rl, strokeDashoffset: rl }, { strokeDashoffset: 0, duration: .9, ease: KF.ease.io }, .2)
.fromTo(tick, { strokeDasharray: tl, strokeDashoffset: tl }, { strokeDashoffset: 0, duration: .5, ease: 'power2.out' }, .55)
.fromTo(doneWords, { yPercent: 115, rotate: 3 }, { yPercent: 0, rotate: 0, duration: 1, ease: KF.ease.out, stagger: .05 }, .3)
.fromTo(again, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: .6, ease: KF.ease.soft }, .8);
};
if (form) {
const fields = [...form.elements].filter(el => rules[el.name]);
fields.forEach(el => {
el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', () => { if (tried || el.getAttribute('aria-invalid') === 'true') check(el); });
el.addEventListener('blur', () => { if (tried) check(el); });
});
/* focus: sky underline draws in from the left, leaves to the right */
form.querySelectorAll('.kf-ctl').forEach(ctl => {
const f = ctl.querySelector('.kf-ulf');
const ctrl = ctl.querySelector('input,select,textarea');
ctrl.addEventListener('focus', () => gsap.to(f, { scaleX: 1, transformOrigin: '0% 50%', duration: reduce ? 0 : .6, ease: KF.ease.out, overwrite: true }));
ctrl.addEventListener('blur', () => gsap.to(f, { scaleX: 0, transformOrigin: '100% 50%', duration: reduce ? 0 : .45, ease: KF.ease.io, overwrite: true }));
});
/* submit arrow: slides out right, a twin slides in from the left */
const send = form.querySelector('.kf-send');
const arrows = send ? [...send.querySelectorAll('.kf-arr > span')] : [];
let cur = 0;
const swap = () => {
if (reduce || arrows.length < 2) return;
const out = arrows[cur], inn = arrows[1 - cur]; cur = 1 - cur;
gsap.to(out, { xPercent: 120, duration: .4, ease: 'power3.in', overwrite: true });
gsap.fromTo(inn, { xPercent: -120 }, { xPercent: 0, duration: .5, delay: .12, ease: KF.ease.out, overwrite: true });
};
if (send) {
gsap.set(arrows[1], { xPercent: -120 });
send.addEventListener('pointerenter', swap);
send.addEventListener('pointerleave', swap);
send.addEventListener('focus', swap);
}
form.addEventListener('submit', e => {
e.preventDefault();
tried = true;
const bad = fields.filter(el => !check(el));
if (bad.length) {
bad[0].focus();
if (!reduce) gsap.fromTo(bad.map(b => b.closest('.kf-field')), { x: 0 }, { x: 0, keyframes: { x: [0, -8, 7, -4, 2, 0] }, duration: .45, ease: 'none', stagger: .04 });
return;
}
if (reduce) { showDone(); return; }
/* collapse the form: fields lift away, then the whole block wipes upward */
gsap.timeline({ onComplete: showDone })
.to(form.querySelectorAll('.kf-field, .kf-submit'), { y: -18, opacity: 0, duration: .35, ease: 'power2.in', stagger: .035 })
.to(form, { clipPath: 'inset(0 0 100% 0)', duration: .45, ease: KF.ease.io }, '-=.15');
});
again && again.addEventListener('click', () => {
form.reset(); tried = false;
fields.forEach(el => {
el.removeAttribute('aria-invalid');
el.closest('.kf-ctl')?.classList.remove('is-bad');
const err = document.getElementById(el.getAttribute('aria-describedby')); if (err) err.textContent = '';
});
gsap.set([form, ...form.querySelectorAll('.kf-field, .kf-submit')], { clearProps: 'clipPath,opacity,transform' });
done.hidden = true; form.hidden = false;
if (!reduce) gsap.from(form.querySelectorAll('.kf-field, .kf-submit'), { y: 24, opacity: 0, duration: .7, ease: KF.ease.soft, stagger: .05 });
form.querySelector('input')?.focus();
KF.refresh();
});
}
/* ---------- offices: live local time with flipping digits + open/closed ---------- */
const offices = [...root.querySelectorAll('.kf-office')].map(li => {
const tz = li.dataset.tz;
const digits = li.querySelector('[data-kf-digits]');
digits.textContent = '';
const slots = [...'00:00'].map(() => {
const s = document.createElement('span'); s.className = 'kf-dg';
const i = document.createElement('span'); s.appendChild(i); digits.appendChild(s);
return i;
});
bindRoll(li, li.querySelector('[data-kf-roll]'));
return {
li, tz, slots, prev: '',
time: li.querySelector('[data-kf-time]'),
sr: li.querySelector('[data-kf-tsr]'),
state: li.querySelector('[data-kf-state]'),
fmt: new Intl.DateTimeFormat('en-GB', { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
parts: new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short', hour: 'numeric', hourCycle: 'h23' })
};
});
const flip = (el, ch, i) => {
gsap.timeline()
.to(el, { yPercent: -110, opacity: 0, duration: .32, ease: 'power2.in', delay: i * .06 })
.add(() => { el.textContent = ch; })
.fromTo(el, { yPercent: 110, opacity: 0 }, { yPercent: 0, opacity: 1, duration: .55, ease: KF.ease.out });
};
const tick = () => {
const now = new Date();
offices.forEach(o => {
const p = Object.fromEntries(o.parts.formatToParts(now).map(x => [x.type, x.value]));
const h = parseInt(p.hour, 10) % 24;
const open = !['Sat', 'Sun'].includes(p.weekday) && h >= 9 && h < 19;
const str = o.fmt.format(now);
if (str !== o.prev) {
[...str].forEach((ch, i) => {
const el = o.slots[i]; if (!el || el.textContent === ch) return;
if (!o.prev || reduce) el.textContent = ch; else flip(el, ch, i);
});
o.prev = str;
o.sr.textContent = str;
}
o.time.setAttribute('datetime', now.toISOString());
o.state.textContent = open ? 'Open now' : 'Closed';
o.li.classList.toggle('is-open', open);
});
};
tick();
setInterval(tick, 5000);
/* ---------- footer: rolls, back to top, email wipe ---------- */
foot && foot.querySelectorAll('.kf-social a').forEach(a => bindRoll(a, a.querySelector('[data-kf-roll]')));
const topBtn = foot && foot.querySelector('[data-kf-top]');
if (topBtn) {
topBtn.addEventListener('click', () => {
if (KF.lenis) KF.lenis.scrollTo(0, { duration: 1.8, easing: t => 1 - Math.pow(1 - t, 4) });
else window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });
});
if (!reduce) {
KF.magnetic(topBtn, .4);
const arr = topBtn.querySelector('.kf-top-arr');
const spin = () => gsap.to(arr, { rotate: '+=360', duration: .9, ease: 'expo.inOut', overwrite: 'auto' });
topBtn.addEventListener('pointerenter', spin);
topBtn.addEventListener('focus', spin);
/* arrow leans slightly more as you near the bottom */
gsap.fromTo(arr.firstElementChild, { y: 6 }, { y: 0, ease: 'none', scrollTrigger: { trigger: foot, start: 'top bottom', end: 'bottom bottom', scrub: .5 } });
}
}
const fm = foot && foot.querySelector('[data-kf-fm]');
if (fm && !reduce) {
const fill = fm.querySelector('.kf-fm-fill');
const wipeIn = () => gsap.fromTo(fill, { clipPath: 'inset(0% 100% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: .75, ease: KF.ease.out, overwrite: true });
const wipeOut = () => gsap.to(fill, { clipPath: 'inset(0% 0% 0% 100%)', duration: .6, ease: KF.ease.io, overwrite: true });
fm.addEventListener('pointerenter', wipeIn); fm.addEventListener('focus', wipeIn);
fm.addEventListener('pointerleave', wipeOut); fm.addEventListener('blur', wipeOut);
}
/* ---------- wordmark: split to chars, fit to the full inner width ---------- */
const clip = foot && foot.querySelector('.kf-mark-clip');
const mark = foot && foot.querySelector('[data-kf-mark]');
const markChars = [];
if (mark) {
const t = mark.textContent.trim(); mark.textContent = '';
[...t].forEach(ch => {
const s = document.createElement('span');
s.className = 'kf-mc' + (ch.toLowerCase() === 'o' ? ' kf-mc--o' : '');
s.textContent = ch; mark.appendChild(s); markChars.push(s);
});
}
const fit = () => {
if (!clip || !mark) return;
const cs = getComputedStyle(clip);
const inner = clip.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
clip.style.setProperty('--kf-fs', '100px');
const w = mark.offsetWidth;
if (w) clip.style.setProperty('--kf-fs', (100 * inner / w * .995).toFixed(2) + 'px');
};
fit();
/* ---------- marquee: GSAP ticker loop, direction follows scroll ---------- */
const track = foot && foot.querySelector('[data-kf-marq]');
let marq = null;
if (track) {
const grp = track.firstElementChild;
const build = () => {
track.querySelectorAll('.kf-marq-grp + .kf-marq-grp').forEach(n => n.remove());
/* repeat inside the group until it covers the viewport, then clone the group once */
const base = grp.dataset.kfBase || (grp.dataset.kfBase = grp.innerHTML);
grp.innerHTML = base;
while (grp.offsetWidth < innerWidth + 40) grp.innerHTML += base;
track.appendChild(grp.cloneNode(true));
return grp.offsetWidth;
};
let gw = build();
if (!reduce) {
let x = 0, dir = -1, boost = 0;
const setX = gsap.quickSetter(track, 'x', 'px');
const wrapX = v => gsap.utils.wrap(-gw, 0, v);
const step = (time, dt) => {
x = wrapX(x + dir * (1 + boost) * .06 * Math.min(dt, 50));
boost *= .93;
setX(x);
};
let on = false;
const play = v => { if (v === on) return; on = v; v ? gsap.ticker.add(step) : gsap.ticker.remove(step); };
marq = ScrollTrigger.create({
trigger: track.parentElement, start: 'top bottom', end: 'bottom top',
onToggle: s => play(s.isActive),
onUpdate: s => { dir = s.direction > 0 ? -1 : 1; boost = Math.min(Math.abs(s.getVelocity()) / 220, 9); }
});
marq.rebuild = () => { gw = build(); x = wrapX(x); };
}
}
let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { fit(); marq && marq.rebuild && marq.rebuild(); KF.refresh(); }, 150); });
(document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(() => { fit(); marq && marq.rebuild && marq.rebuild(); KF.refresh(); });
if (reduce) { KF.refresh(); return; }
/* ================= motion ================= */
const panel = root.querySelector('[data-kf-panel]');
/* panel scales + rounds in as it enters (scrubbed) */
gsap.fromTo(panel, { scale: .88, borderRadius: 64 }, {
scale: 1, borderRadius: 28, ease: 'none',
scrollTrigger: { trigger: panel, start: 'top bottom', end: 'top 30%', scrub: .6 }
});
/* display line: chars rise in */
KF.reveal(root.querySelector('.kf-title'), { mode: 'chars', start: 'top 82%', stagger: .028 });
gsap.from(root.querySelector('.kf-label'), { y: 16, opacity: 0, duration: .8, ease: KF.ease.soft,
scrollTrigger: { trigger: panel, start: 'top 80%', once: true } });
gsap.from(root.querySelectorAll('.kf-intro, .kf-facts > div'), { y: 26, opacity: 0, duration: .9, ease: KF.ease.soft, stagger: .08,
scrollTrigger: { trigger: root.querySelector('.kf-aside'), start: 'top 85%', once: true } });
/* form fields: label, then underline draws */
if (form) {
const tl = gsap.timeline({ scrollTrigger: { trigger: form, start: 'top 85%', once: true } });
form.querySelectorAll('.kf-field').forEach((f, i) => {
const at = i * .09;
tl.from(f.querySelector('label'), { yPercent: 60, opacity: 0, duration: .7, ease: KF.ease.out }, at)
.from(f.querySelector('.kf-ctl'), { opacity: 0, duration: .6, ease: 'power1.out' }, at + .08)
.from(f.querySelector('.kf-ul'), { scaleX: 0, duration: 1, ease: KF.ease.out }, at + .16);
});
tl.from(form.querySelector('.kf-submit'), { y: 20, opacity: 0, duration: .8, ease: KF.ease.soft }, '-=.7');
}
/* orbs: slow idle drift + pointer parallax on desktop; paused offscreen */
const orbs = [...root.querySelectorAll('.kf-orb')];
const idle = orbs.map((o, i) => gsap.to(o.firstElementChild, {
x: gsap.utils.random(-30, 30), y: gsap.utils.random(-36, 36), scale: gsap.utils.random(.92, 1.08),
duration: gsap.utils.random(5, 8), ease: 'sine.inOut', yoyo: true, repeat: -1, delay: i * -1.3, paused: true
}));
ScrollTrigger.create({ trigger: panel, start: 'top bottom', end: 'bottom top', onToggle: s => idle.forEach(t => s.isActive ? t.play() : t.pause()) });
if (fine) {
const movers = orbs.map(o => ({ d: +o.dataset.kfDepth || 20,
x: gsap.quickTo(o, 'x', { duration: 1.4, ease: 'power3.out' }), y: gsap.quickTo(o, 'y', { duration: 1.4, ease: 'power3.out' }) }));
panel.addEventListener('pointermove', e => {
const r = panel.getBoundingClientRect();
const nx = (e.clientX - r.left) / r.width - .5, ny = (e.clientY - r.top) / r.height - .5;
movers.forEach(m => { m.x(nx * m.d * 2); m.y(ny * m.d * 2); });
});
panel.addEventListener('pointerleave', () => movers.forEach(m => { m.x(0); m.y(0); }));
}
/* offices: columns rise, rules draw */
const list = root.querySelector('.kf-list');
gsap.timeline({ scrollTrigger: { trigger: list, start: 'top 88%', once: true } })
.fromTo(list, { '--kf-rs': 0 }, { '--kf-rs': 1, duration: 1.2, ease: KF.ease.out })
.from(root.querySelectorAll('.kf-offices-head > *'), { y: 18, opacity: 0, duration: .8, ease: KF.ease.soft, stagger: .08 }, 0)
.from(offices.flatMap(o => [...o.li.children]), { y: 34, opacity: 0, duration: .9, ease: KF.ease.out, stagger: .025 }, .15)
.fromTo(offices.map(o => o.li), { '--kf-ls': 0 }, { '--kf-ls': 1, duration: 1.1, ease: KF.ease.io, stagger: .08 }, .25);
/* ---------- FOOTER ---------- */
if (foot) {
/* content slides up in a staggered, scrubbed wave as the page end arrives */
const rv = [...foot.querySelectorAll('[data-kf-rv]')];
const ftl = gsap.timeline({ scrollTrigger: { trigger: foot, start: 'top bottom', end: 'bottom bottom', scrub: .8 } });
ftl.fromTo(rv, { y: 110, opacity: 0 }, { y: 0, opacity: 1, ease: 'power3.out', duration: .5, stagger: .07 }, 0);
/* wordmark: each char rises on its own speed, like a wave; the rose o's turn */
if (markChars.length) {
const n = markChars.length;
const wtl = gsap.timeline({ scrollTrigger: { trigger: foot, start: 'top 85%', end: 'bottom bottom', scrub: 1 } });
markChars.forEach((c, i) => {
const wave = Math.sin(i / (n - 1) * Math.PI); /* middle chars lag a little */
const start = i * .06 + wave * .12;
const dur = .55 + ((i * 37) % 5) * .07; /* uneven speeds */
wtl.fromTo(c, { yPercent: 108 }, { yPercent: 0, ease: 'power3.out', duration: dur }, start);
if (c.classList.contains('kf-mc--o')) wtl.fromTo(c, { rotate: i % 2 ? 40 : -40 }, { rotate: i % 2 ? -8 : 8, ease: 'power2.out', duration: dur + .3 }, start);
});
/* pointer: chars lean + swell toward the cursor (one quickTo per prop per char) */
if (fine) {
const q = markChars.map(c => ({ c, cx: 0,
x: gsap.quickTo(c, 'x', { duration: .7, ease: 'power3.out' }),
sk: gsap.quickTo(c, 'skewX', { duration: .7, ease: 'power3.out' }),
s: gsap.quickTo(c, 'scale', { duration: .7, ease: 'power3.out' }) }));
let cw = 1;
const measure = () => { q.forEach(o => { const r = o.c.getBoundingClientRect(); o.cx = r.left + r.width / 2; }); cw = (clip.clientWidth || 1000) / n; };
foot.addEventListener('pointerenter', measure);
foot.addEventListener('pointermove', e => {
q.forEach(o => {
const dx = e.clientX - o.cx, f = Math.exp(-(dx * dx) / (2 * (cw * 1.4) ** 2));
o.x(gsap.utils.clamp(-14, 14, dx * .05));
o.sk(gsap.utils.clamp(-10, 10, -dx / cw * 4) * (1 - f * .4));
o.s(1 + f * .07);
});
});
foot.addEventListener('pointerleave', () => q.forEach(o => { o.x(0); o.sk(0); o.s(1); }));
}
}
}
KF.refresh();
});
</script>
```
## `template.html`
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Kinoform Pictures — Commercials, Film & Branded Work</title>
<meta name="description" content="Kinoform Pictures is an independent production company making commercials, short film and branded work from studios in Lisbon, Seoul, Mexico City, Berlin and Toronto.">
<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=Archivo:wdth,[email protected],400..900&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
<style>
/* ---------- KINOFORM locked palette ---------- */
:root{
--bg:#F3F2F7; /* cool fog page */
--paper:#FFFFFF; /* cards / panels */
--rose:#D11E6B; /* primary (white text OK) */
--rose-deep:#A3124F; /* primary hover */
--rose-soft:#F8D9E6; /* primary tint */
--sky:#7CC7FF; /* accent (ink text only) */
--sky-deep:#4FAEF2; /* accent hover */
--sky-soft:#DDF0FF;
--ink:#1B1530; /* text only — never a page/section/card background */
--mute:#625C78; /* secondary text */
--line:#DCD9E6; /* borders */
--shadow:0 30px 60px -32px rgba(27,21,48,.45);
--shadow-rose:0 24px 50px -24px rgba(209,30,107,.55);
--display:'Archivo', system-ui, sans-serif; /* use font-stretch:125% for the wide display cut */
--mono:'Geist Mono', ui-monospace, monospace;
--gutter:clamp(16px, 4vw, 56px);
--ease:cubic-bezier(.2,.7,.1,1);
}
*,*::before,*::after{box-sizing:border-box}
html.lenis,html.lenis body{height:auto}
.lenis.lenis-smooth{scroll-behavior:auto !important}
.lenis.lenis-stopped{overflow:hidden}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--display);font-size:16px;line-height:1.55;overflow-x:hidden;-webkit-font-smoothing:antialiased}
img{display:block;max-width:100%}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
/* ---------- shared atoms ---------- */
.wrap{max-width:1440px;margin-inline:auto;padding-inline:var(--gutter)}
.sec{padding-block:clamp(80px, 11vw, 160px)}
.label{font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.label--rose{color:var(--rose)}
.display{font-family:var(--display);font-stretch:125%;font-weight:800;line-height:.92;letter-spacing:-.035em;margin:0}
.display em{font-style:normal;color:var(--rose)}
.lede{font-size:clamp(16px,1.25vw,19px);color:var(--mute);max-width:50ch}
.btn{display:inline-flex;align-items:center;gap:.6em;padding:14px 24px;border-radius:999px;font-weight:700;font-size:15px;
background:var(--rose);color:#fff;transition:background .25s var(--ease),transform .25s var(--ease)}
.btn:hover{background:var(--rose-deep);transform:translateY(-2px)}
.btn--sky{background:var(--sky);color:var(--ink)}
.btn--sky:hover{background:var(--sky-deep)}
.btn--ghost{background:transparent;color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--ink)}
.btn--ghost:hover{background:var(--rose);color:#fff;box-shadow:none}
.card{background:var(--paper);border-radius:18px;box-shadow:var(--shadow)}
:focus-visible{outline:2px solid var(--rose);outline-offset:3px}
/* KF.split masks */
.kf-m{display:inline-block;overflow:hidden;vertical-align:top;padding-bottom:.08em;margin-bottom:-.08em}
.kf-i{display:inline-block;will-change:transform}
.kf-w{display:inline-block;white-space:nowrap}
</style>
</head>
<body>
<script>
/* shared runtime: smooth scroll + helpers every section can use */
(function(){
gsap.registerPlugin(ScrollTrigger);
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const KF = window.KF = { reduce };
if(!reduce && window.Lenis){
const lenis = KF.lenis = new Lenis({ lerp:.1 });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(t => lenis.raf(t*1000));
gsap.ticker.lagSmoothing(0);
}
let rt; KF.refresh = () => { clearTimeout(rt); rt = setTimeout(() => ScrollTrigger.refresh(), 120); };
KF.later = fn => (window.requestIdleCallback || (f => setTimeout(f, 60)))(fn);
/* shared motion language: expo.out / power3.out, 0.9–1.2s, 0.04–0.08 stagger */
KF.ease = { out:'expo.out', soft:'power3.out', io:'power2.inOut' };
/* KF.split(el, 'chars'|'words'|'lines') -> array of inner spans, each wrapped in an overflow mask.
Keeps <em>/<br> structure, sets aria-label so screen readers read the original text once. */
KF.split = function(el, mode){
mode = mode || 'words';
if(el._kfSplit) return el._kfSplit;
if(!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.textContent.replace(/\s+/g,' ').trim());
const out = [];
const mk = (txt, parent) => {
const m = document.createElement('span'); m.className = 'kf-m'; m.setAttribute('aria-hidden','true');
const i = document.createElement('span'); i.className = 'kf-i'; i.textContent = txt;
m.appendChild(i); parent.appendChild(m); out.push(i);
};
const walk = (node, parent) => {
[...node.childNodes].forEach(n => {
if(n.nodeType === 3){
const parts = n.textContent.split(/(\s+)/);
parts.forEach(p => {
if(!p) return;
if(/^\s+$/.test(p)){ parent.appendChild(document.createTextNode(' ')); return; }
if(mode === 'chars'){
const w = document.createElement('span'); w.className = 'kf-w'; w.setAttribute('aria-hidden','true');
[...p].forEach(ch => mk(ch, w)); parent.appendChild(w);
} else mk(p, parent);
});
} else if(n.nodeType === 1){
const c = n.cloneNode(false); parent.appendChild(c);
if(n.tagName !== 'BR') walk(n, c);
}
});
};
const frag = document.createDocumentFragment();
walk(el, frag); el.textContent = ''; el.appendChild(frag);
return (el._kfSplit = out);
};
/* KF.reveal(el, opts): split + rise-in when scrolled into view (once). Returns the tween. */
KF.reveal = function(el, o){
o = Object.assign({ mode:'words', start:'top 85%', stagger:null, delay:0 }, o);
const parts = KF.split(el, o.mode);
if(reduce) return null;
gsap.set(parts, { yPercent:115, rotate: o.mode === 'chars' ? 6 : 3 });
return gsap.to(parts, { yPercent:0, rotate:0, duration:1.1, ease:KF.ease.out, delay:o.delay,
stagger: o.stagger != null ? o.stagger : (o.mode === 'chars' ? .03 : .06),
scrollTrigger:{ trigger:el, start:o.start, once:true } });
};
/* KF.magnetic(el, strength): element leans toward the pointer, springs back on leave (mouse only). */
KF.magnetic = function(el, k){
k = k || .35;
if(reduce || !matchMedia('(hover:hover) and (pointer:fine)').matches) return;
const xT = gsap.quickTo(el, 'x', { duration:.6, ease:'power3.out' });
const yT = gsap.quickTo(el, 'y', { duration:.6, ease:'power3.out' });
el.addEventListener('pointermove', e => {
const r = el.getBoundingClientRect();
xT((e.clientX - r.left - r.width/2) * k); yT((e.clientY - r.top - r.height/2) * k);
});
el.addEventListener('pointerleave', () => { gsap.to(el, { x:0, y:0, duration:.9, ease:'elastic.out(1,.4)' }); });
};
/* every .btn on the page is magnetic */
addEventListener('DOMContentLoaded', () => document.querySelectorAll('.btn').forEach(b => KF.magnetic(b, .25)));
addEventListener('load', KF.refresh);
})();
</script>
<!-- @include sections/01-hero.html -->
<!-- @include sections/02-reel.html -->
<!-- @include sections/03-canvas.html -->
<!-- @include sections/04-directors.html -->
<!-- @include sections/05-contact.html -->
</body>
</html>
```