@struq/skill 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -36
- package/package.json +3 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
# Aesthetic Architecture: Tokens & Vibe Presets
|
|
2
|
-
|
|
3
|
-
The skeleton (composition, motion techniques, type *scale*) stays constant; the vibe lives entirely in a token layer. Components only ever reference **semantic** utilities (`bg-surface-1`, `text-ink-muted`, `ease-out-expo`, `font-display`), never raw colors, faces, or curves, so swapping one CSS block + the font imports re-skins the whole build.
|
|
4
|
-
|
|
5
|
-
**Typography is the most important lever in this skill.** The face pairing decides more of a vibe than the palette does. Every preset below names its pairing (one display face and one text face, or one family in both roles), all free Google Fonts drawn from the **Struq Signature** set in [typography.md](typography.md) §3.1, and the display face runs **light (300 to 400)**. None of them uses Inter, Space Grotesk or another starter default; when a brand has no fonts of its own, swap within Signature, never back to a default. For pairing alternatives, the fluid scale, tracking and leading per tier, and SplitText typography, see [typography.md](typography.md).
|
|
6
|
-
|
|
7
|
-
## The token contract
|
|
8
|
-
|
|
9
|
-
Every preset defines exactly this surface area. Components may use nothing outside it (plus neutral utilities like grid/flex/spacing):
|
|
10
|
-
|
|
11
|
-
| Token | Role |
|
|
12
|
-
|---|---|
|
|
13
|
-
| `--color-surface-0…3` | the elevation ladder; 0 = page, each step = one layer closer |
|
|
14
|
-
| `--color-ink / -muted` | the two text steps: ink for everything that must be read, muted for meta and captions. No gray-400/500/600 improvisation |
|
|
15
|
-
| `--color-ink-faint` | **never text.** Hairline emphasis, disabled states, placeholder rules, inactive dots. As text it fails contrast |
|
|
16
|
-
| (inverted surfaces) | On an `ink` panel (the menu, an inverted footer) the ladder flips: `surface-0` is the text, `surface-2` the muted tier (meta, captions), `surface-3` hairlines only. `ink-muted` on `ink` fails contrast. Check surface-2 on ink at 4.5:1 per preset; no new tokens needed |
|
|
17
|
-
| `--color-accent` | the one accent |
|
|
18
|
-
| `--color-line` | hairline borders/dividers (a low-alpha ink, not a gray) |
|
|
19
|
-
| `--font-display / --font-body` | the pairing (one display + one text face, max; one family may fill both) |
|
|
20
|
-
| `--font-h3 / --font-meta / --font-ui / --font-stat` | role faces, mapped in `@theme inline` per the pairing's row in [typography.md](typography.md) §3.1 (defaults = S1) |
|
|
21
|
-
| `--font-mono` | **optional, code only**: literal snippets, `code`, terminal output. Not a preset face. Never for labels, stats, or body (that's the terminal-aesthetic tell, see [anti-patterns.md](anti-patterns.md) §17) |
|
|
22
|
-
| `--radius-base` | corner philosophy in one number |
|
|
23
|
-
| `--ease-out-expo / --ease-out-quart / --ease-in-out-quint` | the motion vocabulary ([transitions.md](transitions.md)) |
|
|
24
|
-
|
|
25
|
-
Plus two values that live in JS (per-preset constants, since Lenis/GSAP don't read CSS): **Lenis lerp** and the **GSAP ease pair** ([feel-layer.md](feel-layer.md)). The Lenis default is **0.1**; a preset moves it only for a reason it can name (Luxury's syrup, Dark Cinematic's weight), and every preset states its value.
|
|
26
|
-
|
|
27
|
-
**Surface direction (light vs dark: decide it, don't improvise it):** dark ladders step **lighter** per layer (closer = brighter; Dark Cinematic is the reference). Light ladders pick one of two directions and hold it: the page is the lightest layer and panels step subtly **deeper** (Soft Modern's mineral greys, Warm Editorial's cream → `#DFD3BF`, Luxury Minimal's greige), or the page is toned and panels **lift to white** (the Brutalist appendix's `#FFFFFF` surface-1 on `#EFEFEA`). Either way, separation in light mode leans harder on `--color-line` hairlines (a low-alpha *ink*, never gray-200) because light tone steps are subtler than dark ones, and shadows stay banned as an elevation system in both modes.
|
|
28
|
-
|
|
29
|
-
**Typography lock (applies across all presets):** hero/display weight runs **light (300 to 400)** unless a declared genre calls for heavy (the Brutalist appendix is the one intentional exception); body ≥1rem, nothing below 12px; and every preset's `ink-muted` must clear **WCAG AA (≥4.5:1)** on every surface it is used on. Where it does not (noted per preset), muted text stays off that surface. `ink-faint` is never text. Presets choose the *faces and weights*; the floor and contrast rules sit on top, non-negotiable. ([anti-patterns.md](anti-patterns.md) §12)
|
|
30
|
-
|
|
31
|
-
**Heroes do not converge.** Each preset names its hero entrance. Two Struq sites with different presets must not open the same way; the masked line rise is a technique, not a default ([typography.md](typography.md) §5).
|
|
32
|
-
|
|
33
|
-
## Wiring (Tailwind `@theme` + next/font)
|
|
34
|
-
|
|
35
|
-
```css
|
|
36
|
-
/* app/globals.css */
|
|
37
|
-
@import "tailwindcss";
|
|
38
|
-
@import "./theme.css"; /* ← the preset. Swap this file to change vibe. */
|
|
39
|
-
|
|
40
|
-
::selection { background: var(--color-accent); color: var(--color-surface-0); }
|
|
41
|
-
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; }
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
```tsx
|
|
45
|
-
// app/fonts.ts: Soft Modern (Signature S1, the token defaults). Same code as typography.md §4.
|
|
46
|
-
import { Urbanist } from "next/font/google";
|
|
47
|
-
|
|
48
|
-
const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-body-face", display: "swap" });
|
|
49
|
-
export const display = urbanist; // one family, both roles
|
|
50
|
-
export const body = urbanist;
|
|
51
|
-
|
|
52
|
-
// Other presets REPLACE the block above (display var --font-display-face, body var --font-body-face):
|
|
53
|
-
// Warm Editorial: Fraunces({ axes: ["opsz", "SOFT"], ... }) + Urbanist({ ... }) (S2)
|
|
54
|
-
// Dark Cinematic: Newsreader({ axes: ["opsz"], ... }) + Manrope({ ... }) (S5)
|
|
55
|
-
// Luxury Minimal: Cormorant_Garamond({ weight: ["300", "400"], ... }) + Jost({ ... }),
|
|
56
|
-
// plus Cormorant_Garamond({ weight: "300", style: "italic", preload: false, ... }) (S4)
|
|
57
|
-
// Brutalist appendix: Archivo_Black({ weight: "400", ... }) + Archivo({ ... })
|
|
58
|
-
|
|
59
|
-
// app/layout.tsx
|
|
60
|
-
// <html className={body.variable}> (two-face presets: `${display.variable} ${body.variable}`)
|
|
61
|
-
// <body className="bg-surface-0 font-body text-ink antialiased">
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
The theme maps semantic tokens onto those vars with `@theme inline` (required when a token references another variable). Utilities generated automatically: `bg-surface-2`, `text-ink-muted`, `border-line`, `font-display`, `font-ui`, `rounded-base`, `ease-out-expo`.
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## Preset 1: Soft Modern
|
|
69
|
-
|
|
70
|
-
Calm, cool and exact: mineral greys, one family, a deep green accent. For product studios, SaaS that wants to look considered, architecture and wellness brands that should feel quiet rather than warm. The token defaults in [typography.md](typography.md) §2.4 are this preset's type.
|
|
71
|
-
|
|
72
|
-
```css
|
|
73
|
-
/* theme.css: SOFT MODERN */
|
|
74
|
-
@theme {
|
|
75
|
-
--color-surface-0: #F3F4F1;
|
|
76
|
-
--color-surface-1: #ECEEEA;
|
|
77
|
-
--color-surface-2: #E3E6E1;
|
|
78
|
-
--color-surface-3: #D8DCD5;
|
|
79
|
-
|
|
80
|
-
--color-ink: #15171A;
|
|
81
|
-
--color-ink-muted: #5C6168;
|
|
82
|
-
--color-ink-faint: #9AA0A6; /* non-text only: hairlines, disabled, placeholder rules */
|
|
83
|
-
--color-accent: #1F5A4A; /* deep mineral green */
|
|
84
|
-
--color-line: rgb(21 23 26 / 0.1);
|
|
85
|
-
|
|
86
|
-
--radius-base: 6px;
|
|
87
|
-
|
|
88
|
-
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
89
|
-
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
90
|
-
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
91
|
-
}
|
|
92
|
-
@theme inline {
|
|
93
|
-
--font-display: var(--font-body-face), system-ui, sans-serif; /* Urbanist */
|
|
94
|
-
--font-body: var(--font-body-face), system-ui, sans-serif; /* Urbanist */
|
|
95
|
-
/* role faces: the S1 defaults from typography.md §2.4, nothing to override */
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
| Axis | Spec |
|
|
100
|
-
|---|---|
|
|
101
|
-
| Fonts | **Urbanist** in both roles, Signature S1: display 300, text 400 at 1.0625rem. Accent word by weight (300 to 500) or the accent colour, never italic. Role values in [typography.md](typography.md) §3.1 |
|
|
102
|
-
| Spacing bias | even and generous: sections 8-12rem, lead 44ch, body 62ch, grids with real gutters |
|
|
103
|
-
| Border/shadow | hairlines at 10% ink, 6px radius on media and inputs; no shadows |
|
|
104
|
-
| Motion personality | *soft-precise*: `expo.out` 0.9 to 1.2s entrances, stagger 0.08; Lenis lerp **0.1**; scrub 1 |
|
|
105
|
-
| Hero entrance | **weight-in** ([typography.md](typography.md) §5): the h1's weight rises from 100 to 300 with a short lift, once |
|
|
106
|
-
| Signature move | weight-in |
|
|
107
|
-
| Contrast note | `ink-muted` on `surface-3` is 4.49:1, a hair under AA: keep muted text off surface-3 |
|
|
108
|
-
| Distinctive moves | index lists with the 300 to 450 weight hover, one accent-green word per section at most, quiet image frames at the 6px radius |
|
|
109
|
-
|
|
110
|
-
---
|
|
111
|
-
|
|
112
|
-
## Preset 2: Warm Editorial
|
|
113
|
-
|
|
114
|
-
Magazine confidence: cream paper, warm ink, a serif with personality. For studios, writing-led brands, agencies, food/culture.
|
|
115
|
-
|
|
116
|
-
```css
|
|
117
|
-
/* theme.css: WARM EDITORIAL */
|
|
118
|
-
@theme {
|
|
119
|
-
--color-surface-0: #FAF6F0;
|
|
120
|
-
--color-surface-1: #F3EDE2;
|
|
121
|
-
--color-surface-2: #EAE1D2;
|
|
122
|
-
--color-surface-3: #DFD3BF;
|
|
123
|
-
|
|
124
|
-
--color-ink: #1E1A16;
|
|
125
|
-
--color-ink-muted: #5E554C; /* clears 4.5:1 on every surface, surface-3 included */
|
|
126
|
-
--color-ink-faint: #A59C8F; /* non-text only */
|
|
127
|
-
--color-accent: #7A2230; /* oxblood: links, one numeral, selection */
|
|
128
|
-
--color-line: rgb(30 26 22 / 0.14);
|
|
129
|
-
|
|
130
|
-
--radius-base: 2px; /* near-sharp; print doesn't round corners */
|
|
131
|
-
|
|
132
|
-
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
133
|
-
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
134
|
-
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
135
|
-
}
|
|
136
|
-
@theme inline {
|
|
137
|
-
--font-display: var(--font-display-face), Georgia, serif; /* Fraunces */
|
|
138
|
-
--font-body: var(--font-body-face), system-ui, sans-serif; /* Urbanist */
|
|
139
|
-
}
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
| Axis | Spec |
|
|
143
|
-
|---|---|
|
|
144
|
-
| Fonts | **Fraunces** (display, `opsz` axis: bookish at h3 size, razor at the hero; `SOFT` around 50 for warmth) + **Urbanist** (text, 400 at 1.0625rem). Signature S2. Display weight 300-400, not bold: the serif's shapes do the work. Accent word in Fraunces italic. Role values in [typography.md](typography.md) §3.1 |
|
|
145
|
-
| Spacing bias | generous but bounded: sections 8-14rem; text measure lead 44ch, body 62ch; dense hairline-divided lists ([anti-patterns.md](anti-patterns.md) §2 layout is native here) |
|
|
146
|
-
| Border/shadow | hairlines everywhere, zero shadows; `divide-y divide-line` is the signature texture |
|
|
147
|
-
| Motion personality | *editorial-calm*: `power3.out` entrances 0.7 to 0.9s, stagger 0.1; Lenis lerp **0.1**; scrub 1; no springs anywhere. **Declared deviation:** the baseline entrance ease is `expo.out`; this preset swaps it for the gentler `power3.out` inside the reveal band. A site on this preset records it in STRUQ.md under `Motion: deviations: entrances power3.out 0.7 to 0.9s (Warm Editorial's editorial-calm)` ([direction.md](direction.md) §4) |
|
|
148
|
-
| Hero entrance | **soft-scrub plus line rise**: the h1 rises line by line on load, then softens (`SOFT` 0 to 100) as the reader scrolls away ([typography.md](typography.md) §5). The rise is allowed here because the scrub is the signature: the rise delivers it, it is not the hero's only gesture. The h1 rests at `SOFT 0`; other display lines pin 50 |
|
|
149
|
-
| Signature move | soft-scrub |
|
|
150
|
-
| Distinctive moves | drop caps, a single oversized serif numeral in accent (as a one-off flourish, never a per-section counter), italic opsz pull-quotes at `wide` bleed with oldstyle figures, image frames with 2px radius |
|
|
151
|
-
|
|
152
|
-
The oxblood is chosen against the cream-plus-terracotta reflex ([typography.md](typography.md) §7): if a brand genuinely owns a terracotta, use it, but never as the default warm accent.
|
|
153
|
-
|
|
154
|
-
---
|
|
155
|
-
|
|
156
|
-
## Preset 3: Dark Cinematic
|
|
157
|
-
|
|
158
|
-
Film-title gravity: a warm near-black tonal ladder, soft light, one signal accent. For creative tech, film/music, launches, portfolio showpieces.
|
|
159
|
-
|
|
160
|
-
```css
|
|
161
|
-
/* theme.css: DARK CINEMATIC */
|
|
162
|
-
@theme {
|
|
163
|
-
--color-surface-0: #0C0B0A;
|
|
164
|
-
--color-surface-1: #131210;
|
|
165
|
-
--color-surface-2: #1B1A17;
|
|
166
|
-
--color-surface-3: #252320;
|
|
167
|
-
|
|
168
|
-
--color-ink: #EFEBE4;
|
|
169
|
-
--color-ink-muted: #A39D94;
|
|
170
|
-
--color-ink-faint: #5E5A54; /* non-text only */
|
|
171
|
-
--color-accent: #FF5C28; /* signal orange: sparingly, it burns */
|
|
172
|
-
--color-line: rgb(239 235 228 / 0.08);
|
|
173
|
-
|
|
174
|
-
--radius-base: 4px;
|
|
175
|
-
|
|
176
|
-
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
177
|
-
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
178
|
-
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
179
|
-
}
|
|
180
|
-
@theme inline {
|
|
181
|
-
--font-display: var(--font-display-face), Georgia, serif; /* Newsreader */
|
|
182
|
-
--font-body: var(--font-body-face), system-ui, sans-serif; /* Manrope */
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
| Axis | Spec |
|
|
187
|
-
|---|---|
|
|
188
|
-
| Fonts | **Newsreader** display (`opsz` automatic, 300 at display and h2, its larger display clamp; tracking `-0.02em`, leading 0.94) + **Manrope** (text, 400 at 1rem). Signature S5. Accent word in Newsreader italic. Meta/labels are **tracked uppercase Manrope** (0.14em) in `ink-muted`, not mono: the cinematic register comes from scale, darkness, and spacing, never a terminal font (mono is code-only, see [anti-patterns.md](anti-patterns.md) §17) |
|
|
189
|
-
| Spacing bias | extreme: heroes 16-20rem, full-viewport breathers with a single line of type; darkness needs emptiness to read as cinema |
|
|
190
|
-
| Border/shadow | tonal ladder does *all* elevation (this is the preset the surface system was born for); hairlines at 8% warm white; glow only as `accent/20` blur behind one hero element, max |
|
|
191
|
-
| Motion personality | *cinematic-heavy*: `expo.out` 1.0 to 1.4s entrances, `quint-in-out` curtains; Lenis lerp **0.08**; scrub 1 to 1.5; departure scrubs and curtains are core, nothing bounces |
|
|
192
|
-
| Hero entrance | **image-to-type match cut**: the hero opens on a full-bleed image for about a second, then cuts on a shared shape (a horizon, an edge, a bright band) to the h1 sitting exactly where that shape was. A hard cut or a 0.3s crossfade, never a slow dissolve; the type itself does not rise. Recipe: `match-cut-hero` in [page-entrances.md](page-entrances.md) |
|
|
193
|
-
| Signature move | type-mask-media or a cropped display |
|
|
194
|
-
| Distinctive moves | ghost type at `text-ink/[0.05]` in muted solid (never outlined), scrubbed veils, tracked-uppercase label wayfinding, the footer-reveal ([composition.md](composition.md) §5) |
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
## Preset 4: Luxury Minimal
|
|
199
|
-
|
|
200
|
-
Almost nothing, perfectly placed: warm greige, hairline serif, bronze accent, slow air. For premium product, architecture, jewelry, hospitality.
|
|
201
|
-
|
|
202
|
-
```css
|
|
203
|
-
/* theme.css: LUXURY MINIMAL */
|
|
204
|
-
@theme {
|
|
205
|
-
--color-surface-0: #F6F5F2;
|
|
206
|
-
--color-surface-1: #EFEDE9;
|
|
207
|
-
--color-surface-2: #E6E3DD;
|
|
208
|
-
--color-surface-3: #DBD6CE;
|
|
209
|
-
|
|
210
|
-
--color-ink: #191919;
|
|
211
|
-
--color-ink-muted: #6E6A63;
|
|
212
|
-
--color-ink-faint: #ACA69D; /* non-text only */
|
|
213
|
-
--color-accent: #76603F; /* bronze: borderline neutral; luxury whispers */
|
|
214
|
-
--color-line: rgb(25 25 25 / 0.1);
|
|
215
|
-
|
|
216
|
-
--radius-base: 0px;
|
|
217
|
-
|
|
218
|
-
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
219
|
-
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
220
|
-
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
221
|
-
}
|
|
222
|
-
@theme inline {
|
|
223
|
-
--font-display: var(--font-display-face), Didot, Georgia, serif; /* Cormorant Garamond 300-400 */
|
|
224
|
-
--font-body: var(--font-body-face), system-ui, sans-serif; /* Jost */
|
|
225
|
-
}
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
| Axis | Spec |
|
|
229
|
-
|---|---|
|
|
230
|
-
| Fonts | **Cormorant Garamond** light display, weight 300-400, at its larger display clamp with leading 0.92 (large sizes only, because hairline serifs die small); its italic 300 is loaded as the accent word + **Jost** (text, 400 at 1.0625rem, leading 1.7; Signature S4); labels at the 12px floor, Jost 500 uppercase at 0.18em. Cormorant never sets running text below 1.125rem |
|
|
231
|
-
| Spacing bias | maximal: sections 16-24rem, one idea per viewport, measures ≤ `40ch`; the emptiness *is* the brand |
|
|
232
|
-
| Border/shadow | almost none: hairlines only at content boundaries (header rule, footer rule); imagery sits frameless on the surface; no cards, ever |
|
|
233
|
-
| Motion personality | *heavy-luxurious*: `expo.out` at 1.2 to 1.6s, stagger 0.12 to 0.15; Lenis lerp **0.06 to 0.07** (the syrup is on-brand here and nowhere else); window-parallax on every image at gentle ±6%; absolutely no springs, no marquees |
|
|
234
|
-
| Hero entrance | **1.6s single-line clip**: one line of display type, revealed by a single slow `clip-path` wipe, no split, no stagger. Recipe: `clip-line-hero` in [page-entrances.md](page-entrances.md) |
|
|
235
|
-
| Signature move | a cropped display or fit-width-wordmark |
|
|
236
|
-
| Contrast note | `ink-muted` on `surface-3` is 3.7:1: muted text never sits on surface-3 |
|
|
237
|
-
| Distinctive moves | single centered serif lines in vast space, image + one caption line per viewport, accent used only for ~2 words per page, slow crossfade galleries |
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## Using and extending
|
|
242
|
-
|
|
243
|
-
1. **Swap = `theme.css` + font imports + 2 JS constants** (lerp, ease pair). If a vibe swap requires touching a component, that component broke the token contract. Fix the component.
|
|
244
|
-
2. **Deriving a new preset:** answer the seven axes in order: temperature (warm/cool/stark) → light or dark ladder (and its direction, per the surface-direction rule above) → display face genre (grotesk / serif / slab; mono *only* for a genuine dev-tool/terminal brand, otherwise banned per [anti-patterns.md](anti-patterns.md) §17), picked from the Signature set in [typography.md](typography.md) §3.1 first, Extended only when Signature has no match → accent voltage (whisper like bronze ↔ scream like electric blue) → corner + border philosophy → spacing compression ↔ extravagance → motion weight (lerp + ease pair + duration tier from [feel-layer.md](feel-layer.md)). Then name the hero entrance and the signature move, different from the presets above. Seven decisions = a coherent vibe; skipping one is where incoherence enters.
|
|
245
|
-
**Second accent:** allowed only as a deliberate genre choice (playful / consumer / kids brands where two voices are the brand). Declare it as `--color-accent-2` in the token layer, give each accent the same ≤1-per-viewport budget, and never let it appear because "the page needed more color." That's the same shape as the display-weight exception: genre choice, never reflex.
|
|
246
|
-
3. **Sanity rule:** any preset must still pass the baseline non-negotiables ([baseline-prompt.md](baseline-prompt.md)). Presets change the *answers*, never the *questions*.
|
|
247
|
-
|
|
248
|
-
---
|
|
249
|
-
|
|
250
|
-
## Genre appendix: Brutalist
|
|
251
|
-
|
|
252
|
-
Not a default and not offered unless the brief declares the genre. Honest structure as decoration: paper white, solid 2px rules, electric accent, type as architecture. For experimental portfolios, fashion, events, dev-culture brands. This is the one deliberate heavy-display genre in the skill.
|
|
253
|
-
|
|
254
|
-
```css
|
|
255
|
-
/* theme.css: BRUTALIST (genre appendix) */
|
|
256
|
-
@theme {
|
|
257
|
-
--color-surface-0: #EFEFEA;
|
|
258
|
-
--color-surface-1: #FFFFFF;
|
|
259
|
-
--color-surface-2: #E2E2DA;
|
|
260
|
-
--color-surface-3: #111111; /* inverted panels are a surface, not a "dark mode" */
|
|
261
|
-
|
|
262
|
-
--color-ink: #111111;
|
|
263
|
-
--color-ink-muted: #3D3D3D;
|
|
264
|
-
--color-ink-faint: #8A8A85; /* non-text only */
|
|
265
|
-
--color-accent: #1400FF; /* electric blue, used flat and loud */
|
|
266
|
-
--color-line: #111111; /* borders are INK: 1-2px solid, no alpha */
|
|
267
|
-
|
|
268
|
-
--radius-base: 0px;
|
|
269
|
-
|
|
270
|
-
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
|
271
|
-
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
|
272
|
-
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
|
|
273
|
-
}
|
|
274
|
-
@theme inline {
|
|
275
|
-
--font-display: var(--font-display-face), Impact, sans-serif; /* Archivo Black */
|
|
276
|
-
--font-body: var(--font-body-face), system-ui, sans-serif; /* Archivo */
|
|
277
|
-
}
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
| Axis | Spec |
|
|
281
|
-
|---|---|
|
|
282
|
-
| Fonts | **Archivo Black** display (uppercase, `tracking-[-0.01em]`, sizes to 14vw, cropped off-canvas) + **Archivo** (text); the genre exception in [typography.md](typography.md) §3.3. Tables and specs use Archivo with `tabular-nums`, not a mono face |
|
|
283
|
-
| Spacing bias | compressed and gridded: sections 4-8rem, content pushed to edges, visible structure; whitespace appears as sudden full-viewport voids, not gentle padding |
|
|
284
|
-
| Border/shadow | `border-2 border-line` on everything that's a thing; tables with full gridlines; the one allowed shadow is the hard offset `shadow-[6px_6px_0_#111]`; cells invert to `surface-3` + white ink on hover |
|
|
285
|
-
| Motion personality | *kinetic-abrupt*: `power4.out` at 0.2 to 0.45s, stagger 0.04; instant hover inversions (`duration-100` or none); Lenis lerp **0.14 or omit Lenis entirely** (native scroll is on-genre); marquees and slot-machine text swaps ARE on-genre here |
|
|
286
|
-
| Hero entrance | **hard cut**: the type wall is there at first paint; no reveal at all is the on-genre entrance |
|
|
287
|
-
| Distinctive moves | exposed grid lines, index/table layouts as hero content, oversized arrows `→`, counters (`01/12`), full-bleed type walls |
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
# WebGL / R3F: the integration recipe (per-project add-on)
|
|
2
|
-
|
|
3
|
-
Three.js is NOT pre-installed in this stack. It is ~150kB+ of JS that most
|
|
4
|
-
sites don't need, so it is added per project, only when a section genuinely
|
|
5
|
-
calls for it (shader hero backdrop, distorted-image hover, particle field,
|
|
6
|
-
product 3D). This file is self-contained: the *wiring* recipe for how a
|
|
7
|
-
`<Canvas>` island coexists with the Lenis + GSAP single-RAF setup without
|
|
8
|
-
fighting it, plus a minimal scene pattern (§3b) that completes the island.
|
|
9
|
-
|
|
10
|
-
This integration recipe is the foundation for everything 3D in Struq. Build on
|
|
11
|
-
it, don't replace it. [webgl-scenes.md](webgl-scenes.md) covers what goes
|
|
12
|
-
inside the canvas: Drei helpers, postprocessing, interactive pointer-driven
|
|
13
|
-
scenes, and performance tiers. For multi-chapter directed experiences where
|
|
14
|
-
the whole page behaves as one continuous 3D film, see
|
|
15
|
-
[../cinematic/README.md](../cinematic/README.md).
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm i three @react-three/fiber @react-three/drei
|
|
19
|
-
npm i -D @types/three
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Budget rule: **one canvas per page.** Multiple WebGL contexts multiply GPU
|
|
23
|
-
memory and lose hardware acceleration arbitration. If two sections need 3D,
|
|
24
|
-
render both scenes through one canvas (drei `<View>`) or cut one.
|
|
25
|
-
|
|
26
|
-
---
|
|
27
|
-
|
|
28
|
-
## 1. The canonical island (lazy, SSR-free, poster-fallback)
|
|
29
|
-
|
|
30
|
-
WebGL must never be in the initial JS bundle or the SSR tree. The island is a
|
|
31
|
-
client component loaded with `next/dynamic` + `ssr: false`, sitting on top of a
|
|
32
|
-
poster so the page is complete without it. Two rules carry the whole pattern:
|
|
33
|
-
|
|
34
|
-
- **The parent section always renders the poster**, server-side, as a real
|
|
35
|
-
`next/image` (`fill`, `preload`). It is the LCP candidate and the permanent
|
|
36
|
-
fallback, not a loading state.
|
|
37
|
-
- **The parent decides whether WebGL loads at all.** The gate (reduced motion,
|
|
38
|
-
coarse pointer) runs in the parent *before* the dynamic import is rendered,
|
|
39
|
-
so gated users never download three.js. A gate inside the lazy chunk is too
|
|
40
|
-
late: by then the chunk is already on the wire.
|
|
41
|
-
|
|
42
|
-
```tsx
|
|
43
|
-
// components/sections/HeroScene.tsx: the SECTION owns the poster, the gate and the lazy boundary
|
|
44
|
-
"use client";
|
|
45
|
-
|
|
46
|
-
import dynamic from "next/dynamic";
|
|
47
|
-
import Image from "next/image";
|
|
48
|
-
import { useEffect, useState } from "react";
|
|
49
|
-
|
|
50
|
-
// The canvas chunk is requested only when <SceneCanvas /> is rendered, which
|
|
51
|
-
// happens only after the gate passes. No loading UI: the poster is already there.
|
|
52
|
-
const SceneCanvas = dynamic(() => import("./SceneCanvas"), { ssr: false });
|
|
53
|
-
|
|
54
|
-
// ONE gate string. If this section also pins or scrubs, its gsap.matchMedia uses the same
|
|
55
|
-
// constant, so the canvas and the pin can never disagree (webgl-scenes.md §4, product-rig).
|
|
56
|
-
// Policy per project: skip touch entirely (default, below), or allow it with a lower dpr.
|
|
57
|
-
export const GATE = "(prefers-reduced-motion: no-preference) and (pointer: fine)";
|
|
58
|
-
|
|
59
|
-
export function HeroScene() {
|
|
60
|
-
const [webgl, setWebgl] = useState(false);
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
const mq = window.matchMedia(GATE);
|
|
64
|
-
const sync = () => setWebgl(mq.matches);
|
|
65
|
-
sync();
|
|
66
|
-
mq.addEventListener("change", sync);
|
|
67
|
-
return () => mq.removeEventListener("change", sync);
|
|
68
|
-
}, []);
|
|
69
|
-
|
|
70
|
-
return (
|
|
71
|
-
<section className="relative min-h-[100svh] overflow-hidden">
|
|
72
|
-
{/* Always rendered: the design without WebGL, and the LCP image with it.
|
|
73
|
-
`preload` or `priority`, whichever the installed next/image declares (setup.md §0). */}
|
|
74
|
-
<Image src="/media/hero-poster.webp" alt="" fill preload sizes="100vw"
|
|
75
|
-
className="object-cover" />
|
|
76
|
-
{webgl && <SceneCanvas />}
|
|
77
|
-
<div className="relative z-10 section-shell">{/* content */}</div>
|
|
78
|
-
</section>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
The lazy chunk is now only the canvas; it never re-checks the gate:
|
|
84
|
-
|
|
85
|
-
```tsx
|
|
86
|
-
// components/sections/SceneCanvas.tsx
|
|
87
|
-
"use client";
|
|
88
|
-
|
|
89
|
-
import { Canvas } from "@react-three/fiber";
|
|
90
|
-
|
|
91
|
-
export default function SceneCanvas() {
|
|
92
|
-
return (
|
|
93
|
-
<Canvas
|
|
94
|
-
aria-hidden // decoration: the real copy lives in the DOM
|
|
95
|
-
className="absolute inset-0"
|
|
96
|
-
frameloop="never" // driven by the GSAP ticker, §2
|
|
97
|
-
dpr={[1, 1.75]} // clamp: retina tax caps at 1.75
|
|
98
|
-
gl={{ antialias: false, powerPreference: "high-performance", alpha: true }}
|
|
99
|
-
camera={{ position: [0, 0, 5], fov: 45 }}
|
|
100
|
-
>
|
|
101
|
-
{/* scene content, minimal pattern in §3b */}
|
|
102
|
-
</Canvas>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
- `dpr={[1, 1.75]}`: full devicePixelRatio on a 3× phone triples the pixel
|
|
108
|
-
budget for zero perceived gain. On mobile, drop to `[1, 1.5]`.
|
|
109
|
-
- `antialias: false` + post-process or MSAA-off is fine for soft/abstract
|
|
110
|
-
scenes; turn it on only for hard-edged geometry.
|
|
111
|
-
- R3F handles resize observation itself, so do NOT wire your own resize → ticker
|
|
112
|
-
loops.
|
|
113
|
-
|
|
114
|
-
---
|
|
115
|
-
|
|
116
|
-
## 2. One clock: drive R3F from the GSAP ticker
|
|
117
|
-
|
|
118
|
-
The whole baseline runs on a single RAF (Lenis is driven by `gsap.ticker`). An
|
|
119
|
-
R3F canvas with default `frameloop="always"` starts a SECOND RAF; the two
|
|
120
|
-
sample on different frames and scroll-tied scenes micro-stutter. It is the same
|
|
121
|
-
disease as Lenis + GSAP unsynced, in a new vector. `frameloop="never"` + a
|
|
122
|
-
ticker bridge restores one clock:
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
// Inside the Canvas tree:
|
|
126
|
-
import { useThree } from "@react-three/fiber";
|
|
127
|
-
import { useEffect } from "react";
|
|
128
|
-
import { gsap } from "@/lib/gsap";
|
|
129
|
-
|
|
130
|
-
function GsapTickerDriver() {
|
|
131
|
-
const advance = useThree((s) => s.advance);
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
const tick = (time: number) => advance(time); // gsap gives seconds; consistent
|
|
134
|
-
gsap.ticker.add(tick);
|
|
135
|
-
return () => gsap.ticker.remove(tick);
|
|
136
|
-
}, [advance]);
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
// <Canvas frameloop="never"> … <GsapTickerDriver /> … </Canvas>
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
**One driver per canvas.** This is the minimal driver. When the scene comes from
|
|
143
|
-
webgl-scenes.md, use the `TickerDriver` in [webgl-scenes.md](webgl-scenes.md) §0 instead:
|
|
144
|
-
it is this driver plus an offscreen pause, and it **replaces** `GsapTickerDriver`. Never
|
|
145
|
-
mount both in one canvas, or every frame advances twice.
|
|
146
|
-
|
|
147
|
-
Cheaper variant for scenes that only change with scroll or pointer (most hero
|
|
148
|
-
backdrops): `frameloop="demand"` and call `invalidate()` from the things that
|
|
149
|
-
change it, so no frames are rendered while the user is idle:
|
|
150
|
-
|
|
151
|
-
```tsx
|
|
152
|
-
const invalidate = useThree((s) => s.invalidate);
|
|
153
|
-
// in a ScrollTrigger onUpdate, pointermove handler, or uniform setter:
|
|
154
|
-
invalidate();
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
Decision rule: continuously animated scene (particles drifting, shader time
|
|
158
|
-
uniform) → `never` + ticker driver. Reactive-only scene → `demand` +
|
|
159
|
-
`invalidate()`. Never ship `always`.
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## 3. Scroll-linking a scene (uniforms, not React state)
|
|
164
|
-
|
|
165
|
-
Bind scroll to the scene exactly like any other scrub target: one ScrollTrigger
|
|
166
|
-
writes into a mutable ref / uniform. **Never `setState` per scroll frame.** A
|
|
167
|
-
React render per frame kills both clocks.
|
|
168
|
-
|
|
169
|
-
```tsx
|
|
170
|
-
function ScrollBoundRig() {
|
|
171
|
-
const progress = useRef(0);
|
|
172
|
-
const invalidate = useThree((s) => s.invalidate);
|
|
173
|
-
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
const st = ScrollTrigger.create({
|
|
176
|
-
trigger: "#hero", // the DOM section the canvas lives in
|
|
177
|
-
start: "top top",
|
|
178
|
-
end: "bottom top",
|
|
179
|
-
scrub: true,
|
|
180
|
-
onUpdate: (self) => {
|
|
181
|
-
progress.current = self.progress; // mutate, don't render
|
|
182
|
-
invalidate(); // demand-mode: request one frame
|
|
183
|
-
},
|
|
184
|
-
});
|
|
185
|
-
return () => st.kill();
|
|
186
|
-
}, [invalidate]);
|
|
187
|
-
|
|
188
|
-
useFrame(({ camera }) => {
|
|
189
|
-
camera.position.z = 5 - progress.current * 2; // read in the frame loop
|
|
190
|
-
});
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
The same pattern feeds shader uniforms (`material.uniforms.uProgress.value =
|
|
196
|
-
self.progress`). Lenis needs no special handling: ScrollTrigger is already
|
|
197
|
-
synced to it by the provider, and the scene inherits the smoothing for free,
|
|
198
|
-
which is exactly what makes scroll-bound WebGL feel expensive.
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
## 3b. A minimal scene (completing the island)
|
|
203
|
-
|
|
204
|
-
The canonical section-backdrop scene is a full-frame shader plane with two
|
|
205
|
-
uniforms, `uTime` (ambient life) and `uProgress` (scroll), not a floating
|
|
206
|
-
mesh. It reacts to the page instead of performing at it:
|
|
207
|
-
|
|
208
|
-
```tsx
|
|
209
|
-
// Inside <Canvas>, alongside ONE driver (GsapTickerDriver, or webgl-scenes.md's TickerDriver) and ScrollBoundRig
|
|
210
|
-
import { useFrame, useThree } from "@react-three/fiber";
|
|
211
|
-
import { useMemo, useRef } from "react";
|
|
212
|
-
import * as THREE from "three";
|
|
213
|
-
|
|
214
|
-
function BackdropPlane({ progress }: { progress: React.RefObject<number> }) {
|
|
215
|
-
const mat = useRef<THREE.ShaderMaterial>(null);
|
|
216
|
-
const { viewport } = useThree();
|
|
217
|
-
|
|
218
|
-
const uniforms = useMemo(() => ({
|
|
219
|
-
uTime: { value: 0 },
|
|
220
|
-
uProgress: { value: 0 },
|
|
221
|
-
}), []);
|
|
222
|
-
|
|
223
|
-
useFrame((_, delta) => {
|
|
224
|
-
mat.current!.uniforms.uTime.value += delta;
|
|
225
|
-
mat.current!.uniforms.uProgress.value = progress.current ?? 0;
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
return (
|
|
229
|
-
<mesh scale={[viewport.width, viewport.height, 1]}>
|
|
230
|
-
<planeGeometry />
|
|
231
|
-
<shaderMaterial
|
|
232
|
-
ref={mat}
|
|
233
|
-
uniforms={uniforms}
|
|
234
|
-
vertexShader={/* glsl */ `
|
|
235
|
-
varying vec2 vUv;
|
|
236
|
-
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`}
|
|
237
|
-
fragmentShader={/* glsl */ `
|
|
238
|
-
varying vec2 vUv; uniform float uTime; uniform float uProgress;
|
|
239
|
-
void main() {
|
|
240
|
-
float wave = sin(vUv.x * 6.0 + uTime * 0.4) * 0.04 * (1.0 - uProgress);
|
|
241
|
-
vec3 a = vec3(0.04, 0.04, 0.045), b = vec3(0.10, 0.10, 0.11); // surface-0 → surface-1 world
|
|
242
|
-
gl_FragColor = vec4(mix(a, b, vUv.y + wave), 1.0);
|
|
243
|
-
}`}
|
|
244
|
-
/>
|
|
245
|
-
</mesh>
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
- `scale={[viewport.width, viewport.height, 1]}` fills the camera frustum.
|
|
251
|
-
The plane IS the backdrop, no layout math.
|
|
252
|
-
- The fragment shader is the design surface: keep its palette inside the
|
|
253
|
-
page's token world (tonal ramp of the surfaces, never a foreign gradient).
|
|
254
|
-
- `uTime` movement stays subtle and damped by `uProgress`. The "nothing moves
|
|
255
|
-
the user didn't cause" doctrine ([feel-layer.md](feel-layer.md) §5) applies
|
|
256
|
-
to shaders too.
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
|
-
## 4. Performance & accessibility floor
|
|
261
|
-
|
|
262
|
-
- The canvas section must hold 60fps with CPU 4× throttle alongside the page's
|
|
263
|
-
other scrub work; if it can't, cut scene cost before cutting page motion.
|
|
264
|
-
- Reduced motion = poster. Not "slower scene": no scene.
|
|
265
|
-
- Touch/mobile = project decision, but the default is poster or `dpr [1, 1.5]`
|
|
266
|
-
with halved particle/segment counts. Battery is a design constraint.
|
|
267
|
-
- The page must be 100% complete without WebGL: real poster, real content,
|
|
268
|
-
no layout shift when the canvas pops in (absolute-fill behind content).
|
|
269
|
-
- `aria-hidden` on the canvas wrapper; it is decoration, never content.
|
|
270
|
-
|
|
271
|
-
**Cheap tell:** a spinning torus-knot/blob over a gradient, `frameloop="always"`,
|
|
272
|
-
shipped in the main bundle, white flash before context creation, dead on mobile.
|
|
273
|
-
**Instead:** A scene that reacts to scroll/pointer through the one synced clock, behind a
|
|
274
|
-
lazy boundary, with a poster path so good you could ship it alone.
|