@struq/skill 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +3 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
# Scroll-Driven Reveals
|
|
2
|
-
|
|
3
|
-
The single highest-payoff motion category. Three techniques, in ascending order of drama: staggered group reveals, masked reveals, and split-text line reveals. All assume the providers from [setup.md](setup.md). Shipping values (durations, eases, staggers) justified in [feel-layer.md](feel-layer.md).
|
|
4
|
-
|
|
5
|
-
**A reveal misbehaving?** Content stuck invisible, an offset that vanished, clipped descenders: [pitfalls.md](pitfalls.md) first, symptom by symptom.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. Staggered group reveal
|
|
10
|
-
|
|
11
|
-
**Term:** Orchestrated entrance / stagger choreography.
|
|
12
|
-
**Mechanic:** Sibling elements enter with a shared transform + opacity, offset in time by 0.06 to 0.1s each, triggered when the group scrolls into view. The offsets create a reading order. The eye is led, not flooded.
|
|
13
|
-
|
|
14
|
-
**When:** Default for any group: nav items, card rows, list items, footer columns. Basically never overkill; this is the floor, not the ceiling.
|
|
15
|
-
**Cost:** Negligible (transform/opacity only).
|
|
16
|
-
|
|
17
|
-
```tsx
|
|
18
|
-
"use client";
|
|
19
|
-
|
|
20
|
-
import { useRef } from "react";
|
|
21
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
22
|
-
|
|
23
|
-
export function FeatureRow({ children }: { children: React.ReactNode }) {
|
|
24
|
-
const container = useRef<HTMLDivElement>(null);
|
|
25
|
-
|
|
26
|
-
useGSAP(
|
|
27
|
-
() => {
|
|
28
|
-
const mm = gsap.matchMedia();
|
|
29
|
-
// Reduced motion: nothing registers, the server markup is already the final state.
|
|
30
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
31
|
-
gsap.from("[data-reveal]", {
|
|
32
|
-
y: 48,
|
|
33
|
-
autoAlpha: 0,
|
|
34
|
-
duration: 0.9,
|
|
35
|
-
ease: "expo.out",
|
|
36
|
-
stagger: { each: 0.08, from: "start" }, // try "center" or "edges" for non-linear rows
|
|
37
|
-
scrollTrigger: {
|
|
38
|
-
trigger: container.current,
|
|
39
|
-
start: "top 78%", // fire while approaching, not at the edge; feels anticipatory
|
|
40
|
-
once: true, // entrances happen once; re-triggering reads as a glitch
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
},
|
|
45
|
-
{ scope: container }
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<div ref={container} className="grid grid-cols-12 gap-6">
|
|
50
|
-
{children /* each child carries data-reveal */}
|
|
51
|
-
</div>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
**Hard rule: one owner per property per element.** A `[data-reveal]` element never carries a CSS `transition-*` on a property this tween writes (`transition-opacity`, `transition-transform`, `transition-all`), and never a Tailwind `translate-*`, `scale-*` or `rotate-*` utility. The transition fights the inline values GSAP writes and can leave the element at `opacity: 0; visibility: hidden` with the tween at progress 1; GSAP sets the individual `translate`, `scale` and `rotate` properties to `none`, which erases the utility. Reveal the wrapper, put hover transitions and offsets on a child. Symptoms and checks: [pitfalls.md](pitfalls.md) §3.
|
|
57
|
-
|
|
58
|
-
Notes that separate this from the reflex version:
|
|
59
|
-
- `start: "top 78%"`: content is already moving as it enters the eye line. `"top bottom"` fires too early (animates off-screen), `"top top"` too late.
|
|
60
|
-
- `once: true` for entrances. Scrub-tied effects may repeat; entrances may not.
|
|
61
|
-
- Vary the stagger origin: a centered logo wall feels better with `from: "center"`.
|
|
62
|
-
- Motion swap: for a one-off, `<motion.div initial={{ opacity: 0, y: 48 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-22%" }} transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}>` is fine. Use GSAP once multiple groups need coordination.
|
|
63
|
-
|
|
64
|
-
**Cheap tell:** every element fades in with `opacity: 0 → 1`, no transform, all at once, `duration-300 ease-in-out` (the "AOS fade" look). **Instead:** Transform + stagger + expo-out, triggered at 78%.
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## 2. Masked / clip-path reveal
|
|
69
|
-
|
|
70
|
-
**Term:** Mask reveal (wipe).
|
|
71
|
-
**Mechanic:** The element doesn't fade *into* view. It's *uncovered*, as if sliding out from behind an invisible edge. Two implementations: **overflow mask** (parent `overflow-hidden`, child translates up from 110%) and **clip-path wipe** (`inset()` animates open). The motion has a surface to interact with, which is what reads as physical.
|
|
72
|
-
|
|
73
|
-
**When:** Images, hero media, big standalone statements. The signature "agency" reveal.
|
|
74
|
-
**Overkill when:** applied to every paragraph. Masks are for moments; body content uses technique 1.
|
|
75
|
-
**Cost:** `clip-path` and transforms are paint/composite-only. Cheap.
|
|
76
|
-
|
|
77
|
-
```tsx
|
|
78
|
-
"use client";
|
|
79
|
-
|
|
80
|
-
import { useRef } from "react";
|
|
81
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
82
|
-
|
|
83
|
-
// A) Overflow mask: image slides up from behind its own bottom edge,
|
|
84
|
-
// while scaling down from 1.25 → 1 ("settle") for a parallax-like richness.
|
|
85
|
-
export function MaskedImage({ src, alt }: { src: string; alt: string }) {
|
|
86
|
-
const container = useRef<HTMLDivElement>(null);
|
|
87
|
-
|
|
88
|
-
useGSAP(
|
|
89
|
-
() => {
|
|
90
|
-
const tl = gsap.timeline({
|
|
91
|
-
scrollTrigger: { trigger: container.current, start: "top 75%", once: true },
|
|
92
|
-
defaults: { duration: 1.2, ease: "expo.out" },
|
|
93
|
-
});
|
|
94
|
-
tl.from(container.current, { yPercent: 18, autoAlpha: 0 }, 0)
|
|
95
|
-
.from("img", { yPercent: 24, scale: 1.25 }, 0); // counter-move: frame up, image down
|
|
96
|
-
},
|
|
97
|
-
{ scope: container }
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<div ref={container} className="overflow-hidden rounded-sm">
|
|
102
|
-
<img src={src} alt={alt} className="block h-full w-full object-cover" />
|
|
103
|
-
</div>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// B) Clip-path wipe, for blocks where overflow-hidden would clip siblings.
|
|
108
|
-
export function WipeReveal({ children }: { children: React.ReactNode }) {
|
|
109
|
-
const el = useRef<HTMLDivElement>(null);
|
|
110
|
-
|
|
111
|
-
useGSAP(
|
|
112
|
-
() => {
|
|
113
|
-
gsap.fromTo(
|
|
114
|
-
el.current,
|
|
115
|
-
{ clipPath: "inset(100% 0% 0% 0%)" }, // covered from top
|
|
116
|
-
{
|
|
117
|
-
clipPath: "inset(0% 0% 0% 0%)",
|
|
118
|
-
duration: 1.1,
|
|
119
|
-
ease: "power4.inOut", // wipes read best with in-out
|
|
120
|
-
scrollTrigger: { trigger: el.current, start: "top 80%", once: true },
|
|
121
|
-
}
|
|
122
|
-
);
|
|
123
|
-
},
|
|
124
|
-
{ scope: el }
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
return <div ref={el}>{children}</div>;
|
|
128
|
-
}
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
The **counter-move** in (A) is the load-bearing detail: container and image translate in opposite directions, so the image appears to be revealed *through* a window rather than sliding as a sticker. Same trick powers parallax-in-mask ([depth-parallax.md](depth-parallax.md)).
|
|
132
|
-
|
|
133
|
-
**Cheap tell:** image scales from 0.8 with a fade ("zoom-in card"), or slides as one rigid block. **Instead:** Frame and content counter-translate inside an overflow mask.
|
|
134
|
-
|
|
135
|
-
---
|
|
136
|
-
|
|
137
|
-
## 2C. Curtain section reveal (named preset)
|
|
138
|
-
|
|
139
|
-
**Term:** Curtain section reveal (a.k.a. "the curtain reveal").
|
|
140
|
-
**Mechanic:** The clip-path wipe (2B) *paired with a counter-scale on the inner image*, fired per media frame as a row/section enters view. The frame is **uncovered bottom-up** (`inset(100% 0 0 0)` → `inset(0)`) while the image behind **settles back from a slight zoom** (`scale 1.2 → 1`), both from the same trigger. The picture is drawn up like a rising curtain while the image "breathes" into place behind it. The two tweens are the whole effect: clip alone is flat; clip + counter-scale is what reads as premium and physical.
|
|
141
|
-
|
|
142
|
-
**When:** Editorial media rows, alternating image/text sections, case-study frames, discipline mosaics. The default "professional section entrance" for imagery on a narrative page. Ready-made named showpiece recipes built on presets like this one are catalogued in [showcase.md](showcase.md).
|
|
143
|
-
**Overkill when:** tiny thumbnails, logo walls, or anything below ~240px tall. The wipe needs vertical distance to read.
|
|
144
|
-
**Cost:** `clip-path` + `transform` only (paint/composite). Cheap. Composes cleanly with a parallax `yPercent` drift on the *same* inner element (translate + scale coexist).
|
|
145
|
-
|
|
146
|
-
```tsx
|
|
147
|
-
// Clip on the OUTER frame, counter-scale on the INNER image, one timeline, once per frame.
|
|
148
|
-
gsap.utils.toArray<HTMLElement>("[data-curtain]").forEach((frame) => {
|
|
149
|
-
const inner = frame.querySelector("[data-curtain-inner]");
|
|
150
|
-
const tl = gsap.timeline({
|
|
151
|
-
scrollTrigger: { trigger: frame, start: "top 88%", once: true },
|
|
152
|
-
defaults: { ease: "power4.out" },
|
|
153
|
-
});
|
|
154
|
-
tl.fromTo(
|
|
155
|
-
frame,
|
|
156
|
-
{ clipPath: "inset(100% 0% 0% 0%)" }, // covered from the bottom up
|
|
157
|
-
{ clipPath: "inset(0% 0% 0% 0%)", duration: 1.1 },
|
|
158
|
-
);
|
|
159
|
-
if (inner) tl.fromTo(inner, { scale: 1.2 }, { scale: 1, duration: 1.4 }, "<"); // settle, same start
|
|
160
|
-
});
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Details that matter:
|
|
164
|
-
- **Clip on the frame, scale on the inner child**: never both on one node, or the scale visibly drags the clip edge. Structure: `<div data-curtain><div data-curtain-inner><img/></div></div>`.
|
|
165
|
-
- `"<"` starts the scale at the **same instant** as the clip; the slightly longer scale duration (1.4 vs 1.1) means the image keeps easing after the curtain is fully open. That trailing settle is the premium tell.
|
|
166
|
-
- `start: "top 88%"` fires just before the frame is comfortably in view; `once: true` (entrances never re-fire).
|
|
167
|
-
- **Do not also wrap the frame in a fade-in** (e.g. a `FadeIn`/`whileInView` opacity). The curtain *is* the entrance; a fade on top muddies it. Give the media its curtain and nothing else.
|
|
168
|
-
- **Reduced-motion:** register the tween only under `(prefers-reduced-motion: no-preference)` and set no initial clip in CSS, so motion-off renders the image fully visible with zero JS. No FOUC.
|
|
169
|
-
- Pairs with: a parallax drift on `[data-curtain-inner]` ([depth-parallax.md](depth-parallax.md)) and an entrance-drawn connector line between frames (DrawSVG, recipes in [svg-motion.md](svg-motion.md); API surface in [gsap-api.md](gsap-api.md)).
|
|
170
|
-
|
|
171
|
-
**Cheap tell:** the frame just fades/zooms in as one block, or the clip runs with no scale. **Instead:** Bottom-up clip on the frame + counter-scale settle on the image, same trigger, image easing a beat longer.
|
|
172
|
-
|
|
173
|
-
---
|
|
174
|
-
|
|
175
|
-
## 3. Line-by-line text split (SplitText)
|
|
176
|
-
|
|
177
|
-
**Term:** Masked line reveal / line stagger.
|
|
178
|
-
**Mechanic:** SplitText slices a heading into line elements, each wrapped in an `overflow: clip` mask; lines translate up from 110% below their own mask, staggered. The text appears to rise out of the page in slats, the canonical award-site headline entrance.
|
|
179
|
-
|
|
180
|
-
**When:** Hero h1, section headlines, pull quotes. 3 to 6 uses per page.
|
|
181
|
-
**Overkill when:** body paragraphs (use a plain fade-up), or per-**char** splits on long text (chars are for short display words only; char-staggering a sentence looks like a typewriter toy).
|
|
182
|
-
**Cost:** Splitting mutates DOM and measures layout. Fine for headlines, do not split hundreds of nodes. `aria` handling is automatic (SplitText sets `aria-label` on the parent).
|
|
183
|
-
|
|
184
|
-
```tsx
|
|
185
|
-
"use client";
|
|
186
|
-
|
|
187
|
-
import { useRef } from "react";
|
|
188
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
189
|
-
|
|
190
|
-
export function SplitHeadline({
|
|
191
|
-
children,
|
|
192
|
-
as: Tag = "h2",
|
|
193
|
-
className = "",
|
|
194
|
-
}: {
|
|
195
|
-
children: string;
|
|
196
|
-
as?: "h1" | "h2" | "h3" | "p";
|
|
197
|
-
className?: string;
|
|
198
|
-
}) {
|
|
199
|
-
const container = useRef<HTMLDivElement>(null);
|
|
200
|
-
|
|
201
|
-
useGSAP(
|
|
202
|
-
() => {
|
|
203
|
-
const mm = gsap.matchMedia();
|
|
204
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
205
|
-
SplitText.create(container.current!.querySelector("[data-split]"), {
|
|
206
|
-
type: "lines",
|
|
207
|
-
mask: "lines", // wraps each line in an overflow-clipped .split-line-mask
|
|
208
|
-
autoSplit: true, // re-splits on font load / resize; animation must live in onSplit
|
|
209
|
-
linesClass: "split-line",
|
|
210
|
-
onSplit: (self) =>
|
|
211
|
-
gsap.from(self.lines, {
|
|
212
|
-
yPercent: 110,
|
|
213
|
-
duration: 1.1,
|
|
214
|
-
ease: "expo.out",
|
|
215
|
-
stagger: 0.09,
|
|
216
|
-
scrollTrigger: { trigger: container.current, start: "top 80%", once: true },
|
|
217
|
-
}),
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
},
|
|
221
|
-
{ scope: container }
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<div ref={container}>
|
|
226
|
-
<Tag data-split className={className}>
|
|
227
|
-
{children}
|
|
228
|
-
</Tag>
|
|
229
|
-
</div>
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Usage:
|
|
234
|
-
// <SplitHeadline className="font-display text-h2 max-w-[20ch]">
|
|
235
|
-
// Design that survives contact with reality
|
|
236
|
-
// </SplitHeadline>
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
```css
|
|
240
|
-
/* globals.css: descender room inside each line mask (class = linesClass + "-mask"), no layout jump */
|
|
241
|
-
.split-line-mask { padding-block: 0.1em; margin-block: -0.1em; }
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Details that matter:
|
|
245
|
-
- `mask: "lines"` + `yPercent: 110` is the whole effect. Without the mask it's just a stagger fade; with it, each line is born at its own baseline.
|
|
246
|
-
- `autoSplit: true` + returning the tween from `onSplit` makes the split responsive and font-safe: GSAP reverts and re-runs on reflow. This API needs GSAP 3.13 or later; do not use the old `new SplitText()` + manual resize listeners.
|
|
247
|
-
- Set the headline with its role token (`text-display`, `text-h2`) *before* splitting: the token carries the pairing's tight leading, and masks clip to line boxes, so loose leading shows gaps.
|
|
248
|
-
- Descenders: the `.split-line-mask` rule above pads each mask and pulls the margin back, so g/y/p are never clipped and the layout does not move. It is not the only place a descender dies: parent overflow, reveal wrappers sized to the line height, clip-path insets and italic overhang all clip too. The full list and the 10-second "Gyp, jq." test: [pitfalls.md](pitfalls.md) §1.
|
|
249
|
-
- Section headlines use this scroll version; the hero has its own load-time variant (`line-rise-hero`, [typography.md](typography.md) §5), and the masked rise is one technique among the presets' named entrances. It is a tell only as the hero's sole gesture, with no signature move and no preset entrance behind it.
|
|
250
|
-
- Reduced motion: the split and the tween exist only in the `no-preference` branch, so the server markup stays the final state.
|
|
251
|
-
- Scrub variant for manifesto paragraphs: split into `words`, set initial `opacity: 0.15`, and `gsap.to(words, { opacity: 1, stagger: 0.05, scrollTrigger: { scrub: true, start: "top 70%", end: "top 20%" } })`, the "reading highlights itself" effect.
|
|
252
|
-
- Motion swap: only if you refuse GSAP. Manually wrap lines and animate `y` with `whileInView`. SplitText's measurement/re-split handling is why GSAP wins here.
|
|
253
|
-
- SplitText typography choreography in depth: [typography.md](typography.md).
|
|
254
|
-
|
|
255
|
-
**Cheap tell:** whole headline fades in as one block; or chars typewriter in one-by-one with uniform linear timing. **Instead:** Lines rise from individual masks, 0.09s apart, expo-out.
|
|
256
|
-
|
|
257
|
-
---
|
|
258
|
-
|
|
259
|
-
## Orchestrating a full section
|
|
260
|
-
|
|
261
|
-
Reveals compose into one timeline per section so the elements *relate* (label → headline → body → media), instead of each element wearing its own trigger:
|
|
262
|
-
|
|
263
|
-
```tsx
|
|
264
|
-
useGSAP(
|
|
265
|
-
() => {
|
|
266
|
-
const tl = gsap.timeline({
|
|
267
|
-
scrollTrigger: { trigger: container.current, start: "top 70%", once: true },
|
|
268
|
-
defaults: { ease: "expo.out", duration: 1 },
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
tl.add(splitHeadlineIn())
|
|
272
|
-
.from("[data-body]", { y: 32, autoAlpha: 0 }, "-=0.6")
|
|
273
|
-
.from("[data-media]", { yPercent: 12, autoAlpha: 0, duration: 1.2 }, "-=0.8");
|
|
274
|
-
},
|
|
275
|
-
{ scope: container }
|
|
276
|
-
);
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
The `-=0.x` overlaps are the craft: sequential-but-overlapping reads as one composed gesture; strictly sequential reads as a PowerPoint build.
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
# Sections: patterns for the arc
|
|
2
|
-
|
|
3
|
-
Copy a pattern, then make it the brief's own: the copy, the photos, the proportions. Each one uses the recipes (`components/struq/`) and the role tokens, never a raw size, never a label above a heading. Vary them: two neighbours never share a layout.
|
|
4
|
-
|
|
5
|
-
Imports for every pattern on this page:
|
|
6
|
-
|
|
7
|
-
```tsx
|
|
8
|
-
import { HorizontalGallery } from "@/components/struq/HorizontalGallery";
|
|
9
|
-
import { MagneticButton } from "@/components/struq/MagneticButton";
|
|
10
|
-
import { MediaFrame } from "@/components/struq/MediaFrame";
|
|
11
|
-
import { PinnedZoom } from "@/components/struq/PinnedZoom";
|
|
12
|
-
import { Reveal } from "@/components/struq/Reveal";
|
|
13
|
-
import { SplitHeadline } from "@/components/struq/SplitHeadline";
|
|
14
|
-
import { imageCredit, images } from "@/lib/struq-images";
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## Header
|
|
18
|
-
|
|
19
|
-
Every page has one. It sits over the hero, not a bar: the name in the display face as the wordmark (marked `data-wordmark`, which `check.mjs` looks for), two or three links, the CTA. No pill, no blur. Internal links use `next/link` (the lint rule fails a plain `<a>` to a page).
|
|
20
|
-
|
|
21
|
-
```tsx
|
|
22
|
-
import Link from "next/link";
|
|
23
|
-
|
|
24
|
-
<header className="absolute inset-x-0 top-0 z-20 flex items-center justify-between px-6 py-6 md:px-[8vw]">
|
|
25
|
-
<Link href="/" data-wordmark="" className="font-display text-h3">Zout</Link>
|
|
26
|
-
<nav className="flex items-center gap-8 text-small">
|
|
27
|
-
<a href="#kaart" className="hidden md:inline">Kaart</a>
|
|
28
|
-
<a href="#zaal" className="hidden md:inline">De zaal</a>
|
|
29
|
-
<MagneticButton href="#reserveren" variant="line">Reserveer</MagneticButton>
|
|
30
|
-
</nav>
|
|
31
|
-
</header>
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
On a ripple hero, give the header `text-on-photo`. A multi-page site with a menu: [nav-header.md](nav-header.md).
|
|
35
|
-
|
|
36
|
-
## Orient: a statement beside an image that bleeds off the edge (grid break)
|
|
37
|
-
|
|
38
|
-
```tsx
|
|
39
|
-
<section className="grid gap-12 px-6 py-32 md:grid-cols-12 md:gap-8 md:px-[8vw] md:py-48">
|
|
40
|
-
<Reveal className="flex flex-col gap-6 md:col-span-5 md:pt-[14vh]">
|
|
41
|
-
<h2 className="font-display text-h2">Eén kaart, die meebeweegt met de markt.</h2>
|
|
42
|
-
<p className="max-w-[44ch] text-body text-ink-muted">Wat de vissers en telers die week brengen, staat zaterdag op tafel. De kaart is kort, en elke week een beetje anders.</p>
|
|
43
|
-
</Reveal>
|
|
44
|
-
<MediaFrame image={images.column[0]} ratio="4/5" sizes="(min-width: 768px) 50vw, 100vw" className="md:col-span-6 md:col-start-7 md:-mr-[8vw]" />
|
|
45
|
-
</section>
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
## Prove: hairline rows instead of cards
|
|
49
|
-
|
|
50
|
-
For a menu, services, a process, projects. Title left, text offset right, the figure (a price, a year) in tabular figures at the end. Only facts the brief gave: no invented prices.
|
|
51
|
-
|
|
52
|
-
```tsx
|
|
53
|
-
<section className="px-6 py-28 md:px-[8vw] md:py-40">
|
|
54
|
-
<SplitHeadline as="h2" on="scroll" className="font-display text-h2 max-w-[16ch]">Deze week op de kaart</SplitHeadline>
|
|
55
|
-
<Reveal as="ul" className="mt-16 divide-y divide-line border-y border-line">
|
|
56
|
-
{rows.map((row) => (
|
|
57
|
-
<li key={row.title} className="grid gap-3 py-8 md:grid-cols-12 md:gap-8">
|
|
58
|
-
<span className="font-display text-h3 md:col-span-5">{row.title}</span>
|
|
59
|
-
<span className="max-w-[48ch] text-body text-ink-muted md:col-span-5 md:col-start-7">{row.text}</span>
|
|
60
|
-
</li>
|
|
61
|
-
))}
|
|
62
|
-
</Reveal>
|
|
63
|
-
</section>
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
## Band: a full-bleed photo with one line overlapping its edge
|
|
67
|
-
|
|
68
|
-
```tsx
|
|
69
|
-
<section className="relative">
|
|
70
|
-
<MediaFrame image={images.band[0]} ratio="21/9" sizes="100vw" className="w-full" />
|
|
71
|
-
<Reveal className="relative z-10 -mt-[10vh] px-6 md:px-[8vw]">
|
|
72
|
-
<p className="font-display text-h2 max-w-[18ch] bg-surface-0 pt-8 pr-10 md:max-w-[22ch]">Brood uit de eigen oven, elke ochtend vroeg.</p>
|
|
73
|
-
</Reveal>
|
|
74
|
-
</section>
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
## Deepen: a tall photo and two details, offset
|
|
78
|
-
|
|
79
|
-
```tsx
|
|
80
|
-
<section className="grid gap-8 px-6 py-32 md:grid-cols-12 md:px-[8vw] md:py-48">
|
|
81
|
-
<MediaFrame image={images.column[1]} ratio="4/5" sizes="(min-width: 768px) 40vw, 100vw" className="md:col-span-5" />
|
|
82
|
-
<div className="flex flex-col gap-8 md:col-span-6 md:col-start-7 md:pt-[20vh]">
|
|
83
|
-
<Reveal className="flex flex-col gap-5">
|
|
84
|
-
<h2 className="font-display text-h2">Van de oven naar tafel.</h2>
|
|
85
|
-
<p className="max-w-[44ch] text-body text-ink-muted">Wat er uit het vuur komt, gaat direct de zaal in.</p>
|
|
86
|
-
</Reveal>
|
|
87
|
-
<div className="grid grid-cols-2 gap-6">
|
|
88
|
-
<MediaFrame image={images.detail[0]} ratio="1/1" sizes="25vw" />
|
|
89
|
-
<MediaFrame image={images.detail[1]} ratio="1/1" sizes="25vw" className="mt-[12vh]" />
|
|
90
|
-
</div>
|
|
91
|
-
</div>
|
|
92
|
-
</section>
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## Invite: the too-empty section
|
|
96
|
-
|
|
97
|
-
One line, one button, a lot of air. This is the section that should feel empty by SaaS standards.
|
|
98
|
-
|
|
99
|
-
```tsx
|
|
100
|
-
<section id="reserveren" className="flex flex-col items-start gap-12 px-6 py-[24vh] md:px-[8vw]">
|
|
101
|
-
<SplitHeadline as="h2" on="scroll" className="font-display text-display max-w-[12ch]">Een tafel voor vanavond?</SplitHeadline>
|
|
102
|
-
<MagneticButton href="mailto:...">Reserveer een tafel</MagneticButton>
|
|
103
|
-
</section>
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
Use only the contact route the brief gave. No booking system named in the brief: a mail link or "bel ons", never a fake form that sends nothing.
|
|
107
|
-
|
|
108
|
-
## Footer: designed as hard as the hero
|
|
109
|
-
|
|
110
|
-
```tsx
|
|
111
|
-
<footer className="overflow-hidden bg-surface-1 px-6 pt-24 pb-10 md:px-[8vw]">
|
|
112
|
-
<div className="grid gap-10 md:grid-cols-12">
|
|
113
|
-
<p className="text-lead md:col-span-5">Veertig plekken in Amsterdam-Noord.</p>
|
|
114
|
-
<nav className="flex flex-col gap-2 text-small md:col-span-3 md:col-start-8">
|
|
115
|
-
<a href="#kaart">Kaart</a>
|
|
116
|
-
<a href="#reserveren">Reserveren</a>
|
|
117
|
-
</nav>
|
|
118
|
-
</div>
|
|
119
|
-
<p aria-hidden className="font-display mt-24 text-[22vw] leading-[0.8] tracking-[-0.04em]">Zout</p>
|
|
120
|
-
<div className="mt-10 flex flex-wrap justify-between gap-4 border-t border-line pt-6 text-small text-ink-muted">
|
|
121
|
-
<span>Zout, Amsterdam-Noord</span>
|
|
122
|
-
<a href={imageCredit.href}>{imageCredit.label}</a>
|
|
123
|
-
</div>
|
|
124
|
-
</footer>
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
The giant wordmark is the one place a vw size is right: it is a shape, not a reading size.
|
|
128
|
-
|
|
129
|
-
## Showpieces
|
|
130
|
-
|
|
131
|
-
```tsx
|
|
132
|
-
<HorizontalGallery
|
|
133
|
-
title="Uit de keuken"
|
|
134
|
-
intro="Wat de oven en de markt deze week brengen."
|
|
135
|
-
items={images.card.slice(0, 6).map((image) => ({ image, title: image.alt }))}
|
|
136
|
-
/>
|
|
137
|
-
|
|
138
|
-
<PinnedZoom image={images.band[1]} statement="Eén zaal, veertig plekken." closing="Reserveer voor vanavond." />
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Write `title` and `text` from the brief; the pack alt is a description of the photo, fine as a caption only when it says nothing false about the client.
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
# SEO, social images and site structure
|
|
2
|
-
|
|
3
|
-
A cinematic site that nobody finds, or that shows a blank grey card when it is shared, has failed at the first impression. These are the basics every Struq build ships, wired the Next.js App Router way (Vite notes at the end). They are cheap, and most of them have to be decided in the plan, not patched after launch.
|
|
4
|
-
|
|
5
|
-
## 1. Site structure (decided in the PLAN phase)
|
|
6
|
-
|
|
7
|
-
- **Flat and named.** Every page is at most two levels deep: `/work`, `/work/<slug>`, `/studio`, `/contact`. Slugs are lowercase words in the site's language, never ids.
|
|
8
|
-
- **One page, one job, one h1.** By default the hero display line **is** the h1: write it so it says what the page is about in words someone would search. Only when the display line is deliberately not descriptive (a poetic claim, a single word) does the h1 become a smaller line above it, with the display line as a `p` in display styling.
|
|
9
|
-
- **Headings in order.** h1, then h2 per section, h3 inside. A caption or meta line is a `p`, not a heading. Visual size and heading level are separate decisions.
|
|
10
|
-
- **Every important page linked from the nav or the footer** with a real `<a href>` (the `TransitionLink` from [route-transitions.md](route-transitions.md) renders one). A route reachable only through a JavaScript click handler does not exist to a crawler.
|
|
11
|
-
- **A real 404** (`app/not-found.tsx`) designed as hard as the hero, with a way back.
|
|
12
|
-
|
|
13
|
-
Record the page list and slugs under PLAN in STRUQ.md ([direction.md](direction.md)).
|
|
14
|
-
|
|
15
|
-
## 2. Metadata
|
|
16
|
-
|
|
17
|
-
Set the base once in the root layout, then per page:
|
|
18
|
-
|
|
19
|
-
```tsx
|
|
20
|
-
// app/layout.tsx
|
|
21
|
-
import type { Metadata } from "next";
|
|
22
|
-
|
|
23
|
-
export const metadata: Metadata = {
|
|
24
|
-
metadataBase: new URL("https://example.com"),
|
|
25
|
-
title: { default: "Name: what it is in five words", template: "%s | Name" },
|
|
26
|
-
description: "One or two plain sentences, 140 to 160 characters, written for a person, not a keyword list.",
|
|
27
|
-
// No alternates.canonical here: metadata merges down, so a root canonical of "/"
|
|
28
|
-
// would mark every page as a duplicate of the home page. Set it per page.
|
|
29
|
-
openGraph: { type: "website", siteName: "Name", locale: "en_US" },
|
|
30
|
-
twitter: { card: "summary_large_image" },
|
|
31
|
-
};
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
```tsx
|
|
35
|
-
// app/page.tsx: the home page sets its own canonical too
|
|
36
|
-
import type { Metadata } from "next";
|
|
37
|
-
|
|
38
|
-
export const metadata: Metadata = {
|
|
39
|
-
alternates: { canonical: "/" },
|
|
40
|
-
};
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
```tsx
|
|
44
|
-
// app/work/[slug]/page.tsx
|
|
45
|
-
import type { Metadata } from "next";
|
|
46
|
-
|
|
47
|
-
export async function generateMetadata(
|
|
48
|
-
{ params }: { params: Promise<{ slug: string }> }
|
|
49
|
-
): Promise<Metadata> {
|
|
50
|
-
const { slug } = await params;
|
|
51
|
-
const project = await getProject(slug);
|
|
52
|
-
return {
|
|
53
|
-
title: project.title,
|
|
54
|
-
description: project.summary,
|
|
55
|
-
alternates: { canonical: `/work/${slug}` },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
- `metadataBase` makes every relative OG and canonical URL absolute. Without it, shared links break.
|
|
61
|
-
- Titles: the page's own name first, the brand after (`%s | Name`). Under about 60 characters.
|
|
62
|
-
- One canonical per page, set in that page's own `metadata` or `generateMetadata`, never in the root layout. Multi-language sites add `alternates.languages`.
|
|
63
|
-
- Add the theme color to `viewport` so mobile browser chrome matches the surface: `export const viewport = { themeColor: "#0A0A0B" }`.
|
|
64
|
-
|
|
65
|
-
## 3. Open Graph images: one per route, designed as posters
|
|
66
|
-
|
|
67
|
-
The OG image is the site's first impression in every chat app and social feed. Design it like a poster from the same system as the site: the surface color, the display face at a light weight, the wordmark, one line, generous margins. Never a screenshot of the hero, never a default grey card.
|
|
68
|
-
|
|
69
|
-
Every route that gets shared gets its own image via the file convention: an `opengraph-image.tsx` next to the route's `page.tsx`.
|
|
70
|
-
|
|
71
|
-
```tsx
|
|
72
|
-
// app/opengraph-image.tsx (and app/work/[slug]/opengraph-image.tsx for per-project cards)
|
|
73
|
-
import { ImageResponse } from "next/og";
|
|
74
|
-
import { readFile } from "node:fs/promises";
|
|
75
|
-
import { join } from "node:path";
|
|
76
|
-
|
|
77
|
-
export const alt = "Name: what it is";
|
|
78
|
-
export const size = { width: 1200, height: 630 };
|
|
79
|
-
export const contentType = "image/png";
|
|
80
|
-
|
|
81
|
-
export default async function Image() {
|
|
82
|
-
// next/og needs the font as data: a TTF, OTF or WOFF of the display face in the repo (Fontsource WOFF works, brand.md §3).
|
|
83
|
-
const display = await readFile(join(process.cwd(), "assets/fonts/Display-Light.ttf"));
|
|
84
|
-
|
|
85
|
-
return new ImageResponse(
|
|
86
|
-
(
|
|
87
|
-
<div
|
|
88
|
-
style={{
|
|
89
|
-
width: "100%",
|
|
90
|
-
height: "100%",
|
|
91
|
-
display: "flex",
|
|
92
|
-
flexDirection: "column",
|
|
93
|
-
justifyContent: "space-between",
|
|
94
|
-
padding: 72,
|
|
95
|
-
background: "#0A0A0B",
|
|
96
|
-
color: "#EDEAE4",
|
|
97
|
-
fontFamily: "Display",
|
|
98
|
-
}}
|
|
99
|
-
>
|
|
100
|
-
<div style={{ fontSize: 28, letterSpacing: "0.12em", textTransform: "uppercase", opacity: 0.6 }}>
|
|
101
|
-
Name
|
|
102
|
-
</div>
|
|
103
|
-
<div style={{ fontSize: 96, lineHeight: 1, letterSpacing: "-0.03em", fontWeight: 300, maxWidth: 900 }}>
|
|
104
|
-
The one line the page is about.
|
|
105
|
-
</div>
|
|
106
|
-
</div>
|
|
107
|
-
),
|
|
108
|
-
{ ...size, fonts: [{ name: "Display", data: display, weight: 300, style: "normal" }] }
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
- `ImageResponse` renders a flexbox subset of CSS: every element with more than one child needs `display: "flex"`. No grid, no `clamp()`.
|
|
114
|
-
- It needs TTF, OTF or WOFF font data, not WOFF2. Keep a TTF of the display face in the repo for this.
|
|
115
|
-
- Per-project images pull the project's title and, if it reads at card size, its cover image as an `<img>` with an absolute URL. In a dynamic segment the image receives the params as a promise, like the page does:
|
|
116
|
-
|
|
117
|
-
```tsx
|
|
118
|
-
// app/work/[slug]/opengraph-image.tsx
|
|
119
|
-
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
|
|
120
|
-
const { slug } = await params;
|
|
121
|
-
const project = await getProject(slug);
|
|
122
|
-
// ...same ImageResponse as above, with project.title as the line
|
|
123
|
-
}
|
|
124
|
-
```
|
|
125
|
-
- Twitter and X use the same image when `twitter.card` is `summary_large_image`; add `twitter-image.tsx` only when it should differ.
|
|
126
|
-
- Check every card at real size before shipping: text over 60 characters wraps badly at 96px.
|
|
127
|
-
|
|
128
|
-
## 4. Sitemap and robots
|
|
129
|
-
|
|
130
|
-
```ts
|
|
131
|
-
// app/sitemap.ts
|
|
132
|
-
import type { MetadataRoute } from "next";
|
|
133
|
-
|
|
134
|
-
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
135
|
-
const base = "https://example.com";
|
|
136
|
-
// A constant, not new Date(): with cacheComponents on, a request-time date breaks the prerender
|
|
137
|
-
// (setup.md §0, Known API shifts). Bump it when the content changes.
|
|
138
|
-
const LAST_UPDATED = "2026-01-15";
|
|
139
|
-
const projects = await getProjects();
|
|
140
|
-
return [
|
|
141
|
-
{ url: base, lastModified: LAST_UPDATED, changeFrequency: "monthly", priority: 1 },
|
|
142
|
-
{ url: `${base}/work`, lastModified: LAST_UPDATED, changeFrequency: "weekly", priority: 0.8 },
|
|
143
|
-
...projects.map((p) => ({
|
|
144
|
-
url: `${base}/work/${p.slug}`,
|
|
145
|
-
lastModified: p.updatedAt,
|
|
146
|
-
changeFrequency: "yearly" as const,
|
|
147
|
-
priority: 0.6,
|
|
148
|
-
})),
|
|
149
|
-
{ url: `${base}/contact`, lastModified: LAST_UPDATED, changeFrequency: "yearly", priority: 0.5 },
|
|
150
|
-
];
|
|
151
|
-
}
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
// app/robots.ts
|
|
156
|
-
import type { MetadataRoute } from "next";
|
|
157
|
-
|
|
158
|
-
export default function robots(): MetadataRoute.Robots {
|
|
159
|
-
return {
|
|
160
|
-
rules: { userAgent: "*", allow: "/" },
|
|
161
|
-
sitemap: "https://example.com/sitemap.xml",
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Every page in the PLAN's page list appears in the sitemap; nothing that 404s does. A staging deploy must disallow everything (`disallow: "/"`) so it never gets indexed.
|
|
167
|
-
|
|
168
|
-
## 5. Structured data
|
|
169
|
-
|
|
170
|
-
One JSON-LD block per page type, server-rendered:
|
|
171
|
-
|
|
172
|
-
```tsx
|
|
173
|
-
// in a server component
|
|
174
|
-
const jsonLd = {
|
|
175
|
-
"@context": "https://schema.org",
|
|
176
|
-
"@type": "Organization", // LocalBusiness for a place, Person for a portfolio, Product for a product page
|
|
177
|
-
name: "Name",
|
|
178
|
-
url: "https://example.com",
|
|
179
|
-
logo: "https://example.com/brand/mark.png",
|
|
180
|
-
sameAs: ["https://www.instagram.com/name"],
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
<script
|
|
184
|
-
type="application/ld+json"
|
|
185
|
-
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
|
|
186
|
-
/>
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
Add `BreadcrumbList` on nested pages and `FAQPage` only when the page really has a visible FAQ. Never mark up content that is not on the page.
|
|
190
|
-
|
|
191
|
-
## 6. Where cinematic sites lose SEO, and the fix
|
|
192
|
-
|
|
193
|
-
| Risk | Fix |
|
|
194
|
-
|---|---|
|
|
195
|
-
| A loader that covers the page delays the Largest Contentful Paint | The hero renders and paints underneath; the loader is an overlay, session-gated, and never blocks HTML ([loaders.md](loaders.md)). Never render the hero only after the loader finishes. |
|
|
196
|
-
| SplitText turns the h1 into dozens of spans for screen readers | Use SplitText's `aria` option (the default `"auto"` keeps an accessible label on the parent); never split before fonts are ready ([typography.md](typography.md)) |
|
|
197
|
-
| Content that only exists after a scroll animation, or starts at `opacity: 0` in server HTML with no fallback | Animate `from` so final state is the default; content stays readable with JavaScript off and under reduced motion ([setup.md](setup.md) §4) |
|
|
198
|
-
| Text inside a WebGL canvas | Real copy lives in the DOM; 3D text is decoration with the same words in the DOM next to it ([webgl-scenes.md](webgl-scenes.md)) |
|
|
199
|
-
| Heavy 3D or video on first load | Lazy islands with posters ([webgl-r3f.md](webgl-r3f.md)); the poster image is the LCP candidate, sized and preloaded |
|
|
200
|
-
| Route transitions built on client-only navigation | `TransitionLink` keeps a real `href`; every route is server-rendered and works on direct load |
|
|
201
|
-
| Images without dimensions | `next/image` with width and height or `fill` in a sized frame; descriptive `alt` for content images, empty `alt` for decoration |
|
|
202
|
-
| Fonts swapping and shifting the layout | `next/font` with `adjustFontFallback` ([typography.md](typography.md)) |
|
|
203
|
-
|
|
204
|
-
**Performance floor to check in verify:** LCP under 2.5s, CLS under 0.1, INP under 200ms on a mid-range phone profile. Run Lighthouse or PageSpeed once on the production build for LCP and CLS; lab Lighthouse reports TBT, not INP, so INP comes from field data or a manual interaction trace ([performance.md](performance.md) §8). Record the numbers under VERIFY in STRUQ.md.
|
|
205
|
-
|
|
206
|
-
## 7. Vite
|
|
207
|
-
|
|
208
|
-
No metadata API: set title, description, canonical and OG tags per route in the HTML or with a head manager, ship static OG PNGs per shared page, and generate `sitemap.xml` at build time (a small script over the route list, or a sitemap plugin). A client-only SPA is weak for SEO; if search matters, prerender the routes or use Next.js.
|
|
209
|
-
|
|
210
|
-
## Checklist
|
|
211
|
-
|
|
212
|
-
- [ ] `metadataBase`, title template and description in the root layout; a canonical on every page (home included), none in the layout
|
|
213
|
-
- [ ] An `opengraph-image` per shared route, designed from the system, checked at real size
|
|
214
|
-
- [ ] `sitemap.ts` covers every page; `robots.ts` points at it; staging disallows all
|
|
215
|
-
- [ ] One h1 per page (the hero display line, unless it is not descriptive), headings in order, captions are not headings
|
|
216
|
-
- [ ] JSON-LD for the page type
|
|
217
|
-
- [ ] Every route reachable by a real link and server-rendered on direct load
|
|
218
|
-
- [ ] Loader and animations never hide content from the server HTML
|
|
219
|
-
- [ ] LCP, CLS and INP recorded under VERIFY
|