@struq/skill 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -1,296 +0,0 @@
1
- # Navigation & Header: the most persistent component
2
-
3
- The header is on screen for 100% of the session; the menu is the one interaction every visitor performs. Templates treat both as chrome (white sticky bar, shadow on scroll, hamburger dropdown); agencies treat the menu as **a scene**, often the most art-directed moment on the site. Assumes the providers from [setup.md](setup.md) and the z-ladder from [composition.md](composition.md) §5 (nav = `z-50`, overlay menu joins it).
4
-
5
- ---
6
-
7
- ## 1. Header anatomy
8
-
9
- - **Contents:** wordmark left, 3 to 5 links + one CTA right (or wordmark + menu button only, the "editorial minimal" header). Anything more belongs in the footer's nav echo ([page-architecture.md](page-architecture.md) §4).
10
- - **Height:** 4 to 5rem, authored in rem so it rides the scaling knob ([scaling.md](scaling.md)).
11
- - **Surface:** transparent over the hero, then, once real content scrolls under it, either a **hard surface swap** (`bg-surface-0` + `border-b border-line`, on-genre for editorial/brutalist) or `bg-surface-0/80` + `backdrop-blur-md` (the one sanctioned blur in the system, cinematic and luxury registers only; every other `backdrop-blur`, glass cards above all, stays on the blocklist in [../SKILL.md](../SKILL.md) AI-default blocklist). Never a drop shadow: the border *is* the elevation ([anti-patterns.md](anti-patterns.md) §1).
12
- - **Links:** body-size or smaller, tracked; hover = the origin-flipping underline ([micro-interactions.md](micro-interactions.md) §3 (a)), not a color swap.
13
- - Wordmark and CTA are prime candidates for the page's 1 to 3 magnetic elements ([micro-interactions.md](micro-interactions.md) §1).
14
-
15
- **Cheap tell:** `sticky top-0 bg-white shadow-md` with 7 gray links and a blue button. **Instead:** Transparent → surfaced on scroll, hairline-separated, 3 to 5 links, one designed CTA.
16
-
17
- ---
18
-
19
- ## 2. Scroll behavior: hide-on-down / show-on-up
20
-
21
- The default for narrative pages: the header gets out of the way while reading (scrolling down) and returns the instant intent reverses (scrolling up). **Pick hide-on-scroll or shrink-on-scroll, never both.** Stacked behaviors read as nervous.
22
-
23
- ```tsx
24
- "use client";
25
-
26
- import { useRef } from "react";
27
- import { gsap, useGSAP, ScrollTrigger } from "@/lib/gsap";
28
-
29
- export function Header({ children }: { children: React.ReactNode }) {
30
- const el = useRef<HTMLElement>(null);
31
-
32
- useGSAP(() => {
33
- const show = gsap.quickTo(el.current, "yPercent", { duration: 0.4, ease: "power4.out" });
34
-
35
- ScrollTrigger.create({
36
- start: "top top",
37
- end: "max",
38
- onUpdate: (self) => {
39
- if (self.scroll() < 80) { show(0); el.current!.dataset.surfaced = "false"; return; }
40
- el.current!.dataset.surfaced = "true";
41
- show(self.direction === 1 ? -100 : 0); // down → hide, up → return
42
- },
43
- });
44
- }, { scope: el });
45
-
46
- return (
47
- <header ref={el} data-surfaced="false"
48
- className="fixed inset-x-0 top-0 z-50 flex h-16 items-center justify-between px-6
49
- transition-colors duration-300
50
- data-[surfaced=true]:border-b data-[surfaced=true]:border-line
51
- data-[surfaced=true]:bg-surface-0/80 data-[surfaced=true]:backdrop-blur-md">
52
- {children}
53
- </header>
54
- );
55
- }
56
- ```
57
-
58
- Craft notes:
59
- - `quickTo` (not a tween per event): this fires every scroll frame; same one-clock rule as magnetic buttons.
60
- - The **80px grace zone** keeps the header stable over the hero; the surface swap and the hide behavior arrive together.
61
- - Direction flips mid-gesture must respond instantly. That's why show/hide is a `quickTo` chase, not a toggled class with a CSS transition fighting itself.
62
- - The **shrink variant** (height 5rem → 3.5rem + wordmark scale 0.85, same trigger, `power3.out` 0.35s) suits content-heavy sites where the header should stay visible but quiet.
63
- - Skip hiding entirely on short pages (< ~3 viewports): the behavior never gets room to read as intentional.
64
-
65
- ---
66
-
67
- ## 3. The full-screen overlay menu (the agency staple)
68
-
69
- Menu opens = a full-viewport inverted panel wipes in; nav links render as **display type** with masked line staggers; a meta column carries contact/socials. The menu is a *destination*, not a dropdown.
70
-
71
- **Colour: invert the page.** The recipes ship `bg-ink text-surface-0`, which reads on every light preset (Soft Modern, Warm Editorial, Luxury Minimal). On a dark preset (Dark Cinematic), where `ink` is the light colour, use `bg-surface-3 text-ink` instead. Never `bg-surface-3 text-surface-0` on a light preset: that is cream on cream. On the inverted panel, `surface-0` is the text and `surface-2` the muted tier; `ink-muted` on `ink` fails contrast ([vibe-presets.md](vibe-presets.md), token contract).
72
-
73
- **Build it on an accessible primitive.** An overlay that covers the page is a modal dialog, and a modal needs a focus trap, Escape to close, `aria-modal` semantics, inert background content, and focus return to the trigger. Hand-rolling all of that is where menus break. Use **Radix Dialog** (`Dialog` from the unified `radix-ui` package that [setup.md](setup.md) installs) or an equivalent primitive (React Aria, Ark UI) and let it own accessibility while GSAP owns the motion. §3a shows the motion; §3b shows it wrapped in Radix, which is the version to ship.
74
-
75
- ### 3a. The motion (reference sketch)
76
-
77
- ```tsx
78
- "use client";
79
-
80
- import { useRef, useState } from "react";
81
- import { gsap, useGSAP } from "@/lib/gsap";
82
- import { useLenis } from "lenis/react";
83
-
84
- const LINKS = [
85
- { href: "/work", label: "Work" },
86
- { href: "/studio", label: "Studio" },
87
- { href: "/journal", label: "Journal" },
88
- { href: "/contact", label: "Contact" },
89
- ];
90
-
91
- export function OverlayMenu() {
92
- const scope = useRef<HTMLDivElement>(null);
93
- const panel = useRef<HTMLDivElement>(null);
94
- const [open, setOpen] = useState(false);
95
- const lenis = useLenis();
96
-
97
- const { contextSafe } = useGSAP({ scope });
98
-
99
- const toggle = contextSafe(() => {
100
- const next = !open;
101
- setOpen(next);
102
- const links = gsap.utils.toArray<HTMLElement>("[data-menu-link]");
103
-
104
- if (next) {
105
- lenis?.stop(); // scroll lock: the Lenis-native way, no overflow:hidden fights
106
- gsap.timeline()
107
- .set(panel.current, { display: "block" })
108
- .fromTo(panel.current, { yPercent: -100 }, { yPercent: 0, duration: 0.6, ease: "power4.inOut" })
109
- .fromTo(links,
110
- { yPercent: 110 },
111
- { yPercent: 0, duration: 0.8, ease: "expo.out", stagger: 0.06 }, "-=0.15")
112
- .fromTo("[data-menu-meta]", { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.5 }, "<0.1");
113
- } else {
114
- gsap.timeline({ onComplete: () => { gsap.set(panel.current, { display: "none" }); lenis?.start(); } })
115
- .to(links, { yPercent: -110, duration: 0.35, ease: "power2.in", stagger: { each: 0.03, from: "end" } })
116
- .to(panel.current, { yPercent: -100, duration: 0.5, ease: "power4.inOut" }, "-=0.1");
117
- }
118
- });
119
-
120
- return (
121
- <div ref={scope}>
122
- <button onClick={toggle} aria-expanded={open} aria-controls="site-menu"
123
- className="font-ui text-ui relative z-[60]">
124
- {open ? "Close" : "Menu"}
125
- </button>
126
-
127
- <div ref={panel} id="site-menu" role="dialog" aria-modal="true"
128
- className="fixed inset-0 z-50 hidden bg-ink text-surface-0">
129
- <nav className="flex h-full flex-col justify-between px-6 pb-10 pt-32">
130
- <ul>
131
- {LINKS.map((l) => (
132
- <li key={l.href} className="-my-[0.1em] overflow-hidden py-[0.1em]">{/* the mask, with descender room */}
133
- <a data-menu-link href={l.href}
134
- className="font-display text-display block">
135
- {l.label}
136
- </a>
137
- </li>
138
- ))}
139
- </ul>
140
- <div data-menu-meta className="text-small flex justify-between">
141
- <span>hello@studio.com</span><span>Oslo, 14:32</span>
142
- </div>
143
- </nav>
144
- </div>
145
- </div>
146
- );
147
- }
148
- ```
149
-
150
- Load-bearing details:
151
- - **`lenis.stop()` / `lenis.start()`** is the scroll lock. Never `overflow: hidden` on body, which fights the smooth-scroll provider and jumps the page.
152
- - Links live in `overflow-hidden` `<li>` masks: the masked line reveal ([scroll-reveals.md](scroll-reveals.md) §3) at menu scale, with the same 0.1em descender padding as `.split-line-mask`. The exit reverses with `power2.in` at ~60% duration and `from: "end"`, the designed exit rule ([transitions.md](transitions.md) §4).
153
- - The menu button sits **above** the panel (`z-[60]`) and swaps its label. The close affordance is designed, not a reflexive close-icon glyph.
154
- - Menu links can navigate through the page-flow wipe ([route-transitions.md](route-transitions.md)): close the menu, then `push(href)`, never both animations at once.
155
- - **A11y is non-optional:** `role="dialog"` + `aria-modal`, `aria-expanded`/`aria-controls` on the trigger, `Esc` closes, focus moves into the panel on open and returns to the trigger on close, and focus is trapped while open. The sketch above declares the semantics but does not implement the trap or Escape; §3b gets all of it from the primitive. Reduced motion: panel and links fade 0.2s, no travel.
156
- - In-page anchor variant (one-pagers): links call `lenis.scrollTo(target)` after close. `anchors: true` covers plain hash links already.
157
-
158
- ### 3b. The same menu on Radix Dialog (ship this)
159
-
160
- Radix supplies the focus trap, Escape handling, `aria-modal`, inert background, and focus return to the trigger. GSAP keeps the choreography. The only wiring decisions are who controls `open`, and when the exit finishes.
161
-
162
- ```tsx
163
- "use client";
164
-
165
- import { useCallback, useRef, useState, type MouseEvent } from "react";
166
- import { useRouter } from "next/navigation";
167
- import { Dialog } from "radix-ui";
168
- import { gsap } from "@/lib/gsap";
169
- import { useLenis } from "lenis/react";
170
-
171
- // LINKS as in §3a
172
-
173
- export function OverlayMenu() {
174
- const [open, setOpen] = useState(false);
175
- const panel = useRef<HTMLDivElement | null>(null);
176
- const enterTl = useRef<gsap.core.Timeline | null>(null);
177
- const closing = useRef(false);
178
- const pendingHref = useRef<string | null>(null);
179
- const router = useRouter();
180
- const lenis = useLenis();
181
-
182
- // Enter: Radix mounts the content, the ref callback plays the timeline.
183
- const setPanel = useCallback((node: HTMLDivElement | null) => {
184
- panel.current = node;
185
- if (!node) return;
186
- const links = node.querySelectorAll("[data-menu-link]");
187
- enterTl.current = gsap.timeline()
188
- .fromTo(node, { yPercent: -100 }, { yPercent: 0, duration: 0.6, ease: "power4.inOut" })
189
- .fromTo(links, { yPercent: 110 },
190
- { yPercent: 0, duration: 0.8, ease: "expo.out", stagger: 0.06 }, "-=0.15")
191
- .fromTo(node.querySelector("[data-menu-meta]"), { autoAlpha: 0, y: 16 },
192
- { autoAlpha: 1, y: 0, duration: 0.5 }, "<0.1");
193
- }, []);
194
-
195
- // Radix calls this for Escape, the Close button, and outside interaction.
196
- // Opening is immediate; closing plays the exit first, then unmounts.
197
- const onOpenChange = (next: boolean) => {
198
- if (next) { lenis?.stop(); setOpen(true); return; }
199
- if (closing.current || !panel.current) return;
200
- closing.current = true;
201
- enterTl.current?.kill(); // a fast close must not fight a half-played entrance
202
- const node = panel.current;
203
- gsap.timeline({
204
- onComplete: () => {
205
- closing.current = false;
206
- setOpen(false);
207
- lenis?.start();
208
- const href = pendingHref.current;
209
- pendingHref.current = null;
210
- if (href) router.push(href); // navigate only after the exit has finished
211
- },
212
- })
213
- .to(node.querySelectorAll("[data-menu-link]"),
214
- { yPercent: -110, duration: 0.35, ease: "power2.in", stagger: { each: 0.03, from: "end" } })
215
- .to(node, { yPercent: -100, duration: 0.5, ease: "power4.inOut" }, "-=0.1");
216
- };
217
-
218
- // Keep the real href (crawlers, middle-click, new tab) but take over a plain click.
219
- const onLinkClick = (e: MouseEvent<HTMLAnchorElement>, href: string) => {
220
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
221
- e.preventDefault();
222
- pendingHref.current = href;
223
- onOpenChange(false);
224
- };
225
-
226
- return (
227
- <Dialog.Root open={open} onOpenChange={onOpenChange}>
228
- {/* Mobile-only menu: desktop shows inline links in the header (§4), so the trigger
229
- hides from md up. Drop md:hidden for an "editorial minimal" header on every width. */}
230
- <Dialog.Trigger className="font-ui text-ui h-12 md:hidden">Menu</Dialog.Trigger>
231
-
232
- <Dialog.Portal>
233
- <Dialog.Content ref={setPanel} aria-describedby={undefined}
234
- className="fixed inset-0 z-50 bg-ink text-surface-0">
235
- <Dialog.Title className="sr-only">Site menu</Dialog.Title>
236
-
237
- {/* The trigger is inert while the modal is open, so Close lives inside the panel.
238
- It must sit EXACTLY over the header's Menu button: same right inset as the header's
239
- px-6, same height (h-12) and the same vertical centre as the h-16 header bar, so the
240
- label swaps in place instead of jumping. Change one, change both. */}
241
- <Dialog.Close className="font-ui text-ui absolute right-6 top-2 h-12">
242
- Close
243
- </Dialog.Close>
244
-
245
- <nav className="flex h-full flex-col justify-between px-6 pb-10 pt-32">
246
- <ul>
247
- {LINKS.map((l) => (
248
- <li key={l.href} className="-my-[0.1em] overflow-hidden py-[0.1em]">
249
- <a data-menu-link href={l.href} onClick={(e) => onLinkClick(e, l.href)}
250
- className="font-display text-display block">
251
- {l.label}
252
- </a>
253
- </li>
254
- ))}
255
- </ul>
256
- <div data-menu-meta className="text-small flex justify-between">
257
- <span>hello@studio.com</span><span>Oslo, 14:32</span>
258
- </div>
259
- </nav>
260
- </Dialog.Content>
261
- </Dialog.Portal>
262
- </Dialog.Root>
263
- );
264
- }
265
- ```
266
-
267
- Notes on the Radix version:
268
- - **Controlled `open`** is what lets the exit animation finish before Radix unmounts the content. `onOpenChange(false)` starts the exit; `setOpen(false)` runs only in the timeline's `onComplete`.
269
- - **Scroll lock still coordinates with Lenis.** Radix applies its own modal scroll lock; keep `lenis.stop()` on open and `lenis.start()` after the exit so Lenis' wheel and touch handling halts too. Put `data-lenis-prevent` on any scrollable region inside the menu so it can scroll natively while Lenis is running elsewhere.
270
- - **`Dialog.Title` is required** for screen readers (hide it visually with `sr-only`). `aria-describedby={undefined}` is the documented way to opt out of a description when there isn't one.
271
- - **Focus** moves into the panel on open and returns to the trigger on close without extra code. Do not fight it with manual `focus()` calls.
272
- - **Links navigate client-side after the exit.** A plain `<a href>` navigates immediately and cuts the exit short, and a full reload drops the Lenis and transition providers. `onLinkClick` stores the target in `pendingHref`, prevents the default, starts the exit, and `router.push` runs in the exit timeline's `onComplete`, so the motion stays strictly sequential. The `href` stays real for crawlers and modified clicks. On a site with route curtains, swap `useRouter` for `useTransitionRouter()` from the provider in [route-transitions.md](route-transitions.md) (its `push` is the same `navigate` that `TransitionLink` calls) so the menu exit hands straight into the curtain.
273
- - **The enter timeline is kept in a ref and killed before the exit runs.** Escape pressed mid-entrance otherwise leaves two timelines writing the same `yPercent`.
274
- - **Reduced motion:** branch with `gsap.matchMedia()` ([setup.md](setup.md) §4) and replace the travel with a 0.2s `autoAlpha` fade in both timelines.
275
-
276
- **Cheap tell:** hamburger → white dropdown of body-size links sliding 200ms `ease-in-out`, with no focus trap and Escape ignored. **Instead:** A surface wipe, display-type links rising from masks, meta column, choreographed exit, scroll properly locked, and a real dialog primitive underneath.
277
-
278
- ---
279
-
280
- ## 4. Mobile
281
-
282
- - **The overlay IS the mobile nav.** One menu system for all widths: desktop may *also* show inline links, mobile shows only wordmark + menu button. Never a separate "mobile hamburger dropdown." The usual split: the inline links carry `max-md:hidden`, the trigger carries `md:hidden` (§3b), and both live in the same header so the Close button can sit over Menu.
283
- - Touch targets ≥ 48×48px for the menu button and each link row ([iconography.md](iconography.md) §7); the display-type links clear this by default.
284
- - Hide-on-scroll stays on. It's *more* valuable on small screens where 4rem of chrome is 5% of the viewport. The `backdrop-blur` surface is fine on modern mobile; if the page is effect-heavy, prefer the hard surface swap (blur stacks with everything else on the compositor).
285
- - The wordmark stays ≤ ~40% of viewport width; long names abbreviate to a mark.
286
-
287
- ---
288
-
289
- ## Header checklist
290
-
291
- - [ ] Transparent over hero → surfaced after ~80px (border or sanctioned blur, never shadow)
292
- - [ ] One scroll behavior (hide *or* shrink), quickTo-driven
293
- - [ ] 3 to 5 links + one CTA max; overlay menu for everything else
294
- - [ ] Menu: built on Radix Dialog (or equivalent), Lenis stop/start, masked link stagger in, reversed exit, ≤ 1s total
295
- - [ ] Dialog semantics from the primitive: title, Esc, focus trap + return; trigger has `aria-expanded`; reduced-motion fade
296
- - [ ] Mobile: same overlay, 48px targets, no dropdown variant
@@ -1,134 +0,0 @@
1
- # Page Architecture: the Narrative Arc, Section Inventory & Footer
2
-
3
- Everything else in Struq makes a *section* excellent; this file decides **which sections exist and in what order**. It is the layer templates fake with "Hero / Features / Testimonials / Pricing / CTA", which is a stack, not a story. Read this **before** picking a vibe preset: the arc determines what you build; the preset determines how it looks.
4
-
5
- This file does not write the words. It only sets the *structural* budgets copy must fit (headline lengths, one manifesto line, eyebrow grammar).
6
-
7
- ---
8
-
9
- ## 1. The arc: five beats, in order
10
-
11
- Every effective marketing/narrative page is the same dramatic shape:
12
-
13
- | Beat | Job | Typical sections | Pacing (spacing role) |
14
- |---|---|---|---|
15
- | **Hook** | Stop the scroll; claim a position in one line | Hero | vast: 12 to 20rem, one idea |
16
- | **Orient** | Who this is, in one breath | Intro / positioning statement | breather: a manifesto line (`font-display text-statement`) in 16 to 24rem of air |
17
- | **Prove** | Evidence: the work, the product, the numbers | Selected work / product walkthrough / capabilities | dense: 6 to 8rem, editorial rows |
18
- | **Deepen** | How and why, the thing competitors can't copy | Process / principles / about | medium; this is where the showpiece lives |
19
- | **Invite** | One ask, made unmissable | CTA block + footer | vast again: the close mirrors the open |
20
-
21
- Rules the arc enforces:
22
-
23
- - **5 to 8 sections total.** More is a pacing failure, not thoroughness, so cut or merge. (A section is a distinct visual movement, not every `<section>` tag.)
24
- - **One idea per beat.** If a section is doing two jobs (proving *and* explaining process), split it or cut half.
25
- - **Pacing alternates.** Dense, then breather, then dense. Two dense sections back-to-back read as a wall; two breathers as emptiness. This is the page-level version of the spacing-asymmetry rule.
26
- - **A showpiece is its own density class.** It is neither dense nor breather, even when it is airy on screen (`manifesto-fill`, a pinned single statement). So "breather, showpiece, breather" is legal and is the pattern (§3); it never counts as two breathers in a row. The no-two-breathers rule applies to plain sections only.
27
- - **The page ends where it started, louder.** The Invite beat re-states the Hook's claim as an action ("Studio for durable brands" becomes "Build something durable. Talk to us.").
28
-
29
- **Cheap tell:** Hero → 3-card Features → Stats → Testimonials → Pricing → FAQ → CTA. That is the template stack: every section the same weight, no beat structure. **Instead:** Five beats, alternating density, one showpiece, one ask.
30
-
31
- ---
32
-
33
- ## 2. Per-genre section inventories
34
-
35
- | Genre | Inventory (in arc order) | Showpiece slot |
36
- |---|---|---|
37
- | **Portfolio** | Hero (name + role claim) → manifesto line → selected work (the meat, 60% of the page) → about/process → contact footer | horizontal work gallery (`pin-horizontal-gallery` in [showcase.md](showcase.md); technique in [pinning-scrub.md](pinning-scrub.md) §2) |
38
- | **Studio / agency** | Hero → positioning statement → selected work → capabilities (editorial rows) → process or principles → CTA + footer | pinned process sequence ([pinning-scrub.md](pinning-scrub.md) §1) |
39
- | **Product launch** | Hero (product as the visual) → the one-line why → product walkthrough → proof (numbers/logos/quotes, pick ONE form) → pricing or waitlist → footer | scrubbed product walkthrough |
40
- | **Campaign / event** | Hero (date/name as display type) → what/why → program or lineup → practical info → register CTA + footer | the hero itself (departure scrub + one act-break curtain) |
41
-
42
- Proof sections: choose **one** proof form and do it well: a logo wall *or* two strong pull-quotes *or* three big numbers. Stacking all three is the template reflex; one, art-directed, is the agency move.
43
-
44
- Showpiece slots name a technique; the named, copy-ready recipes (with ids) are cataloged in [showcase.md](showcase.md). Pick the recipe there once the slot is decided here.
45
-
46
- ### 2b. Mapping a niche onto a genre
47
-
48
- Struq has no per-niche rulebook in the free skill, so derive the niche decisions in three moves: pick the nearest genre row above, set the proof form and the visitor's fear, then pour a preset from [vibe-presets.md](vibe-presets.md) (its seven axes) over the result. Common mappings:
49
-
50
- | Niche (by who signs the check) | Start from genre | The ONE proof form | Visitor fear to design against |
51
- |---|---|---|---|
52
- | Product brand (food/bev, skincare, fashion) | Product launch | the products themselves, shot well | "It will not be as good as the photos" |
53
- | Place people visit (restaurant, hotel, gym, clinic) | Campaign, with the room as the hero | the room and the people in it | "It will feel generic or unwelcoming" |
54
- | Service or practice (legal, medical, consulting, trades) | Studio / agency | two strong pull-quotes or outcomes | "I will be sold to, not helped" |
55
- | Show work (portfolio, photography, architecture) | Portfolio | the work, 60% of the page | "The taste will not hold up" |
56
- | Launch something (SaaS, AI, game, record) | Product launch or Campaign | numbers or the product in motion | "It is vapor, or one more template" |
57
- | Cause or institution (nonprofit, university) | Campaign | three big numbers | "My money or time will be wasted" |
58
-
59
- Classify by buyer and business model, not by surface topic: a legal-tech SaaS is a launch page for a software buyer, not a legal-practice page.
60
- ---
61
-
62
- ## 3. Showpiece placement
63
-
64
- The 1 to 2 pinned/scrubbed showpieces ([pinning-scrub.md](pinning-scrub.md), catalog in [showcase.md](showcase.md)) are **act-breaks**, and placement is most of their value:
65
-
66
- - **Slot A, post-Prove:** after the work/product has earned attention, the showpiece explains *how* (process sequence). The classic studio-site shape.
67
- - **Slot B, the hero itself:** for campaign/launch pages where the product IS the show. If the hero becomes a WebGL scene, see [webgl-r3f.md](webgl-r3f.md) and [webgl-scenes.md](webgl-scenes.md).
68
- - **Never back-to-back.** Two consecutive scroll-jacked sections feel like a hostage situation; separate them with at least one natively-scrolling beat.
69
- - **Breathers flank the showpiece.** A vast, near-empty section before it (anticipation) and after it (recovery). A pin that starts mid-density feels like a malfunction. The showpiece is its own density class (§1), so its flanking breathers never count as two breathers back to back.
70
- - **Never in the Invite beat.** By the close, the user has decided; do not make them scrub through theater to reach the CTA.
71
-
72
- ---
73
-
74
- ## 4. Footer anatomy: "designed as hard as the hero," specified
75
-
76
- The footer is the Invite beat's second half and the most-skipped design surface on the internet. The agency footer is a **destination**, not a sitemap dump:
77
-
78
- | Zone | Content | Treatment |
79
- |---|---|---|
80
- | **The ask** | One oversized CTA line ("Let's talk.", "Start a project") | `font-display text-display`, the footer's hero (the page's one other display line) |
81
- | **Nav echo** | 4 to 7 links max: the header nav repeated, not every route | `font-ui text-ui`, one column |
82
- | **Meta row** | location + local time, email, 2 to 4 socials, legal | `text-ink-muted`, baseline-aligned row |
83
- | **Signature** | wordmark (often oversized/cropped off the bottom edge), © year | the one place giant ghost type is almost always right |
84
-
85
- - Surface: step the ladder (footer on `surface-1` under a `surface-0` page, or inverted) so the close registers as a new room.
86
- - The **footer-reveal** pattern, where the page slides up to uncover a fixed footer ([composition.md](composition.md) §5), is the highest-payoff footer move and costs zero JS.
87
- - Local time (`Intl.DateTimeFormat` with the studio's zone) is a tiny detail that signals "real people, real place."
88
- - The oversized ask and wordmark are type decisions: see [typography.md](typography.md) for the fluid display scale.
89
-
90
- **Cheap tell:** four columns of gray links on `bg-gray-900`, a newsletter input nobody asked for, social icons in circles. **Instead:** One giant ask, a short nav echo, a meta row, an oversized wordmark, composed like a closing shot.
91
-
92
- ---
93
-
94
- ## 5. The copy interface: structural budgets
95
-
96
- Copywriting is its own craft; the *architecture* constrains it so display type stays display type:
97
-
98
- - **Hero h1: ≤ ~8 words**, breaking into 1 to 3 lines of ≤ ~12ch at display size (`max-w-[12ch]` is the layout guard). Longer claims get demoted to the sub-line.
99
- - **One manifesto line per page**: a single sentence given a whole breather section. More than one and neither is the manifesto.
100
- - **Eyebrow grammar:** 1 to 3 words, uppercase, tracked. A *category label* ("Capabilities", "Selected work"), never a sentence, never punctuated.
101
- - **Section headline: ≤ ~10 words.** Body blocks: `max-w-[44-52ch]`, 2 to 4 sentences per block. Long arguments become editorial rows, not paragraphs.
102
- - **CTA labels: 2 to 4 words, verb-first** ("Start a project", not "Learn more about our services").
103
- - Real copy from the first draft: the baseline's no-lorem rule is architectural, because fake copy hides that a section has no actual job.
104
-
105
- ---
106
-
107
- ## 6. Worked example: 7-section studio site, arc annotated
108
-
109
- ```
110
- 1 HERO [Hook] "Studio for durable brands", font-display text-display,
111
- departure scrub; media punches into §2 (seam #1)
112
- 2 POSITIONING [Orient] one manifesto line, 20rem air above/below (breather)
113
- 3 SELECTED [Prove] editorial rows + full-bleed images, dense 6 to 8rem,
114
- WORK sibling-dimming hovers; ghost type crop (grid-break #2)
115
- 4 CAPABILITIES [Prove] hairline-divided list in 16rem breather air after §3's imagery
116
- 5 PROCESS [Deepen] SHOWPIECE: pinned 3-phase sequence, ≤2.5 viewports,
117
- flanked by §4 and §6, both breathers
118
- 6 PRINCIPLES [Deepen] 2 to 3 numbered-ONCE oversized statements, breather spacing (16 to 24rem)
119
- 7 CTA + FOOTER [Invite] footer-reveal; "Let's build something durable." as
120
- the close: the hero's claim, made an action
121
- ```
122
-
123
- Pacing trace: vast → breather → dense → breather → showpiece → breather → vast. Two grid-breaks, one showpiece, one proof form (the work itself), one ask.
124
-
125
- ---
126
-
127
- ## Architecture checklist (run before building anything)
128
-
129
- - [ ] Every section assigned to a beat; any section without a beat is cut
130
- - [ ] 5 to 8 sections; density alternates; one manifesto breather exists
131
- - [ ] Exactly one proof form chosen
132
- - [ ] Showpiece (if any) at an act-break, flanked by calm, never doubled, recipe picked from [showcase.md](showcase.md)
133
- - [ ] Footer designed to the §4 anatomy before calling the page done
134
- - [ ] Hero h1 ≤8 words; every CTA verb-first; no section doing two jobs
@@ -1,198 +0,0 @@
1
- # Per-Zone Page Entrances: the `template.tsx` layer
2
-
3
- One arm of the page-flow system: what the *revealed* page does. Siblings: [route-transitions.md](route-transitions.md) (the cover→swap→reveal wipe this entrance overlaps) and [loaders.md](loaders.md) (the first-load overlay).
4
-
5
- `template.tsx` re-mounts on every navigation within a route group ([setup.md](setup.md) §6), so it's where the revealed page gets a subtle entrance. It overlaps the reveal so content rises in the cover's wake rather than popping. Differentiate zones: richer for marketing, quick for app.
6
-
7
- ```tsx
8
- // app/(marketing)/template.tsx: expressive
9
- "use client";
10
- import { useRef } from "react";
11
- import { useGSAP, gsap, ScrollTrigger } from "@/lib/gsap";
12
-
13
- export default function Template({ children }: { children: React.ReactNode }) {
14
- const ref = useRef<HTMLDivElement>(null);
15
- useGSAP(() => {
16
- const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
17
- gsap.from(ref.current, reduce
18
- ? { opacity: 0, duration: 0.3, clearProps: "all" }
19
- : { opacity: 0, y: 24, duration: 0.8, ease: "power3.out", clearProps: "all",
20
- onComplete: () => ScrollTrigger.refresh() });
21
- }, { scope: ref });
22
- return <div ref={ref}>{children}</div>;
23
- }
24
- ```
25
-
26
- ```tsx
27
- // app/(app)/template.tsx: functional, fast
28
- "use client";
29
- import { useRef } from "react";
30
- import { useGSAP, gsap } from "@/lib/gsap";
31
-
32
- export default function Template({ children }: { children: React.ReactNode }) {
33
- const ref = useRef<HTMLDivElement>(null);
34
- useGSAP(() => {
35
- gsap.from(ref.current, { opacity: 0, duration: 0.25, ease: "power1.out", clearProps: "all" });
36
- }, { scope: ref });
37
- return <div ref={ref}>{children}</div>;
38
- }
39
- ```
40
-
41
- **Two non-obvious, load-bearing details:**
42
- - **`clearProps: "all"`**: a leftover transform on this wrapper (even `translate(0,0)`) makes it a containing block for `position: fixed`, which silently breaks ScrollTrigger pinning for *every* section inside. Clearing props after the entrance removes the wrapper's footprint.
43
- - **`ScrollTrigger.refresh()` on complete**: trigger positions measured while the page was still offset (mid-entrance) are wrong. Refreshing after it settles re-measures against the final layout so pinned/scrubbed sections don't jump.
44
-
45
- ---
46
-
47
- ## Hero entrances the presets name
48
-
49
- The template entrance above moves the whole page. The hero's own entrance is a separate, per-preset decision ([vibe-presets.md](vibe-presets.md)): Soft Modern's `weight-in` and Warm Editorial's `soft-scrub` live in [typography.md](typography.md) §5; the two below belong to Dark Cinematic and Luxury Minimal. Both follow the same rules as every recipe: a `"use client"` leaf, `useGSAP` with a scope ref, all animation inside the `no-preference` branch of `gsap.matchMedia()`, and the server markup already the final state for reduced motion.
50
-
51
- ### `match-cut-hero`: image to type (Dark Cinematic)
52
-
53
- The hero opens on a full-bleed image, holds it for about a second, then cuts to the h1 sitting exactly where a shape in the image was (a horizon, an edge, a bright band). The cut is hard, or a 0.3s dissolve at most; the type itself does not rise. The shared shape is art direction: place the h1 on the image's horizon line at 1440 and at 390, and choose or crop the image so the shape lands there.
54
-
55
- ```css
56
- /* globals.css: the type waits under the image only while motion is allowed */
57
- @media (prefers-reduced-motion: no-preference) {
58
- [data-cut-hero]:not([data-cut]) [data-cut-type] { visibility: hidden; }
59
- }
60
- @media (prefers-reduced-motion: reduce) {
61
- [data-cut-image] { display: none; } /* reduced motion opens on the type, no cut */
62
- }
63
- ```
64
-
65
- ```tsx
66
- "use client";
67
- import { useRef } from "react";
68
- import Image from "next/image";
69
- import { gsap, useGSAP } from "@/lib/gsap";
70
-
71
- type NetInfo = { effectiveType?: string; saveData?: boolean };
72
-
73
- export function MatchCutHero({ src, title, cut = "hard" }: {
74
- src: string; title: string; cut?: "hard" | "dissolve";
75
- }) {
76
- const root = useRef<HTMLElement>(null);
77
-
78
- useGSAP(() => {
79
- const mm = gsap.matchMedia();
80
- mm.add("(prefers-reduced-motion: no-preference)", () => {
81
- const el = root.current!;
82
- const img = el.querySelector<HTMLImageElement>("[data-cut-image] img");
83
-
84
- // Shorter hold on slow or data-saving connections: the post-cut content is the LCP candidate.
85
- const net = (navigator as Navigator & { connection?: NetInfo }).connection;
86
- const slow = net?.saveData === true || /2g|3g/.test(net?.effectiveType ?? "");
87
- const HOLD = slow ? 0.6 : 1;
88
-
89
- const tl = gsap.timeline({ paused: true })
90
- .call(() => { el.dataset.cut = "1"; }, [], 0) // the type is there at the cut
91
- .to("[data-cut-image]", { autoAlpha: 0, duration: cut === "hard" ? 0 : 0.3, ease: "none" }, 0);
92
-
93
- // The hold starts when the photo has PAINTED, never on mount: a hold over an empty frame
94
- // is no match cut at all. Use delayedCall, not tl.delay(): play() ignores a delay set
95
- // after the timeline was created.
96
- let started = false;
97
- let hold: gsap.core.Tween | undefined;
98
- const start = (seconds: number) => {
99
- if (started) return;
100
- started = true;
101
- hold = gsap.delayedCall(seconds, () => { tl.play(); });
102
- };
103
- const onLoad = () => start(HOLD);
104
- img?.addEventListener("load", onLoad, { once: true });
105
- if (img?.complete && img.naturalWidth > 0) onLoad(); // cached: already painted
106
- const fallback = gsap.delayedCall(2.5, () => start(0)); // photo never came: open on the type
107
-
108
- return () => {
109
- tl.kill(); hold?.kill(); fallback.kill();
110
- img?.removeEventListener("load", onLoad);
111
- delete el.dataset.cut;
112
- };
113
- });
114
- }, { scope: root });
115
-
116
- return (
117
- <section ref={root} data-cut-hero
118
- className="relative grid min-h-svh items-end overflow-clip bg-surface-0 px-6 pb-[12svh]">
119
- <h1 data-cut-type className="font-display text-display max-w-[16ch]">{title}</h1>
120
- <div data-cut-image aria-hidden className="absolute inset-0">
121
- {/* LCP hint: `preload` or `priority`, whichever the installed next/image declares (setup.md §0, Known API shifts) */}
122
- <Image src={src} alt="" fill preload sizes="100vw" className="object-cover" />
123
- </div>
124
- </section>
125
- );
126
- }
127
- ```
128
-
129
- - **The hold is gated on the image.** `img.complete` (cached) or its `load` event starts a `gsap.delayedCall` for the hold, with a 2.5s fallback straight to the type. A timeline with `delay: 1` on mount fires the cut over an empty frame whenever the photo is remote or slow, and a delay set later with `tl.delay()` is ignored by `play()`. Verify it with a frame trace: the hold frame must be the photo ([pitfalls.md](pitfalls.md) §3.3).
130
- - **LCP is not free here.** The h1 is in the HTML from the start, hidden only by CSS while motion is allowed, and whatever is hidden until the cut (the h1, a product shot) becomes the LCP candidate the moment it appears. So lab LCP lands after hold plus cut, and fails the 2.5s floor ([performance.md](performance.md) §1) whenever the post-cut content paints a larger area than the photo did. Keep the post-cut hero smaller than the photo's painted area, and keep the shorter hold on slow connections (`HOLD` above). Measure it with the LCP probe in [performance.md](performance.md) §8; never assume it.
131
- - Add `<noscript><style>{"[data-cut-image]{display:none!important}[data-cut-type]{visibility:visible!important}"}</style></noscript>` to the root layout's `<head>`, so a JS failure opens on the type.
132
- - With a loader, start the hold as the loader exits ([loaders.md](loaders.md)), not on mount, or the cut happens behind the curtain.
133
- - **When not to:** when the image has no shape the type can land on (a cut with no shared shape is just a slideshow), and when the post-cut hero must be larger than the photo's painted area and LCP under 2.5s is a hard requirement: then open on the type and let the image follow.
134
-
135
- ### `clip-line-hero`: the 1.6s single-line clip (Luxury Minimal)
136
-
137
- One line of display type, revealed by one slow `clip-path` wipe from left to right. No split, no stagger, no y-travel: the restraint is the move.
138
-
139
- ```css
140
- /* globals.css: pre-paint guard, same shape as line-rise-hero */
141
- @media (prefers-reduced-motion: no-preference) {
142
- [data-clip-line]:not([data-ready]) { clip-path: inset(0% 100% 0% 0%); }
143
- }
144
- ```
145
-
146
- ```tsx
147
- "use client";
148
- import { useRef } from "react";
149
- import { gsap, useGSAP } from "@/lib/gsap";
150
-
151
- export function ClipLineHero({ children }: { children: string }) {
152
- const root = useRef<HTMLDivElement>(null);
153
-
154
- useGSAP(() => {
155
- const mm = gsap.matchMedia();
156
- mm.add("(prefers-reduced-motion: no-preference)", () => {
157
- const line = root.current!.querySelector<HTMLElement>("[data-clip-line]")!;
158
- gsap.fromTo(line,
159
- { clipPath: "inset(0% 100% 0% 0%)" },
160
- { clipPath: "inset(0% 0% 0% 0%)", duration: 1.6, ease: "power4.inOut", delay: 0.2,
161
- clearProps: "clipPath" }); // no clip left on the glyphs once it is open
162
- line.dataset.ready = "1"; // the inline start clip now holds the line hidden
163
- });
164
- }, { scope: root });
165
-
166
- return (
167
- <div ref={root}>
168
- <h1 data-clip-line className="font-display text-display -my-[0.1em] whitespace-nowrap py-[0.1em]">
169
- {children}
170
- </h1>
171
- </div>
172
- );
173
- }
174
- ```
175
-
176
- - `fromTo` renders its start clip immediately, so `data-ready` can lift the CSS guard in the same tick without a flash. The 0.1em padding pulled back with a negative margin keeps descenders inside the clipped box at leading 0.92. An italic last glyph overhangs its box on the right: add `px-[0.08em] -mx-[0.08em]` too when the line ends in italic ([pitfalls.md](pitfalls.md) §1).
177
- - `power4.inOut` is the baseline's curtain ease: the wipe is a curtain drawn across one line. 1.6s is the top of the hero band, which Luxury Minimal's motion personality owns.
178
- - Add `<noscript><style>{"[data-clip-line]{clip-path:none!important}"}</style></noscript>` to the root layout's `<head>`.
179
- - One line only. If the copy needs two lines at 390px, shorten the copy ([page-architecture.md](page-architecture.md) §5); a two-line clip wipe reads as two reveals.
180
-
181
- ---
182
-
183
- ## Timing reference
184
-
185
- | Phase | Duration | Ease | Transform |
186
- |---|---|---|---|
187
- | Marketing entrance | 0.8s | `power3.out` | `y 24→0` + fade |
188
- | App entrance | 0.25s | `power1.out` | fade |
189
- | Match cut (Dark Cinematic) | 1s hold from image load (0.6s on slow connections, 2.5s fallback), then 0s or 0.3s | `none` | image `autoAlpha` to 0, type static |
190
- | Single-line clip (Luxury Minimal) | 1.6s | `power4.inOut` | `clip-path` inset wipe |
191
- | **Reduced motion** | 0.3s | none | opacity only |
192
-
193
- ---
194
-
195
- ## Anti-pattern (sweep before shipping)
196
-
197
- - **Forgetting `clearProps`/`refresh`** → broken pins on revealed pages. If a pinned section jumps only after navigating (not on hard reload), this is the cause ([pitfalls.md](pitfalls.md) §2).
198
- - **A timed entrance started on mount** while it depends on an image, a font or a loader → it plays behind the curtain or over an empty frame. Start it from the thing it waits for ([pitfalls.md](pitfalls.md) §3.3).