@struq/skill 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +133 -2
- package/bin/struq.mjs +132 -0
- package/package.json +41 -4
- package/skills/struq/SKILL.md +237 -0
- package/skills/struq/cinematic/README.md +409 -0
- package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
- package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
- package/skills/struq/cinematic/checklists/03-polish.md +17 -0
- package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
- package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
- package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
- package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
- package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
- package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
- package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
- package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
- package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
- package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
- package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
- package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
- package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
- package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
- package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
- package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
- package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
- package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
- package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
- package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
- package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
- package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
- package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
- package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
- package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
- package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
- package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
- package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
- package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
- package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
- package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
- package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
- package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
- package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
- package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
- package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
- package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
- package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
- package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
- package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
- package/skills/struq/cinematic/references/13-official-docs.md +53 -0
- package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
- package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
- package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
- package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
- package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
- package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
- package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
- package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
- package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
- package/skills/struq/references/anti-patterns.md +265 -0
- package/skills/struq/references/baseline-prompt.md +118 -0
- package/skills/struq/references/brand.md +143 -0
- package/skills/struq/references/brief-router.md +219 -0
- package/skills/struq/references/composition.md +191 -0
- package/skills/struq/references/depth-parallax.md +235 -0
- package/skills/struq/references/direction.md +188 -0
- package/skills/struq/references/feel-layer.md +107 -0
- package/skills/struq/references/gsap-api.md +293 -0
- package/skills/struq/references/iconography.md +255 -0
- package/skills/struq/references/imagery.md +144 -0
- package/skills/struq/references/loaders.md +287 -0
- package/skills/struq/references/micro-interactions.md +270 -0
- package/skills/struq/references/mobile-composition.md +70 -0
- package/skills/struq/references/nav-header.md +296 -0
- package/skills/struq/references/page-architecture.md +134 -0
- package/skills/struq/references/page-entrances.md +198 -0
- package/skills/struq/references/performance.md +235 -0
- package/skills/struq/references/pinning-scrub.md +341 -0
- package/skills/struq/references/pitfalls.md +290 -0
- package/skills/struq/references/route-transitions.md +281 -0
- package/skills/struq/references/scaling.md +151 -0
- package/skills/struq/references/scroll-reveals.md +280 -0
- package/skills/struq/references/seo.md +219 -0
- package/skills/struq/references/setup.md +397 -0
- package/skills/struq/references/showcase.md +87 -0
- package/skills/struq/references/stack-targets.md +78 -0
- package/skills/struq/references/struq-imagery.md +107 -0
- package/skills/struq/references/svg-motion.md +390 -0
- package/skills/struq/references/transitions.md +160 -0
- package/skills/struq/references/triage.md +71 -0
- package/skills/struq/references/typography.md +720 -0
- package/skills/struq/references/vibe-presets.md +287 -0
- package/skills/struq/references/webgl-r3f.md +274 -0
- package/skills/struq/references/webgl-scenes.md +593 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# The Scaling System: fluid root-rem, one knob
|
|
2
|
+
|
|
3
|
+
The single most expensive recurring mistake is building a layout that looks right at
|
|
4
|
+
one width and then fixing every element by hand at every other width. That tax comes
|
|
5
|
+
from authoring in pixels and treating breakpoints as *resize* tools. This file kills it:
|
|
6
|
+
**one root-level knob scales everything proportionally**, and breakpoints are reserved
|
|
7
|
+
for genuine *layout changes* (column count, stack to row), never for resizing.
|
|
8
|
+
|
|
9
|
+
The mental model: design once at a reference width; the whole interface (type, padding,
|
|
10
|
+
gaps, component sizes) breathes in and out together as the viewport changes. No
|
|
11
|
+
per-element viewport patching.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. The knob: scale the root font-size with the viewport
|
|
16
|
+
|
|
17
|
+
Everything authored in `rem` is measured against `html`'s font-size. Scale that one
|
|
18
|
+
value with the viewport and *every rem in the build scales with it at once.*
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
/* app/globals.css (Next) or src/index.css (Vite): the ONE knob */
|
|
22
|
+
html {
|
|
23
|
+
font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem);
|
|
24
|
+
/* 16px up to about 640w (so 16px at 390), about 17.6px at 1440, frozen at 18px from about 1640 */
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Why this exact form (the a11y-safe part, do not skip)
|
|
29
|
+
|
|
30
|
+
A pure-`vw` root (`font-size: 2.6vw`) scales beautifully **but breaks browser zoom and
|
|
31
|
+
the user's font-size preference**: `vw` ignores both, so zoom does nothing to text and
|
|
32
|
+
accessibility is gone. The fix is to **always keep a `rem` term in the expression**:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
clamp( MIN_rem , REM_BASE_rem + SLOPE_vw , MAX_rem )
|
|
36
|
+
└── zoomable anchor ──┘ └ fluid part ┘
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `REM_BASE` term responds to zoom and OS font settings; the `vw` term adds the fluid
|
|
40
|
+
scaling on top. Result: proportional scaling **and** working zoom. This is the only
|
|
41
|
+
acceptable form. A clamp whose middle term is `vw`-only is the same bug as pure-`vw`.
|
|
42
|
+
|
|
43
|
+
> Deriving the middle term: pick two anchors, (minViewport, minRoot) and
|
|
44
|
+
> (maxViewport, maxRoot), both in px. `slope_vw = 100 * (maxRoot - minRoot) / (maxVp - minVp)`,
|
|
45
|
+
> `base_rem = (minRoot - slope_vw/100 * minVp) / 16`. Or just use a fluid-clamp
|
|
46
|
+
> calculator and paste. The two clamp ends are your readable floor and ceiling.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 2. Keep the root band narrow, layer the drama on top
|
|
51
|
+
|
|
52
|
+
The root knob also scales **body text**, so a wide band makes body huge on 4K or tiny on
|
|
53
|
+
mobile. Keep the band **modest and readable** (16px to 18px, frozen from about 1640). The body
|
|
54
|
+
should feel basically the same size everywhere; what you want scaling dramatically is the
|
|
55
|
+
*display* type and *section rhythm*, not paragraphs.
|
|
56
|
+
|
|
57
|
+
So the system is two layers:
|
|
58
|
+
|
|
59
|
+
1. **Root knob (narrow band)** carries proportional scaling of all spacing, gaps,
|
|
60
|
+
component sizes, and body text. Set once, never touched again.
|
|
61
|
+
2. **Per-element `clamp()` on the extremes**: the hero/display type keeps its own
|
|
62
|
+
aggressive fluid clamp *on top* of the root:
|
|
63
|
+
```css
|
|
64
|
+
/* the text-display role token (typography.md §2.4): 46px at 390, about 120px at 1440 */
|
|
65
|
+
@theme { --text-display: clamp(2.875rem, 1.2rem + 6.86vw, 8rem); } /* drama lives here, not in the root */
|
|
66
|
+
```
|
|
67
|
+
These two compose cleanly: the root scales the rem baseline gently; the hero's own
|
|
68
|
+
clamp adds the big swing. The middle term keeps its `rem` part, so zoom still scales
|
|
69
|
+
the hero; a `vw`-only middle (`clamp(3rem, 8vw, 9rem)`) is the same bug as a pure-`vw`
|
|
70
|
+
root. Components use the token (`font-display text-display`), never a hand-written clamp.
|
|
71
|
+
|
|
72
|
+
Rule of thumb: **if it's body, labels, padding, gaps, radii, use plain rem and let the root
|
|
73
|
+
carry it. If it's a display headline or a showpiece measurement, give it its own
|
|
74
|
+
`clamp()`.** The full display-type scale (sizes, tracking, leading per tier) lives in
|
|
75
|
+
[typography.md](typography.md).
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 3. Author in rem: no px for anything that should scale
|
|
80
|
+
|
|
81
|
+
This is the discipline that makes the knob work:
|
|
82
|
+
|
|
83
|
+
- **Type:** rem (Tailwind `text-*` is already rem). Display type: its own `clamp()`.
|
|
84
|
+
- **Spacing / padding / gap / margin:** rem (Tailwind `p-*`, `gap-*`, `space-*`, `m-*`
|
|
85
|
+
are already rem). Section padding stays role-varied per the
|
|
86
|
+
[baseline](baseline-prompt.md), just expressed in rem so it scales.
|
|
87
|
+
- **Component sizing, max-widths, radii:** rem / `ch` / `%`. `max-w-[44ch]` scales with
|
|
88
|
+
type; `rounded-[0.5rem]` scales with the knob.
|
|
89
|
+
- **Hairlines & true 1px details:** `px` is correct. Borders, focus rings, and
|
|
90
|
+
decorative hairlines should stay crisp at 1px and must NOT scale. This is the one place
|
|
91
|
+
px is right.
|
|
92
|
+
- **Media:** width in `%`/`rem`/`vw`, height via aspect-ratio (also prevents CLS).
|
|
93
|
+
|
|
94
|
+
Tailwind (v4 or later) cooperates for free: its spacing scale and font sizes are rem-based, so once
|
|
95
|
+
the root is wired, every utility inherits the scaling. You mostly just *stop reaching for
|
|
96
|
+
arbitrary px values*.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 4. Breakpoints change layout, never size
|
|
101
|
+
|
|
102
|
+
With the knob doing the resizing, breakpoints (`md:`, `lg:`) are freed for their real
|
|
103
|
+
job: **structural** changes that fluid scaling can't express.
|
|
104
|
+
|
|
105
|
+
- column count: `grid-cols-1 md:grid-cols-12`
|
|
106
|
+
- direction: `flex-col lg:flex-row`
|
|
107
|
+
- show/hide: a nav collapsing to a menu
|
|
108
|
+
- composition swaps: a 50/50 split becoming full-bleed
|
|
109
|
+
|
|
110
|
+
You should rarely write `text-2xl md:text-4xl lg:text-6xl` ladders again. That's the
|
|
111
|
+
manual-resize anti-pattern the root knob replaces. One fluid value, no ladder.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 5. Bounds and the test matrix
|
|
116
|
+
|
|
117
|
+
- **Ceiling:** the upper `clamp()` arm freezes growth so 2560px+ monitors don't keep
|
|
118
|
+
inflating. Past ~1600 to 1920 the design should *stop scaling and start adding margin*,
|
|
119
|
+
not get bigger. (Cap the content container with a rem/`ch` max-width.)
|
|
120
|
+
- **Floor:** the lower arm holds the readable minimum; never let the knob drive body
|
|
121
|
+
below 16px effective or labels below the 12px floor ([anti-patterns.md](anti-patterns.md) §12).
|
|
122
|
+
- **Test at 360 / 768 / 1280 / 1920 / 2560.** If every element looks proportional at all
|
|
123
|
+
five *without per-width overrides*, the system is working. If you're adding `lg:`
|
|
124
|
+
size overrides to fix spacing, something was authored in px: find it and convert.
|
|
125
|
+
- **Zoom test:** Ctrl/Cmd-+ to 150% must enlarge text. If it doesn't, a `vw`-only value
|
|
126
|
+
leaked into the root or into a font-size. Fix it (see §1).
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 6. Wiring per stack
|
|
131
|
+
|
|
132
|
+
The knob is just CSS, so it's framework-agnostic. Only the file differs:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
/* Next App Router → app/globals.css | Vite → src/index.css (imported in main.tsx) */
|
|
136
|
+
@import "tailwindcss";
|
|
137
|
+
|
|
138
|
+
html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Full stack-detection and the rest of the primitive translations are in
|
|
142
|
+
[stack-targets.md](stack-targets.md). Nothing else about scaling changes between Vite and
|
|
143
|
+
Next: it's one CSS rule in the global stylesheet either way.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
**Cheap tell:** px-everything layout with `sm: md: lg: xl:` size ladders on type and
|
|
148
|
+
padding, hand-tuned per breakpoint, that still drifts at 1440 and 2560 and needs a fresh
|
|
149
|
+
override every time a new screen is tested.
|
|
150
|
+
**Instead:** One a11y-safe root clamp, everything in rem, breakpoints only for structural change,
|
|
151
|
+
display type on its own `clamp()`: proportional at every width with zero per-element fixes.
|
|
@@ -0,0 +1,280 @@
|
|
|
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.from("[data-eyebrow]", { y: 24, autoAlpha: 0, duration: 0.6 })
|
|
272
|
+
.add(splitHeadlineIn(), "-=0.4") // overlap, don't queue
|
|
273
|
+
.from("[data-body]", { y: 32, autoAlpha: 0 }, "-=0.6")
|
|
274
|
+
.from("[data-media]", { yPercent: 12, autoAlpha: 0, duration: 1.2 }, "-=0.8");
|
|
275
|
+
},
|
|
276
|
+
{ scope: container }
|
|
277
|
+
);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The `-=0.x` overlaps are the craft: sequential-but-overlapping reads as one composed gesture; strictly sequential reads as a PowerPoint build.
|
|
@@ -0,0 +1,219 @@
|
|
|
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 section eyebrow 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, eyebrows 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
|