Halcyon. Hospitality landing page.

A warm, editorial luxury-hospitality homepage. A pinned hero paints a layered procedural Amalfi landscape (sky, sun, sea, a cliff village of 120 houses, mist and bougainvillea) that parallaxes by depth and shifts from golden hour to dusk as you scroll, with a live clock and village lights coming on. A booking bar floats at the bottom of the first screen and docks under the condensing nav; then clip-path image reveals, a pinned horizontal gallery of four houses, hover-zoom room cards, an interactive rituals list, a crossfading guest book, illustrated maps whose coastlines draw in and pins drop, and a newsletter footer. Large serif display, sand / ink / terracotta, no images or web fonts.

  • Time-of-day hero
  • Layered parallax
  • Docking booking bar
  • Clip-path reveals
  • Pinned horizontal gallery
  • Testimonial crossfade
  • Map pins draw in
Full screen

The preview is interactive — scroll inside it to run the animations.

The whole page. One file, nothing left out.

1,538 lines · 100.4 KB · imports React only. This is the exact file behind the preview above — every section, interaction and style.

  1. 01Save it as halcyon-hotels.tsx in any React 18+ project.
  2. 02Render <Page /> on a route of its own — it brings its own nav and footer.
  3. 03Swap the copy and tokens. Keep the DESIGN.md next to it so your AI agent follows the system.
halcyon-hotels.tsx
1/**
2 * Halcyon — boutique hotels on the Amalfi coast and in Kyoto.
3 *
4 * A warm, editorial luxury-hospitality homepage. Everything you see is
5 * procedural (SVG + CSS gradients) — no external images or fonts.
6 *
7 * Motion (all scroll effects are transform/opacity driven by one rAF loop
8 * that writes a `--p` progress variable onto `[data-hal-p]` elements):
9 * - Pinned hero: layered SVG landscape with depth parallax and a
10 * time-of-day shift (golden hour → dusk, sun sets, village lights on).
11 * - Booking bar: sticky at the bottom of the first screen, then docks
12 * under the condensed nav once you scroll past the hero.
13 * - Clip-path image reveals, a pinned horizontal "houses" gallery,
14 * hover-zoom room cards, an editorial rituals list with crossfading
15 * frame, an auto-advancing testimonial crossfade, map pins that drop
16 * in with routes drawing, and a newsletter form in the footer.
17 *
18 * Reduced motion: parallax, pinning and autoplay are removed; the gallery
19 * becomes a native swipe row; state changes stay instant.
20 * SSR-safe: no window/document access during render.
21 * Accessibility: real buttons/inputs/labels, visible focus rings,
22 * aria-live for the booking result and testimonial, AA contrast.
23 */
24import { memo, useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
25
26/* ------------------------------------------------------------------ */
27/* Deterministic pseudo-random (module scope is pure: SSR == client) */
28/* ------------------------------------------------------------------ */
29function rng(seed: number) {
30 let a = seed >>> 0;
31 return () => {
32 a = (a + 0x6d2b79f5) >>> 0;
33 let t = a;
34 t = Math.imul(t ^ (t >>> 15), t | 1);
35 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
36 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
37 };
38}
39
40type House = { x: number; y: number; w: number; h: number; c: string; win: number[]; lit: boolean };
41const HOUSE_COLORS = ["#F4E7D3", "#EBCFA8", "#DE9A6C", "#F7F0E6", "#CF7F5C", "#E8DCC4", "#F0C48E"];
42
43/** Houses packed under a cliff edge. `edge(y)` returns the x-limit of the cliff at y. */
44function village(seed: number, n: number, yMin: number, yMax: number, edge: (y: number) => [number, number]): House[] {
45 const r = rng(seed);
46 const out: House[] = [];
47 for (let i = 0; i < n; i++) {
48 const y = yMin + r() * (yMax - yMin);
49 const [x0, x1] = edge(y);
50 const w = 18 + r() * 34;
51 const h = 14 + r() * 22;
52 if (x1 - x0 < w + 6) continue;
53 const x = x0 + r() * (x1 - x0 - w);
54 const win: number[] = [];
55 const nw = 1 + Math.floor(r() * 3);
56 for (let k = 0; k < nw; k++) win.push(0.18 + (k / nw) * 0.7 + r() * 0.06);
57 out.push({ x, y, w, h, c: HOUSE_COLORS[Math.floor(r() * HOUSE_COLORS.length)], win, lit: r() > 0.45 });
58 }
59 return out.sort((a, b) => a.y - b.y);
60}
61
62const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
63function edgeLerp(pts: [number, number][], y: number) {
64 for (let i = 0; i < pts.length - 1; i++) {
65 const [xa, ya] = pts[i];
66 const [xb, yb] = pts[i + 1];
67 if (y >= ya && y <= yb) return lerp(xa, xb, (y - ya) / (yb - ya));
68 }
69 return pts[pts.length - 1][0];
70}
71
72// Hero cliff (viewBox 1600×1000): cliff occupies the right side.
73const HERO_EDGE: [number, number][] = [[1600, 250], [1420, 300], [1300, 380], [1210, 470], [1130, 560], [1060, 640], [1010, 720]];
74const HERO_HOUSES = village(7, 120, 330, 700, (y) => [edgeLerp(HERO_EDGE, y) + 18, 1600]);
75// Positano scene (viewBox 800×1000): cliff on the left.
76const POS_EDGE: [number, number][] = [[0, 250], [200, 330], [350, 490], [500, 700], [590, 860], [640, 1000]];
77const POS_HOUSES = village(19, 110, 300, 900, (y) => [0, edgeLerp(POS_EDGE, y) - 22]);
78
79/* ------------------------------------------------------------------ */
80/* Procedural photography */
81/* ------------------------------------------------------------------ */
82type SceneKind = "positano" | "ravello" | "gion" | "arashiyama" | "roomSea" | "roomKyoto" | "pool" | "boat" | "tea" | "lantern";
83
84function useSvgId() {
85 return "hal" + useId().replace(/[^a-zA-Z0-9_-]/g, "");
86}
87
88const Scene = memo(function Scene({ kind, label }: { kind: SceneKind; label: string }) {
89 const id = useSvgId();
90 const u = (n: string) => `url(#${id}-${n})`;
91 const d = (n: string) => `${id}-${n}`;
92 let body: ReactNode = null;
93
94 if (kind === "positano") {
95 body = (
96 <>
97 <defs>
98 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#E3AD82" /><stop offset=".5" stopColor="#F1CDA5" /><stop offset="1" stopColor="#F7E5CB" /></linearGradient>
99 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#93A9A6" /><stop offset=".45" stopColor="#4F7581" /><stop offset="1" stopColor="#2C4E5B" /></linearGradient>
100 <linearGradient id={d("cliff")} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#A87050" /><stop offset="1" stopColor="#5E3B2B" /></linearGradient>
101 <radialGradient id={d("sun")}><stop offset="0" stopColor="#FFF3DD" /><stop offset=".35" stopColor="#FBE0B8" stopOpacity=".9" /><stop offset="1" stopColor="#FBE0B8" stopOpacity="0" /></radialGradient>
102 </defs>
103 <rect width="800" height="560" fill={u("sky")} />
104 <circle cx="590" cy="350" r="190" fill={u("sun")} />
105 <circle cx="590" cy="350" r="44" fill="#FFF2DC" />
106 <path d="M300 528 C 380 506 460 498 540 510 S 700 520 800 506 L800 560 L300 560Z" fill="#C59E8F" opacity=".75" />
107 <rect y="550" width="800" height="450" fill={u("sea")} />
108 {[0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
109 <rect key={i} x={540 + ((i * 37) % 90) - 30} y={575 + i * 34} width={60 + ((i * 53) % 80)} height="2.5" rx="1" fill="#FFF1DA" opacity={0.55 - i * 0.05} />
110 ))}
111 <path d="M0 250 C 90 262 150 300 200 330 C 270 380 320 440 350 490 C 410 580 460 640 500 700 C 540 770 570 820 590 860 C 610 910 630 960 640 1000 L0 1000Z" fill={u("cliff")} />
112 {POS_HOUSES.map((h, i) => (
113 <g key={i}>
114 <rect x={h.x} y={h.y} width={h.w} height={h.h} fill={h.c} />
115 <rect x={h.x} y={h.y + h.h} width={h.w} height="3" fill="#000" opacity=".12" />
116 {h.win.map((wx, k) => (
117 <rect key={k} x={h.x + wx * h.w} y={h.y + h.h * 0.35} width="3.5" height="6" fill="#4A3426" opacity=".65" />
118 ))}
119 </g>
120 ))}
121 {[[60, 420, 46], [180, 560, 38], [40, 700, 60], [300, 760, 44], [120, 880, 70], [420, 900, 50]].map(([x, y, r], i) => (
122 <ellipse key={i} cx={x} cy={y} rx={r} ry={r * 0.55} fill="#4E5A37" opacity=".85" />
123 ))}
124 <g transform="translate(230 600)">
125 <rect x="-26" y="0" width="52" height="46" fill="#F3E6D2" />
126 <path d="M-24 0 A24 26 0 0 1 24 0Z" fill="#3D7774" />
127 <path d="M-16 -2 A 16 22 0 0 1 0 -24 M 16 -2 A 16 22 0 0 0 0 -24" stroke="#E2B95A" strokeWidth="2" fill="none" />
128 <rect x="-1" y="-36" width="2" height="12" fill="#5B4636" />
129 </g>
130 <path d="M800 0 L800 210 C 760 200 740 160 700 150 C 660 140 650 100 610 90 C 580 80 570 40 560 0Z" fill="#2E3826" />
131 {[[700, 120], [735, 160], [760, 90], [655, 80], [780, 175], [720, 60]].map(([x, y], i) => (
132 <circle key={i} cx={x} cy={y} r={9 + (i % 3) * 3} fill="#B94B6B" opacity=".85" />
133 ))}
134 </>
135 );
136 } else if (kind === "ravello") {
137 body = (
138 <>
139 <defs>
140 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#B9CBD0" /><stop offset=".6" stopColor="#E3E3D6" /><stop offset="1" stopColor="#F1E6D2" /></linearGradient>
141 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#7E9EA6" /><stop offset="1" stopColor="#3F6772" /></linearGradient>
142 <radialGradient id={d("lemon")} cx=".35" cy=".35"><stop offset="0" stopColor="#FCEB8A" /><stop offset=".6" stopColor="#EFC93A" /><stop offset="1" stopColor="#C99A1E" /></radialGradient>
143 <linearGradient id={d("floor")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#C98B65" /><stop offset="1" stopColor="#9E6142" /></linearGradient>
144 </defs>
145 <rect width="800" height="1000" fill={u("sky")} />
146 <rect y="430" width="800" height="270" fill={u("sea")} />
147 <path d="M0 470 C 120 450 210 430 330 445 C 420 455 470 470 560 462 L560 480 L0 520Z" fill="#8E9A86" opacity=".7" />
148 <rect x="0" y="690" width="800" height="310" fill={u("floor")} />
149 {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
150 <line key={i} x1={i * 115} y1="690" x2={i * 160 - 160} y2="1000" stroke="#7F4A31" strokeWidth="1.5" opacity=".45" />
151 ))}
152 <rect x="0" y="640" width="800" height="54" fill="#EDE3D3" />
153 <rect x="0" y="636" width="800" height="8" fill="#D8CCB8" />
154 {[90, 270, 450, 630].map((x) => (
155 <rect key={x} x={x} y="120" width="26" height="520" fill="#E9DFCD" />
156 ))}
157 <rect x="0" y="96" width="800" height="30" fill="#DCCFB9" />
158 {Array.from({ length: 22 }).map((_, i) => (
159 <ellipse key={i} cx={(i * 97) % 820} cy={40 + ((i * 53) % 120)} rx="62" ry="22" transform={`rotate(${(i * 41) % 60 - 30} ${(i * 97) % 820} ${40 + ((i * 53) % 120)})`} fill={i % 2 ? "#4F6A35" : "#3D5729"} />
160 ))}
161 {Array.from({ length: 13 }).map((_, i) => {
162 const x = 40 + ((i * 131) % 740);
163 const y = 150 + ((i * 71) % 130);
164 return (
165 <g key={i}>
166 <line x1={x} y1={y - 40} x2={x} y2={y - 14} stroke="#3D5729" strokeWidth="2" />
167 <ellipse cx={x} cy={y} rx="17" ry="22" fill={u("lemon")} />
168 </g>
169 );
170 })}
171 </>
172 );
173 } else if (kind === "gion" || kind === "lantern") {
174 const dusk = kind === "lantern";
175 body = (
176 <>
177 <defs>
178 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1">
179 <stop offset="0" stopColor={dusk ? "#22223A" : "#3B3854"} />
180 <stop offset=".55" stopColor={dusk ? "#5C3E55" : "#8E6C7E"} />
181 <stop offset="1" stopColor={dusk ? "#B26B5A" : "#D9A48A"} />
182 </linearGradient>
183 <linearGradient id={d("street")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#4A3B3A" /><stop offset="1" stopColor="#1E1718" /></linearGradient>
184 <radialGradient id={d("glow")}><stop offset="0" stopColor="#FFD596" stopOpacity=".9" /><stop offset="1" stopColor="#FFB45E" stopOpacity="0" /></radialGradient>
185 <pattern id={d("slat")} width="9" height="20" patternUnits="userSpaceOnUse"><rect width="9" height="20" fill="#3B2A22" /><rect width="3" height="20" fill="#2A1D17" /></pattern>
186 </defs>
187 <rect width="800" height="1000" fill={u("sky")} />
188 <g fill="#2B2230" opacity=".85">
189 <rect x="384" y="190" width="32" height="420" />
190 {[0, 1, 2, 3, 4].map((i) => (
191 <path key={i} d={`M${330 + i * 10} ${270 + i * 70} L${470 - i * 10} ${270 + i * 70} L${440 - i * 10} ${250 + i * 70} L${360 + i * 10} ${250 + i * 70}Z`} />
192 ))}
193 <rect x="398" y="140" width="4" height="60" />
194 </g>
195 <path d="M0 520 L250 600 L250 1000 L0 1000Z" fill={u("slat")} />
196 <path d="M0 470 L280 560 L280 590 L0 500Z" fill="#1F1A1C" />
197 <path d="M800 520 L550 600 L550 1000 L800 1000Z" fill={u("slat")} />
198 <path d="M800 470 L520 560 L520 590 L800 500Z" fill="#1F1A1C" />
199 <path d="M250 600 L550 600 L800 1000 L0 1000Z" fill={u("street")} />
200 {[0, 1, 2, 3, 4, 5].map((i) => (
201 <line key={i} x1={400} y1={600} x2={-200 + i * 240} y2={1000} stroke="#5E4A45" strokeWidth="1" opacity=".5" />
202 ))}
203 {[[120, 610, 1], [210, 640, 0.75], [690, 610, 1], [590, 640, 0.75], [300, 600, 0.5], [500, 600, 0.5]].map(([x, y, s], i) => (
204 <g key={i}>
205 <circle cx={x} cy={y} r={110 * s} fill={u("glow")} />
206 <rect x={x - 22 * s} y={y - 34 * s} width={44 * s} height={68 * s} rx={20 * s} fill="#F6CE93" />
207 <rect x={x - 22 * s} y={y - 6 * s} width={44 * s} height={3} fill="#C9823F" opacity=".7" />
208 <ellipse cx={x} cy={y + 360 * s * 0.6} rx={18 * s} ry={60 * s} fill="#F6CE93" opacity=".18" />
209 </g>
210 ))}
211 {dusk && <circle cx="620" cy="160" r="26" fill="#F6E7CF" opacity=".9" />}
212 </>
213 );
214 } else if (kind === "arashiyama") {
215 body = (
216 <>
217 <defs>
218 <linearGradient id={d("bg")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#E7EAD2" /><stop offset=".6" stopColor="#B7C597" /><stop offset="1" stopColor="#6E8350" /></linearGradient>
219 <linearGradient id={d("stalk")} x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#4F6B33" /><stop offset=".45" stopColor="#9FB872" /><stop offset="1" stopColor="#4A6430" /></linearGradient>
220 <linearGradient id={d("ray")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#FFFBEA" stopOpacity=".7" /><stop offset="1" stopColor="#FFFBEA" stopOpacity="0" /></linearGradient>
221 </defs>
222 <rect width="800" height="1000" fill={u("bg")} />
223 {Array.from({ length: 16 }).map((_, i) => {
224 const x = (i * 157) % 860 - 30;
225 const w = 22 + ((i * 29) % 34);
226 const far = i % 3 === 0;
227 return (
228 <g key={i} opacity={far ? 0.45 : 1}>
229 <rect x={x} y="-20" width={w} height="1040" fill={u("stalk")} />
230 {[180, 420, 660, 880].map((y, k) => (
231 <rect key={k} x={x - 2} y={y + ((i * 37) % 70)} width={w + 4} height="5" rx="2" fill="#3E5528" opacity=".7" />
232 ))}
233 </g>
234 );
235 })}
236 <path d="M330 0 L470 0 L620 1000 L180 1000Z" fill={u("ray")} opacity=".5" />
237 <path d="M0 920 C 200 880 600 900 800 870 L800 1000 L0 1000Z" fill="#59683D" />
238 </>
239 );
240 } else if (kind === "roomSea") {
241 body = (
242 <>
243 <defs>
244 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#A9C3CA" /><stop offset="1" stopColor="#EAE2CF" /></linearGradient>
245 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5D8792" /><stop offset="1" stopColor="#335A66" /></linearGradient>
246 <linearGradient id={d("wall")} x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="#E9DFD0" /><stop offset=".5" stopColor="#F4EDE2" /><stop offset="1" stopColor="#E4D8C6" /></linearGradient>
247 <linearGradient id={d("floor")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#C88158" /><stop offset="1" stopColor="#A35F3E" /></linearGradient>
248 <linearGradient id={d("linen")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#FFFDF8" /><stop offset="1" stopColor="#E6DDCF" /></linearGradient>
249 <clipPath id={d("arch")}><path d="M250 640 L250 280 A150 150 0 0 1 550 280 L550 640Z" /></clipPath>
250 </defs>
251 <rect width="800" height="1000" fill={u("wall")} />
252 <g clipPath={u("arch")}>
253 <rect x="240" y="120" width="320" height="330" fill={u("sky")} />
254 <rect x="240" y="440" width="320" height="220" fill={u("sea")} />
255 <path d="M240 450 C 300 420 360 430 420 444 L420 452 L240 460Z" fill="#7F8E7D" />
256 <circle cx="470" cy="300" r="30" fill="#FFF6E4" opacity=".85" />
257 </g>
258 <path d="M250 640 L250 280 A150 150 0 0 1 550 280 L550 640" fill="none" stroke="#D8CCBA" strokeWidth="14" />
259 <rect y="760" width="800" height="240" fill={u("floor")} />
260 {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => (
261 <line key={i} x1={i * 90 - 20} y1="760" x2={i * 150 - 280} y2="1000" stroke="#86492D" strokeWidth="1.2" opacity=".5" />
262 ))}
263 {[800, 860, 930].map((y) => (
264 <line key={y} x1="0" y1={y} x2="800" y2={y} stroke="#86492D" strokeWidth="1.2" opacity=".4" />
265 ))}
266 <ellipse cx="400" cy="900" rx="330" ry="40" fill="#5A3322" opacity=".25" />
267 <rect x="90" y="700" width="620" height="170" rx="18" fill={u("linen")} />
268 <path d="M90 760 C 260 730 520 790 710 750 L710 870 L90 870Z" fill="#D9CDB9" opacity=".7" />
269 <rect x="120" y="660" width="170" height="70" rx="26" fill="#FBF7EF" />
270 <rect x="300" y="664" width="170" height="66" rx="26" fill="#F5EFE4" />
271 <path d="M480 780 C 560 740 640 760 710 740 L710 800 C 640 820 560 810 480 830Z" fill="#B4532A" opacity=".85" />
272 <g transform="translate(660 560)">
273 <path d="M-22 0 C -30 40 -26 80 0 90 C 26 80 30 40 22 0Z" fill="#E7DCC9" />
274 <path d="M0 0 C -10 -50 -40 -80 -60 -110 M0 0 C 6 -60 20 -90 40 -130" stroke="#6E6B45" strokeWidth="3" fill="none" />
275 </g>
276 </>
277 );
278 } else if (kind === "roomKyoto") {
279 body = (
280 <>
281 <defs>
282 <linearGradient id={d("garden")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#B9C49A" /><stop offset="1" stopColor="#5E7443" /></linearGradient>
283 <pattern id={d("shoji")} width="60" height="70" patternUnits="userSpaceOnUse"><rect width="60" height="70" fill="#F5EFE2" /><rect width="60" height="3" fill="#B39C7C" /><rect width="3" height="70" fill="#B39C7C" /></pattern>
284 <linearGradient id={d("tatami")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#D8CB97" /><stop offset="1" stopColor="#B4A46C" /></linearGradient>
285 </defs>
286 <rect width="800" height="1000" fill="#E7DDCA" />
287 <rect x="0" y="120" width="800" height="520" fill={u("garden")} />
288 <circle cx="560" cy="250" r="110" fill="#B4442E" opacity=".9" />
289 <circle cx="640" cy="320" r="80" fill="#C9583A" opacity=".85" />
290 <circle cx="480" cy="330" r="70" fill="#9E3826" opacity=".8" />
291 <rect x="590" y="320" width="10" height="220" fill="#3F2C22" />
292 <ellipse cx="300" cy="580" rx="140" ry="22" fill="#8C9579" />
293 <rect x="0" y="120" width="230" height="520" fill={u("shoji")} />
294 <rect x="0" y="100" width="800" height="24" fill="#5C4334" />
295 <rect x="0" y="636" width="800" height="18" fill="#5C4334" />
296 <rect x="390" y="120" width="10" height="520" fill="#5C4334" />
297 <path d="M0 654 L800 654 L800 1000 L0 1000Z" fill={u("tatami")} />
298 <line x1="0" y1="800" x2="800" y2="800" stroke="#4E3F2B" strokeWidth="5" />
299 <line x1="400" y1="654" x2="400" y2="800" stroke="#4E3F2B" strokeWidth="5" />
300 <rect x="250" y="760" width="300" height="34" rx="4" fill="#2E211B" />
301 <rect x="270" y="794" width="12" height="60" fill="#2E211B" />
302 <rect x="518" y="794" width="12" height="60" fill="#2E211B" />
303 <ellipse cx="400" cy="900" rx="220" ry="18" fill="#6B5A35" opacity=".3" />
304 <circle cx="360" cy="752" r="14" fill="#E9E1D3" />
305 <circle cx="430" cy="750" r="10" fill="#7C8B5A" />
306 </>
307 );
308 } else if (kind === "pool") {
309 body = (
310 <>
311 <defs>
312 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#9DBBC6" /><stop offset="1" stopColor="#F2E2C8" /></linearGradient>
313 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#6E95A0" /><stop offset="1" stopColor="#3A6370" /></linearGradient>
314 <linearGradient id={d("pool")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#7FC2C4" /><stop offset="1" stopColor="#2F8592" /></linearGradient>
315 <linearGradient id={d("deck")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#E8DCC7" /><stop offset="1" stopColor="#CDBDA3" /></linearGradient>
316 </defs>
317 <rect width="800" height="480" fill={u("sky")} />
318 <rect y="470" width="800" height="200" fill={u("sea")} />
319 <path d="M0 475 C 100 455 180 440 260 452 C 320 460 360 474 420 472 L420 480 L0 482Z" fill="#8C9A8E" opacity=".7" />
320 <rect y="600" width="800" height="400" fill={u("deck")} />
321 <path d="M-20 640 L820 600 L820 820 L-20 860Z" fill={u("pool")} />
322 <path d="M-20 640 L820 600" stroke="#F7F1E6" strokeWidth="6" />
323 {[0, 1, 2, 3, 4].map((i) => (
324 <path key={i} d={`M${60 + i * 150} ${700 + (i % 2) * 40} q 30 -8 60 0 t 60 0`} stroke="#E8FBFA" strokeWidth="2" fill="none" opacity=".55" />
325 ))}
326 {[160, 460].map((x) => (
327 <g key={x} transform={`translate(${x} 900)`}>
328 <path d="M0 0 L190 -10 L210 -60 L200 -64 L184 -22 L0 -12Z" fill="#F7F2EA" />
329 <path d="M0 0 L190 -10 L190 10 L0 20Z" fill="#B9A88E" />
330 </g>
331 ))}
332 <g transform="translate(720 820)">
333 <rect x="-40" y="0" width="80" height="90" rx="6" fill="#B4532A" />
334 <rect x="-4" y="-120" width="8" height="120" fill="#4E3A2A" />
335 <circle cx="0" cy="-150" r="70" fill="#4F6A35" />
336 {[[-30, -170], [20, -140], [-10, -120], [35, -185]].map(([x, y], i) => (
337 <ellipse key={i} cx={x} cy={y} rx="9" ry="12" fill="#EFC93A" />
338 ))}
339 </g>
340 </>
341 );
342 } else if (kind === "boat") {
343 body = (
344 <>
345 <defs>
346 <linearGradient id={d("sky")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#C8C3D3" /><stop offset=".6" stopColor="#EED3C0" /><stop offset="1" stopColor="#F7E7D2" /></linearGradient>
347 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#A8B6BA" /><stop offset=".5" stopColor="#5D828D" /><stop offset="1" stopColor="#2E5361" /></linearGradient>
348 </defs>
349 <rect width="800" height="560" fill={u("sky")} />
350 <circle cx="200" cy="470" r="34" fill="#FFF0DA" />
351 <path d="M430 548 C 470 500 520 470 560 486 C 590 498 600 530 640 548Z" fill="#8E8D96" />
352 <path d="M620 552 C 650 530 690 524 720 540 L 740 552Z" fill="#9C99A1" />
353 <rect y="548" width="800" height="452" fill={u("sea")} />
354 {[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
355 <rect key={i} x={150 + ((i * 41) % 80)} y={570 + i * 40} width={70 + ((i * 29) % 60)} height="2.5" fill="#FFF1DA" opacity={0.5 - i * 0.05} />
356 ))}
357 <g transform="translate(390 760)">
358 <path d="M-210 0 C -150 40 150 40 220 -10 L 200 30 C 120 70 -130 70 -190 30Z" fill="#F4EEE3" />
359 <path d="M-190 30 C -130 70 120 70 200 30 L 190 44 C 110 84 -120 84 -180 44Z" fill="#2F5866" />
360 <rect x="-60" y="-50" width="110" height="46" rx="6" fill="#E5D8C2" />
361 <rect x="-50" y="-70" width="90" height="22" rx="4" fill="#B4532A" />
362 <rect x="-4" y="-150" width="5" height="100" fill="#5A4636" />
363 <ellipse cx="0" cy="90" rx="230" ry="16" fill="#1E3A44" opacity=".35" />
364 </g>
365 </>
366 );
367 } else if (kind === "tea") {
368 body = (
369 <>
370 <defs>
371 <radialGradient id={d("matcha")} cx=".45" cy=".4"><stop offset="0" stopColor="#B9CF70" /><stop offset=".7" stopColor="#7C9A3A" /><stop offset="1" stopColor="#56702A" /></radialGradient>
372 <radialGradient id={d("bowl")} cx=".4" cy=".35"><stop offset="0" stopColor="#5B4A44" /><stop offset="1" stopColor="#2A201D" /></radialGradient>
373 <linearGradient id={d("tatami")} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#D8CB97" /><stop offset="1" stopColor="#B7A56D" /></linearGradient>
374 <pattern id={d("weave")} width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="none" /><rect width="8" height="1.2" fill="#9D8B55" opacity=".35" /></pattern>
375 </defs>
376 <rect width="800" height="1000" fill={u("tatami")} />
377 <rect width="800" height="1000" fill={u("weave")} />
378 <rect x="0" y="0" width="60" height="1000" fill="#3B3226" />
379 <ellipse cx="420" cy="520" rx="230" ry="230" fill="#000" opacity=".18" />
380 <circle cx="400" cy="500" r="220" fill={u("bowl")} />
381 <circle cx="400" cy="500" r="170" fill={u("matcha")} />
382 {Array.from({ length: 40 }).map((_, i) => (
383 <circle key={i} cx={400 + Math.cos(i * 2.4) * (20 + (i * 37) % 130)} cy={500 + Math.sin(i * 2.4) * (20 + (i * 37) % 130)} r={2 + (i % 4)} fill="#D7E59A" opacity=".55" />
384 ))}
385 <g transform="translate(640 830) rotate(-30)">
386 <rect x="-12" y="-170" width="24" height="170" rx="6" fill="#D8C08A" />
387 {Array.from({ length: 11 }).map((_, i) => (
388 <line key={i} x1={-12 + i * 2.4} y1="0" x2={-30 + i * 6} y2="90" stroke="#E4D2A4" strokeWidth="2" />
389 ))}
390 </g>
391 <path d="M120 860 L320 860 L320 880 L120 880Z" fill="#7A5A3A" />
392 </>
393 );
394 }
395
396 return (
397 <svg className="hal-scene" viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice" role="img" aria-label={label}>
398 {body}
399 </svg>
400 );
401});
402
403/* ------------------------------------------------------------------ */
404/* Hero landscape (layered for parallax) */
405/* ------------------------------------------------------------------ */
406function L({ cls, children }: { cls: string; children: ReactNode }) {
407 return (
408 <div className={`hal-layer ${cls}`}>
409 <svg viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
410 {children}
411 </svg>
412 </div>
413 );
414}
415
416const HeroLandscape = memo(function HeroLandscape() {
417 const id = useSvgId();
418 const u = (n: string) => `url(#${id}-${n})`;
419 const d = (n: string) => `${id}-${n}`;
420 return (
421 <div className="hal-hero-scene" aria-hidden="true">
422 <L cls="hal-l-sky">
423 <defs>
424 <linearGradient id={d("day")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#D9C6B8" /><stop offset=".45" stopColor="#EED3B2" /><stop offset=".66" stopColor="#F6E2C6" /></linearGradient>
425 <linearGradient id={d("dusk")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#262640" /><stop offset=".38" stopColor="#5E4560" /><stop offset=".58" stopColor="#C2735D" /><stop offset=".66" stopColor="#E9A170" /></linearGradient>
426 </defs>
427 <rect width="1600" height="1000" fill={u("day")} />
428 <rect className="hal-dusk" width="1600" height="1000" fill={u("dusk")} />
429 <g className="hal-stars" fill="#FFF6E6">
430 {Array.from({ length: 46 }).map((_, i) => (
431 <circle key={i} cx={(i * 337) % 1600} cy={(i * 191) % 380} r={i % 5 === 0 ? 1.8 : 1.1} />
432 ))}
433 </g>
434 </L>
435 <L cls="hal-l-sun">
436 <defs>
437 <radialGradient id={d("glow")}><stop offset="0" stopColor="#FFF0D4" stopOpacity=".95" /><stop offset=".3" stopColor="#FAD9A9" stopOpacity=".55" /><stop offset="1" stopColor="#FAD9A9" stopOpacity="0" /></radialGradient>
438 <radialGradient id={d("glow2")}><stop offset="0" stopColor="#FFB070" stopOpacity=".95" /><stop offset=".35" stopColor="#E9764B" stopOpacity=".45" /><stop offset="1" stopColor="#E9764B" stopOpacity="0" /></radialGradient>
439 </defs>
440 <circle cx="900" cy="520" r="330" fill={u("glow")} />
441 <circle className="hal-dusk" cx="900" cy="520" r="360" fill={u("glow2")} />
442 <circle cx="900" cy="520" r="58" fill="#FFF3DE" />
443 <circle className="hal-dusk" cx="900" cy="520" r="58" fill="#F79A5E" />
444 </L>
445 <L cls="hal-l-far">
446 <path d="M0 662 C 90 648 170 630 250 636 C 330 642 380 610 450 600 C 520 590 560 626 640 640 C 700 650 760 660 820 664 L820 680 L0 680Z" fill="#B8988F" />
447 <path className="hal-dusk" d="M0 662 C 90 648 170 630 250 636 C 330 642 380 610 450 600 C 520 590 560 626 640 640 C 700 650 760 660 820 664 L820 680 L0 680Z" fill="#4C3A4C" />
448 </L>
449 <L cls="hal-l-sea">
450 <defs>
451 <linearGradient id={d("sea")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#9EAEAA" /><stop offset=".35" stopColor="#5D808A" /><stop offset="1" stopColor="#2B4D5A" /></linearGradient>
452 <linearGradient id={d("seaDusk")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#B07A6E" /><stop offset=".3" stopColor="#4B4A62" /><stop offset="1" stopColor="#1C2635" /></linearGradient>
453 </defs>
454 <rect y="660" width="1600" height="340" fill={u("sea")} />
455 <rect className="hal-dusk" y="660" width="1600" height="340" fill={u("seaDusk")} />
456 <g className="hal-glints">
457 {Array.from({ length: 16 }).map((_, i) => (
458 <rect key={i} x={900 - (40 + i * 9) + ((i * 53) % 40) - 20} y={672 + i * 20} width={80 + i * 18} height={2 + i * 0.25} rx="1.5" fill="#FFE9C9" opacity={0.75 - i * 0.04} />
459 ))}
460 </g>
461 {Array.from({ length: 22 }).map((_, i) => (
462 <rect key={i} x={(i * 233) % 1500} y={700 + ((i * 97) % 280)} width={40 + ((i * 31) % 70)} height="1.5" fill="#E8EEEA" opacity=".22" />
463 ))}
464 </L>
465 <L cls="hal-l-cliff">
466 <defs>
467 <linearGradient id={d("cliff")} x1="1" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#9C6A4D" /><stop offset="1" stopColor="#5A3A2B" /></linearGradient>
468 </defs>
469 <path d="M1600 240 L1420 300 C 1360 330 1320 360 1300 380 C 1260 420 1230 450 1210 470 C 1170 510 1150 540 1130 560 C 1100 600 1080 620 1060 640 C 1040 670 1020 700 1010 720 C 990 760 980 800 960 1000 L1600 1000Z" fill={u("cliff")} />
470 {HERO_HOUSES.map((h, i) => (
471 <g key={i}>
472 <rect x={h.x} y={h.y} width={h.w} height={h.h} fill={h.c} />
473 <rect x={h.x} y={h.y + h.h} width={h.w} height="3" fill="#000" opacity=".14" />
474 {h.win.map((wx, k) => (
475 <rect key={k} x={h.x + wx * h.w} y={h.y + h.h * 0.34} width="3.5" height="6" fill="#4A3426" opacity=".6" />
476 ))}
477 </g>
478 ))}
479 {[[1290, 470, 40], [1480, 420, 56], [1180, 610, 46], [1400, 640, 70], [1540, 560, 60], [1100, 700, 50]].map(([x, y, r], i) => (
480 <ellipse key={i} cx={x} cy={y} rx={r} ry={r * 0.5} fill="#4C5735" opacity=".8" />
481 ))}
482 <g transform="translate(1330 540)">
483 <rect x="-24" y="0" width="48" height="40" fill="#F3E6D2" />
484 <path d="M-22 0 A22 24 0 0 1 22 0Z" fill="#3D7774" />
485 <rect x="-1" y="-34" width="2" height="12" fill="#5B4636" />
486 </g>
487 <path className="hal-dusk" d="M1600 240 L1420 300 C 1360 330 1320 360 1300 380 C 1260 420 1230 450 1210 470 C 1170 510 1150 540 1130 560 C 1100 600 1080 620 1060 640 C 1040 670 1020 700 1010 720 C 990 760 980 800 960 1000 L1600 1000Z" fill="#1B1626" opacity=".62" />
488 <g className="hal-lights" fill="#FFD48E">
489 {HERO_HOUSES.filter((h) => h.lit).map((h, i) => (
490 <rect key={i} x={h.x + h.win[0] * h.w} y={h.y + h.h * 0.34} width="3.5" height="6" />
491 ))}
492 </g>
493 </L>
494 <div className="hal-layer hal-l-mist">
495 <span className="hal-mist hal-mist-a" />
496 <span className="hal-mist hal-mist-b" />
497 </div>
498 <L cls="hal-l-front">
499 <defs>
500 <linearGradient id={d("stone")} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#E9DCC8" /><stop offset="1" stopColor="#CDBBA2" /></linearGradient>
501 </defs>
502 <g transform="translate(1600 0) scale(-0.62 0.62)">
503 <path d="M0 0 L0 520 C 30 470 60 430 120 420 C 170 400 190 340 250 330 C 300 320 320 260 380 250 C 420 240 430 190 470 170 C 500 150 500 90 540 70 C 560 50 560 20 580 0Z" fill="#283224" />
504 <path d="M0 360 C 60 330 110 300 160 300 C 210 260 260 250 300 210 C 340 190 370 150 400 110" stroke="#1C241A" strokeWidth="10" fill="none" />
505 {[[140, 300], [220, 250], [300, 220], [80, 380], [360, 160], [420, 120], [40, 260], [250, 330], [480, 60], [190, 360], [330, 280]].map(([x, y], i) => (
506 <circle key={i} cx={x} cy={y} r={12 + (i % 3) * 5} fill={i % 4 === 0 ? "#C95C80" : "#B3466A"} opacity=".92" />
507 ))}
508 </g>
509 <rect y="900" width="1600" height="100" fill={u("stone")} />
510 <rect y="880" width="1600" height="24" fill="#EFE5D5" />
511 <rect y="902" width="1600" height="4" fill="#B9A68C" opacity=".6" />
512 </L>
513 </div>
514 );
515});
516
517/* ------------------------------------------------------------------ */
518/* Data */
519/* ------------------------------------------------------------------ */
520const HOUSES: { name: string; place: string; scene: SceneKind; rooms: number; since: string; from: string; note: string }[] = [
521 { name: "Casa Halcyon", place: "Positano, Amalfi Coast", scene: "positano", rooms: 24, since: "1962", from: "€690", note: "Fourteen terraces stacked above Spiaggia Grande; the lift opens into a lemon grove." },
522 { name: "Villa Limoni", place: "Ravello, Amalfi Coast", scene: "ravello", rooms: 17, since: "2009", from: "€820", note: "A 13th-century merchant's villa, 365 metres above the sea, with a pergola you can dine under." },
523 { name: "Halcyon Gion", place: "Higashiyama, Kyoto", scene: "gion", rooms: 21, since: "2018", from: "¥96,000", note: "Three restored machiya townhouses joined by a moss courtyard, a minute from Yasaka-jinja." },
524 { name: "Halcyon Arashiyama", place: "Ukyō-ku, Kyoto", scene: "arashiyama", rooms: 24, since: "2023", from: "¥118,000", note: "A riverside ryokan with hinoki baths and a private path into the bamboo before 7 a.m." },
525];
526
527const ROOMS: { name: string; house: string; scene: SceneKind; size: string; feat: string; price: string }[] = [
528 { name: "Sea-view Junior Suite", house: "Casa Halcyon · Positano", scene: "roomSea", size: "42 m²", feat: "Private terrace · rain shower", price: "€740" },
529 { name: "Garden Ryokan Room", house: "Halcyon Arashiyama · Kyoto", scene: "roomKyoto", size: "38 m²", feat: "Hinoki tub · futon or bed", price: "¥118,000" },
530 { name: "Lemon Terrace Suite", house: "Villa Limoni · Ravello", scene: "pool", size: "64 m²", feat: "Plunge pool · pergola", price: "€1,180" },
531];
532
533const RITUALS: { time: string; title: string; where: string; scene: SceneKind }[] = [
534 { time: "06:10", title: "The first boat to Li Galli", where: "Casa Halcyon · with Capitano Enzo", scene: "boat" },
535 { time: "11:00", title: "Lemon harvest on the upper terraces", where: "Villa Limoni · with Signora Aurelia", scene: "ravello" },
536 { time: "16:30", title: "Tea in the moss courtyard", where: "Halcyon Gion · usucha & wagashi", scene: "tea" },
537 { time: "21:00", title: "Lantern walk through Higashiyama", where: "Halcyon Gion · after the crowds", scene: "lantern" },
538];
539
540const QUOTES: { q: string; who: string; stay: string }[] = [
541 { q: "We came for three nights and stayed six. Somebody had pressed our linen with lemon leaves; I still don't know how they knew.", who: "Margot & Lise Verhoeven", stay: "Casa Halcyon · June 2026" },
542 { q: "The bath was filled at the exact moment the bamboo went quiet. Nothing was announced. Everything was anticipated.", who: "Daniel Okafor", stay: "Halcyon Arashiyama · April 2026" },
543 { q: "Dinner under the pergola, the coast turning violet below us — the best table I have sat at in twenty years of travel.", who: "Inés Calderón", stay: "Villa Limoni · September 2025" },
544 { q: "They walked us through Gion at nine, when the lanterns are lit and the streets finally belong to the people who live there.", who: "Henrik & Sofie Lund", stay: "Halcyon Gion · November 2025" },
545];
546
547const DATES = ["Thu 13 May", "Fri 14 May", "Sat 15 May", "Sun 16 May", "Mon 17 May", "Tue 18 May", "Wed 19 May", "Thu 20 May", "Fri 21 May", "Sat 22 May", "Sun 23 May", "Mon 24 May", "Tue 25 May", "Wed 26 May"];
548
549/* ------------------------------------------------------------------ */
550/* Page */
551/* ------------------------------------------------------------------ */
552export function Page() {
553 const rootRef = useRef<HTMLDivElement>(null);
554 const clockRef = useRef<HTMLSpanElement>(null);
555 const [navSmall, setNavSmall] = useState(false);
556 const [docked, setDocked] = useState(false);
557 const [dest, setDest] = useState<"amalfi" | "kyoto">("amalfi");
558 const [arrive, setArrive] = useState(1);
559 const [nights, setNights] = useState(4);
560 const [guests, setGuests] = useState(2);
561 const [datesOpen, setDatesOpen] = useState(false);
562 const [result, setResult] = useState("");
563 const [ritual, setRitual] = useState(0);
564 const [quote, setQuote] = useState(0);
565 const [quotePaused, setQuotePaused] = useState(false);
566 const [email, setEmail] = useState("");
567 const [subscribed, setSubscribed] = useState(false);
568 const [menuOpen, setMenuOpen] = useState(false);
569
570 /* Scroll engine: progress vars, nav + dock state, reveals, gallery sizing. */
571 useEffect(() => {
572 const root = rootRef.current;
573 if (!root) return;
574 const doc = root.ownerDocument;
575 const win = doc.defaultView;
576 if (!win) return;
577 const reduce = win.matchMedia("(prefers-reduced-motion: reduce)").matches;
578 root.classList.add("hal-js");
579
580 // Hosts that put overflow:hidden on BOTH <html> and <body> turn <body> into a
581 // non-scrolling scroll container, which silently breaks position:sticky.
582 // `clip` gives the same visual result without that side effect.
583 let restoreBody: (() => void) | null = null;
584 const fixRaf = win.requestAnimationFrame(() => {
585 const b = doc.body;
586 const bs = win.getComputedStyle(b);
587 const hs = win.getComputedStyle(doc.documentElement);
588 if (hs.overflowX !== "visible" && (bs.overflowX === "hidden" || bs.overflowY === "auto")) {
589 const prev = [b.style.overflowX, b.style.overflowY];
590 b.style.overflowX = "clip";
591 b.style.overflowY = "visible";
592 restoreBody = () => {
593 b.style.overflowX = prev[0];
594 b.style.overflowY = prev[1];
595 };
596 }
597 });
598
599 const progEls = Array.from(root.querySelectorAll<HTMLElement>("[data-hal-p]"));
600 const gallery = root.querySelector<HTMLElement>(".hal-houses");
601 const track = root.querySelector<HTMLElement>(".hal-houses-track");
602 const bar = root.querySelector<HTMLElement>(".hal-book");
603 const cur = root.querySelector<HTMLElement>(".hal-houses-cur");
604 const hero = root.querySelector<HTMLElement>(".hal-hero");
605 let galleryOn = false;
606
607 const measure = () => {
608 galleryOn = !reduce && win.innerWidth > 900;
609 if (gallery && track) {
610 const dist = galleryOn ? Math.max(0, track.scrollWidth - win.innerWidth) : 0;
611 gallery.style.setProperty("--dist", `${dist}px`);
612 gallery.dataset.dist = String(dist);
613 }
614 };
615
616 let raf = 0;
617 let lastSmall = false;
618 let lastDock = false;
619 const update = () => {
620 raf = 0;
621 const vh = win.innerHeight;
622 for (const el of progEls) {
623 const r = el.getBoundingClientRect();
624 const mode = el.dataset.halP;
625 let p = 0;
626 if (mode === "pin") p = (-r.top) / Math.max(1, r.height - vh);
627 else p = (vh - r.top) / (vh + r.height);
628 p = Math.min(1, Math.max(0, p));
629 el.style.setProperty("--p", p.toFixed(4));
630 if (el === gallery && track) {
631 const dist = Number(gallery.dataset.dist || 0);
632 track.style.transform = galleryOn ? `translate3d(${(-p * dist).toFixed(1)}px,0,0)` : "";
633 if (cur) cur.textContent = `0${Math.min(HOUSES.length, 1 + Math.floor(p * HOUSES.length * 0.999))}`;
634 }
635 if (el === hero && clockRef.current) {
636 const mins = Math.round(lerp(17 * 60 + 42, 20 * 60 + 16, Math.min(1, p * 1.25)));
637 clockRef.current.textContent = `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}`;
638 el.classList.toggle("is-dusk", p > 0.45);
639 }
640 }
641 const y = win.scrollY || doc.documentElement.scrollTop;
642 const small = y > 24;
643 if (small !== lastSmall) {
644 lastSmall = small;
645 setNavSmall(small);
646 }
647 if (bar) {
648 const top = bar.getBoundingClientRect().top;
649 const dock = top < 120 && y > vh * 0.5;
650 if (dock !== lastDock) {
651 lastDock = dock;
652 setDocked(dock);
653 }
654 }
655 };
656 const onScroll = () => {
657 if (!raf) raf = win.requestAnimationFrame(update);
658 };
659 const onResize = () => {
660 measure();
661 onScroll();
662 };
663 measure();
664 update();
665 win.addEventListener("scroll", onScroll, { passive: true });
666 win.addEventListener("resize", onResize);
667
668 // One-shot reveals.
669 const io = new win.IntersectionObserver(
670 (entries) => {
671 for (const e of entries) {
672 if (e.isIntersecting) {
673 e.target.classList.add("is-in");
674 io.unobserve(e.target);
675 }
676 }
677 },
678 { rootMargin: "0px 0px -12% 0px", threshold: 0.12 },
679 );
680 root.querySelectorAll("[data-hal-rv]").forEach((el) => io.observe(el));
681
682 return () => {
683 win.cancelAnimationFrame(fixRaf);
684 if (raf) win.cancelAnimationFrame(raf);
685 win.removeEventListener("scroll", onScroll);
686 win.removeEventListener("resize", onResize);
687 io.disconnect();
688 restoreBody?.();
689 root.classList.remove("hal-js");
690 };
691 }, []);
692
693 /* Testimonial autoplay (paused on hover/focus, off for reduced motion or hidden tab). */
694 useEffect(() => {
695 const root = rootRef.current;
696 const win = root?.ownerDocument.defaultView;
697 if (!win || quotePaused) return;
698 if (win.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
699 const t = win.setInterval(() => {
700 if (!root!.ownerDocument.hidden) setQuote((q) => (q + 1) % QUOTES.length);
701 }, 6500);
702 return () => win.clearInterval(t);
703 }, [quotePaused]);
704
705 const depart = DATES[Math.min(DATES.length - 1, arrive + nights)] ?? DATES[DATES.length - 1];
706 const nightly = dest === "amalfi" ? 690 : 640;
707 const check = () => {
708 const suites = dest === "amalfi" ? 5 + (arrive % 3) : 3 + (arrive % 4);
709 setResult(
710 `${suites} rooms open ${DATES[arrive].replace(/^\w+ /, "")}–${depart.replace(/^\w+ /, "")} · from €${(nightly + guests * 35).toLocaleString("en-GB")} a night`,
711 );
712 };
713
714 const navLinks = ["Houses", "Rooms", "Rituals", "Journal", "Gift cards"];
715
716 return (
717 <div ref={rootRef} className="hal">
718 <style>{css}</style>
719 <a className="hal-skip" href="#hal-main">Skip to content</a>
720
721 {/* NAV */}
722 <header className={`hal-nav${navSmall ? " is-small" : ""}${menuOpen ? " is-open" : ""}`}>
723 <div className="hal-nav-bar">
724 <a className="hal-logo" href="#hal-top" aria-label="Halcyon — home">
725 <svg viewBox="0 0 28 28" aria-hidden="true" focusable="false"><circle cx="14" cy="14" r="13" fill="none" stroke="currentColor" strokeWidth="1.2" /><path d="M3 16.5 C 8 13.5 11 13.5 14 15 S 20 16.5 25 13.5" fill="none" stroke="currentColor" strokeWidth="1.2" /><circle cx="14" cy="10" r="3.2" fill="currentColor" /></svg>
726 <span>Halcyon</span>
727 </a>
728 <nav className="hal-nav-links" aria-label="Primary">
729 {navLinks.map((l) => (
730 <a key={l} href={`#hal-${l.toLowerCase().replace(/\s/g, "")}`}>{l}</a>
731 ))}
732 </nav>
733 <div className="hal-nav-end">
734 <a className="hal-nav-lang" href="#hal-top" aria-label="Language: English">EN</a>
735 <a className="hal-btn hal-btn-ink hal-btn-sm" href="#hal-book">Reserve</a>
736 <button className="hal-burger" type="button" aria-label={menuOpen ? "Close menu" : "Open menu"} aria-expanded={menuOpen} onClick={() => setMenuOpen((o) => !o)}>
737 <span /><span />
738 </button>
739 </div>
740 </div>
741 <div className="hal-sheet" hidden={!menuOpen}>
742 {navLinks.map((l) => (
743 <a key={l} href={`#hal-${l.toLowerCase().replace(/\s/g, "")}`} onClick={() => setMenuOpen(false)}>{l}</a>
744 ))}
745 </div>
746 </header>
747
748 <main id="hal-main">
749 {/* HERO — pinned, time-of-day */}
750 <section id="hal-top" className="hal-hero" data-hal-p="pin" aria-labelledby="hal-hero-title">
751 <div className="hal-hero-pin">
752 <HeroLandscape />
753 <div className="hal-hero-copy">
754 <p className="hal-eyebrow hal-hero-eyebrow">
755 <span>Positano</span>
756 <i aria-hidden="true" />
757 <span ref={clockRef} className="hal-clock">17:42</span>
758 <i aria-hidden="true" />
759 <span>24°C, sea calm</span>
760 </p>
761 <h1 id="hal-hero-title" className="hal-hero-title">
762 <span className="hal-line"><span>Where the light</span></span>
763 <span className="hal-line"><span><em>lingers</em> longer.</span></span>
764 </h1>
765 <p className="hal-hero-sub">Four small houses on the Amalfi coast and in Kyoto, kept by the same families since 1962. Eighty-six rooms, no lobbies, and a staff who will remember how you take your coffee.</p>
766 </div>
767 <p className="hal-hero-scroll" aria-hidden="true"><span />Scroll for dusk</p>
768 </div>
769 </section>
770
771 {/* BOOKING BAR — floats at the bottom of screen one, docks under the nav */}
772 <div id="hal-book" className={`hal-book${docked ? " is-docked" : ""}`}>
773 <form className="hal-book-card" onSubmit={(e) => { e.preventDefault(); check(); }} aria-label="Check availability">
774 <div className="hal-field hal-field-dest" role="group" aria-labelledby="hal-dest-l">
775 <span className="hal-label" id="hal-dest-l">Destination</span>
776 <div className="hal-seg" role="radiogroup" aria-label="Destination">
777 {(["amalfi", "kyoto"] as const).map((v) => (
778 <button key={v} type="button" role="radio" aria-checked={dest === v} className={dest === v ? "is-on" : ""} onClick={() => { setDest(v); setResult(""); }}>
779 {v === "amalfi" ? "Amalfi Coast" : "Kyoto"}
780 </button>
781 ))}
782 <span className="hal-seg-thumb" style={{ transform: `translateX(${dest === "amalfi" ? 0 : 100}%)` }} aria-hidden="true" />
783 </div>
784 </div>
785 <div className="hal-field hal-field-date">
786 <span className="hal-label" id="hal-arrive-l">Arrive</span>
787 <button type="button" className="hal-date-btn" aria-labelledby="hal-arrive-l" aria-expanded={datesOpen} onClick={() => setDatesOpen((o) => !o)}>
788 {DATES[arrive]} <small>→ {depart}</small>
789 </button>
790 {datesOpen && (
791 <div className="hal-pop" role="listbox" aria-label="Arrival date, May 2027">
792 <p className="hal-pop-title">May 2027</p>
793 <div className="hal-pop-grid">
794 {DATES.slice(0, 12).map((dd, i) => (
795 <button key={dd} type="button" role="option" aria-selected={arrive === i} className={arrive === i ? "is-on" : ""} onClick={() => { setArrive(i); setDatesOpen(false); setResult(""); }}>
796 <small>{dd.slice(0, 3)}</small>
797 {dd.split(" ")[1]}
798 </button>
799 ))}
800 </div>
801 </div>
802 )}
803 </div>
804 <div className="hal-field">
805 <span className="hal-label">Nights</span>
806 <div className="hal-step">
807 <button type="button" aria-label="Fewer nights" onClick={() => setNights((n) => Math.max(1, n - 1))}>−</button>
808 <output aria-live="polite">{nights}</output>
809 <button type="button" aria-label="More nights" onClick={() => setNights((n) => Math.min(10, n + 1))}>+</button>
810 </div>
811 </div>
812 <div className="hal-field">
813 <span className="hal-label">Guests</span>
814 <div className="hal-step">
815 <button type="button" aria-label="Fewer guests" onClick={() => setGuests((n) => Math.max(1, n - 1))}>−</button>
816 <output aria-live="polite">{guests}</output>
817 <button type="button" aria-label="More guests" onClick={() => setGuests((n) => Math.min(6, n + 1))}>+</button>
818 </div>
819 </div>
820 <button className="hal-btn hal-btn-terra hal-book-cta" type="submit">
821 <span className="hal-cta-full">Check availability</span>
822 <span className="hal-cta-short" aria-hidden="true">Search</span>
823 <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>
824 </button>
825 <p className={`hal-book-result${result ? " is-on" : ""}`} aria-live="polite">{result}</p>
826 </form>
827 </div>
828
829 {/* INTRO */}
830 <section className="hal-intro" aria-labelledby="hal-intro-title">
831 <div className="hal-wrap hal-intro-grid">
832 <div className="hal-intro-text">
833 <p className="hal-eyebrow" data-hal-rv>Est. 1962 · Positano</p>
834 <h2 id="hal-intro-title" className="hal-h2" data-hal-rv>A house is not a hotel until somebody waits up for you.</h2>
835 <p className="hal-lede hal-drop" data-hal-rv>Nonna Teresa Aiello opened eight rooms above Spiaggia Grande with a borrowed espresso machine and a rule that every guest be met at the boat. Sixty-four years later her grandchildren keep four houses on two coasts — and the rule.</p>
836 <p className="hal-body" data-hal-rv>We have never built a lobby. You arrive into a kitchen, a garden, a tea room. Rooms are named, not numbered. In Kyoto we found a family who had been doing the same for nine generations, and asked if we might learn.</p>
837 <dl className="hal-figs" data-hal-rv>
838 <div><dt>Houses</dt><dd>4</dd></div>
839 <div><dt>Rooms & suites</dt><dd>86</dd></div>
840 <div><dt>Guests who return</dt><dd>61%</dd></div>
841 </dl>
842 </div>
843 <div className="hal-intro-media" data-hal-p="view">
844 <figure className="hal-clip hal-clip-a" data-hal-rv>
845 <div className="hal-clip-in"><Scene kind="positano" label="Casa Halcyon's terraces above Positano at golden hour" /></div>
846 <figcaption>Casa Halcyon, from the ferry at 18:20</figcaption>
847 </figure>
848 <figure className="hal-clip hal-clip-b" data-hal-rv>
849 <div className="hal-clip-in"><Scene kind="roomKyoto" label="A tatami room at Halcyon Arashiyama opening onto a maple garden" /></div>
850 <figcaption>Room Kaede, Arashiyama</figcaption>
851 </figure>
852 </div>
853 </div>
854 </section>
855
856 {/* HOUSES — pinned horizontal gallery */}
857 <section id="hal-houses" className="hal-houses" data-hal-p="pin" aria-labelledby="hal-houses-title">
858 <div className="hal-houses-pin">
859 <div className="hal-houses-head hal-wrap">
860 <div>
861 <p className="hal-eyebrow">The houses</p>
862 <h2 id="hal-houses-title" className="hal-h2">Four houses, two coasts.</h2>
863 </div>
864 <div className="hal-houses-meter" aria-hidden="true">
865 <span className="hal-houses-bar"><i /></span>
866 <span className="hal-houses-count"><b className="hal-houses-cur">01</b> — 04</span>
867 </div>
868 </div>
869 <ol className="hal-houses-track">
870 {HOUSES.map((h, i) => (
871 <li key={h.name} className="hal-house">
872 <a href="#hal-rooms" className="hal-house-link" aria-label={`${h.name}, ${h.place} — from ${h.from} a night`}>
873 <div className="hal-house-img"><div className="hal-house-par"><Scene kind={h.scene} label={`${h.name}, ${h.place}`} /></div></div>
874 <div className="hal-house-info">
875 <span className="hal-house-n">0{i + 1}</span>
876 <div>
877 <h3 className="hal-h3">{h.name}</h3>
878 <p className="hal-house-place">{h.place} · since {h.since}</p>
879 <p className="hal-house-note">{h.note}</p>
880 </div>
881 <p className="hal-house-meta"><span>{h.rooms} rooms</span><span>from {h.from}</span></p>
882 </div>
883 </a>
884 </li>
885 ))}
886 </ol>
887 </div>
888 </section>
889
890 {/* ROOMS */}
891 <section id="hal-rooms" className="hal-rooms" aria-labelledby="hal-rooms-title">
892 <div className="hal-wrap">
893 <div className="hal-sec-head">
894 <p className="hal-eyebrow" data-hal-rv>Rooms & suites</p>
895 <h2 id="hal-rooms-title" className="hal-h2" data-hal-rv>Rooms are named, never numbered.</h2>
896 <p className="hal-body hal-sec-sub" data-hal-rv>Every room has a window that frames something worth waking for. Rates include breakfast, the minibar, and the boat or car from wherever you land.</p>
897 </div>
898 <ul className="hal-room-grid">
899 {ROOMS.map((r, i) => (
900 <li key={r.name} className="hal-room" data-hal-rv style={{ "--d": `${i * 90}ms` } as CSSProperties}>
901 <a href="#hal-book" className="hal-room-link">
902 <div className="hal-room-img"><Scene kind={r.scene} label={`${r.name} at ${r.house}`} /></div>
903 <div className="hal-room-body">
904 <p className="hal-room-house">{r.house}</p>
905 <h3 className="hal-h3">{r.name}</h3>
906 <p className="hal-room-feat">{r.size} · {r.feat}</p>
907 <p className="hal-room-foot">
908 <span>from <strong>{r.price}</strong> / night</span>
909 <span className="hal-room-cta">View room <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg></span>
910 </p>
911 </div>
912 </a>
913 </li>
914 ))}
915 </ul>
916 </div>
917 </section>
918
919 {/* RITUALS */}
920 <section id="hal-rituals" className="hal-rituals" aria-labelledby="hal-rituals-title">
921 <div className="hal-wrap hal-rituals-grid">
922 <div className="hal-rituals-frame" aria-hidden="true">
923 {RITUALS.map((r, i) => (
924 <div key={r.title} className={`hal-rituals-img${ritual === i ? " is-on" : ""}`}><Scene kind={r.scene} label={r.title} /></div>
925 ))}
926 <span className="hal-rituals-time">{RITUALS[ritual].time}</span>
927 </div>
928 <div>
929 <p className="hal-eyebrow" data-hal-rv>A day, kept slowly</p>
930 <h2 id="hal-rituals-title" className="hal-h2" data-hal-rv>Small rituals, booked by nobody.</h2>
931 <ol className="hal-rituals-list">
932 {RITUALS.map((r, i) => (
933 <li key={r.title}>
934 <button type="button" className={`hal-ritual${ritual === i ? " is-on" : ""}`} aria-pressed={ritual === i} onMouseEnter={() => setRitual(i)} onFocus={() => setRitual(i)} onClick={() => setRitual(i)}>
935 <span className="hal-ritual-time">{r.time}</span>
936 <span className="hal-ritual-title">{r.title}</span>
937 <span className="hal-ritual-where">{r.where}</span>
938 </button>
939 </li>
940 ))}
941 </ol>
942 </div>
943 </div>
944 </section>
945
946 {/* TESTIMONIALS */}
947 <section className="hal-quotes" aria-labelledby="hal-quotes-title" onMouseEnter={() => setQuotePaused(true)} onMouseLeave={() => setQuotePaused(false)} onFocus={() => setQuotePaused(true)} onBlur={() => setQuotePaused(false)}>
948 <div className="hal-wrap hal-quotes-in">
949 <h2 id="hal-quotes-title" className="hal-eyebrow">From the guest book</h2>
950 <div className="hal-quote-stack" aria-live="polite">
951 {QUOTES.map((q, i) => (
952 <figure key={q.who} className={`hal-quote${quote === i ? " is-on" : ""}`} aria-hidden={quote !== i}>
953 <blockquote><p>“{q.q}”</p></blockquote>
954 <figcaption><strong>{q.who}</strong><span>{q.stay}</span></figcaption>
955 </figure>
956 ))}
957 </div>
958 <div className="hal-quote-dots">
959 {QUOTES.map((q, i) => (
960 <button key={q.who} type="button" aria-label={`Show quote ${i + 1} of ${QUOTES.length}: ${q.who}`} aria-current={quote === i} className={quote === i ? "is-on" : ""} onClick={() => setQuote(i)}>
961 <i style={{ animationPlayState: quotePaused ? "paused" : "running" }} />
962 </button>
963 ))}
964 </div>
965 </div>
966 </section>
967
968 {/* MAP */}
969 <section id="hal-journal" className="hal-map" aria-labelledby="hal-map-title">
970 <div className="hal-wrap">
971 <div className="hal-sec-head">
972 <p className="hal-eyebrow" data-hal-rv>Getting here</p>
973 <h2 id="hal-map-title" className="hal-h2" data-hal-rv>Two coasts, 9,600 km apart, one way of keeping house.</h2>
974 </div>
975 <div className="hal-map-grid">
976 <figure className="hal-map-card" data-hal-rv>
977 <svg className="hal-map-svg" viewBox="0 0 600 420" role="img" aria-label="Map of the Amalfi Coast with Casa Halcyon in Positano and Villa Limoni in Ravello">
978 <rect width="600" height="420" fill="#E7EEEC" />
979 <path d="M0 120 C 60 130 110 170 150 200 C 190 230 230 226 270 240 C 320 256 350 250 390 238 C 430 226 470 236 520 210 C 550 196 580 170 600 160 L600 0 L0 0Z" fill="#EDE3D2" />
980 <path className="hal-draw" d="M0 120 C 60 130 110 170 150 200 C 190 230 230 226 270 240 C 320 256 350 250 390 238 C 430 226 470 236 520 210 C 550 196 580 170 600 160" fill="none" stroke="#A9502B" strokeWidth="1.5" />
981 <path className="hal-draw hal-draw-route" d="M180 220 C 240 300 380 300 420 240" fill="none" stroke="#2F5866" strokeWidth="1.5" strokeDasharray="4 6" />
982 <ellipse cx="80" cy="330" rx="40" ry="16" fill="#EDE3D2" />
983 <text x="80" y="370" className="hal-map-t hal-map-t-sm" textAnchor="middle">Capri</text>
984 <text x="300" y="380" className="hal-map-t hal-map-t-sea" textAnchor="middle">Golfo di Salerno</text>
985 <text x="300" y="90" className="hal-map-t hal-map-t-sm" textAnchor="middle">Monti Lattari</text>
986 <g className="hal-pin" style={{ "--d": "300ms" } as CSSProperties} transform="translate(180 214)">
987 <path d="M0 0 C -10 -14 -14 -20 -14 -28 A14 14 0 0 1 14 -28 C 14 -20 10 -14 0 0Z" fill="#A9502B" />
988 <circle cy="-28" r="5" fill="#FBF8F3" />
989 </g>
990 <text x="180" y="244" className="hal-map-t" textAnchor="middle">Positano</text>
991 <g className="hal-pin" style={{ "--d": "520ms" } as CSSProperties} transform="translate(420 232)">
992 <path d="M0 0 C -10 -14 -14 -20 -14 -28 A14 14 0 0 1 14 -28 C 14 -20 10 -14 0 0Z" fill="#A9502B" />
993 <circle cy="-28" r="5" fill="#FBF8F3" />
994 </g>
995 <text x="420" y="262" className="hal-map-t" textAnchor="middle">Ravello</text>
996 </svg>
997 <figcaption>
998 <strong>Amalfi Coast</strong>
999 <span>Naples airport → Positano 75 min by car, or 40 min by our boat from Salerno. Positano → Ravello 35 min along the SS163.</span>
1000 </figcaption>
1001 </figure>
1002 <figure className="hal-map-card" data-hal-rv style={{ "--d": "120ms" } as CSSProperties}>
1003 <svg className="hal-map-svg" viewBox="0 0 600 420" role="img" aria-label="Map of Kyoto with Halcyon Gion in Higashiyama and Halcyon Arashiyama in the west">
1004 <rect width="600" height="420" fill="#EEEADF" />
1005 {Array.from({ length: 9 }).map((_, i) => (
1006 <line key={`v${i}`} x1={160 + i * 40} y1="40" x2={160 + i * 40} y2="400" stroke="#DCD3C1" strokeWidth="1" />
1007 ))}
1008 {Array.from({ length: 9 }).map((_, i) => (
1009 <line key={`h${i}`} x1="160" y1={40 + i * 45} x2="500" y2={40 + i * 45} stroke="#DCD3C1" strokeWidth="1" />
1010 ))}
1011 <path d="M0 0 L120 0 C 100 80 90 160 110 240 C 120 300 90 360 60 420 L0 420Z" fill="#DCE2CC" />
1012 <path className="hal-draw" d="M430 0 C 440 80 445 160 438 240 C 432 320 440 380 450 420" fill="none" stroke="#7FA3AC" strokeWidth="5" />
1013 <path className="hal-draw" d="M70 60 C 90 140 70 220 100 300 C 120 350 140 390 150 420" fill="none" stroke="#7FA3AC" strokeWidth="4" />
1014 <path className="hal-draw hal-draw-route" d="M110 200 C 200 250 360 250 470 190" fill="none" stroke="#2F5866" strokeWidth="1.5" strokeDasharray="4 6" />
1015 <text x="448" y="400" className="hal-map-t hal-map-t-sea">Kamo-gawa</text>
1016 <rect x="300" y="355" width="70" height="16" fill="#D6CDB9" />
1017 <text x="335" y="390" className="hal-map-t hal-map-t-sm" textAnchor="middle">Kyoto Station</text>
1018 <g className="hal-pin" style={{ "--d": "300ms" } as CSSProperties} transform="translate(110 196)">
1019 <path d="M0 0 C -10 -14 -14 -20 -14 -28 A14 14 0 0 1 14 -28 C 14 -20 10 -14 0 0Z" fill="#A9502B" />
1020 <circle cy="-28" r="5" fill="#FBF8F3" />
1021 </g>
1022 <text x="110" y="226" className="hal-map-t" textAnchor="middle">Arashiyama</text>
1023 <g className="hal-pin" style={{ "--d": "520ms" } as CSSProperties} transform="translate(470 186)">
1024 <path d="M0 0 C -10 -14 -14 -20 -14 -28 A14 14 0 0 1 14 -28 C 14 -20 10 -14 0 0Z" fill="#A9502B" />
1025 <circle cy="-28" r="5" fill="#FBF8F3" />
1026 </g>
1027 <text x="470" y="216" className="hal-map-t" textAnchor="middle">Gion</text>
1028 </svg>
1029 <figcaption>
1030 <strong>Kyoto</strong>
1031 <span>Kyoto Station → Gion 18 min, → Arashiyama 25 min. Our car meets every Nozomi; a house-to-house transfer runs twice daily.</span>
1032 </figcaption>
1033 </figure>
1034 </div>
1035 </div>
1036 </section>
1037 </main>
1038
1039 {/* FOOTER + NEWSLETTER */}
1040 <footer className="hal-foot">
1041 <div className="hal-wrap">
1042 <div className="hal-news">
1043 <div>
1044 <p className="hal-eyebrow hal-eyebrow-light">Letters from the coast</p>
1045 <h2 className="hal-h2 hal-news-title">Four letters a year. Never more.</h2>
1046 <p className="hal-news-sub">First word on the lemon harvest, autumn rooms in Kyoto, and the week each spring we open Casa Halcyon early.</p>
1047 </div>
1048 <form className="hal-news-form" onSubmit={(e) => { e.preventDefault(); if (/\S+@\S+\.\S+/.test(email)) setSubscribed(true); }}>
1049 <label className="hal-news-label" htmlFor="hal-email">Email address</label>
1050 <div className={`hal-news-row${subscribed ? " is-done" : ""}`}>
1051 <input id="hal-email" type="email" required placeholder="you@example.com" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={subscribed} />
1052 <button type="submit" className="hal-btn hal-btn-sand" disabled={subscribed}>{subscribed ? "Subscribed" : "Subscribe"}</button>
1053 </div>
1054 <p className="hal-news-note" aria-live="polite">{subscribed ? `Grazie — the next letter reaches ${email} in early spring.` : "We never share your address. Unsubscribe in one click."}</p>
1055 </form>
1056 </div>
1057 <div className="hal-foot-cols">
1058 <div>
1059 <p className="hal-foot-h">Houses</p>
1060 <ul>{HOUSES.map((h) => <li key={h.name}><a href="#hal-houses">{h.name}</a></li>)}</ul>
1061 </div>
1062 <div>
1063 <p className="hal-foot-h">Stay</p>
1064 <ul>{["Rooms & suites", "Seasonal offers", "Gift cards", "Weddings & buy-outs"].map((x) => <li key={x}><a href="#hal-rooms">{x}</a></li>)}</ul>
1065 </div>
1066 <div>
1067 <p className="hal-foot-h">Halcyon</p>
1068 <ul>{["Our story", "Careers", "Press room", "Sustainability report 2025"].map((x) => <li key={x}><a href="#hal-top">{x}</a></li>)}</ul>
1069 </div>
1070 <div>
1071 <p className="hal-foot-h">Reservations</p>
1072 <ul>
1073 <li><a href="#hal-book">+39 089 875 0162</a></li>
1074 <li><a href="#hal-book">stay@halcyon.example</a></li>
1075 <li><span>Daily 08:00–22:00 CET</span></li>
1076 </ul>
1077 </div>
1078 </div>
1079 <p className="hal-foot-mark" aria-hidden="true">Halcyon</p>
1080 <div className="hal-foot-legal">
1081 <span>© 2026 Halcyon Case & Ryokan S.r.l. · Via Cristoforo Colombo 41, 84017 Positano (SA)</span>
1082 <span><a href="#hal-top">Privacy</a><a href="#hal-top">Cookies</a><a href="#hal-top">Accessibility</a></span>
1083 </div>
1084 </div>
1085 </footer>
1086 </div>
1087 );
1088}
1089
1090export default Page;
1091
1092/* ------------------------------------------------------------------ */
1093/* Styles */
1094/* ------------------------------------------------------------------ */
1095const css = String.raw`
1096.hal {
1097 --hal-sand: #F3ECE2;
1098 --hal-paper: #FBF8F3;
1099 --hal-ink: #1E1915;
1100 --hal-ink-2: #5B5048;
1101 --hal-terra: #A9502B;
1102 --hal-terra-2: #C8693F;
1103 --hal-sea: #2F5866;
1104 --hal-night: #191513;
1105 --hal-line: rgba(30, 25, 21, 0.14);
1106 --hal-serif: ui-serif, "New York", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
1107 --hal-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
1108 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
1109 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
1110 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
1111 --fd-dur-1: 120ms;
1112 --fd-dur-2: 240ms;
1113 --fd-dur-3: 420ms;
1114 --fd-dur-4: 700ms;
1115 --hal-nav-h: 84px;
1116 --hal-nav-s: 60px;
1117 position: relative;
1118 display: block;
1119 width: 100%;
1120 margin: 0;
1121 padding: 0;
1122 overflow-x: clip;
1123 background: var(--hal-sand);
1124 color: var(--hal-ink);
1125 font: 400 17px/1.6 var(--hal-sans);
1126 letter-spacing: 0;
1127 text-align: left;
1128 -webkit-font-smoothing: antialiased;
1129 -moz-osx-font-smoothing: grayscale;
1130}
1131.hal *, .hal *::before, .hal *::after {
1132 box-sizing: border-box;
1133 margin: 0;
1134 padding: 0;
1135 border: 0 solid transparent;
1136 font: inherit;
1137 color: inherit;
1138 letter-spacing: inherit;
1139 text-decoration: none;
1140 list-style: none;
1141 background: none;
1142 -webkit-appearance: none;
1143 appearance: none;
1144 min-width: 0;
1145}
1146.hal :where(svg) { display: block; overflow: hidden; }
1147.hal :where(button) { cursor: pointer; text-align: inherit; line-height: inherit; }
1148.hal :where(button:disabled) { cursor: default; }
1149.hal :focus-visible { outline: 2px solid rgba(169, 80, 43, 0.7); outline-offset: 2px; border-radius: 6px; }
1150.hal [hidden] { display: none !important; }
1151.hal-skip { position: absolute; left: 16px; top: -60px; z-index: 100; padding: 10px 16px; background: var(--hal-ink); color: var(--hal-paper); border-radius: 9999px; font: 500 13px/1 var(--hal-sans); }
1152.hal-skip:focus { top: 12px; }
1153
1154.hal-wrap { width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 40px; }
1155.hal-eyebrow { display: flex; align-items: center; gap: 10px; font: 500 12px/1.4 var(--hal-sans); letter-spacing: 0.18em; text-transform: uppercase; color: var(--hal-terra); }
1156.hal-h2 { font: 400 clamp(32px, 4.2vw, 64px)/1.06 var(--hal-serif); letter-spacing: -0.025em; color: var(--hal-ink); text-wrap: balance; }
1157.hal-h3 { font: 400 28px/1.15 var(--hal-serif); letter-spacing: -0.015em; color: var(--hal-ink); }
1158.hal-lede { font: 400 24px/1.5 var(--hal-serif); letter-spacing: -0.01em; color: var(--hal-ink); }
1159.hal-body { font: 400 17px/1.7 var(--hal-sans); color: var(--hal-ink-2); }
1160.hal-scene { width: 100%; height: 100%; }
1161
1162/* Buttons */
1163.hal-btn { display: inline-flex; align-items: center; justify-content: center; gap: 10px; height: 52px; padding: 0 26px; border-radius: 9999px; font: 500 15px/1 var(--hal-sans); letter-spacing: 0.01em; white-space: nowrap; transition: background-color var(--fd-dur-2) var(--fd-ease-out), color var(--fd-dur-2) var(--fd-ease-out), transform var(--fd-dur-2) var(--fd-spring); }
1164.hal-btn svg { width: 16px; height: 16px; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
1165.hal-btn:hover svg { transform: translateX(3px); }
1166.hal-btn:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
1167.hal-btn-sm { height: 40px; padding: 0 18px; font-size: 13px; }
1168.hal-btn-ink { background: var(--hal-ink); color: var(--hal-paper); }
1169.hal-btn-ink:hover { background: #3A302A; }
1170.hal-btn-terra { background: var(--hal-terra); color: #FFF8F1; }
1171.hal-btn-terra:hover { background: #923F1E; }
1172.hal-btn-sand { background: var(--hal-sand); color: var(--hal-ink); }
1173.hal-btn-sand:hover { background: #FFFFFF; }
1174
1175/* NAV */
1176.hal-nav { position: sticky; top: 0; z-index: 60; height: var(--hal-nav-h); margin-bottom: calc(var(--hal-nav-h) * -1); pointer-events: none; }
1177.hal-nav-bar { pointer-events: auto; position: relative; display: flex; align-items: center; justify-content: space-between; gap: 24px; height: var(--hal-nav-h); padding: 0 40px; transition: height var(--fd-dur-3) var(--fd-ease-out), background-color var(--fd-dur-3) var(--fd-ease-out), box-shadow var(--fd-dur-3) var(--fd-ease-out); }
1178.hal-nav.is-small .hal-nav-bar { height: var(--hal-nav-s); background: rgba(243, 236, 226, 0.86); -webkit-backdrop-filter: saturate(1.4) blur(18px); backdrop-filter: saturate(1.4) blur(18px); box-shadow: 0 1px 0 var(--hal-line); }
1179.hal-logo { display: inline-flex; align-items: center; gap: 12px; color: var(--hal-ink); font: 400 22px/1 var(--hal-serif); letter-spacing: 0.22em; text-transform: uppercase; }
1180.hal-logo svg { width: 28px; height: 28px; transition: transform var(--fd-dur-3) var(--fd-ease-out); }
1181.hal-nav.is-small .hal-logo svg { transform: scale(0.86); }
1182.hal-nav-links { display: flex; gap: 34px; }
1183.hal-nav-links a { position: relative; font: 500 14px/1 var(--hal-sans); letter-spacing: 0.02em; color: var(--hal-ink); padding: 8px 0; }
1184.hal-nav-links a::after { content: ""; position: absolute; left: 0; right: 0; bottom: 2px; height: 1px; background: currentColor; transform: scaleX(0); transform-origin: right; transition: transform var(--fd-dur-3) var(--fd-ease-out); }
1185.hal-nav-links a:hover::after { transform: scaleX(1); transform-origin: left; }
1186.hal-nav-end { display: flex; align-items: center; gap: 18px; }
1187.hal-nav-lang { font: 500 13px/1 var(--hal-sans); letter-spacing: 0.08em; color: var(--hal-ink-2); }
1188.hal-burger { display: none; width: 44px; height: 44px; border-radius: 9999px; position: relative; }
1189.hal-burger span { position: absolute; left: 13px; right: 13px; height: 1.5px; background: var(--hal-ink); transition: transform var(--fd-dur-3) var(--fd-ease-out); }
1190.hal-burger span:first-child { top: 18px; }
1191.hal-burger span:last-child { top: 25px; }
1192.hal-nav.is-open .hal-burger span:first-child { transform: translateY(3.5px) rotate(45deg); }
1193.hal-nav.is-open .hal-burger span:last-child { transform: translateY(-3.5px) rotate(-45deg); }
1194.hal-sheet { pointer-events: auto; display: flex; flex-direction: column; padding: 8px 24px 24px; background: rgba(243, 236, 226, 0.97); box-shadow: 0 1px 0 var(--hal-line), 0 30px 60px -30px rgba(30, 25, 21, 0.3); }
1195.hal-sheet a { font: 400 28px/1.2 var(--hal-serif); padding: 12px 0; border-bottom: 1px solid var(--hal-line); color: var(--hal-ink); }
1196
1197/* HERO */
1198.hal-hero { position: relative; height: 190vh; height: 190svh; background: #EAD3B4; --p: 0; }
1199.hal-hero-pin { position: sticky; top: 0; height: 100vh; height: 100svh; min-height: 620px; overflow: hidden; }
1200.hal-hero-scene { position: absolute; inset: 0; }
1201.hal-layer { position: absolute; inset: -2% 0 0 0; will-change: transform; }
1202.hal-layer svg { width: 100%; height: 100%; }
1203.hal-dusk { opacity: var(--p); }
1204.hal-stars { opacity: calc((var(--p) - 0.55) * 2.4); }
1205.hal-lights { opacity: calc((var(--p) - 0.35) * 2); }
1206/* Camera cranes up as you scroll: near layers fall away faster than far ones, the sun sets behind the sea. */
1207.hal-l-sun { transform: translate3d(0, calc(var(--p) * 21%), 0); }
1208.hal-l-far { transform: translate3d(0, calc(var(--p) * 1%), 0); }
1209.hal-l-sea { transform: translate3d(0, calc(var(--p) * 1.6%), 0); }
1210.hal-l-cliff { transform: translate3d(calc(var(--p) * 2%), calc(var(--p) * 5%), 0) scale(calc(1 + var(--p) * 0.04)); transform-origin: 80% 100%; }
1211.hal-l-mist { transform: translate3d(calc(var(--p) * -6%), calc(var(--p) * 4%), 0); opacity: calc(0.9 - var(--p) * 0.4); }
1212.hal-l-front { transform: translate3d(0, calc(var(--p) * 16%), 0) scale(calc(1 + var(--p) * 0.06)); transform-origin: 50% 100%; }
1213.hal-glints { animation: hal-shimmer 3.6s var(--fd-ease-in-out) infinite alternate; }
1214@keyframes hal-shimmer { from { opacity: 0.55; transform: translateX(-6px); } to { opacity: 1; transform: translateX(6px); } }
1215.hal-mist { position: absolute; border-radius: 50%; background: radial-gradient(closest-side, rgba(255, 246, 232, 0.85), rgba(255, 246, 232, 0)); filter: blur(10px); }
1216.hal-mist-a { left: 30%; top: 58%; width: 70%; height: 18%; animation: hal-drift 22s linear infinite alternate; }
1217.hal-mist-b { left: -10%; top: 66%; width: 60%; height: 14%; opacity: 0.7; animation: hal-drift 30s linear infinite alternate-reverse; }
1218@keyframes hal-drift { from { transform: translateX(-4%); } to { transform: translateX(6%); } }
1219
1220.hal-hero-copy { position: absolute; left: 0; right: 0; top: 0; bottom: 0; display: flex; flex-direction: column; justify-content: center; padding: var(--hal-nav-h) 40px 150px; max-width: 1280px; margin: 0 auto; transform: translate3d(0, calc(var(--p) * -60px), 0); opacity: calc(1.6 - var(--p) * 1.9); }
1221.hal-hero-eyebrow { color: var(--hal-ink); transition: color var(--fd-dur-4) var(--fd-ease-out); margin-bottom: 28px; }
1222.hal-hero-eyebrow i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: 0.6; }
1223.hal-clock { font-variant-numeric: tabular-nums; min-width: 3.2em; }
1224.hal-hero-title { font: 400 clamp(56px, 7.4vw, 120px)/0.98 var(--hal-serif); letter-spacing: -0.04em; color: var(--hal-ink); max-width: 9.5em; transition: color var(--fd-dur-4) var(--fd-ease-out); }
1225.hal-hero-title em { font-style: italic; color: var(--hal-terra); transition: color var(--fd-dur-4) var(--fd-ease-out); }
1226.hal-line { display: block; overflow: hidden; padding-bottom: 0.08em; margin-bottom: -0.08em; }
1227.hal-line > span { display: block; transform: translateY(105%); animation: hal-rise 1.2s var(--fd-ease-out) forwards; }
1228.hal-line:nth-child(2) > span { animation-delay: 140ms; }
1229@keyframes hal-rise { to { transform: translateY(0); } }
1230.hal-hero-sub { max-width: 470px; margin-top: 30px; font: 400 17px/1.65 var(--hal-sans); color: var(--hal-ink-2); opacity: 0; animation: hal-fade 1s var(--fd-ease-out) 500ms forwards; transition: color var(--fd-dur-4) var(--fd-ease-out); }
1231@keyframes hal-fade { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
1232.hal-hero.is-dusk .hal-hero-title, .hal-hero.is-dusk .hal-hero-eyebrow { color: #FBF3E8; }
1233.hal-hero.is-dusk .hal-hero-title em { color: #F2B48C; }
1234.hal-hero.is-dusk .hal-hero-sub { color: rgba(251, 243, 232, 0.86); }
1235.hal-hero-scroll { position: absolute; right: 40px; bottom: 150px; display: flex; align-items: center; gap: 12px; font: 500 12px/1 var(--hal-sans); letter-spacing: 0.18em; text-transform: uppercase; color: var(--hal-ink); writing-mode: vertical-rl; opacity: calc(1 - var(--p) * 4); }
1236.hal-hero-scroll span { display: block; width: 1px; height: 56px; background: currentColor; transform-origin: top; animation: hal-tick 2.4s var(--fd-ease-in-out) infinite; }
1237@keyframes hal-tick { 0% { transform: scaleY(0); } 50% { transform: scaleY(1); transform-origin: top; } 51% { transform-origin: bottom; } 100% { transform: scaleY(0); transform-origin: bottom; } }
1238.hal-l-sky, .hal-l-sun, .hal-l-far, .hal-l-sea, .hal-l-cliff, .hal-l-front { animation: hal-settle 1.6s var(--fd-ease-out) both; }
1239.hal-l-cliff { animation-delay: 80ms; }
1240.hal-l-front { animation-delay: 160ms; }
1241@keyframes hal-settle { from { opacity: 0; translate: 0 3%; } to { opacity: 1; translate: 0 0; } }
1242.hal-l-sky { animation-name: hal-fade-in; }
1243@keyframes hal-fade-in { from { opacity: 0; } to { opacity: 1; } }
1244
1245/* BOOKING BAR */
1246.hal-book { position: sticky; top: calc(var(--hal-nav-s) + 10px); bottom: 24px; z-index: 50; display: flex; justify-content: center; margin-top: -120px; padding: 0 40px; height: 96px; pointer-events: none; }
1247.hal-book-card { pointer-events: auto; position: relative; display: grid; grid-template-columns: auto 1.3fr 0.8fr 0.8fr auto; align-items: center; gap: 0; width: 100%; max-width: 1120px; height: 96px; padding: 0 12px 0 24px; border-radius: 24px; background: rgba(251, 248, 243, 0.94); -webkit-backdrop-filter: saturate(1.3) blur(20px); backdrop-filter: saturate(1.3) blur(20px); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset, 0 24px 60px -24px rgba(30, 25, 21, 0.35), 0 0 0 1px rgba(30, 25, 21, 0.06); transition: height var(--fd-dur-3) var(--fd-ease-out), border-radius var(--fd-dur-3) var(--fd-ease-out), box-shadow var(--fd-dur-3) var(--fd-ease-out), max-width var(--fd-dur-4) var(--fd-ease-out); }
1248.hal-book.is-docked .hal-book-card { height: 68px; max-width: 1200px; border-radius: 9999px; box-shadow: 0 0 0 1px rgba(30, 25, 21, 0.08), 0 12px 30px -16px rgba(30, 25, 21, 0.3); }
1249.hal-field { position: relative; display: flex; flex-direction: column; justify-content: center; gap: 6px; height: 100%; padding: 0 22px; border-left: 1px solid var(--hal-line); }
1250.hal-field-dest { border-left: 0; padding-left: 0; }
1251.hal-label { display: block; font: 500 11px/1 var(--hal-sans); letter-spacing: 0.16em; text-transform: uppercase; color: var(--hal-ink-2); transition: opacity var(--fd-dur-2) var(--fd-ease-out), height var(--fd-dur-3) var(--fd-ease-out); }
1252.hal-book.is-docked .hal-label { opacity: 0; height: 0; margin-bottom: -6px; overflow: hidden; }
1253.hal-seg { position: relative; display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 9999px; background: rgba(30, 25, 21, 0.06); }
1254.hal-seg button { position: relative; z-index: 1; height: 36px; padding: 0 16px; border-radius: 9999px; font: 500 14px/1 var(--hal-sans); color: var(--hal-ink-2); text-align: center; white-space: nowrap; transition: color var(--fd-dur-2) var(--fd-ease-out); }
1255.hal-seg button.is-on { color: var(--hal-ink); }
1256.hal-seg-thumb { position: absolute; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px); border-radius: 9999px; background: #FFFFFF; box-shadow: 0 1px 2px rgba(30, 25, 21, 0.12), 0 4px 12px -4px rgba(30, 25, 21, 0.18); transition: transform var(--fd-dur-3) var(--fd-spring); }
1257.hal-date-btn { display: flex; align-items: baseline; gap: 8px; height: 32px; font: 400 20px/32px var(--hal-serif); letter-spacing: -0.01em; color: var(--hal-ink); white-space: nowrap; }
1258.hal-date-btn small { font: 400 14px/1 var(--hal-sans); color: var(--hal-ink-2); }
1259.hal-pop { position: absolute; left: 10px; bottom: calc(100% + 14px); width: 360px; padding: 18px; border-radius: 20px; background: var(--hal-paper); box-shadow: 0 0 0 1px var(--hal-line), 0 30px 60px -20px rgba(30, 25, 21, 0.4); animation: hal-pop var(--fd-dur-3) var(--fd-ease-out); }
1260.hal-book.is-docked .hal-pop { bottom: auto; top: calc(100% + 14px); }
1261@keyframes hal-pop { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
1262.hal-pop-title { font: 400 18px/1.2 var(--hal-serif); margin-bottom: 12px; color: var(--hal-ink); }
1263.hal-pop-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
1264.hal-pop-grid button { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 52px; justify-content: center; border-radius: 14px; font: 500 16px/1 var(--hal-sans); color: var(--hal-ink); transition: background-color var(--fd-dur-1) var(--fd-ease-out); }
1265.hal-pop-grid button small { font: 500 10px/1 var(--hal-sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--hal-ink-2); }
1266.hal-pop-grid button:hover { background: rgba(30, 25, 21, 0.06); }
1267.hal-pop-grid button.is-on { background: var(--hal-ink); color: var(--hal-paper); }
1268.hal-pop-grid button.is-on small { color: rgba(251, 248, 243, 0.7); }
1269.hal-step { display: flex; align-items: center; gap: 10px; height: 32px; }
1270.hal-step button { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--hal-line); font: 400 18px/1 var(--hal-sans); color: var(--hal-ink); text-align: center; transition: background-color var(--fd-dur-1) var(--fd-ease-out), transform var(--fd-dur-2) var(--fd-spring); }
1271.hal-step button:hover { background: rgba(30, 25, 21, 0.06); }
1272.hal-step button:active { transform: scale(0.9); }
1273.hal-step output { min-width: 18px; text-align: center; font: 400 20px/1 var(--hal-serif); color: var(--hal-ink); font-variant-numeric: tabular-nums; }
1274.hal-cta-short { display: none; }
1275.hal-book-cta { height: 64px; padding: 0 28px; transition: height var(--fd-dur-3) var(--fd-ease-out), background-color var(--fd-dur-2) var(--fd-ease-out), transform var(--fd-dur-2) var(--fd-spring); }
1276.hal-book.is-docked .hal-book-cta { height: 48px; }
1277.hal-book-result { position: absolute; left: 50%; top: calc(100% + 12px); transform: translate(-50%, -6px); padding: 10px 18px; border-radius: 9999px; background: var(--hal-ink); color: var(--hal-paper); font: 500 13px/1.3 var(--hal-sans); white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity var(--fd-dur-3) var(--fd-ease-out), transform var(--fd-dur-3) var(--fd-ease-out); }
1278.hal-book-result.is-on { opacity: 1; transform: translate(-50%, 0); }
1279.hal-book:not(.is-docked) .hal-book-result { top: auto; bottom: calc(100% + 12px); }
1280
1281/* REVEALS */
1282.hal-js [data-hal-rv] { opacity: 0; transform: translate3d(0, 28px, 0); transition: opacity 1s var(--fd-ease-out), transform 1.1s var(--fd-ease-out); transition-delay: var(--d, 0ms); }
1283.hal-js [data-hal-rv].is-in { opacity: 1; transform: none; }
1284
1285/* INTRO */
1286.hal-intro { padding: 180px 0 160px; }
1287.hal-intro-grid { display: grid; grid-template-columns: 1fr 1.05fr; gap: 96px; align-items: start; }
1288.hal-intro-text { position: relative; padding-top: 40px; }
1289.hal-intro-text .hal-h2 { margin: 22px 0 36px; }
1290.hal-drop::first-letter { float: left; font: 400 88px/0.82 var(--hal-serif); color: var(--hal-terra); margin: 8px 12px 0 0; }
1291.hal-intro-text .hal-body { margin-top: 22px; max-width: 520px; }
1292.hal-figs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; margin-top: 56px; padding-top: 28px; border-top: 1px solid var(--hal-line); }
1293.hal-figs dt { font: 500 12px/1.3 var(--hal-sans); letter-spacing: 0.12em; text-transform: uppercase; color: var(--hal-ink-2); }
1294.hal-figs dd { margin-top: 8px; font: 400 44px/1 var(--hal-serif); letter-spacing: -0.025em; color: var(--hal-ink); }
1295.hal-intro-media { position: relative; height: 820px; --p: 0; }
1296.hal-clip { position: absolute; margin: 0; }
1297.hal-clip-a { right: 0; top: 0; width: 78%; height: 620px; transform: translate3d(0, calc(var(--p) * -40px), 0); }
1298.hal-clip-b { left: 0; bottom: 0; width: 46%; height: 380px; z-index: 2; transform: translate3d(0, calc(var(--p) * -110px), 0); }
1299.hal-clip-in { width: 100%; height: calc(100% - 34px); overflow: hidden; border-radius: 4px; background: #D9C4A6; }
1300.hal-clip-b .hal-clip-in { box-shadow: 0 0 0 10px var(--hal-sand); }
1301.hal-clip figcaption { margin-top: 12px; font: italic 400 15px/1.3 var(--hal-serif); color: var(--hal-ink-2); }
1302.hal-js .hal-clip[data-hal-rv] { opacity: 1; transform: translate3d(0, calc(var(--p) * -40px), 0); }
1303.hal-js .hal-clip-b[data-hal-rv] { transform: translate3d(0, calc(var(--p) * -110px), 0); }
1304.hal-js .hal-clip .hal-clip-in { clip-path: inset(100% 0 0 0); transition: clip-path 1.4s var(--fd-ease-out); }
1305.hal-js .hal-clip .hal-scene { transform: scale(1.18); transition: transform 2s var(--fd-ease-out); }
1306.hal-js .hal-clip.is-in .hal-clip-in { clip-path: inset(0 0 0 0); }
1307.hal-js .hal-clip.is-in .hal-scene { transform: scale(1); }
1308.hal-js .hal-clip-b .hal-clip-in { transition-delay: 220ms; }
1309.hal-js .hal-clip figcaption { opacity: 0; transition: opacity 1s var(--fd-ease-out) 700ms; }
1310.hal-js .hal-clip.is-in figcaption { opacity: 1; }
1311
1312/* HOUSES — pinned horizontal */
1313.hal-houses { position: relative; height: calc(100vh + var(--dist, 0px)); height: calc(100svh + var(--dist, 0px)); background: var(--hal-paper); --p: 0; }
1314.hal-houses-pin { position: sticky; top: 0; height: 100vh; height: 100svh; min-height: 640px; display: flex; flex-direction: column; justify-content: center; gap: 44px; overflow: hidden; padding-top: 150px; }
1315.hal-houses-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; }
1316.hal-houses-head .hal-h2 { margin-top: 16px; }
1317.hal-houses-meter { display: flex; align-items: center; gap: 18px; padding-bottom: 10px; }
1318.hal-houses-bar { position: relative; display: block; width: 180px; height: 1px; background: var(--hal-line); }
1319.hal-houses-bar i { position: absolute; inset: 0; background: var(--hal-ink); transform: scaleX(calc(0.25 + var(--p) * 0.75)); transform-origin: left; }
1320.hal-houses-cur { font-weight: 600; color: var(--hal-ink); }
1321.hal-houses-count { font: 500 13px/1 var(--hal-sans); letter-spacing: 0.12em; color: var(--hal-ink-2); font-variant-numeric: tabular-nums; }
1322.hal-houses-track { display: flex; gap: 32px; padding: 0 40px; width: max-content; will-change: transform; }
1323.hal-house { flex: 0 0 auto; width: min(58vw, 860px); }
1324.hal-house-link { display: grid; grid-template-columns: 1.35fr 1fr; gap: 32px; height: min(58vh, 560px); color: var(--hal-ink); }
1325.hal-house-img { position: relative; overflow: hidden; border-radius: 4px; background: #D9C4A6; }
1326.hal-house-par { position: absolute; inset: 0 -12%; transform: translate3d(calc(var(--p) * 14% - 7%), 0, 0); }
1327.hal-house-link:hover .hal-house-par .hal-scene { transform: scale(1.04); }
1328.hal-house-par .hal-scene { transition: transform 1.2s var(--fd-ease-out); }
1329.hal-house-info { display: flex; flex-direction: column; justify-content: space-between; padding: 8px 0 4px; }
1330.hal-house-n { font: italic 400 20px/1 var(--hal-serif); color: var(--hal-terra); }
1331.hal-house-info .hal-h3 { font-size: 40px; letter-spacing: -0.025em; }
1332.hal-house-place { margin-top: 10px; font: 500 13px/1.4 var(--hal-sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--hal-ink-2); }
1333.hal-house-note { margin-top: 22px; font: 400 17px/1.65 var(--hal-sans); color: var(--hal-ink-2); max-width: 34ch; }
1334.hal-house-meta { display: flex; justify-content: space-between; padding-top: 18px; border-top: 1px solid var(--hal-line); font: 400 20px/1 var(--hal-serif); color: var(--hal-ink); }
1335
1336/* ROOMS */
1337.hal-rooms { padding: 180px 0 140px; }
1338.hal-sec-head { max-width: 760px; margin-bottom: 64px; }
1339.hal-sec-head .hal-h2 { margin-top: 18px; }
1340.hal-sec-sub { margin-top: 22px; max-width: 560px; }
1341.hal-room-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; }
1342.hal-room-link { display: block; color: var(--hal-ink); }
1343.hal-room-img { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border-radius: 4px; background: #D9C4A6; }
1344.hal-room-img .hal-scene { transition: transform 1.1s var(--fd-ease-out); }
1345.hal-room-img::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(30, 25, 21, 0) 60%, rgba(30, 25, 21, 0.18)); opacity: 0; transition: opacity var(--fd-dur-4) var(--fd-ease-out); }
1346.hal-room-link:hover .hal-room-img .hal-scene, .hal-room-link:focus-visible .hal-room-img .hal-scene { transform: scale(1.07); }
1347.hal-room-link:hover .hal-room-img::after { opacity: 1; }
1348.hal-room-body { padding-top: 22px; }
1349.hal-room-house { font: 500 12px/1.4 var(--hal-sans); letter-spacing: 0.14em; text-transform: uppercase; color: var(--hal-terra); }
1350.hal-room-body .hal-h3 { margin-top: 10px; }
1351.hal-room-feat { margin-top: 8px; font: 400 15px/1.5 var(--hal-sans); color: var(--hal-ink-2); }
1352.hal-room-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--hal-line); font: 400 15px/1 var(--hal-sans); color: var(--hal-ink-2); }
1353.hal-room-foot strong { font: 400 20px/1 var(--hal-serif); color: var(--hal-ink); }
1354.hal-room-cta { display: inline-flex; align-items: center; gap: 8px; font: 500 14px/1 var(--hal-sans); color: var(--hal-ink); }
1355.hal-room-cta svg { width: 16px; height: 16px; transition: transform var(--fd-dur-3) var(--fd-ease-out); }
1356.hal-room-link:hover .hal-room-cta svg { transform: translateX(4px); }
1357
1358/* RITUALS */
1359.hal-rituals { padding: 140px 0; background: var(--hal-night); color: #F3ECE2; }
1360.hal-rituals .hal-h2 { color: #F7F0E6; margin: 18px 0 44px; }
1361.hal-rituals .hal-eyebrow { color: #E6A47F; }
1362.hal-rituals-grid { display: grid; grid-template-columns: 0.9fr 1.1fr; gap: 96px; align-items: center; }
1363.hal-rituals-frame { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border-radius: 4px; background: #2A221D; }
1364.hal-rituals-img { position: absolute; inset: 0; opacity: 0; transform: scale(1.06); transition: opacity var(--fd-dur-4) var(--fd-ease-in-out), transform 1.4s var(--fd-ease-out); }
1365.hal-rituals-img.is-on { opacity: 1; transform: scale(1); }
1366.hal-rituals-time { position: absolute; left: 24px; bottom: 20px; font: 400 64px/1 var(--hal-serif); letter-spacing: -0.04em; color: #FFF8EE; text-shadow: 0 2px 30px rgba(0, 0, 0, 0.35); font-variant-numeric: tabular-nums; }
1367.hal-rituals-list { border-top: 1px solid rgba(243, 236, 226, 0.16); }
1368.hal-ritual { display: grid; grid-template-columns: 72px 1fr; grid-template-rows: auto auto; column-gap: 20px; row-gap: 6px; width: 100%; padding: 26px 0; border-bottom: 1px solid rgba(243, 236, 226, 0.16); color: rgba(243, 236, 226, 0.6); text-align: left; transition: color var(--fd-dur-3) var(--fd-ease-out), padding var(--fd-dur-3) var(--fd-ease-out); }
1369.hal-ritual-time { grid-row: span 2; font: 500 14px/2 var(--hal-sans); letter-spacing: 0.1em; font-variant-numeric: tabular-nums; }
1370.hal-ritual-title { font: 400 30px/1.15 var(--hal-serif); letter-spacing: -0.015em; }
1371.hal-ritual-where { font: 400 15px/1.4 var(--hal-sans); color: rgba(243, 236, 226, 0.6); }
1372.hal-ritual.is-on, .hal-ritual:hover { color: #FFF8EE; padding-left: 12px; }
1373.hal-ritual.is-on .hal-ritual-time { color: #E6A47F; }
1374.hal-ritual:focus-visible { outline-color: #E6A47F; }
1375
1376/* QUOTES */
1377.hal-quotes { padding: 160px 0; background: var(--hal-sand); }
1378.hal-quotes-in { display: flex; flex-direction: column; align-items: center; text-align: center; }
1379.hal-quote-stack { display: grid; width: 100%; max-width: 980px; margin-top: 36px; }
1380.hal-quote { grid-area: 1 / 1; opacity: 0; transform: translateY(10px); filter: blur(4px); transition: opacity 900ms var(--fd-ease-in-out), transform 900ms var(--fd-ease-out), filter 900ms var(--fd-ease-out); pointer-events: none; }
1381.hal-quote.is-on { opacity: 1; transform: none; filter: none; pointer-events: auto; }
1382.hal-quote blockquote p { font: italic 400 clamp(26px, 3.2vw, 44px)/1.3 var(--hal-serif); letter-spacing: -0.015em; color: var(--hal-ink); text-wrap: balance; }
1383.hal-quote figcaption { display: flex; flex-direction: column; gap: 6px; margin-top: 32px; font: 400 15px/1.4 var(--hal-sans); color: var(--hal-ink-2); }
1384.hal-quote figcaption strong { font: 500 15px/1.4 var(--hal-sans); color: var(--hal-ink); }
1385.hal-quote-dots { display: flex; gap: 10px; margin-top: 44px; }
1386.hal-quote-dots button { position: relative; width: 44px; height: 44px; }
1387.hal-quote-dots button::before { content: ""; position: absolute; left: 4px; right: 4px; top: 21px; height: 2px; background: var(--hal-line); }
1388.hal-quote-dots i { position: absolute; left: 4px; right: 4px; top: 21px; height: 2px; background: var(--hal-ink); transform: scaleX(0); transform-origin: left; }
1389.hal-quote-dots button.is-on i { animation: hal-fill 6.5s linear forwards; }
1390@keyframes hal-fill { to { transform: scaleX(1); } }
1391
1392/* MAP */
1393.hal-map { padding: 160px 0; background: var(--hal-paper); }
1394.hal-map-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
1395.hal-map-card { margin: 0; }
1396.hal-map-svg { width: 100%; height: auto; border-radius: 4px; }
1397.hal-map-card figcaption { display: grid; grid-template-columns: 160px 1fr; gap: 20px; margin-top: 22px; font: 400 15px/1.6 var(--hal-sans); color: var(--hal-ink-2); }
1398.hal-map-card figcaption strong { font: 400 24px/1.2 var(--hal-serif); color: var(--hal-ink); }
1399.hal-map-t { font: 500 13px/1 var(--hal-sans); fill: #3E342D; letter-spacing: 0.02em; }
1400.hal-map-t-sm { font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; fill: #7A6D62; }
1401.hal-map-t-sea { font: italic 400 15px/1 var(--hal-serif); fill: #50727C; }
1402.hal-js .hal-draw { stroke-dasharray: 1200; stroke-dashoffset: 1200; transition: stroke-dashoffset 2.4s var(--fd-ease-out) 200ms; }
1403.hal-js .hal-draw-route { stroke-dasharray: 4 6; stroke-dashoffset: 0; opacity: 0; transition: opacity 1s var(--fd-ease-out) 1.2s; }
1404.hal-js .is-in .hal-draw { stroke-dashoffset: 0; }
1405.hal-js .is-in .hal-draw-route { opacity: 1; animation: hal-march 1.2s linear infinite; }
1406@keyframes hal-march { to { stroke-dashoffset: -20; } }
1407.hal-pin { transform-box: fill-box; }
1408.hal-js .hal-pin path, .hal-js .hal-pin circle { opacity: 0; transform: translateY(-36px); transition: opacity 400ms var(--fd-ease-out) var(--d), transform 700ms var(--fd-spring) var(--d); }
1409.hal-js .is-in .hal-pin path, .hal-js .is-in .hal-pin circle { opacity: 1; transform: none; }
1410
1411/* FOOTER */
1412.hal-foot { padding: 120px 0 40px; background: var(--hal-night); color: #E9E0D4; overflow: hidden; }
1413.hal-news { display: grid; grid-template-columns: 1.1fr 1fr; gap: 80px; align-items: end; padding-bottom: 96px; border-bottom: 1px solid rgba(243, 236, 226, 0.14); }
1414.hal-eyebrow-light { color: #E6A47F; }
1415.hal-news-title { color: #F7F0E6; margin-top: 18px; }
1416.hal-news-sub { margin-top: 18px; max-width: 470px; font: 400 17px/1.65 var(--hal-sans); color: rgba(243, 236, 226, 0.72); }
1417.hal-news-label { display: block; margin-bottom: 12px; font: 500 12px/1 var(--hal-sans); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(243, 236, 226, 0.72); }
1418.hal-news-row { display: flex; gap: 8px; padding: 6px; border-radius: 9999px; background: rgba(243, 236, 226, 0.08); box-shadow: inset 0 0 0 1px rgba(243, 236, 226, 0.16); transition: box-shadow var(--fd-dur-2) var(--fd-ease-out); }
1419.hal-news-row:focus-within { box-shadow: inset 0 0 0 1px rgba(230, 164, 127, 0.8); }
1420.hal-news-row input { flex: 1; height: 52px; padding: 0 20px; border-radius: 9999px; background: transparent; font: 400 17px/1 var(--hal-sans); color: #FBF3E8; outline: none; }
1421.hal-news-row input::placeholder { color: rgba(243, 236, 226, 0.5); }
1422.hal-news-row.is-done { box-shadow: inset 0 0 0 1px rgba(230, 164, 127, 0.8); }
1423.hal-news-row.is-done .hal-btn { background: #E6A47F; color: var(--hal-night); }
1424.hal-news-note { margin-top: 14px; font: 400 14px/1.5 var(--hal-sans); color: rgba(243, 236, 226, 0.62); }
1425.hal-foot-cols { display: grid; grid-template-columns: repeat(4, 1fr); gap: 40px; padding: 72px 0; }
1426.hal-foot-h { font: 500 12px/1 var(--hal-sans); letter-spacing: 0.16em; text-transform: uppercase; color: rgba(243, 236, 226, 0.6); margin-bottom: 20px; }
1427.hal-foot-cols li { margin-bottom: 12px; font: 400 15px/1.4 var(--hal-sans); color: #F3ECE2; }
1428.hal-foot-cols a { color: #F3ECE2; transition: color var(--fd-dur-1) var(--fd-ease-out); }
1429.hal-foot-cols a:hover { color: #E6A47F; }
1430.hal-foot-mark { font: 400 clamp(88px, 21vw, 340px)/0.8 var(--hal-serif); letter-spacing: -0.05em; color: transparent; background: linear-gradient(180deg, rgba(243, 236, 226, 0.22), rgba(243, 236, 226, 0.02)); -webkit-background-clip: text; background-clip: text; text-align: center; white-space: nowrap; margin: 0 -40px; user-select: none; }
1431.hal-foot-legal { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; padding-top: 28px; border-top: 1px solid rgba(243, 236, 226, 0.14); font: 400 13px/1.5 var(--hal-sans); color: rgba(243, 236, 226, 0.62); }
1432.hal-foot-legal span:last-child { display: flex; gap: 22px; }
1433.hal-foot-legal a { color: rgba(243, 236, 226, 0.78); }
1434.hal-foot-legal a:hover { color: #FBF3E8; }
1435
1436/* RESPONSIVE */
1437@media (max-width: 1080px) {
1438 .hal-nav-links { gap: 22px; }
1439 .hal-intro-grid { gap: 56px; }
1440 .hal-book-card { grid-template-columns: auto 1.2fr 0.9fr 0.9fr auto; padding-left: 16px; }
1441 .hal-field { padding: 0 14px; }
1442 .hal-book-cta { padding: 0 20px; }
1443}
1444@media (max-width: 900px) {
1445 .hal-wrap { padding: 0 24px; }
1446 .hal-nav-bar { padding: 0 20px; }
1447 .hal-nav-links, .hal-nav-lang { display: none; }
1448 .hal-burger { display: block; }
1449 .hal-hero-copy { padding: var(--hal-nav-h) 24px 150px; }
1450 .hal-hero-scroll { display: none; }
1451 .hal-intro { padding: 120px 0 100px; }
1452 .hal-intro-grid, .hal-rituals-grid, .hal-news { grid-template-columns: 1fr; gap: 56px; }
1453 .hal-intro-media { height: 640px; }
1454 .hal-houses { height: auto; }
1455 .hal-houses-pin { position: relative; height: auto; min-height: 0; padding: 110px 0 90px; }
1456 .hal-houses-meter { display: none; }
1457 .hal-houses-track { width: auto; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 12px; scrollbar-width: none; transform: none !important; }
1458 .hal-houses-track::-webkit-scrollbar { display: none; }
1459 .hal-house { width: 82vw; scroll-snap-align: center; }
1460 .hal-house-link { grid-template-columns: 1fr; height: auto; gap: 20px; }
1461 .hal-house-img { aspect-ratio: 4 / 3; }
1462 .hal-house-par { inset: 0; transform: none; }
1463 .hal-house-note { margin-top: 14px; }
1464 .hal-house-meta { margin-top: 18px; }
1465 .hal-rooms { padding: 110px 0; }
1466 .hal-room-grid { grid-template-columns: 1fr; gap: 48px; }
1467 .hal-rituals { padding: 100px 0; }
1468 .hal-rituals-frame { aspect-ratio: 4 / 3; }
1469 .hal-quotes, .hal-map { padding: 110px 0; }
1470 .hal-map-grid { grid-template-columns: 1fr; }
1471 .hal-foot-cols { grid-template-columns: 1fr 1fr; }
1472}
1473@media (max-width: 760px) {
1474 .hal-book { padding: 0 12px; height: auto; margin-top: -196px; bottom: 12px; top: calc(var(--hal-nav-s) + 6px); }
1475 .hal-book-card { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto auto; height: auto; padding: 14px; gap: 10px 0; border-radius: 22px; }
1476 .hal-field-dest { grid-column: 1 / -1; padding: 0; }
1477 .hal-field-dest .hal-label { display: none; }
1478 .hal-field-date { grid-column: 1 / -1; padding: 0 4px; border-left: 0; }
1479 .hal-field { padding: 0 4px; height: auto; gap: 4px; }
1480 .hal-field:nth-child(4) { padding-left: 16px; }
1481 .hal-field:nth-child(3) { border-left: 0; }
1482 .hal-book-cta { grid-column: 1 / -1; height: 52px; }
1483 .hal-label { font-size: 10px; }
1484 .hal-book.is-docked .hal-book-card { height: auto; grid-template-columns: 1fr auto; grid-template-rows: auto; gap: 8px; padding: 6px; border-radius: 9999px; }
1485 .hal-book.is-docked .hal-field:nth-child(3), .hal-book.is-docked .hal-field:nth-child(4), .hal-book.is-docked .hal-field-date { display: none; }
1486 .hal-book.is-docked .hal-field-dest { grid-column: 1; }
1487 .hal-book.is-docked .hal-book-cta { grid-column: 2; height: 44px; padding: 0 18px; }
1488 .hal-book.is-docked .hal-cta-full { display: none; }
1489 .hal-book.is-docked .hal-cta-short { display: inline; }
1490 .hal-pop { width: calc(100vw - 48px); left: -4px; }
1491 .hal-pop-grid { grid-template-columns: repeat(4, 1fr); }
1492 .hal-book-result { white-space: normal; width: max-content; max-width: calc(100vw - 48px); text-align: center; }
1493 .hal-hero-copy { padding: calc(var(--hal-nav-h) + 36px) 24px 290px; justify-content: flex-start; }
1494 /* Portrait crop: frame the sea + the cliff edge (viewBox x≈700–1125) instead of the centre. */
1495 .hal-layer { left: -643px; right: auto; width: 1469px; }
1496 .hal-hero-title { font-size: clamp(48px, 14vw, 64px); }
1497 .hal-hero-sub { font-size: 15px; margin-top: 18px; }
1498 .hal-hero-eyebrow { margin-bottom: 18px; font-size: 11px; gap: 8px; }
1499}
1500@media (max-width: 560px) {
1501 .hal-h3 { font-size: 24px; }
1502 .hal-house-info .hal-h3 { font-size: 32px; }
1503 .hal-intro-media { height: 520px; }
1504 .hal-clip-a { width: 86%; height: 400px; }
1505 .hal-clip-b { width: 54%; height: 250px; }
1506 .hal-figs dd { font-size: 32px; }
1507 .hal-lede { font-size: 21px; }
1508 .hal-drop::first-letter { font-size: 64px; }
1509 .hal-ritual-title { font-size: 24px; }
1510 .hal-ritual { grid-template-columns: 56px 1fr; }
1511 .hal-rituals-time { font-size: 44px; }
1512 .hal-map-card figcaption { grid-template-columns: 1fr; gap: 6px; }
1513 .hal-foot-legal { flex-direction: column; }
1514 .hal-news-row { flex-direction: column; border-radius: 22px; }
1515 .hal-news-row .hal-btn { width: 100%; }
1516}
1517
1518@media (prefers-reduced-motion: reduce) {
1519 .hal *, .hal *::before, .hal *::after { animation-duration: 1ms !important; animation-iteration-count: 1 !important; transition-duration: 120ms !important; transition-delay: 0ms !important; }
1520 .hal-hero { height: 100vh; height: 100svh; }
1521 .hal-layer, .hal-hero-copy, .hal-house-par, .hal-clip-a, .hal-clip-b, .hal-js .hal-clip[data-hal-rv], .hal-js .hal-clip-b[data-hal-rv] { transform: none !important; }
1522 .hal-houses { height: auto; }
1523 .hal-houses-pin { position: relative; height: auto; min-height: 0; padding: 110px 0 90px; }
1524 .hal-houses-track { width: auto; overflow-x: auto; scroll-snap-type: x mandatory; transform: none !important; }
1525 .hal-house { scroll-snap-align: start; }
1526 .hal-houses-meter, .hal-hero-scroll { display: none; }
1527 .hal-js [data-hal-rv] { transform: none; }
1528 .hal-js .hal-clip .hal-clip-in { clip-path: none; }
1529 .hal-js .hal-clip .hal-scene { transform: none; }
1530}
1531@media (prefers-contrast: more) {
1532 .hal-book-card, .hal-nav.is-small .hal-nav-bar { background: var(--hal-paper); -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: 0 0 0 1px var(--hal-ink); }
1533}
1534@media (prefers-reduced-transparency: reduce) {
1535 .hal-book-card, .hal-nav.is-small .hal-nav-bar { background: var(--hal-paper); -webkit-backdrop-filter: none; backdrop-filter: none; }
1536}
1537`;

The design brief. For you and your AI agent.

Tokens, type, layout, components and motion — written from the real values in the file, so a model can extend the page without drifting.

Halcyon — DESIGN.md

Boutique hotels on the Amalfi coast and in Kyoto. Warm luxury editorial: a large serif display face with tight tracking, sand / ink / terracotta palette, generous whitespace, and procedural "photography" (inline SVG landscapes and rooms, no image files).

All CSS lives in Page.tsx under the .hal prefix. Every element is reset explicitly (margin, padding, border, font, appearance, list-style, box-sizing), so the page looks the same under Tailwind preflight, Bootstrap or no reset at all.

1. Palette

RoleTokenHex
Page background (sand)--hal-sand#F3ECE2
Raised surface / houses / map band--hal-paper#FBF8F3
Primary text, ink buttons--hal-ink#1E1915
Secondary text--hal-ink-2#5B5048
Accent: eyebrows, italic emphasis, primary CTA--hal-terra#A9502B (hover #923F1E)
Secondary terracotta (reserved)--hal-terra-2#C8693F
Sea (map routes)--hal-sea#2F5866
Dark bands (rituals, footer)--hal-night#191513
Hairlines--hal-linergba(30,25,21,0.14)
Accent on dark—#E6A47F
Light text on dark—#F3ECE2 / #F7F0E6 / #FBF3E8

The hero illustration has its own golden-hour palette (sky #D9C6B8 → #EED3B2 → #F6E2C6, sea #9EAEAA → #2B4D5A, cliff #9C6A4D → #5A3A2B, bougainvillea #B3466A/#C95C80) and a dusk palette that fades in over it (sky #262640 → #5E4560 → #C2735D → #E9A170, sea #B07A6E → #1C2635, window lights #FFD48E).

Contrast: ink on sand is about 15:1, --hal-ink-2 on sand about 7:1, terracotta on sand about 5:1 (AA for text). On night, #E6A47F is about 8:1.

2. Typography

  • Display serif (--hal-serif): ui-serif, "New York", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif. Weight 400 only; italic is used for emphasis.
  • UI sans (--hal-sans): ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif.
StyleSpec
Hero H1serif 400, clamp(56px, 7.4vw, 120px) / 0.98, tracking −0.04em, max-width 9.5em; mobile clamp(48px, 14vw, 64px)
H2serif 400, clamp(32px, 4.2vw, 64px) / 1.06, −0.025em, text-wrap: balance
H3serif 400, 28px / 1.15, −0.015em (house names 40px, −0.025em)
Ledeserif 400, 24px / 1.5, −0.01em; drop cap 88px terracotta
Quoteserif italic, clamp(26px, 3.2vw, 44px) / 1.3
Ritual time on frameserif 64px, −0.04em, tabular numerals
Bodysans 400, 17px / 1.7, --hal-ink-2
Eyebrowsans 500, 12px, tracking 0.18em, uppercase, terracotta
Nav linksans 500, 14px, 0.02em
Logoserif 22px, tracking 0.22em, uppercase
Field labelsans 500, 11px, 0.16em uppercase

3. Spacing, layout, radii

  • Container: max-width: 1280px, side padding 40px (24px at ≤900px).
  • Section padding: 160–180px top/bottom on desktop, 100–120px on mobile.
  • Radii: imagery 4px (deliberately near-square and editorial). Controls are capsules (9999px). Booking card 24px, docked 9999px. Date popover 20px, date cells 14px.
  • Elevation: booking card 0 24px 60px -24px rgba(30,25,21,.35) plus a 1px ring; when docked, 0 12px 30px -16px.

4. Motion tokens

--fd-ease-out:    cubic-bezier(0.22, 1, 0.36, 1)   entrances, reveals
--fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1)   crossfades, state swaps
--fd-spring:      cubic-bezier(0.34, 1.56, 0.64, 1) segmented thumb, map pins, steppers
--fd-dur-1 120ms · --fd-dur-2 240ms · --fd-dur-3 420ms · --fd-dur-4 700ms

One rAF scroll loop writes a --p progress value (0→1) onto every [data-hal-p] element. pin mode measures progress through a tall sticky section; view mode measures progress across the viewport. All scroll effects animate transform and opacity only.

Choreography

  1. Load. The headline lines rise out of masks (1.2s, 140ms stagger). The sub-line fades up at 500ms. Landscape layers settle from 3% below (1.6s, staggered 0/80/160ms).
  2. Hero, pinned for 190vh, time of day. As --p goes 0→1:
  3. the sun sinks 21% behind the sea
  4. the cliff drops 5% and drifts 2% right; the foreground bougainvillea and parapet fall 16% and scale to 1.06
  5. mist drifts left
  6. dusk overlays fade in with opacity: var(--p); window lights appear after p≈0.35 and stars after p≈0.55
  7. the live clock in the eyebrow counts 17:42 → 20:16
  8. after p>0.45 the copy switches to light text (.is-dusk, 700ms colour transition)
  9. the copy drifts up 60px and fades out near the end.
  10. Booking bar. Sticky with both bottom: 24px and top: nav + 10px. It floats at the bottom of the first screen, then docks under the condensed nav. Docking (JS .is-docked, triggered when its top is under 120px) shrinks it from 96px to 68px, turns it into a capsule, and hides the field labels.
  11. Intro. Two figures reveal with clip-path: inset(100% 0 0 0) → inset(0) (1.4s; the second is delayed 220ms), while the inner scene scales 1.18→1. The two figures parallax at different rates (−40px and −110px across the viewport).
  12. Houses gallery, pinned horizontally. The section height is 100vh + track overflow. The track translates -p × overflow. Each image pans inside its frame (±7%). The meter fills from 25% to 100% and the counter reads 01–04.
  13. Rooms. Cards stagger in (90ms). On hover the image zooms 1.07 (1.1s) and the arrow nudges 4px.
  14. Rituals. Hovering, focusing or clicking a row crossfades the 4:5 frame (700ms in-out plus a 1.06→1 settle) and indents the row 12px.
  15. Testimonials. Autoplay every 6.5s with a crossfade, 10px lift and 4px blur clear (900ms). Progress dashes fill linearly. Hover or focus pauses it; it is disabled for reduced motion.
  16. Map. The coastline and rivers draw in (dash offset, 2.4s). Pins drop with the spring (300/520ms delays). The dashed routes fade in and then march.

5. Sections

  1. Sticky condensing nav. 84px and transparent, condensing to 60px with a sand glass background (blur(18px)) and hairline. Logo, five links (underline grows from the left), language, and an ink "Reserve" pill. Below 900px a burger opens a sheet with 28px serif links.
  2. Hero. Full-bleed layered SVG: sky, sun, far headland, sea with glints, a cliff village of about 120 deterministic houses, mist, and a bougainvillea branch with a stone parapet.
  3. Booking bar. Destination segmented control (spring thumb), arrival date popover (6-column day grid, May 2027), nights and guests steppers, terracotta CTA. The result pill is announced via aria-live.
  4. Intro. Two-column story with a drop cap and a 3-figure stats row (4 houses, 86 rooms, 61% returning), plus an overlapping two-image collage.
  5. Houses. Pinned horizontal gallery of 4 properties (image, number, name, place, note, rooms, from-price).
  6. Rooms. 3-up grid of 4:5 cards.
  7. Rituals. Dark band; sticky-looking frame plus a list of four timed rituals.
  8. Guest book. Centred italic quote crossfade with progress dashes.
  9. Getting here. Two illustrated maps (Amalfi, Kyoto) with transfer notes.
  10. Footer. Newsletter, four link columns, a giant gradient wordmark, and a legal row.

6. Components and states

  • Buttons. 52px capsules (40px small). Hover darkens the fill and moves the arrow 3px; press scales to 0.96 over 120ms and springs back.
  • Segmented control. A white thumb slides with --fd-spring (420ms); options use role="radio" and aria-checked.
  • Stepper. 32px round buttons that press to 0.9; the value sits in an <output aria-live>.
  • Date popover. Selected cell is ink-filled; it opens above the bar when floating and below it when docked.
  • Room card. Whole card is a link; the image zooms and gets a bottom gradient wash on hover or focus.
  • Ritual row. aria-pressed; active row has a terracotta time and light title.
  • Newsletter. Glass capsule on night. Focus-within shows a terracotta ring. After success the button turns #E6A47F and the note reads back the address.

7. Responsive

  • ≤1080px: tighter nav gaps, narrower booking columns.
  • ≤900px: burger nav; single-column intro, rituals and footer news. The gallery becomes a native horizontal scroll-snap row (82vw cards, no pinning). Rooms and maps stack.
  • ≤760px:
  • the booking card becomes a 2-row grid (destination, then date, then nights and guests, then a full-width CTA)
  • when docked it collapses to a capsule with the destination toggle plus a short "Search" CTA
  • hero layers are re-framed (1469px wide, offset −643px) so the portrait crop keeps the sea and cliff edge
  • copy is top-aligned under the nav.
  • ≤560px: smaller collage, 32px stats, stacked newsletter.

8. Accessibility and reduced motion

  • Skip link, landmarks, aria-labelledby on sections, labelled controls, aria-live for availability, quotes, steppers and newsletter. Focus ring is 2px terracotta at 70%, offset 2px.
  • prefers-reduced-motion:
  • hero is 100vh with no layer parallax
  • houses become a swipe row
  • no clip-path reveals or scale-ins
  • testimonial autoplay is off
  • all transitions are capped at 120ms.
  • prefers-contrast: more and prefers-reduced-transparency: the booking card and nav become opaque paper with an ink ring.
  • The page fixes a host quirk: if both <html> and <body> clip overflow (which makes <body> a non-scrolling scroll container and breaks sticky), it switches <body> to overflow-x: clip and restores it on unmount.

9. Do / Don't

  • Do keep the serif at weight 400 and let size and italics carry hierarchy.
  • Do reserve terracotta for one emphasis per view: an eyebrow, an italic word, or the primary CTA.
  • Do keep imagery nearly square-cornered (4px) and controls fully round.
  • Don't add drop shadows to imagery; depth comes from parallax and overlap.
  • Don't animate layout properties; scroll effects use --p with transform and opacity only.
  • Don't run more than one autoplaying element per screen.