@struq/skill 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +139 -307
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
  19. package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
  20. package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
  21. package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
  22. package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
  23. package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
  24. package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
  25. package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
  26. package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/images.ts +17 -0
  29. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  30. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  31. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  32. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  33. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  34. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  35. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  36. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  37. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  38. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  39. package/skills/struq/blocks/sections/approach.tsx +661 -0
  40. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  41. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  42. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  43. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  44. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  45. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  46. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  47. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  48. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  49. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  50. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  51. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  52. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  53. package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
  54. package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
  55. package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
  56. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  57. package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
  58. package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
  59. package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
  60. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  61. package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
  62. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  63. package/skills/struq/canon/canon.json +627 -0
  64. package/skills/struq/cinematic/README.md +7 -18
  65. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  66. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  67. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  68. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  69. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  70. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  71. package/skills/struq/recipes/app/fonts.ts +12 -22
  72. package/skills/struq/recipes/app/globals.css +34 -177
  73. package/skills/struq/recipes/app/layout.tsx +13 -11
  74. package/skills/struq/references/craft.md +77 -0
  75. package/skills/struq/references/iconography.md +28 -230
  76. package/skills/struq/references/stack-targets.md +23 -74
  77. package/skills/struq/references/tells.md +52 -0
  78. package/skills/struq/references/triage.md +6 -7
  79. package/skills/struq/scripts/blocks.mjs +156 -0
  80. package/skills/struq/scripts/canon.mjs +106 -281
  81. package/skills/struq/scripts/check.mjs +92 -126
  82. package/skills/struq/scripts/images.mjs +2 -2
  83. package/skills/struq/scripts/log.mjs +6 -23
  84. package/skills/struq/scripts/tokens.mjs +222 -0
  85. package/skills/struq/cinematic/showpieces/README.md +0 -18
  86. package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
  87. package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
  88. package/skills/struq/references/anti-patterns.md +0 -330
  89. package/skills/struq/references/baseline-prompt.md +0 -122
  90. package/skills/struq/references/brand.md +0 -143
  91. package/skills/struq/references/brief-router.md +0 -218
  92. package/skills/struq/references/composition.md +0 -190
  93. package/skills/struq/references/depth-parallax.md +0 -235
  94. package/skills/struq/references/direction.md +0 -188
  95. package/skills/struq/references/feel-layer.md +0 -107
  96. package/skills/struq/references/gsap-api.md +0 -293
  97. package/skills/struq/references/hero-scenes.md +0 -107
  98. package/skills/struq/references/imagery.md +0 -125
  99. package/skills/struq/references/loaders.md +0 -287
  100. package/skills/struq/references/micro-interactions.md +0 -270
  101. package/skills/struq/references/mobile-composition.md +0 -70
  102. package/skills/struq/references/nav-header.md +0 -296
  103. package/skills/struq/references/niche-packets.md +0 -338
  104. package/skills/struq/references/page-architecture.md +0 -134
  105. package/skills/struq/references/page-entrances.md +0 -198
  106. package/skills/struq/references/performance.md +0 -235
  107. package/skills/struq/references/pinning-scrub.md +0 -340
  108. package/skills/struq/references/pitfalls.md +0 -290
  109. package/skills/struq/references/route-transitions.md +0 -281
  110. package/skills/struq/references/scaling.md +0 -151
  111. package/skills/struq/references/scroll-reveals.md +0 -279
  112. package/skills/struq/references/sections.md +0 -141
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
  123. package/skills/struq/scripts/font-names.mjs +0 -40
  124. package/skills/struq/scripts/recipes.mjs +0 -135
  125. /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
  126. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
  127. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
  128. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
  129. /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
@@ -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).
@@ -1,235 +0,0 @@
1
- # Performance: cinematic and fast
2
-
3
- An award-tier site that stutters or takes four seconds to show its hero is not award-tier. The motion in this skill is cheap when it runs on one clock and animates transforms; what actually makes these sites slow is **assets**: a 40MB hero video, PNG photography, an unoptimized GLB, six font files. Fix assets first, then code.
4
-
5
- Run this in two places: on every build before verify, and first thing on a redesign of an existing project (existing projects are almost always carrying badly optimized media).
6
-
7
- ## 1. Budgets
8
-
9
- | Asset | Budget | Notes |
10
- |---|---|---|
11
- | Hero image | 250KB at 1920w (AVIF or WebP) | the LCP element on most pages; preload it |
12
- | Content image | 150KB at 1440w | lazy below the first viewport |
13
- | Hero or ambient video loop | 2 to 4MB, 6 to 12s, no audio | always a poster; WebM first, MP4 fallback |
14
- | Any single video | 10MB max | longer content belongs on a video host |
15
- | GLB model | 1 to 3MB after compression | Draco or meshopt geometry, KTX2 or WebP textures |
16
- | Fonts | 2 families, 4 files max, about 150KB total | WOFF2, subset, variable where possible |
17
- | JavaScript on first load | about 250KB gzipped for the route, measured (§8) | three.js and R3F never in it; lazy island only |
18
-
19
- **Where the 250KB goes** (measured on a dry run with a recent Next.js and React, three.js correctly excluded: 244KB gzipped for `/`): the framework runtime (React, React DOM, the Next.js client and router) is roughly 150 to 190KB of it and is not yours to cut; GSAP core, ScrollTrigger, SplitText and DrawSVG are about 49KB; Lenis about 6KB; the rest is your components. Over budget, cut in this order: GSAP plugins registered but unused (MorphSVG and Flip are the usual passengers, [setup.md](setup.md) §1), Motion on a page where nothing uses it, Radix primitives imported into the root layout instead of the component that opens them, then heavy client components that could stay server components. The framework share moves with each major, so measure on the installed versions and write the number under VERIFY; never quote this table as the measurement.
20
-
21
- Targets on a mid-range phone profile: **LCP under 2.5s, CLS under 0.1, INP under 200ms**. Record them under VERIFY in STRUQ.md ([direction.md](direction.md)).
22
-
23
- ## 2. Audit an existing project (redesign mode, step one)
24
-
25
- ```bash
26
- # the 25 heaviest files under public/ and any asset folders
27
- find public assets -type f \( -iname "*.mp4" -o -iname "*.mov" -o -iname "*.webm" -o -iname "*.png" -o -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.webp" -o -iname "*.avif" -o -iname "*.gif" -o -iname "*.svg" -o -iname "*.glb" -o -iname "*.gltf" -o -iname "*.ttf" -o -iname "*.otf" -o -iname "*.woff" -o -iname "*.woff2" \) -printf "%s\t%p\n" 2>/dev/null | sort -rn | head -25 | awk -F'\t' '{printf "%8.1f KB %s\n", $1/1024, $2}'
28
-
29
- # what a video actually is: codec, size, fps, duration, bitrate, audio
30
- ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,r_frame_rate:format=duration,bit_rate,size -of compact input.mp4
31
- ```
32
-
33
- (On macOS, `find -printf` is unavailable: use `du -k` over the same globs.)
34
-
35
- List every file over budget with its role (hero, ambient, content, poster), then fix them in this order: videos, images, models, fonts. Replace references in code to point at the optimized files; never optimize in place over the source.
36
-
37
- ## 3. Video with ffmpeg
38
-
39
- Decorative and hero loops: strip audio, cap at 30fps, never upscale, trim to the loop.
40
-
41
- ```bash
42
- # WebM (VP9), primary source. CRF 32 to 36 for ambient, 28 to 32 for a hero that people look at.
43
- ffmpeg -i input.mov -t 10 -an -vf "scale='min(1920,iw)':-2,fps=30" \
44
- -c:v libvpx-vp9 -crf 32 -b:v 0 -pix_fmt yuv420p -row-mt 1 -deadline good -cpu-used 2 \
45
- public/media/hero.webm
46
-
47
- # MP4 (H.264), fallback. faststart so playback starts before the download ends.
48
- ffmpeg -i input.mov -t 10 -an -vf "scale='min(1920,iw)':-2,fps=30" \
49
- -c:v libx264 -preset slow -crf 24 -profile:v high -pix_fmt yuv420p -movflags +faststart \
50
- public/media/hero.mp4
51
-
52
- # Mobile variant: same commands with min(1080,iw) and one CRF step higher.
53
-
54
- # Poster from a representative frame (pick the second, never a black first frame)
55
- ffmpeg -ss 2.5 -i input.mov -frames:v 1 -vf "scale='min(1920,iw)':-2" -c:v libwebp -quality 80 public/media/hero-poster.webp
56
- ```
57
-
58
- ```tsx
59
- <video autoPlay muted loop playsInline preload="metadata" poster="/media/hero-poster.webp">
60
- <source src="/media/hero.webm" type="video/webm" />
61
- <source src="/media/hero.mp4" type="video/mp4" />
62
- </video>
63
- ```
64
-
65
- - Under `prefers-reduced-motion` or Save-Data, show the poster and do not autoplay.
66
- - Pause offscreen loops (IntersectionObserver); keep one decoding video per viewport.
67
- - A scroll-scrubbed video sequence is better as a WebP frame sequence on a canvas than as `currentTime` scrubbing, which stutters on most browsers: `ffmpeg -i input.mov -vf "fps=24,scale='min(1440,iw)':-2" -c:v libwebp -quality 75 frames/%04d.webp`, then cap the count (120 to 180 frames).
68
-
69
- ## 4. Images
70
-
71
- Use `next/image` for everything it can serve: it negotiates AVIF and WebP and resizes per `sizes`. Set `images.formats = ["image/avif", "image/webp"]` in `next.config`. What it cannot fix is a 12MB source in the repo, so normalize sources first:
72
-
73
- ```bash
74
- # one-off with sharp-cli: cap sources at 2400w and convert to high-quality WebP
75
- npx sharp-cli -i "assets/raw/*.{jpg,png}" -o public/images/ resize 2400 --withoutEnlargement -f webp -q 82
76
- ```
77
-
78
- - The LCP hint on exactly one image, the LCP image of that page: `preload` or `priority`, whichever the installed `next/image` declares ([setup.md](setup.md) §0, Known API shifts). Never `fetchPriority="high"` without `loading="eager"`; on a lazy image the hint does nothing.
79
- - `sizes` on every large image, matching the layout (`(min-width: 1024px) 50vw, 100vw`).
80
- - Width and height, or `fill` inside a sized frame, on every image: that is CLS.
81
- - PNG only for real transparency that WebP cannot carry; GIF never (convert to a muted video loop).
82
- - The grade from [imagery.md](imagery.md) is applied before export, not with CSS filters at runtime.
83
-
84
- ## 5. 3D models and textures
85
-
86
- ```bash
87
- # one command: dedupe, weld, meshopt geometry, WebP textures capped at 2048,
88
- # with the scene graph kept intact
89
- npx @gltf-transform/cli optimize input.glb public/models/product.glb \
90
- --compress meshopt --texture-compress webp --texture-size 2048 \
91
- --flatten false --join false
92
- ```
93
-
94
- - `--flatten false --join false` keep the node hierarchy and names. The defaults flatten the scene and merge meshes, which drops the node names that rigs, `useGLTF` lookups and per-part animation target. Drop the two flags only for a static, single-object model that nothing addresses by name.
95
- - drei's `useGLTF` takes meshopt as a boolean and ships its own decoder: `useGLTF(url, true, true)` (Draco on, meshopt on). The Draco decoder is fetched only for a Draco-compressed file; self-host it once with `useGLTF.setDecoderPath("/draco/")` at module level in the island. See [webgl-scenes.md](webgl-scenes.md) §1.
96
- - Textures are usually the weight, not geometry: 2048 max on desktop, 1024 for mobile variants.
97
- - Every model loads when its island mounts. `useGLTF.preload` buys nothing inside the lazy island (it runs only when the island chunk loads), and calling it from the section pulls three into the page bundle; the poster covers the wait.
98
- - Always ship the poster image that stands in before and instead of the canvas ([webgl-r3f.md](webgl-r3f.md)).
99
-
100
- ## 6. Fonts
101
-
102
- - Two families, WOFF2 only, variable files where they exist. `next/font` self-hosts and subsets Google fonts automatically; for local files subset to the languages used.
103
- - Preload the display and the body face, nothing else.
104
- - `adjustFontFallback` (default in `next/font`) to keep CLS near zero when the face swaps.
105
- - The TTF kept for OG images lives outside `public/` so it is never shipped to browsers ([seo.md](seo.md)).
106
-
107
- ## 7. Code
108
-
109
- - **Lazy WebGL.** three.js, R3F, Drei and postprocessing load only inside the island, via `next/dynamic` with `ssr: false`. They must never appear in the first-load bundle.
110
- - **One clock.** Lenis, ScrollTrigger and R3F all on `gsap.ticker` ([setup.md](setup.md)); a second requestAnimationFrame loop is a performance bug, not a style choice.
111
- - **Animate transforms and opacity only.** Never `top`, `left`, `width`, `height` or `filter: blur()` on scroll. `clip-path` and masks are fine for reveals.
112
- - **No React state per frame.** Scroll and pointer values go to refs, CSS variables or uniforms ([webgl-r3f.md](webgl-r3f.md) §3).
113
- - **`will-change` only while animating**, set and cleared by the timeline, never globally.
114
- - **Clean up.** Every `useGSAP` scope reverts on unmount; route changes must not leave ScrollTriggers, tickers or canvases alive ([route-transitions.md](route-transitions.md)).
115
- - **Register only the GSAP plugins in use.**
116
-
117
- ## 8. Verify
118
-
119
- 1. **Build for production and serve it** (`next build && next start -p <port>`, or `vite build && vite preview --port <port>`). Never measure or shoot the dev server. **Confirm the port belongs to the server you just started:** before starting, `lsof -i :<port>` (Windows: `netstat -ano | findstr :<port>`) shows nothing; after, it shows the PID of that production process. A dev server left running on the same port answers every request silently, and `next start` then fails with `EADDRINUSE` in a log nobody reads.
120
- 2. **First-load JS.** Turbopack builds print no per-route "First Load JS" table, so measure it from the served HTML. The script also proves three.js is absent on touch and arrives lazily on desktop:
121
-
122
- ```js
123
- // scripts/firstload.mjs: node scripts/firstload.mjs http://localhost:<port>
124
- import { gzipSync } from "node:zlib";
125
- import { chromium } from "playwright";
126
-
127
- const base = process.argv[2];
128
- const html = await (await fetch(base + "/")).text();
129
- const srcs = [...new Set([...html.matchAll(/<script[^>]+src="([^"]+)"/g)].map((m) => m[1]))];
130
- const THREE = /WebGLRenderer|WebGL2RenderingContext|react-three-fiber/;
131
- let raw = 0, gz = 0;
132
- const hits = [];
133
- for (const s of srcs) {
134
- const buf = Buffer.from(await (await fetch(new URL(s, base))).arrayBuffer());
135
- raw += buf.length; gz += gzipSync(buf).length;
136
- if (THREE.test(buf.toString("utf8"))) hits.push(s);
137
- }
138
- console.log(`first-load: ${srcs.length} scripts, ${(raw / 1024).toFixed(1)} kB raw, ${(gz / 1024).toFixed(1)} kB gzip`);
139
- console.log(`three.js in first load: ${hits.length ? hits.join(", ") : "none"}`);
140
-
141
- const browser = await chromium.launch({ args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
142
- for (const [name, opts] of [
143
- ["desktop", { viewport: { width: 1440, height: 900 } }],
144
- ["phone", { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }],
145
- ]) {
146
- const page = await browser.newPage(opts);
147
- const js = [];
148
- page.on("response", async (r) => {
149
- if (!r.url().endsWith(".js")) return;
150
- try { const b = await r.body(); js.push({ kb: b.length / 1024, three: THREE.test(b.toString("utf8")) }); } catch {}
151
- });
152
- await page.goto(base, { waitUntil: "networkidle" });
153
- await page.waitForTimeout(1500);
154
- const lazy = js.filter((j) => j.three);
155
- console.log(`${name}: ${js.length} JS files; three.js chunks ${lazy.length} (${lazy.reduce((a, b) => a + b.kb, 0).toFixed(1)} kB raw)`);
156
- await page.close();
157
- }
158
- await browser.close();
159
- ```
160
-
161
- 3. **LCP and CLS: an in-page probe** under 4x CPU and a throttled network (about 1.6 Mbps down, 150ms latency), mobile 390 and desktop 1440. It names the LCP element, which is the number that matters: with a hidden split hero or a match cut, the LCP element is whatever appears last, and on an unthrottled local server the figure is meaningless (a text hero measures 150ms locally).
162
-
163
- ```js
164
- // scripts/vitals.mjs: node scripts/vitals.mjs http://localhost:<port>
165
- import { chromium } from "playwright";
166
-
167
- const base = process.argv[2];
168
- const browser = await chromium.launch();
169
- for (const [label, opts] of [
170
- ["mobile 390", { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }],
171
- ["desktop 1440", { viewport: { width: 1440, height: 900 } }],
172
- ]) {
173
- const ctx = await browser.newContext(opts);
174
- const page = await ctx.newPage();
175
- const cdp = await ctx.newCDPSession(page);
176
- await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
177
- await cdp.send("Network.enable");
178
- await cdp.send("Network.emulateNetworkConditions", {
179
- offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8,
180
- });
181
- await page.addInitScript(() => {
182
- const v = { lcp: 0, cls: 0, el: "" };
183
- Object.assign(window, { __vitals: v });
184
- new PerformanceObserver((list) => {
185
- for (const e of list.getEntries()) {
186
- v.lcp = e.startTime;
187
- const el = e.element;
188
- v.el = el ? `${el.tagName} ${(el.getAttribute("src") ?? el.textContent ?? "").slice(0, 60)}` : "";
189
- }
190
- }).observe({ type: "largest-contentful-paint", buffered: true });
191
- new PerformanceObserver((list) => {
192
- for (const e of list.getEntries()) if (!e.hadRecentInput) v.cls += e.value;
193
- }).observe({ type: "layout-shift", buffered: true });
194
- });
195
- await page.goto(base, { waitUntil: "load" });
196
- await page.waitForTimeout(5000); // let timed entrances finish: their content is often the LCP
197
- await page.mouse.wheel(0, 600); // LCP stops recording at the first input
198
- await page.waitForTimeout(500);
199
- const v = await page.evaluate(() => window.__vitals);
200
- console.log(`${label}: LCP ${(v.lcp / 1000).toFixed(2)}s (${v.el.trim()}), CLS ${v.cls.toFixed(3)}`);
201
- await ctx.close();
202
- }
203
- await browser.close();
204
- ```
205
-
206
- Lighthouse (or PageSpeed Insights on the deploy) on the home page and one inner page is the second opinion. Lab tools report TBT, not INP: INP needs field data (PageSpeed's CrUX panel once the site has traffic) or a manual interaction trace in the performance panel (click the menu, a hover scene and a form control; read the longest interaction). Until then write "INP: field data pending" with the TBT number.
207
- 4. **Frame trace for timed entrances.** A PNG at rest cannot show a hold that fires over an empty frame, a split hero that flashes unsplit, or a reveal stuck at opacity 0. Capture the first 3 seconds and look at the frames in order:
208
-
209
- ```js
210
- // scripts/trace.mjs: node scripts/trace.mjs http://localhost:<port> (writes shots/trace-<w>-NN.png)
211
- import { mkdirSync } from "node:fs";
212
- import { chromium } from "playwright";
213
-
214
- const base = process.argv[2];
215
- mkdirSync("shots", { recursive: true });
216
- const browser = await chromium.launch();
217
- for (const [w, h, mobile] of [[1440, 900, false], [390, 844, true]]) {
218
- const page = await browser.newPage({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile });
219
- const t0 = Date.now();
220
- await page.goto(base, { waitUntil: "commit" });
221
- for (let i = 0; i < 15; i++) {
222
- await page.screenshot({ path: `shots/trace-${w}-${String(i).padStart(2, "0")}.png` }).catch(() => {});
223
- console.log(`${w} frame ${i}: ${Date.now() - t0}ms`);
224
- await page.waitForTimeout(200);
225
- }
226
- await page.close();
227
- }
228
- await browser.close();
229
- ```
230
-
231
- Each entrance must do what its recipe says: the hold frame of a match cut is the photo ([page-entrances.md](page-entrances.md)), the split hero never shows unsplit, nothing stays invisible ([pitfalls.md](pitfalls.md) §3).
232
- 5. Scroll the full page with the browser's performance panel recording: no long tasks during scroll, frames steady through the pinned showpiece.
233
- 6. Re-run the size listing from §2 on the output: nothing over budget without a written reason in STRUQ.md.
234
-
235
- The scripts use the `playwright` package (`npm i -D playwright`, then `npx playwright install chromium`); any harness browser tool that can throttle, observe and screenshot does the same job.