@struq/skill 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -35
- package/package.json +3 -2
- package/skills/struq/SKILL.md +148 -212
- 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/blocks/kit/Cursor.tsx +117 -0
- package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
- package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
- package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
- package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
- package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
- package/skills/struq/blocks/kit/Reveal.tsx +64 -0
- package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
- package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/gsap.ts +21 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
- package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
- package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
- package/skills/struq/blocks/kit/scenes/types.ts +114 -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/blocks/showpieces/fill-rows.tsx +74 -0
- package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
- package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
- package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
- 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 -0
- package/skills/struq/recipes/app/globals.css +42 -0
- package/skills/struq/recipes/app/layout.tsx +30 -0
- 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 +134 -0
- package/skills/struq/scripts/check.mjs +513 -0
- package/skills/struq/scripts/images.mjs +360 -0
- package/skills/struq/scripts/log.mjs +75 -0
- package/skills/struq/scripts/packs.mjs +156 -0
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/references/anti-patterns.md +0 -265
- package/skills/struq/references/baseline-prompt.md +0 -118
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -219
- package/skills/struq/references/composition.md +0 -191
- 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/imagery.md +0 -144
- 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/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 -341
- 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 -280
- 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
|
@@ -21,7 +21,7 @@ export function LenisRoot({ children }: LenisRootProps): JSX.Element {
|
|
|
21
21
|
useEffect(() => {
|
|
22
22
|
// Registered unconditionally; the instance is resolved through the ref on
|
|
23
23
|
// every frame. Never capture it once and early-return: that is the
|
|
24
|
-
// dead-scroll race
|
|
24
|
+
// dead-scroll race.
|
|
25
25
|
const update = (time: number) => {
|
|
26
26
|
lenisRef.current?.lenis?.raf(time * 1000);
|
|
27
27
|
};
|
|
@@ -55,7 +55,7 @@ a {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/* Demo values. In a Struq build the h1 takes `font-display text-display` and the
|
|
58
|
-
pairing's tracking
|
|
58
|
+
pairing's tracking; the clamp keeps a rem term for zoom. */
|
|
59
59
|
.experience-copy h1 {
|
|
60
60
|
margin: 0;
|
|
61
61
|
font-size: clamp(2.875rem, 1.2rem + 6.86vw, 8rem);
|
|
@@ -16,7 +16,7 @@ export function LenisRoot({ children }: LenisRootProps): JSX.Element {
|
|
|
16
16
|
useLenis(() => ScrollTrigger.update());
|
|
17
17
|
|
|
18
18
|
useEffect(() => {
|
|
19
|
-
// Registered unconditionally, instance resolved per frame
|
|
19
|
+
// Registered unconditionally, instance resolved per frame.
|
|
20
20
|
const update = (time: number) => {
|
|
21
21
|
lenisRef.current?.lenis?.raf(time * 1000);
|
|
22
22
|
};
|
|
@@ -118,7 +118,7 @@ These transitions should have explicit entry, active, and exit states.
|
|
|
118
118
|
|
|
119
119
|
## One clock, several signals
|
|
120
120
|
|
|
121
|
-
There is exactly one frame clock. Lenis is advanced from `gsap.ticker`, ScrollTrigger reads from Lenis, and the R3F canvas is advanced from the same ticker (
|
|
121
|
+
There is exactly one frame clock. Lenis is advanced from `gsap.ticker`, ScrollTrigger reads from Lenis, and the R3F canvas is advanced from the same ticker (the kit's `SmoothScroll` and [`../examples/code/shared/GsapFrameLoop.tsx`](../examples/code/shared/GsapFrameLoop.tsx)). Nothing in the experience runs its own `requestAnimationFrame` loop. Scroll progress and elapsed time are different signals read on that one clock, not different clocks.
|
|
122
122
|
|
|
123
123
|
At minimum, distinguish:
|
|
124
124
|
|
|
@@ -138,7 +138,7 @@ Offsetting related elements. A stagger should communicate reading order, directi
|
|
|
138
138
|
Mapping scroll progress to a timeline playhead.
|
|
139
139
|
|
|
140
140
|
### Smoothing
|
|
141
|
-
Reducing abrupt changes in a signal. Lenis smoothing, ScrollTrigger scrub smoothing, and manual damping should be assigned intentionally rather than stacked without control. Pair them
|
|
141
|
+
Reducing abrupt changes in a signal. Lenis smoothing, ScrollTrigger scrub smoothing, and manual damping should be assigned intentionally rather than stacked without control. Pair them: lower the Lenis lerp, lower the scrub number, and use `scrub: true` for attached layers and a small number for floating ones.
|
|
142
142
|
|
|
143
143
|
### Easing
|
|
144
144
|
The mathematical shape of a known transition between values.
|
|
@@ -73,7 +73,7 @@ Key integration responsibilities:
|
|
|
73
73
|
- Lenis is stopped during states that require scroll lock.
|
|
74
74
|
- native nested scrolling is handled deliberately.
|
|
75
75
|
|
|
76
|
-
Lenis must be created with `autoRaf: false`. If it keeps its own animation loop it becomes a second clock next to `gsap.ticker`, and scroll, ScrollTrigger and the canvas drift apart by a frame. The canonical setup
|
|
76
|
+
Lenis must be created with `autoRaf: false`. If it keeps its own animation loop it becomes a second clock next to `gsap.ticker`, and scroll, ScrollTrigger and the canvas drift apart by a frame. The canonical setup is the kit's `SmoothScroll` (`blocks/kit/SmoothScroll.tsx`); this is the conceptual shape.
|
|
77
77
|
|
|
78
78
|
Conceptual setup:
|
|
79
79
|
|
|
@@ -102,7 +102,7 @@ Do not add CSS `scroll-behavior: smooth` on top of Lenis.
|
|
|
102
102
|
|
|
103
103
|
## R3F frame-loop options
|
|
104
104
|
|
|
105
|
-
The single-clock rule applies: the canvas is rendered from `gsap.ticker`, the same callback chain that advances Lenis. The exact bridge is
|
|
105
|
+
The single-clock rule applies: the canvas is rendered from `gsap.ticker`, the same callback chain that advances Lenis. The exact bridge is shown in [`../examples/code/shared/GsapFrameLoop.tsx`](../examples/code/shared/GsapFrameLoop.tsx).
|
|
106
106
|
|
|
107
107
|
### Ticker-driven loop (default)
|
|
108
108
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// app/fonts.ts (Struq recipe). canon.mjs type <pairing> rewrites this file; the default is serif-editorial.
|
|
2
|
+
// Each Google face is asked for its real weight range, then as the bare family as a fallback, because Google refuses a
|
|
3
|
+
// request for a weight the family lacks. The layout puts one <link> per URL in <head>; the token block in globals.css
|
|
4
|
+
// names the faces (--face-display, --face-body, --face-accent), so the blocks measure with the real face.
|
|
5
|
+
export const fontHrefs: string[] = [
|
|
6
|
+
"https://fonts.googleapis.com/css2?family=Fraunces:wght@100..900&display=swap",
|
|
7
|
+
"https://fonts.googleapis.com/css2?family=Fraunces&display=swap",
|
|
8
|
+
"https://fonts.googleapis.com/css2?family=Urbanist:wght@100..900&display=swap",
|
|
9
|
+
"https://fonts.googleapis.com/css2?family=Urbanist&display=swap",
|
|
10
|
+
"https://fonts.googleapis.com/css2?family=Fraunces:ital,wght@1,100..900&display=swap",
|
|
11
|
+
"https://fonts.googleapis.com/css2?family=Fraunces:ital@1&display=swap",
|
|
12
|
+
];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
/* The block kit: type roles, the motion gate (.sq-js with its 3 s fallback), the contrast scenes and every block's
|
|
3
|
+
own CSS, all scoped to [data-blocks] (on <body>). */
|
|
4
|
+
@import "../components/struq/kit/blocks.css";
|
|
5
|
+
|
|
6
|
+
/* The theme names the blocks use that the kit leaves to the site. */
|
|
7
|
+
@theme inline {
|
|
8
|
+
--color-accent: var(--accent);
|
|
9
|
+
--color-accent-2: var(--accent-2);
|
|
10
|
+
--color-photo-ground: var(--photo-ground);
|
|
11
|
+
--font-display: var(--face-display), ui-serif, Georgia, serif;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* One fluid root-rem knob: author in rem, breakpoints change layout, never size. */
|
|
15
|
+
html {
|
|
16
|
+
font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* STRUQ TOKENS: palette graphite-ivory, type serif-editorial. Written by canon.mjs, the block Struq's lab and struq_richting give. */
|
|
20
|
+
[data-blocks] {
|
|
21
|
+
--surface-0: #F3F0E8;
|
|
22
|
+
--surface-1: #E8E1D5;
|
|
23
|
+
--surface-2: oklch(0.8674 0.0168 81.33);
|
|
24
|
+
--surface-3: oklch(0.8101 0.0154 81.33);
|
|
25
|
+
--ink: #272826;
|
|
26
|
+
--ink-muted: color-mix(in oklab, var(--ink) 66%, var(--surface-0));
|
|
27
|
+
--accent: #995029;
|
|
28
|
+
--accent-ink: #F3F0E8;
|
|
29
|
+
--accent-2: #2B4A56;
|
|
30
|
+
--line: color-mix(in oklab, var(--ink) 13%, transparent);
|
|
31
|
+
--on-photo: #F3F0E8;
|
|
32
|
+
--photo-ground: #272826;
|
|
33
|
+
--inverse: #272826;
|
|
34
|
+
--on-inverse: #F3F0E8;
|
|
35
|
+
--field: #995029;
|
|
36
|
+
--on-field: #F3F0E8;
|
|
37
|
+
color-scheme: light;
|
|
38
|
+
--display-weight: 300;
|
|
39
|
+
--face-display: "Fraunces";
|
|
40
|
+
--face-body: "Urbanist";
|
|
41
|
+
--face-accent: "Fraunces";
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// app/layout.tsx (Struq recipe). Set the title, the description and lang.
|
|
2
|
+
// The inline script puts `sq-js` on <html> before first paint: the kit's motion gate keys off it, so an animated
|
|
3
|
+
// element never flashes before its entrance, shows anyway after 3 s, and a visitor without JavaScript sees the page.
|
|
4
|
+
// data-blocks on <body> is the scope every block and the STRUQ TOKENS block read.
|
|
5
|
+
import type { Metadata } from "next";
|
|
6
|
+
import { SmoothScroll } from "../components/struq/kit/SmoothScroll";
|
|
7
|
+
import { fontHrefs } from "./fonts";
|
|
8
|
+
import "./globals.css";
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: "SITE_TITLE",
|
|
12
|
+
description: "SITE_DESCRIPTION",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
16
|
+
return (
|
|
17
|
+
<html lang="nl" suppressHydrationWarning>
|
|
18
|
+
<head>
|
|
19
|
+
<script dangerouslySetInnerHTML={{ __html: "document.documentElement.classList.add('sq-js')" }} />
|
|
20
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
|
21
|
+
{fontHrefs.map((href) => (
|
|
22
|
+
<link key={href} rel="stylesheet" href={href} />
|
|
23
|
+
))}
|
|
24
|
+
</head>
|
|
25
|
+
<body data-blocks="">
|
|
26
|
+
<SmoothScroll>{children}</SmoothScroll>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Craft: what makes the blocks feel high end
|
|
2
|
+
|
|
3
|
+
The blocks already do all of this. Read it when you write the glue between blocks, adapt one, or build a section the
|
|
4
|
+
catalogue does not have: your code has to hold the same bar or it shows as the seam.
|
|
5
|
+
|
|
6
|
+
## Motion
|
|
7
|
+
|
|
8
|
+
- **One clock.** Lenis runs on `gsap.ticker` (`kit/SmoothScroll.tsx`, mounted once in the layout); ScrollTrigger reads
|
|
9
|
+
it. No second `requestAnimationFrame` loop, no `anticipatePin` with smooth scroll. A menu or overlay calls
|
|
10
|
+
`getLenis()?.stop()` and `start()`.
|
|
11
|
+
- **Clip from numbers.** A reveal is a clip on a layer already at its full size, never a scale of the box. Tween plain
|
|
12
|
+
numbers (top, right, bottom, left, radius) and write `clip-path: inset(...)` from them in `onUpdate`; never tween
|
|
13
|
+
one clip-path string into another, which GSAP interpolates through broken intermediate values.
|
|
14
|
+
- **Whole pixels.** Round every computed translate, clip edge and fitted size to whole pixels (`Math.round`, or
|
|
15
|
+
`gsap.utils.snap(1)`), so type and hairlines never sit on half pixels and shimmer while they move.
|
|
16
|
+
- **Decode before show.** `await img.decode()` (with a catch) before a photo is revealed, cut to or swapped in; a
|
|
17
|
+
sequence of photos starts only when all are decoded. A reveal over an undecoded image shows a blank frame.
|
|
18
|
+
- **Scroll-tied and reversing.** A scroll effect scrubs (`scrub: true` or a short number) and runs backwards when the
|
|
19
|
+
visitor scrolls back: scrolling up re-sets the large wordmark, closes the print, lowers the line. A one-way
|
|
20
|
+
entrance on scroll reads as a template. Entrances on load play once.
|
|
21
|
+
- **Hold on the key frames.** A pinned stretch spends part of its length still on each composed frame. One idea per
|
|
22
|
+
screen, long eases (`expo.out` in, `power4.inOut` for curtains), nothing bounces.
|
|
23
|
+
- **Every entrance moves and fades,** never opacity alone. Lines rise through masks (`SplitText` with `mask: "lines"`,
|
|
24
|
+
the `.struq-line-mask` padding keeps descenders).
|
|
25
|
+
- **One handover signal.** Preloaders and page transitions close a cover (`data-curtain="closed"` on `<html>`) and fire
|
|
26
|
+
`curtain:lift` when it opens; a hero runs its entrance through `whenUncovered` from
|
|
27
|
+
`preloaders/preloader-slices-uncovered.ts`, so the same entrance serves the first load and every navigation.
|
|
28
|
+
|
|
29
|
+
## The gate
|
|
30
|
+
|
|
31
|
+
- `<html class="sq-js">` is set by an inline script before first paint (the layout recipe). The kit's CSS hides only
|
|
32
|
+
under `.sq-js`: `[data-split]`, `[data-reveal]` children and `[data-frame="mask"]` wait for `data-ready`, and a CSS
|
|
33
|
+
animation shows them anyway after 3 s. A visitor without JavaScript, or a script that throws, still gets the page.
|
|
34
|
+
- Never hide content with an inline `opacity: 0` or a class your script removes. Use the gate attributes, or your own
|
|
35
|
+
rule under `.sq-js` with the same 3 s fallback.
|
|
36
|
+
- Under `prefers-reduced-motion` every block lands on a composed still: the final frame, no pin, no scrub. Phones get
|
|
37
|
+
the same still where a pin would fight the thumb.
|
|
38
|
+
|
|
39
|
+
## Photos and type on photos
|
|
40
|
+
|
|
41
|
+
- **The calm side is measured.** `kit/photo-read.ts` reads the photo's luminance under the type's box; the heroes put
|
|
42
|
+
the headline on the side that needs the weaker veil. The beeldbank's `negativeSpace` sets the first paint and
|
|
43
|
+
breaks a tie; pass `side` only when you know better.
|
|
44
|
+
- **A light veil.** Type over a photo gets the weakest flat veil that gives the headline 3:1 and a running line 4.5:1,
|
|
45
|
+
measured from that photo, capped at about a quarter. Never a gradient scrim, never a dark band by habit.
|
|
46
|
+
`--photo-ground` is the palette's own dark ground for a bar or band laid over a photo.
|
|
47
|
+
- **Never an empty frame.** Every image slot holds a real photo from `lib/struq-images.ts` or the client. A photo shows
|
|
48
|
+
the world around the client, never their own room, team or product unless it is theirs; captions come from the alt.
|
|
49
|
+
|
|
50
|
+
## Type
|
|
51
|
+
|
|
52
|
+
- **Light display weights.** The display face runs at the pairing's weight (`--display-weight`, 300 for most, 400 where
|
|
53
|
+
the face starts at 400, 700 or 800 only for the poster pairings). Never `font-bold` on a heading; hierarchy comes
|
|
54
|
+
from scale: `text-display`, `text-h2`, `text-h3`, `text-lead`, `text-body`, `text-small`.
|
|
55
|
+
- **Real faces, measured.** A line fitted to the width (a wordmark, a poster line) is measured after
|
|
56
|
+
`document.fonts.ready` with the loaded face, and refitted on `loadingdone`. Never fit with the fallback face, and
|
|
57
|
+
never ask Google for a weight the family does not ship: `canon.mjs type` requests each face's real range.
|
|
58
|
+
- **Word spacing at poster size:** 0.06 to 0.1em on display lines, so tight tracking never runs two words into one.
|
|
59
|
+
- One italic accent face at most, on one word of the page.
|
|
60
|
+
- One fluid root-rem knob (`html { font-size: clamp(...) }` in globals.css): author in rem; breakpoints change the
|
|
61
|
+
layout, never the size.
|
|
62
|
+
|
|
63
|
+
## Colour and surface
|
|
64
|
+
|
|
65
|
+
- Tokens only: `--surface-0` to `--surface-3`, `--ink`, `--ink-muted`, `--accent`, `--accent-ink`, `--line`,
|
|
66
|
+
`--on-photo`, `--photo-ground`, `--inverse`, `--on-inverse`, `--field`, `--on-field`. The surface steps hold the
|
|
67
|
+
ground's hue; mixes are `in oklab`, never `in oklch` (an oklch mix walks round the hue wheel and turns cream lilac).
|
|
68
|
+
- **One contrast scene,** at most two: wrap a section in `scene-inverse` (the palette's dark ground, light type) or
|
|
69
|
+
`scene-field` (the accent at full strength); every block inside follows the turned tokens.
|
|
70
|
+
- Elevation is a surface step plus a hairline, never a shadow. Radius 0 to 4px. Flat fills, no gradients, no glass.
|
|
71
|
+
|
|
72
|
+
## Composition
|
|
73
|
+
|
|
74
|
+
- The display type is the composition, not a heading above it: 12 to 20vw on the poster lines, small body beside it.
|
|
75
|
+
- Vary the sections: after a split comes a full-bleed band or hairline rows, never three equal cards. Section padding
|
|
76
|
+
varies by role; one section should feel too empty by template standards.
|
|
77
|
+
- Never two pinned pieces back to back: a plain section breathes between them.
|
|
@@ -1,255 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Icons
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Struq Icons is the default: `npm i @struq/icons` (MIT), the Iconify set `struq` for non-React stacks, browsable at
|
|
4
|
+
[struq.nl/iconen](https://struq.nl/iconen). A two-tone set on one 24 grid, the base tone for the shape and the accent
|
|
5
|
+
on the part that carries the meaning, each icon with its own hover motion.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
## Whether an icon earns its place
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **Size and color:** one size per context (`size={20}` inline, `size={24}` nav, or a `size-5` / `size-6` class so the icon rides the root-rem knob), ink-colored; color only for state and semantics.
|
|
11
|
-
- **Budget:** functional icons only (nav, close, chevrons, form states, CTA anchors). No 3-up feature-card icons, no Sparkles, Rocket or Zap, no emoji, no colored chips.
|
|
12
|
-
- **Motion:** the set's own hover motion (200ms, hover and focus only, never on load, never looping, off under reduced motion) is inside the budget. Other animated icons under 500ms on click, never looping in standard UI, static under reduced motion.
|
|
13
|
-
- **Record in STRUQ.md SYSTEM:** `Icons: Struq Icons, @struq/icons, strokeWidth 1.5, accent = --color-accent, states.css on`.
|
|
9
|
+
Functional only: navigation, close, chevrons, an arrow on a text link, form states. Never a row of feature-card icons,
|
|
10
|
+
never an icon in a coloured chip, never sparkles, rocket, lightning, bulb, wand or emoji. Most studio sections need
|
|
11
|
+
none: the blocks carry their links as type.
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Two separate decisions, never collapse them:
|
|
19
|
-
|
|
20
|
-
1. **Whether an icon earns its place**: the Struq taste gate. Most marketing
|
|
21
|
-
sections need *fewer* icons than the reflex wants; icon-as-filler is an AI tell.
|
|
22
|
-
2. **How the icon behaves once it's in**: the craft doctrine below (family, register,
|
|
23
|
-
color, state, animation timing, accessibility).
|
|
24
|
-
|
|
25
|
-
The gate (§1) governs *where*; the doctrine (§2 to §8) governs *how*. A perfectly-crafted
|
|
26
|
-
icon in the wrong place is still wrong.
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## 1. The gate: does it earn its place?
|
|
31
|
-
|
|
32
|
-
Keep the existing bans hard:
|
|
33
|
-
|
|
34
|
-
- **Never** as 3-up feature-card filler (centered icon + h3 + two gray lines), the single
|
|
35
|
-
most recognizable generated section ([anti-patterns.md](anti-patterns.md) §2, §13).
|
|
36
|
-
- **Never** Sparkles / Rocket / Zap / lightning, or emoji (🚀✨💡). They are the visual signature
|
|
37
|
-
of generated UI.
|
|
38
|
-
- **Never** in a colored circle/chip background.
|
|
39
|
-
- **Never** five sizes / five strokes scattered across a page.
|
|
40
|
-
|
|
41
|
-
Icons **earn their place** when they do real work:
|
|
42
|
-
|
|
43
|
-
- **Functional affordances**: nav, search, close, chevrons, form-field states, toggles.
|
|
44
|
-
- **Scannability** in menus and dashboards: a per-row icon lets the eye decode the list
|
|
45
|
-
faster than text alone (humans parse a glyph in ~1/10s).
|
|
46
|
-
- **Button visual-anchors**: an icon on a CTA gives a fast hint of the action.
|
|
47
|
-
- **Paragraph / feature separators**: between blocks of a "wall of text," an icon marks a
|
|
48
|
-
visual point of separation. It must *reinforce* the message, never *replace* it.
|
|
49
|
-
|
|
50
|
-
When in doubt, **no icon**. Hairline rules, ghost type, a single oversized numeral as an
|
|
51
|
-
accent, or real imagery often carry decoration better in a marketing context.
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## 2. Source: one coherent family per project, chosen in this order
|
|
56
|
-
|
|
57
|
-
The family is a SYSTEM decision, recorded in STRUQ.md ([direction.md](direction.md) §4), not
|
|
58
|
-
something picked per component. Resolve it top to bottom and stop at the first that fits:
|
|
59
|
-
|
|
60
|
-
| # | Family | When | Install | Weight to match light display type |
|
|
61
|
-
|---|---|---|---|---|
|
|
62
|
-
| 1 | **The brand's own icons** | The client already has a drawn set | their files, inlined as components | as drawn |
|
|
63
|
-
| 2 | **Struq Icons** | The default for every Struq build: a two-tone set on one 24 grid (a base tone for the shape, an accent on the part that carries the meaning), each icon with its own hover motion and a draw-in variant | `npm i @struq/icons`; sprite, plain SVG and Iconify JSON (prefix `struq`) for non-React stacks | `strokeWidth={1.5}` (the set's default 2 is Lucide weight; `1.25` for luxury) |
|
|
64
|
-
| 3 | **Phosphor** | A softer, rounder line, or a site that needs icons the Struq set does not have | `npm i @phosphor-icons/react`, or Iconify `ph:` | `weight="light"` (or `"thin"` for luxury) |
|
|
65
|
-
| 4 | **Iconoir** or **Tabler** | A sharper, more technical or architectural line | `npm i iconoir-react` / `@tabler/icons-react`, or Iconify `iconoir:` / `tabler:` | Tabler `stroke={1.25}`; Iconoir `strokeWidth={1.25}` |
|
|
66
|
-
|
|
67
|
-
### Struq Icons: the wiring
|
|
68
|
-
|
|
69
|
-
Import the stylesheets once, at the root, before the site's own stylesheet; then import icons by name. Every icon is a named PascalCase export (`Search`, `ArrowRight`, `External`, `ChevronDown`, `Close`); the barrel tree-shakes. Check a name against the installed `dist/index.d.ts` or [struq.nl/iconen](https://struq.nl/iconen) before using it: the set names icons by meaning, so the up-right arrow is `External`, not `ArrowUpRight`.
|
|
13
|
+
## Wiring (React)
|
|
70
14
|
|
|
71
15
|
```tsx
|
|
72
|
-
// app/layout.tsx
|
|
16
|
+
// app/layout.tsx, once, before globals.css
|
|
73
17
|
import "@struq/icons/tokens.css";
|
|
74
18
|
import "@struq/icons/icons.css";
|
|
75
|
-
import "@struq/icons/states.css"; //
|
|
19
|
+
import "@struq/icons/states.css"; // one tone at rest, the accent on hover, focus and current
|
|
76
20
|
```
|
|
77
21
|
|
|
78
22
|
```css
|
|
79
|
-
/* globals.css: the set's accent follows the
|
|
80
|
-
On body, so it wins over tokens.css's :root and .dark rules by inheritance. */
|
|
23
|
+
/* globals.css: the set's accent follows the palette, not its default. On body, so it wins by inheritance. */
|
|
81
24
|
body {
|
|
82
25
|
--icon-base: currentColor;
|
|
83
|
-
--icon-accent: var(--
|
|
84
|
-
--icon-accent-live: var(--
|
|
26
|
+
--icon-accent: var(--accent);
|
|
27
|
+
--icon-accent-live: var(--accent);
|
|
85
28
|
}
|
|
86
29
|
```
|
|
87
30
|
|
|
88
31
|
```tsx
|
|
89
32
|
import { ArrowRight, Search } from "@struq/icons";
|
|
90
33
|
|
|
91
|
-
<a href="/
|
|
92
|
-
|
|
93
|
-
</a>
|
|
94
|
-
|
|
95
|
-
<button type="button" className="grid size-12 place-items-center">
|
|
96
|
-
<Search size={24} strokeWidth={1.5} label="Search" /> {/* the only content: give it a label */}
|
|
97
|
-
</button>
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
- **Props:** `size` (16, 20, 24, 32 or 48 px; a `size-5` class overrides it in rem), `strokeWidth`, `label` (names it for screen readers; without one it is `aria-hidden` decoration), `animate` (`"hover"` default, `"draw"`, `"none"`), `color` and `accentColor` (any CSS colour, set per icon; they beat the variables). Other SVG props pass through.
|
|
101
|
-
- **Server components.** The icons use no hooks, so they render in server components. Only the draw-in entry is a client component.
|
|
102
|
-
- **Draw-in** for one empty state, first run or hero moment, never a list: `import { StruqIconDraw } from "@struq/icons/draw"`, `<StruqIconDraw icon={Inbox} size={48} delay={0.2} />`. It needs `motion` (already in the stack), draws once, never loops, and shows the finished icon on the server, without JS and under reduced motion.
|
|
103
|
-
- **Name clashes.** `Link`, `Image`, `File` and `Text` collide with `next/link`, `next/image` and DOM globals; import the `LinkIcon`, `ImageIcon`, `FileIcon` and `TextIcon` aliases instead.
|
|
104
|
-
- **Accent off in one area:** `data-icon-accent="off"` on a parent paints the accent in the base colour (needs `icons.css`). `data-icons="live"` keeps the accent on at rest in an area when `states.css` is loaded.
|
|
105
|
-
- **Status icons** (one tone) take their colour from the place they sit in: they follow `currentColor`, or a token through `color="var(--color-...)"`, never a hex.
|
|
106
|
-
- **Without React:** `sprite.svg` (copy it to `public/`, `<use href="/sprite.svg#search" />`, two-tone, no hover motion), plain SVG files under `svg/`, or `iconify.json` (prefix `struq`, for Iconify loaders in Vue, Svelte, Astro or Tailwind). Same family, same rules.
|
|
107
|
-
- **A missing icon** is drawn to the set's rules (24 grid, round caps and joins, the site's stroke, base and accent as two parts) or the whole site moves to row 3. Never patch the gap with a Phosphor glyph.
|
|
108
|
-
|
|
109
|
-
**Never Lucide as the reflex.** It is the default of every generated UI, so it reads as
|
|
110
|
-
generated. **Never two families on one site.** If the chosen family is missing an icon, either
|
|
111
|
-
draw the missing icon to its rules (its grid, its stroke, its caps and joins) or move the whole
|
|
112
|
-
site to the next family. Patching gaps with a second set is the icon version of three display
|
|
113
|
-
fonts on one page.
|
|
114
|
-
|
|
115
|
-
**Iconify** is an optional transport for families 3 and 4 (and for Struq Icons outside React) when you want any family through one
|
|
116
|
-
component: one API over about 200k icons across every major set. It is a delivery method, not a
|
|
117
|
-
second family.
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
# Next / any React # Vite (compile-time, zero runtime, tree-shaken)
|
|
121
|
-
npm i @iconify/react npm i -D unplugin-icons @iconify/json
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
// @iconify/react: works in both Next and Vite
|
|
126
|
-
import { Icon } from "@iconify/react";
|
|
127
|
-
<Icon icon="ph:arrow-up-right" className="size-5 text-ink" /> // Phosphor
|
|
128
|
-
<Icon icon="iconoir:search" className="size-5 text-ink" /> // Iconoir
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
// Vite alternative: unplugin-icons, imported as components (no runtime fetch)
|
|
133
|
-
import ArrowUpRight from "~icons/ph/arrow-up-right";
|
|
134
|
-
<ArrowUpRight className="size-5 text-ink" />
|
|
34
|
+
<a href="/werk">Bekijk het werk <ArrowRight size={20} strokeWidth={1.5} /></a>
|
|
35
|
+
<button type="button"><Search size={24} strokeWidth={1.5} label="Zoeken" /></button>
|
|
135
36
|
```
|
|
136
37
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
## 3. Two registers: system vs decorative
|
|
144
|
-
|
|
145
|
-
| Register | Use | Traits |
|
|
146
|
-
|---|---|---|
|
|
147
|
-
| **System** | menus, buttons, inputs, table rows, anything **≤24px** | minimal, reduced to essentials, legible at small size, single stroke |
|
|
148
|
-
| **Decorative / expressive** | bigger explanatory moments, feature anchors, animated (Lordicon/Lottie) | richer, more detail, only at **larger** sizes |
|
|
149
|
-
|
|
150
|
-
**Complex/decorative icons die at small sizes**: their detail turns to mud below ~24px.
|
|
151
|
-
If it's small, it's a system icon. If it's expressive, it's big. Don't mix the registers
|
|
152
|
-
within one role.
|
|
153
|
-
|
|
154
|
-
One stroke across the set, as the §2 table sets it (Struq Icons `strokeWidth={1.5}`,
|
|
155
|
-
Phosphor `weight="light"`, Tabler `stroke={1.25}`, Iconoir `strokeWidth={1.25}`), one size per
|
|
156
|
-
context (e.g. `size-5` inline, `size-6` nav), ink-colored by default. Struq Icons' two tones
|
|
157
|
-
are not two colours in the §4 sense: at rest under `states.css` they read as one tone, and the
|
|
158
|
-
accent is the site's one accent, shown only where the visitor acts.
|
|
159
|
-
|
|
160
|
-
---
|
|
161
|
-
|
|
162
|
-
## 4. Color
|
|
163
|
-
|
|
164
|
-
- **One color across the set** by default: ink. An icon set in four hues reads as no
|
|
165
|
-
decision made (same rule as the one-accent color system).
|
|
166
|
-
- Color earns its place only for **state** (hover/pressed/active) and **semantics**:
|
|
167
|
-
red/warning for errors, a success check, etc.
|
|
168
|
-
- Never rely on color *alone* to carry meaning (accessibility, see §7).
|
|
169
|
-
|
|
170
|
-
---
|
|
171
|
-
|
|
172
|
-
## 5. States & feedback
|
|
173
|
-
|
|
174
|
-
An icon the user can act on must *look* actionable and must confirm the action:
|
|
175
|
-
|
|
176
|
-
- Distinct **default / hover / pressed**, via motion, color, or both. A clickable icon
|
|
177
|
-
that never changes reads as decoration.
|
|
178
|
-
- **Feedback through the icon itself:** a checkmark confirms success, a warning sign flags
|
|
179
|
-
an error, a state-swap (empty→full trash) signals what happened. Digital UI lacks the
|
|
180
|
-
physical click of a real switch, so this feedback matters *more*, not less.
|
|
181
|
-
|
|
182
|
-
---
|
|
183
|
-
|
|
184
|
-
## 6. Animated icons (Lordicon / Lottie): timing is locked
|
|
185
|
-
|
|
186
|
-
Animated icons convey more meaning than static ones and drive engagement, but only with
|
|
187
|
-
discipline. The same budget as micro-interactions applies: **2 to 3 per view**, never a field
|
|
188
|
-
of looping motion.
|
|
189
|
-
|
|
190
|
-
**Timing (hard limits):**
|
|
191
|
-
|
|
192
|
-
| Trigger | Duration | Notes |
|
|
193
|
-
|---|---|---|
|
|
194
|
-
| **Click / tap** | **≤ 500ms** | must keep up with repeat clicks; longer feels out of sync and becomes a distraction by the 3rd press |
|
|
195
|
-
| **Hover (decorative)** | **1500-3000ms** | the delight sweet spot; more complex symbol → longer end of the range |
|
|
196
|
-
| GIF fallback loop | **2000-5000ms delay between loops** | only if tech forces a looping GIF |
|
|
197
|
-
|
|
198
|
-
**Rules:**
|
|
199
|
-
|
|
200
|
-
- **No constant loops in dashboards / standard UI.** They become an unwelcome
|
|
201
|
-
distraction (especially for attention-sensitive users). Trigger on hover instead.
|
|
202
|
-
A looping animation is fine *as a page loader*, not as ambient dashboard decoration.
|
|
203
|
-
(This rhymes with the skill-wide "nothing moves that the user didn't cause" principle,
|
|
204
|
-
[feel-layer.md](feel-layer.md) §5.)
|
|
205
|
-
- **No extreme flashing or flickering**: a seizure/motion-sensitivity hazard. Smooth the
|
|
206
|
-
frequency; never strobe.
|
|
207
|
-
- **Natural physics only.** Motion that breaks real-world dynamics (unnatural bounce,
|
|
208
|
-
impossible 3D, irregular movement) leaves users unsettled and confused. Smooth, weighted,
|
|
209
|
-
accurate. Every movement must serve a purpose and match the icon's meaning (a security
|
|
210
|
-
icon shouldn't "wave and wiggle").
|
|
211
|
-
- **Don't overload.** Too many animated icons, especially on loop, drown the message.
|
|
212
|
-
|
|
213
|
-
Animated icons are a micro-interaction: they obey the same library split and gating as
|
|
214
|
-
everything in [micro-interactions.md](micro-interactions.md). Lottie/Lordicon players are
|
|
215
|
-
state/lifecycle-driven (Motion territory for triggering), hover effects are
|
|
216
|
-
`@media (hover: hover)`-gated, and **reduced-motion → static icon** (render the still
|
|
217
|
-
frame, never the animation).
|
|
218
|
-
|
|
219
|
-
---
|
|
220
|
-
|
|
221
|
-
## 7. Accessibility
|
|
222
|
-
|
|
223
|
-
- **Meaningful icon:** give it an accessible name via `aria-label` (or visible label, §8).
|
|
224
|
-
An icon that carries content needs alt text so screen readers convey it.
|
|
225
|
-
- **Decorative icon:** `aria-hidden`, which keeps it out of the a11y tree entirely.
|
|
226
|
-
- **Never color-alone** for meaning (color-vision-deficient and low-vision users). Pair
|
|
227
|
-
with shape, label, or motion.
|
|
228
|
-
- **Touch target ≥ 48×48px**: the tappable area can exceed the visual glyph. Keep spacing
|
|
229
|
-
between grouped tappable icons so they don't misfire.
|
|
230
|
-
- **Reduced motion** → static icon (see §6).
|
|
231
|
-
|
|
232
|
-
---
|
|
38
|
+
- `strokeWidth={1.5}` beside light display type (1.25 for a luxury register); one size per context (20 inline, 24 in
|
|
39
|
+
the bar). `label` names an icon that is a control's only content; without it the icon is decoration.
|
|
40
|
+
- Names are by meaning: the up-right arrow is `External`. Check a name in `dist/index.d.ts` before using it.
|
|
41
|
+
`Link`, `Image`, `File` and `Text` clash with Next: import `LinkIcon`, `ImageIcon`, `FileIcon`, `TextIcon`.
|
|
42
|
+
- The hover motion is the set's own (hover and focus only, never on load, off under reduced motion).
|
|
233
43
|
|
|
234
|
-
##
|
|
44
|
+
## Without React
|
|
235
45
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
using an action word (Buy, Continue, Subscribe). Icons reinforce; labels disambiguate.
|
|
239
|
-
- **Universal icons reduce cognitive load.** Lean on the globally-recognized set where it
|
|
240
|
-
fits, beyond cultural differences.
|
|
241
|
-
- **Conflicting icons** (heart vs star both mean "favorite/save/rate"): don't avoid them,
|
|
242
|
-
**consciously pick one** and label its function clearly. Don't use one icon for two
|
|
243
|
-
purposes or two icons for one purpose.
|
|
244
|
-
- **Cultural context** matters for unique/symbolic icons, so consider the audience.
|
|
245
|
-
- **Test** new icons with outsiders for *recognizability* (do they understand it?) and
|
|
246
|
-
*memorability* (do they recall it a week later?). Re-design or add a label if not.
|
|
46
|
+
`iconify.json` with the prefix `struq` (`struq:search`, `struq:<name>-draw` for a draw-in), `sprite.svg` with
|
|
47
|
+
`<use href="/sprite.svg#search" />`, or the plain SVG files. Same rules.
|
|
247
48
|
|
|
248
|
-
|
|
49
|
+
## The one fallback
|
|
249
50
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
**Instead:** One deliberately chosen icon family, system vs decorative registers kept separate,
|
|
254
|
-
ink-colored with color only for state/semantics, animated only where it informs and
|
|
255
|
-
within the timing limits, labeled where not universal, `aria` correct, ≥48px targets.
|
|
51
|
+
When the set lacks an icon and drawing it to the set's rules (24 grid, round caps and joins, the site's stroke, base
|
|
52
|
+
and accent as two parts) is not worth it, move the whole site to **Phosphor at `weight="light"`**
|
|
53
|
+
(`@phosphor-icons/react`, or Iconify `ph:`). One family per site, never mixed, never Lucide by reflex.
|