@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.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -0,0 +1,313 @@
1
+ "use client";
2
+ // footer-reveal (Struq footer recipe)
3
+ // The footer is already there, under the page. As the reader reaches the end, the last section lifts away like a
4
+ // sheet and uncovers it, scrubbed to the scroll; scrolling back lays the page down again. The footer itself is calm
5
+ // and complete: the name large at a light weight, one closing line, the mail address and the phone number as real
6
+ // links, the street address, a few plain links and a small foot line. Three uncoverings:
7
+ //
8
+ // - lift: the last section rises and its bottom edge carries a hairline and a short soft shadow while it leaves; the
9
+ // footer stands still beneath it. The classic sticky reveal.
10
+ // - fold: the last section tilts back from its bottom edge a few degrees as it lifts, like a page turned up (the hinge
11
+ // is a screen above the edge, so the tilt is the same whatever the section's height), and the footer brightens as
12
+ // it is uncovered.
13
+ // - iris: the footer is uncovered through an opening that grows from a thin horizontal slit at the bottom of the
14
+ // screen to the full footer, its clip written from plain numbers, and the name rises inside it.
15
+ //
16
+ // <FooterReveal variant="lift" name="Noord" line="Kom langs, de tekentafel staat klaar." email="hallo@noord.studio"
17
+ // phone="+31 10 412 77 30" address={["Maashaven Zuidzijde 14", "3081 AE Rotterdam"]}
18
+ // links={[{ label: "Werk", href: "/werk" }, { label: "Instagram", href: "https://instagram.com/noord" }]}>
19
+ // <section>the page's last section</section>
20
+ // </FooterReveal>
21
+ //
22
+ // How it holds: the last section (children) and the footer share one wrapper; the section sits above, the footer is
23
+ // `position: sticky` to the bottom of the screen beneath it. So the document is exactly as tall as the two of them
24
+ // (no margin trick, no fixed layer, no spacer) and the hand-over has nothing to switch: when the section has gone, the
25
+ // footer simply is where it always was. A footer taller than the screen sticks by its top instead (its sticky bottom
26
+ // is the screen height minus its own), so the lift uncovers its first screen and then the footer scrolls on.
27
+ // Every effect is painted from the raw scroll progress in onUpdate (Lenis already smooths the scroll), so the shadow,
28
+ // the tilt and the clip stay locked to the section's real edge and a fast flick never stacks states.
29
+ //
30
+ // Without scripts and under reduced motion nothing is sticky and nothing is hidden: the footer follows the page.
31
+ // Keyboard: a link in the footer that takes focus while the section still covers it scrolls the footer clear.
32
+ // The last section should be at least a screen tall; a shorter one still opens no gap, the footer just follows sooner.
33
+ import { useId, useRef, type ReactNode } from "react";
34
+ import { gsap, ScrollTrigger, useGSAP } from "../kit/gsap";
35
+ import { getLenis } from "../kit/SmoothScroll";
36
+
37
+ export const FOOTER_REVEAL_VARIANTS = [
38
+ { id: "lift", title: "Optillen" },
39
+ { id: "fold", title: "Omslaan" },
40
+ { id: "iris", title: "Opening" },
41
+ ] as const;
42
+
43
+ export type FooterRevealVariant = (typeof FOOTER_REVEAL_VARIANTS)[number]["id"];
44
+
45
+ type FooterLink = { label: string; href: string };
46
+
47
+ type Props = {
48
+ variant?: FooterRevealVariant;
49
+ /** The page's last section: the sheet that lifts away. */
50
+ children: ReactNode;
51
+ name: string;
52
+ /** One short closing line. */
53
+ line: string;
54
+ email: string;
55
+ /** As it is read, "+31 10 412 77 30"; the tel: link is made from its digits. */
56
+ phone?: string;
57
+ /** The street address, one entry per line. */
58
+ address: string[];
59
+ /** Three or four plain links. */
60
+ links: FooterLink[];
61
+ year?: number;
62
+ className?: string;
63
+ };
64
+
65
+ /** The fold's largest tilt, in degrees, and its perspective as a share of the screen height. */
66
+ const TILT = 3.5;
67
+ const DEPTH = 2.4;
68
+ /** The iris: the slit's height in px, and the share of the width shut at each side while it is a slit. */
69
+ const SLIT = 3;
70
+ const SIDE_WIDE = 0.3;
71
+ const SIDE_PHONE = 0.14;
72
+ /** The name's rise inside the opening, as a share of its own height. */
73
+ const NAME_RISE = 0.55;
74
+
75
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
76
+ /** Where p sits inside the window [from, to], as 0 to 1. */
77
+ const within = (p: number, from: number, to: number) => clamp01((p - from) / (to - from));
78
+ const out2 = gsap.parseEase("power2.out");
79
+ const out3 = gsap.parseEase("power3.out");
80
+ const inOut2 = gsap.parseEase("power2.inOut");
81
+
82
+ /** How deep the shade over the footer starts: lighter on a light ground, deeper on a dark one, or it does not show. */
83
+ function dimFor(el: HTMLElement) {
84
+ const m = getComputedStyle(el).backgroundColor.match(/\d+(\.\d+)?/g);
85
+ if (!m || m.length < 3) return 0.35;
86
+ const [r, g, b] = m.map(Number);
87
+ return r * 0.299 + g * 0.587 + b * 0.114 < 128 ? 0.6 : 0.32;
88
+ }
89
+
90
+ export function FooterReveal({
91
+ variant = "lift",
92
+ children,
93
+ name,
94
+ line,
95
+ email,
96
+ phone,
97
+ address,
98
+ links,
99
+ year = new Date().getFullYear(),
100
+ className = "",
101
+ }: Props) {
102
+ const root = useRef<HTMLDivElement>(null);
103
+ const titleId = useId();
104
+ const iris = variant === "iris";
105
+
106
+ useGSAP(
107
+ () => {
108
+ const el = root.current;
109
+ const sheet = el?.querySelector<HTMLElement>("[data-fr-sheet]");
110
+ const foot = el?.querySelector<HTMLElement>("[data-fr-foot]");
111
+ if (!el || !sheet || !foot) return;
112
+ const edge = el.querySelector<HTMLElement>("[data-fr-edge]");
113
+ const shade = el.querySelector<HTMLElement>("[data-fr-shade]");
114
+ const mark = el.querySelector<HTMLElement>("[data-fr-name]");
115
+
116
+ const mm = gsap.matchMedia();
117
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
118
+ // The measures every paint reads, taken again on every refresh: the screen, the footer, the part of the
119
+ // footer the lift uncovers (all of it, or its first screen when it is taller), and the sheet.
120
+ let vw = window.innerWidth;
121
+ let vh = window.innerHeight;
122
+ let footH = foot.offsetHeight;
123
+ let reveal = Math.min(footH, vh);
124
+ let sheetH = sheet.offsetHeight;
125
+ let nameH = mark?.offsetHeight ?? 0;
126
+ let dim = dimFor(foot);
127
+ let p = 0;
128
+
129
+ const measure = () => {
130
+ vw = window.innerWidth;
131
+ vh = window.innerHeight;
132
+ footH = foot.offsetHeight;
133
+ reveal = Math.min(footH, vh);
134
+ sheetH = sheet.offsetHeight;
135
+ nameH = mark?.offsetHeight ?? 0;
136
+ dim = dimFor(foot);
137
+ // A footer that fits sticks by its bottom; a taller one by its top, so its first screen is what lifts free.
138
+ el.style.setProperty("--fr-bottom", `${Math.min(0, vh - footH)}px`);
139
+ if (variant === "fold") sheet.style.transformOrigin = `50% ${Math.max(0, sheetH - vh)}px`;
140
+ };
141
+
142
+ const paint = () => {
143
+ if (edge) edge.style.opacity = (within(p, 0, 0.05) * (1 - within(p, 0.8, 1))).toFixed(3);
144
+
145
+ if (variant === "fold") {
146
+ // The free bottom edge lifts toward the reader (a positive tilt), never away into the screen. The tilt rises
147
+ // early and holds; where the section's edge still shows at rest (a footer shorter than
148
+ // the screen), it lays flat again before the end, so the hand-over never shows a wedge.
149
+ const flatten = clamp01((vh - reveal) / (0.2 * vh));
150
+ const a = TILT * out2(within(p, 0, 0.45)) * (1 - flatten * inOut2(within(p, 0.55, 1)));
151
+ sheet.style.transform = a > 0.005 ? `perspective(${Math.round(vh * DEPTH)}px) rotateX(${a.toFixed(3)}deg)` : "";
152
+ if (shade) shade.style.opacity = (dim * (1 - inOut2(p))).toFixed(3);
153
+ }
154
+
155
+ if (iris) {
156
+ if (p >= 1) {
157
+ foot.style.clipPath = "none";
158
+ } else {
159
+ // In the footer's own pixels: the opening's bottom is the bottom of the screen, its top climbs from a
160
+ // slit locked to the section's edge (no band of ground between them), and its sides open within the first fifth
161
+ // of the move, so no line of text is ever cut at the opening's side.
162
+ const height = SLIT + (reveal - SLIT) * p;
163
+ const top = Math.max(0, reveal - height);
164
+ const side = vw * (vw < 768 ? SIDE_PHONE : SIDE_WIDE) * (1 - out3(within(p, 0, 0.2)));
165
+ foot.style.clipPath = `inset(${top.toFixed(1)}px ${side.toFixed(1)}px 0px ${side.toFixed(1)}px)`;
166
+ }
167
+ if (mark) {
168
+ // The name rises once the opening has reached it, so the rise is seen.
169
+ const e = out2(within(p, 0.6, 1));
170
+ mark.style.transform = `translate3d(0, ${Math.round((1 - e) * nameH * NAME_RISE)}px, 0)`;
171
+ }
172
+ }
173
+ };
174
+
175
+ measure();
176
+ el.dataset.live = variant;
177
+ if (variant === "fold") sheet.style.willChange = "transform";
178
+
179
+ const st = ScrollTrigger.create({
180
+ trigger: el,
181
+ // The sheet's bottom edge reaching the bottom of the screen, measured on the untransformed wrapper.
182
+ start: () => `top+=${sheet.offsetHeight} bottom`,
183
+ end: () => `+=${Math.min(foot.offsetHeight, window.innerHeight)}`,
184
+ onRefresh: (self) => {
185
+ measure();
186
+ p = self.progress;
187
+ paint();
188
+ },
189
+ onUpdate: (self) => {
190
+ p = self.progress;
191
+ paint();
192
+ },
193
+ });
194
+ p = st.progress;
195
+ paint();
196
+
197
+ // The footer or the sheet changing height (a face landing, a line wrapping) moves the stretch: measure again.
198
+ let frame = 0;
199
+ const heights = [footH, sheetH];
200
+ const ro = new ResizeObserver(() => {
201
+ if (foot.offsetHeight === heights[0] && sheet.offsetHeight === heights[1]) return;
202
+ heights[0] = foot.offsetHeight;
203
+ heights[1] = sheet.offsetHeight;
204
+ cancelAnimationFrame(frame);
205
+ frame = requestAnimationFrame(() => ScrollTrigger.refresh());
206
+ });
207
+ ro.observe(foot);
208
+ ro.observe(sheet);
209
+
210
+ // A link that takes focus while the sheet still covers the footer: scroll until the footer is clear (and,
211
+ // in a tall footer, until the link itself is on screen).
212
+ const onFocus = (e: FocusEvent) => {
213
+ if (p >= 0.999) return;
214
+ const target = e.target as HTMLElement;
215
+ const below = target.getBoundingClientRect().bottom - foot.getBoundingClientRect().top;
216
+ const over = vh - reveal + below - (vh - 24);
217
+ const y = st.end + Math.max(0, over);
218
+ const lenis = getLenis();
219
+ if (lenis) lenis.scrollTo(y, { duration: 0.7 });
220
+ else window.scrollTo({ top: y });
221
+ };
222
+ foot.addEventListener("focusin", onFocus);
223
+
224
+ return () => {
225
+ foot.removeEventListener("focusin", onFocus);
226
+ ro.disconnect();
227
+ cancelAnimationFrame(frame);
228
+ st.kill();
229
+ delete el.dataset.live;
230
+ el.style.removeProperty("--fr-bottom");
231
+ sheet.style.transform = "";
232
+ sheet.style.transformOrigin = "";
233
+ sheet.style.willChange = "";
234
+ foot.style.clipPath = "";
235
+ if (edge) edge.style.opacity = "";
236
+ if (shade) shade.style.opacity = "";
237
+ if (mark) mark.style.transform = "";
238
+ };
239
+ });
240
+ return () => mm.revert();
241
+ },
242
+ { scope: root, dependencies: [variant] },
243
+ );
244
+
245
+ const tel = phone ? `tel:${phone.replace(/[^\d+]/g, "")}` : null;
246
+ const link = "underline decoration-transparent underline-offset-[0.22em] transition-[text-decoration-color] duration-200 hover:decoration-current focus-visible:decoration-current";
247
+
248
+ return (
249
+ <div ref={root} data-footer-reveal={variant} className={className}>
250
+ <div data-fr-sheet="">
251
+ {children}
252
+ {iris ? null : <div data-fr-edge="" aria-hidden />}
253
+ </div>
254
+ <footer
255
+ data-fr-foot=""
256
+ aria-labelledby={titleId}
257
+ className={iris ? "scene-inverse" : "bg-surface-1 text-ink"}
258
+ >
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)]">
261
+ <div className={iris ? "flex flex-col items-center text-center" : ""}>
262
+ <h2
263
+ id={titleId}
264
+ data-fr-name=""
265
+ className="m-0 text-[min(24vw,24svh)] leading-[0.86] tracking-[-0.04em] md:text-[min(13vw,25svh)]"
266
+ >
267
+ {name}
268
+ </h2>
269
+ <p className="m-0 mt-[3svh] max-w-[30ch] font-display text-h3 text-ink-muted">{line}</p>
270
+ </div>
271
+
272
+ <div aria-hidden className="min-h-[7svh] flex-1" />
273
+
274
+ <div className="grid gap-7 border-t border-line pt-6 text-body md:grid-cols-12 md:gap-6">
275
+ <address className="flex flex-col not-italic md:col-span-4">
276
+ <a href={`mailto:${email}`} className={`text-lead text-ink ${link}`}>
277
+ {email}
278
+ </a>
279
+ {tel ? (
280
+ <a href={tel} className={`mt-1 text-ink-muted ${link}`}>
281
+ {phone}
282
+ </a>
283
+ ) : null}
284
+ </address>
285
+ <p className="m-0 text-ink-muted md:col-span-4">
286
+ {address.map((part, i) => (
287
+ <span key={part} className="block">
288
+ {part}
289
+ {i < address.length - 1 ? <span className="sr-only">, </span> : null}
290
+ </span>
291
+ ))}
292
+ </p>
293
+ <nav aria-label={`${name}, verder`} className="md:col-span-3 md:col-start-10">
294
+ <ul className="m-0 flex list-none flex-wrap gap-x-6 gap-y-1 p-0 md:flex-col">
295
+ {links.map((l) => (
296
+ <li key={l.href}>
297
+ <a href={l.href} className={`text-ink ${link}`}>
298
+ {l.label}
299
+ </a>
300
+ </li>
301
+ ))}
302
+ </ul>
303
+ </nav>
304
+ </div>
305
+
306
+ <p className="m-0 mt-10 text-small text-ink-muted">
307
+ © {year} {name}
308
+ </p>
309
+ </div>
310
+ </footer>
311
+ </div>
312
+ );
313
+ }