@struq/skill 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +3 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// hero-framed (Struq hero recipe)
|
|
3
|
+
// The first screen as a gallery wall. One photograph hung at the centre of the page ground, about a third of the
|
|
4
|
+
// screen, with four fine registration marks at its corners; beside it a small museum label (the studio's name, its
|
|
5
|
+
// line, the place), and under the frame a tiny caption (title, year). On load the marks draw in from the corners and
|
|
6
|
+
// the photograph opens inside them, then the label rises line by line. On the first scroll you walk up to the print:
|
|
7
|
+
// the frame opens to the whole screen while the marks travel out to the screen's corners and go, the label and the
|
|
8
|
+
// caption leave through their masks, and the photograph becomes the ground for the next line of copy, which rises on
|
|
9
|
+
// its calm side.
|
|
10
|
+
//
|
|
11
|
+
// Registration: the photograph is one full-screen layer (data-stack-photo) inside the frame (data-stack-target), at
|
|
12
|
+
// the screen's own origin, so the frame always shows that part of the full-screen picture, and opening it is a clip
|
|
13
|
+
// from whole-pixel numbers, never a scale. A stack preloader or page transition lands its print on the frame with
|
|
14
|
+
// the photograph laid as that layer. The copy over the open photograph is set on the side that needs the weaker
|
|
15
|
+
// veil, measured from the photograph itself (3:1 for the heading, 4.5:1 for the line), the veil kept light.
|
|
16
|
+
//
|
|
17
|
+
// The first paint is the composed wall. With scripts and motion the marks, the photograph and the label wait for the
|
|
18
|
+
// entrance (blocks.css, "--- hero-framed ---") and show after 3s if the script never comes; whether that fallback
|
|
19
|
+
// already ran is read once from the element itself. Under a closing cover the frame stands open and the rest
|
|
20
|
+
// arrives in the cover's wake. The pin is 150%, the holds as long as the move.
|
|
21
|
+
// Lead for a photographer, a gallery, an architect, an atelier: any brief where one image carries the work.
|
|
22
|
+
//
|
|
23
|
+
// <HeroFramed image={images.hero[1]} name="Noord" line="Architectuur en bouw" place="Rotterdam, sinds 2011"
|
|
24
|
+
// caption="Binnenplaats met olijf, 2024" heading={["Ruimte die", "het licht volgt."]} lead="..." />
|
|
25
|
+
//
|
|
26
|
+
// Phones: the frame larger (about 80% of the width), the label under it, the entrance only; the next line follows
|
|
27
|
+
// on the ground. Reduced motion and pages without scripts: the composed wall, then the line.
|
|
28
|
+
//
|
|
29
|
+
// Three variants, the same print on the same wall:
|
|
30
|
+
// - wand (default): the above. A small landscape print at the centre, the label beside it; you walk up to it.
|
|
31
|
+
// - portret: the type takes the room. The studio's name set large on the wall at the left, a tall portrait print
|
|
32
|
+
// hung high at the right edge. On the first scroll the print opens from the edge across the wall, over the name,
|
|
33
|
+
// to the whole screen, and the next line rises on its calm side.
|
|
34
|
+
// - passe: the image takes the room. The print nearly fills the screen inside a thin mat of the ground, the label in
|
|
35
|
+
// the mat. On the first scroll you step back: the print closes (the same clip, run toward a smaller box) to a
|
|
36
|
+
// small portrait at the left, the marks follow its corners in, and the next line rises on the wall beside it, in
|
|
37
|
+
// ink, with no veil.
|
|
38
|
+
import Image from "next/image";
|
|
39
|
+
import { useRef } from "react";
|
|
40
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
41
|
+
import type { StruqImage } from "../kit/images";
|
|
42
|
+
import { photoLuminances } from "../kit/photo-read";
|
|
43
|
+
import { covered, whenUncovered } from "../preloaders/preloader-slices-uncovered";
|
|
44
|
+
|
|
45
|
+
export const HERO_FRAMED_VARIANTS = [
|
|
46
|
+
{ id: "wand", title: "Wand" },
|
|
47
|
+
{ id: "portret", title: "Portret" },
|
|
48
|
+
{ id: "passe", title: "Passe-partout" },
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export type HeroFramedVariant = (typeof HERO_FRAMED_VARIANTS)[number]["id"];
|
|
52
|
+
|
|
53
|
+
type Props = {
|
|
54
|
+
/** The composition; the first is the default. */
|
|
55
|
+
variant?: HeroFramedVariant;
|
|
56
|
+
image: StruqImage;
|
|
57
|
+
/** The studio's name, set as the label's title. */
|
|
58
|
+
name: string;
|
|
59
|
+
/** The label's second line: the discipline. */
|
|
60
|
+
line?: string;
|
|
61
|
+
/** The label's third line: place, year. */
|
|
62
|
+
place?: string;
|
|
63
|
+
/** The caption under the frame: the photograph's title and year. */
|
|
64
|
+
caption?: string;
|
|
65
|
+
/** The next line of copy, standing on the open photograph; one entry per line. */
|
|
66
|
+
heading: string[];
|
|
67
|
+
/** One sentence under it. */
|
|
68
|
+
lead?: string;
|
|
69
|
+
className?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The longest the fit waits for the display face (the CSS fallback is at 3s). */
|
|
73
|
+
const FONT_WAIT = 2600;
|
|
74
|
+
/** The heaviest veil the copy may take over the open photograph. */
|
|
75
|
+
const VEIL_MAX = 0.45;
|
|
76
|
+
|
|
77
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
78
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
79
|
+
const linear = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
80
|
+
const encode = (l: number) => (l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055);
|
|
81
|
+
const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
82
|
+
|
|
83
|
+
/** The relative luminance of any CSS colour, through a one-pixel canvas so every colour syntax resolves. */
|
|
84
|
+
function luminanceOf(color: string): number {
|
|
85
|
+
const cv = document.createElement("canvas");
|
|
86
|
+
cv.width = 1;
|
|
87
|
+
cv.height = 1;
|
|
88
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
89
|
+
if (!ctx) return 0;
|
|
90
|
+
ctx.fillStyle = "#000";
|
|
91
|
+
ctx.fillStyle = color;
|
|
92
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
93
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
94
|
+
return 0.2126 * linear(r / 255) + 0.7152 * linear(g / 255) + 0.0722 * linear(b / 255);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The weakest veil that brings `under` to `ratio` against an ink, composited as the browser does: in sRGB. */
|
|
98
|
+
function veilFor(under: number, ink: number, veilS: number, ratio: number): number {
|
|
99
|
+
if (contrast(under, ink) >= ratio) return 0;
|
|
100
|
+
const s = encode(under);
|
|
101
|
+
const ok = (a: number) => contrast(linear(s * (1 - a) + veilS * a), ink) >= ratio;
|
|
102
|
+
if (!ok(0.85)) return 0.85;
|
|
103
|
+
let lo = 0;
|
|
104
|
+
let hi = 0.85;
|
|
105
|
+
for (let i = 0; i < 14; i += 1) {
|
|
106
|
+
const mid = (lo + hi) / 2;
|
|
107
|
+
if (ok(mid)) hi = mid;
|
|
108
|
+
else lo = mid;
|
|
109
|
+
}
|
|
110
|
+
return hi;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Corner = "tl" | "tr" | "bl" | "br";
|
|
114
|
+
const CORNERS: Corner[] = ["tl", "tr", "bl", "br"];
|
|
115
|
+
|
|
116
|
+
/** Each variant's wide-screen layout: the frame, the photograph's resting registration, the label, the name's size. */
|
|
117
|
+
const LAYOUT: Record<HeroFramedVariant, { vars: Record<string, string>; frame: string; photo: string; label: string; name: string }> = {
|
|
118
|
+
wand: {
|
|
119
|
+
// The frame: 3:2, about a third of the screen on wide screens, never taller than 60% of it.
|
|
120
|
+
vars: { "--hf-fw": "min(56vw, 90svh)", "--hf-fh": "calc(var(--hf-fw) / 1.5)" },
|
|
121
|
+
frame:
|
|
122
|
+
"md:absolute md:left-[calc(50%_-_var(--hf-fw)/2)] md:top-[calc(50%_-_var(--hf-fh)/2)] md:mx-0 md:aspect-auto md:h-[var(--hf-fh)] md:w-[var(--hf-fw)]",
|
|
123
|
+
photo: "md:left-[calc(var(--hf-fw)/2_-_50vw)] md:top-[calc(var(--hf-fh)/2_-_50svh)]",
|
|
124
|
+
label: "md:absolute md:bottom-[calc(50%_-_var(--hf-fh)/2)] md:left-[calc(50%_+_var(--hf-fw)/2_+_3vw)] md:right-[4vw] md:mt-0",
|
|
125
|
+
name: "clamp(2.25rem, 1rem + 2.4vw, 3.5rem)",
|
|
126
|
+
},
|
|
127
|
+
portret: {
|
|
128
|
+
// A 3:4 portrait, about three quarters of the screen's height, hung a little high at the right edge.
|
|
129
|
+
vars: { "--hf-fw": "min(36vw, 57svh)", "--hf-fh": "calc(var(--hf-fw) / 0.75)" },
|
|
130
|
+
frame:
|
|
131
|
+
"md:absolute md:right-[6vw] md:top-[calc(50%_-_var(--hf-fh)/2_-_3svh)] md:z-[1] md:mx-0 md:aspect-auto md:h-[var(--hf-fh)] md:w-[var(--hf-fw)]",
|
|
132
|
+
photo: "md:left-[calc(var(--hf-fw)_+_6vw_-_100vw)] md:top-[calc(var(--hf-fh)/2_+_3svh_-_50svh)]",
|
|
133
|
+
label:
|
|
134
|
+
"md:absolute md:bottom-[calc(50%_-_var(--hf-fh)/2_+_3svh)] md:left-[6vw] md:right-[calc(10vw_+_var(--hf-fw))] md:mt-0",
|
|
135
|
+
name: "clamp(4.5rem, 1rem + 11vw, 16rem)",
|
|
136
|
+
},
|
|
137
|
+
passe: {
|
|
138
|
+
// The print inside a thin mat of the ground, the mat wider at the foot where the label sits. It steps back to a
|
|
139
|
+
// 4:5 portrait (--hf-tw by --hf-th) at the left, standing in the middle of the screen's height.
|
|
140
|
+
vars: { "--hf-tw": "min(30vw, 52svh)", "--hf-th": "calc(var(--hf-tw) * 1.25)" },
|
|
141
|
+
frame: "md:absolute md:inset-x-[4vw] md:bottom-[18svh] md:top-[5svh] md:mx-0 md:aspect-auto md:w-auto",
|
|
142
|
+
photo: "md:-left-[4vw] md:-top-[5svh]",
|
|
143
|
+
label: "md:absolute md:bottom-[3svh] md:right-[4vw] md:mt-0 md:w-[34vw] md:text-right",
|
|
144
|
+
name: "clamp(1.75rem, 0.8rem + 1.6vw, 2.75rem)",
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export function HeroFramed({ variant = "wand", image, name, line, place, caption, heading, lead, className = "" }: Props) {
|
|
149
|
+
const lay = LAYOUT[variant] ?? LAYOUT.wand;
|
|
150
|
+
const back = variant === "passe";
|
|
151
|
+
const stage = useRef<HTMLElement>(null);
|
|
152
|
+
|
|
153
|
+
useGSAP(
|
|
154
|
+
() => {
|
|
155
|
+
const el = stage.current;
|
|
156
|
+
if (!el) return;
|
|
157
|
+
const q = <T extends HTMLElement = HTMLElement>(s: string) => Array.from(el.querySelectorAll<T>(s));
|
|
158
|
+
const wall = el.querySelector<HTMLElement>("[data-hf-wall]");
|
|
159
|
+
const frame = el.querySelector<HTMLElement>("[data-hf-frame]");
|
|
160
|
+
const photo = el.querySelector<HTMLElement>("[data-stack-photo]");
|
|
161
|
+
const nameEl = el.querySelector<HTMLElement>("[data-hf-name]");
|
|
162
|
+
if (!wall || !frame || !photo || !nameEl) return;
|
|
163
|
+
let dead = false;
|
|
164
|
+
|
|
165
|
+
// Whether the CSS fallback already showed the wall: the first label line rests at 0. Read once per element.
|
|
166
|
+
if (!el.dataset.hfFirst) {
|
|
167
|
+
const first = el.querySelector<HTMLElement>("[data-hf-rise]");
|
|
168
|
+
const rest = first ? getComputedStyle(first).translate : "none";
|
|
169
|
+
const y = rest === "none" ? 0 : parseFloat(rest.trim().split(/\s+/)[1] ?? "0");
|
|
170
|
+
el.dataset.hfFirst = y === 0 ? "late" : "gated";
|
|
171
|
+
}
|
|
172
|
+
const late = el.dataset.hfFirst === "late";
|
|
173
|
+
|
|
174
|
+
// The label's name fitted to the room beside the frame, with the face loaded: its CSS size, or smaller when a
|
|
175
|
+
// wide face would run past its column (so the script never makes it jump larger than the first paint).
|
|
176
|
+
const fitName = () => {
|
|
177
|
+
const col = el.querySelector<HTMLElement>("[data-hf-label]");
|
|
178
|
+
if (!col) return;
|
|
179
|
+
el.style.removeProperty("--hf-name");
|
|
180
|
+
const max = parseFloat(getComputedStyle(nameEl).fontSize) || 48;
|
|
181
|
+
const c = nameEl.cloneNode(true) as HTMLElement;
|
|
182
|
+
Object.assign(c.style, { position: "absolute", visibility: "hidden", fontSize: "100px", whiteSpace: "nowrap" });
|
|
183
|
+
nameEl.parentElement?.appendChild(c);
|
|
184
|
+
const width = c.offsetWidth;
|
|
185
|
+
c.remove();
|
|
186
|
+
const size = width > 0 ? Math.min(max, (100 * col.clientWidth) / width) : max;
|
|
187
|
+
el.style.setProperty("--hf-name", `${Math.floor(size)}px`);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// The photograph at the screen's origin inside the frame (phones: simply the frame's own crop).
|
|
191
|
+
const place = () => {
|
|
192
|
+
if (el.clientWidth < 768) {
|
|
193
|
+
for (const p of ["left", "top", "width", "height"] as const) photo.style[p] = "";
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const r = frame.getBoundingClientRect();
|
|
197
|
+
const b = wall.getBoundingClientRect();
|
|
198
|
+
Object.assign(photo.style, {
|
|
199
|
+
left: `${Math.round(b.left - r.left)}px`,
|
|
200
|
+
top: `${Math.round(b.top - r.top)}px`,
|
|
201
|
+
width: `${wall.clientWidth}px`,
|
|
202
|
+
height: `${wall.clientHeight}px`,
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// The copy over the open photograph: the side (left or right) and ink that need the weaker veil, measured.
|
|
207
|
+
const tone = () => {
|
|
208
|
+
const img = photo.querySelector("img");
|
|
209
|
+
const copy = el.querySelector<HTMLElement>("[data-hf-copy]");
|
|
210
|
+
if (!img || !copy || !copy.offsetParent) return;
|
|
211
|
+
const probe = (k: string) => {
|
|
212
|
+
const n = el.querySelector<HTMLElement>(`[data-ink='${k}']`);
|
|
213
|
+
return n ? luminanceOf(getComputedStyle(n).color) : 0;
|
|
214
|
+
};
|
|
215
|
+
const light = probe("light");
|
|
216
|
+
const dark = probe("dark");
|
|
217
|
+
const W = wall.clientWidth;
|
|
218
|
+
const H = wall.clientHeight;
|
|
219
|
+
const screen = { x: 0, y: 0, w: W, h: H };
|
|
220
|
+
const parts = [
|
|
221
|
+
{ n: copy.querySelector<HTMLElement>("h2"), ratio: 3, tail: 0.1 },
|
|
222
|
+
{ n: copy.querySelector<HTMLElement>("p"), ratio: 4.5, tail: 0.03 },
|
|
223
|
+
];
|
|
224
|
+
const base = wall.getBoundingClientRect();
|
|
225
|
+
const cb = copy.getBoundingClientRect();
|
|
226
|
+
// Where the copy block would stand at the foot (10% up from the bottom) or the top (10% down) of the photo.
|
|
227
|
+
const tops = { foot: H * 0.9 - cb.height, top: H * 0.1 };
|
|
228
|
+
const nowSide = el.dataset.side === "right" ? "right" : "left";
|
|
229
|
+
const nowPos = el.dataset.pos === "top" ? "top" : "foot";
|
|
230
|
+
const try_ = (sideTo: "left" | "right", pos: "foot" | "top") => {
|
|
231
|
+
let d = 0;
|
|
232
|
+
let l = 0;
|
|
233
|
+
// How busy the photograph is under the small line (its tonal spread, as the eye sees it): a contrast that
|
|
234
|
+
// passes over frost or gravel still reads mottled.
|
|
235
|
+
let busy = 0;
|
|
236
|
+
for (const p of parts) {
|
|
237
|
+
if (!p.n) continue;
|
|
238
|
+
const b = p.n.getBoundingClientRect();
|
|
239
|
+
let box = {
|
|
240
|
+
x: b.left - base.left - 12,
|
|
241
|
+
y: tops[pos] + (b.top - cb.top) - 12,
|
|
242
|
+
w: b.width + 24,
|
|
243
|
+
h: b.height + 24,
|
|
244
|
+
};
|
|
245
|
+
if (sideTo !== nowSide) box = { ...box, x: W - box.x - box.w };
|
|
246
|
+
const ys = photoLuminances(img, screen, box);
|
|
247
|
+
if (!ys) return null;
|
|
248
|
+
const lo = ys[Math.floor(ys.length * p.tail)];
|
|
249
|
+
const hi = ys[Math.floor(ys.length * (1 - p.tail))];
|
|
250
|
+
d = Math.max(d, veilFor(lo, dark, encode(light), p.ratio));
|
|
251
|
+
l = Math.max(l, veilFor(hi, light, encode(dark), p.ratio));
|
|
252
|
+
if (p.ratio > 3) busy = Math.max(busy, encode(ys[Math.floor(ys.length * 0.9)]) - encode(ys[Math.floor(ys.length * 0.1)]));
|
|
253
|
+
}
|
|
254
|
+
const veil = Math.min(d, l);
|
|
255
|
+
return { dark: d <= l, veil, busy, score: veil + busy, side: sideTo, pos };
|
|
256
|
+
};
|
|
257
|
+
// The foot first, on either side; the top of either side when the foot cannot reach the ratio within the light
|
|
258
|
+
// veil, or reads busy under the line, and the top is clearly calmer. A photograph that cannot be read keeps the
|
|
259
|
+
// foot and the heaviest light veil.
|
|
260
|
+
const other = nowSide === "left" ? "right" : "left";
|
|
261
|
+
const foot = [try_(nowSide, "foot"), try_(other, "foot")].filter((c) => c !== null);
|
|
262
|
+
const head = [try_(nowSide, "top"), try_(other, "top")].filter((c) => c !== null);
|
|
263
|
+
const best = (cs: typeof foot) => cs.reduce<(typeof foot)[number] | null>((a, c) => (!a || c.score + 0.02 < a.score ? c : a), null);
|
|
264
|
+
let pick = best(foot);
|
|
265
|
+
const up = best(head);
|
|
266
|
+
if (pick && up && (pick.veil > VEIL_MAX || pick.busy > 0.12) && up.score + 0.05 < pick.score) pick = up;
|
|
267
|
+
const chosen = pick ?? { dark: false, veil: VEIL_MAX, busy: 0, score: VEIL_MAX, side: nowSide, pos: nowPos };
|
|
268
|
+
el.dataset.side = chosen.side;
|
|
269
|
+
el.dataset.pos = chosen.pos;
|
|
270
|
+
el.dataset.tone = chosen.dark ? "dark" : "light";
|
|
271
|
+
// Still over the light veil's limit, or still over a busy patch: the lead, which is body text, takes one weight
|
|
272
|
+
// step and a little size.
|
|
273
|
+
if (chosen.veil > VEIL_MAX + 0.005 || chosen.busy > 0.12) el.dataset.strong = "1";
|
|
274
|
+
else delete el.dataset.strong;
|
|
275
|
+
const v = el.querySelector<HTMLElement>("[data-hf-veil]");
|
|
276
|
+
if (v) v.style.opacity = clamp(chosen.veil, 0, VEIL_MAX).toFixed(3);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const listeners: Array<() => void> = [];
|
|
280
|
+
const refit = () => {
|
|
281
|
+
if (dead) return;
|
|
282
|
+
fitName();
|
|
283
|
+
place();
|
|
284
|
+
for (const f of listeners) f();
|
|
285
|
+
tone();
|
|
286
|
+
};
|
|
287
|
+
const faceIn = Promise.race([document.fonts?.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, FONT_WAIT))]);
|
|
288
|
+
fitName();
|
|
289
|
+
place();
|
|
290
|
+
faceIn.then(refit);
|
|
291
|
+
const onFonts = () => refit();
|
|
292
|
+
document.fonts?.addEventListener?.("loadingdone", onFonts);
|
|
293
|
+
window.addEventListener("resize", refit);
|
|
294
|
+
|
|
295
|
+
// The photograph decoded ahead of its first show; its tone read once it is in.
|
|
296
|
+
for (const img of q<HTMLImageElement>("img")) {
|
|
297
|
+
const go = () => {
|
|
298
|
+
img.decode?.().catch(() => {});
|
|
299
|
+
tone();
|
|
300
|
+
};
|
|
301
|
+
if (img.complete) go();
|
|
302
|
+
else img.addEventListener("load", go, { once: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const rises = q("[data-hf-rise]");
|
|
306
|
+
const copyRise = q("[data-hf-copy-rise]");
|
|
307
|
+
const mm = gsap.matchMedia();
|
|
308
|
+
mm.add(
|
|
309
|
+
{
|
|
310
|
+
wide: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
|
|
311
|
+
phone: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
|
|
312
|
+
},
|
|
313
|
+
(ctx) => {
|
|
314
|
+
const wide = Boolean(ctx.conditions?.wide);
|
|
315
|
+
const marks = q("[data-hf-mark]");
|
|
316
|
+
const veil = el.querySelector<HTMLElement>("[data-hf-veil-on]");
|
|
317
|
+
|
|
318
|
+
// Every value the entrance and the scroll drive: the marks drawn, the photograph open, the grow.
|
|
319
|
+
const s = { draw: late ? 1 : 0, open: late || covered() ? 1 : 0, grow: 0 };
|
|
320
|
+
const g = { W: 1, H: 1, f: { x: 0, y: 0, w: 1, h: 1 }, to: { x: 0, y: 0, w: 1, h: 1 } };
|
|
321
|
+
const toBox = el.querySelector<HTMLElement>("[data-hf-to]");
|
|
322
|
+
const measure = () => {
|
|
323
|
+
g.W = wall.clientWidth;
|
|
324
|
+
g.H = wall.clientHeight;
|
|
325
|
+
const r = frame.getBoundingClientRect();
|
|
326
|
+
const b = wall.getBoundingClientRect();
|
|
327
|
+
g.f = { x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height };
|
|
328
|
+
// Where the scroll carries the print: the whole screen, or (passe) the smaller box it steps back to.
|
|
329
|
+
const t = back && toBox ? toBox.getBoundingClientRect() : null;
|
|
330
|
+
g.to = t ? { x: t.left - b.left, y: t.top - b.top, w: t.width, h: t.height } : { x: 0, y: 0, w: g.W, h: g.H };
|
|
331
|
+
};
|
|
332
|
+
const paint = () => {
|
|
333
|
+
// The marks: drawn in toward the corners (each arm grows from the corner, the mark slides in a little),
|
|
334
|
+
// then carried out to the screen's corners as the frame opens, going as they arrive.
|
|
335
|
+
for (const m of marks) {
|
|
336
|
+
const at = m.dataset.hfMark as Corner;
|
|
337
|
+
const sx = at[1] === "l" ? -1 : 1;
|
|
338
|
+
const sy = at[0] === "t" ? -1 : 1;
|
|
339
|
+
const inX = Math.round(sx * 24 * (1 - s.draw));
|
|
340
|
+
const inY = Math.round(sy * 24 * (1 - s.draw));
|
|
341
|
+
const cornerX = at[1] === "l" ? g.to.x - g.f.x : g.to.x + g.to.w - (g.f.x + g.f.w);
|
|
342
|
+
const cornerY = at[0] === "t" ? g.to.y - g.f.y : g.to.y + g.to.h - (g.f.y + g.f.h);
|
|
343
|
+
const outX = Math.round(cornerX * s.grow);
|
|
344
|
+
const outY = Math.round(cornerY * s.grow);
|
|
345
|
+
m.style.transform = `translate3d(${inX + outX}px, ${inY + outY}px, 0)`;
|
|
346
|
+
// Out to the screen's corners they go as they arrive; stepping back they stay on the smaller print.
|
|
347
|
+
m.style.opacity = back ? "1" : String(clamp(1 - (s.grow - 0.6) / 0.4, 0, 1));
|
|
348
|
+
for (const arm of Array.from(m.children) as HTMLElement[]) {
|
|
349
|
+
arm.style.transform = arm.dataset.arm === "h" ? `scaleX(${s.draw.toFixed(4)})` : `scaleY(${s.draw.toFixed(4)})`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (!wide) {
|
|
353
|
+
const v = ((1 - s.open) * 50).toFixed(3);
|
|
354
|
+
photo.style.clipPath = `inset(${v}% 0% ${v}% 0%)`;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// The photograph: from a hairline at the frame's middle to the frame, then to the whole screen.
|
|
358
|
+
const mid = g.f.y + g.f.h / 2;
|
|
359
|
+
const t0 = lerp(mid - 0.5, g.f.y, s.open);
|
|
360
|
+
const b0 = lerp(mid + 0.5, g.f.y + g.f.h, s.open);
|
|
361
|
+
// Whole pixels, rounded inward: the photograph never shows past the frame's own (fractional) box.
|
|
362
|
+
const T = Math.ceil(lerp(t0, g.to.y, s.grow));
|
|
363
|
+
const B = Math.ceil(lerp(g.H - b0, g.H - (g.to.y + g.to.h), s.grow));
|
|
364
|
+
const L = Math.ceil(lerp(g.f.x, g.to.x, s.grow));
|
|
365
|
+
const R = Math.ceil(lerp(g.W - (g.f.x + g.f.w), g.W - (g.to.x + g.to.w), s.grow));
|
|
366
|
+
photo.style.clipPath = `inset(${T}px ${R}px ${B}px ${L}px)`;
|
|
367
|
+
// The veil only where copy will stand on the photograph (never when stepping back).
|
|
368
|
+
if (veil) veil.style.opacity = back ? "0" : clamp((s.grow - 0.5) / 0.5, 0, 1).toFixed(3);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// The CSS gate lets go first, then the script holds every start itself.
|
|
372
|
+
el.dataset.live = wide ? "wide" : "phone";
|
|
373
|
+
gsap.set(rises, { y: 0, yPercent: late ? 0 : 140 });
|
|
374
|
+
gsap.set(copyRise, { y: 0, yPercent: 140 });
|
|
375
|
+
measure();
|
|
376
|
+
paint();
|
|
377
|
+
const again = () => {
|
|
378
|
+
measure();
|
|
379
|
+
paint();
|
|
380
|
+
};
|
|
381
|
+
listeners.push(again);
|
|
382
|
+
|
|
383
|
+
// The entrance, with the face in and nothing covering the page: the marks draw in from the corners, the
|
|
384
|
+
// photograph opens inside them, then the label rises line by line.
|
|
385
|
+
let intro: gsap.core.Timeline | null = null;
|
|
386
|
+
let cancel = () => {};
|
|
387
|
+
if (!late) {
|
|
388
|
+
intro = gsap.timeline({ delay: 0.15, paused: true, onUpdate: paint });
|
|
389
|
+
intro.to(s, { draw: 1, duration: 1.0, ease: "expo.out" }, 0);
|
|
390
|
+
if (s.open < 1) intro.to(s, { open: 1, duration: 1.1, ease: "power3.inOut" }, 0.3);
|
|
391
|
+
intro.to(rises, { yPercent: 0, duration: 1.0, stagger: 0.07, ease: "expo.out" }, 0.75);
|
|
392
|
+
faceIn.then(() => {
|
|
393
|
+
if (!dead) cancel = whenUncovered(() => intro?.play());
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (wide) {
|
|
398
|
+
// The first scroll: hold on the wall; walk up to the print (the frame opens by registration, the marks
|
|
399
|
+
// go to the screen's corners, the label leaves); the copy rises on the calm side; hold.
|
|
400
|
+
// Portret: the label stays on the wall and the opening print passes over it; only the caption leaves.
|
|
401
|
+
// Passe: the mat's label and the caption leave, the print steps back, the line rises beside it.
|
|
402
|
+
const leave = variant === "portret" ? rises.filter((r) => !r.closest("[data-hf-label]")) : rises;
|
|
403
|
+
const tl = gsap.timeline({
|
|
404
|
+
defaults: { ease: "none" },
|
|
405
|
+
onUpdate: paint,
|
|
406
|
+
scrollTrigger: {
|
|
407
|
+
trigger: el,
|
|
408
|
+
start: "top top",
|
|
409
|
+
end: "+=150%",
|
|
410
|
+
pin: true,
|
|
411
|
+
scrub: variant === "wand" ? 1 : 0.4,
|
|
412
|
+
onRefresh: again,
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
tl.to({}, { duration: 0.6 })
|
|
416
|
+
.to(leave, { yPercent: -140, duration: 0.45, stagger: 0.03, ease: "power2.in" }, 0.6)
|
|
417
|
+
.to(s, { grow: 1, duration: 1.2, ease: "power3.inOut" }, 0.6)
|
|
418
|
+
.to(copyRise, { yPercent: 0, duration: 0.7, stagger: 0.08, ease: "expo.out" }, back ? 1.35 : 1.6)
|
|
419
|
+
.to({}, { duration: 1.0 }, 2.0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return () => {
|
|
423
|
+
cancel();
|
|
424
|
+
intro?.kill();
|
|
425
|
+
listeners.splice(listeners.indexOf(again), 1);
|
|
426
|
+
delete el.dataset.live;
|
|
427
|
+
photo.style.clipPath = "";
|
|
428
|
+
if (veil) veil.style.opacity = "";
|
|
429
|
+
for (const m of marks) {
|
|
430
|
+
m.style.transform = "";
|
|
431
|
+
m.style.opacity = "";
|
|
432
|
+
for (const arm of Array.from(m.children) as HTMLElement[]) arm.style.transform = "";
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
return () => {
|
|
439
|
+
dead = true;
|
|
440
|
+
document.fonts?.removeEventListener?.("loadingdone", onFonts);
|
|
441
|
+
window.removeEventListener("resize", refit);
|
|
442
|
+
mm.revert();
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
{ scope: stage, dependencies: [name, image.src, variant] },
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const mask = "block overflow-hidden pb-[0.18em] -mb-[0.18em]";
|
|
449
|
+
const grade = "object-cover [filter:saturate(0.92)_contrast(1.04)]";
|
|
450
|
+
// Inline: the kit balances display type, which must not wrap the label's name; no transition, so the fit reads a
|
|
451
|
+
// settled size.
|
|
452
|
+
const nowrap = { whiteSpace: "nowrap", textWrap: "nowrap", transition: "none" } as React.CSSProperties;
|
|
453
|
+
|
|
454
|
+
const mark = (at: Corner) => (
|
|
455
|
+
<span
|
|
456
|
+
key={at}
|
|
457
|
+
data-hf-mark={at}
|
|
458
|
+
aria-hidden
|
|
459
|
+
className={`pointer-events-none absolute z-10 size-[1.1rem] ${at[0] === "t" ? "-top-[0.6rem]" : "-bottom-[0.6rem]"} ${
|
|
460
|
+
at[1] === "l" ? "-left-[0.6rem]" : "-right-[0.6rem]"
|
|
461
|
+
}`}
|
|
462
|
+
>
|
|
463
|
+
<span
|
|
464
|
+
data-arm="h"
|
|
465
|
+
className={`absolute h-px w-full bg-ink ${at[0] === "t" ? "top-0" : "bottom-0"} ${
|
|
466
|
+
at[1] === "l" ? "left-0 origin-left" : "right-0 origin-right"
|
|
467
|
+
}`}
|
|
468
|
+
/>
|
|
469
|
+
<span
|
|
470
|
+
data-arm="v"
|
|
471
|
+
className={`absolute h-full w-px bg-ink ${at[1] === "l" ? "left-0" : "right-0"} ${
|
|
472
|
+
at[0] === "t" ? "top-0 origin-top" : "bottom-0 origin-bottom"
|
|
473
|
+
}`}
|
|
474
|
+
/>
|
|
475
|
+
</span>
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const ink = "text-on-photo group-data-[tone=dark]/hf:text-[var(--hf-dark)]";
|
|
479
|
+
// Where the next line stands: on the open photograph (measured side and veil), on the wall beside the print it
|
|
480
|
+
// stepped back to (passe, plain ink), or on the ground under the wall (phones, reduced motion, no scripts).
|
|
481
|
+
const copyBlock = (at: "photo" | "wall" | "ground") => {
|
|
482
|
+
const overlay = at === "photo";
|
|
483
|
+
const moves = at !== "ground";
|
|
484
|
+
return (
|
|
485
|
+
<div
|
|
486
|
+
data-hf-copy={overlay ? "" : undefined}
|
|
487
|
+
className={
|
|
488
|
+
overlay
|
|
489
|
+
? `absolute bottom-[10svh] max-w-[44rem] px-[5vw] group-data-[pos=top]/hf:bottom-auto group-data-[pos=top]/hf:top-[10svh] ${ink} group-data-[side=right]/hf:right-0 group-data-[side=right]/hf:text-right group-data-[side=left]/hf:left-0`
|
|
490
|
+
: at === "wall"
|
|
491
|
+
? "absolute left-[calc(12vw_+_var(--hf-tw))] right-[6vw] top-1/2 -translate-y-1/2 text-ink"
|
|
492
|
+
: "px-6 pb-20 pt-6 text-ink md:px-[8vw] md:pb-[14svh]"
|
|
493
|
+
}
|
|
494
|
+
>
|
|
495
|
+
<h2 className="m-0 font-display text-[clamp(2.5rem,1rem_+_4.4vw,6.5rem)] leading-[0.95] tracking-[-0.03em]">
|
|
496
|
+
{heading.map((h) => (
|
|
497
|
+
<span key={h} className={mask}>
|
|
498
|
+
<span data-hf-copy-rise={moves ? "" : undefined} className="block">
|
|
499
|
+
{h}
|
|
500
|
+
</span>
|
|
501
|
+
</span>
|
|
502
|
+
))}
|
|
503
|
+
</h2>
|
|
504
|
+
{lead ? (
|
|
505
|
+
<p className="m-0 mt-6 overflow-hidden text-lead">
|
|
506
|
+
<span
|
|
507
|
+
data-hf-copy-rise={moves ? "" : undefined}
|
|
508
|
+
className={`block max-w-[36ch] ${overlay ? "group-data-[side=right]/hf:ml-auto group-data-[strong]/hf:text-[1.2em] group-data-[strong]/hf:font-medium" : "text-ink-muted"}`}
|
|
509
|
+
>
|
|
510
|
+
{lead}
|
|
511
|
+
</span>
|
|
512
|
+
</p>
|
|
513
|
+
) : null}
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<section
|
|
520
|
+
ref={stage}
|
|
521
|
+
data-hero-framed=""
|
|
522
|
+
data-variant={variant}
|
|
523
|
+
data-side="left"
|
|
524
|
+
data-tone="light"
|
|
525
|
+
aria-label={name}
|
|
526
|
+
className={`group/hf relative bg-surface-0 ${className}`}
|
|
527
|
+
style={
|
|
528
|
+
{
|
|
529
|
+
...lay.vars,
|
|
530
|
+
"--hf-name-css": lay.name,
|
|
531
|
+
"--hf-dark": "oklch(from var(--surface-0) min(calc(l * 0.42), 0.19) c h)",
|
|
532
|
+
} as React.CSSProperties
|
|
533
|
+
}
|
|
534
|
+
>
|
|
535
|
+
<h1 className="sr-only">{`${name}${line ? `, ${line}` : ""}`}</h1>
|
|
536
|
+
{/* The inks as this palette resolves them, read once to measure contrast. */}
|
|
537
|
+
<span aria-hidden className="pointer-events-none absolute size-0 overflow-hidden">
|
|
538
|
+
<span data-ink="light" className="text-on-photo" />
|
|
539
|
+
<span data-ink="dark" className="text-[var(--hf-dark)]" />
|
|
540
|
+
</span>
|
|
541
|
+
|
|
542
|
+
<div data-hf-wall="" className="relative px-6 pb-14 pt-[12svh] md:h-svh md:overflow-hidden md:p-0">
|
|
543
|
+
{/* The frame: the box a stack preloader lands on, the photograph inside it registered to the screen. */}
|
|
544
|
+
<div
|
|
545
|
+
data-hf-frame=""
|
|
546
|
+
data-stack-target=""
|
|
547
|
+
className={`relative mx-auto aspect-[4/5] w-[80vw] ${lay.frame}`}
|
|
548
|
+
>
|
|
549
|
+
<div className="absolute inset-0 overflow-hidden bg-surface-2 group-data-[live=wide]/hf:overflow-visible group-data-[live=wide]/hf:bg-transparent">
|
|
550
|
+
<div data-stack-photo="" className={`absolute inset-0 md:inset-auto ${lay.photo} md:h-svh md:w-screen`}>
|
|
551
|
+
<Image src={image.src} alt={image.alt} fill priority sizes="100vw" unoptimized={image.unoptimized} className={grade} />
|
|
552
|
+
{/* The veil the copy needs over the open photograph, coming in with the open; light, measured. */}
|
|
553
|
+
<div data-hf-veil-on="" aria-hidden className="absolute inset-0 opacity-0">
|
|
554
|
+
<div
|
|
555
|
+
data-hf-veil=""
|
|
556
|
+
className="absolute inset-0 bg-[var(--hf-dark)] group-data-[tone=dark]/hf:bg-on-photo"
|
|
557
|
+
style={{ opacity: 0.3 }}
|
|
558
|
+
/>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</div>
|
|
562
|
+
{CORNERS.map(mark)}
|
|
563
|
+
{/* The caption under the frame. */}
|
|
564
|
+
{caption ? (
|
|
565
|
+
<p className="absolute left-0 top-[calc(100%_+_1rem)] m-0 text-small text-ink-muted">
|
|
566
|
+
<span className={mask}>
|
|
567
|
+
<span data-hf-rise="" className="block">
|
|
568
|
+
{caption}
|
|
569
|
+
</span>
|
|
570
|
+
</span>
|
|
571
|
+
</p>
|
|
572
|
+
) : null}
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
{/* The box the passe print steps back to, read for its place and size. */}
|
|
576
|
+
{back ? (
|
|
577
|
+
<div
|
|
578
|
+
data-hf-to=""
|
|
579
|
+
aria-hidden
|
|
580
|
+
className="pointer-events-none invisible absolute left-[6vw] top-[calc(50%_-_var(--hf-th)/2)] hidden h-[var(--hf-th)] w-[var(--hf-tw)] md:block"
|
|
581
|
+
/>
|
|
582
|
+
) : null}
|
|
583
|
+
|
|
584
|
+
{/* The museum label: beside the frame (wand), large on the wall (portret) or in the mat (passe) on wide
|
|
585
|
+
screens, aligned to the frame's foot; under it on phones. */}
|
|
586
|
+
<div data-hf-label="" className={`mt-14 ${lay.label}`}>
|
|
587
|
+
<p className="m-0 font-display text-[length:var(--hf-name,var(--hf-name-css))] leading-[0.95] tracking-[-0.02em] text-ink">
|
|
588
|
+
<span className={mask}>
|
|
589
|
+
<span data-hf-rise="" className="block">
|
|
590
|
+
<span data-hf-name="" className="inline-block" style={nowrap}>
|
|
591
|
+
{name}
|
|
592
|
+
</span>
|
|
593
|
+
</span>
|
|
594
|
+
</span>
|
|
595
|
+
</p>
|
|
596
|
+
{line ? (
|
|
597
|
+
<p className="m-0 mt-3 text-small text-ink">
|
|
598
|
+
<span className={mask}>
|
|
599
|
+
<span data-hf-rise="" className="block">
|
|
600
|
+
{line}
|
|
601
|
+
</span>
|
|
602
|
+
</span>
|
|
603
|
+
</p>
|
|
604
|
+
) : null}
|
|
605
|
+
{place ? (
|
|
606
|
+
<p className="m-0 mt-1 text-small text-ink-muted">
|
|
607
|
+
<span className={mask}>
|
|
608
|
+
<span data-hf-rise="" className="block">
|
|
609
|
+
{place}
|
|
610
|
+
</span>
|
|
611
|
+
</span>
|
|
612
|
+
</p>
|
|
613
|
+
) : null}
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{/* The next line, over the open photograph or beside the stepped-back print (wide screens with motion, once
|
|
617
|
+
scripts run). */}
|
|
618
|
+
<div className="pointer-events-none absolute inset-0 z-20 hidden md:motion-safe:[:is(.js,.sq-js)_&]:block">
|
|
619
|
+
{copyBlock(back ? "wall" : "photo")}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
|
|
623
|
+
{/* The next line on the ground: phones, reduced motion and pages without scripts. */}
|
|
624
|
+
<div className="md:motion-safe:[:is(.js,.sq-js)_&]:hidden">{copyBlock("ground")}</div>
|
|
625
|
+
</section>
|
|
626
|
+
);
|
|
627
|
+
}
|