@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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/skills/struq/SKILL.md +145 -89
  4. package/skills/struq/blocks/CATALOG.md +74 -22
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +34 -12
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +14 -6
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +40 -15
  8. package/skills/struq/blocks/catalog.json +697 -16
  9. package/skills/struq/blocks/film.json +426 -0
  10. package/skills/struq/blocks/footers/footer-contact.tsx +81 -55
  11. package/skills/struq/blocks/footers/footer-reveal.tsx +1 -1
  12. package/skills/struq/blocks/headers/header-bar.tsx +24 -13
  13. package/skills/struq/blocks/headers/menu-curtain.tsx +24 -11
  14. package/skills/struq/blocks/heroes/hero-framed.tsx +33 -20
  15. package/skills/struq/blocks/heroes/hero-index.tsx +32 -11
  16. package/skills/struq/blocks/heroes/hero-poster.tsx +68 -35
  17. package/skills/struq/blocks/heroes/hero-split.tsx +22 -9
  18. package/skills/struq/blocks/heroes/hero-still.tsx +80 -74
  19. package/skills/struq/blocks/kit/accent.ts +82 -0
  20. package/skills/struq/blocks/kit/blocks.css +78 -35
  21. package/skills/struq/blocks/kit/quiet/Close.tsx +73 -0
  22. package/skills/struq/blocks/kit/quiet/PhotoText.tsx +79 -0
  23. package/skills/struq/blocks/kit/quiet/Rows.tsx +81 -0
  24. package/skills/struq/blocks/kit/quiet/Statement.tsx +157 -0
  25. package/skills/struq/blocks/kit/quiet/index.ts +7 -0
  26. package/skills/struq/blocks/kit/quiet/sheet.ts +12 -0
  27. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +87 -36
  28. package/skills/struq/blocks/preloaders/preloader-gate.tsx +116 -46
  29. package/skills/struq/blocks/preloaders/preloader-stack.tsx +10 -7
  30. package/skills/struq/blocks/sections/contact-form.tsx +1 -1
  31. package/skills/struq/blocks/sections/cta-closing.tsx +3 -2
  32. package/skills/struq/blocks/sections/fanned-cards.tsx +14 -10
  33. package/skills/struq/blocks/sections/film-strip.tsx +18 -14
  34. package/skills/struq/blocks/sections/grid-assembly.tsx +16 -11
  35. package/skills/struq/blocks/sections/manifesto.tsx +3 -2
  36. package/skills/struq/blocks/sections/panel-accordion.tsx +3 -2
  37. package/skills/struq/blocks/sections/split-pair.tsx +4 -3
  38. package/skills/struq/blocks/showpieces/hero-opener.tsx +76 -17
  39. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +3 -1
  40. package/skills/struq/blocks/transitions/zoom-through.tsx +9 -10
  41. package/skills/struq/canon/canon.json +1 -1
  42. package/skills/struq/references/craft.md +4 -0
  43. package/skills/struq/references/film.md +158 -0
  44. package/skills/struq/references/triage.md +3 -0
  45. package/skills/struq/scripts/blocks.mjs +89 -7
  46. package/skills/struq/scripts/canon.mjs +8 -1
  47. package/skills/struq/scripts/check.mjs +94 -11
  48. package/skills/struq/scripts/film-check.mjs +997 -0
  49. package/skills/struq/scripts/gate.mjs +146 -0
  50. 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 with
4
- // its live local time and a calm marker of whether the studio is open (computed from that time and the opening hours,
5
- // never invented), a phone line, the address, three or four plain links and a small foot line. Three compositions:
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: a large live clock for the studio's city is the centre, hours and minutes at display size, the colon
12
- // breathing once a second and a changed digit rolling over on the minute (every digit in a slot as wide as the
13
- // face's widest, so nothing shifts). The mail address leads the ruled rows under it, the phone, the address and the
14
- // hours beneath it, like a timetable. Without scripts the slot holds the opening hours instead of a time.
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
- // timeZone="Europe/Amsterdam" hours={{ days: [1, 2, 3, 4, 5], from: "9:00", to: "18:00" }} links={[...]} foot="..." />
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
- // The time is never rendered on the server: the first paint holds a stable placeholder (an unseen 00:00, an empty
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
- timeZone: string;
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
- timeZone,
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-6 pb-6 pt-16 text-ink md:px-[3vw] md:pt-[10svh] ${className}`}
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
- {studio}, {city}
669
- </p>
670
- <p data-fc-soft="" className="text-ink-muted">
671
- <OpenMark status={status} />
672
- </p>
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
- <p className="relative w-full text-center">
677
- <span className="sr-only">{wall ? `Het is nu ${time} in ${city}.` : `De tijd in ${city}.`}</span>
678
- <span aria-hidden data-fc-clock="" data-pending={known ? undefined : ""} className="font-display">
679
- <span data-fc-hmask="">
680
- <span data-fc-h="" className="block">
681
- {known ? digits(wall.hh) : "00"}
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
- </span>
684
- <span data-fc-colon-wrap="">
685
- <span ref={colon} data-fc-colon="">
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
- <span data-fc-mmask="">
690
- <span data-fc-m="" className="block">
691
- {known ? digits(wall.mm) : "00"}
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
- </span>
695
- <noscript>
696
- <span className="block font-display text-h2">{hoursText(hours)}</span>
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-6 pb-6 pt-[14svh] text-ink md:px-[4vw] md:pt-[18svh] ${className}`}
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-6 pb-6 pt-[14svh] text-ink md:px-[3vw] ${className}`}
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-6 pb-6 pt-[max(4rem,11svh)] md:px-[3vw] md:pt-[max(4.5rem,13svh)]">
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 and live local time on the right;
16
- // one line glides under the hovered link, and a thin rule under the bar fills with the page's progress.
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-6 md:px-[3vw] ${split ? "grid grid-cols-[1fr_auto_1fr]" : "flex justify-between"}`}
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
- <time data-hb-time="" data-pending={time ? undefined : ""} className="tabular-nums">
206
- {time ?? "00:00"}
207
- </time>
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 whose local time the bar keeps. */
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-6 md:px-[3vw]">
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 whose local time the foot line keeps. */
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-[3vw] top-1/2 aspect-[4/5] h-[min(58svh,40vw)] -translate-y-1/2"
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-[3vw] top-[var(--menu-bar-h)] left-[calc(75cqw+1.5rem)]"
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
- className={`absolute bottom-[4.5rem] left-6 top-[var(--menu-bar-h)] m-0 flex list-none flex-col justify-end p-0 pb-[4svh] md:left-[3vw] md:justify-center md:pb-0 ${variant === "slices" ? "md:right-[calc(25cqw+1.5rem)]" : "md:right-[45vw]"}`}
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 overflow-hidden">
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-6 pb-5 text-small text-[var(--on-menu-muted)] md:px-[3vw] md:pb-6"
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} <span className="tabular-nums">{time}</span>
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-[4vw] md:mt-0",
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-[6vw] md:right-[calc(10vw_+_var(--hf-fw))] md:mt-0",
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-[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",
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 = "block overflow-hidden pb-[0.18em] -mb-[0.18em]";
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-[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`
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
- ? "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]"
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={h} className={mask}>
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 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}
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-6 pb-14 pt-[12svh] md:h-svh md:overflow-hidden md:p-0">
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}