@struq/skill 0.3.0 → 0.4.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.
Files changed (129) hide show
  1. package/README.md +22 -36
  2. package/package.json +4 -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 +4 -4
  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,107 +0,0 @@
1
- # Hero scenes: the WebGL layer behind the hero
2
-
3
- Every Struq marketing site carries a WebGL layer in its hero. `components/struq/HeroCanvas.tsx` owns everything that makes such a layer safe in production; a scene owns only what it draws.
4
-
5
- | HeroCanvas owns | The scene owns |
6
- |---|---|
7
- | Loading three.js in its own chunk after first paint | Its `THREE.Scene` and camera |
8
- | The poster (the photo, or the flat surface) and the fade-in after the first frame | Its geometry, material and uniforms |
9
- | The DPR cap (1.75, phones 1.5) and a 2.4 million pixel budget | Reading the palette it is given |
10
- | Pausing offscreen and in a hidden tab | `update(state)`: time, pointer, scroll, velocity into uniforms |
11
- | One composed still frame under `prefers-reduced-motion` and on phones | `resize(w, h)`, an optional multi-pass `render(renderer)`, `dispose()` |
12
- | The quiet zones: up to four `[data-quiet]` rects behind the copy, in device pixels | Calling `hush(v, gl_FragCoord.xy)` on its intensity |
13
- | The pointer's `presence`, easing in on move and out on rest | Using it as a gentle modulation only |
14
- | Running on `gsap.ticker`, the page's one clock | |
15
- | Keeping the poster when WebGL or the scene fails | |
16
-
17
- ## The three shipped scenes
18
-
19
- | Scene | Looks like | Needs | Suits |
20
- |---|---|---|---|
21
- | `contours` | contour lines drifting like a slow survey map, every sixth in the accent | nothing | architecture, studios, consultancy, nature, research |
22
- | `ripple` | the hero photo as a liquid surface, rippling under the pointer, pushing in on scroll | `image` | hospitality, food, hotels, fashion, wellness |
23
- | `dots` | a field of points rolling like a swell, a hill under the pointer | nothing | mobility, logistics, technology, sport, energy |
24
-
25
- Props: `scene`, `image` (poster, and the texture for ripple), `grade` (0 to 1, how far ripple pulls the photo into the palette), `scrim` (0 to 0.6, a flat black layer for type in `text-on-photo`), `priority` (the poster is the LCP image).
26
-
27
- ## The craft bar
28
-
29
- What separates a high-end ambient hero from a screensaver, and what every shipped scene does:
30
-
31
- - **Clean:** fine marks (hairlines, small dots) on a flat ground, antialiased with `fwidth`, and where marks crowd closer than the screen can draw, they fade into a mist instead of moire.
32
- - **Deep:** a perspective field (a horizon just above the top edge or in the upper third), marks compressing and thinning toward it, a fog toward the far side.
33
- - **Calm behind the copy:** `hush` holds the field under a soft ceiling behind every `data-quiet` element, so the copy reads at 4.5:1 and the field still runs on behind it.
34
- - **Composed:** the field is weighted to the right or the vanishing point sits on the right third; the left, where the headline sits, is the quiet side.
35
- - **Slow:** the ambient clock is 0.02 to 0.06 of real time. The pointer only modulates (a soft lift, a ripple) and only while it is present.
36
- - **Cheap:** noise runs once per field cell into a small target, never per screen pixel, where the look allows (`contours` shows the two-pass pattern).
37
-
38
- ## Writing a scene (or porting the MCP background)
39
-
40
- A scene is one file in `components/struq/scenes/` that exports `createScene`:
41
-
42
- ```ts
43
- // components/struq/scenes/<name>.ts
44
- import { NOISE_GLSL, QUIET_GLSL, quietUniforms, type CreateScene } from "./types";
45
-
46
- export const createScene: CreateScene = async ({ THREE, renderer, palette, image, grade, dark }) => {
47
- const scene = new THREE.Scene();
48
- const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
49
- const quiet = quietUniforms(THREE, dark ? 0.08 : 0.12);
50
- const uniforms = {
51
- ...quiet.uniforms,
52
- uTime: { value: 0 },
53
- uRes: { value: new THREE.Vector2(1, 1) },
54
- uPointer: { value: new THREE.Vector2() },
55
- uScroll: { value: 0 },
56
- uSurface: { value: new THREE.Color(palette.surface) },
57
- uInk: { value: new THREE.Color(palette.ink) },
58
- uAccent: { value: new THREE.Color(palette.accent) },
59
- };
60
- const material = new THREE.ShaderMaterial({
61
- uniforms,
62
- vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
63
- fragmentShader: `
64
- uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; uniform float uScroll;
65
- uniform vec3 uSurface; uniform vec3 uInk; uniform vec3 uAccent;
66
- varying vec2 vUv;
67
- ${NOISE_GLSL}
68
- ${QUIET_GLSL}
69
- void main() {
70
- float v = 0.0; // ... the look's intensity, from noise per cell where you can
71
- v = hush(v, gl_FragCoord.xy);
72
- vec3 col = mix(uSurface, uInk, v); // the three palette colours only
73
- gl_FragColor = vec4(col, 1.0);
74
- #include <colorspace_fragment>
75
- }`,
76
- });
77
- const geometry = new THREE.PlaneGeometry(2, 2);
78
- scene.add(new THREE.Mesh(geometry, material));
79
- return {
80
- scene,
81
- camera,
82
- update(s) { uniforms.uTime.value = s.time; uniforms.uPointer.value.set(s.pointer.x, s.pointer.y); uniforms.uScroll.value = s.scroll; quiet.update(s); },
83
- resize(w, h) { uniforms.uRes.value.set(w, h); },
84
- dispose() { geometry.dispose(); material.dispose(); },
85
- };
86
- };
87
- ```
88
-
89
- Then register it in `HeroCanvas.tsx`:
90
-
91
- ```ts
92
- const SCENES = {
93
- contours: () => import("./scenes/contours"),
94
- ripple: () => import("./scenes/ripple"),
95
- dots: () => import("./scenes/dots"),
96
- "<name>": () => import("./scenes/<name>"),
97
- };
98
- ```
99
-
100
- **The MCP background (`struq_achtergrond`).** It returns a background family with its shader or scene description and its parameters. Port it into this contract: its shader becomes the fragment shader, its colour inputs become `uSurface`, `uInk` and `uAccent` built from `palette` (that is the recolour: the site's tokens, never the colours in the MCP sample), its animation reads `uTime`, and its pointer or scroll inputs read `uPointer` and `uScroll`. Build it even when you cannot watch it: if it fails to compile, HeroCanvas logs the error and keeps the poster, so the hero is never empty. Check the browser console when you do have a browser.
101
-
102
- ## Rules that keep it fast
103
-
104
- - Colours are linear inside the shader; end every fragment shader with `#include <colorspace_fragment>` and set `texture.colorSpace = THREE.SRGBColorSpace` on photo textures, or the palette renders too dark.
105
- - One full-screen pass, at most five noise octaves, no loops over pixels. Points: up to about 15,000.
106
- - One HeroCanvas per page. A second canvas further down is a deliberate WebGL moment ([webgl-r3f.md](webgl-r3f.md)), not a reflex.
107
- - Never a glossy 3D blob, a purple glow or a mesh-gradient wash: the scene works in the palette's neutrals with the accent as a thread.
@@ -1,125 +0,0 @@
1
- # Imagery & Art Direction: one grade, declared ratios, treated assets
2
-
3
- Imagery is now where generated builds fail hardest: the CSS can be flawless and the page still screams "AI" because the images are ungraded stock, glossy renders, or six unrelated looks. The fix is the same shape as the color system: **decide once, apply everywhere**. One grade, one ratio set, one sourcing standard.
4
-
5
- Media *behavior* (window parallax, masked reveals, aspect-ratio for CLS) lives in [depth-parallax.md](depth-parallax.md) and [scroll-reveals.md](scroll-reveals.md); this file is about what the pixels *are*.
6
-
7
- ---
8
-
9
- ## 1. One grade per page
10
-
11
- Every image on a page passes through the **same treatment**, chosen to match the preset's temperature ([vibe-presets.md](vibe-presets.md)). Mixed grading (a warm lifestyle shot next to a cool product render next to a saturated stock photo) is the single most reliable imagery tell. It says the images were *collected*, not *directed*.
12
-
13
- Pick one:
14
-
15
- | Grade | Character | Pairs with |
16
- |---|---|---|
17
- | **Warm film** | lifted blacks, warm midtones, gentle contrast | Warm Editorial |
18
- | **Desaturated cool** | -20 to -40% saturation, cool shift, deep blacks | Dark Cinematic |
19
- | **High-contrast BW** | full monochrome, punchy | the Brutalist genre appendix only, when the brief declares the genre (color arrives only via the accent) |
20
- | **Soft neutral** | low contrast, airy highlights, near-monochrome palette subjects | Soft Modern (cool-leaning), Luxury Minimal (warm-leaning) |
21
- | **Duotone** | surface color + ink (or accent) mapped to shadows/highlights | any preset, as the *signature* look |
22
-
23
- Apply the grade in one reusable layer, not per-image in an editor:
24
-
25
- ```tsx
26
- // The grade as a component. On a Struq build MediaFrame is this layer: add the filter to its <Image> className.
27
- // components/ui/GradedImage.tsx: every content image renders through this
28
- export function GradedImage({ src, alt, ratio = "4/3", className = "" }: {
29
- src: string; alt: string; ratio?: string; className?: string;
30
- }) {
31
- return (
32
- <div className={`relative overflow-hidden ${className}`} style={{ aspectRatio: ratio }}>
33
- <img src={src} alt={alt} className="h-full w-full scale-[1.18] object-cover
34
- [filter:saturate(0.82)_contrast(1.05)]" />
35
- </div>
36
- );
37
- }
38
- ```
39
-
40
- - The `filter` values are **per-project constants**: tune once against the preset, then never per-image. (Static CSS filters are fine; it's *scrubbed* filters that cost, see [setup.md](setup.md) §5.)
41
- - Duotone variant: `[filter:grayscale(1)_sepia(0.2)]` on the img. Never a tinted translucent layer over the photo: that is a tell of its own ([anti-patterns.md](anti-patterns.md) §20).
42
- - `scale-[1.18]` is the parallax headroom from [depth-parallax.md](depth-parallax.md) §2, so grading and panning share the same frame component.
43
-
44
- **Cheap tell:** every image at its source colors, six looks on one page. **Instead:** One `GradedImage` component; swap the page's grade by editing one filter line.
45
-
46
- ---
47
-
48
- ## 2. The ratio set: declared like tokens
49
-
50
- Pick **2 to 3 aspect ratios per project** and use nothing else. A page whose media is 4/3, 16/9, 3/2, 1/1, and 21/9 at random reads as unplaced; the same images in a disciplined ratio set read as a system. (Explicit ratios are also the CLS guard, see [setup.md](setup.md) §6.)
51
-
52
- | Role | Default | Alternatives by genre |
53
- |---|---|---|
54
- | Standard content / work thumb | `4/3` | `3/2` editorial, `1/1` brutalist grid |
55
- | Full-bleed statement (1 per ~3 viewports) | `21/9` | `2/1` |
56
- | Portrait accent (people, product detail) | `3/4` | `4/5` |
57
-
58
- Declare them where the tokens live and reference by role, never ad-hoc:
59
-
60
- ```css
61
- @theme {
62
- --ratio-standard: 4/3;
63
- --ratio-wide: 21/9;
64
- --ratio-portrait: 3/4;
65
- }
66
- ```
67
-
68
- ---
69
-
70
- ## 3. Texture: grain, and when to stop
71
-
72
- One **global grain overlay** unifies mixed sources better than any filter. It puts every image (and the surfaces) on the same "stock":
73
-
74
- ```tsx
75
- // One fixed element at the end of <body>. z-index above content, below nav (composition.md z-ladder).
76
- <div aria-hidden className="pointer-events-none fixed inset-0 z-40 opacity-[0.05] mix-blend-overlay"
77
- style={{ backgroundImage: "url(/noise.png)", backgroundSize: "128px" }} />
78
- ```
79
-
80
- - `noise.png`: a 128px tileable monochrome noise tile (or an inline SVG `feTurbulence` data-URI). Opacity 0.03 to 0.06: visible on a dark surface if you look, never legible as a pattern.
81
- - Grain suits film/cinematic/editorial registers; **skip it** for Luxury Minimal (clean air is the brand) and product-UI screenshots (they must stay crisp).
82
- - Budget: grain is the *only* always-on texture. Grain + vignettes + gradients + blobs = mud.
83
-
84
- ---
85
-
86
- ## 4. Sourcing ladder: where images come from
87
-
88
- In order of preference:
89
-
90
- 1. **Real assets**: the client's work, product, people, places. Always first; treatment (§1) makes even average photography cohesive.
91
- 2. **Struq's beeldbank**: real, graded, CC0 sector packs from struq.nl, one pack per site so the grade stays single, downloaded into the project and credited. The first stop when the client has no photos, or not enough. How to pick, fetch and credit: [struq-imagery.md](struq-imagery.md).
92
- 3. **Generated on-brief**, with any capable image model (Flux/SDXL-class local checkpoints or a hosted generator). The standard when real assets don't exist and no beeldbank pack fits. Treat each prompt as an art-direction document, not a subject description, and generate the whole set in one session with the same model and settings so it reads as one shoot. Prompt for the *page's* grade, not per-image whims:
93
- - State the grade in every prompt: light temperature ("warm late-afternoon window light" / "cool overcast studio light"), palette bias matching the preset's neutrals, film character ("35mm film grain, muted colors" vs "clean digital").
94
- - Keep **one lighting world** across the set. Consistent light does more for coherence than consistent subjects.
95
- - Compose for the crop: prompt the ratio you'll place (§2), and leave dead space where type will sit if it's a lockup image ([composition.md](composition.md) §3).
96
- - Avoid the AI-render look: no glossy 3D blobs, no purple-glow gradients, no hyperdetailed-everything. The §1 grade pass over generated output hides most remaining tells.
97
- 4. **Treated stock**: last resort, and only *through* the grade layer. Untreated stock (beaming laptop people, handshake photos) is below the floor.
98
-
99
- **Beeldbank, stock and generated images show context, never the client's product.** The city, the canal, a rider, a room, a material: yes. A stranger's e-bike, a generated bottle or a stock ceramic presented as the product: never, because the visitor reads it as the thing they would buy. Until the real product is shot, it is type, a context image of the client's world, a declared 3D stand-in ([webgl-scenes.md](webgl-scenes.md) §4, "No model file yet"), or type.
100
-
101
- **Never:** browser-chrome screenshot mockups tilted in perspective, generic "dashboard glow" renders, or watermark-adjacent placeholder imagery. If a section has no honest image, the strongest move is **no image**: type, space, and surface carry it (same logic as the no-icon rule, [iconography.md](iconography.md) §1).
102
-
103
- ---
104
-
105
- ## 4b. Never an empty frame
106
-
107
- A slot that will one day hold the client's own photo holds a beeldbank image today. There is no placeholder state on a Struq site: no grey box, no tonal frame with a "photo to come" caption, no placeholder component.
108
-
109
- - **Default:** `node <skill-dir>/scripts/images.mjs <pack>` fills every slot from the pack that matches the brief ([struq-imagery.md](struq-imagery.md)). It runs on every build, without asking: the images are free and CC0.
110
- - **struq.nl unreachable:** the same script writes graded fallback plates in the site palette (two planes of light, a horizon hairline, grain) at each slot's ratio, and `lib/struq-images.ts` points at them. They are abstract images, not boxes. Run the script again when the network is back and the photos replace them in place.
111
- - **The client's product, team or projects:** a beeldbank photo never stands in for those. Those sections use type, the WebGL layer or a context image of the client's world (the material, the place), and the gap is listed under Assumptions.
112
- - **Markup:** every image goes through `MediaFrame`, which requires an `image`, so an empty frame cannot be written by accident. `check.mjs` fails on placeholder components, "photo to come" captions and empty `aspect-` boxes.
113
-
114
- ---
115
-
116
- ## 5. Accessibility & weight
117
-
118
- - **Content images:** real `alt` describing the subject. **Decorative/texture images** (ghost imagery, grain, backdrop media): `alt=""` or `aria-hidden`, so "image of background texture" never leaks to screen readers.
119
- - Ship AVIF/WebP with sensible `sizes`; a full-bleed hero stays at or under 250KB at 1920w and a content image at or under 150KB at 1440w, the budgets in [performance.md](performance.md) §1. Never 4MB.
120
- - Ambient/looping video obeys the motion doctrine: muted, `playsinline`, poster, paused under `prefers-reduced-motion`. It also counts as the page's one autonomous mover ([feel-layer.md](feel-layer.md) §5).
121
-
122
- ---
123
-
124
- **Cheap tell:** mixed-grade stock + a tilted browser-chrome screenshot + a purple-glow 3D blob, all at random ratios, alt-less.
125
- **Instead:** One grade constant, one grain layer, 2 to 3 declared ratios, assets real or generated on-brief, treated identically. The imagery reads as *photographed for this site*.
@@ -1,287 +0,0 @@
1
- # First-Load Loaders: session-gated, SSR-safe
2
-
3
- One arm of the page-flow system, the site's *arrival*. A loader is the route transition's cover phase with no swap ([route-transitions.md](route-transitions.md)): it starts closed and clears once assets are ready. Sibling: [page-entrances.md](page-entrances.md) for what the revealed page does next.
4
-
5
- **It is optional.** It is warranted on narrative/portfolio/launch sites and skipped on app/dashboard zones. Keep it short, tie it to real load progress, and play it **once per session**. Never a spinner: the overlay sits on top of finished content and reveals it.
6
-
7
- ---
8
-
9
- ## 0. The lifecycle invariant: why flash-free loaders work
10
-
11
- A first-load loader has **two different state machines**. Never collapse them into one boolean:
12
-
13
- | State | Owner | What it may do |
14
- |---|---|---|
15
- | First paint: play or skip | synchronous script in `<head>` + CSS | Hide SSR loader markup on a revisit before the browser paints |
16
- | Live phase: loading → revealing → done | the mounted loader component | Keep the first-visit curtain mounted until its transform/opacity exit completes |
17
-
18
- The classic regression is using one `data-loader-seen` attribute for both jobs, then running `markLoaderSeen()` when the intro starts or when the exit starts. If CSS contains `html[data-loader-seen] [data-first-loader] { display:none }`, that write removes the live curtain immediately. The reveal timeline technically starts against `display:none`, so the user sees a flash/cut instead of a curtain.
19
-
20
- **Non-negotiable order:**
21
-
22
- 1. SSR emits an already-opaque curtain. Any animated word/chars are also emitted in their hidden start state; do not wait for hydration to apply a GSAP `.from()` state.
23
- 2. A synchronous `<head>` script stamps the skip attribute only for a session revisit or excluded app route. CSS suppresses that SSR overlay before first paint.
24
- 3. On a genuine first visit, await real readiness plus a minimum floor, capped by a safety ceiling. The curtain remains static and opaque while waiting.
25
- 4. Start one compositor-only exit (`transform` and/or `opacity`). Do not read layout or change overflow at release time.
26
- 5. In the exit's `onComplete`, and only there: persist the session marker, stamp the skip attribute, release scroll, and unmount.
27
-
28
- This is why the system stays clean at both ends: the SSR gate prevents revisit flashes, while delayed persistence lets the live curtain finish.
29
-
30
- ---
31
-
32
- ## 1. The session gate (shared by every loader)
33
-
34
- ```ts
35
- // components/ui/loaders/useFirstVisit.ts
36
- const PREFIX = "loader:";
37
- export const LOADER_SEEN_ATTR = "data-loader-seen";
38
-
39
- // Inline this synchronously in <head>, before loader markup. It is the only code
40
- // allowed to activate the CSS skip gate before the first paint.
41
- export const PRE_PAINT_LOADER_SCRIPT = `try{if(sessionStorage.getItem('${PREFIX}intro')==='1')document.documentElement.setAttribute('${LOADER_SEEN_ATTR}','1')}catch(e){}`;
42
-
43
- export function loaderSeen(id?: string): boolean {
44
- if (typeof window === "undefined" || !id) return false;
45
- if (document.documentElement.hasAttribute(LOADER_SEEN_ATTR)) return true;
46
- try { return sessionStorage.getItem(PREFIX + id) === "1"; } catch { return false; }
47
- }
48
- export function markLoaderSeen(id?: string): void {
49
- if (typeof window === "undefined" || !id) return;
50
- // CALL ONLY FROM THE CURTAIN EXIT'S onComplete.
51
- document.documentElement.setAttribute(LOADER_SEEN_ATTR, "1");
52
- try { sessionStorage.setItem(PREFIX + id, "1"); } catch { /* private mode → plays every time */ }
53
- }
54
- ```
55
-
56
- ```css
57
- /* SSR revisit gate. This selector is permanent suppression, not animation state. */
58
- html[data-loader-seen] [data-first-loader] {
59
- display: none !important;
60
- }
61
- ```
62
-
63
- Mount the loader in a persistent root/marketing layout, never inside a page template that remounts on every client navigation. A small same-document module guard may protect Strict Mode/HMR remounts while the first run is active; session persistence still happens only after the exit completes.
64
-
65
- ---
66
-
67
- ## 2. Asset-aware counter (the default loader)
68
-
69
- Tie release to **real** readiness (`document.fonts.ready` + critical image decode/load) and a minimum on-screen floor, raced against a hard ceiling. The floor makes a warm-cache run legible; the ceiling means one stalled request can never pin the site.
70
-
71
- The key lifecycle, not the visual styling, is:
72
-
73
- ```tsx
74
- "use client";
75
- import { useRef, useState } from "react";
76
- import { gsap, useGSAP } from "@/lib/gsap";
77
- import { loaderSeen, markLoaderSeen } from "./useFirstVisit";
78
-
79
- const MIN_HOLD_MS = 1450;
80
- const SAFETY_CEILING_MS = 4000;
81
- const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
82
-
83
- function decodeCriticalHeroImage(src = "/hero-poster.webp"): Promise<void> {
84
- return new Promise((resolve) => {
85
- const image = new Image();
86
- image.src = src;
87
- image.decode().then(resolve).catch(resolve);
88
- });
89
- }
90
-
91
- export function LoaderCounter({ id = "intro" }: { id?: string }) {
92
- const root = useRef<HTMLDivElement>(null);
93
- const [done, setDone] = useState(false);
94
-
95
- useGSAP(() => {
96
- if (loaderSeen(id)) { setDone(true); return; }
97
- const layer = root.current;
98
- if (!layer) return;
99
-
100
- const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
101
- let cancelled = false;
102
- document.documentElement.classList.add("loader-scroll-lock");
103
-
104
- const assetChecks = Promise.all([
105
- (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve(),
106
- decodeCriticalHeroImage(), // resolve on decode OR error
107
- wait(MIN_HOLD_MS),
108
- ]);
109
-
110
- Promise.race([assetChecks, wait(SAFETY_CEILING_MS)]).then(() => {
111
- if (cancelled) return;
112
- layer.dataset.loaderPhase = "revealing";
113
- gsap.to(layer, {
114
- yPercent: reduce ? 0 : -100,
115
- autoAlpha: reduce ? 0 : 1,
116
- duration: reduce ? 0.25 : 0.9,
117
- ease: reduce ? "power2.out" : "power4.inOut",
118
- onComplete: () => {
119
- // Persist LAST. Earlier writes activate the CSS display:none gate and
120
- // cut this live reveal off.
121
- markLoaderSeen(id);
122
- document.documentElement.classList.remove("loader-scroll-lock");
123
- setDone(true);
124
- },
125
- });
126
- });
127
-
128
- return () => {
129
- cancelled = true;
130
- document.documentElement.classList.remove("loader-scroll-lock");
131
- };
132
- }, { scope: root });
133
-
134
- if (done) return null;
135
- return (
136
- <div ref={root} data-first-loader data-loader-phase="loading"
137
- className="fixed inset-0 z-[10001] bg-surface-0">
138
- {/* Encode hidden start states in SSR markup/CSS. If SplitText must create
139
- them after hydration, keep the whole word visibility:hidden until its
140
- chars have been positioned, then reveal the wrapper. */}
141
- <span className="loader-word" style={{ visibility: "hidden" }}>Brand</span>
142
- </div>
143
- );
144
- }
145
- ```
146
-
147
- The readiness callback must only flip state/start a transform. Never run `getBoundingClientRect()`, `ScrollTrigger.refresh()`, image decoding, or synchronous asset work in the release frame.
148
-
149
- ### Flash-free animated start states
150
-
151
- The curtain background itself must be present in SSR HTML. Any content that winds up (masked chars, mark, line, dot) must also start hidden before React runs:
152
-
153
- ```css
154
- .loader-char { transform: translateY(118%); }
155
- .loader-dot { opacity: 0; transform: scale(0); }
156
- ```
157
-
158
- For SplitText, SSR cannot emit the generated char wrappers. Set `visibility:hidden` on the unsplit word in JSX, create the split, position every char, then set the wrapper visible. A GSAP `.from()` without that SSR guard is too late: the browser can paint the completed word for one frame before hydration, producing the initial flash.
159
-
160
- ### Scroll lock without scrollbar shift
161
-
162
- `scrollbar-gutter: stable` alone is not enough if the lock changes `<html>` to `overflow:hidden`; Chromium may collapse the gutter because the root is no longer scrollable. Keep the root scrollable and lock the body/content instead:
163
-
164
- ```css
165
- html { scrollbar-gutter: stable; }
166
- html.loader-scroll-lock {
167
- overflow-y: scroll;
168
- touch-action: none;
169
- overscroll-behavior: none;
170
- }
171
- html.loader-scroll-lock body {
172
- height: 100dvh;
173
- overflow: hidden;
174
- }
175
- ```
176
-
177
- Apply the class while the opaque curtain is present and remove it only after the exit completes. Measure `document.documentElement.clientWidth` before/during/after the lock in browser coverage; all three values must match.
178
-
179
- ---
180
-
181
- ## 3. Loader preset menu (pick one per project)
182
-
183
- All are SSR-safe overlays at `z-[10001]`, reveal on exit, respect reduced-motion, and use `mixBlendMode: "difference"` so the mark reads on any backdrop. Add the `loaderSeen`/`markLoaderSeen` gate (inside `useGSAP`) for any loader you want to play once per session.
184
-
185
- | Preset | Feel | Mechanic | Exit |
186
- |---|---|---|---|
187
- | **Counter** | data / precise | % count tied to real asset load | slide up `yPercent:-100` 0.9s `power4.inOut` |
188
- | **Mark** | minimal / brand | wordmark fades in, holds, layer fades | `opacity→0` 0.6s `power2.inOut` |
189
- | **Shutter** | architectural | grid of tiles holds brand, staggered grid-wipe | tiles `scaleY→0` 0.6s `power4.inOut` (the baseline curtain ease) |
190
- | **Odometer** | mechanical | rolling 0→100 reel | slide up 0.9s `power4.inOut` |
191
- | **WordCycle** | editorial | masked word reel settles on brand | slide up 0.9s `power4.inOut` |
192
- | **ProgressBar** | restrained | hairline fills 0→100% under a wordmark | slide up 0.8s `power4.inOut` |
193
- | **DrawMark** | crafted | SVG mark draws in (DrawSVG, recipe in [svg-motion.md](svg-motion.md)) + wordmark | layer fades 0.6s |
194
-
195
- > **Warning: avoid a scramble / "decode" / terminal-typewriter loader by default.** That monospaced, glitchy-text aesthetic reads as techy filler and collides with the monospace ban ([anti-patterns.md](anti-patterns.md)). Use it only when the brand is genuinely a terminal/dev product.
196
-
197
- Example of the gate wired into a preset (Shutter):
198
-
199
- ```tsx
200
- // components/ui/loaders/LoaderShutter.tsx (abridged)
201
- "use client";
202
- import { useRef, useState } from "react";
203
- import { gsap, useGSAP } from "@/lib/gsap";
204
- import { loaderSeen, markLoaderSeen } from "./useFirstVisit";
205
-
206
- export function LoaderShutter({ id, brand = "Brand", rows = 3, cols = 4 }: {
207
- id?: string; brand?: string; rows?: number; cols?: number;
208
- }) {
209
- const root = useRef<HTMLDivElement>(null);
210
- const [done, setDone] = useState(false);
211
-
212
- useGSAP(() => {
213
- if (loaderSeen(id)) { setDone(true); return; } // revisit → hide before paint
214
- const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
215
- const tiles = gsap.utils.toArray<HTMLElement>(".sh-tile");
216
- const tl = gsap.timeline();
217
- tl.from(".sh-word", { autoAlpha: 0, y: 14, duration: reduce ? 0.2 : 0.5, ease: "power2.out" });
218
- tl.to(".sh-word", { autoAlpha: 0, duration: reduce ? 0.2 : 0.4, ease: "power2.in" }, "+=" + (reduce ? 0.1 : 0.35));
219
- tl.to(tiles, {
220
- scaleY: 0, transformOrigin: "top", duration: reduce ? 0.3 : 0.6, ease: "power4.inOut", // curtains: quint-in-out
221
- stagger: { each: reduce ? 0 : 0.04, grid: [rows, cols], from: "start" },
222
- onComplete: () => {
223
- markLoaderSeen(id); // persist only after the last tile has cleared
224
- setDone(true);
225
- },
226
- }, "-=0.1");
227
- }, { scope: root });
228
-
229
- if (done) return null;
230
- return (
231
- <div ref={root} className="fixed inset-0 z-[10001]">
232
- <div className="absolute inset-0 grid"
233
- style={{ gridTemplateColumns: `repeat(${cols},1fr)`, gridTemplateRows: `repeat(${rows},1fr)` }}>
234
- {Array.from({ length: rows * cols }).map((_, i) => (
235
- <div key={i} className="sh-tile bg-surface-0 will-change-transform" />
236
- ))}
237
- </div>
238
- <div className="sh-word pointer-events-none absolute inset-0 grid place-items-center font-display text-h2 text-ink"
239
- style={{ mixBlendMode: "difference" }}>{brand}</div>
240
- </div>
241
- );
242
- }
243
- ```
244
-
245
- **Why SSR-safe matters:** the overlay sits *on top of* the rendered HTML and reveals it. The page is fully in the SSR markup underneath, so there's no flash of unstyled content, no hydration mismatch, no SEO cost, and if JS fails the content is still there.
246
-
247
- Loaders mount inside `<body>`, above `children`. The root-layout wiring is in [route-transitions.md](route-transitions.md) §2.
248
-
249
- ---
250
-
251
- ## Timing reference
252
-
253
- | Phase | Duration | Ease | Transform |
254
- |---|---|---|---|
255
- | Loader exit (slide) | 0.8-0.9s | `power4.inOut` | `yPercent 0→-100` |
256
- | Loader exit (fade) | 0.6s | `power2.inOut` | opacity |
257
- | **Reduced motion** | 0.2-0.3s | none | opacity only |
258
-
259
- ---
260
-
261
- ## Anti-patterns (sweep before shipping)
262
-
263
- - **Spinner / loading gif** while content pops in → an asset-aware overlay that reveals finished content.
264
- - **Loader on every navigation** (stacked on the route wipe) → session-gate with `id`; loader is first-visit-only.
265
- - **FOUC / content reflowing in** as fonts/images land → SSR-safe overlay + explicit media aspect ratios; entrances double as load choreography.
266
- - **Completed wordmark flashes, then restarts** → its start state only exists in a hydration-time `.from()` tween; encode it in SSR CSS/inline visibility first.
267
- - **Curtain disappears instead of lifting** → the session `seen` attribute activated `display:none` before exit completion; persist only in `onComplete`.
268
- - **Page jumps horizontally at lock/unlock** → `overflow:hidden` collapsed the root scrollbar gutter; keep `<html>` at `overflow-y:scroll` and lock body/content.
269
- - **Warm loads blink / slow loads hang** → readiness has neither a minimum floor nor a hard safety ceiling; use both in one shared promise.
270
- - **Scramble/terminal-decode loader** as a default aesthetic → use a typographic preset instead (monospace ban).
271
-
272
- ## When to use a loader
273
-
274
- - **Use** on narrative, portfolio, campaign, and launch sites, and only **once per session** (`id`). Keep it under ~1.5s of real work.
275
- - **Skip** on app/dashboard zones and utility pages. There, the route wipe alone (or even just the fast app entrance, [page-entrances.md](page-entrances.md)) is enough; a theatrical intro on a tool feels wrong.
276
- - Reduced motion: every preset branches to a 0.2-0.3s fade, no travel.
277
-
278
- ## Regression coverage: required for loader work
279
-
280
- At minimum, automate these four checks in a real browser:
281
-
282
- 1. First visit: loader is attached and visible; the persistent `seen` attribute is absent while it plays.
283
- 2. Reveal: loader enters an explicit `revealing` phase, remains `display != none`, and its transform moves off-screen before unmount.
284
- 3. Same-session revisit: the pre-paint attribute exists and no frame ever shows the SSR loader layer.
285
- 4. Scroll lock: root `clientWidth` is identical before, during, and after lock; the lock class is removed when the loader finishes.
286
-
287
- Also inspect raw SSR HTML: the `<head>` pre-paint script must occur before loader markup, and animated content must carry its hidden first-paint state.