1/**
2 * Kiln — a ceramics & design studio whose whole portfolio is an infinite,
3 * pannable, zoomable canvas.
4 *
5 * First screen: the canvas at overview zoom with the studio name cut into a
6 * clay slab. Project cards, notebook slips, process sketches and a studio map
7 * are scattered over the plane at different depths (parallax: nearer layers
8 * move faster than the camera). Drag with momentum, wheel/trackpad pan,
9 * ctrl/pinch zoom, arrow keys, minimap in the corner. Clicking (or Tab-focusing)
10 * a card flies the camera to it; Enter/click expands a case-study panel.
11 * "Return to overview" flies back. A List view toggle (default for
12 * prefers-reduced-motion) shows the same content as a plain grid.
13 *
14 * Below the canvas: manifesto words that fire as you scroll, a pinned
15 * "throwing" scene where a lump of clay morphs into a vase, a pinned kiln
16 * firing curve that draws itself while the peephole heats up, an interactive
17 * glaze library, a commission form and a footer.
18 *
19 * Only react is imported. No images, no web fonts, no network. Everything is
20 * procedural SVG / CSS. SSR-safe (no window/document during render).
21 */
22import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
23
24/* ================================================================== */
25/* Helpers */
26/* ================================================================== */
27function rng(seed: number) {
28 let a = (seed >>> 0) || 1;
29 return () => {
30 a |= 0;
31 a = (a + 0x6d2b79f5) | 0;
32 let t = Math.imul(a ^ (a >>> 15), 1 | a);
33 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
34 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35 };
36}
37const f1 = (n: number) => Math.round(n * 10) / 10;
38const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
39const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
40const ease = (t: number) => t * t * (3 - 2 * t);
41
42/** Catmull-Rom through points, emitted as cubic segments (no leading M). */
43function seg(pts: number[][]): string {
44 let d = "";
45 for (let i = 0; i < pts.length - 1; i++) {
46 const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
47 d += `C${f1(p1[0] + (p2[0] - p0[0]) / 6)} ${f1(p1[1] + (p2[1] - p0[1]) / 6)} ${f1(p2[0] - (p3[0] - p1[0]) / 6)} ${f1(p2[1] - (p3[1] - p1[1]) / 6)} ${f1(p2[0])} ${f1(p2[1])}`;
48 }
49 return d;
50}
51type Prof = number[][];
52/** Closed silhouette of a thrown form from a half-profile [radius, y]. */
53function outline(prof: Prof, cx: number): string {
54 const right = prof.map(([r, y]) => [cx + r, y]);
55 const left = prof.map(([r, y]) => [cx - r, y]).reverse();
56 return `M${f1(right[0][0])} ${f1(right[0][1])}${seg(right)}L${f1(left[0][0])} ${f1(left[0][1])}${seg(left)}Z`;
57}
58function rAt(prof: Prof, y: number): number {
59 for (let i = 0; i < prof.length - 1; i++) {
60 const a = prof[i], b = prof[i + 1];
61 if (y >= a[1] && y <= b[1]) return lerp(a[0], b[0], (y - a[1]) / Math.max(0.001, b[1] - a[1]));
62 }
63 return y < prof[0][1] ? prof[0][0] : prof[prof.length - 1][0];
64}
65
66/* ================================================================== */
67/* Glazes and thrown forms */
68/* ================================================================== */
69type Glz = { a: string; b: string; clay: string; speck: string; mode: "dip" | "full" | "bare"; line?: number };
70const GL: Record<string, Glz> = {
71 tide: { a: "#6B9AAB", b: "#2F5668", clay: "#B5573A", speck: "#1d3b4a", mode: "dip", line: 0.58 },
72 bone: { a: "#F1E8D6", b: "#CDBF9F", clay: "#B5573A", speck: "#7a5a3d", mode: "dip", line: 0.72 },
73 iron: { a: "#55433b", b: "#1f1a18", clay: "#A5502F", speck: "#c58a58", mode: "full" },
74 ember: { a: "#CF6A45", b: "#8A3320", clay: "#B5573A", speck: "#3a1a10", mode: "full" },
75 ochre: { a: "#DDAB6A", b: "#A8713A", clay: "#B5573A", speck: "#5a3a1a", mode: "dip", line: 0.46 },
76 raw: { a: "#D08A66", b: "#8F3F28", clay: "#C07049", speck: "#4A2E22", mode: "bare" },
77};
78const PROF: Record<string, Prof> = {
79 bowl: [[100, 110], [98, 124], [86, 152], [62, 178], [44, 194], [42, 200]],
80 vase: [[30, 40], [28, 56], [44, 92], [68, 128], [72, 158], [60, 188], [52, 200]],
81 mug: [[52, 100], [54, 130], [54, 170], [50, 194], [50, 200]],
82 lamp: [[16, 44], [24, 60], [62, 92], [104, 140], [122, 184], [124, 200]],
83 planter: [[84, 96], [86, 108], [74, 160], [62, 196], [60, 200]],
84 jug: [[24, 80], [28, 104], [54, 128], [68, 158], [64, 188], [56, 200]],
85 cup: [[34, 150], [36, 170], [30, 192], [26, 200]],
86};
87
88function Vessel({ prof, glaze, seed, handle }: { prof: Prof; glaze: Glz; seed: number; handle?: { y1: number; y2: number; out: number } }) {
89 const uid = "knv" + useId().replace(/[^a-zA-Z0-9]/g, "");
90 const cx = 150;
91 const g = glaze;
92 const calc = useMemo(() => {
93 const rnd = rng(seed);
94 const ys = prof.map((p) => p[1]);
95 const y0 = Math.min(...ys), y1 = Math.max(...ys);
96 const mr = Math.max(...prof.map((p) => p[0]));
97 const yb = y0 + (y1 - y0) * (g.line ?? 0.6);
98 let glazeD = "";
99 if (g.mode === "full") glazeD = `M${cx - mr - 20} ${y0 - 40}H${cx + mr + 20}V${y1 + 20}H${cx - mr - 20}Z`;
100 if (g.mode === "dip") {
101 const pts: number[][] = [];
102 for (let x = cx - mr - 12; x <= cx + mr + 12; x += 12) {
103 const drip = rnd() < 0.2 ? 6 + rnd() * 26 : 0;
104 pts.push([x, yb + Math.sin(x * 0.07 + seed) * 5 + drip]);
105 }
106 glazeD = `M${pts[0][0]} ${y0 - 40}L${pts[0][0]} ${f1(pts[0][1])}${seg(pts)}L${pts[pts.length - 1][0]} ${y0 - 40}Z`;
107 }
108 const specks: { x: number; y: number; r: number; l: boolean }[] = [];
109 const n = g.mode === "bare" ? 110 : 56;
110 for (let i = 0; i < n; i++) specks.push({ x: cx + (rnd() * 2 - 1) * mr, y: y0 + rnd() * (y1 - y0), r: 0.5 + rnd() * 1.2, l: rnd() < 0.3 });
111 const rings: { y: number; w: number }[] = [];
112 for (let y = y0 + 8; y < y1; y += 7.5) rings.push({ y, w: (rnd() - 0.5) * 3 });
113 const mott = [0, 1, 2].map(() => ({ x: cx + (rnd() * 2 - 1) * mr * 0.7, y: y0 + rnd() * (y1 - y0), rx: 14 + rnd() * 24, ry: 8 + rnd() * 14 }));
114 return { y0, y1, mr, yb, glazeD, specks, rings, mott, d: outline(prof, cx) };
115 }, [prof, seed, g]);
116 const { y0, y1, mr, glazeD, specks, rings, mott, d } = calc;
117 const rx0 = prof[0][0];
118 let handleEl: ReactNode = null;
119 if (handle) {
120 const ra = rAt(prof, handle.y1) - 2, rb = rAt(prof, handle.y2) - 2;
121 const hd = `M${cx + ra} ${handle.y1}C${cx + ra + handle.out} ${handle.y1 - 10} ${cx + rb + handle.out} ${handle.y2 + 10} ${cx + rb} ${handle.y2}`;
122 handleEl = (
123 <g fill="none" strokeLinecap="round">
124 <path d={hd} stroke={g.mode === "bare" ? g.clay : g.a} strokeWidth="11" />
125 <path d={hd} stroke="#000" strokeOpacity=".14" strokeWidth="3" transform="translate(1.5 2)" />
126 </g>
127 );
128 }
129 return (
130 <g>
131 <defs>
132 <clipPath id={uid + "c"}><path d={d} /></clipPath>
133 <linearGradient id={uid + "g"} x1="0" y1="0" x2="0" y2="1">
134 <stop offset="0" stopColor={g.a} /><stop offset="1" stopColor={g.b} />
135 </linearGradient>
136 <linearGradient id={uid + "s"} gradientUnits="userSpaceOnUse" x1={cx - mr} y1="0" x2={cx + mr} y2="0">
137 <stop offset="0" stopColor="#000" stopOpacity=".46" />
138 <stop offset=".16" stopColor="#000" stopOpacity=".1" />
139 <stop offset=".34" stopColor="#fff" stopOpacity=".26" />
140 <stop offset=".5" stopColor="#fff" stopOpacity="0" />
141 <stop offset=".82" stopColor="#000" stopOpacity=".16" />
142 <stop offset="1" stopColor="#000" stopOpacity=".52" />
143 </linearGradient>
144 </defs>
145 <ellipse cx={cx} cy={y1 + 2} rx={mr * 1.08} ry="8" fill="#26211F" opacity=".26" />
146 {handleEl}
147 <path d={d} fill={g.clay} />
148 <g clipPath={`url(#${uid}c)`}>
149 {mott.map((m, i) => <ellipse key={i} cx={m.x} cy={m.y} rx={m.rx} ry={m.ry} fill="#4A2E22" opacity={g.mode === "bare" ? 0.16 : 0.1} />)}
150 {glazeD && <path d={glazeD} fill={`url(#${uid}g)`} />}
151 {rings.map((r, i) => (
152 <g key={i}>
153 <path d={`M${cx - mr - 2} ${r.y}Q${cx} ${r.y + r.w} ${cx + mr + 2} ${r.y}`} stroke="#000" strokeOpacity=".08" fill="none" />
154 <path d={`M${cx - mr - 2} ${r.y + 1.6}Q${cx} ${r.y + r.w + 1.6} ${cx + mr + 2} ${r.y + 1.6}`} stroke="#fff" strokeOpacity=".09" fill="none" />
155 </g>
156 ))}
157 {specks.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.r} fill={s.l ? "#fff" : g.speck} opacity={s.l ? 0.3 : 0.55} />)}
158 <rect x={cx - mr} y={y0 - 4} width={mr * 2} height={y1 - y0 + 8} fill={`url(#${uid}s)`} />
159 <path d={`M${cx - mr * 0.5} ${y0 + (y1 - y0) * 0.22}V${y0 + (y1 - y0) * 0.62}`} stroke="#fff" strokeOpacity=".3" strokeWidth="4" strokeLinecap="round" />
160 </g>
161 <ellipse cx={cx} cy={y0} rx={rx0} ry={Math.max(3, rx0 * 0.16)} fill={g.mode === "bare" ? "#6b3220" : g.b} />
162 <ellipse cx={cx} cy={y0} rx={rx0} ry={Math.max(3, rx0 * 0.16)} fill="none" stroke={g.mode === "bare" ? "#E0A27E" : g.a} strokeWidth="2" opacity=".9" />
163 </g>
164 );
165}
166const at = (X: number, sc: number) => `translate(${f1(X - 150 * sc)} ${f1(206 - 200 * sc)}) scale(${sc})`;
167
168/* ================================================================== */
169/* Illustrations */
170/* ================================================================== */
171function Scene({ bg, children, floor = true }: { bg: string; children: ReactNode; floor?: boolean }) {
172 const id = "kns" + useId().replace(/[^a-zA-Z0-9]/g, "");
173 return (
174 <>
175 <defs>
176 <radialGradient id={id} cx=".5" cy=".4" r=".7"><stop offset="0" stopColor="#fff" stopOpacity=".28" /><stop offset="1" stopColor="#000" stopOpacity=".16" /></radialGradient>
177 </defs>
178 <rect width="300" height="260" fill={bg} />
179 <rect width="300" height="260" fill={`url(#${id})`} />
180 {floor && <><rect y="206" width="300" height="54" fill="#26211F" opacity=".14" /><line x1="0" x2="300" y1="206" y2="206" stroke="#26211F" strokeOpacity=".25" /></>}
181 {children}
182 </>
183 );
184}
185function Tiles() {
186 const cols = ["#3F6F86", "#B5573A", "#EFE6D6", "#C98F4F", "#8FB4BF", "#A8452B"];
187 const r = rng(11);
188 const out: ReactNode[] = [];
189 for (let j = 0; j < 3; j++) for (let i = 0; i < 4; i++) {
190 const x = 32 + i * 61, y = 44 + j * 61, k = (i * 2 + j * 3 + (i > 1 ? 1 : 0)) % cols.length;
191 const jit = () => (r() - 0.5) * 3.2;
192 const d = `M${x + jit()} ${y + jit()}L${x + 56 + jit()} ${y + jit()}L${x + 56 + jit()} ${y + 56 + jit()}L${x + jit()} ${y + 56 + jit()}Z`;
193 const dark = k === 2 || k === 3 || k === 4;
194 out.push(
195 <g key={i + "-" + j}>
196 <path d={d} fill={cols[k]} />
197 <path d={d} fill="#fff" opacity=".08" />
198 <path d={`M${x + 28} ${y + 7}L${x + 49} ${y + 28}L${x + 28} ${y + 49}L${x + 7} ${y + 28}Z M${x + 28} ${y + 17}L${x + 39} ${y + 28}L${x + 28} ${y + 39}L${x + 17} ${y + 28}Z`} fill="none" stroke={dark ? "#26211F" : "#EFE6D6"} strokeWidth="1.6" strokeOpacity=".8" />
199 {Array.from({ length: 6 }).map((_, n) => <circle key={n} cx={x + 4 + r() * 48} cy={y + 4 + r() * 48} r=".9" fill="#26211F" opacity=".35" />)}
200 </g>,
201 );
202 }
203 return <>{out}</>;
204}
205function Lamp({ X, sc, top, glaze, seed }: { X: number; sc: number; top: number; glaze: Glz; seed: number }) {
206 const gid = "knl" + useId().replace(/[^a-zA-Z0-9]/g, "");
207 const bottom = top + 156 * sc;
208 return (
209 <g>
210 <defs>
211 <radialGradient id={gid}><stop offset="0" stopColor="#FFE2A8" /><stop offset=".7" stopColor="#F2A857" /><stop offset="1" stopColor="#C8623F" /></radialGradient>
212 <linearGradient id={gid + "b"} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#FFD58E" stopOpacity=".42" /><stop offset="1" stopColor="#FFD58E" stopOpacity="0" /></linearGradient>
213 </defs>
214 <path d={`M${X - 118 * sc} ${bottom}L${X - 150 * sc - 20} 260H${X + 150 * sc + 20}L${X + 118 * sc} ${bottom}Z`} fill={`url(#${gid}b)`} />
215 <line x1={X} x2={X} y1="0" y2={top} stroke="#8a7a6c" strokeWidth="2" />
216 <g transform={`translate(${f1(X - 150 * sc)} ${f1(top - 44 * sc)}) scale(${sc})`}>
217 <Vessel prof={PROF.lamp} glaze={glaze} seed={seed} />
218 <ellipse cx="150" cy="200" rx="122" ry="17" fill={`url(#${gid})`} />
219 </g>
220 </g>
221 );
222}
223function Planter({ X, sc, glaze, seed }: { X: number; sc: number; glaze: Glz; seed: number }) {
224 const r = rng(seed + 9);
225 const y = 206 - 104 * sc;
226 return (
227 <g>
228 <g fill="none" stroke="#6c7a52" strokeWidth="2.4" strokeLinecap="round">
229 {Array.from({ length: 9 }).map((_, i) => {
230 const a = (i / 8) * 1.7 - 0.85, h = (52 + r() * 40) * sc;
231 return <path key={i} d={`M${X} ${y + 4}Q${X + Math.sin(a) * h * 0.4} ${y - h * 0.6} ${X + Math.sin(a) * h} ${y - h * Math.cos(a)}`} />;
232 })}
233 </g>
234 <g transform={at(X, sc)}><Vessel prof={PROF.planter} glaze={glaze} seed={seed} /></g>
235 </g>
236 );
237}
238function Art({ kind, label }: { kind: string; label?: string }) {
239 const common = { viewBox: "0 0 300 260", role: label ? "img" : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, preserveAspectRatio: "xMidYMid slice", focusable: false } as const;
240 switch (kind) {
241 case "bowl":
242 return <svg {...common}><Scene bg="#E4D7C0"><g transform={at(100, 0.92)}><Vessel prof={PROF.bowl} glaze={GL.tide} seed={3} /></g><g transform={at(240, 0.55)}><Vessel prof={PROF.bowl} glaze={GL.bone} seed={5} /></g></Scene></svg>;
243 case "vase":
244 return <svg {...common}><Scene bg="#C9D5D3"><g transform={at(52, 0.6)}><Vessel prof={PROF.vase} glaze={GL.ochre} seed={8} /></g><g transform={at(250, 0.5)}><Vessel prof={PROF.vase} glaze={GL.iron} seed={9} /></g><g transform={at(150, 1)}><Vessel prof={PROF.vase} glaze={GL.ember} seed={7} /></g></Scene></svg>;
245 case "mug":
246 return <svg {...common}><Scene bg="#C9805E"><g transform={at(225, 0.85)}><Vessel prof={PROF.mug} glaze={GL.ochre} seed={13} handle={{ y1: 118, y2: 178, out: 34 }} /></g><g transform={at(115, 1.15)}><Vessel prof={PROF.mug} glaze={GL.bone} seed={12} handle={{ y1: 118, y2: 178, out: 34 }} /></g></Scene></svg>;
247 case "lamp":
248 return <svg {...common}><Scene bg="#2A2321" floor={false}><Lamp X={46} sc={0.34} top={80} glaze={GL.bone} seed={2} /><Lamp X={256} sc={0.4} top={60} glaze={GL.ochre} seed={4} /><Lamp X={150} sc={0.78} top={42} glaze={GL.bone} seed={6} /></Scene></svg>;
249 case "planter":
250 return <svg {...common}><Scene bg="#DCCDB0"><Planter X={105} sc={1} glaze={GL.iron} seed={21} /><Planter X={236} sc={0.72} glaze={GL.raw} seed={22} /></Scene></svg>;
251 case "tea":
252 return <svg {...common}><Scene bg="#8FB4BF"><ellipse cx="150" cy="209" rx="140" ry="12" fill="#2A2321" /><ellipse cx="150" cy="206" rx="140" ry="11" fill="#3b312d" /><g transform={at(98, 0.92)}><Vessel prof={PROF.jug} glaze={GL.ochre} seed={31} handle={{ y1: 100, y2: 170, out: 34 }} /></g><g transform={at(212, 0.9)}><Vessel prof={PROF.cup} glaze={GL.bone} seed={32} /></g><g transform={at(262, 0.76)}><Vessel prof={PROF.cup} glaze={GL.bone} seed={33} /></g></Scene></svg>;
253 case "tiles":
254 return <svg {...common}><Scene bg="#2A2321" floor={false}><Tiles /></Scene></svg>;
255 case "profile":
256 return <Sketch kind="profile" label={label} />;
257 case "curve":
258 return <Sketch kind="curve" label={label} />;
259 case "foot":
260 return <Sketch kind="foot" label={label} />;
261 case "map":
262 return <MapArt label={label} />;
263 default:
264 return null;
265 }
266}
267
268/* Process sketches (pencil on paper) */
269const FIRE: number[][] = [[0, 20], [1, 120], [3, 570], [3.5, 610], [7.5, 1100], [9, 1222], [9.4, 1222], [10.5, 1050], [14, 600], [20, 120]];
270function Sketch({ kind, label }: { kind: "profile" | "curve" | "foot"; label?: string }) {
271 const id = "knk" + useId().replace(/[^a-zA-Z0-9]/g, "");
272 const ink = "#3a312c";
273 const base = { viewBox: "0 0 280 220", role: label ? "img" : undefined, "aria-label": label, "aria-hidden": label ? undefined : true, focusable: false } as const;
274 const paper = (
275 <>
276 <defs>
277 <pattern id={id + "h"} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" stroke={ink} strokeWidth=".8" opacity=".5" /></pattern>
278 </defs>
279 <rect width="280" height="220" fill="#F3ECDD" />
280 {Array.from({ length: 11 }).map((_, i) => <line key={i} x1="0" x2="280" y1={20 + i * 20} y2={20 + i * 20} stroke="#3F6F86" strokeOpacity=".13" />)}
281 <line x1="30" x2="30" y1="0" y2="220" stroke="#B5573A" strokeOpacity=".35" />
282 </>
283 );
284 if (kind === "profile") {
285 const p = PROF.vase.map(([r, y]) => [r * 0.95, y * 0.95 + 4]);
286 const cx = 150;
287 return (
288 <svg {...base}>
289 {paper}
290 <line x1={cx} x2={cx} y1="14" y2="208" stroke={ink} strokeDasharray="8 3 2 3" strokeWidth=".9" />
291 <path d={outline(p, cx)} fill="none" stroke={ink} strokeWidth="1.8" strokeLinejoin="round" />
292 <path d={outline(PROF.vase.map(([r, y]) => [r * 0.95 - 4, y * 0.95 + 4]), cx)} fill="none" stroke={ink} strokeWidth=".7" strokeOpacity=".5" />
293 {p.map(([r, y], i) => <circle key={i} cx={cx + r} cy={y} r="2.6" fill="#B5573A" />)}
294 <g stroke={ink} strokeWidth=".9" fill="none"><path d={`M${cx - p[4][0]} 196H${cx + p[4][0]}M${cx - p[4][0]} 192v8M${cx + p[4][0]} 192v8`} /><path d="M232 42V195M228 42h8M228 195h8" /></g>
295 <g fontFamily="ui-monospace,Menlo,monospace" fontSize="9" fill={ink}>
296 <text x="104" y="212">Ø 144 belly</text><text x="238" y="120" transform="rotate(90 238 120)">H 360 mm</text>
297 <text x="196" y="44">rim 6 mm</text><text x="40" y="90">pull 3: neck in</text><text x="38" y="160">throw 8% big</text>
298 </g>
299 </svg>
300 );
301 }
302 if (kind === "curve") {
303 const X = (h: number) => 34 + (h / 20) * 220, Y = (t: number) => 184 - (t / 1300) * 150;
304 const pts = FIRE.map(([h, t]) => [X(h), Y(t)]);
305 return (
306 <svg {...base}>
307 {paper}
308 <g stroke={ink} strokeWidth="1" fill="none"><path d="M34 28V184H258" /></g>
309 <path d={`M${pts[0][0]} ${pts[0][1]}${seg(pts)}`} fill="none" stroke="#B5573A" strokeWidth="2.4" strokeLinecap="round" />
310 <line x1="34" x2="258" y1={Y(1222)} y2={Y(1222)} stroke={ink} strokeDasharray="3 3" strokeOpacity=".5" />
311 <line x1={X(3.2)} x2={X(3.2)} y1="184" y2={Y(573)} stroke={ink} strokeDasharray="3 3" strokeOpacity=".5" />
312 <g fontFamily="ui-monospace,Menlo,monospace" fontSize="9" fill={ink}>
313 <text x="38" y={Y(1222) - 4}>cone 6 · 1222°C</text><text x={X(3.2) + 4} y={Y(573) + 12}>573 quartz</text>
314 <text x="214" y="196">20 h</text><text x="36" y="196">0</text><text x="200" y="130" transform="rotate(-24 200 130)">cool slowly</text>
315 </g>
316 </svg>
317 );
318 }
319 return (
320 <svg {...base}>
321 {paper}
322 <path d="M70 26L102 26L102 126L208 126L208 152L102 152L102 172L70 172Z" fill={`url(#${id}h)`} stroke={ink} strokeWidth="1.8" strokeLinejoin="round" />
323 <line x1="150" x2="150" y1="104" y2="126" stroke="#B5573A" strokeWidth="1.4" />
324 <path d="M118 64h8M118 64l5-3M118 64l5 3" stroke={ink} fill="none" />
325 <g stroke={ink} fill="none" strokeWidth=".9"><path d="M62 26V172M58 26h8M58 172h8" /><path d="M70 184H102M70 180v8M102 180v8" /></g>
326 <g fontFamily="ui-monospace,Menlo,monospace" fontSize="9" fill={ink}>
327 <text x="130" y="68">wall 5 mm</text><text x="152" y="108">trim here, 3 mm</text><text x="112" y="195">foot ring 8 mm</text><text x="44" y="102" transform="rotate(-90 44 102)">H 146</text>
328 </g>
329 </svg>
330 );
331}
332function MapArt({ label }: { label?: string }) {
333 const m = "ui-monospace,Menlo,monospace";
334 const room = (x: number, y: number, w: number, h: number, t: string, c: string) => (
335 <g key={t}><rect x={x} y={y} width={w} height={h} fill={c} stroke="#26211F" strokeWidth="2" /><text x={x + 8} y={y + 16} fontFamily={m} fontSize="10" fill="#26211F" letterSpacing=".04em">{t}</text></g>
336 );
337 return (
338 <svg viewBox="0 0 380 280" role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true} focusable={false} preserveAspectRatio="xMidYMid slice">
339 <rect width="380" height="280" fill="#EFE6D6" />
340 {Array.from({ length: 20 }).map((_, i) => <line key={i} x1={i * 20} x2={i * 20} y1="0" y2="280" stroke="#3F6F86" strokeOpacity=".1" />)}
341 {Array.from({ length: 14 }).map((_, i) => <line key={i} y1={i * 20} y2={i * 20} x1="0" x2="380" stroke="#3F6F86" strokeOpacity=".1" />)}
342 <rect x="22" y="22" width="336" height="236" fill="#F6EFE0" stroke="#26211F" strokeWidth="3" />
343 {room(22, 22, 130, 96, "WHEEL ROOM", "#E4D7C0")}
344 {room(152, 22, 90, 96, "CLAY STORE", "#D9CBB0")}
345 {room(242, 22, 116, 96, "GLAZE LAB", "#C9D5D3")}
346 {room(22, 118, 110, 140, "KILN YARD", "#C9805E")}
347 {room(132, 118, 226, 60, "SHOWROOM", "#F1E8D6")}
348 <circle cx="257" cy="220" r="26" fill="#9aa67c" stroke="#26211F" strokeWidth="2" />
349 <text x="228" y="258" fontFamily={m} fontSize="10" fill="#26211F">FIG COURTYARD</text>
350 {[0, 1, 2, 3].map((i) => <circle key={i} cx={52 + i * 24} cy="72" r="8" fill="#B5573A" stroke="#26211F" strokeWidth="1.5" />)}
351 <rect x="42" y="190" width="34" height="44" fill="#26211F" /><rect x="84" y="190" width="34" height="44" fill="#26211F" />
352 <rect x="196" y="146" width="150" height="16" fill="#8F3F28" stroke="#26211F" strokeWidth="1.5" />
353 <path d="M152 118V148" stroke="#F6EFE0" strokeWidth="6" /><path d="M340 258v-10" stroke="#F6EFE0" strokeWidth="8" />
354 <g fontFamily={m} fontSize="9" fill="#26211F"><text x="46" y="106">4 wheels</text><text x="44" y="246">Kiln 1 · Kiln 2</text><text x="200" y="158" fill="#F6EFE0">long table</text></g>
355 <path d="M350 56l7 18h-14z" fill="#26211F" /><text x="344" y="86" fontFamily={m} fontSize="9" fill="#26211F">N</text>
356 </svg>
357 );
358}
359
360/* ================================================================== */
361/* Content */
362/* ================================================================== */
363type Kind = "project" | "note" | "sketch" | "map" | "about";
364type Item = {
365 id: string; kind: Kind; art: string; title: string; kicker: string;
366 x: number; y: number; w: number; h: number; f: number; r: number;
367 lead: string; specs: [string, string][]; steps?: string[]; note?: string[];
368};
369const ITEMS: Item[] = [
370 { id: "tidewater", kind: "project", art: "bowl", title: "Tidewater Tableware", kicker: "Tableware · 2026", x: 520, y: 1040, w: 300, h: 380, f: 1.04, r: -2,
371 lead: "A 140-piece dinner service for Sal & Maré, a seafood house on the Tagus. Bowls, plates and cups share one dipped blue glaze that deepens wherever it pools.",
372 specs: [["Client", "Sal & Maré, Lisbon"], ["Pieces", "140 in 4 loads"], ["Clay", "Speckled stoneware"], ["Glaze", "Tidewater Blue"], ["Firing", "Cone 6, oxidation"], ["Timeline", "11 weeks"]],
373 steps: ["Three trial sets thrown to settle rim thickness against dishwasher chipping.", "Dip line lowered 6 mm after the first kiln so the blue meets bare clay under the thumb.", "Packed in wool-lined crates and fired in four loads, one tile test per shelf."] },
374 { id: "ember", kind: "project", art: "vase", title: "Ash & Ember Vases", kicker: "Vessels · 2025", x: 1180, y: 1270, w: 300, h: 380, f: 0.98, r: 1.5,
375 lead: "Twelve tall vessels for the group show Fire Remembers at Galeria Lume, glazed with our own ember red and pushed to the edge of what the kiln allows.",
376 specs: [["Venue", "Galeria Lume"], ["Pieces", "12, unique"], ["Height", "38 to 62 cm"], ["Glaze", "Ember, double dipped"], ["Firing", "Cone 6, slow cool"], ["Timeline", "5 months"]],
377 steps: ["Each profile drawn at full scale on the studio wall, then thrown in two sections.", "Ember glaze applied thick at the shoulder so it runs into the throwing rings.", "Four pieces lost to the kiln gods. The eight that survived are the show."] },
378 { id: "mugs", kind: "project", art: "mug", title: "Morning Ritual Mugs", kicker: "Everyday · 2026", x: 1850, y: 1190, w: 300, h: 380, f: 1.06, r: -1,
379 lead: "A small-batch mug for people who hold things for a long time. Pulled handles, a generous thumb rest and a bone glaze that warms to cream where it is thin.",
380 specs: [["Edition", "Batches of 60"], ["Capacity", "320 ml"], ["Clay", "Buff stoneware"], ["Glaze", "Bone Satin, Ochre Ash"], ["Firing", "Cone 6"], ["Price", "from 48 EUR"]],
381 steps: ["Handle pulled from a single 90 g coil, left to leather-hard on its own.", "Thumb rest adjusted nine times on the wheel before anyone approved it.", "Glaze dipped in two passes so the lip stays slightly darker."] },
382 { id: "salt-pan", kind: "project", art: "lamp", title: "Salt Pan Pendants", kicker: "Lighting · 2025", x: 2420, y: 880, w: 300, h: 380, f: 1.0, r: 2,
383 lead: "Hand-thrown pendant shades for the Hotel Salina lobby. Each dome is glazed warm white inside so the light comes out the colour of late afternoon.",
384 specs: [["Client", "Hotel Salina, Setubal"], ["Pieces", "26 pendants"], ["Diameter", "24, 36 and 48 cm"], ["Glaze", "Bone Satin inside"], ["Fitting", "E27, ceramic collar"], ["Timeline", "14 weeks"]],
385 steps: ["Domes thrown upside down and trimmed in one sitting to keep walls even.", "Light-tested every shade in a dark room before glazing was approved.", "Wired by the hotel's electrician from our hanging drawings."] },
386 { id: "lacuna", kind: "project", art: "planter", title: "Lacuna Planters", kicker: "Architectural · 2024", x: 2330, y: 1470, w: 300, h: 380, f: 0.96, r: -1.5,
387 lead: "Thirty large planters for a courtyard in Alfama, thrown in two halves and joined while soft. Iron-black glaze, rough feet and drainage hidden in the seam.",
388 specs: [["Client", "Casa Alfama"], ["Pieces", "30, three sizes"], ["Height", "60 to 90 cm"], ["Glaze", "Iron Night, raw feet"], ["Firing", "Cone 6, frost-proof"], ["Timeline", "9 months"]],
389 steps: ["Halves thrown on a bat and joined at leather-hard with slip and patience.", "Frost test: ten cycles in a freezer borrowed from the fishmonger next door.", "Delivered by hand cart up the hill, because the van would not fit."] },
390 { id: "tea", kind: "project", art: "tea", title: "Cold Spring Tea Set", kicker: "Tableware · 2026", x: 720, y: 1590, w: 300, h: 380, f: 1.02, r: 1,
391 lead: "A jug, cups and tray for a small tea house, in ochre and bone. Designed so every piece nests: nothing needs a cabinet of its own.",
392 specs: [["Client", "Casa de Cha Nuvem"], ["Set", "Jug, 6 cups, tray"], ["Clay", "Porcelain body"], ["Glaze", "Ochre Ash, Bone Satin"], ["Firing", "Cone 6"], ["Timeline", "7 weeks"]],
393 steps: ["Cups thrown off the hump, forty in an hour, to keep the family resemblance.", "Jug spout cut and checked with water until it stopped dripping.", "Tray built from a slab, dried between boards so it stayed flat."] },
394 { id: "tiles", kind: "project", art: "tiles", title: "Sgraffito Tile Wall", kicker: "Surface · 2025", x: 1500, y: 1650, w: 300, h: 380, f: 1.0, r: -0.5,
395 lead: "1,240 hand-pressed tiles for a hotel stair hall, each scratched through a slip layer with the same diamond motif and none of them quite the same.",
396 specs: [["Client", "Hotel Salina"], ["Tiles", "1,240, 12 cm"], ["Colours", "6 glazes"], ["Technique", "Slip and sgraffito"], ["Firing", "Bisque 06, glaze cone 6"], ["Timeline", "6 months"]],
397 steps: ["Tiles pressed into a plaster mould, 80 a day, and dried under newspaper.", "Diamond scratched with a loop tool by whoever had a free hour, so the hands vary.", "Laid out on the studio floor in sequence before crating, so the pattern drifts naturally."] },
398 { id: "map", kind: "map", art: "map", title: "Studio map", kicker: "Visit · Rua do Forno 14", x: 330, y: 500, w: 380, h: 360, f: 1.0, r: -1,
399 lead: "A converted bakery in Lisbon with a long table where everything gets decided. The kilns live in the old oven yard, and the fig tree has right of way.",
400 specs: [["Address", "Rua do Forno 14, Lisbon"], ["Open studio", "Saturdays, 11 to 17"], ["Tours", "By appointment"], ["Equipment", "4 wheels, 2 kilns"], ["Access", "Step-free to showroom"], ["Parking", "None. Take the tram."]],
401 steps: ["Enter through the red door and ask for coffee.", "Showroom first, then the wheel room if someone is throwing.", "The seconds shelf is by the fig tree. Cash or card."] },
402 { id: "about", kind: "about", art: "none", title: "About Kiln", kicker: "Studio · since 2014", x: 2640, y: 420, w: 300, h: 250, f: 1.0, r: 1.5,
403 lead: "Four makers, two kilns and one long table. We design and make tableware, lighting and surfaces for people who care how things feel in the hand.",
404 specs: [["Founded", "2014, Lisbon"], ["Makers", "Ines, Tomas, Rui, Maya"], ["Output", "About 4,000 pieces a year"], ["Clients", "Restaurants, hotels, homes"], ["Materials", "Local stoneware, porcelain"], ["Energy", "Kilns on renewable tariff"]],
405 steps: ["We say no to most large orders so we can say yes to good ones.", "Every piece is signed or stamped by the person who finished it.", "Seconds are sold, never destroyed."], note: ["Four makers.", "Two kilns.", "One long table."] },
406 { id: "sk-profile", kind: "sketch", art: "profile", title: "Profile study: Ember vase", kicker: "Sketch · profile", x: 1000, y: 230, w: 280, h: 300, f: 1.08, r: -2.5,
407 lead: "Every form starts as a half-profile drawn at full size. The orange points are the places the hand will actually change direction.",
408 specs: [["Scale", "1:1"], ["Tool", "2B pencil, steel rule"], ["Points", "7 control points"], ["Rule", "Throw 8% bigger"], ["Used for", "Ash & Ember Vases"], ["Date", "February 2025"]], steps: ["Draw the profile, then mirror it.", "Mark the three places the wall must be thickest.", "Cut a plywood rib to check the throw."] },
409 { id: "sk-curve", kind: "sketch", art: "curve", title: "Firing curve, cone 6", kicker: "Sketch · kiln", x: 2050, y: 250, w: 280, h: 300, f: 0.94, r: 2,
410 lead: "Twenty hours from cold to cold. The curve is slow through quartz inversion at 573 degrees, where pots crack if they are hurried.",
411 specs: [["Peak", "1222°C, cone 6"], ["Soak", "20 minutes"], ["Total", "20 hours"], ["Atmosphere", "Oxidation"], ["Kiln", "Two, 280 L each"], ["Fuel", "Electric"]], steps: ["Water smoking below 150°C.", "Crawl through 573°C.", "Cool to 150°C before anyone opens the lid."] },
412 { id: "sk-foot", kind: "sketch", art: "foot", title: "Foot ring section", kicker: "Sketch · detail", x: 2750, y: 1360, w: 280, h: 300, f: 1.1, r: -1.5,
413 lead: "The foot is where a piece tells you whether it was made with care. Eight millimetres of ring, trimmed at three, wide enough to sit true on a table.",
414 specs: [["Foot ring", "8 mm"], ["Trim wall", "3 mm"], ["Glaze", "Wiped clean at foot"], ["Fired on", "Alumina-dusted shelf"], ["Tool", "Loop trimmer, 6 mm"], ["Check", "Rocks on glass? Trim again."]], steps: ["Centre the leather-hard pot upside down.", "Trim the foot ring, then relieve the base.", "Wipe the glaze from the foot with a damp sponge."] },
415 { id: "n-glaze", kind: "note", art: "none", title: "Glaze note: Tidewater", kicker: "Notebook · page 31", x: 1480, y: 1020, w: 220, h: 175, f: 1.12, r: -3,
416 note: ["Tidewater blue", "Cobalt 0.5% + rutile 2%", "Test tile #31: best yet.", "Do not touch the rutile again."],
417 lead: "Thirty tests to find a blue that breaks to rust on edges and still looks like water when it runs. Number thirty-one is the one on the dinner plates.",
418 specs: [["Recipe", "Tidewater Blue v31"], ["Cone", "6, oxidation"], ["Thickness", "1.2 mm, two dips"], ["Breaks to", "Rust on rims"], ["Tested on", "Speckled stoneware"], ["Date", "October 2025"]], steps: ["Weigh dry materials twice.", "Sieve through 80 mesh, then again.", "Test tile on every clay body, every time."] },
419 { id: "n-shrink", kind: "note", art: "none", title: "Shrinkage note", kicker: "Notebook · page 8", x: 270, y: 1380, w: 220, h: 175, f: 1.1, r: 2.5,
420 note: ["Shrinkage = 12.4%", "Throw 14% bigger!", "Measure the lid twice.", "(the lid is always wrong)"],
421 lead: "Our stoneware loses 12.4 percent of its size between wet and fired. Every drawing in the studio carries a second, bigger set of dimensions for the wheel.",
422 specs: [["Wet to fired", "12.4%"], ["Wet to bone dry", "6%"], ["Bone dry to fired", "7%"], ["Clay", "Speckled stoneware"], ["Tested", "Quarterly"], ["Tool", "Shrink ruler, 112%"]], steps: ["Throw to the larger number.", "Check at leather-hard.", "Check again after bisque."] },
423 { id: "n-log", kind: "note", art: "none", title: "Kiln log #214", kicker: "Notebook · kiln 2", x: 2020, y: 1520, w: 220, h: 175, f: 0.9, r: -2,
424 note: ["Kiln 2, firing #214", "Cone 6, 9 h 10 min", "Peeked at 1180: all good.", "Cooling overnight."],
425 lead: "Every firing is logged by hand: the load, the schedule, what we saw through the peephole. The log is how we found the quartz-inversion cracks in 2019.",
426 specs: [["Firing", "#214"], ["Kiln", "Kiln 2, 280 L"], ["Peak", "1222°C, cone 6"], ["Duration", "9 h 10 min up"], ["Load", "34 pieces, 6 shelves"], ["Result", "2 seconds"]], steps: ["Load, cone packs in sight of the peephole.", "Log temperature at the hour.", "Unload only when the lid is cool to the hand."] },
427 { id: "n-open", kind: "note", art: "none", title: "Open studio", kicker: "Notebook · reminder", x: 1350, y: 250, w: 220, h: 175, f: 1.14, r: 3,
428 note: ["Open studio", "Saturdays 11 to 17", "Seconds shelf by the fig tree.", "Bring a bag."],
429 lead: "Once a week the doors open. Come and watch a pot get thrown, drink something, buy a second, or ask about a commission.",
430 specs: [["When", "Saturdays, 11 to 17"], ["Where", "Rua do Forno 14"], ["Cost", "Free"], ["Groups", "Book ahead"], ["Dogs", "Welcome"], ["Seconds", "Cash or card"]], steps: ["Ring the bell if the door is shut.", "Ask to see the kilns.", "Say hello to Fig, the studio cat."] },
431];
432
433type Decor = { id: string; x: number; y: number; w: number; h: number; f: number; r: number; k: "blob" | "crumb" | "rings" | "dots"; c: string; o: number; seed: number };
434const DECOR: Decor[] = [
435 { id: "d-rings", x: 1500, y: 700, w: 2400, h: 2400, f: 0.55, r: 0, k: "rings", c: "#B5573A", o: 0.5, seed: 1 },
436 { id: "d-b1", x: 250, y: 180, w: 620, h: 520, f: 0.6, r: 12, k: "blob", c: "#C9805E", o: 0.42, seed: 3 },
437 { id: "d-b2", x: 2850, y: 1000, w: 700, h: 560, f: 0.62, r: -20, k: "blob", c: "#8FB4BF", o: 0.4, seed: 5 },
438 { id: "d-b3", x: 1450, y: 1860, w: 800, h: 480, f: 0.58, r: 6, k: "blob", c: "#B5573A", o: 0.28, seed: 7 },
439 { id: "d-b4", x: 2250, y: 70, w: 560, h: 420, f: 0.66, r: -9, k: "blob", c: "#E4D7C0", o: 0.7, seed: 9 },
440 { id: "d-b5", x: 80, y: 1600, w: 520, h: 520, f: 0.64, r: 30, k: "blob", c: "#3F6F86", o: 0.22, seed: 11 },
441 { id: "d-c1", x: 880, y: 760, w: 60, h: 46, f: 1.45, r: 20, k: "crumb", c: "#8F3F28", o: 0.9, seed: 21 },
442 { id: "d-c2", x: 2050, y: 900, w: 80, h: 56, f: 1.5, r: -30, k: "crumb", c: "#B5573A", o: 0.9, seed: 22 },
443 { id: "d-c3", x: 1200, y: 1700, w: 70, h: 50, f: 1.4, r: 50, k: "crumb", c: "#3F6F86", o: 0.85, seed: 23 },
444 { id: "d-c4", x: 2800, y: 700, w: 50, h: 40, f: 1.55, r: 10, k: "crumb", c: "#C98F4F", o: 0.9, seed: 24 },
445 { id: "d-c5", x: 140, y: 900, w: 66, h: 48, f: 1.42, r: -15, k: "crumb", c: "#8F3F28", o: 0.9, seed: 25 },
446 { id: "d-c6", x: 1850, y: 420, w: 54, h: 42, f: 1.48, r: 33, k: "crumb", c: "#26211F", o: 0.8, seed: 26 },
447 { id: "d-c7", x: 900, y: 1380, w: 46, h: 36, f: 1.5, r: -40, k: "crumb", c: "#B5573A", o: 0.9, seed: 27 },
448];
449const W = 3000, H = 1900, CX = 1500;
450
451function Blob({ d, c, o }: { d: Decor; c: string; o: number }) {
452 const pts = useMemo(() => {
453 const r = rng(d.seed), n = 9, out: number[][] = [];
454 for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2, k = 0.72 + r() * 0.28; out.push([50 + Math.cos(a) * 48 * k, 50 + Math.sin(a) * 48 * k]); }
455 out.push(out[0]);
456 out.push(out[1]);
457 return out;
458 }, [d.seed]);
459 const dd = `M${f1(pts[0][0])} ${f1(pts[0][1])}${seg(pts)}Z`;
460 const sp = useMemo(() => { const r = rng(d.seed + 40); return Array.from({ length: d.k === "crumb" ? 6 : 40 }, () => [20 + r() * 60, 20 + r() * 60, 0.4 + r() * 0.9]); }, [d.seed, d.k]);
461 return (
462 <svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="none" aria-hidden focusable={false}>
463 <path d={dd} fill={c} opacity={o} />
464 {sp.map((s, i) => <circle key={i} cx={s[0]} cy={s[1]} r={s[2] * (d.k === "crumb" ? 1.4 : 0.8)} fill="#26211F" opacity={d.k === "crumb" ? 0.35 : 0.18} />)}
465 {d.k === "crumb" && <path d={dd} fill="none" stroke="#fff" strokeOpacity=".2" strokeWidth="3" transform="translate(-1.5 -1.5)" />}
466 </svg>
467 );
468}
469function DecorArt({ d }: { d: Decor }) {
470 if (d.k === "rings") {
471 return (
472 <svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden focusable={false}>
473 {[48, 40, 32, 24, 16].map((r, i) => <circle key={r} cx="50" cy="50" r={r} fill="none" stroke={d.c} strokeOpacity={d.o - i * 0.06} strokeWidth=".12" strokeDasharray={i % 2 ? "0.6 0.8" : undefined} />)}
474 </svg>
475 );
476 }
477 return <Blob d={d} c={d.c} o={d.o} />;
478}
479
480/* Title slab: the studio name cut into clay */
481function Slab() {
482 const id = "knt" + useId().replace(/[^a-zA-Z0-9]/g, "");
483 const { d, sp } = useMemo(() => {
484 const r = rng(77), pts: number[][] = [];
485 const jit = () => (r() - 0.5) * 12;
486 for (let x = 20; x <= 1480; x += 90) pts.push([x, 14 + jit()]);
487 for (let y = 14; y <= 546; y += 90) pts.push([1486 + jit(), y]);
488 for (let x = 1480; x >= 20; x -= 90) pts.push([x, 546 + jit()]);
489 for (let y = 546; y >= 14; y -= 90) pts.push([14 + jit(), y]);
490 pts.push(pts[0], pts[1]);
491 const sp = Array.from({ length: 230 }, () => ({ x: 20 + r() * 1460, y: 20 + r() * 520, r: 0.8 + r() * 2.2, l: r() < 0.3 }));
492 return { d: `M${pts[0][0]} ${pts[0][1]}${seg(pts)}Z`, sp };
493 }, []);
494 const font = `"Fraunces","Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif`;
495 return (
496 <svg viewBox="0 0 1500 560" width="100%" height="100%" aria-hidden focusable={false}>
497 <defs>
498 <linearGradient id={id + "g"} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#C4694A" /><stop offset=".5" stopColor="#B5573A" /><stop offset="1" stopColor="#9A4529" /></linearGradient>
499 <clipPath id={id + "c"}><path d={d} /></clipPath>
500 <filter id={id + "f"} x="-5%" y="-10%" width="110%" height="125%">
501 <feOffset in="SourceAlpha" dy="16" result="o" /><feGaussianBlur in="o" stdDeviation="9" result="b" />
502 <feComposite in="SourceAlpha" in2="b" operator="out" result="inv" />
503 <feFlood floodColor="#1b0d07" floodOpacity=".85" /><feComposite in2="inv" operator="in" result="sh" />
504 <feMerge><feMergeNode in="SourceGraphic" /><feMergeNode in="sh" /></feMerge>
505 </filter>
506 </defs>
507 <path d={d} fill="#26211F" opacity=".28" transform="translate(0 18)" />
508 <path d={d} fill={`url(#${id}g)`} />
509 <g clipPath={`url(#${id}c)`}>
510 {sp.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.r} fill={s.l ? "#F1E8D6" : "#4A2417"} opacity={s.l ? 0.3 : 0.4} />)}
511 {Array.from({ length: 16 }).map((_, i) => <path key={i} d={`M0 ${40 + i * 34}Q750 ${30 + i * 34 + (i % 3) * 6} 1500 ${40 + i * 34}`} stroke="#000" strokeOpacity=".05" fill="none" />)}
512 <path d="M1180 14L1120 130L1150 214L1100 330" stroke="#4A2417" strokeOpacity=".5" strokeWidth="2.4" fill="none" />
513 </g>
514 <text x="750" y="446" textAnchor="middle" fontFamily={font} fontWeight="800" fontSize="470" letterSpacing="-14" fill="#F1E8D6" opacity=".55" transform="translate(0 4)">KILN</text>
515 <text x="750" y="446" textAnchor="middle" fontFamily={font} fontWeight="800" fontSize="470" letterSpacing="-14" fill="#3b1c10" filter={`url(#${id}f)`}>KILN</text>
516 <text x="750" y="516" textAnchor="middle" fontFamily="ui-monospace,Menlo,monospace" fontSize="26" letterSpacing="9" fill="#2a130b" opacity=".78">CERAMICS & DESIGN STUDIO · LISBON</text>
517 </svg>
518 );
519}
520
521function Face({ it }: { it: Item }) {
522 if (it.kind === "note") return <span className="kn-note-body">{it.note!.map((l, i) => <span key={i} className={i === 0 ? "kn-note-h" : ""}>{l}</span>)}</span>;
523 if (it.kind === "about") return <span className="kn-about-body">{it.note!.map((l, i) => <span key={i}>{l}</span>)}</span>;
524 return <span className="kn-art"><Art kind={it.art} /></span>;
525}
526function Cap({ it }: { it: Item }) {
527 return (
528 <span className="kn-cap">
529 <span className="kn-k">{it.kicker}</span>
530 <span className="kn-t">{it.title}</span>
531 {it.kind === "project" && <span className="kn-more">Case study <span aria-hidden>→</span></span>}
532 </span>
533 );
534}
535
536/* ================================================================== */
537/* Hooks */
538/* ================================================================== */
539function useReduced(): boolean {
540 const [r, setR] = useState(false);
541 useEffect(() => {
542 const m = window.matchMedia("(prefers-reduced-motion: reduce)");
543 const on = () => setR(m.matches);
544 on();
545 m.addEventListener("change", on);
546 return () => m.removeEventListener("change", on);
547 }, []);
548 return r;
549}
550/** 0..1 progress through a tall section (top reaches viewport top -> bottom reaches viewport bottom). */
551function useScrub(ref: RefObject<HTMLElement | null>, cb: (p: number) => void) {
552 const cbr = useRef(cb);
553 cbr.current = cb;
554 useEffect(() => {
555 const el = ref.current;
556 if (!el) return;
557 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
558 let raf = 0;
559 const run = () => {
560 raf = 0;
561 if (reduce) return cbr.current(1);
562 const r = el.getBoundingClientRect();
563 const span = r.height - window.innerHeight;
564 cbr.current(span > 0 ? clamp(-r.top / span, 0, 1) : 1);
565 };
566 const on = () => { if (!raf) raf = requestAnimationFrame(run); };
567 run();
568 window.addEventListener("scroll", on, { passive: true });
569 window.addEventListener("resize", on);
570 return () => { window.removeEventListener("scroll", on); window.removeEventListener("resize", on); if (raf) cancelAnimationFrame(raf); };
571 }, [ref]);
572}
573
574/* ================================================================== */
575/* Canvas stage (hero) */
576/* ================================================================== */
577type Cam = { x: number; y: number; s: number; ls: number; tx: number; ty: number; ts: number; k: number; vx: number; vy: number; vw: number; vh: number };
578const ovTarget = (vw: number, vh: number) => (vw < 720 ? { x: CX, y: 820, s: 0.3 } : { x: CX, y: 950, s: Math.min(vw / 2700, vh / 1700) });
579const MIN_S = 0.12, MAX_S = 2.2;
580type Api = { fly: (it: Item, panel: boolean) => void; overview: () => void; zoom: (f: number) => void; pan: (dx: number, dy: number) => void; goto: (wx: number, wy: number) => void; kick: () => void };
581const MMC: Record<Kind, string> = { project: "#B5573A", note: "#C98F4F", sketch: "#3F6F86", map: "#26211F", about: "#26211F" };
582
583function Stage() {
584 const reduced = useReduced();
585 const [listView, setListView] = useState(false);
586 const [openId, setOpenId] = useState<string | null>(null);
587 const [away, setAway] = useState(false);
588 const [ready, setReady] = useState(false);
589 const [touched, setTouched] = useState(false);
590 const stageRef = useRef<HTMLDivElement>(null);
591 const viewRef = useRef<HTMLDivElement>(null);
592 const bg1 = useRef<HTMLDivElement>(null);
593 const bg2 = useRef<HTMLDivElement>(null);
594 const mmVp = useRef<HTMLDivElement>(null);
595 const mmBox = useRef<HTMLDivElement>(null);
596 const panelRef = useRef<HTMLElement>(null);
597 const headRef = useRef<HTMLHeadingElement>(null);
598 const els = useRef<Record<string, HTMLElement | null>>({});
599 const cam = useRef<Cam>({ x: CX, y: 950, s: 0.5, ls: Math.log(0.5), tx: CX, ty: 950, ts: 0.5, k: 4, vx: 0, vy: 0, vw: 1200, vh: 800 });
600 const api = useRef<Api | null>(null);
601 const reducedRef = useRef(false);
602 reducedRef.current = reduced;
603 const openRef = useRef<string | null>(null);
604 openRef.current = openId;
605 const awayRef = useRef(false);
606 const lastBtn = useRef<HTMLElement | null>(null);
607 const lastItem = useRef<Item>(ITEMS[0]);
608 const item = ITEMS.find((i) => i.id === openId) || null;
609 if (item) lastItem.current = item;
610
611 useEffect(() => { if (reduced) setListView(true); }, [reduced]);
612
613 /* ---------------- camera ---------------- */
614 useEffect(() => {
615 if (listView) { api.current = null; return; }
616 const stage = stageRef.current, view = viewRef.current;
617 if (!stage || !view) return;
618 const c = cam.current;
619 let raf = 0, last = 0;
620 const drag = { active: false, moved: false, sx: 0, sy: 0, lx: 0, ly: 0, lt: 0, vx: 0, vy: 0 };
621 const ptrs = new Map<number, { x: number; y: number }>();
622 let pinch: { d0: number; s0: number } | null = null;
623 let didDrag = false;
624
625 const measure = () => { c.vw = stage.clientWidth; c.vh = stage.clientHeight; };
626 const clampAxis = (v: number, lo: number, hi: number, prev: number) => Math.min(Math.max(v, Math.min(lo, prev)), Math.max(hi, prev));
627 const panBy = (dx: number, dy: number) => {
628 const hw = c.vw / (2 * c.ts), hh = c.vh / (2 * c.ts), m = 300;
629 const ox = c.tx, oy = c.ty;
630 c.tx = clampAxis(ox + dx, Math.min(hw - m, W / 2), Math.max(W - hw + m, W / 2), ox);
631 c.ty = clampAxis(oy + dy, Math.min(hh - m, H / 2), Math.max(H - hh + m, H / 2), oy);
632 return Math.abs(c.tx - ox) > 0.01 || Math.abs(c.ty - oy) > 0.01;
633 };
634 const render = () => {
635 const { x, y, s, vw, vh } = c;
636 const place = (id: string, o: { x: number; y: number; w: number; h: number; f: number; r: number }) => {
637 const el = els.current[id];
638 if (!el) return;
639 const X = vw / 2 + s * o.f * (o.x - x), Y = vh / 2 + s * o.f * (o.y - y);
640 const m = Math.max(o.w, o.h) * s * 0.75 + 40;
641 const vis = X > -m && X < vw + m && Y > -m && Y < vh + m;
642 const v = vis ? "visible" : "hidden";
643 if (el.style.visibility !== v) el.style.visibility = v;
644 if (vis) el.style.transform = `translate3d(${X.toFixed(1)}px,${Y.toFixed(1)}px,0) rotate(${o.r}deg) scale(${s.toFixed(4)})`;
645 };
646 place("title", { x: CX, y: 640, w: 1500, h: 560, f: 1, r: 0 });
647 for (const o of DECOR) place(o.id, o);
648 for (const o of ITEMS) place(o.id, o);
649 if (bg1.current) { const b = 160 * s * 0.5; bg1.current.style.backgroundSize = `${b}px ${b}px`; bg1.current.style.backgroundPosition = `${vw / 2 - s * 0.5 * x}px ${vh / 2 - s * 0.5 * y}px`; }
650 if (bg2.current) { const b = 500 * s * 0.8; bg2.current.style.backgroundSize = `${b}px ${b}px`; bg2.current.style.backgroundPosition = `${vw / 2 - s * 0.8 * x}px ${vh / 2 - s * 0.8 * y}px`; }
651 const vp = mmVp.current;
652 if (vp) {
653 vp.style.left = `${((x - vw / (2 * s)) / W) * 100}%`; vp.style.top = `${((y - vh / (2 * s)) / H) * 100}%`;
654 vp.style.width = `${(vw / s / W) * 100}%`; vp.style.height = `${(vh / s / H) * 100}%`;
655 }
656 const ov = ovTarget(vw, vh);
657 const a = Math.abs(Math.log(c.ts / ov.s)) > 0.06 || Math.hypot(c.tx - ov.x, c.ty - ov.y) * c.ts > 90;
658 if (a !== awayRef.current) { awayRef.current = a; setAway(a); }
659 };
660 const tick = (t: number) => {
661 raf = 0;
662 const dt = Math.min(0.05, Math.max(0.001, (t - last) / 1000));
663 last = t;
664 let active = false;
665 if (drag.active) active = true;
666 else if (Math.abs(c.vx) > 10 || Math.abs(c.vy) > 10) {
667 panBy(-(c.vx * dt) / c.s, -(c.vy * dt) / c.s);
668 c.x = c.tx; c.y = c.ty;
669 const dmp = Math.exp(-dt * 3.4);
670 c.vx *= dmp; c.vy *= dmp;
671 active = true;
672 } else { c.vx = 0; c.vy = 0; }
673 const a = reducedRef.current ? 1 : 1 - Math.exp(-dt * c.k);
674 const dl = Math.log(c.ts) - c.ls;
675 if (Math.abs(c.tx - c.x) * c.s > 0.2 || Math.abs(c.ty - c.y) * c.s > 0.2 || Math.abs(dl) > 0.0008) {
676 c.x += (c.tx - c.x) * a; c.y += (c.ty - c.y) * a; c.ls += dl * a; c.s = Math.exp(c.ls);
677 active = true;
678 } else { c.x = c.tx; c.y = c.ty; c.ls = Math.log(c.ts); c.s = c.ts; }
679 render();
680 if (active) raf = requestAnimationFrame(tick);
681 };
682 const kick = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };
683 const setZoomAt = (mx: number, my: number, ns: number, direct: boolean) => {
684 const s2 = clamp(ns, MIN_S, MAX_S);
685 const wx = c.tx + (mx - c.vw / 2) / c.ts, wy = c.ty + (my - c.vh / 2) / c.ts;
686 c.ts = s2; c.tx = wx - (mx - c.vw / 2) / s2; c.ty = wy - (my - c.vh / 2) / s2;
687 panBy(0, 0);
688 if (direct) { c.x = c.tx; c.y = c.ty; c.s = s2; c.ls = Math.log(s2); }
689 };
690 const interact = () => { setTouched(true); };
691
692 api.current = {
693 kick,
694 fly: (it, panel) => {
695 measure();
696 const mobile = c.vw < 720;
697 const pw = panel && !mobile ? Math.min(480, c.vw * 0.46) : 0;
698 const ph = panel && mobile ? c.vh * 0.56 : 0;
699 const s = clamp(Math.min(((c.vw - pw) * 0.76) / it.w, ((c.vh - ph - 130) * 0.8) / it.h), 0.3, 1.6);
700 c.ts = s; c.tx = it.x + pw / 2 / (s * it.f); c.ty = it.y + ph / 2 / (s * it.f); c.k = 4.2;
701 kick();
702 },
703 overview: () => { measure(); const o = ovTarget(c.vw, c.vh); c.tx = o.x; c.ty = o.y; c.ts = o.s; c.k = 3.6; kick(); },
704 zoom: (fct) => { c.k = 9; setZoomAt(c.vw / 2, c.vh / 2, c.ts * fct, false); kick(); },
705 pan: (dx, dy) => { c.k = 9; panBy(dx / c.ts, dy / c.ts); kick(); },
706 goto: (wx, wy) => { c.k = 7; c.tx = wx; c.ty = wy; panBy(0, 0); kick(); },
707 };
708
709 const onDown = (e: PointerEvent) => {
710 if (e.pointerType === "mouse" && e.button !== 0) return;
711 interact();
712 ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });
713 c.vx = 0; c.vy = 0;
714 if (ptrs.size === 1) {
715 didDrag = false;
716 Object.assign(drag, { active: true, moved: false, sx: e.clientX, sy: e.clientY, lx: e.clientX, ly: e.clientY, lt: e.timeStamp, vx: 0, vy: 0 });
717 c.tx = c.x; c.ty = c.y; c.ts = c.s; c.ls = Math.log(c.s);
718 } else if (ptrs.size === 2) {
719 const [a, b] = [...ptrs.values()];
720 pinch = { d0: Math.hypot(a.x - b.x, a.y - b.y) || 1, s0: c.s };
721 didDrag = true;
722 }
723 };
724 const onMove = (e: PointerEvent) => {
725 const p = ptrs.get(e.pointerId);
726 if (!p) return;
727 p.x = e.clientX; p.y = e.clientY;
728 const rect = stage.getBoundingClientRect();
729 if (pinch && ptrs.size >= 2) {
730 const [a, b] = [...ptrs.values()];
731 const d = Math.hypot(a.x - b.x, a.y - b.y) || 1;
732 setZoomAt((a.x + b.x) / 2 - rect.left, (a.y + b.y) / 2 - rect.top, pinch.s0 * (d / pinch.d0), true);
733 kick();
734 return;
735 }
736 if (!drag.active) return;
737 if (!drag.moved && Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) > 6) {
738 drag.moved = true; didDrag = true;
739 try { view.setPointerCapture(e.pointerId); } catch { /* ignore */ }
740 view.classList.add("is-drag");
741 }
742 if (!drag.moved) return;
743 const dx = e.clientX - drag.lx, dy = e.clientY - drag.ly, dts = Math.max(0.001, (e.timeStamp - drag.lt) / 1000);
744 drag.vx = drag.vx * 0.6 + (dx / dts) * 0.4; drag.vy = drag.vy * 0.6 + (dy / dts) * 0.4;
745 drag.lx = e.clientX; drag.ly = e.clientY; drag.lt = e.timeStamp;
746 panBy(-dx / c.s, -dy / c.s);
747 c.x = c.tx; c.y = c.ty;
748 kick();
749 };
750 const onUp = (e: PointerEvent) => {
751 if (!ptrs.has(e.pointerId)) return;
752 ptrs.delete(e.pointerId);
753 if (ptrs.size < 2) pinch = null;
754 if (ptrs.size === 0) {
755 if (drag.moved && e.timeStamp - drag.lt < 90 && !reducedRef.current) { c.vx = clamp(drag.vx, -4000, 4000); c.vy = clamp(drag.vy, -4000, 4000); }
756 drag.active = false; drag.moved = false;
757 view.classList.remove("is-drag");
758 kick();
759 } else {
760 const [r] = [...ptrs.values()];
761 drag.lx = r.x; drag.ly = r.y;
762 }
763 };
764 const onWheel = (e: WheelEvent) => {
765 measure();
766 const rect = stage.getBoundingClientRect();
767 const mul = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? c.vh : 1;
768 if (e.ctrlKey || e.metaKey) {
769 e.preventDefault(); interact();
770 const fct = clamp(Math.exp(-e.deltaY * mul * 0.01), 0.8, 1.25);
771 c.k = 14;
772 setZoomAt(e.clientX - rect.left, e.clientY - rect.top, c.ts * fct, false);
773 kick();
774 return;
775 }
776 const dx = e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX, dy = e.shiftKey && !e.deltaX ? 0 : e.deltaY;
777 if (panBy((dx * mul) / c.ts, (dy * mul) / c.ts)) { e.preventDefault(); interact(); c.k = 14; kick(); }
778 };
779 const onClickCap = (e: MouseEvent) => { if (didDrag) { e.preventDefault(); e.stopPropagation(); didDrag = false; } };
780 const onDbl = (e: MouseEvent) => {
781 if ((e.target as HTMLElement).closest(".kn-card")) return;
782 const rect = stage.getBoundingClientRect();
783 c.k = 8; setZoomAt(e.clientX - rect.left, e.clientY - rect.top, c.ts * 1.7, false); kick();
784 };
785 const onScroll = () => { stage.scrollTop = 0; stage.scrollLeft = 0; };
786 const onResize = () => {
787 measure();
788 if (!awayRef.current && !openRef.current) { const o = ovTarget(c.vw, c.vh); c.x = c.tx = o.x; c.y = c.ty = o.y; c.s = c.ts = o.s; c.ls = Math.log(o.s); }
789 else if (openRef.current) { const it = ITEMS.find((i) => i.id === openRef.current); if (it) api.current?.fly(it, true); }
790 kick();
791 };
792
793 measure();
794 const o = ovTarget(c.vw, c.vh);
795 c.tx = o.x; c.ty = o.y; c.ts = o.s; c.x = o.x; c.y = o.y;
796 c.s = reducedRef.current ? o.s : o.s * 2.1; c.ls = Math.log(c.s); c.k = 1.7;
797 render();
798 kick();
799 const rdy = requestAnimationFrame(() => setReady(true));
800
801 view.addEventListener("pointerdown", onDown);
802 view.addEventListener("pointermove", onMove);
803 view.addEventListener("pointerup", onUp);
804 view.addEventListener("pointercancel", onUp);
805 view.addEventListener("wheel", onWheel, { passive: false });
806 view.addEventListener("click", onClickCap, true);
807 view.addEventListener("dblclick", onDbl);
808 stage.addEventListener("scroll", onScroll);
809 const ro = new ResizeObserver(onResize);
810 ro.observe(stage);
811 return () => {
812 view.removeEventListener("pointerdown", onDown); view.removeEventListener("pointermove", onMove);
813 view.removeEventListener("pointerup", onUp); view.removeEventListener("pointercancel", onUp);
814 view.removeEventListener("wheel", onWheel); view.removeEventListener("click", onClickCap, true);
815 view.removeEventListener("dblclick", onDbl); stage.removeEventListener("scroll", onScroll);
816 ro.disconnect(); cancelAnimationFrame(rdy);
817 if (raf) cancelAnimationFrame(raf);
818 api.current = null;
819 };
820 }, [listView]);
821
822 /* ---------------- panel ---------------- */
823 useEffect(() => {
824 if (openId) {
825 const t = window.setTimeout(() => headRef.current?.focus({ preventScroll: true }), 60);
826 if (panelRef.current) panelRef.current.scrollTop = 0;
827 return () => window.clearTimeout(t);
828 }
829 }, [openId]);
830
831 const open = (it: Item, el?: HTMLElement | null) => {
832 if (el) lastBtn.current = el;
833 setOpenId(it.id);
834 api.current?.fly(it, true);
835 };
836 const close = () => {
837 setOpenId(null);
838 lastBtn.current?.focus({ preventScroll: true });
839 };
840 const overview = () => { setOpenId(null); api.current?.overview(); };
841 const step = (dir: number) => {
842 const i = ITEMS.findIndex((x) => x.id === openId);
843 const n = ITEMS[(i + dir + ITEMS.length) % ITEMS.length];
844 lastBtn.current = els.current[n.id];
845 open(n);
846 };
847 const onKey = (e: React.KeyboardEvent) => {
848 if (e.key === "Escape") {
849 if (openId) { e.preventDefault(); close(); } else if (away) { e.preventDefault(); overview(); }
850 return;
851 }
852 if (listView || e.ctrlKey || e.metaKey || e.altKey) return;
853 if ((e.target as HTMLElement).closest(".kn-panel,.kn-top,.kn-ctl")) return;
854 const a = api.current;
855 if (!a) return;
856 const k = e.key;
857 const st = 180;
858 if (k === "ArrowLeft") a.pan(-st, 0);
859 else if (k === "ArrowRight") a.pan(st, 0);
860 else if (k === "ArrowUp") a.pan(0, -st);
861 else if (k === "ArrowDown") a.pan(0, st);
862 else if (k === "+" || k === "=") a.zoom(1.3);
863 else if (k === "-" || k === "_") a.zoom(1 / 1.3);
864 else if (k === "0" || k === "Home") a.overview();
865 else return;
866 setTouched(true);
867 e.preventDefault();
868 };
869 const mmMove = (e: React.PointerEvent) => {
870 if (e.type === "pointermove" && !(e.buttons & 1)) return;
871 const b = mmBox.current;
872 if (!b) return;
873 const r = b.getBoundingClientRect();
874 api.current?.goto(clamp(((e.clientX - r.left) / r.width) * W, 0, W), clamp(((e.clientY - r.top) / r.height) * H, 0, H));
875 setTouched(true);
876 };
877
878 const reg = (id: string) => (el: HTMLElement | null) => { els.current[id] = el; };
879 const btn = (it: Item) => (
880 <button
881 type="button"
882 className={`kn-card kn-card--${it.kind}${openId === it.id ? " is-open" : ""}`}
883 aria-label={`${it.title}. ${it.kicker}. Opens case study.`}
884 onClick={(e) => open(it, e.currentTarget)}
885 onFocus={(e) => {
886 if (listView) return;
887 let vis = false;
888 try { vis = e.currentTarget.matches(":focus-visible"); } catch { vis = true; }
889 if (vis) { lastBtn.current = e.currentTarget; api.current?.fly(it, !!openRef.current); }
890 }}
891 >
892 <Face it={it} />
893 <Cap it={it} />
894 </button>
895 );
896
897 return (
898 <div ref={stageRef} className={`kn-stage${listView ? " kn-stage--list" : ""}${ready || listView ? " kn-ready" : ""}`} onKeyDown={onKey}>
899 <div className="kn-top">
900 <div className="kn-brand"><span className="kn-brand-n">Kiln</span><span className="kn-brand-s">Ceramics & design · Lisbon</span></div>
901 <div className="kn-top-r">
902 <button type="button" className="kn-btn kn-btn--ghost" aria-pressed={listView} onClick={() => { setListView((v) => !v); setOpenId(null); }}>
903 <span aria-hidden>{listView ? "◧" : "☰"}</span> {listView ? "Canvas view" : "List view"}
904 </button>
905 <a className="kn-btn kn-btn--solid" href="#kn-commission">Commission</a>
906 </div>
907 </div>
908
909 {!listView && (
910 <>
911 <div className="kn-bg1" ref={bg1} aria-hidden />
912 <div className="kn-bg2" ref={bg2} aria-hidden />
913 <div
914 ref={viewRef}
915 className="kn-view"
916 tabIndex={0}
917 role="group"
918 aria-label="Studio canvas. Arrow keys pan, plus and minus zoom, zero returns to overview. Tab moves through every project, note, sketch and the map."
919 >
920 {DECOR.map((d) => (
921 <div key={d.id} ref={reg(d.id)} className={`kn-it kn-decor kn-decor--${d.k}`} style={{ width: d.w, height: d.h, marginLeft: -d.w / 2, marginTop: -d.h / 2 }} aria-hidden>
922 <DecorArt d={d} />
923 </div>
924 ))}
925 <div ref={reg("title")} className="kn-it kn-title" style={{ width: 1500, height: 560, marginLeft: -750, marginTop: -280 }}>
926 <h1 className="kn-h1" aria-label="Kiln. Ceramics and design studio, Lisbon."><Slab /></h1>
927 </div>
928 {ITEMS.map((it) => (
929 <div key={it.id} ref={reg(it.id)} className={`kn-it kn-it--${it.kind}`} style={{ width: it.w, height: it.h, marginLeft: -it.w / 2, marginTop: -it.h / 2 }}>
930 {btn(it)}
931 </div>
932 ))}
933 </div>
934 <p className={`kn-hint${touched ? " is-off" : ""}`} aria-hidden>Drag to explore · scroll to pan · ctrl + scroll or pinch to zoom · Tab visits every piece</p>
935 <div className="kn-ctl" role="group" aria-label="Camera">
936 <button type="button" className="kn-btn kn-btn--round" onClick={() => api.current?.zoom(1 / 1.3)} aria-label="Zoom out">−</button>
937 <button type="button" className={`kn-btn kn-btn--solid kn-ret${away ? " is-on" : ""}`} onClick={overview}>Return to overview</button>
938 <button type="button" className="kn-btn kn-btn--round" onClick={() => api.current?.zoom(1.3)} aria-label="Zoom in">+</button>
939 </div>
940 <div className="kn-mm" aria-hidden>
941 <div className="kn-mm-box" ref={mmBox} onPointerDown={(e) => { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); mmMove(e); }} onPointerMove={mmMove}>
942 {ITEMS.map((it) => <i key={it.id} className="kn-mm-dot" style={{ left: `${((it.x - it.w / 2) / W) * 100}%`, top: `${((it.y - it.h / 2) / H) * 100}%`, width: `${(it.w / W) * 100}%`, height: `${(it.h / H) * 100}%`, background: MMC[it.kind] }} />)}
943 <i className="kn-mm-vp" ref={mmVp} />
944 </div>
945 <span className="kn-mm-l">Map</span>
946 </div>
947 </>
948 )}
949
950 {listView && (
951 <div className="kn-list">
952 <h1 className="kn-lh1">Kiln<span>.</span></h1>
953 <p className="kn-llede">Ceramics and design studio, Lisbon. Tableware, lighting and surfaces, thrown by hand. This is the studio canvas as a plain list: every project, note, sketch and the map.</p>
954 <div className="kn-lgrid">
955 {ITEMS.map((it) => <div key={it.id} className="kn-lcell">{btn(it)}</div>)}
956 </div>
957 </div>
958 )}
959
960 <aside ref={panelRef} className={`kn-panel${openId ? " is-on" : ""}${listView ? " kn-panel--fixed" : ""}`} aria-hidden={!openId} aria-labelledby="kn-panel-h" role="dialog" aria-modal="false">
961 {listView && openId && <button type="button" className="kn-scrim" aria-label="Close case study" tabIndex={-1} onClick={close} />}
962 <div className="kn-p-in" key={lastItem.current.id}>
963 <button type="button" className="kn-x" onClick={close} aria-label="Close case study" tabIndex={openId ? 0 : -1}>×</button>
964 <p className="kn-k">{lastItem.current.kicker}</p>
965 <h2 id="kn-panel-h" className="kn-p-h" tabIndex={-1} ref={headRef}>{lastItem.current.title}</h2>
966 <div className={`kn-p-art kn-p-art--${lastItem.current.kind}`}>
967 {lastItem.current.kind === "note" || lastItem.current.kind === "about" ? <div className={lastItem.current.kind === "note" ? "kn-p-note" : "kn-p-about"}><Face it={lastItem.current} /></div> : <Art kind={lastItem.current.art} label={lastItem.current.title} />}
968 </div>
969 <p className="kn-p-lead">{lastItem.current.lead}</p>
970 <h3 className="kn-p-h3">Details</h3>
971 <dl className="kn-specs">{lastItem.current.specs.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
972 {lastItem.current.steps && (<><h3 className="kn-p-h3">{lastItem.current.kind === "project" ? "Process" : "In practice"}</h3><ol className="kn-steps">{lastItem.current.steps.map((s, i) => <li key={i}>{s}</li>)}</ol></>)}
973 <div className="kn-p-nav">
974 <button type="button" className="kn-btn kn-btn--ghost" onClick={() => step(-1)} tabIndex={openId ? 0 : -1}>← Previous</button>
975 <button type="button" className="kn-btn kn-btn--ghost" onClick={overview} tabIndex={openId ? 0 : -1}>Overview</button>
976 <button type="button" className="kn-btn kn-btn--ghost" onClick={() => step(1)} tabIndex={openId ? 0 : -1}>Next →</button>
977 </div>
978 </div>
979 </aside>
980 </div>
981 );
982}
983
984/* ================================================================== */
985/* Below the canvas */
986/* ================================================================== */
987const MANIFESTO = "We make *things that are meant to be handled.* Every vessel is thrown, trimmed and fired in our Lisbon studio, so it arrives with the small honest evidence of a hand: a thumbprint in the foot, a glaze that pooled where the wall was thin. We design slowly, fire carefully and keep our batches *small enough to remember.*".split(" ");
988
989function Manifesto() {
990 const ref = useRef<HTMLElement>(null);
991 useScrub(ref, (p) => { ref.current?.style.setProperty("--p", p.toFixed(3)); });
992 let em = false;
993 const words = MANIFESTO.map((w, i) => {
994 const starts = w.startsWith("*"), ends = w.endsWith("*");
995 if (starts) em = true;
996 const clean = w.replace(/\*/g, "");
997 const out = <span key={i} className={`kn-w${em ? " kn-w--em" : ""}`} style={{ "--i": i, "--n": MANIFESTO.length } as CSSProperties}>{clean} </span>;
998 if (ends) em = false;
999 return out;
1000 });
1001 return (
1002 <section ref={ref} className="kn-tall kn-man" id="kn-studio" style={{ height: "220vh" }}>
1003 <div className="kn-sticky">
1004 <p className="kn-eyebrow">The studio</p>
1005 <p className="kn-man-t">{words}</p>
1006 </div>
1007 </section>
1008 );
1009}
1010
1011const K0: Prof = [[40, 170], [62, 176], [72, 190], [74, 205], [72, 218], [68, 228], [66, 232]];
1012const K1: Prof = [[22, 110], [28, 125], [38, 150], [48, 175], [56, 200], [62, 220], [64, 230]];
1013const K2: Prof = [[60, 60], [62, 90], [62, 125], [62, 160], [62, 195], [62, 222], [62, 230]];
1014const K3: Prof = [[34, 22], [30, 45], [46, 85], [74, 125], [78, 165], [66, 205], [52, 230]];
1015const KF = [K0, K1, K2, K3];
1016function morph(p: number): Prof {
1017 const sgm = clamp(p, 0, 0.9999) * 3, i = Math.floor(sgm), t = ease(sgm - i);
1018 return KF[i].map((a, n) => [lerp(a[0], KF[i + 1][n][0], t), lerp(a[1], KF[i + 1][n][1], t)]);
1019}
1020const THROW_STEPS = ["Centre the clay", "Open the well", "Pull the walls", "Shape and collar"];
1021
1022function Throwing() {
1023 const ref = useRef<HTMLElement>(null);
1024 const body = useRef<SVGPathElement>(null), clipP = useRef<SVGPathElement>(null), rim = useRef<SVGEllipseElement>(null), rimIn = useRef<SVGEllipseElement>(null);
1025 const rings = useRef<SVGGElement>(null), hl = useRef<SVGGElement>(null), hr = useRef<SVGGElement>(null), head = useRef<SVGGElement>(null);
1026 const read = useRef<HTMLSpanElement>(null);
1027 const lis = useRef<(HTMLLIElement | null)[]>([]);
1028 const uid = "knw" + useId().replace(/[^a-zA-Z0-9]/g, "");
1029 const cx = 200;
1030 const ringLines = useMemo(() => Array.from({ length: 22 }, (_, i) => 24 + i * 10), []);
1031 useScrub(ref, (p) => {
1032 const prof = morph(p);
1033 const d = outline(prof, cx);
1034 body.current?.setAttribute("d", d);
1035 clipP.current?.setAttribute("d", d);
1036 const r0 = prof[0][0], y0 = prof[0][1];
1037 for (const e of [rim.current, rimIn.current]) if (e) { e.setAttribute("cy", String(y0)); e.setAttribute("rx", String(r0)); e.setAttribute("ry", String(Math.max(2, r0 * 0.2))); }
1038 if (rimIn.current) rimIn.current.setAttribute("rx", String(Math.max(0, r0 - 4)));
1039 if (rimIn.current) rimIn.current.style.opacity = String(clamp((p - 0.28) * 6, 0, 1));
1040 if (rings.current) rings.current.style.opacity = String(clamp(p * 1.4, 0, 0.9));
1041 const hy = lerp(212, 40 + (1 - p) * 70, ease(p));
1042 const rr = rAt(prof, clamp(hy, y0 + 2, 228));
1043 hl.current?.setAttribute("transform", `translate(${cx - rr - 4} ${hy})`);
1044 hr.current?.setAttribute("transform", `translate(${cx + rr + 4} ${hy}) scale(-1 1)`);
1045 head.current?.setAttribute("transform", `translate(${(p * 900) % 30} 0)`);
1046 const idx = Math.min(3, Math.floor(p * 4));
1047 lis.current.forEach((li, i) => li && li.classList.toggle("is-on", i === idx));
1048 if (read.current) read.current.textContent = `Height ${Math.round((232 - y0) * 1.8)} mm · wall ${(14 - p * 9).toFixed(1)} mm`;
1049 });
1050 return (
1051 <section ref={ref} className="kn-tall kn-throw" id="kn-wheel" style={{ height: "320vh" }}>
1052 <div className="kn-sticky kn-throw-in">
1053 <div className="kn-throw-l">
1054 <p className="kn-eyebrow">On the wheel</p>
1055 <h2 className="kn-h2">One lump, <em>four moves.</em></h2>
1056 <p className="kn-body">Scroll to throw. A kilo and a half of stoneware becomes a vase in about six minutes, if the hands know what they are doing and the clay agrees.</p>
1057 <ol className="kn-tsteps">{THROW_STEPS.map((s, i) => <li key={s} ref={(el) => { lis.current[i] = el; }} className={i === 0 ? "is-on" : ""}><span>0{i + 1}</span>{s}</li>)}</ol>
1058 <p className="kn-mono"><span ref={read}>Height 76 mm · wall 14.0 mm</span></p>
1059 </div>
1060 <svg className="kn-throw-svg" viewBox="0 0 400 300" role="img" aria-label="A lump of clay on a wheel is centred, opened, pulled and shaped into a vase as you scroll.">
1061 <defs>
1062 <clipPath id={uid + "c"}><path ref={clipP} d={outline(K3, cx)} /></clipPath>
1063 <linearGradient id={uid + "s"} gradientUnits="userSpaceOnUse" x1="110" x2="290" y1="0" y2="0">
1064 <stop offset="0" stopColor="#000" stopOpacity=".5" /><stop offset=".25" stopColor="#fff" stopOpacity=".24" /><stop offset=".5" stopColor="#000" stopOpacity="0" /><stop offset="1" stopColor="#000" stopOpacity=".5" />
1065 </linearGradient>
1066 <clipPath id={uid + "w"}><ellipse cx="200" cy="256" rx="150" ry="22" /></clipPath>
1067 </defs>
1068 <ellipse cx="200" cy="262" rx="170" ry="26" fill="#000" opacity=".35" />
1069 <path d="M60 248V262Q200 300 340 262V248Z" fill="#26211F" />
1070 <ellipse cx="200" cy="248" rx="150" ry="22" fill="#3b312d" />
1071 <g clipPath={`url(#${uid}w)`}><g ref={head} stroke="#8a7a6c" strokeWidth="1.6" opacity=".6">{Array.from({ length: 22 }).map((_, i) => <line key={i} x1={i * 30 - 60} x2={i * 30 - 60} y1="230" y2="278" />)}</g></g>
1072 <ellipse cx="200" cy="248" rx="150" ry="22" fill="none" stroke="#8a7a6c" strokeOpacity=".6" />
1073 <path ref={body} d={outline(K0, cx)} fill="#B5573A" />
1074 <g clipPath={`url(#${uid}c)`}>
1075 <rect x="100" y="0" width="200" height="260" fill={`url(#${uid}s)`} />
1076 <g ref={rings} stroke="#000" strokeOpacity=".16" fill="none" style={{ opacity: 0 }}>{ringLines.map((y) => <path key={y} d={`M100 ${y}Q200 ${y + (y % 20 ? 2 : -2)} 300 ${y}`} />)}</g>
1077 </g>
1078 <ellipse ref={rim} cx="200" cy="170" rx="40" ry="8" fill="#D08A66" />
1079 <ellipse ref={rimIn} cx="200" cy="170" rx="36" ry="7" fill="#6b3220" style={{ opacity: 0 }} />
1080 {[hl, hr].map((rf, i) => (
1081 <g key={i} ref={rf} transform={`translate(${i ? 270 : 130} 212)${i ? " scale(-1 1)" : ""}`}>
1082 <rect x="-6" y="-24" width="30" height="48" rx="15" fill="#D9B79D" stroke="#3a312c" strokeWidth="1.4" />
1083 <rect x="-90" y="-20" width="90" height="40" rx="20" fill="#D9B79D" stroke="#3a312c" strokeWidth="1.4" />
1084 </g>
1085 ))}
1086 </svg>
1087 </div>
1088 </section>
1089 );
1090}
1091
1092const HOT: [number, number[]][] = [[0, [42, 29, 25]], [0.35, [110, 44, 28]], [0.62, [200, 70, 30]], [0.85, [255, 150, 70]], [1, [255, 230, 190]]];
1093function hotColor(t: number): string {
1094 for (let i = 0; i < HOT.length - 1; i++) {
1095 const [a, ca] = HOT[i], [b, cb] = HOT[i + 1];
1096 if (t <= b) { const u = (t - a) / (b - a); return `rgb(${ca.map((v, n) => Math.round(lerp(v, cb[n], u))).join(",")})`; }
1097 }
1098 return "rgb(255,230,190)";
1099}
1100const FIRE_STAGES: [number, string, string][] = [[1, "Water smoking", "Doors cracked, vents open. Steam leaves the clay."], [3.6, "Quartz inversion", "573°C. We crawl through it. Hurry here and the pots crack."], [8.6, "Climbing", "Glazes begin to melt and seal. The peephole turns orange."], [9.5, "Cone 6, soak", "1222°C. We watch the cones bend, then hold for twenty minutes."], [99, "Cooling", "Lid shut for twelve hours. Nobody opens it before 150°C."]];
1101
1102function Firing() {
1103 const ref = useRef<HTMLElement>(null);
1104 const path = useRef<SVGPathElement>(null), dot = useRef<SVGCircleElement>(null), glow = useRef<HTMLDivElement>(null);
1105 const tEl = useRef<HTMLSpanElement>(null), hEl = useRef<HTMLSpanElement>(null), sEl = useRef<HTMLSpanElement>(null), dEl = useRef<HTMLSpanElement>(null);
1106 const X = (h: number) => 70 + (h / 20) * 800, Y = (t: number) => 330 - (t / 1300) * 290;
1107 const pts = useMemo(() => FIRE.map(([h, t]) => [X(h), Y(t)]), []);
1108 const d = `M${pts[0][0]} ${pts[0][1]}${seg(pts)}`;
1109 useScrub(ref, (p) => {
1110 const pe = path.current;
1111 if (!pe) return;
1112 const len = pe.getTotalLength();
1113 const pt = pe.getPointAtLength(len * p);
1114 pe.style.strokeDashoffset = String(1 - p);
1115 dot.current?.setAttribute("cx", String(pt.x)); dot.current?.setAttribute("cy", String(pt.y));
1116 const T = Math.min(1222, ((330 - pt.y) / 290) * 1300), h = ((pt.x - 70) / 800) * 20;
1117 if (tEl.current) tEl.current.textContent = `${Math.round(Math.max(20, T)).toLocaleString("en-US")}`;
1118 if (hEl.current) hEl.current.textContent = `${Math.floor(h)}h ${String(Math.round((h % 1) * 60)).padStart(2, "0")}m`;
1119 const st = FIRE_STAGES.find((s) => h < s[0]) || FIRE_STAGES[4];
1120 if (sEl.current) sEl.current.textContent = st[1];
1121 if (dEl.current) dEl.current.textContent = st[2];
1122 const hot = clamp((T - 150) / 1072, 0, 1);
1123 const c = hotColor(hot);
1124 if (glow.current) { glow.current.style.background = `radial-gradient(circle at 50% 50%, ${c} 0%, ${c} 22%, rgba(60,25,15,.9) 62%, #1c1614 100%)`; glow.current.style.boxShadow = `0 0 ${Math.round(hot * 140)}px ${Math.round(hot * 30)}px rgba(255,120,40,${(hot * 0.5).toFixed(2)})`; }
1125 });
1126 return (
1127 <section ref={ref} className="kn-tall kn-fire" id="kn-kiln" style={{ height: "300vh" }}>
1128 <div className="kn-sticky kn-fire-in">
1129 <div className="kn-fire-l">
1130 <p className="kn-eyebrow kn-eyebrow--lt">In the kiln</p>
1131 <h2 className="kn-h2 kn-h2--lt">Twenty hours, <em>one curve.</em></h2>
1132 <div className="kn-peep" aria-hidden><div className="kn-peep-ring"><div className="kn-peep-glow" ref={glow} /></div></div>
1133 </div>
1134 <div className="kn-fire-r">
1135 <div className="kn-read">
1136 <div><span className="kn-big"><span ref={tEl}>20</span><small>°C</small></span><span className="kn-mono kn-mono--lt">elapsed <span ref={hEl}>0h 00m</span></span></div>
1137 <div aria-live="off"><strong ref={sEl} className="kn-stage-n">Water smoking</strong><span ref={dEl} className="kn-stage-d">Doors cracked, vents open. Steam leaves the clay.</span></div>
1138 </div>
1139 <svg viewBox="0 0 900 380" className="kn-chart" role="img" aria-label="Kiln temperature over twenty hours: a slow climb to 1222 degrees Celsius, a twenty minute soak, then a long cool-down.">
1140 <g stroke="#EFE6D6" strokeOpacity=".16">{[0, 300, 600, 900, 1200].map((t) => <line key={t} x1="70" x2="880" y1={Y(t)} y2={Y(t)} />)}</g>
1141 <g fill="#EFE6D6" fillOpacity=".6" fontFamily="ui-monospace,Menlo,monospace" fontSize="12">{[0, 300, 600, 900, 1200].map((t) => <text key={t} x="62" y={Y(t) + 4} textAnchor="end">{t}</text>)}{[0, 5, 10, 15, 20].map((h) => <text key={h} x={X(h)} y="356" textAnchor="middle">{h}h</text>)}</g>
1142 <line x1="70" x2="880" y1={Y(1222)} y2={Y(1222)} stroke="#C98F4F" strokeDasharray="4 6" strokeOpacity=".7" />
1143 <text x="878" y={Y(1222) - 8} textAnchor="end" fill="#C98F4F" fontFamily="ui-monospace,Menlo,monospace" fontSize="12">cone 6 · 1222°C</text>
1144 <path d={d} fill="none" stroke="#EFE6D6" strokeOpacity=".14" strokeWidth="3" />
1145 <path ref={path} d={d} pathLength={1} fill="none" stroke="#E27A4B" strokeWidth="4" strokeLinecap="round" strokeDasharray="1" strokeDashoffset="1" />
1146 <circle ref={dot} cx="70" cy={Y(20)} r="8" fill="#FFD9A8" stroke="#E27A4B" strokeWidth="3" />
1147 </svg>
1148 </div>
1149 </div>
1150 </section>
1151 );
1152}
1153
1154type GlazeInfo = { id: keyof typeof GL; name: string; cone: string; finish: string; recipe: string };
1155const GLAZES: GlazeInfo[] = [
1156 { id: "tide", name: "Tidewater Blue", cone: "Cone 6 · oxidation", finish: "Satin gloss that breaks to rust on edges.", recipe: "Nepheline syenite 42 · Whiting 18 · EPK 14 · Silica 24 · Cobalt carb 0.5 · Rutile 2" },
1157 { id: "bone", name: "Bone Satin", cone: "Cone 6 · oxidation", finish: "Soft matte that pools in the throwing rings.", recipe: "Custer feldspar 38 · Dolomite 14 · Zinc oxide 4 · EPK 12 · Silica 22 · Tin oxide 6" },
1158 { id: "iron", name: "Iron Night", cone: "Cone 6 · oxidation", finish: "Deep black, breaking to tenmoku brown on rims.", recipe: "Albany slip 60 · Frit 3134 20 · Nepheline 12 · Red iron oxide 8" },
1159 { id: "ember", name: "Ember", cone: "Cone 6 · oxidation", finish: "A copper-red warmth, without a reduction kiln.", recipe: "Frit 3195 44 · Silica 18 · EPK 10 · Whiting 12 · Copper carb 2 · Tin 1" },
1160 { id: "ochre", name: "Ochre Ash", cone: "Cone 6 · oxidation", finish: "Runs and pools like wood ash. Dips best at 45%.", recipe: "Hardwood ash 40 · Feldspar 30 · EPK 20 · Rutile 6 · Red iron oxide 4" },
1161 { id: "raw", name: "Raw Clay", cone: "Cone 04 · bisque only", finish: "Unglazed and burnished. Warm, faintly dusty.", recipe: "No glaze. Burnish with a spoon at leather-hard." },
1162];
1163function Glazes() {
1164 const [sel, setSel] = useState(0);
1165 const g = GLAZES[sel];
1166 const onKey = (e: React.KeyboardEvent) => {
1167 const n = GLAZES.length;
1168 if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setSel((s) => (s + 1) % n); }
1169 if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setSel((s) => (s + n - 1) % n); }
1170 };
1171 useEffect(() => { /* keep DOM focus on the selected radio */ const el = document.querySelector<HTMLElement>(`.kn-gl-b[aria-checked="true"]`); if (el && document.activeElement?.classList.contains("kn-gl-b")) el.focus(); }, [sel]);
1172 return (
1173 <section className="kn-sec kn-glz" id="kn-glazes">
1174 <div className="kn-wrap kn-glz-g">
1175 <div className="kn-rv">
1176 <p className="kn-eyebrow">Glaze library</p>
1177 <h2 className="kn-h2">Six glazes, <em>tested to death.</em></h2>
1178 <p className="kn-body">Choose one. Every recipe has been tile-tested on every clay body we use, then fired in both kilns.</p>
1179 <div className="kn-gl-list" role="radiogroup" aria-label="Glaze" onKeyDown={onKey}>
1180 {GLAZES.map((x, i) => (
1181 <button key={x.id} type="button" role="radio" aria-checked={sel === i} tabIndex={sel === i ? 0 : -1} className={`kn-gl-b${sel === i ? " is-on" : ""}`} onClick={() => setSel(i)}>
1182 <i className="kn-sw" style={{ background: `linear-gradient(135deg, ${GL[x.id].a}, ${GL[x.id].b})` }} />
1183 <span>{x.name}</span>
1184 </button>
1185 ))}
1186 </div>
1187 </div>
1188 <div className="kn-gl-stage kn-rv">
1189 <svg viewBox="0 0 300 260" key={g.id} className="kn-gl-svg" role="img" aria-label={`A vase glazed in ${g.name}`}>
1190 <Scene bg="#E4D7C0"><g transform={at(150, 1.05)}><Vessel prof={PROF.vase} glaze={GL[g.id]} seed={7 + sel} /></g><g transform={at(52, 0.52)}><Vessel prof={PROF.cup} glaze={GL[g.id]} seed={3 + sel} /></g><g transform={at(250, 0.62)}><Vessel prof={PROF.bowl} glaze={GL[g.id]} seed={5 + sel} /></g></Scene>
1191 </svg>
1192 <div className="kn-gl-info" aria-live="polite">
1193 <h3>{g.name}</h3>
1194 <p className="kn-mono">{g.cone}</p>
1195 <p>{g.finish}</p>
1196 <p className="kn-recipe">{g.recipe}</p>
1197 </div>
1198 </div>
1199 </div>
1200 </section>
1201 );
1202}
1203
1204function Marquee() {
1205 const t = ["Stoneware", "Porcelain", "Cone 6", "Hand-thrown", "Lisbon", "Two kilns", "Seconds by the fig tree", "Commissions open"];
1206 return (
1207 <div className="kn-marq" aria-hidden>
1208 <div className="kn-marq-track">{[0, 1].map((k) => <span key={k}>{t.map((x) => <b key={x}>{x}<i>●</i></b>)}</span>)}</div>
1209 </div>
1210 );
1211}
1212
1213function Commission() {
1214 const [sent, setSent] = useState(false);
1215 return (
1216 <section className="kn-sec kn-com" id="kn-commission">
1217 <div className="kn-wrap kn-com-g">
1218 <div className="kn-rv">
1219 <p className="kn-eyebrow">Commissions</p>
1220 <h2 className="kn-h2">Spring 2027 books <em>are open.</em></h2>
1221 <p className="kn-body">Tell us what you are imagining. We reply within three working days, usually with questions, sometimes with a clay sample in the post.</p>
1222 <ul className="kn-lead">
1223 <li><b>Tableware sets</b><span>8 to 12 weeks</span></li>
1224 <li><b>Lighting</b><span>12 to 16 weeks</span></li>
1225 <li><b>Tiles and surfaces</b><span>5 to 6 months</span></li>
1226 <li><b>Single pieces</b><span>4 to 6 weeks</span></li>
1227 </ul>
1228 </div>
1229 <form className="kn-form kn-rv" onSubmit={(e) => { e.preventDefault(); setSent(true); }}>
1230 {sent ? (
1231 <div className="kn-sent" role="status"><h3>Thank you.</h3><p>Your note is on the long table. Expect a reply from Ines within three working days.</p><button type="button" className="kn-btn kn-btn--ghost" onClick={() => setSent(false)}>Send another</button></div>
1232 ) : (
1233 <>
1234 <label>Your name<input name="name" required autoComplete="name" /></label>
1235 <label>Email<input name="email" type="email" required autoComplete="email" /></label>
1236 <label>What are you after?
1237 <select name="type" defaultValue="Tableware set"><option>Tableware set</option><option>Lighting</option><option>Tiles or surfaces</option><option>Planters</option><option>Something else</option></select>
1238 </label>
1239 <label>Tell us about it<textarea name="msg" rows={4} required placeholder="Where will it live? How many? By when?" /></label>
1240 <button type="submit" className="kn-btn kn-btn--solid kn-btn--lg">Send to the studio</button>
1241 </>
1242 )}
1243 </form>
1244 </div>
1245 </section>
1246 );
1247}
1248
1249function Footer() {
1250 return (
1251 <footer className="kn-foot">
1252 <div className="kn-wrap kn-foot-g">
1253 <div><h3>Visit</h3><p>Rua do Forno 14<br />Lisbon, Portugal<br />Saturdays 11 to 17</p></div>
1254 <div><h3>Write</h3><p><a href="mailto:hello@kilnstudio.example">hello@kilnstudio.example</a><br />Press and trade: <a href="mailto:press@kilnstudio.example">press@kilnstudio.example</a></p></div>
1255 <div><h3>Follow</h3><p><a href="#kn-studio">Journal</a><br /><a href="#kn-glazes">Glaze notes</a><br /><a href="#kn-kiln">Kiln log</a></p></div>
1256 </div>
1257 <p className="kn-word" aria-hidden>Kiln</p>
1258 <p className="kn-fine">© 2026 Kiln Studio Lda. Made by hand, fired twice.</p>
1259 </footer>
1260 );
1261}
1262
1263/* ================================================================== */
1264/* Page */
1265/* ================================================================== */
1266export function Page() {
1267 const root = useRef<HTMLDivElement>(null);
1268 useEffect(() => {
1269 const el = root.current;
1270 if (!el) return;
1271 const nodes = el.querySelectorAll(".kn-rv");
1272 if (typeof IntersectionObserver === "undefined") { nodes.forEach((n) => n.classList.add("is-in")); return; }
1273 const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); } }), { threshold: 0.15 });
1274 nodes.forEach((n) => io.observe(n));
1275 return () => io.disconnect();
1276 }, []);
1277 return (
1278 <div ref={root} className="kn">
1279 <style>{CSS}</style>
1280 <Stage />
1281 <Marquee />
1282 <Manifesto />
1283 <Throwing />
1284 <Firing />
1285 <Glazes />
1286 <Commission />
1287 <Footer />
1288 </div>
1289 );
1290}
1291export default Page;
1292
1293/* ================================================================== */
1294/* Styles */
1295/* ================================================================== */
1296const CSS = `
1297.kn{--kn-bone:#EFE6D6;--kn-bone2:#E4D7C0;--kn-paper:#F6EFE0;--kn-terra:#B5573A;--kn-terra-d:#8F3F28;--kn-blue:#3F6F86;--kn-blue-l:#8FB4BF;--kn-char:#26211F;--kn-ash:#6E645B;--kn-ochre:#C98F4F;
1298--kn-serif:"Fraunces","Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;--kn-sans:"Instrument Sans","Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;--kn-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;--kn-hand:"Bradley Hand","Segoe Print","Chalkboard SE","Comic Sans MS",cursive;
1299--kn-grain:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .25 0 0 0 0 .15 0 0 0 0 .1 0 0 0 .5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='.55'/></svg>");
1300font-family:var(--kn-sans);font-size:16px;line-height:1.5;color:var(--kn-char);background:var(--kn-bone);box-sizing:border-box;position:relative;overflow-x:clip;-webkit-font-smoothing:antialiased}
1301.kn *,.kn *::before,.kn *::after{box-sizing:border-box}
1302.kn :where(h1,h2,h3,p,ul,ol,dl,dd,figure){margin:0;padding:0}
1303.kn :where(ul,ol){list-style:none}
1304.kn :where(button,input,select,textarea){font:inherit;color:inherit;margin:0}
1305.kn :where(a){color:inherit}
1306.kn :where(svg){display:block}
1307.kn-wrap{max-width:1200px;margin:0 auto;padding:0 24px}
1308.kn-eyebrow{font-family:var(--kn-mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--kn-terra-d);margin-bottom:18px}
1309.kn-eyebrow--lt{color:#E6A47F}
1310.kn-h2{font-family:var(--kn-serif);font-weight:500;font-size:clamp(36px,5vw,72px);line-height:1.03;letter-spacing:-.025em;text-wrap:balance}
1311.kn-h2 em{font-style:italic;color:var(--kn-terra)}
1312.kn-h2--lt{color:var(--kn-bone)}.kn-h2--lt em{color:#E6A47F}
1313.kn-body{font-size:18px;line-height:1.65;color:#4a413b;max-width:34em;margin-top:20px}
1314.kn-mono{font-family:var(--kn-mono);font-size:13px;letter-spacing:.04em;color:var(--kn-ash)}
1315.kn-mono--lt{color:#d6c9b5}
1316
1317/* ---- stage ---- */
1318.kn-stage{position:relative;height:100vh;height:100svh;min-height:560px;overflow:hidden;overflow:clip;background:#E9DFCB;background-image:var(--kn-grain);isolation:isolate}
1319.kn-stage--list{height:auto;min-height:100vh;overflow:visible;padding-bottom:72px}
1320.kn-bg1,.kn-bg2{position:absolute;inset:0;pointer-events:none;z-index:0}
1321.kn-bg1{background-image:radial-gradient(circle,rgba(38,33,31,.28) 1.4px,transparent 1.9px);background-size:80px 80px}
1322.kn-bg2{background-image:linear-gradient(rgba(181,87,58,.14) 1px,transparent 1px),linear-gradient(90deg,rgba(181,87,58,.14) 1px,transparent 1px);background-size:400px 400px}
1323.kn-view{position:absolute;inset:0;z-index:1;touch-action:none;cursor:grab;outline:none;user-select:none;-webkit-user-select:none}
1324.kn-view.is-drag{cursor:grabbing}
1325.kn-view:focus-visible{box-shadow:inset 0 0 0 4px var(--kn-blue)}
1326.kn-it{position:absolute;left:0;top:0;transform-origin:50% 50%;visibility:hidden;opacity:0}
1327.kn-ready .kn-it{opacity:1;transition:opacity .9s ease}
1328.kn-decor{pointer-events:none}
1329.kn-decor--crumb{filter:drop-shadow(0 8px 6px rgba(40,20,10,.35))}
1330.kn-title{pointer-events:none}
1331.kn-h1{width:100%;height:100%;font-size:0;line-height:0}
1332.kn-card{appearance:none;-webkit-appearance:none;display:block;width:100%;height:100%;border:0;padding:0;text-align:left;cursor:pointer;background:var(--kn-paper);border-radius:6px;overflow:hidden;position:relative;box-shadow:0 1px 0 rgba(255,255,255,.7) inset,0 22px 34px -16px rgba(60,30,15,.5),0 3px 7px rgba(60,30,15,.28);transition:box-shadow .3s}
1333.kn-card:hover{box-shadow:0 1px 0 rgba(255,255,255,.7) inset,0 30px 44px -16px rgba(60,30,15,.6),0 3px 7px rgba(60,30,15,.3)}
1334.kn-card:focus-visible{outline:5px solid var(--kn-blue);outline-offset:6px}
1335.kn-card.is-open{outline:4px solid var(--kn-terra);outline-offset:5px}
1336.kn-art{display:block;width:100%;aspect-ratio:300/260;overflow:hidden}
1337.kn-art svg{width:100%;height:100%;transition:transform .8s cubic-bezier(.2,.8,.2,1)}
1338.kn-card:hover .kn-art svg{transform:scale(1.05)}
1339.kn-cap{display:block;padding:14px 16px 16px}
1340.kn-k{display:block;font-family:var(--kn-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--kn-ash)}
1341.kn-t{display:block;font-family:var(--kn-serif);font-weight:500;font-size:24px;line-height:1.1;letter-spacing:-.015em;margin-top:6px}
1342.kn-more{display:block;margin-top:10px;font-size:13px;color:var(--kn-terra-d);font-weight:600}
1343.kn-card--map .kn-art{aspect-ratio:380/280}
1344.kn-card--map .kn-art svg{width:100%;height:100%}
1345.kn-card--sketch .kn-art{aspect-ratio:280/220}
1346.kn-card--sketch .kn-t,.kn-card--map .kn-t{font-size:20px}
1347.kn-card--sketch{background:#F3ECDD}
1348.kn-card--note{background:#F3E7B8;border-radius:2px;padding:0;transform:none}
1349.kn-card--note::before{content:"";position:absolute;top:-4px;left:50%;width:70px;height:20px;margin-left:-35px;background:rgba(201,143,79,.55);transform:rotate(-3deg)}
1350.kn-card--note .kn-cap{display:none}
1351.kn-note-body{display:block;padding:24px 16px 0;font-family:var(--kn-hand);font-size:19px;line-height:1.3;color:#3a312c}
1352.kn-note-body span{display:block}.kn-note-h{font-size:24px;font-weight:700;color:var(--kn-terra-d);margin-bottom:4px}
1353.kn-card--about{background:var(--kn-char);color:var(--kn-bone)}
1354.kn-card--about .kn-k{color:#E6A47F}.kn-card--about .kn-t{color:var(--kn-bone)}
1355.kn-about-body span{display:block}
1356.kn-about-body{display:block;padding:22px 18px 0;font-family:var(--kn-serif);font-size:30px;line-height:1.05;letter-spacing:-.02em}
1357.kn-about-body span:nth-child(2){color:#E6A47F;font-style:italic}
1358
1359/* ---- HUD ---- */
1360.kn-top{position:absolute;z-index:20;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;padding:16px 20px;pointer-events:none}
1361.kn-top>*{pointer-events:auto}
1362.kn-brand{display:flex;flex-direction:column;line-height:1.1}
1363.kn-brand-n{font-family:var(--kn-serif);font-weight:800;font-size:28px;letter-spacing:-.03em}
1364.kn-brand-s{font-family:var(--kn-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--kn-ash);margin-top:2px}
1365.kn-top-r{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
1366.kn-btn{appearance:none;-webkit-appearance:none;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 18px;border-radius:999px;border:1.5px solid var(--kn-char);background:transparent;color:var(--kn-char);font-family:var(--kn-sans);font-size:14px;font-weight:600;text-decoration:none;cursor:pointer;transition:background .2s,color .2s,transform .2s}
1367.kn-btn:hover{background:var(--kn-char);color:var(--kn-bone)}
1368.kn-btn:focus-visible{outline:3px solid var(--kn-blue);outline-offset:3px}
1369.kn-btn--ghost{background:rgba(246,239,224,.85);backdrop-filter:blur(6px)}
1370.kn-btn--solid{background:var(--kn-char);color:var(--kn-bone)}
1371.kn-btn--solid:hover{background:var(--kn-terra-d);border-color:var(--kn-terra-d)}
1372.kn-btn--round{width:44px;padding:0;font-size:22px;background:rgba(246,239,224,.9)}
1373.kn-btn--lg{min-height:52px;padding:0 28px;font-size:16px}
1374.kn-ctl{position:absolute;z-index:20;left:50%;bottom:18px;transform:translateX(-50%);display:flex;gap:8px;align-items:center}
1375.kn-ret{opacity:.62}.kn-ret.is-on{opacity:1;background:var(--kn-terra);border-color:var(--kn-terra);color:#fff}
1376.kn-ret.is-on:hover{background:var(--kn-terra-d)}
1377.kn-hint{position:absolute;z-index:20;left:0;right:0;bottom:76px;text-align:center;font-family:var(--kn-mono);font-size:12px;letter-spacing:.06em;color:var(--kn-char);padding:0 24px;pointer-events:none;transition:opacity .8s}
1378.kn-hint.is-off{opacity:0}
1379.kn-mm{position:absolute;z-index:20;left:18px;bottom:18px;width:176px}
1380.kn-mm-box{position:relative;width:176px;height:111px;background:rgba(246,239,224,.92);border:1.5px solid var(--kn-char);border-radius:6px;overflow:hidden;cursor:crosshair;touch-action:none}
1381.kn-mm-dot{position:absolute;display:block;opacity:.85;border-radius:1px}
1382.kn-mm-vp{position:absolute;display:block;border:2px solid var(--kn-blue);background:rgba(63,111,134,.14);pointer-events:none}
1383.kn-mm-l{position:absolute;left:8px;top:-20px;font-family:var(--kn-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--kn-ash)}
1384
1385/* ---- panel ---- */
1386.kn-panel{position:absolute;z-index:30;top:0;right:0;bottom:0;width:min(480px,46%);background:var(--kn-paper);background-image:var(--kn-grain);box-shadow:-30px 0 60px -24px rgba(40,20,10,.5);overflow:auto;overscroll-behavior:contain;transform:translateX(104%);visibility:hidden;transition:transform .7s cubic-bezier(.2,.8,.2,1),visibility 0s .7s}
1387.kn-panel.is-on{transform:none;visibility:visible;transition:transform .7s cubic-bezier(.2,.8,.2,1),visibility 0s}
1388.kn-panel--fixed{position:fixed}
1389.kn-scrim{position:fixed;inset:0;right:min(480px,46%);border:0;background:rgba(38,33,31,.35);cursor:default}
1390.kn-p-in{position:relative;padding:28px 28px 40px;min-height:100%}
1391.kn-p-in .kn-k{margin-bottom:8px}
1392.kn-p-h{font-family:var(--kn-serif);font-weight:500;font-size:clamp(30px,3.4vw,44px);line-height:1.04;letter-spacing:-.025em;padding-right:48px;outline:none}
1393.kn-x{position:absolute;top:18px;right:18px;width:44px;height:44px;border-radius:50%;border:1.5px solid var(--kn-char);background:var(--kn-paper);font-size:26px;line-height:1;cursor:pointer}
1394.kn-x:hover{background:var(--kn-char);color:var(--kn-bone)}.kn-x:focus-visible{outline:3px solid var(--kn-blue);outline-offset:3px}
1395.kn-p-art{margin:22px 0;border-radius:6px;overflow:hidden;box-shadow:0 14px 30px -14px rgba(60,30,15,.5);animation:knGrow .8s cubic-bezier(.2,.8,.2,1) both}
1396.kn-p-art svg{width:100%;height:auto;aspect-ratio:300/260}
1397.kn-p-art--map svg{aspect-ratio:380/280}.kn-p-art--sketch svg{aspect-ratio:280/220}
1398.kn-p-note,.kn-p-about{padding:28px 22px}
1399.kn-p-note{background:#F3E7B8}.kn-p-note .kn-note-body{padding:0;font-size:24px}.kn-p-note .kn-note-h{font-size:30px}
1400.kn-p-about{background:var(--kn-char);color:var(--kn-bone)}.kn-p-about .kn-about-body{padding:0;font-size:44px}.kn-p-about .kn-about-body span:nth-child(2){color:#E6A47F;font-style:italic}
1401@keyframes knGrow{from{clip-path:inset(14% 26% 14% 26% round 10px);opacity:.2}to{clip-path:inset(0 0 0 0 round 6px);opacity:1}}
1402.kn-p-lead{font-family:var(--kn-serif);font-size:20px;line-height:1.5;letter-spacing:-.005em}
1403.kn-p-h3{font-family:var(--kn-mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--kn-terra-d);margin:28px 0 12px}
1404.kn-specs{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}
1405.kn-specs>div{padding:10px 0;border-top:1px solid rgba(38,33,31,.16)}
1406.kn-specs dt{font-family:var(--kn-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--kn-ash)}
1407.kn-specs dd{font-size:15px;margin-top:2px}
1408.kn-steps{counter-reset:s}
1409.kn-steps li{counter-increment:s;position:relative;padding:12px 0 12px 40px;border-top:1px solid rgba(38,33,31,.16);font-size:15.5px;line-height:1.55}
1410.kn-steps li::before{content:counter(s);position:absolute;left:0;top:11px;width:26px;height:26px;border-radius:50%;background:var(--kn-terra);color:#fff;font-family:var(--kn-mono);font-size:12px;display:grid;place-items:center}
1411.kn-p-nav{display:flex;gap:8px;margin-top:32px;flex-wrap:wrap}
1412
1413/* ---- list view ---- */
1414.kn-list{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:112px 24px 0}
1415.kn-lh1{font-family:var(--kn-serif);font-weight:800;font-size:clamp(88px,20vw,240px);line-height:.85;letter-spacing:-.05em;color:var(--kn-terra-d)}
1416.kn-lh1 span{color:var(--kn-char)}
1417.kn-llede{max-width:38em;margin-top:24px;font-size:19px;line-height:1.6}
1418.kn-lgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:28px;margin-top:48px}
1419.kn-lcell{position:relative}
1420.kn-lcell .kn-card{height:auto;min-height:100%}
1421.kn-lcell .kn-card--note{min-height:210px}
1422.kn-lcell .kn-card--about{min-height:210px}
1423
1424/* ---- marquee ---- */
1425.kn-marq{overflow:hidden;background:var(--kn-char);color:var(--kn-bone);border-top:1px solid #000;padding:16px 0}
1426.kn-marq-track{display:flex;width:max-content;animation:knMarq 38s linear infinite}
1427.kn-marq-track span{display:flex}
1428.kn-marq b{font-family:var(--kn-serif);font-weight:500;font-style:italic;font-size:28px;white-space:nowrap;display:flex;align-items:center}
1429.kn-marq i{font-style:normal;color:var(--kn-terra);font-size:12px;margin:0 28px}
1430@keyframes knMarq{to{transform:translateX(-50%)}}
1431
1432/* ---- tall scrub sections ---- */
1433.kn-tall{position:relative}
1434.kn-sticky{position:sticky;top:0;height:100vh;height:100svh;overflow:hidden;display:flex;flex-direction:column;justify-content:center}
1435.kn-man{background:var(--kn-bone);background-image:var(--kn-grain)}
1436.kn-man .kn-sticky{padding:0 clamp(24px,8vw,140px)}
1437.kn-man-t{font-family:var(--kn-serif);font-weight:500;font-size:clamp(28px,4.6vw,68px);line-height:1.14;letter-spacing:-.025em;max-width:19em}
1438.kn-w{opacity:clamp(.14,calc((var(--p,0) * (var(--n) + 6) - var(--i)) * .5),1);transition:none}
1439.kn-w--em{color:var(--kn-terra);font-style:italic}
1440.kn-throw{background:#DCCDB0;background-image:var(--kn-grain)}
1441.kn-throw-in{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,6fr);gap:32px;align-items:center;padding:0 clamp(24px,6vw,100px)}
1442.kn-throw-svg{width:100%;max-height:78vh;height:auto}
1443.kn-tsteps{margin:28px 0 22px}
1444.kn-tsteps li{display:flex;gap:16px;align-items:baseline;font-family:var(--kn-serif);font-size:26px;letter-spacing:-.01em;padding:8px 0;border-top:1px solid rgba(38,33,31,.2);color:rgba(38,33,31,.4);transition:color .3s,padding .3s}
1445.kn-tsteps li span{font-family:var(--kn-mono);font-size:12px;letter-spacing:.1em}
1446.kn-tsteps li.is-on{color:var(--kn-char);padding-left:12px}.kn-tsteps li.is-on span{color:var(--kn-terra)}
1447.kn-fire{background:#1d1816}
1448.kn-fire-in{display:grid;grid-template-columns:minmax(0,4fr) minmax(0,7fr);gap:40px;align-items:center;padding:0 clamp(24px,6vw,100px);color:var(--kn-bone)}
1449.kn-peep{margin-top:36px}
1450.kn-peep-ring{width:min(240px,30vh);aspect-ratio:1;border-radius:50%;padding:14px;background:linear-gradient(145deg,#5a4f48,#2a2321);box-shadow:inset 0 2px 4px rgba(255,255,255,.2),0 16px 30px rgba(0,0,0,.5)}
1451.kn-peep-glow{width:100%;height:100%;border-radius:50%;background:#2a1d19}
1452.kn-read{display:flex;justify-content:space-between;gap:24px;align-items:flex-end;margin-bottom:14px;flex-wrap:wrap}
1453.kn-big{font-family:var(--kn-serif);font-weight:500;font-size:clamp(56px,8vw,112px);line-height:.9;letter-spacing:-.04em;display:block;font-variant-numeric:tabular-nums}
1454.kn-big small{font-size:.4em;margin-left:6px;color:#E6A47F}
1455.kn-read>div:last-child{max-width:20em}
1456.kn-stage-n{display:block;font-family:var(--kn-serif);font-size:24px;font-weight:500;color:#E6A47F}
1457.kn-stage-d{display:block;font-size:15px;line-height:1.5;color:#d6c9b5;margin-top:4px}
1458.kn-chart{width:100%;height:auto}
1459
1460/* ---- glazes / commission / footer ---- */
1461.kn-sec{padding:clamp(72px,10vw,140px) 0}
1462.kn-glz{background:var(--kn-bone2);background-image:var(--kn-grain)}
1463.kn-glz-g{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,6fr);gap:clamp(32px,5vw,80px);align-items:center}
1464.kn-gl-list{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:28px}
1465.kn-gl-b{appearance:none;display:flex;align-items:center;gap:12px;min-height:56px;padding:6px 14px 6px 8px;border-radius:10px;border:1.5px solid rgba(38,33,31,.25);background:rgba(246,239,224,.6);text-align:left;cursor:pointer;font-size:15px;font-weight:600;transition:background .2s,border-color .2s,transform .2s}
1466.kn-gl-b:hover{border-color:var(--kn-char)}
1467.kn-gl-b.is-on{background:var(--kn-char);color:var(--kn-bone);border-color:var(--kn-char)}
1468.kn-gl-b:focus-visible{outline:3px solid var(--kn-blue);outline-offset:3px}
1469.kn-sw{width:38px;height:38px;border-radius:50%;flex:none;box-shadow:inset 0 0 0 2px rgba(255,255,255,.3),inset -4px -6px 10px rgba(0,0,0,.28)}
1470.kn-gl-stage{background:var(--kn-paper);border-radius:10px;overflow:hidden;box-shadow:0 30px 50px -24px rgba(60,30,15,.55)}
1471.kn-gl-svg{width:100%;height:auto;aspect-ratio:300/260;animation:knFade .6s ease both}
1472@keyframes knFade{from{opacity:.2;transform:scale(.98)}to{opacity:1;transform:none}}
1473.kn-gl-info{padding:20px 24px 26px}
1474.kn-gl-info h3{font-family:var(--kn-serif);font-weight:500;font-size:32px;letter-spacing:-.02em;line-height:1.1}
1475.kn-gl-info p{margin-top:6px;font-size:16px}
1476.kn-gl-info .kn-mono{margin-top:8px}
1477.kn-recipe{font-family:var(--kn-mono);font-size:12.5px!important;line-height:1.6;color:var(--kn-ash);border-top:1px dashed rgba(38,33,31,.3);padding-top:12px;margin-top:14px!important}
1478.kn-com{background:var(--kn-paper);background-image:var(--kn-grain)}
1479.kn-com-g{display:grid;grid-template-columns:minmax(0,6fr) minmax(0,5fr);gap:clamp(32px,6vw,96px);align-items:start}
1480.kn-lead{margin-top:32px}
1481.kn-lead li{display:flex;justify-content:space-between;gap:16px;padding:14px 0;border-top:1px solid rgba(38,33,31,.2);font-size:17px}
1482.kn-lead li:last-child{border-bottom:1px solid rgba(38,33,31,.2)}
1483.kn-lead span{font-family:var(--kn-mono);font-size:13px;color:var(--kn-ash)}
1484.kn-form{background:var(--kn-bone);border:1.5px solid var(--kn-char);border-radius:10px;padding:clamp(20px,3vw,34px);display:grid;gap:18px;box-shadow:10px 10px 0 var(--kn-terra)}
1485.kn-form label{display:grid;gap:6px;font-family:var(--kn-mono);font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--kn-ash)}
1486.kn-form input,.kn-form select,.kn-form textarea{width:100%;border:1.5px solid rgba(38,33,31,.4);border-radius:6px;background:#FBF6EA;padding:12px 14px;font-family:var(--kn-sans);font-size:16px;letter-spacing:0;text-transform:none;color:var(--kn-char);min-height:46px}
1487.kn-form input:focus-visible,.kn-form select:focus-visible,.kn-form textarea:focus-visible{outline:3px solid var(--kn-blue);outline-offset:1px;border-color:var(--kn-blue)}
1488.kn-sent h3{font-family:var(--kn-serif);font-weight:500;font-size:40px;letter-spacing:-.02em}
1489.kn-sent p{margin:10px 0 20px;font-size:17px}
1490.kn-foot{background:var(--kn-char);color:var(--kn-bone);padding:72px 0 28px;overflow:hidden}
1491.kn-foot-g{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:32px}
1492.kn-foot h3{font-family:var(--kn-mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#E6A47F;margin-bottom:12px}
1493.kn-foot p{font-size:16px;line-height:1.7}
1494.kn-foot a{text-underline-offset:3px}
1495.kn-foot a:focus-visible{outline:3px solid var(--kn-blue-l);outline-offset:3px}
1496.kn-word{font-family:var(--kn-serif);font-weight:800;font-size:clamp(140px,32vw,460px);line-height:.8;letter-spacing:-.05em;text-align:center;margin-top:56px;color:transparent;background:radial-gradient(circle at 22% 30%,rgba(38,20,12,.5) 1.2px,transparent 1.8px) 0 0/16px 16px,radial-gradient(circle at 70% 60%,rgba(255,230,200,.3) 1px,transparent 1.5px) 0 0/22px 22px,linear-gradient(180deg,#C4694A,#8F3F28);-webkit-background-clip:text;background-clip:text;padding-bottom:.08em}
1497.kn-fine{text-align:center;font-family:var(--kn-mono);font-size:12px;letter-spacing:.08em;color:#b9ad9c;margin-top:8px}
1498
1499/* ---- reveal ---- */
1500.kn-rv{opacity:0;transform:translateY(32px);transition:opacity .9s ease,transform .9s cubic-bezier(.2,.8,.2,1)}
1501.kn-rv.is-in{opacity:1;transform:none}
1502
1503/* ---- responsive ---- */
1504@media (max-width:900px){
1505 .kn-throw-in,.kn-fire-in{grid-template-columns:1fr;gap:12px;align-content:center}
1506 .kn-throw-svg{max-height:42vh}
1507 .kn-throw-l .kn-body,.kn-tsteps{display:none}
1508 .kn-glz-g,.kn-com-g{grid-template-columns:1fr}
1509 .kn-peep{display:none}
1510 .kn-fire-l .kn-h2{font-size:34px}
1511 .kn-foot-g{grid-template-columns:1fr}
1512}
1513@media (max-width:720px){
1514 .kn-panel{top:auto;left:0;right:0;width:100%;height:56%;border-radius:18px 18px 0 0;transform:translateY(104%)}
1515 .kn-panel.is-on{transform:none}
1516 .kn-panel--fixed{height:82%}
1517 .kn-scrim{right:0}
1518 .kn-p-in{padding:22px 20px 32px}
1519 .kn-mm{left:12px;bottom:12px;width:118px}
1520 .kn-mm-box{width:118px;height:75px}
1521 .kn-mm-l{display:none}
1522 .kn-ctl{left:auto;right:12px;bottom:12px;transform:none;flex-direction:column;align-items:flex-end}
1523 .kn-ret{min-height:44px}
1524 .kn-hint{display:none}
1525 .kn-top{padding:12px}
1526 .kn-brand-s{display:none}
1527 .kn-top-r .kn-btn{padding:0 14px;font-size:13px}
1528 .kn-stage:has(.kn-panel.is-on) .kn-mm,.kn-stage:has(.kn-panel.is-on) .kn-ctl,.kn-stage:has(.kn-panel.is-on) .kn-hint{visibility:hidden}
1529 .kn-specs{grid-template-columns:1fr}
1530 .kn-gl-list{grid-template-columns:1fr 1fr}
1531 .kn-list{padding-top:92px}
1532 .kn-man-t{font-size:clamp(26px,7.4vw,40px)}
1533}
1534@media (prefers-reduced-motion:reduce){
1535 .kn-marq-track{animation:none}
1536 .kn-rv{opacity:1;transform:none;transition:none}
1537 .kn-p-art,.kn-gl-svg{animation:none}
1538 .kn-panel,.kn-panel.is-on{transition:none}
1539 .kn-ready .kn-it{transition:none}
1540 .kn-tall{height:auto!important}
1541 .kn-sticky{position:relative;height:auto;min-height:100vh;padding-top:80px;padding-bottom:80px}
1542 .kn-w{opacity:1}
1543}
1544`;