@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,720 +0,0 @@
|
|
|
1
|
-
# Typography: the single biggest lever
|
|
2
|
-
|
|
3
|
-
Type is most of what a visitor sees on a marketing site, and it decides in the first second whether the page reads as designed or generated. It costs no assets, no GPU and no scroll math, so spend the first hour of every build here. The baseline in [../SKILL.md](../SKILL.md) states the non-negotiables; this file is the system behind them.
|
|
4
|
-
|
|
5
|
-
Treat type as a **system, not a font choice**: a fixed set of roles, one scale, a weight budget and per-size optics. Picking two nice faces and then improvising sizes per section is how good fonts end up looking like a template.
|
|
6
|
-
|
|
7
|
-
Prerequisites: the GSAP registry, `useGSAP` rules and reduced-motion pattern from [setup.md](setup.md); the root-rem knob from [scaling.md](scaling.md); the token contract from [vibe-presets.md](vibe-presets.md). The masked line reveal is specified in [scroll-reveals.md](scroll-reveals.md) §3. This file builds on it and does not repeat it.
|
|
8
|
-
|
|
9
|
-
## Decide here
|
|
10
|
-
|
|
11
|
-
During planning ([direction.md](direction.md) §4) read only this section; the rest of the file is for the build. Three decisions go into STRUQ.md SYSTEM:
|
|
12
|
-
|
|
13
|
-
1. **The pairing, by temperament.** Read the brief's temperament word and take the Signature row that names it (§3.1). Extended (§3.2) only when no Signature temperament fits, or to keep two sites in one niche apart. "Unsure" is not a temperament; Inter and the other off-list faces (§3.4) are never the answer.
|
|
14
|
-
|
|
15
|
-
| Temperament | Signature | Preset it anchors |
|
|
16
|
-
|---|---|---|
|
|
17
|
-
| Modern, open, calm (a declared one-family system) | S1 Urbanist + Urbanist | Soft Modern |
|
|
18
|
-
| Warm, literate, editorial | S2 Fraunces + Urbanist | Warm Editorial |
|
|
19
|
-
| Soft, friendly, optimistic | S3 Fraunces (SOFT 100) + Outfit | light brands of any preset |
|
|
20
|
-
| Quiet luxury | S4 Cormorant Garamond + Jost | Luxury Minimal |
|
|
21
|
-
| Cinematic, dark, literate | S5 Newsreader + Manrope | Dark Cinematic |
|
|
22
|
-
| Essay-led, sans headlines over book text | S6 Urbanist + Lora | Warm Editorial, Luxury Minimal |
|
|
23
|
-
| Product, precise, technical | S7 Sora + Instrument Sans | Dark Cinematic, Soft Modern |
|
|
24
|
-
|
|
25
|
-
**Extended by the same word.** When the brief's temperament matches an Extended row word for word (a potter is "warm, crafted, hand-set", E1), take that row; sourcing cost alone is not a reason to fall back. If the face cannot be fetched in this run (Fontshare is a manual download), set the nearest Signature row, and record the Extended swap under ASSUMPTIONS so it lands when the files arrive.
|
|
26
|
-
|
|
27
|
-
**A brand that owns one face** keeps it in the role the matching S row gives that face, and takes the row's partner for the other role (a brand set in Sora takes S7: Sora display, Instrument Sans text), unless the brand already sets its body copy in its own face too; then it is a declared one-family system. Log the partner under ASSUMPTIONS.
|
|
28
|
-
|
|
29
|
-
2. **The roles are fixed; the pairing only overrides values.** Every string is one of the roles in §2.1, each with its own token: `font-display text-display` (hero), `font-display text-statement` (the Orient line or manifesto), `font-display text-h2`, `type-h3 text-h3`, `text-lead`, `text-body`, `text-small`, `meta text-meta text-ink-muted`, `font-ui text-ui`, `stat text-stat`. The pairing's row writes its weights, tracking, leading, role faces and display clamp into `@theme`; components never change.
|
|
30
|
-
|
|
31
|
-
3. **One signature move** (§5): `weight-in`, `soft-scrub`, `type-mask-media`, `fit-width-wordmark` or a cropped display. Record it next to the pairing id, with the preset's hero entrance ([vibe-presets.md](vibe-presets.md)).
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
|
|
35
|
-
## 1. Principles, with the reasons
|
|
36
|
-
|
|
37
|
-
**Two faces, no more.** One display face, one text face. Each extra face is another voice the page has to referee. A single family used for both roles counts as one face and is a valid, quiet choice.
|
|
38
|
-
|
|
39
|
-
**Display runs large and light (300 to 400).** At 115px a light stem is about 7px wide and a bold stem about 17px. Light type at that size shows its drawing: curves, counters, the air between letters. Bold type at that size is a slab of ink. Template sites put `font-bold` on every heading because it is safe at 24px; award sites let scale carry the emphasis and keep the weight down. That inversion is the agency signal.
|
|
40
|
-
|
|
41
|
-
The cost: light display needs a face drawn for large sizes. Hairline serifs die at 16px and sing at 120px. Choose faces for their behaviour at hero size and use weight 300 only from 3rem up.
|
|
42
|
-
|
|
43
|
-
**Heavy display only as a declared genre.** Brutalist, poster, sport, streetwear: pages meant to shout. Then commit fully (700 to 900 or a single-weight black face, leading 0.85 to 0.92, up to 14vw cropped off canvas). The failure is the half measure, a 600 display that is neither quiet nor loud. In [vibe-presets.md](vibe-presets.md) only the Brutalist genre appendix declares it.
|
|
44
|
-
|
|
45
|
-
**Hierarchy by contrast, not steps.** Five sizes between 1.25rem and 2.25rem make a flat page. A few tiers with big gaps make a designed one. The gaps are the design.
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## 2. The system: roles, weights, optics
|
|
50
|
-
|
|
51
|
-
### 2.1 The roles
|
|
52
|
-
|
|
53
|
-
Every piece of text on the page is one of these. If a string fits none, the design is improvising.
|
|
54
|
-
|
|
55
|
-
| Role | Token | 390px to 1440px | Face token | Use it for | Never |
|
|
56
|
-
|---|---|---|---|---|---|
|
|
57
|
-
| **Display** | `text-display` | 46 to 120px | `font-display` | The hero h1 and at most one other display line per page (the footer ask) | Section titles; anything over 3 lines |
|
|
58
|
-
| **Statement** | `text-statement` | 28 to 52px | `font-display` | The Orient beat's one sentence and the `manifesto-fill` paragraph: larger than lead, not a heading | Section titles; more than one per page outside the manifesto |
|
|
59
|
-
| **h2** | `text-h2` | 36 to 58px | `font-display` | Section openers, one per section | Two per viewport |
|
|
60
|
-
| **h3** | `text-h3` | 24 to 33px | `type-h3` (`--font-h3`) | Item titles in lists, cards, case rows | As a subhead under an h2 (use lead) |
|
|
61
|
-
| **Lead** | `text-lead` | 20 to 22px | `font-body` | The one paragraph under a hero or h2 | More than one paragraph in a row |
|
|
62
|
-
| **Body** | `text-body` | 16 to 18.7px | `font-body` | Running copy | Below 1rem; wider than 75ch |
|
|
63
|
-
| **Small** | `text-small` | 14 to 15.4px | `font-body` | Captions, meta, footnotes, legal | Body-length copy; below 0.875rem |
|
|
64
|
-
| **Meta** | `text-meta` | 13 to 14.3px | `meta` (`--font-meta`), sentence case | Captions, dates, credits, the line under a photo | Uppercase tracked labels above headings (the eyebrow tell); mono |
|
|
65
|
-
| **UI** | `text-ui` | 15 to 16.5px | `font-ui` (`--font-ui`) | Buttons, nav links, form labels, tabs | Sentence-length strings |
|
|
66
|
-
| **Numerals** | `text-stat` | 48 to 82px | `stat` (`--font-stat`), `tabular-nums` | Stats, counters, prices | Monospace |
|
|
67
|
-
|
|
68
|
-
Sizes are read at the knob's two ends: the root is 16px at 390px and about 17.6px at 1440px, and freezes at 18px from about 1640px ([scaling.md](scaling.md)). Body spans 1rem to 1.0625rem depending on the text face (§3.1).
|
|
69
|
-
|
|
70
|
-
**The root and the scale.** The skill's root is `html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }`. On it sits a 1.333 scale with skipped steps, so every tier is a real jump: body 1, lead 1.25, h3 about 1.78, h2 about 3.16, display 7 to 8. Every `clamp()` in this file keeps a `rem` term in its middle argument, so browser zoom and the user's font-size preference still scale it; a bare `vw` middle ignores both.
|
|
71
|
-
|
|
72
|
-
### 2.2 Weight rules
|
|
73
|
-
|
|
74
|
-
- **Display, h2, h3: 300 to 400.** Lighter on dark grounds (light on dark reads heavier by irradiation; run a variable face 20 to 40 units lighter, for example 340 on light and 310 on dark). Never below 300, never faked with opacity.
|
|
75
|
-
- **Text roles: 400.** Body, lead, small at 400. The text face at 300 is too pale for 4.5:1 on toned surfaces.
|
|
76
|
-
- **Emphasis is 500, not 700.** UI labels, an inline `<strong>`: 500. A 700 inside light typography is a hole in the page.
|
|
77
|
-
- **At most three weights per family loaded**, typically 300, 400 and 500. With a variable file it is one request, but the rule still holds for what the CSS uses: a fourth weight is a fourth voice.
|
|
78
|
-
- **`font-synthesis: none` on `body`**, so a single-weight face (Instrument Serif, Archivo Black) is never smeared into a fake bold.
|
|
79
|
-
|
|
80
|
-
### 2.3 Optics per size
|
|
81
|
-
|
|
82
|
-
- **Tracking tightens as size grows.** Spacing drawn for 16px reads loose at 115px. Grotesks take about -0.04em at display size, -0.02em at h2, 0 at body. Serifs and wide geometrics carry their own horizontal rhythm and want 40 to 60% less, or letters touch.
|
|
83
|
-
- **Leading tightens as size grows.** The eye reads the white between lines, which scales with size. Body wants 1.6 to 1.7 to track a 62ch line back; a three-line headline at 1.65 falls apart. Below 0.95, descenders hit ascenders.
|
|
84
|
-
- **Uppercase is not a hierarchy tool.** Capitals have no descenders, so a display line set in caps can drop its leading to 0.88 with tracking 0 to +0.02em. Small tracked capitals as labels above headings are the eyebrow tell: never use them.
|
|
85
|
-
- **Small tiers loosen.** +0.005em on small, +0.01em on meta and UI.
|
|
86
|
-
- **Measure.** Body 55 to 75 characters (`max-w-[62ch]`), lead 40 to 50ch, hero 14 to 18ch, h2 18 to 22ch. Cap widths in `ch`, never in px: `ch` follows the face and the knob.
|
|
87
|
-
|
|
88
|
-
### 2.4 The tokens
|
|
89
|
-
|
|
90
|
-
Tailwind (v4 or later, for `@theme`) lets a `--text-*` token carry its line height, tracking and weight, so `text-h2` applies all four and components never assemble them by hand. Roles that can change face per pairing (h3, meta, UI, numerals) also get a **role font token**, so a pairing swap never touches a component. These defaults equal the S1 row in §3.1 exactly (Urbanist in both roles); every other pairing's row lists the values to override in the preset's `@theme`, including its four role faces.
|
|
91
|
-
|
|
92
|
-
```css
|
|
93
|
-
/* app/theme.css: type layer, next to the preset's colour tokens. Defaults = S1. */
|
|
94
|
-
@theme {
|
|
95
|
-
--text-meta: 0.8125rem; --text-meta--line-height: 1.45; --text-meta--letter-spacing: 0.01em; --text-meta--font-weight: 400;
|
|
96
|
-
--text-small: 0.875rem; --text-small--line-height: 1.55; --text-small--letter-spacing: 0.01em;
|
|
97
|
-
--text-ui: 0.9375rem; --text-ui--line-height: 1; --text-ui--letter-spacing: 0.02em; --text-ui--font-weight: 500;
|
|
98
|
-
--text-body: 1.0625rem; --text-body--line-height: 1.65;
|
|
99
|
-
--text-lead: 1.25rem; --text-lead--line-height: 1.5;
|
|
100
|
-
|
|
101
|
-
--text-h3: clamp(1.5rem, 1.34rem + 0.65vw, 1.875rem); --text-h3--line-height: 1.2; --text-h3--letter-spacing: -0.01em; --text-h3--font-weight: 400;
|
|
102
|
-
--text-h2: clamp(2.25rem, 1.8rem + 1.82vw, 3.3rem); --text-h2--line-height: 1.06; --text-h2--letter-spacing: -0.02em; --text-h2--font-weight: 300;
|
|
103
|
-
--text-statement: clamp(1.75rem, 1.2rem + 2.2vw, 3.25rem); --text-statement--line-height: 1.2; --text-statement--letter-spacing: -0.015em; --text-statement--font-weight: 300;
|
|
104
|
-
--text-stat: clamp(3rem, 2.2rem + 3vw, 5rem); --text-stat--line-height: 1; --text-stat--letter-spacing: -0.03em; --text-stat--font-weight: 300;
|
|
105
|
-
|
|
106
|
-
/* The top of the scale, and ghost texture type. Both keep a rem term in the middle (zoom). */
|
|
107
|
-
--text-display: clamp(2.875rem, 1.2rem + 6.86vw, 8rem); --text-display--line-height: 0.98; --text-display--letter-spacing: -0.03em; --text-display--font-weight: 300;
|
|
108
|
-
--text-ghost: clamp(6rem, 2rem + 20vw, 24rem); --text-ghost--line-height: 0.85;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/* Role faces. --font-display and --font-body come from the preset; these default to S1. */
|
|
112
|
-
@theme inline {
|
|
113
|
-
--font-h3: var(--font-display);
|
|
114
|
-
--font-meta: var(--font-body);
|
|
115
|
-
--font-ui: var(--font-body);
|
|
116
|
-
--font-stat: var(--font-display);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
@utility meta { font-family: var(--font-meta); font-variant-numeric: tabular-nums; }
|
|
120
|
-
@utility stat { font-family: var(--font-stat); font-variant-numeric: lining-nums tabular-nums; }
|
|
121
|
-
@utility type-h3 { font-family: var(--font-h3); }
|
|
122
|
-
|
|
123
|
-
body { font-synthesis: none; }
|
|
124
|
-
h1, h2, h3 { text-wrap: balance; }
|
|
125
|
-
p, li, blockquote { text-wrap: pretty; }
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
`text-statement` takes the pairing's h2 weight and tracking unless its row says otherwise; it is the only role a manifesto paragraph or an Orient sentence ever needs, so neither recipe carries an arbitrary `text-[clamp(...)]`.
|
|
129
|
-
|
|
130
|
-
Usage: `<h1 class="font-display text-display max-w-[16ch]">`, `<p class="font-display text-statement max-w-[30ch]">`, `<h3 class="type-h3 text-h3">`, `<p class="text-body max-w-[62ch]">`, `<span class="meta text-meta text-ink-muted">`, `<button class="font-ui text-ui">`, `<span class="stat text-stat">`. The scale has no h1 token on purpose: the page's h1 is the display role. Meta lines take `text-ink-muted`; `ink-faint` is never text ([vibe-presets.md](vibe-presets.md)).
|
|
131
|
-
|
|
132
|
-
**Wrapping.** `balance` evens headline lines so the last is not an orphan; engines cap it at a handful of lines, which suits headings only. `pretty` avoids one-word last lines in paragraphs. Both are progressive enhancement. SplitText measures the balanced layout, so balance and line masks agree.
|
|
133
|
-
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
## 3. The pairing library
|
|
137
|
-
|
|
138
|
-
A short, curated set, not a catalogue. The Struq taste profile: **open, geometric, soft-modern sans** (clean round forms, generous apertures, elegant at light weights) set against **refined serifs** (high-contrast or warm). Not neutral grotesks, not quirky display gimmicks.
|
|
139
|
-
|
|
140
|
-
**How to choose.** Pick by brand temperament: warm and literate, soft and friendly, quiet luxury, cinematic, product and precise. Take the **Signature** pairing that matches. Use **Extended** only when the brief names a temperament Signature does not cover, or when two Struq sites in the same niche would otherwise share a face. **Unsure is not a temperament.** Pick from the brief's temperament word. S1 only when the brief declares a one-family system. Two Struq sites in the same niche never share a display face. **Never fall back to Inter or another default** (§3.4); "unsure" is exactly when a default sneaks in. The presets in [vibe-presets.md](vibe-presets.md) are built from Signature, and the type step of [brief-router.md](brief-router.md) defers to this section: Signature first, always.
|
|
141
|
-
|
|
142
|
-
**Reading the role rows.** Size tokens are fixed by the system (§2.4): display `text-display`, h2 `text-h2`, h3 `text-h3`, meta `text-meta`, body `text-body`, small `text-small`, UI `text-ui`, numerals `text-stat`. Each row gives `weight / tracking (em) / leading` per role; those are the overrides you write into the preset's `@theme`. Display and h2 use the display face, body and small the text face. h3, meta, UI and numerals take whatever face the pairing's role font tokens name (the third table): write those four `--font-*` values into the preset's `@theme inline`, plus the display clamp and body size where the row differs from the standard. **GF** is Google Fonts (`next/font/google`); **FS** is Fontshare (self-host with `next/font/local`, §4). Every weight listed ships in the family.
|
|
143
|
-
|
|
144
|
-
### 3.1 Struq Signature (the default set)
|
|
145
|
-
|
|
146
|
-
| # | Pairing | Source | Temperament | Why it is not generic | Preset |
|
|
147
|
-
|---|---|---|---|---|---|
|
|
148
|
-
| S1 | **Urbanist** + **Urbanist** | GF | Modern, open, calm | A geometric with round, open forms that stays elegant at 300; one family, one file, a page that feels drawn rather than defaulted | Soft Modern (its pairing); only when the brief declares a one-family system |
|
|
149
|
-
| S2 | **Fraunces** (`opsz`, `SOFT`) + **Urbanist** | GF | Warm, literate, editorial | A soft-serif display with optical sizing over a round geometric: warmth and precision in one line, unlike the usual serif-plus-grotesk | Warm Editorial (its pairing) |
|
|
150
|
-
| S3 | **Fraunces** (`SOFT` 100, `opsz` auto) + **Outfit** | GF | Soft, friendly, optimistic | Fraunces at full softness reads round and kind without a novelty rounded face, and Outfit's clean geometry keeps the text crisp | Light brands of any preset |
|
|
151
|
-
| S4 | **Cormorant Garamond** + **Jost** | GF | Quiet luxury | Hairline Garamond at size, over a Futura-lineage geometric that takes wide-tracked caps and stays legible on toned paper | Luxury Minimal (its pairing) |
|
|
152
|
-
| S5 | **Newsreader** (`opsz`) + **Manrope** | GF | Cinematic, dark, literate | An optical-size news serif that turns razor-fine at hero size on black, over an engineered text sans: film-title gravity with a reading voice | Dark Cinematic (its pairing) |
|
|
153
|
-
| S6 | **Urbanist** + **Lora** | GF | Essay-led editorial sans | The reversed pair: light geometric headlines over a warm book serif for long reading. Editorial without any serif headline | Warm Editorial, Luxury Minimal |
|
|
154
|
-
| S7 | **Sora** + **Instrument Sans** | GF | Product, precise, technical | A wide geometric display over a compact, slightly condensed grotesk: technical by letterform, never by monospace | Dark Cinematic, Soft Modern |
|
|
155
|
-
|
|
156
|
-
| # | Display | h2 | h3 | Meta | Body | Small | UI | Numerals |
|
|
157
|
-
|---|---|---|---|---|---|---|---|---|
|
|
158
|
-
| S1 | 300 / -0.03 / 0.98 | 300 / -0.02 / 1.06 | 400 / -0.01 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.01 / 1.55 | 500 / 0.02 / 1 | Urbanist 300, tabular |
|
|
159
|
-
| S2 | 300 / -0.025 / 0.95 | 300 / -0.015 / 1.06 | 400 / -0.005 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.01 / 1.55 | 500 / 0.02 / 1 | Fraunces 300, -0.02 |
|
|
160
|
-
| S3 | 400 / -0.02 / 0.96 | 300 / -0.012 / 1.06 | 400 / -0.005 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.01 / 1.55 | 500 / 0.02 / 1 | Outfit 400, tabular |
|
|
161
|
-
| S4 | 300 / -0.01 / 0.92 | 300 / -0.005 / 1.04 | 400 / 0 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.7 | 400 / 0.01 / 1.55 | 500 / 0.06 / 1 | Jost 400, tabular |
|
|
162
|
-
| S5 | 300 / -0.02 / 0.94 | 300 / -0.012 / 1.04 | 400 / -0.005 / 1.15 | 400 / 0.01 / 1.45 | 400 / 0 / 1.6 | 400 / 0.005 / 1.5 | 500 / 0.01 / 1 | Manrope 400, tabular |
|
|
163
|
-
| S6 | 300 / -0.03 / 0.98 | 300 / -0.02 / 1.06 | 400 / -0.01 / 1.2 | 400 / 0.01 / 1.45 | Lora 400 / 0 / 1.7 | Urbanist 400 / 0.01 / 1.5 | 500 / 0.02 / 1 | Urbanist 300, tabular |
|
|
164
|
-
| S7 | 300 / -0.025 / 1.0 | 300 / -0.015 / 1.08 | 400 / -0.01 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.6 | 400 / 0.005 / 1.5 | 500 / 0.01 / 1 | Sora 300, tabular |
|
|
165
|
-
|
|
166
|
-
Per-pairing columns: the role faces (write them into `@theme inline`), the display size, the body size and the accent move. "Standard" display is the §2.4 token. `display` and `body` here mean `var(--font-display)` and `var(--font-body)`.
|
|
167
|
-
|
|
168
|
-
| # | `--font-h3` | `--font-meta` | `--font-ui` | `--font-stat` | `--text-display` | `--text-body` | Accent move |
|
|
169
|
-
|---|---|---|---|---|---|---|---|
|
|
170
|
-
| S1 | display | body | body | display | standard | 1.0625rem | Weight shift 300 to 500 on one word, or the accent colour |
|
|
171
|
-
| S2 | display | body | body | display | standard | 1.0625rem | One word in Fraunces italic |
|
|
172
|
-
| S3 | display | body | body | body | standard | 1.0625rem | One word in Fraunces italic (SOFT 100 keeps it round) |
|
|
173
|
-
| S4 | display | body | body | body | `clamp(3.25rem, 1.4rem + 7.8vw, 9rem)` | 1.0625rem | One word in Cormorant italic 300; the italic is loaded (§4) |
|
|
174
|
-
| S5 | display | body | body | body | `clamp(3.25rem, 1.4rem + 7.8vw, 9rem)` | 1rem | One word in Newsreader italic |
|
|
175
|
-
| S6 | display | display | display | display | standard | 1.0625rem (Lora) | Weight shift 300 to 500 on one word, or the accent colour |
|
|
176
|
-
| S7 | display | body | body | display | `clamp(2.5rem, 1rem + 6vw, 7rem)`, hero `max-w-[18ch]` | 1rem | Weight shift 300 to 500 on one word, or the accent colour |
|
|
177
|
-
|
|
178
|
-
The display clamps compensate for x-height and width: Cormorant and Newsreader have small x-heights and read a size smaller than a geometric at the same `font-size`, so they set larger; Sora runs wide and sets smaller with a wider measure. **A face with no italic (Sora, Comfortaa, Archivo Black) never gets an `<em>` accent.** Urbanist does ship an italic, but a slanted geometric reads as a style toggle, not a voice, so S1, S6 and S7 move weight or colour instead.
|
|
179
|
-
|
|
180
|
-
Honest notes, per face:
|
|
181
|
-
|
|
182
|
-
- **Urbanist** (100 to 900) is geometric and wide-set, so it wants less negative tracking than a neo-grotesk and slightly looser leading; its round `o` and `e` collide first. At 400 it is a genuine text face, set at 1.0625rem because its x-height is modest.
|
|
183
|
-
- **Fraunces**: use the axes on purpose. `opsz` follows size automatically through `next/font`, so the hero gets the sharp cut and the h3 the sturdier one. `SOFT` (0 to 100) rounds the terminals: around 50 suits warm brands (S2), 100 is the soft and friendly voice of S3, 0 keeps it crisp. Pin it with `font-variation-settings: "SOFT" 50` (or 100) on the display roles only, and load the axis (§4). Write the pin inside `@layer base` (`@layer base { .font-display, .type-h3, .stat { font-variation-settings: "SOFT" 50; } }`): an unlayered rule after `@import "tailwindcss"` beats every utility, including the start value of `soft-scrub`. On a `soft-scrub` site the hero h1 rests at `SOFT 0` (the scrub's start); every other display line pins 50. Leaving `opsz` out of `font-variation-settings` keeps optical sizing automatic.
|
|
184
|
-
- **Cormorant Garamond** needs size. Display and h2 only, at its own larger clamp and leading 0.92; if it ever sets running text (a pull quote, a lead), never below 1.125rem. Hairlines vanish on cheap screens below that. Its h3 can move to Jost 500 in dense lists. Load the italic: it is the accent.
|
|
185
|
-
- **Newsreader** (200 to 800, `opsz` 6 to 72) is S5's display face. Leave `opsz` automatic so the hero gets the fine display cut and h3 the sturdier text cut; run it 300 on dark grounds. Like Cormorant it sets a size larger than a geometric.
|
|
186
|
-
- **Sora** (100 to 800) runs wide. It takes less negative tracking than its weight suggests (-0.025em at display, -0.015em at h2) because tight tracking on a wide face clots the counters; carry the width with a smaller clamp and a hero `max-w-[18ch]`, or headlines break into single words.
|
|
187
|
-
- **Manrope** (200 to 800): 200 is too thin for anything but one giant word. As text at 400 it holds up down to 0.875rem; body at 1rem.
|
|
188
|
-
- **Lora** (400 to 700) is a text serif: body and lead only, never display. Body 1.0625rem at leading 1.7, because serif text on screen wants more air than sans.
|
|
189
|
-
- **Outfit** (100 to 900) is a clean geometric; body at 1.0625rem. On dark grounds keep body and small at 400, not 300.
|
|
190
|
-
- **Jost** (100 to 900) is a Futura-lineage geometric. Body 1.0625rem at 1.7 under Cormorant; UI at 0.06em.
|
|
191
|
-
- **Instrument Sans** (400 to 700, `wdth` axis) is compact and even: body 1rem at 1.6. Keep `wdth` at 100; condensing it under a wide Sora display tips the contrast into a mismatch.
|
|
192
|
-
|
|
193
|
-
**House pairing: Comfortaa + Urbanist.** This is Struq's own brand pairing, not a default for other brands, and it is not in the Signature set. Comfortaa sets the wordmark and the h1 only; h2 and everything below runs in Urbanist 300 and up. Comfortaa is a rounded display face that charms at 40px and up and tires in a paragraph, so it never sets h3, body, small or UI.
|
|
194
|
-
|
|
195
|
-
### 3.2 Extended (distinctive alternatives, same spirit)
|
|
196
|
-
|
|
197
|
-
Less-seen faces for when Signature does not fit, or to keep two sites in the same niche apart. Mostly Fontshare: free, self-hosted, and rarely seen on generated sites.
|
|
198
|
-
|
|
199
|
-
| # | Pairing | Source | Temperament | Why it is not generic | Preset |
|
|
200
|
-
|---|---|---|---|---|---|
|
|
201
|
-
| E1 | **Gambetta** + **Albert Sans** | FS + GF | Warm, crafted, hand-set | A soft, inky serif with calligraphic warmth over a clean, slightly warm geometric grotesk | Warm Editorial |
|
|
202
|
-
| E2 | **Zodiak** + **General Sans** | FS | Confident editorial | A high-contrast serif with sharp wedges, set light, over a clean sans with open apertures | Warm Editorial, Dark Cinematic |
|
|
203
|
-
| E3 | **Sentient** + **General Sans** | FS | Modern luxury | Warmer and less brittle than a Didone; reads expensive at 300 without hairline fragility | Luxury Minimal |
|
|
204
|
-
| E4 | **Boska** + **Albert Sans** | FS + GF | Fashion, editorial | Sharp, high-contrast display serif whose light weights stay graceful at 120px | Luxury Minimal, Dark Cinematic |
|
|
205
|
-
| E5 | **Bodoni Moda** (`opsz`) + **Albert Sans** | GF | Fashion house | A Didone with optical sizing over a quiet grotesk that lets the hairlines carry the voice | Luxury Minimal |
|
|
206
|
-
| E6 | **Instrument Serif** (400 only) + **Outfit** | GF | Film title, poster | A condensed high-contrast serif against a soft geometric: poster gravity without a heavy weight. Never with the italic accent word inside a sans headline (the 2025 to 2026 template tell) | Dark Cinematic |
|
|
207
|
-
|
|
208
|
-
| # | Display | h2 | h3 | Meta | Body | Small | UI | Numerals |
|
|
209
|
-
|---|---|---|---|---|---|---|---|---|
|
|
210
|
-
| E1 | 300 / -0.02 / 0.96 | 300 / -0.01 / 1.06 | 400 / 0 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.005 / 1.55 | 500 / 0.01 / 1 | Albert Sans 400, tabular |
|
|
211
|
-
| E2 | 300 / -0.02 / 0.95 | 300 / -0.015 / 1.05 | 400 / -0.005 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.005 / 1.55 | 500 / 0.01 / 1 | General Sans 400, tabular |
|
|
212
|
-
| E3 | 300 / -0.02 / 0.98 | 300 / -0.01 / 1.06 | 400 / 0 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.7 | 400 / 0.005 / 1.55 | 500 / 0.02 / 1 | Sentient 300, -0.01 |
|
|
213
|
-
| E4 | 300 / -0.02 / 0.96 | 300 / -0.01 / 1.04 | 400 / 0 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.005 / 1.55 | 500 / 0.02 / 1 | Albert Sans 400, tabular |
|
|
214
|
-
| E5 | 400 / -0.015 / 1.0 | 400 / -0.01 / 1.08 | 400 / 0 / 1.2 | 400 / 0.01 / 1.45 | 400 / 0 / 1.7 | 400 / 0.01 / 1.55 | 500 / 0.04 / 1 | Albert Sans 400, tabular |
|
|
215
|
-
| E6 | 400 / -0.01 / 0.92 | 400 / -0.005 / 1.02 | 400 / 0 / 1.15 | 400 / 0.01 / 1.45 | 400 / 0 / 1.65 | 400 / 0.005 / 1.5 | 500 / 0.01 / 1 | Outfit 400, tabular |
|
|
216
|
-
|
|
217
|
-
Role faces for Extended: `--font-h3` is the display face; meta, UI and numerals use the text face, except E3, whose numerals use Sentient. Every Extended row takes the standard display clamp and a 1rem body (1.0625rem for Outfit in E6). Bodoni Moda starts at 400, which reads light because of its hairlines; hairlines vanish under 40px, so h3 can move to Albert Sans 500. **Instrument Serif** has one weight, 400, plus an italic: set every display-role weight to 400, never ask it for 700, and keep its italic inside its own serif headlines.
|
|
218
|
-
|
|
219
|
-
### 3.3 Genre exception: heavy display
|
|
220
|
-
|
|
221
|
-
Outside the profile on purpose, for the one preset that shouts. **Archivo Black** (400 only, a black weight) + **Archivo** (GF) for Brutalist: display `400 caps / -0.01 / 0.88`, h2 `400 caps / 0 / 0.92`, h3 Archivo `500 / -0.01 / 1.15`, meta `400 / 0.01 / 1.45`, body `400 / 0 / 1.55`, small `400 / 0 / 1.45`, UI `500 caps / 0.04 / 1`, numerals Archivo 500 tabular. Use it only when the brief declares the genre.
|
|
222
|
-
|
|
223
|
-
### 3.4 Off the list, on purpose
|
|
224
|
-
|
|
225
|
-
**Inter, Roboto, Open Sans, Poppins, Montserrat and Lato** are the defaults of starters, UI kits and generators; on a marketing page they read as "nobody chose this". **Space Grotesk and Geist** are good faces that became the reflex for anything "technical" or anything scaffolded by a Next.js starter, so they carry the same signal. Use one only when the brand already owns it. None appears in a preset, and none is ever the fallback when a choice feels hard.
|
|
226
|
-
|
|
227
|
-
**Commercial faces seen on award sites.** Use them only with a licence; otherwise take the free stand-in.
|
|
228
|
-
|
|
229
|
-
| Commercial | Free stand-in |
|
|
230
|
-
|---|---|
|
|
231
|
-
| Canela | Newsreader, Gambetta |
|
|
232
|
-
| PP Editorial New | Instrument Serif (E6), Gambetta |
|
|
233
|
-
| PP Neue Montreal, Neue Haas Grotesk | General Sans |
|
|
234
|
-
| Söhne | General Sans, Manrope |
|
|
235
|
-
| GT Sectra | Fraunces, Zodiak |
|
|
236
|
-
|
|
237
|
-
### 3.5 Three tests before committing
|
|
238
|
-
|
|
239
|
-
1. **Structure contrast.** Serif against geometric, or one family in two roles. Two similar sans faces from different families read as a mistake. (S7 passes because Sora's width against Instrument Sans's compact text is the contrast.)
|
|
240
|
-
2. **96 and 16.** Set the headline at 96px and a paragraph at 16px on the real surface. If the paragraph tires you, the text face is wrong.
|
|
241
|
-
3. **Squint.** Blur your eyes. The headline is the first shape; the meta line recedes to texture. If two tiers compete, the gap is too small, not the fonts.
|
|
242
|
-
|
|
243
|
-
---
|
|
244
|
-
|
|
245
|
-
## 4. Loading: variable, preloaded, no shift
|
|
246
|
-
|
|
247
|
-
1. **Variable files wherever the family has one.** One request carries every weight and the `opsz` axis.
|
|
248
|
-
2. **`display: "swap"`.** Fallback text paints immediately; invisible text costs more than a swap.
|
|
249
|
-
3. **Preload two files: display roman and body roman.** Italics and anything else get `preload: false`.
|
|
250
|
-
4. **Subset `latin`.** It covers English, Dutch, German, French and Spanish (`ë ï ü ö ä ß é`). Add `latin-ext` only for Polish, Czech, Turkish and similar.
|
|
251
|
-
5. **`adjustFontFallback`** generates a metric-matched fallback (`size-adjust`, ascent and descent overrides), so the swap shifts almost nothing. That matters twice: CLS, and SplitText line breaks.
|
|
252
|
-
|
|
253
|
-
```tsx
|
|
254
|
-
// app/fonts.ts: ONE pairing. Default: S1, Urbanist in both roles (Soft Modern).
|
|
255
|
-
import { Urbanist } from "next/font/google";
|
|
256
|
-
|
|
257
|
-
// One variable file carries every weight; both role variables point at it.
|
|
258
|
-
const urbanist = Urbanist({ subsets: ["latin"], variable: "--font-body-face", display: "swap" });
|
|
259
|
-
export const display = urbanist; // adjustFontFallback defaults to true for Google fonts
|
|
260
|
-
export const body = urbanist;
|
|
261
|
-
|
|
262
|
-
/* Another pairing REPLACES the block above; never load two pairings.
|
|
263
|
-
|
|
264
|
-
// S2 (Warm Editorial): a variable display with axes, plus its italic as its own call.
|
|
265
|
-
// import { Fraunces, Urbanist } from "next/font/google";
|
|
266
|
-
// export const display = Fraunces({
|
|
267
|
-
// subsets: ["latin"], axes: ["opsz", "SOFT"], variable: "--font-display-face", display: "swap",
|
|
268
|
-
// });
|
|
269
|
-
// export const displayItalic = Fraunces({
|
|
270
|
-
// subsets: ["latin"], style: "italic", axes: ["opsz", "SOFT"],
|
|
271
|
-
// variable: "--font-display-italic-face", display: "swap", preload: false, // the accent word, not LCP
|
|
272
|
-
// });
|
|
273
|
-
// export const body = Urbanist({ subsets: ["latin"], variable: "--font-body-face", display: "swap" });
|
|
274
|
-
|
|
275
|
-
// S4 (Luxury Minimal): a static family states its weights.
|
|
276
|
-
// import { Cormorant_Garamond, Jost } from "next/font/google";
|
|
277
|
-
// export const display = Cormorant_Garamond({ weight: ["300", "400"], subsets: ["latin"], variable: "--font-display-face", display: "swap" });
|
|
278
|
-
// export const displayItalic = Cormorant_Garamond({ weight: "300", style: "italic", subsets: ["latin"], variable: "--font-display-italic-face", display: "swap", preload: false });
|
|
279
|
-
// export const body = Jost({ subsets: ["latin"], variable: "--font-body-face", display: "swap" });
|
|
280
|
-
|
|
281
|
-
// Fontshare (Extended): download the family, commit the web-kit WOFF2 (the variable file when offered).
|
|
282
|
-
// import localFont from "next/font/local";
|
|
283
|
-
// export const display = localFont({
|
|
284
|
-
// src: [{ path: "../fonts/Gambetta-Variable.woff2", weight: "300 700", style: "normal" }],
|
|
285
|
-
// variable: "--font-display-face", display: "swap",
|
|
286
|
-
// adjustFontFallback: "Times New Roman", // "Arial" for a sans, "Times New Roman" for a serif
|
|
287
|
-
// fallback: ["Georgia", "serif"],
|
|
288
|
-
// });
|
|
289
|
-
*/
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
```css
|
|
293
|
-
/* S1 theme: one family fills both role tokens */
|
|
294
|
-
@theme inline {
|
|
295
|
-
--font-display: var(--font-body-face), system-ui, sans-serif;
|
|
296
|
-
--font-body: var(--font-body-face), system-ui, sans-serif;
|
|
297
|
-
}
|
|
298
|
-
/* Pairings with a separate italic call: the accent word picks it up */
|
|
299
|
-
:where(h1, h2, .font-display) em { font-family: var(--font-display-italic-face), var(--font-display); font-style: italic; }
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
Wire the variables onto `<html>` exactly as in [vibe-presets.md](vibe-presets.md): `${body.variable}` for S1, `${display.variable} ${body.variable}` (plus `${displayItalic.variable}`) for a two-face pairing. The italic gets its own call with `preload: false`, so the roman display and body stay the only two preloaded files; one call with `style: ["normal", "italic"]` also works and is simpler, but preloads the italic too. If a Fontshare family ships only static cuts, list each weight you use (three at most) as its own `src` entry. Read the licence on the family page once; Fontshare's free licence covers commercial use. Fraunces also has a `WONK` axis; leave it off unless the brand is genuinely playful. `SOFT` must be listed in `axes` before `font-variation-settings` can use it (§3.1).
|
|
303
|
-
|
|
304
|
-
**`lang` is not decoration.** It drives hyphenation, quote selection and fallback choice. Set it on `<html>` and on any element whose copy switches language.
|
|
305
|
-
|
|
306
|
-
### Fonts and SplitText
|
|
307
|
-
|
|
308
|
-
- **Lines: `autoSplit: true`, animation returned from `onSplit`.** SplitText re-splits when fonts finish loading and when the element's width changes, reverting the returned animation first and carrying its progress into the new one. This API needs GSAP 3.13 or later; never `new SplitText()` plus a manual resize listener.
|
|
309
|
-
- **Words or chars without `autoSplit`: split after `document.fonts.ready`, inside the matchMedia context** (char recipe in §5): `document.fonts.ready.then(() => live && ctx.add(() => { ... }))`. `ctx.add` puts the late split and tween in the same context the media query reverts, and `live` stops a resolve that lands after unmount or a media change. Split before the fonts land and it measures the fallback face.
|
|
310
|
-
- **`adjustFontFallback` makes the re-split nearly invisible:** pre-swap breaks land within a word of the final ones.
|
|
311
|
-
- **The options this skill uses:** `type`, `linesClass`, `mask`, `autoSplit`, `onSplit`, `aria`. `aria` defaults to `"auto"`: the split element gets an `aria-label` with its full text and the fragments are hidden from assistive tech. A label flattens markup, so do not split a heading that contains links.
|
|
312
|
-
- **Where the accessible name lives.** `aria-label` is only reliable on a heading, a link, a button or another element with a role; on a `span`, `div` or `p` (generic roles) screen readers may ignore it. So: split a heading directly and `"auto"` is right (`line-rise-hero`). For a link or button, put `aria-label` on that element and split inner `aria-hidden` spans with `aria: "none"` (SwapLink). For a paragraph or a bare word, render an `sr-only` copy next to an `aria-hidden` visual copy and split the visual one with `aria: "none"` (Manifesto, CharWord). `aria: "hidden"` hides the fragments without adding a label, for a split whose text already exists elsewhere.
|
|
313
|
-
- **Line masks and descenders.** With `mask: "lines"` each line sits in a wrapper with the class `linesClass + "-mask"` (`split-line-mask` here), which clips at tight leading. Pad the mask and pull the margin back so layout does not move: `.split-line-mask { padding-block: 0.1em; margin-block: -0.1em; }`. Every other way a descender or an italic overhang gets clipped, and the 10-second test: [pitfalls.md](pitfalls.md) §1.
|
|
314
|
-
- **Chars drop kerning.** Each character becomes an inline block, so pairs like `To`, `AV` and `Ty` open up at 8rem. Use chars only on short words and revert the split when the animation ends.
|
|
315
|
-
|
|
316
|
-
---
|
|
317
|
-
|
|
318
|
-
## 5. Choreography recipes
|
|
319
|
-
|
|
320
|
-
All recipes import from `@/lib/gsap` ([setup.md](setup.md)), live in a `"use client"` leaf with a scope ref, and create animation only inside the `no-preference` branch of `gsap.matchMedia()`. Reduced-motion users get the server markup as the final state with no animation work.
|
|
321
|
-
|
|
322
|
-
### The typographic signature move
|
|
323
|
-
|
|
324
|
-
**Every STRUQ.md declares one signature move**, the single typographic gesture the site is remembered by. Pick one:
|
|
325
|
-
|
|
326
|
-
- **`weight-in`**: the hero's `wght` rises from 100 to its resting 300 together with a short y-rise, once, on load (below).
|
|
327
|
-
- **`soft-scrub`**: Fraunces `SOFT` scrubbed from 0 to 100 across the hero's departure, so the headline softens as it leaves (below).
|
|
328
|
-
- **`type-mask-media`**: the display word is a window onto a photograph or video (the media clipped to the glyphs with `background-clip: text`, or an SVG `<mask>` for video), with the same words in the DOM.
|
|
329
|
-
- **`fit-width-wordmark`**: the wordmark set edge to edge at the exact container width, its size computed once from a measurement and on resize, never guessed in `vw`.
|
|
330
|
-
- **A cropped display**: one display line deliberately cut by the canvas edge, the rest of the word implied.
|
|
331
|
-
|
|
332
|
-
One per site, written into STRUQ.md next to the pairing. `manifesto-fill` (below) only when the copy is a genuine manifesto. The masked line rise is a technique, not a signature. It is allowed as the delivery of a named entrance (Warm Editorial rises, then soft-scrubs) and as the fallback entrance. **The tell is precise:** the rise as the hero's *only* gesture, with no signature move on the hero and no preset entrance behind it (§7).
|
|
333
|
-
|
|
334
|
-
### `weight-in`: the weight arrives with the line
|
|
335
|
-
|
|
336
|
-
```tsx
|
|
337
|
-
"use client";
|
|
338
|
-
import { useRef } from "react";
|
|
339
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
340
|
-
|
|
341
|
-
export function WeightInHeadline({ children }: { children: string }) {
|
|
342
|
-
const root = useRef<HTMLDivElement>(null);
|
|
343
|
-
|
|
344
|
-
useGSAP(() => {
|
|
345
|
-
const mm = gsap.matchMedia();
|
|
346
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
347
|
-
const h1 = root.current!.querySelector<HTMLElement>("[data-split-hero]")!;
|
|
348
|
-
SplitText.create(h1, {
|
|
349
|
-
type: "lines",
|
|
350
|
-
autoSplit: true,
|
|
351
|
-
onSplit(self) {
|
|
352
|
-
h1.dataset.ready = "1"; // same pre-split guard as line-rise-hero
|
|
353
|
-
return gsap.from(self.lines, {
|
|
354
|
-
fontWeight: 100, yPercent: 40, autoAlpha: 0,
|
|
355
|
-
duration: 1.6, ease: "expo.out", stagger: 0.09,
|
|
356
|
-
});
|
|
357
|
-
},
|
|
358
|
-
});
|
|
359
|
-
});
|
|
360
|
-
}, { scope: root });
|
|
361
|
-
|
|
362
|
-
return (
|
|
363
|
-
<div ref={root}>
|
|
364
|
-
<h1 data-split-hero className="font-display text-display max-w-[16ch]">{children}</h1>
|
|
365
|
-
</div>
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
- The lines are measured at the resting weight, then animated *from* 100, which is narrower, so no line can overflow and rewrap mid-move.
|
|
371
|
-
- It needs a variable face whose `wght` axis reaches 100: Urbanist, Fraunces, Outfit, Sora and Jost do; Cormorant (300), Newsreader and Manrope (200) do not, so start from their minimum. The 100 is a transit value and does not count against the three-weight budget (§2.2).
|
|
372
|
-
- Animating weight is a layout per frame. That is acceptable once, on load, on one element; never on scroll and never on body copy.
|
|
373
|
-
|
|
374
|
-
### `soft-scrub`: the headline softens as it leaves
|
|
375
|
-
|
|
376
|
-
```tsx
|
|
377
|
-
"use client";
|
|
378
|
-
import { useRef } from "react";
|
|
379
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
380
|
-
|
|
381
|
-
export function SoftScrubHero({ children }: { children: string }) {
|
|
382
|
-
const root = useRef<HTMLElement>(null);
|
|
383
|
-
|
|
384
|
-
useGSAP(() => {
|
|
385
|
-
const mm = gsap.matchMedia();
|
|
386
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
387
|
-
gsap.fromTo("[data-soft]", { fontVariationSettings: '"SOFT" 0' }, {
|
|
388
|
-
fontVariationSettings: '"SOFT" 100',
|
|
389
|
-
ease: "none",
|
|
390
|
-
scrollTrigger: { trigger: root.current, start: "top top", end: "bottom top", scrub: 1 },
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
}, { scope: root });
|
|
394
|
-
|
|
395
|
-
return (
|
|
396
|
-
<section ref={root} className="min-h-svh">
|
|
397
|
-
<h1 data-soft className="font-display text-display max-w-[16ch] [font-variation-settings:'SOFT'_0]">{children}</h1>
|
|
398
|
-
</section>
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
- Fraunces only (S2, S3), with `SOFT` in the font's `axes` (§4). `opsz` stays automatic because it is not in the settings string.
|
|
404
|
-
- `SOFT` reshapes terminals without changing advance widths, so this is a repaint, not a layout. Still one per page.
|
|
405
|
-
- Reduced motion keeps the resting `SOFT 0`; nothing animates. The hero rests at the scrub's start, so it never flashes from a pinned 50 to 0 on hydrate; keep the site-wide `SOFT 50` pin in `@layer base` (§3.1 Fraunces note) so this utility wins on the hero.
|
|
406
|
-
|
|
407
|
-
### Index-list hover: weight, not colour
|
|
408
|
-
|
|
409
|
-
A list of case titles or services in `type-h3`: on hover the row's title moves from `wght` 300 to 450 over 0.4s. It reads as the line leaning toward the pointer, which a colour change never does.
|
|
410
|
-
|
|
411
|
-
```css
|
|
412
|
-
.index-row .type-h3 { font-weight: 300; }
|
|
413
|
-
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
|
414
|
-
.index-row .type-h3 { transition: font-weight 0.4s var(--ease-out-quart); }
|
|
415
|
-
}
|
|
416
|
-
.index-row:hover .type-h3, .index-row:focus-visible .type-h3 { font-weight: 450; }
|
|
417
|
-
```
|
|
418
|
-
|
|
419
|
-
Keep each title on one line with room to its right: the heavier weight is wider, and a title that wraps on hover jumps the whole list.
|
|
420
|
-
|
|
421
|
-
### `line-rise-hero`: masked line rise on load
|
|
422
|
-
|
|
423
|
-
The hero variant of [scroll-reveals.md](scroll-reveals.md) §3: it fires on load, not on scroll, and it must not flash.
|
|
424
|
-
|
|
425
|
-
```css
|
|
426
|
-
/* globals.css: hide only while motion is allowed and the split has not happened */
|
|
427
|
-
@media (prefers-reduced-motion: no-preference) {
|
|
428
|
-
[data-split-hero]:not([data-ready]) { visibility: hidden; }
|
|
429
|
-
}
|
|
430
|
-
/* descender room inside each line mask (class = linesClass + "-mask"), with no layout jump */
|
|
431
|
-
.split-line-mask { padding-block: 0.1em; margin-block: -0.1em; }
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
```tsx
|
|
435
|
-
"use client";
|
|
436
|
-
import { useRef } from "react";
|
|
437
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
438
|
-
|
|
439
|
-
export function HeroHeadline({ children }: { children: string }) {
|
|
440
|
-
const root = useRef<HTMLDivElement>(null);
|
|
441
|
-
|
|
442
|
-
useGSAP(() => {
|
|
443
|
-
const mm = gsap.matchMedia();
|
|
444
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
445
|
-
const h1 = root.current!.querySelector<HTMLElement>("[data-split-hero]")!;
|
|
446
|
-
SplitText.create(h1, {
|
|
447
|
-
type: "lines",
|
|
448
|
-
mask: "lines",
|
|
449
|
-
linesClass: "split-line",
|
|
450
|
-
autoSplit: true,
|
|
451
|
-
onSplit(self) {
|
|
452
|
-
h1.dataset.ready = "1"; // lines are in their start pose; safe to show
|
|
453
|
-
return gsap.from(self.lines, { yPercent: 115, duration: 1.3, ease: "expo.out", stagger: 0.11 });
|
|
454
|
-
},
|
|
455
|
-
});
|
|
456
|
-
});
|
|
457
|
-
}, { scope: root });
|
|
458
|
-
|
|
459
|
-
return (
|
|
460
|
-
<div ref={root}>
|
|
461
|
-
<h1 data-split-hero className="font-display text-display max-w-[16ch]">{children}</h1>
|
|
462
|
-
</div>
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
- The h1 is split directly, so the default `aria: "auto"` puts the full text in an `aria-label` on a heading, where screen readers honour it.
|
|
468
|
-
- Add `<noscript><style>{"[data-split-hero]{visibility:visible!important}"}</style></noscript>` to the root layout's `<head>` so a JS failure never hides the h1.
|
|
469
|
-
- **Not the hero's only gesture.** This is one entrance among several. Each preset names its own ([vibe-presets.md](vibe-presets.md)); use the rise when the preset's entrance includes it (Warm Editorial) or as a declared fallback, and pair it with the site's signature move. A hero that only rises line by line, on every Struq site, is a template.
|
|
470
|
-
- This is the one place CSS hides content until JS runs, and it has a price: the hero is usually the LCP element, so its paint waits for hydration. Keep this component a small leaf with no heavy imports and measure LCP in verify ([seo.md](seo.md) §6).
|
|
471
|
-
- With a loader, start the rise as the loader exits ([page-entrances.md](page-entrances.md), [loaders.md](loaders.md)).
|
|
472
|
-
- **When not to:** every section headline. Those use the scroll-triggered version from scroll-reveals. The hero earns the loud one.
|
|
473
|
-
|
|
474
|
-
### Char stagger for one short word
|
|
475
|
-
|
|
476
|
-
For a single word of 3 to 12 characters: a section title, a number, a brand name. Never a sentence.
|
|
477
|
-
|
|
478
|
-
```tsx
|
|
479
|
-
"use client";
|
|
480
|
-
import { useRef } from "react";
|
|
481
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
482
|
-
|
|
483
|
-
export function CharWord({ word }: { word: string }) {
|
|
484
|
-
const root = useRef<HTMLDivElement>(null);
|
|
485
|
-
|
|
486
|
-
useGSAP(() => {
|
|
487
|
-
const mm = gsap.matchMedia();
|
|
488
|
-
mm.add("(prefers-reduced-motion: no-preference)", (ctx) => {
|
|
489
|
-
let live = true;
|
|
490
|
-
document.fonts.ready.then(() => live && ctx.add(() => { // measure the real face, not the fallback
|
|
491
|
-
const split = SplitText.create(root.current!.querySelector("[data-word]"), {
|
|
492
|
-
type: "lines,chars", mask: "lines", aria: "none", // the sr-only copy carries the name
|
|
493
|
-
});
|
|
494
|
-
gsap.from(split.chars, {
|
|
495
|
-
yPercent: 110, duration: 1, ease: "expo.out", stagger: 0.035,
|
|
496
|
-
scrollTrigger: { trigger: root.current, start: "top 80%", once: true },
|
|
497
|
-
onComplete: () => split.revert(), // restores kerning and ligatures
|
|
498
|
-
});
|
|
499
|
-
}));
|
|
500
|
-
return () => { live = false; };
|
|
501
|
-
});
|
|
502
|
-
}, { scope: root });
|
|
503
|
-
|
|
504
|
-
return (
|
|
505
|
-
<div ref={root}>
|
|
506
|
-
<p className="font-display text-display">
|
|
507
|
-
<span className="sr-only">{word}</span>
|
|
508
|
-
<span data-word aria-hidden className="block">{word}</span>
|
|
509
|
-
</p>
|
|
510
|
-
</div>
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
- `ctx.add` runs the late split inside the matchMedia context, so the media query flipping or the component unmounting reverts it with everything else; `live` stops a resolve that lands after either.
|
|
516
|
-
- The `sr-only` copy is the accessible text; the visual copy is `aria-hidden` and split with `aria: "none"`, so assistive tech reads one clean word, never a list of letters.
|
|
517
|
-
- `stagger: { each: 0.035, from: "center" }` suits a centred word. Never `from: "random"` on anything a client reads twice.
|
|
518
|
-
- **When not to:** body copy, phrases longer than two words (a typewriter toy), more than one per viewport.
|
|
519
|
-
|
|
520
|
-
### `scrub-headline`: oversized headline on scroll
|
|
521
|
-
|
|
522
|
-
An oversized line that slides and scales with scroll and runs off the canvas. With modest travel it stays readable, so it can be the real heading, set in the display role (the page's one other display line). As pure texture it takes `text-ghost` instead (last bullet).
|
|
523
|
-
|
|
524
|
-
```tsx
|
|
525
|
-
"use client";
|
|
526
|
-
import { useRef } from "react";
|
|
527
|
-
import { gsap, useGSAP } from "@/lib/gsap";
|
|
528
|
-
|
|
529
|
-
export function ScrubHeadline({ children }: { children: string }) {
|
|
530
|
-
const section = useRef<HTMLElement>(null);
|
|
531
|
-
|
|
532
|
-
useGSAP(() => {
|
|
533
|
-
const mm = gsap.matchMedia();
|
|
534
|
-
mm.add({ motion: "(prefers-reduced-motion: no-preference)", small: "(max-width: 767px)" }, (ctx) => {
|
|
535
|
-
const { motion, small } = ctx.conditions!;
|
|
536
|
-
if (!motion) return;
|
|
537
|
-
const travel = small ? 4 : 10; // percent; authored smaller on phones
|
|
538
|
-
gsap.fromTo("[data-big]",
|
|
539
|
-
{ xPercent: travel, scale: 0.94, transformOrigin: "0% 50%" },
|
|
540
|
-
{ xPercent: -travel, scale: 1.04, ease: "none",
|
|
541
|
-
scrollTrigger: { trigger: section.current, start: "top bottom", end: "bottom top", scrub: 1 } });
|
|
542
|
-
});
|
|
543
|
-
}, { scope: section });
|
|
544
|
-
|
|
545
|
-
return (
|
|
546
|
-
<section ref={section} className="overflow-clip py-[14rem]">
|
|
547
|
-
<h2 data-big className="font-display text-display whitespace-nowrap">
|
|
548
|
-
{children}
|
|
549
|
-
</h2>
|
|
550
|
-
</section>
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
- Scrubbed tweens use `ease: "none"`; `scrub: 1` supplies the weight ([feel-layer.md](feel-layer.md)).
|
|
556
|
-
- `xPercent` and `scale` stay on the compositor. Scrubbing `letter-spacing` or a `wght` axis costs a layout per frame; budget at most one, on a short line.
|
|
557
|
-
- Past about 15% travel the line is unreadable mid-move. Then it is texture: `font-display text-ghost`, `aria-hidden`, ghost colour, and the real h2 below it.
|
|
558
|
-
- The `scale` up to 1.04 sits on top of the token's clamp, so the line reads larger than the hero for a moment without a second arbitrary size. `text-display` already carries the pairing's leading and tracking.
|
|
559
|
-
- **When not to:** more than one per page, or on a line people must read to understand the section.
|
|
560
|
-
|
|
561
|
-
### `manifesto-fill`: word-by-word color fill
|
|
562
|
-
|
|
563
|
-
A large paragraph "reads itself": words go from faint to full ink as the reader scrolls.
|
|
564
|
-
|
|
565
|
-
```tsx
|
|
566
|
-
"use client";
|
|
567
|
-
import { useRef } from "react";
|
|
568
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
569
|
-
|
|
570
|
-
export function Manifesto({ children }: { children: string }) {
|
|
571
|
-
const root = useRef<HTMLDivElement>(null);
|
|
572
|
-
|
|
573
|
-
useGSAP(() => {
|
|
574
|
-
const mm = gsap.matchMedia();
|
|
575
|
-
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
|
576
|
-
SplitText.create(root.current!.querySelector("[data-fill]"), {
|
|
577
|
-
type: "words",
|
|
578
|
-
aria: "none", // the visual copy is aria-hidden; the sr-only copy is what is read
|
|
579
|
-
autoSplit: true,
|
|
580
|
-
onSplit: (self) =>
|
|
581
|
-
gsap.fromTo(self.words, { opacity: 0.16 }, {
|
|
582
|
-
opacity: 1, ease: "none", stagger: 0.1,
|
|
583
|
-
scrollTrigger: { trigger: root.current, start: "top 75%", end: "bottom 45%", scrub: 0.6 },
|
|
584
|
-
}),
|
|
585
|
-
});
|
|
586
|
-
});
|
|
587
|
-
}, { scope: root });
|
|
588
|
-
|
|
589
|
-
return (
|
|
590
|
-
<div ref={root}>
|
|
591
|
-
<p className="sr-only">{children}</p>
|
|
592
|
-
<p data-fill aria-hidden className="font-display text-statement max-w-[30ch]">
|
|
593
|
-
{children}
|
|
594
|
-
</p>
|
|
595
|
-
</div>
|
|
596
|
-
);
|
|
597
|
-
}
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
- **Only for a genuine manifesto:** copy written as a statement of belief, read slowly. Pasting a features paragraph into it is the tell.
|
|
601
|
-
- The `sr-only` paragraph is what assistive tech reads; the split copy is `aria-hidden` with `aria: "none"`, so no generic `p` is left carrying an `aria-label`.
|
|
602
|
-
- Opacity on `text-ink` is the compositor-cheap fill and stays inside the palette. The 0.16 start fails contrast on purpose, so the fill must complete before the paragraph leaves the viewport; for long copy, pin it ([pinning-scrub.md](pinning-scrub.md)).
|
|
603
|
-
- Split words, not chars: 60 to 90 words is fine, 400 is not.
|
|
604
|
-
- **When not to:** more than one per site, or text people skim (pricing, terms, specs).
|
|
605
|
-
|
|
606
|
-
### Ghost marquee
|
|
607
|
-
|
|
608
|
-
The scroll-drift mechanic and its code live in [depth-parallax.md](depth-parallax.md) §4. The type setup on top: `text-ghost` size, the display face at its lightest loaded weight, `text-ink/[0.05]` (0.04 to 0.06; higher competes with content, lower vanishes on cheap panels), `whitespace-nowrap`, the phrase repeated three times so the window never runs dry, `aria-hidden` on the wrapper. Keep it muted solid type; outlined ghost type (`-webkit-text-stroke` with a transparent fill) is a dated template move, so it is not offered here. **When not to:** light surfaces where 5% ink reads as a smudge, or pages that already run a marquee.
|
|
609
|
-
|
|
610
|
-
### Text-swap hover
|
|
611
|
-
|
|
612
|
-
The CSS slot-machine label is in [micro-interactions.md](micro-interactions.md) §3c. The per-char upgrade needs SplitText and belongs on exactly one element, the main CTA or the logo:
|
|
613
|
-
|
|
614
|
-
```tsx
|
|
615
|
-
"use client";
|
|
616
|
-
import { useRef } from "react";
|
|
617
|
-
import { gsap, useGSAP, SplitText } from "@/lib/gsap";
|
|
618
|
-
|
|
619
|
-
export function SwapLink({ href, label }: { href: string; label: string }) {
|
|
620
|
-
const root = useRef<HTMLAnchorElement>(null);
|
|
621
|
-
const tl = useRef<gsap.core.Timeline | null>(null);
|
|
622
|
-
|
|
623
|
-
useGSAP(() => {
|
|
624
|
-
const mm = gsap.matchMedia();
|
|
625
|
-
mm.add("(hover: hover) and (prefers-reduced-motion: no-preference)", () => {
|
|
626
|
-
// the <a> carries the name; both visual copies are aria-hidden, so the split adds nothing
|
|
627
|
-
const a = SplitText.create(root.current!.querySelector("[data-a]"), { type: "chars", aria: "none" });
|
|
628
|
-
const b = SplitText.create(root.current!.querySelector("[data-b]"), { type: "chars", aria: "none" });
|
|
629
|
-
tl.current = gsap.timeline({ paused: true, defaults: { duration: 0.5, ease: "expo.out", stagger: 0.015 } })
|
|
630
|
-
.set("[data-b]", { autoAlpha: 1 }, 0)
|
|
631
|
-
.to(a.chars, { yPercent: -110 }, 0)
|
|
632
|
-
.fromTo(b.chars, { yPercent: 110 }, { yPercent: 0 }, 0);
|
|
633
|
-
return () => { tl.current = null; };
|
|
634
|
-
});
|
|
635
|
-
}, { scope: root });
|
|
636
|
-
|
|
637
|
-
return (
|
|
638
|
-
<a ref={root} href={href} aria-label={label} className="font-ui text-ui relative inline-flex overflow-hidden"
|
|
639
|
-
onPointerEnter={() => tl.current?.play()} onPointerLeave={() => tl.current?.reverse()}
|
|
640
|
-
onFocus={() => tl.current?.play()} onBlur={() => tl.current?.reverse()}>
|
|
641
|
-
<span data-a aria-hidden>{label}</span>
|
|
642
|
-
<span data-b aria-hidden className="invisible absolute inset-0">{label}</span>
|
|
643
|
-
</a>
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
The timeline exists only on hover-capable devices with motion allowed; elsewhere the handlers are no-ops and the link is plain. Handlers only `play()` and `reverse()` a timeline built inside `useGSAP`, so they need no `contextSafe`. **When not to:** every nav link. If every label rolls, none is special.
|
|
649
|
-
|
|
650
|
-
---
|
|
651
|
-
|
|
652
|
-
## 6. Details that sell it
|
|
653
|
-
|
|
654
|
-
**Real punctuation.** Type the real characters in copy: `’` for apostrophes, `“ ”` for English quotes, `„ “` for German, `…` for an ellipsis. Straight `"` and `'` are keyboard artefacts. Browsers pick `<q>` quote marks from `lang`.
|
|
655
|
-
|
|
656
|
-
**Numbers: `tabular-nums`, not mono.** Counters and tables need equal-width digits so a ticking number does not wobble. That is a font feature (`font-variant-numeric: lining-nums tabular-nums`, Tailwind `tabular-nums lining-nums`), not a monospace face. Check the face ships tabular figures; if the display face lacks them, set stats in the text face, which is why the numerals column in §3 names a face. **Running text is the opposite:** in Cormorant and Fraunces paragraphs, pull quotes and leads, a year or a figure set in lining tabular digits stands up like a capital. Use oldstyle proportional figures there (`font-variant-numeric: oldstyle-nums proportional-nums`) and keep lining tabular for counters, prices and tables.
|
|
657
|
-
|
|
658
|
-
**Optical margin alignment.** A display or h2 line that starts with a round or open letter (`O`, `C`, `G`, `T`, `W`, a quote mark) looks indented next to the body copy's straight edge, because the side bearing and the curve's overshoot add white. Pull the heading back a hair: `margin-inline-start: -0.04em` to `-0.07em`, tuned per face at display size. Round starts need the most; serifs with a flat stem start need the least. Set it per pairing on the display and h2 roles, never on body text.
|
|
659
|
-
|
|
660
|
-
**Ligatures and kerning.** Leave both at their defaults; never switch on discretionary ligatures site-wide. SplitText chars disable both (§4).
|
|
661
|
-
|
|
662
|
-
**`::selection`.** `::selection { background: var(--color-accent); color: var(--color-surface-0); }` is part of the token contract ([vibe-presets.md](vibe-presets.md)). Selection is the moment people stare at your type, and default blue says "unfinished".
|
|
663
|
-
|
|
664
|
-
**Hanging punctuation.** `hanging-punctuation: first` works only in Safari. On pull quotes elsewhere, fake it with a negative `text-indent` of about 0.4em, tuned per face.
|
|
665
|
-
|
|
666
|
-
**Dutch and German compounds.** Words like `Duurzaamheidsrapportage` overflow narrow columns. Set `lang` and hyphenate body copy, never headlines:
|
|
667
|
-
|
|
668
|
-
```css
|
|
669
|
-
:lang(nl) .prose, :lang(de) .prose { hyphens: auto; hyphenate-limit-chars: 7 3 3; } /* limit: progressive */
|
|
670
|
-
h1, h2, h3 { hyphens: manual; overflow-wrap: break-word; }
|
|
671
|
-
```
|
|
672
|
-
|
|
673
|
-
In a headline, place one soft hyphen by hand (`Duurzaam­heid`), never inside a word you split into chars. Dictionaries differ per browser and device: check the hero at 390px on a real phone with the longest real word in the copy.
|
|
674
|
-
|
|
675
|
-
**One accent word.** One word of a headline in the display italic is the cheapest high-end move there is, when the display face has a real italic and it is loaded (S2, S3, S4, S5). Wrap it in `<em>`; SplitText keeps nested inline elements. Geometric pairings (S1, S6, S7) shift that one word from 300 to 500 or set it in the accent colour instead. The accent word stays in the headline's own family: a serif italic `<em>` dropped into a sans headline is the template tell of the moment (§7).
|
|
676
|
-
|
|
677
|
-
**No eyebrows.** A small uppercase tracked label above a heading is the most common AI tell on a marketing page. Wayfinding comes from the heading itself, a hairline rule, a change of surface step and a jump in scale. Never a stamped `01 / 02 / 03` either ([anti-patterns.md](anti-patterns.md)).
|
|
678
|
-
|
|
679
|
-
---
|
|
680
|
-
|
|
681
|
-
## 7. Typography tell list
|
|
682
|
-
|
|
683
|
-
Sweep before shipping, alongside [anti-patterns.md](anti-patterns.md).
|
|
684
|
-
|
|
685
|
-
| Tell | Why it reads as generated | Replace with |
|
|
686
|
-
|---|---|---|
|
|
687
|
-
| Inter, Roboto, Open Sans, Poppins, Montserrat or Lato anywhere | Starter and UI-kit defaults | A pairing from §3 |
|
|
688
|
-
| Space Grotesk or Geist as the "technical" reflex | The generator's idea of modern | S7 (Sora + Instrument Sans) or S1 (Urbanist) |
|
|
689
|
-
| `tracking-tight`, `text-xs` or `text-3xl` to `text-5xl` on headings or labels | The role tokens were bypassed; the size and optics are improvised | `text-h2`, `text-h3`, `text-meta` and the role faces (§2.4) |
|
|
690
|
-
| `text-ink-faint` on any text | `ink-faint` is for hairlines, disabled states and placeholder rules; as text it fails contrast | `text-ink-muted` for meta |
|
|
691
|
-
| An Instrument Serif `<em>` inside a sans headline | The 2025 to 2026 template tell | The pairing's own accent move (§3.1) |
|
|
692
|
-
| Cream surface plus terracotta accent with no brand reason | The default "warm, human" palette of every generator | A palette argued from the brand ([vibe-presets.md](vibe-presets.md)) |
|
|
693
|
-
| The masked line rise as the hero's only gesture (no signature move on it, no preset entrance behind it) | One entrance on every site is a template | The preset's named hero entrance and the site's signature move (§5); the rise may deliver them |
|
|
694
|
-
| An arbitrary `text-[clamp(...)]` on a manifesto, statement or oversized line | The role tokens were bypassed | `text-statement`, `text-display` or `text-ghost` (§2.4) |
|
|
695
|
-
| `font-bold` on every heading | Safe at 24px, loud at 115px | Weight 300 to 400, hierarchy by size |
|
|
696
|
-
| 700 for emphasis inside light type | A hole in the page | 500 |
|
|
697
|
-
| Four or more weights of one family | Extra voices, extra bytes | Three at most (300, 400, 500) |
|
|
698
|
-
| Five similar sizes (`text-lg` to `text-4xl`) | Flat page, no focal point | The roles with real gaps (§2) |
|
|
699
|
-
| Mono labels, stats or badges | Dev-tool cosplay | The text face in sentence case; `tabular-nums` |
|
|
700
|
-
| A rounded or hairline display face used for body | Charming at 60px, tiring at 16px | Display faces stay at display sizes |
|
|
701
|
-
| Everything centred | No axis, no reading edge | Left-aligned on a strong edge; centre only short single lines |
|
|
702
|
-
| Gradient text | The 2021 SaaS tell, and it fails contrast | Solid ink; accent on one word at most |
|
|
703
|
-
| One `tracking-tight` on all sizes | A utility, not optics | Tracking per role and face (§2.3, §3) |
|
|
704
|
-
| Faux bold or italic | Smeared strokes | `font-synthesis: none`; load the real cut |
|
|
705
|
-
| Straight quotes, `...`, `--` | Keyboard text, not typeset text | Real quotes, `…`, proper punctuation |
|
|
706
|
-
| Orphans and one-word last lines | No wrap control | `balance` on headings, `pretty` on body |
|
|
707
|
-
| Body under 1rem, muted under 0.875rem | Fails the baseline floor | Body 1rem to 1.125rem, contrast 4.5:1+ |
|
|
708
|
-
| Char-split sentences | Typewriter toy | Lines for sentences, chars for one word |
|
|
709
|
-
| Hero flashes unsplit, then jumps | No pre-split guard | The `line-rise-hero` guard (§5) |
|
|
710
|
-
|
|
711
|
-
## 8. Thirty-second type audit
|
|
712
|
-
|
|
713
|
-
1. Two faces, display at 300 to 400 unless the preset declares heavy, three weights per family at most?
|
|
714
|
-
2. Every string on the page maps to one of the roles in §2.1, with that role's token?
|
|
715
|
-
3. Hero at the pairing's display clamp (standard `clamp(2.875rem, 1.2rem + 6.86vw, 8rem)`) with its tracking and leading, not defaults? One signature move declared?
|
|
716
|
-
4. Body 400, at least 1rem, 55 to 75ch, `text-wrap: pretty`; emphasis at 500?
|
|
717
|
-
5. `next/font`, two preloaded files, zero visible shift on a hard reload?
|
|
718
|
-
6. Hero hidden until split, no flash, `lang` set, longest real word checked at 390px?
|
|
719
|
-
7. `tabular-nums` on stats, real quotes, `::selection`, `font-synthesis: none`?
|
|
720
|
-
8. Reduced motion: every line of type visible and static with no animation work?
|