@struq/skill 0.4.3 → 0.5.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 +1 -1
- package/package.json +2 -2
- package/skills/struq/SKILL.md +145 -89
- package/skills/struq/blocks/CATALOG.md +74 -22
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
- package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
- package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
- package/skills/struq/blocks/catalog.json +697 -16
- package/skills/struq/blocks/film.json +426 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
- package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
- package/skills/struq/blocks/headers/header-bar.tsx +24 -13
- package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
- package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
- package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
- package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
- package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
- package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
- package/skills/struq/blocks/kit/accent.ts +82 -0
- package/skills/struq/blocks/kit/blocks.css +78 -35
- package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
- package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
- package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
- package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
- package/skills/struq/blocks/kit/quiet/index.ts +7 -0
- package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
- package/skills/struq/blocks/sections/contact-form.tsx +1 -1
- package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
- package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
- package/skills/struq/blocks/sections/film-strip.tsx +18 -14
- package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
- package/skills/struq/blocks/sections/manifesto.tsx +3 -2
- package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
- package/skills/struq/blocks/sections/split-pair.tsx +4 -3
- package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/craft.md +4 -0
- package/skills/struq/references/film.md +158 -0
- package/skills/struq/references/triage.md +3 -0
- package/skills/struq/scripts/blocks.mjs +89 -7
- package/skills/struq/scripts/canon.mjs +8 -1
- package/skills/struq/scripts/check.mjs +94 -11
- package/skills/struq/scripts/film-check.mjs +997 -0
- package/skills/struq/scripts/gate.mjs +146 -0
- package/skills/struq/scripts/packs.mjs +73 -12
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
// footer-contact (Struq footer recipe)
|
|
3
|
-
// A footer whose hero is the way to reach the studio: the mail address as a real mailto link, the studio's city
|
|
4
|
-
// its
|
|
5
|
-
//
|
|
3
|
+
// A footer whose hero is the way to reach the studio: the mail address as a real mailto link, the studio's city and
|
|
4
|
+
// its opening hours, a phone line, the address, three or four plain links and a small foot line. With `clock` the city
|
|
5
|
+
// also keeps its live local time and a calm marker of whether the studio is open (computed from that time and the
|
|
6
|
+
// opening hours, never invented); without it nothing reads the time and the hours stand as plain text. One live clock
|
|
7
|
+
// per site at most, so the clock is off unless asked. Three compositions:
|
|
6
8
|
//
|
|
7
9
|
// - address: the mail address is the footer's headline, set at the top and fitted to the width with the loaded face
|
|
8
10
|
// (on a phone it breaks at the @ when one line would set too small). Its characters roll up into their own slots
|
|
9
11
|
// from blank as the footer arrives, staggered across the width, and on hover or focus each rolls up again to the
|
|
10
12
|
// same character, staggered from the side the pointer came in. The details close the footer as one ruled line.
|
|
11
|
-
// - clock:
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// - clock: the studio's city at display size is the centre, fitted to the width and sliding out of its mask. With
|
|
14
|
+
// `clock` it is a large live clock instead, hours and minutes at display size, the colon breathing once a second and
|
|
15
|
+
// a changed digit rolling over on the minute (every digit in a slot as wide as the face's widest, so nothing shifts);
|
|
16
|
+
// without scripts that slot holds the opening hours instead of a time. The mail address leads the ruled rows under
|
|
17
|
+
// it, the phone, the address and the hours beneath it, like a timetable.
|
|
15
18
|
// - columns: a quiet editorial grid. A short closing line at display size, then three columns (Contact, Studio, Volg
|
|
16
19
|
// ons); the address is a link whose underline draws from the side the pointer came in. The most restrained.
|
|
17
20
|
//
|
|
18
21
|
// <FooterContact variant="address" studio="Noord" email="hallo@noord.studio" phone={{ label: "010 214 63 80",
|
|
19
22
|
// href: "tel:+31102146380" }} address={["Maashaven Noordzijde 12", "3072 AA Rotterdam"]} city="Rotterdam"
|
|
20
|
-
//
|
|
23
|
+
// hours={{ days: [1, 2, 3, 4, 5], from: "9:00", to: "18:00" }} links={[...]} foot="..." />
|
|
24
|
+
// Add `clock timeZone="Europe/Amsterdam"` for the live time and the open marker.
|
|
21
25
|
//
|
|
22
26
|
// The entrance is scrubbed to the scroll over the footer's own arrival (from its top meeting the bottom of the screen
|
|
23
27
|
// to its bottom meeting it), with 0.4 s of smoothing, so scrolling back runs it backwards and a flick never stacks
|
|
@@ -29,8 +33,8 @@
|
|
|
29
33
|
// ---"); the fitted type stays unseen until it is measured with the face it is read in (capped), and refits when a face
|
|
30
34
|
// lands or the width changes.
|
|
31
35
|
//
|
|
32
|
-
//
|
|
33
|
-
// marker) and the real time arrives after mount, so there is nothing to mismatch. It ticks on the minute.
|
|
36
|
+
// With `clock` the time is never rendered on the server: the first paint holds a stable placeholder (an unseen 00:00,
|
|
37
|
+
// an empty marker) and the real time arrives after mount, so there is nothing to mismatch. It ticks on the minute.
|
|
34
38
|
// Reduced motion: the composed footer, no travel; the colon holds and the digits cut; hovers show a plain underline.
|
|
35
39
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
|
|
36
40
|
import { gsap, ScrollTrigger, SplitText, useGSAP } from "../kit/gsap";
|
|
@@ -53,7 +57,10 @@ type Props = {
|
|
|
53
57
|
/** The visiting address, one entry per line. */
|
|
54
58
|
address: string[];
|
|
55
59
|
city: string;
|
|
56
|
-
|
|
60
|
+
/** The city's live local time and the open marker computed from it. Off unless asked: one clock per site at most. */
|
|
61
|
+
clock?: boolean;
|
|
62
|
+
/** IANA zone for the clock. */
|
|
63
|
+
timeZone?: string;
|
|
57
64
|
/** Opening hours: weekdays 1 (Monday) to 7 (Sunday), times as "9:00". */
|
|
58
65
|
hours: { days: number[]; from: string; to: string };
|
|
59
66
|
/** The closing line the columns variant opens with. */
|
|
@@ -145,10 +152,11 @@ function faceReady() {
|
|
|
145
152
|
return Promise.race([fonts ?? Promise.resolve(), new Promise((res) => window.setTimeout(res, FONT_CAP))]);
|
|
146
153
|
}
|
|
147
154
|
|
|
148
|
-
/** The time now, only after mount (null on the server and the first paint), ticking on the minute. */
|
|
149
|
-
function useNow() {
|
|
155
|
+
/** The time now, only when `on` and only after mount (null on the server and the first paint), ticking on the minute. */
|
|
156
|
+
function useNow(on: boolean) {
|
|
150
157
|
const [now, setNow] = useState<Date | null>(null);
|
|
151
158
|
useEffect(() => {
|
|
159
|
+
if (!on) return;
|
|
152
160
|
let timer = 0;
|
|
153
161
|
const tick = () => {
|
|
154
162
|
const d = new Date();
|
|
@@ -166,8 +174,8 @@ function useNow() {
|
|
|
166
174
|
window.clearTimeout(timer);
|
|
167
175
|
document.removeEventListener("visibilitychange", onShow);
|
|
168
176
|
};
|
|
169
|
-
}, []);
|
|
170
|
-
return now;
|
|
177
|
+
}, [on]);
|
|
178
|
+
return on ? now : null;
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
type Wall = { dow: number; h: number; m: number; hh: string; mm: string; date: string };
|
|
@@ -237,8 +245,9 @@ function OpenMark({ status }: { status: { open: boolean; text: string } | null }
|
|
|
237
245
|
);
|
|
238
246
|
}
|
|
239
247
|
|
|
240
|
-
/** "Rotterdam 14:32": the city, and the local time once it is known (an unseen placeholder until then). */
|
|
241
|
-
function CityTime({ city, wall }: { city: string; wall: Wall | null }) {
|
|
248
|
+
/** "Rotterdam 14:32": the city, and with the clock its local time once it is known (an unseen placeholder until then). */
|
|
249
|
+
function CityTime({ city, wall, clock }: { city: string; wall: Wall | null; clock: boolean }) {
|
|
250
|
+
if (!clock) return <span>{city}</span>;
|
|
242
251
|
return (
|
|
243
252
|
<span className="tabular-nums">
|
|
244
253
|
{city}{" "}
|
|
@@ -309,7 +318,7 @@ function RollAddress({ email, parts, className = "", enter = false }: { email: s
|
|
|
309
318
|
</span>
|
|
310
319
|
);
|
|
311
320
|
return (
|
|
312
|
-
<span key={i} data-fc-ch="" style={{ "--d": `${((i / Math.max(1, total - 1)) * ROLL_SPAN).toFixed(3)}s` } as CSSProperties}>
|
|
321
|
+
<span key={i} data-fc-ch="" className="struq-char-mask" style={{ "--d": `${((i / Math.max(1, total - 1)) * ROLL_SPAN).toFixed(3)}s` } as CSSProperties}>
|
|
313
322
|
{enter ? <span data-fc-enter="">{roll}</span> : roll}
|
|
314
323
|
</span>
|
|
315
324
|
);
|
|
@@ -355,7 +364,8 @@ export function FooterContact({
|
|
|
355
364
|
phone,
|
|
356
365
|
address,
|
|
357
366
|
city,
|
|
358
|
-
|
|
367
|
+
clock = false,
|
|
368
|
+
timeZone = "Europe/Amsterdam",
|
|
359
369
|
hours,
|
|
360
370
|
line = "Zullen we een keer komen kijken?",
|
|
361
371
|
links,
|
|
@@ -363,7 +373,7 @@ export function FooterContact({
|
|
|
363
373
|
className = "",
|
|
364
374
|
}: Props) {
|
|
365
375
|
const root = useRef<HTMLElement>(null);
|
|
366
|
-
const now = useNow();
|
|
376
|
+
const now = useNow(clock);
|
|
367
377
|
const { wall, status } = useWall(now, timeZone, hours);
|
|
368
378
|
const known = wall !== null;
|
|
369
379
|
const at = email.indexOf("@");
|
|
@@ -431,7 +441,7 @@ export function FooterContact({
|
|
|
431
441
|
ro.disconnect();
|
|
432
442
|
document.fonts?.removeEventListener?.("loadingdone", onFonts);
|
|
433
443
|
};
|
|
434
|
-
}, [variant, email]);
|
|
444
|
+
}, [variant, email, city, clock]);
|
|
435
445
|
|
|
436
446
|
// The clock refits once the real digits replace the placeholder.
|
|
437
447
|
useEffect(() => {
|
|
@@ -661,42 +671,56 @@ export function FooterContact({
|
|
|
661
671
|
<footer
|
|
662
672
|
ref={root}
|
|
663
673
|
data-footer-contact="clock"
|
|
664
|
-
className={`relative flex min-h-svh flex-col bg-surface-0 px-
|
|
674
|
+
className={`relative flex min-h-svh flex-col bg-surface-0 px-sheet-margin pb-6 pt-16 text-ink md:pt-[10svh] ${className}`}
|
|
665
675
|
>
|
|
666
676
|
<div className="flex flex-wrap items-start justify-between gap-x-10 gap-y-4 text-body">
|
|
667
|
-
<p data-fc-soft="">
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
677
|
+
<p data-fc-soft="">{clock ? `${studio}, ${city}` : studio}</p>
|
|
678
|
+
{clock ? (
|
|
679
|
+
<p data-fc-soft="" className="text-ink-muted">
|
|
680
|
+
<OpenMark status={status} />
|
|
681
|
+
</p>
|
|
682
|
+
) : null}
|
|
673
683
|
</div>
|
|
674
684
|
|
|
675
685
|
<div className="flex flex-1 flex-col items-center justify-center py-[8svh]">
|
|
676
|
-
|
|
677
|
-
<
|
|
678
|
-
|
|
679
|
-
<span data-fc-
|
|
680
|
-
<span data-fc-
|
|
681
|
-
|
|
686
|
+
{clock ? (
|
|
687
|
+
<p className="relative w-full text-center">
|
|
688
|
+
<span className="sr-only">{wall ? `Het is nu ${time} in ${city}.` : `De tijd in ${city}.`}</span>
|
|
689
|
+
<span aria-hidden data-fc-clock="" data-pending={known ? undefined : ""} className="font-display">
|
|
690
|
+
<span data-fc-hmask="">
|
|
691
|
+
<span data-fc-h="" className="block">
|
|
692
|
+
{known ? digits(wall.hh) : "00"}
|
|
693
|
+
</span>
|
|
682
694
|
</span>
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
695
|
+
<span data-fc-colon-wrap="">
|
|
696
|
+
<span ref={colon} data-fc-colon="">
|
|
697
|
+
:
|
|
698
|
+
</span>
|
|
699
|
+
</span>
|
|
700
|
+
<span data-fc-mmask="">
|
|
701
|
+
<span data-fc-m="" className="block">
|
|
702
|
+
{known ? digits(wall.mm) : "00"}
|
|
703
|
+
</span>
|
|
687
704
|
</span>
|
|
688
705
|
</span>
|
|
689
|
-
<
|
|
690
|
-
<span
|
|
691
|
-
|
|
706
|
+
<noscript>
|
|
707
|
+
<span className="block font-display text-h2">{hoursText(hours)}</span>
|
|
708
|
+
</noscript>
|
|
709
|
+
</p>
|
|
710
|
+
) : (
|
|
711
|
+
// Without the clock the city holds the centre at display size, sliding out of its mask as the hours do.
|
|
712
|
+
<p className="relative w-full text-center">
|
|
713
|
+
<span className="sr-only">{city}</span>
|
|
714
|
+
<span aria-hidden data-fc-clock="" className="font-display">
|
|
715
|
+
<span data-fc-hmask="">
|
|
716
|
+
<span data-fc-h="" className="block">
|
|
717
|
+
{city}
|
|
718
|
+
</span>
|
|
692
719
|
</span>
|
|
693
720
|
</span>
|
|
694
|
-
</
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
</noscript>
|
|
698
|
-
</p>
|
|
699
|
-
<p data-fc-soft="" className="mt-4 text-body text-ink-muted">
|
|
721
|
+
</p>
|
|
722
|
+
)}
|
|
723
|
+
<p data-fc-soft="" hidden={!clock} className="mt-4 text-body text-ink-muted">
|
|
700
724
|
<span data-fc-fade="" data-pending={known ? undefined : ""}>{wall ? cap(wall.date) : " "}</span>
|
|
701
725
|
</p>
|
|
702
726
|
</div>
|
|
@@ -717,7 +741,7 @@ export function FooterContact({
|
|
|
717
741
|
<footer
|
|
718
742
|
ref={root}
|
|
719
743
|
data-footer-contact="columns"
|
|
720
|
-
className={`relative bg-surface-0 px-
|
|
744
|
+
className={`relative bg-surface-0 px-sheet-margin pb-6 pt-[14svh] text-ink md:pt-[18svh] ${className}`}
|
|
721
745
|
>
|
|
722
746
|
<h2 data-fc-split="" className="font-display max-w-[13ch] text-display">
|
|
723
747
|
{line}
|
|
@@ -743,11 +767,13 @@ export function FooterContact({
|
|
|
743
767
|
))}
|
|
744
768
|
</address>
|
|
745
769
|
<p className="mt-4 text-body">
|
|
746
|
-
<CityTime city={city} wall={wall} />
|
|
747
|
-
</p>
|
|
748
|
-
<p className="mt-1 text-small text-ink-muted">
|
|
749
|
-
<OpenMark status={status} />
|
|
770
|
+
<CityTime city={city} wall={wall} clock={clock} />
|
|
750
771
|
</p>
|
|
772
|
+
{clock ? (
|
|
773
|
+
<p className="mt-1 text-small text-ink-muted">
|
|
774
|
+
<OpenMark status={status} />
|
|
775
|
+
</p>
|
|
776
|
+
) : null}
|
|
751
777
|
</div>
|
|
752
778
|
<div data-fc-soft="" className="md:col-span-2 md:col-start-11">
|
|
753
779
|
<h3 className="text-small text-ink-muted">Volg ons</h3>
|
|
@@ -766,7 +792,7 @@ export function FooterContact({
|
|
|
766
792
|
<footer
|
|
767
793
|
ref={root}
|
|
768
794
|
data-footer-contact="address"
|
|
769
|
-
className={`relative bg-surface-0 px-
|
|
795
|
+
className={`relative bg-surface-0 px-sheet-margin pb-6 pt-[14svh] text-ink ${className}`}
|
|
770
796
|
>
|
|
771
797
|
<div>
|
|
772
798
|
<RollAddress email={email} parts={halves} enter className="font-display" />
|
|
@@ -778,8 +804,8 @@ export function FooterContact({
|
|
|
778
804
|
</p>
|
|
779
805
|
<p data-fc-soft="">{oneLine(address)}</p>
|
|
780
806
|
<p data-fc-soft="" className="flex flex-wrap gap-x-4">
|
|
781
|
-
<CityTime city={city} wall={wall} />
|
|
782
|
-
<OpenMark status={status} />
|
|
807
|
+
<CityTime city={city} wall={wall} clock={clock} />
|
|
808
|
+
{clock ? <OpenMark status={status} /> : <span>{hoursText(hours)}</span>}
|
|
783
809
|
</p>
|
|
784
810
|
<div data-fc-soft="">{linkList("flex gap-x-5 text-ink")}</div>
|
|
785
811
|
<p data-fc-soft="">{foot}</p>
|
|
@@ -257,7 +257,7 @@ export function FooterReveal({
|
|
|
257
257
|
className={iris ? "scene-inverse" : "bg-surface-1 text-ink"}
|
|
258
258
|
>
|
|
259
259
|
{variant === "fold" ? <div data-fr-shade="" aria-hidden /> : null}
|
|
260
|
-
<div className="relative flex min-h-svh flex-col px-
|
|
260
|
+
<div className="relative flex min-h-svh flex-col px-sheet-margin pb-6 pt-[max(4rem,11svh)] md:pt-[max(4.5rem,13svh)]">
|
|
261
261
|
<div className={iris ? "flex flex-col items-center text-center" : ""}>
|
|
262
262
|
<h2
|
|
263
263
|
id={titleId}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// its place in the bar, and the links fade in beside it as it lands (a phone's "Menu" stays). The ground
|
|
13
13
|
// comes in only once it fits the bar. Scrolling back sets it large again. Hover draws a line from the side
|
|
14
14
|
// the pointer came in.
|
|
15
|
-
// split three parts: wordmark left, the links centred, and the studio's place
|
|
16
|
-
// one line glides under the hovered link, and a thin rule under the bar fills with
|
|
15
|
+
// split three parts: wordmark left, the links centred, and the studio's place on the right (with `clock`, its live
|
|
16
|
+
// local time beside it); one line glides under the hovered link, and a thin rule under the bar fills with
|
|
17
|
+
// the page's progress. The clock is off unless asked: one live clock per site at most.
|
|
17
18
|
//
|
|
18
19
|
// The bar turns its ink with what is under it, exactly at the edge, per pixel: it is drawn three times (ink for the
|
|
19
20
|
// page ground, on-photo, on-inverse), each copy clipped to the stretches of the bar its own sections cover. Sections
|
|
@@ -88,6 +89,7 @@ type FaceProps = {
|
|
|
88
89
|
menuHref: string;
|
|
89
90
|
onMenu?: () => void;
|
|
90
91
|
place: string;
|
|
92
|
+
clock: boolean;
|
|
91
93
|
time: string | null;
|
|
92
94
|
/** The copy's ground, for the copies only. */
|
|
93
95
|
tone?: BarTone;
|
|
@@ -99,7 +101,7 @@ function Letters({ text }: { text: string }) {
|
|
|
99
101
|
return (
|
|
100
102
|
<>
|
|
101
103
|
{Array.from(text).map((c, k) => (
|
|
102
|
-
<span key={k} data-hb-ch="" style={{ "--i": k } as CSSProperties}>
|
|
104
|
+
<span key={k} data-hb-ch="" className="struq-char-mask" style={{ "--i": k } as CSSProperties}>
|
|
103
105
|
<span>{c}</span>
|
|
104
106
|
<span>{c}</span>
|
|
105
107
|
</span>
|
|
@@ -112,7 +114,7 @@ function Letters({ text }: { text: string }) {
|
|
|
112
114
|
* The bar's face. Drawn once for real (links, transparent text once the copies show) and as aria-hidden copies in every
|
|
113
115
|
* ink, so all of them lay out alike to the pixel.
|
|
114
116
|
*/
|
|
115
|
-
function Face({ real = false, tone = "ground", variant, name, homeHref, links, action, menuHref, onMenu, place, time, bind }: FaceProps) {
|
|
117
|
+
function Face({ real = false, tone = "ground", variant, name, homeHref, links, action, menuHref, onMenu, place, clock, time, bind }: FaceProps) {
|
|
116
118
|
const n = links.length;
|
|
117
119
|
const split = variant === "split";
|
|
118
120
|
// Where the full bar starts: split needs the room for three parts.
|
|
@@ -147,7 +149,7 @@ function Face({ real = false, tone = "ground", variant, name, homeHref, links, a
|
|
|
147
149
|
};
|
|
148
150
|
|
|
149
151
|
const word = (
|
|
150
|
-
<span data-hb-wmask="" className="block">
|
|
152
|
+
<span data-hb-wmask="" className="struq-line-mask block">
|
|
151
153
|
<span data-hb-wroll="" className="relative block">
|
|
152
154
|
<span className="block">{name}</span>
|
|
153
155
|
<span aria-hidden="true" className="absolute left-0 block">
|
|
@@ -181,7 +183,7 @@ function Face({ real = false, tone = "ground", variant, name, homeHref, links, a
|
|
|
181
183
|
return (
|
|
182
184
|
<div
|
|
183
185
|
data-hb-face=""
|
|
184
|
-
className={`relative h-[var(--hb-h)] items-center gap-6 px-
|
|
186
|
+
className={`relative h-[var(--hb-h)] items-center gap-6 px-sheet-margin ${split ? "grid grid-cols-[1fr_auto_1fr]" : "flex justify-between"}`}
|
|
185
187
|
>
|
|
186
188
|
{real ? null : <div data-hb-ground="" aria-hidden="true" className={`absolute inset-0 ${TONE_GROUND[tone]}`} />}
|
|
187
189
|
{real ? (
|
|
@@ -201,10 +203,15 @@ function Face({ real = false, tone = "ground", variant, name, homeHref, links, a
|
|
|
201
203
|
</Nav>
|
|
202
204
|
<div data-hb-fade="" className={`relative flex items-center gap-8 justify-self-end ${wide}`}>
|
|
203
205
|
<span className="whitespace-nowrap text-small leading-none">
|
|
204
|
-
<span data-hb-place="">{place}</span>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
<span data-hb-place="">{place}</span>
|
|
207
|
+
{clock ? (
|
|
208
|
+
<>
|
|
209
|
+
{" "}
|
|
210
|
+
<time data-hb-time="" data-pending={time ? undefined : ""} className="tabular-nums">
|
|
211
|
+
{time ?? "00:00"}
|
|
212
|
+
</time>
|
|
213
|
+
</>
|
|
214
|
+
) : null}
|
|
208
215
|
</span>
|
|
209
216
|
{act}
|
|
210
217
|
</div>
|
|
@@ -265,6 +272,7 @@ export function HeaderBar({
|
|
|
265
272
|
menuHref = "#menu",
|
|
266
273
|
onMenu,
|
|
267
274
|
place = "Rotterdam",
|
|
275
|
+
clock = false,
|
|
268
276
|
timeZone = "Europe/Amsterdam",
|
|
269
277
|
startTone = "ground",
|
|
270
278
|
}: {
|
|
@@ -280,15 +288,18 @@ export function HeaderBar({
|
|
|
280
288
|
menuHref?: string;
|
|
281
289
|
/** Opens the site's menu (the menu curtain); the phone's "Menu" is then a button. */
|
|
282
290
|
onMenu?: () => void;
|
|
283
|
-
/** split: the place
|
|
291
|
+
/** split: the studio's place, on the right of the bar. */
|
|
284
292
|
place?: string;
|
|
293
|
+
/** split: the place's live local time beside it. Off unless asked: one live clock per site at most. */
|
|
294
|
+
clock?: boolean;
|
|
295
|
+
/** IANA zone for the clock. */
|
|
285
296
|
timeZone?: string;
|
|
286
297
|
/** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
|
|
287
298
|
startTone?: BarTone;
|
|
288
299
|
}) {
|
|
289
300
|
const root = useRef<HTMLDivElement>(null);
|
|
290
301
|
const api = useRef<{ hot: (i: number, on: boolean, from?: "left" | "right") => void; focus: (on: boolean) => void } | null>(null);
|
|
291
|
-
const time = useWallTime(variant === "split", timeZone);
|
|
302
|
+
const time = useWallTime(variant === "split" && clock, timeZone);
|
|
292
303
|
|
|
293
304
|
const bind = (i: number) => ({
|
|
294
305
|
onPointerEnter: (e: PointerEvent<HTMLElement>) => {
|
|
@@ -618,7 +629,7 @@ export function HeaderBar({
|
|
|
618
629
|
{ scope: root, dependencies: [variant, links.length] },
|
|
619
630
|
);
|
|
620
631
|
|
|
621
|
-
const face = { variant, name, homeHref, links, action, menuHref, onMenu, place, time };
|
|
632
|
+
const face = { variant, name, homeHref, links, action, menuHref, onMenu, place, clock, time };
|
|
622
633
|
|
|
623
634
|
return (
|
|
624
635
|
<div ref={root} data-header-bar={variant} data-start={startTone} style={{ "--hb-adv": advanceEm(name).toFixed(3) } as CSSProperties}>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// A minimal bar: the wordmark on the left, one button on the right that reads "Menu" and rolls to "Sluiten". The menu
|
|
4
4
|
// opens as a curtain in the language of one of the page transitions, the links rise in a stagger through their masks,
|
|
5
5
|
// the hovered or focused link shows its photo on the menu's calm side, and a foot line carries the contact line and
|
|
6
|
-
// the studio's local time. It closes the same way, reversed: every part moves from where it is to where it is going,
|
|
6
|
+
// the studio's place (with `clock`, its live local time; off unless asked, one live clock per site at most). It closes the same way, reversed: every part moves from where it is to where it is going,
|
|
7
7
|
// so a close overtaken by an open turns round instead of starting over.
|
|
8
8
|
//
|
|
9
9
|
// gate a line draws from the middle of the screen and the menu opens from it, two edges parting up and down;
|
|
@@ -125,7 +125,7 @@ function BarFace({
|
|
|
125
125
|
</>
|
|
126
126
|
);
|
|
127
127
|
return (
|
|
128
|
-
<div className="flex h-[var(--menu-bar-h)] items-center justify-between gap-6 px-
|
|
128
|
+
<div className="flex h-[var(--menu-bar-h)] items-center justify-between gap-6 px-sheet-margin">
|
|
129
129
|
{real ? (
|
|
130
130
|
<a href={homeHref} className={word}>
|
|
131
131
|
{name}
|
|
@@ -169,6 +169,7 @@ export function MenuCurtain({
|
|
|
169
169
|
items,
|
|
170
170
|
contact,
|
|
171
171
|
place = "Rotterdam",
|
|
172
|
+
clock = false,
|
|
172
173
|
timeZone = "Europe/Amsterdam",
|
|
173
174
|
startTone = "ground",
|
|
174
175
|
}: {
|
|
@@ -180,8 +181,11 @@ export function MenuCurtain({
|
|
|
180
181
|
items: MenuItem[];
|
|
181
182
|
/** The foot line's contact: a mailto, a phone or a page. */
|
|
182
183
|
contact: { label: string; href: string };
|
|
183
|
-
/** The place
|
|
184
|
+
/** The studio's place, on the foot line. */
|
|
184
185
|
place?: string;
|
|
186
|
+
/** The place's live local time beside it. Off unless asked: one live clock per site at most. */
|
|
187
|
+
clock?: boolean;
|
|
188
|
+
/** IANA zone for the clock. */
|
|
185
189
|
timeZone?: string;
|
|
186
190
|
/** The ground under the bar on the first paint, before the script measures it (the hero's, usually "photo"). */
|
|
187
191
|
startTone?: BarTone;
|
|
@@ -198,13 +202,15 @@ export function MenuCurtain({
|
|
|
198
202
|
const [time, setTime] = useState("");
|
|
199
203
|
const menuId = useId();
|
|
200
204
|
|
|
205
|
+
// The clock, only when asked: empty on the server and the first paint, set after mount.
|
|
201
206
|
useEffect(() => {
|
|
207
|
+
if (!clock) return;
|
|
202
208
|
const fmt = new Intl.DateTimeFormat("nl-NL", { hour: "2-digit", minute: "2-digit", timeZone });
|
|
203
209
|
const tick = () => setTime(fmt.format(new Date()));
|
|
204
210
|
tick();
|
|
205
211
|
const id = window.setInterval(tick, 15000);
|
|
206
212
|
return () => window.clearInterval(id);
|
|
207
|
-
}, [timeZone]);
|
|
213
|
+
}, [clock, timeZone]);
|
|
208
214
|
|
|
209
215
|
/** Holds the page still and inert while the menu is open; the undo runs on every way out, unmount included. */
|
|
210
216
|
const hold = useCallback(() => {
|
|
@@ -642,10 +648,10 @@ export function MenuCurtain({
|
|
|
642
648
|
const face = { name, homeHref, items };
|
|
643
649
|
const frameBox =
|
|
644
650
|
variant === "gate"
|
|
645
|
-
? "right-
|
|
651
|
+
? "right-sheet-margin top-1/2 aspect-[4/5] h-[min(58svh,40vw)] -translate-y-1/2"
|
|
646
652
|
: variant === "slices"
|
|
647
|
-
? "bottom-[4.5rem] right-
|
|
648
|
-
: "right-[13vw] top-1/2 aspect-[4/5] h-[min(46svh,30vw)] -translate-y-1/2";
|
|
653
|
+
? "bottom-[4.5rem] right-sheet-margin top-[var(--menu-bar-h)] left-[calc(75cqw+1.5rem)]"
|
|
654
|
+
: "right-[13vw] top-1/2 aspect-[4/5] h-[min(46svh,30vw)] -translate-y-1/2"; // sheet-allow: the stack frame floats inside the page, a composition offset
|
|
649
655
|
const frameSizes = variant === "gate" ? "32vw" : "24vw";
|
|
650
656
|
const frameStart = variant === "gate" ? "inset(50% 0px 50% 0px)" : EMPTY;
|
|
651
657
|
|
|
@@ -724,7 +730,8 @@ export function MenuCurtain({
|
|
|
724
730
|
<nav aria-label="Menu" className="absolute inset-0">
|
|
725
731
|
<ul
|
|
726
732
|
data-menu-list=""
|
|
727
|
-
|
|
733
|
+
// sheet-allow: the list's right bound at 45vw leaves the frame its side, a composition offset
|
|
734
|
+
className={`absolute bottom-[4.5rem] left-sheet-margin top-[var(--menu-bar-h)] m-0 flex list-none flex-col justify-end p-0 pb-[4svh] md:justify-center md:pb-0 ${variant === "slices" ? "md:right-[calc(25cqw+1.5rem)]" : "md:right-[45vw]"}`}
|
|
728
735
|
>
|
|
729
736
|
{items.map((item, i) => (
|
|
730
737
|
<li key={item.label}>
|
|
@@ -736,7 +743,7 @@ export function MenuCurtain({
|
|
|
736
743
|
onClick={onLink}
|
|
737
744
|
className="inline-block font-display text-[min(15.5vw,9svh)] leading-[1.02] tracking-[-0.03em] md:text-[min(7.2vw,11svh)]"
|
|
738
745
|
>
|
|
739
|
-
<span className="struq-line-mask block
|
|
746
|
+
<span className="struq-line-mask block">
|
|
740
747
|
<span data-rise="" className="block" style={{ transform: "translate3d(0, 110%, 0)" }}>
|
|
741
748
|
{item.label}
|
|
742
749
|
</span>
|
|
@@ -768,14 +775,20 @@ export function MenuCurtain({
|
|
|
768
775
|
|
|
769
776
|
<div
|
|
770
777
|
data-foot=""
|
|
771
|
-
className="absolute inset-x-0 bottom-0 flex items-baseline justify-between gap-6 px-
|
|
778
|
+
className="absolute inset-x-0 bottom-0 flex items-baseline justify-between gap-6 px-sheet-margin pb-5 text-small text-[var(--on-menu-muted)] md:pb-6"
|
|
772
779
|
style={{ opacity: 0 }}
|
|
773
780
|
>
|
|
774
781
|
<a href={contact.href} className="underline-offset-4 hover:underline">
|
|
775
782
|
{contact.label}
|
|
776
783
|
</a>
|
|
777
784
|
<span>
|
|
778
|
-
{place}
|
|
785
|
+
{place}
|
|
786
|
+
{clock ? (
|
|
787
|
+
<>
|
|
788
|
+
{" "}
|
|
789
|
+
<span className="tabular-nums">{time}</span>
|
|
790
|
+
</>
|
|
791
|
+
) : null}
|
|
779
792
|
</span>
|
|
780
793
|
</div>
|
|
781
794
|
</nav>
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// Lead for a photographer, a gallery, an architect, an atelier: any brief where one image carries the work.
|
|
22
22
|
//
|
|
23
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="..." />
|
|
24
|
+
// caption="Binnenplaats met olijf, 2024" heading={["Ruimte die", "het licht volgt."]} accent="licht" lead="..." />
|
|
25
|
+
//
|
|
26
|
+
// `accent` (optional) is the site's one accent word: where it first stands in the heading (the line on the open
|
|
27
|
+
// photograph) it is set in the pairing's italic accent face, inside its line's mask, as the Statement and the Close
|
|
28
|
+
// set the same word. Left out, or not in the heading, the heading is exactly as it was.
|
|
25
29
|
//
|
|
26
30
|
// Phones: the frame larger (about 80% of the width), the label under it, the entrance only; the next line follows
|
|
27
31
|
// on the ground. Reduced motion and pages without scripts: the composed wall, then the line.
|
|
@@ -37,6 +41,7 @@
|
|
|
37
41
|
// ink, with no veil.
|
|
38
42
|
import Image from "next/image";
|
|
39
43
|
import { useRef } from "react";
|
|
44
|
+
import { accentLines } from "../kit/accent";
|
|
40
45
|
import { gsap, useGSAP } from "../kit/gsap";
|
|
41
46
|
import type { StruqImage } from "../kit/images";
|
|
42
47
|
import { photoLuminances } from "../kit/photo-read";
|
|
@@ -64,6 +69,9 @@ type Props = {
|
|
|
64
69
|
caption?: string;
|
|
65
70
|
/** The next line of copy, standing on the open photograph; one entry per line. */
|
|
66
71
|
heading: string[];
|
|
72
|
+
/** The site's one accent word: where it first stands in the heading it is set in the pairing's italic accent face
|
|
73
|
+
* (--face-accent), as the Statement and the Close set it. A word the heading does not hold changes nothing. */
|
|
74
|
+
accent?: string;
|
|
67
75
|
/** One sentence under it. */
|
|
68
76
|
lead?: string;
|
|
69
77
|
className?: string;
|
|
@@ -121,31 +129,32 @@ const LAYOUT: Record<HeroFramedVariant, { vars: Record<string, string>; frame: s
|
|
|
121
129
|
frame:
|
|
122
130
|
"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
131
|
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-
|
|
132
|
+
label: "md:absolute md:bottom-[calc(50%_-_var(--hf-fh)/2)] md:left-[calc(50%_+_var(--hf-fw)/2_+_3vw)] md:right-sheet-margin md:mt-0",
|
|
125
133
|
name: "clamp(2.25rem, 1rem + 2.4vw, 3.5rem)",
|
|
126
134
|
},
|
|
127
135
|
portret: {
|
|
128
136
|
// A 3:4 portrait, about three quarters of the screen's height, hung a little high at the right edge.
|
|
129
137
|
vars: { "--hf-fw": "min(36vw, 57svh)", "--hf-fh": "calc(var(--hf-fw) / 0.75)" },
|
|
130
138
|
frame:
|
|
139
|
+
// sheet-allow: the print hangs 6vw in from the wall's edge, its own distance; the registration below follows it
|
|
131
140
|
"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
141
|
photo: "md:left-[calc(var(--hf-fw)_+_6vw_-_100vw)] md:top-[calc(var(--hf-fh)/2_+_3svh_-_50svh)]",
|
|
133
142
|
label:
|
|
134
|
-
"md:absolute md:bottom-[calc(50%_-_var(--hf-fh)/2_+_3svh)] md:left-
|
|
143
|
+
"md:absolute md:bottom-[calc(50%_-_var(--hf-fh)/2_+_3svh)] md:left-sheet-margin md:right-[calc(10vw_+_var(--hf-fw))] md:mt-0",
|
|
135
144
|
name: "clamp(4.5rem, 1rem + 11vw, 16rem)",
|
|
136
145
|
},
|
|
137
146
|
passe: {
|
|
138
147
|
// 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
148
|
// 4:5 portrait (--hf-tw by --hf-th) at the left, standing in the middle of the screen's height.
|
|
140
149
|
vars: { "--hf-tw": "min(30vw, 52svh)", "--hf-th": "calc(var(--hf-tw) * 1.25)" },
|
|
141
|
-
frame: "md:absolute md:inset-x-
|
|
142
|
-
photo: "md:-left-
|
|
143
|
-
label: "md:absolute md:bottom-[3svh] md:right-
|
|
150
|
+
frame: "md:absolute md:inset-x-sheet-margin md:bottom-[18svh] md:top-[5svh] md:mx-0 md:aspect-auto md:w-auto",
|
|
151
|
+
photo: "md:-left-sheet-margin md:-top-[5svh]",
|
|
152
|
+
label: "md:absolute md:bottom-[3svh] md:right-sheet-margin md:mt-0 md:w-[34vw] md:text-right",
|
|
144
153
|
name: "clamp(1.75rem, 0.8rem + 1.6vw, 2.75rem)",
|
|
145
154
|
},
|
|
146
155
|
};
|
|
147
156
|
|
|
148
|
-
export function HeroFramed({ variant = "wand", image, name, line, place, caption, heading, lead, className = "" }: Props) {
|
|
157
|
+
export function HeroFramed({ variant = "wand", image, name, line, place, caption, heading, accent, lead, className = "" }: Props) {
|
|
149
158
|
const lay = LAYOUT[variant] ?? LAYOUT.wand;
|
|
150
159
|
const back = variant === "passe";
|
|
151
160
|
const stage = useRef<HTMLElement>(null);
|
|
@@ -445,7 +454,7 @@ export function HeroFramed({ variant = "wand", image, name, line, place, caption
|
|
|
445
454
|
{ scope: stage, dependencies: [name, image.src, variant] },
|
|
446
455
|
);
|
|
447
456
|
|
|
448
|
-
const mask = "
|
|
457
|
+
const mask = "struq-line-mask block";
|
|
449
458
|
const grade = "object-cover [filter:saturate(0.92)_contrast(1.04)]";
|
|
450
459
|
// Inline: the kit balances display type, which must not wrap the label's name; no transition, so the fit reads a
|
|
451
460
|
// settled size.
|
|
@@ -486,15 +495,16 @@ export function HeroFramed({ variant = "wand", image, name, line, place, caption
|
|
|
486
495
|
data-hf-copy={overlay ? "" : undefined}
|
|
487
496
|
className={
|
|
488
497
|
overlay
|
|
489
|
-
? `absolute bottom-[10svh] max-w-[44rem] px-
|
|
498
|
+
? `absolute bottom-[10svh] max-w-[44rem] px-sheet-margin 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
499
|
: at === "wall"
|
|
491
|
-
?
|
|
492
|
-
|
|
500
|
+
? // sheet-allow: beside the stepped-back print, mirroring its 6vw from the wall
|
|
501
|
+
"absolute left-[calc(12vw_+_var(--hf-tw))] right-[6vw] top-1/2 -translate-y-1/2 text-ink"
|
|
502
|
+
: "px-sheet-margin pb-20 pt-6 text-ink md:pb-[14svh]"
|
|
493
503
|
}
|
|
494
504
|
>
|
|
495
505
|
<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={
|
|
506
|
+
{accentLines(heading, accent).map((h, i) => (
|
|
507
|
+
<span key={heading[i]} className={mask}>
|
|
498
508
|
<span data-hf-copy-rise={moves ? "" : undefined} className="block">
|
|
499
509
|
{h}
|
|
500
510
|
</span>
|
|
@@ -502,12 +512,14 @@ export function HeroFramed({ variant = "wand", image, name, line, place, caption
|
|
|
502
512
|
))}
|
|
503
513
|
</h2>
|
|
504
514
|
{lead ? (
|
|
505
|
-
<p className="m-0 mt-6
|
|
506
|
-
<span
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
515
|
+
<p className="m-0 mt-6 text-lead">
|
|
516
|
+
<span className="struq-line-mask block">
|
|
517
|
+
<span
|
|
518
|
+
data-hf-copy-rise={moves ? "" : undefined}
|
|
519
|
+
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"}`}
|
|
520
|
+
>
|
|
521
|
+
{lead}
|
|
522
|
+
</span>
|
|
511
523
|
</span>
|
|
512
524
|
</p>
|
|
513
525
|
) : null}
|
|
@@ -539,7 +551,7 @@ export function HeroFramed({ variant = "wand", image, name, line, place, caption
|
|
|
539
551
|
<span data-ink="dark" className="text-[var(--hf-dark)]" />
|
|
540
552
|
</span>
|
|
541
553
|
|
|
542
|
-
<div data-hf-wall="" className="relative px-
|
|
554
|
+
<div data-hf-wall="" className="relative px-sheet-margin pb-14 pt-[12svh] md:h-svh md:overflow-hidden md:p-0">
|
|
543
555
|
{/* The frame: the box a stack preloader lands on, the photograph inside it registered to the screen. */}
|
|
544
556
|
<div
|
|
545
557
|
data-hf-frame=""
|
|
@@ -577,6 +589,7 @@ export function HeroFramed({ variant = "wand", image, name, line, place, caption
|
|
|
577
589
|
<div
|
|
578
590
|
data-hf-to=""
|
|
579
591
|
aria-hidden
|
|
592
|
+
// sheet-allow: the stepped-back print hangs 6vw in from the wall's edge, a composition offset
|
|
580
593
|
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
594
|
/>
|
|
582
595
|
) : null}
|