@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.
Files changed (122) hide show
  1. package/README.md +23 -35
  2. package/package.json +3 -2
  3. package/skills/struq/SKILL.md +148 -212
  4. package/skills/struq/blocks/CATALOG.md +426 -0
  5. package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
  6. package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
  7. package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
  8. package/skills/struq/blocks/catalog.json +1302 -0
  9. package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
  10. package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
  11. package/skills/struq/blocks/headers/header-bar.tsx +652 -0
  12. package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
  13. package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
  14. package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
  15. package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
  16. package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
  17. package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
  18. package/skills/struq/blocks/kit/Cursor.tsx +117 -0
  19. package/skills/struq/blocks/kit/HeroCanvas.tsx +278 -0
  20. package/skills/struq/blocks/kit/HorizontalGallery.tsx +106 -0
  21. package/skills/struq/blocks/kit/MagneticButton.tsx +92 -0
  22. package/skills/struq/blocks/kit/MediaFrame.tsx +96 -0
  23. package/skills/struq/blocks/kit/PinnedZoom.tsx +89 -0
  24. package/skills/struq/blocks/kit/Reveal.tsx +64 -0
  25. package/skills/struq/blocks/kit/SmoothScroll.tsx +57 -0
  26. package/skills/struq/blocks/kit/SplitHeadline.tsx +68 -0
  27. package/skills/struq/blocks/kit/blocks.css +2102 -0
  28. package/skills/struq/blocks/kit/gsap.ts +21 -0
  29. package/skills/struq/blocks/kit/images.ts +17 -0
  30. package/skills/struq/blocks/kit/photo-read.ts +102 -0
  31. package/skills/struq/blocks/kit/scenes/contours.ts +161 -0
  32. package/skills/struq/blocks/kit/scenes/dots.ts +110 -0
  33. package/skills/struq/blocks/kit/scenes/ripple.ts +99 -0
  34. package/skills/struq/blocks/kit/scenes/types.ts +114 -0
  35. package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
  36. package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
  37. package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
  38. package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
  39. package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
  40. package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
  41. package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
  42. package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
  43. package/skills/struq/blocks/sections/about-studio.tsx +436 -0
  44. package/skills/struq/blocks/sections/approach.tsx +661 -0
  45. package/skills/struq/blocks/sections/contact-form.tsx +664 -0
  46. package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
  47. package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
  48. package/skills/struq/blocks/sections/film-strip.tsx +385 -0
  49. package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
  50. package/skills/struq/blocks/sections/hover-list.tsx +467 -0
  51. package/skills/struq/blocks/sections/image-trail.tsx +432 -0
  52. package/skills/struq/blocks/sections/manifesto.tsx +691 -0
  53. package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
  54. package/skills/struq/blocks/sections/reveals.tsx +416 -0
  55. package/skills/struq/blocks/sections/split-pair.tsx +670 -0
  56. package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
  57. package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
  58. package/skills/struq/blocks/showpieces/fill-rows.tsx +74 -0
  59. package/skills/struq/blocks/showpieces/hero-opener.tsx +128 -0
  60. package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +89 -0
  61. package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
  62. package/skills/struq/blocks/showpieces/panel-wipe.tsx +89 -0
  63. package/skills/struq/blocks/showpieces/pixel-resolve.tsx +107 -0
  64. package/skills/struq/blocks/showpieces/project-stack.tsx +86 -0
  65. package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
  66. package/skills/struq/blocks/showpieces/wordmark-finale.tsx +105 -0
  67. package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
  68. package/skills/struq/canon/canon.json +627 -0
  69. package/skills/struq/cinematic/README.md +7 -18
  70. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
  71. package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
  72. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
  73. package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
  74. package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
  75. package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
  76. package/skills/struq/recipes/app/fonts.ts +12 -0
  77. package/skills/struq/recipes/app/globals.css +42 -0
  78. package/skills/struq/recipes/app/layout.tsx +30 -0
  79. package/skills/struq/references/craft.md +77 -0
  80. package/skills/struq/references/iconography.md +28 -230
  81. package/skills/struq/references/stack-targets.md +23 -74
  82. package/skills/struq/references/tells.md +52 -0
  83. package/skills/struq/references/triage.md +6 -7
  84. package/skills/struq/scripts/blocks.mjs +156 -0
  85. package/skills/struq/scripts/canon.mjs +134 -0
  86. package/skills/struq/scripts/check.mjs +513 -0
  87. package/skills/struq/scripts/images.mjs +360 -0
  88. package/skills/struq/scripts/log.mjs +75 -0
  89. package/skills/struq/scripts/packs.mjs +156 -0
  90. package/skills/struq/scripts/tokens.mjs +222 -0
  91. package/skills/struq/references/anti-patterns.md +0 -265
  92. package/skills/struq/references/baseline-prompt.md +0 -118
  93. package/skills/struq/references/brand.md +0 -143
  94. package/skills/struq/references/brief-router.md +0 -219
  95. package/skills/struq/references/composition.md +0 -191
  96. package/skills/struq/references/depth-parallax.md +0 -235
  97. package/skills/struq/references/direction.md +0 -188
  98. package/skills/struq/references/feel-layer.md +0 -107
  99. package/skills/struq/references/gsap-api.md +0 -293
  100. package/skills/struq/references/imagery.md +0 -144
  101. package/skills/struq/references/loaders.md +0 -287
  102. package/skills/struq/references/micro-interactions.md +0 -270
  103. package/skills/struq/references/mobile-composition.md +0 -70
  104. package/skills/struq/references/nav-header.md +0 -296
  105. package/skills/struq/references/page-architecture.md +0 -134
  106. package/skills/struq/references/page-entrances.md +0 -198
  107. package/skills/struq/references/performance.md +0 -235
  108. package/skills/struq/references/pinning-scrub.md +0 -341
  109. package/skills/struq/references/pitfalls.md +0 -290
  110. package/skills/struq/references/route-transitions.md +0 -281
  111. package/skills/struq/references/scaling.md +0 -151
  112. package/skills/struq/references/scroll-reveals.md +0 -280
  113. package/skills/struq/references/seo.md +0 -219
  114. package/skills/struq/references/setup.md +0 -397
  115. package/skills/struq/references/showcase.md +0 -87
  116. package/skills/struq/references/struq-imagery.md +0 -107
  117. package/skills/struq/references/svg-motion.md +0 -390
  118. package/skills/struq/references/transitions.md +0 -160
  119. package/skills/struq/references/typography.md +0 -720
  120. package/skills/struq/references/vibe-presets.md +0 -287
  121. package/skills/struq/references/webgl-r3f.md +0 -274
  122. package/skills/struq/references/webgl-scenes.md +0 -593
@@ -0,0 +1,222 @@
1
+ // The token block and the font requests, derived exactly as Struq's block lab derives them (lib/lab/tokens.ts,
2
+ // lib/canon/surface-ladder.ts, lib/canon/palette-roles.ts, lib/canon/font-sources.ts), so a block in a client
3
+ // project wears the same colours and faces it was judged in. Pure functions, no I/O: canon.mjs writes, tests compare.
4
+
5
+ /* ------------------------------------------------------------------ colour */
6
+ const linearise = (byte) => {
7
+ const v = byte / 255;
8
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
9
+ };
10
+ const encode = (linear) => {
11
+ const v = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
12
+ return Math.round(Math.min(255, Math.max(0, v * 255)));
13
+ };
14
+
15
+ function oklabToLinear({ L, a, b }) {
16
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
17
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
18
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
19
+ return {
20
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
21
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
22
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
23
+ };
24
+ }
25
+ function linearToOklab({ r, g, b }) {
26
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
27
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
28
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
29
+ return {
30
+ L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
31
+ a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
32
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
33
+ };
34
+ }
35
+
36
+ /** A canon colour (hex or oklch) as linear RGB, or null (background-exposure.ts parseColor). */
37
+ export function parseLinear(value) {
38
+ const v = String(value).trim();
39
+ if (v.startsWith("#")) {
40
+ const body = v.slice(1);
41
+ const parts =
42
+ body.length === 3
43
+ ? body.split("").map((c) => parseInt(c + c, 16))
44
+ : body.length === 6
45
+ ? [0, 2, 4].map((i) => parseInt(body.slice(i, i + 2), 16))
46
+ : null;
47
+ if (!parts || parts.some(Number.isNaN)) return null;
48
+ return { r: linearise(parts[0]), g: linearise(parts[1]), b: linearise(parts[2]) };
49
+ }
50
+ if (!v.toLowerCase().startsWith("oklch")) return null;
51
+ const m = /oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)/i.exec(v);
52
+ if (!m) return null;
53
+ let L = parseFloat(m[1]);
54
+ if (v.includes("%")) L /= 100;
55
+ const C = parseFloat(m[2]);
56
+ const h = (parseFloat(m[3]) * Math.PI) / 180;
57
+ return oklabToLinear({ L, a: C * Math.cos(h), b: C * Math.sin(h) });
58
+ }
59
+
60
+ export function toHex(c) {
61
+ const clamp = (n) => Math.min(1, Math.max(0, n));
62
+ return `#${[c.r, c.g, c.b].map((x) => encode(clamp(x)).toString(16).padStart(2, "0")).join("")}`;
63
+ }
64
+
65
+ /** sRGB bytes, oklch clamped into gamut first (facets.ts parseColor). */
66
+ function parseBytes(input) {
67
+ const value = String(input).trim().toLowerCase();
68
+ const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
69
+ if (hex) {
70
+ const d = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
71
+ return { r: parseInt(d.slice(0, 2), 16), g: parseInt(d.slice(2, 4), 16), b: parseInt(d.slice(4, 6), 16) };
72
+ }
73
+ const ok = value.match(/^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/);
74
+ if (!ok) return null;
75
+ const L = ok[1].endsWith("%") ? Number(ok[1].slice(0, -1)) / 100 : Number(ok[1]);
76
+ const h = (Number(ok[3]) * Math.PI) / 180;
77
+ const lin = oklabToLinear({ L, a: Number(ok[2]) * Math.cos(h), b: Number(ok[2]) * Math.sin(h) });
78
+ const enc = (c) => {
79
+ const x = Math.min(1, Math.max(0, c));
80
+ const g = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
81
+ return Math.round(Math.min(1, Math.max(0, g)) * 255);
82
+ };
83
+ return { r: enc(lin.r), g: enc(lin.g), b: enc(lin.b) };
84
+ }
85
+ const luminance = ({ r, g, b }) => 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
86
+
87
+ /** WCAG contrast ratio between two canon colours. */
88
+ export function contrast(a, b) {
89
+ const ca = parseBytes(a);
90
+ const cb = parseBytes(b);
91
+ if (!ca || !cb) return 0;
92
+ const [hi, lo] = [luminance(ca), luminance(cb)].sort((x, y) => y - x);
93
+ return (hi + 0.05) / (lo + 0.05);
94
+ }
95
+
96
+ /** Lightness for light or dark: OKLCH L, else the HSL lightness of a hex (lib/lab/tokens.ts). */
97
+ export function lightness(v) {
98
+ const m = String(v).match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/i);
99
+ if (m) return m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
100
+ const h = String(v).match(/^#([0-9a-f]{6})$/i);
101
+ if (!h) return 0.5;
102
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(h[1].slice(i, i + 2), 16) / 255);
103
+ return (Math.max(r, g, b) + Math.min(r, g, b)) / 2;
104
+ }
105
+
106
+ /* ------------------------------------------------------------------- roles */
107
+ const NEAR_WHITE = "oklch(0.98 0.005 85)";
108
+ const NEAR_BLACK = "oklch(0.16 0.01 60)";
109
+
110
+ function inkFor(ground, own) {
111
+ const ranked = [...own, NEAR_WHITE, NEAR_BLACK]
112
+ .map((ink, order) => ({ ink, order, ratio: contrast(ground, ink) }))
113
+ .sort((a, b) => b.ratio - a.ratio || a.order - b.order);
114
+ const ownPassing = ranked.find((e) => e.order < own.length && e.ratio >= 4.5);
115
+ return (ownPassing ?? ranked[0]).ink;
116
+ }
117
+
118
+ function deepenedField(accent, light) {
119
+ const base = parseLinear(accent);
120
+ if (!base) return { field: accent, ink: inkFor(accent, light) };
121
+ for (let step = 0; step <= 12; step += 1) {
122
+ const f = Math.pow(0.9, step);
123
+ const field = step === 0 ? accent : toHex({ r: base.r * f, g: base.g * f, b: base.b * f });
124
+ const ink = inkFor(field, light);
125
+ if (contrast(field, ink) >= 4.5) return { field, ink };
126
+ }
127
+ return { field: accent, ink: inkFor(accent, light) };
128
+ }
129
+
130
+ /** The field and inverse roles: authored on the palette, else derived from its own colours (palette-roles.ts). */
131
+ export function paletteRoles(palette) {
132
+ if (palette.roles) return palette.roles;
133
+ const { background, text, accent } = palette.colors;
134
+ const ink = inkFor(accent, [background, text]);
135
+ const { field, ink: fieldInk } =
136
+ contrast(accent, ink) >= 4.5 ? { field: accent, ink } : deepenedField(accent, [background, text]);
137
+ return { field, fieldInk, inverse: text, inverseInk: background };
138
+ }
139
+
140
+ /** A deeper surface step held on the ground's own hue (surface-ladder.ts). */
141
+ export function surfaceStep(surface, ink, pct) {
142
+ const s = parseLinear(surface);
143
+ const k = parseLinear(ink);
144
+ if (!s || !k) return `color-mix(in oklab, ${surface}, ${ink} ${pct}%)`;
145
+ const a = linearToOklab(s);
146
+ const b = linearToOklab(k);
147
+ const t = pct / 100;
148
+ const mixed = { L: a.L + (b.L - a.L) * t, a: a.a + (b.a - a.a) * t, b: a.b + (b.b - a.b) * t };
149
+ let C = Math.hypot(mixed.a, mixed.b);
150
+ const H = Math.hypot(a.a, a.b) < 0.002 ? Math.atan2(mixed.b, mixed.a) : Math.atan2(a.b, a.a);
151
+ const inGamut = (c) => [c.r, c.g, c.b].every((v) => v >= -1e-4 && v <= 1 + 1e-4);
152
+ while (C > 0 && !inGamut(oklabToLinear({ L: mixed.L, a: C * Math.cos(H), b: C * Math.sin(H) }))) C -= 0.001;
153
+ const deg = ((H * 180) / Math.PI + 360) % 360;
154
+ return `oklch(${mixed.L.toFixed(4)} ${Math.max(0, C).toFixed(4)} ${deg.toFixed(2)})`;
155
+ }
156
+
157
+ /* ------------------------------------------------------------------- faces */
158
+ export const headFamily = (stack) => (String(stack ?? "").split(",")[0] ?? "").trim().replace(/^['"]|['"]$/g, "");
159
+
160
+ /** The token block, as [name, value] pairs in the lab's order (lib/lab/tokens.ts paletteTokens). */
161
+ export function paletteTokens(palette, pairing) {
162
+ const c = palette.colors;
163
+ const roles = paletteRoles(palette);
164
+ const dark = lightness(c.background) < 0.5;
165
+ const accentInk = contrast(c.accent, c.background) >= contrast(c.accent, c.text) ? c.background : c.text;
166
+ const tokens = [
167
+ ["--surface-0", c.background],
168
+ ["--surface-1", c.surface],
169
+ ["--surface-2", surfaceStep(c.surface, c.text, dark ? 9 : 7)],
170
+ ["--surface-3", surfaceStep(c.surface, c.text, dark ? 18 : 16)],
171
+ ["--ink", c.text],
172
+ ["--ink-muted", `color-mix(in oklab, var(--ink) ${dark ? 70 : 66}%, var(--surface-0))`],
173
+ ["--accent", c.accent],
174
+ ["--accent-ink", accentInk],
175
+ ["--accent-2", c.accentAlt ?? c.surface],
176
+ ["--line", "color-mix(in oklab, var(--ink) 13%, transparent)"],
177
+ ["--on-photo", dark ? c.text : c.background],
178
+ ["--photo-ground", dark ? c.background : roles.inverse],
179
+ ["--inverse", roles.inverse],
180
+ ["--on-inverse", roles.inverseInk],
181
+ ["--field", roles.field],
182
+ ["--on-field", roles.fieldInk],
183
+ ["color-scheme", dark ? "dark" : "light"],
184
+ ["--display-weight", String(pairing.displayScale?.weight ?? 300)],
185
+ ["--face-display", `"${headFamily(pairing.display)}"`],
186
+ ["--face-body", `"${headFamily(pairing.body)}"`],
187
+ ];
188
+ if (pairing.accentItalic) tokens.push(["--face-accent", `"${headFamily(pairing.accentItalic)}"`]);
189
+ return tokens;
190
+ }
191
+
192
+ /**
193
+ * Stylesheet URLs for a pairing's faces (lib/lab/tokens.ts fontHrefs): per Google family the face's real weight range
194
+ * and the bare family as a fallback, because css2 refuses a request that names a weight the family lacks.
195
+ */
196
+ export function fontHrefs(pairing, { fontWeights = {}, fontProviders = {} } = {}) {
197
+ const hrefs = [];
198
+ const faces = [
199
+ { stack: pairing.display, italic: false },
200
+ { stack: pairing.body, italic: false },
201
+ ...(pairing.accentItalic ? [{ stack: pairing.accentItalic, italic: true }] : []),
202
+ ];
203
+ for (const { stack, italic } of faces) {
204
+ const family = headFamily(stack);
205
+ const provider = fontProviders[family];
206
+ if (!provider || provider === "system") continue;
207
+ if (provider === "fontshare") {
208
+ hrefs.push(`https://api.fontshare.com/v2/css?f[]=${family.toLowerCase().replace(/\s+/g, "-")}@300,400,500,600,700&display=swap`);
209
+ continue;
210
+ }
211
+ const name = family.replace(/\s+/g, "+");
212
+ const range = fontWeights[family];
213
+ const axis = range && range[0] < range[1] ? `${range[0]}..${range[1]}` : null;
214
+ hrefs.push(
215
+ italic
216
+ ? `https://fonts.googleapis.com/css2?family=${name}:${axis ? `ital,wght@1,${axis}` : "ital@1"}&display=swap`
217
+ : `https://fonts.googleapis.com/css2?family=${name}${axis ? `:wght@${axis}` : ""}&display=swap`,
218
+ );
219
+ hrefs.push(`https://fonts.googleapis.com/css2?family=${name}${italic ? ":ital@1" : ""}&display=swap`);
220
+ }
221
+ return [...new Set(hrefs)];
222
+ }
@@ -1,265 +0,0 @@
1
- # Anti-Pattern Gallery: the Cheap Reflex vs. the Agency Version
2
-
3
- Each pair below names the **tell**, the specific detail that exposes the default. Use this file as a pre-ship checklist: scan for tells, replace with the paired fix. Cross-references point to the full technique.
4
-
5
- ---
6
-
7
- ## 1. Elevation
8
-
9
- **Reflex:**
10
- ```tsx
11
- <div className="rounded-xl bg-white p-6 shadow-lg border border-gray-200">
12
- ```
13
- **The tell:** `shadow-md`/`shadow-lg` doing elevation on a light card, gray-200 border, on a `bg-gray-50` page. Component-library depth model.
14
-
15
- **Agency:**
16
- ```tsx
17
- // Dark: elevation = lighter surface. Light: elevation = hairline + tone.
18
- <div className="rounded-sm bg-surface-2 p-8 border border-line">
19
- ```
20
- Surface ladder steps (`surface-0 → 1 → 2`) signal depth tonally. Real shadows are reserved for true floaters, and then: `shadow-[0_24px_80px_-12px_rgb(0_0_0/0.4)]`. ([baseline-prompt.md](baseline-prompt.md))
21
-
22
- ---
23
-
24
- ## 2. The feature section
25
-
26
- **Reflex:**
27
- ```tsx
28
- <section className="py-16">
29
- <h2 className="text-center text-3xl font-bold">Why Choose Us</h2>
30
- <p className="mt-2 text-center text-gray-500">Everything you need to succeed</p>
31
- <div className="mt-12 grid grid-cols-3 gap-8">
32
- {features.map(f => (
33
- <div className="rounded-xl border p-6 shadow-sm text-center">
34
- <Icon className="mx-auto h-8 w-8 text-blue-600" />
35
- <h3 className="mt-4 font-semibold">{f.title}</h3>
36
- <p className="mt-2 text-sm text-gray-500">{f.body}</p>
37
- </div>
38
- ))}
39
- </div>
40
- </section>
41
- ```
42
- **The tell:** centered h2 + subhead + 3 identical icon-cards. The single most recognizable AI/template section on the internet.
43
-
44
- **Agency:** kill the cards; let type and rhythm carry it:
45
- ```tsx
46
- <section className="content-grid py-40 gap-y-0">
47
- <p className="eyebrow text-eyebrow text-ink-muted">Capabilities</p>
48
- <div className="mt-16 divide-y divide-line">
49
- {features.map((f) => (
50
- <div key={f.title} className="group grid grid-cols-12 items-baseline gap-4 py-10">
51
- <h3 className="type-h3 text-h3 col-span-6">{f.title}</h3>
52
- <p className="text-body col-span-5 col-start-8 max-w-[44ch] text-ink-muted">{f.body}</p>
53
- </div>
54
- ))}
55
- </div>
56
- </section>
57
- ```
58
- Editorial rows, left-aligned, divided by hairlines, asymmetric columns. Hierarchy comes from type scale and the eyebrow, not from a number on every row (see §16). ([composition.md](composition.md))
59
-
60
- ---
61
-
62
- ## 3. Scroll reveal
63
-
64
- **Reflex:**
65
- ```tsx
66
- <motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} transition={{ duration: 0.3 }}>
67
- ```
68
- **The tell:** opacity-only, no transform, no stagger, 300ms default ease. Content blinks into existence, all siblings simultaneously.
69
-
70
- **Agency:**
71
- ```tsx
72
- gsap.from("[data-reveal]", {
73
- y: 48, autoAlpha: 0, duration: 0.9, ease: "expo.out",
74
- stagger: 0.08,
75
- scrollTrigger: { trigger: container.current, start: "top 78%", once: true },
76
- });
77
- ```
78
- Transform + opacity, expo-out tail, 80ms rhythm between siblings, inside the reduced-motion branch. ([scroll-reveals.md](scroll-reveals.md))
79
-
80
- **The silent failure:** a `[data-reveal]` element that also carries `transition-opacity` (a sibling-dim hover) or a `translate-y-*` offset. The first leaves it stuck at `opacity: 0` with the tween at progress 1; the second is erased because GSAP sets `translate: none`. Reveal the wrapper, transition and offset the child ([pitfalls.md](pitfalls.md) §3).
81
-
82
- ---
83
-
84
- ## 4. Headline entrance
85
-
86
- **Reflex:** the whole `<h1>` fades up as one block, or worse, a per-character typewriter with uniform linear timing.
87
- **The tell:** text treated as a rectangle (or a teletype), not as lines.
88
-
89
- **Agency:**
90
- ```tsx
91
- SplitText.create(el, {
92
- type: "lines", mask: "lines", autoSplit: true,
93
- onSplit: (self) => gsap.from(self.lines, {
94
- yPercent: 110, duration: 1.1, ease: "expo.out", stagger: 0.09,
95
- scrollTrigger: { trigger: el, start: "top 80%", once: true },
96
- }),
97
- });
98
- ```
99
- Lines rising from individual overflow masks. ([scroll-reveals.md](scroll-reveals.md) §3; the typographic side of SplitText lives in [typography.md](typography.md))
100
-
101
- ---
102
-
103
- ## 5. Hero
104
-
105
- **Reflex:** centered h1, gray subhead, two buttons (one blue, one outline), then a product screenshot in a browser-chrome mockup, possibly tilted with a purple glow.
106
- **The tell:** the symmetric "SaaS hero stack," gradient text on the keyword, floating screenshot.
107
-
108
- **Agency (structure, not just styling):** left-aligned or compositionally split; h1 as `font-display text-display` (the pairing's display clamp, [typography.md](typography.md) §2.4) doing the visual work; media bleeding off-grid or punching into the next section; meta-information (location, availability, scroll cue) placed at the hero's edges like a poster; scrub-tied departure ([pinning-scrub.md](pinning-scrub.md) §3). A hero is a *composition*, not a stack. For ready-made hero showpieces, see [showcase.md](showcase.md).
109
-
110
- ---
111
-
112
- ## 6. Parallax
113
-
114
- **Reflex:** `background-attachment: fixed` banner, or one layer drifting at 0.5× scroll.
115
- **The tell:** single-layer movement (and iOS breakage). Depth needs *relative* rates.
116
-
117
- **Agency:** three siblings at `data-speed` 0.9 / 1.0 / 1.12 within one section, scrubbed, symmetric around natural position, plus photos panning inside `overflow-hidden` frames (`scale(1.18)`, `yPercent` from -8 to 8). ([depth-parallax.md](depth-parallax.md))
118
-
119
- ---
120
-
121
- ## 7. Sticky stacking
122
-
123
- **Reflex:** bare `sticky top-0` cards papering over each other.
124
- **The tell:** the covered card sits frozen at full scale/brightness. It looks glitched, not layered.
125
-
126
- **Agency:** each covered card recedes (`scale: 0.92`, surface-colored veil to 50%), scrubbed to the *next* card's approach. ([depth-parallax.md](depth-parallax.md) §3)
127
-
128
- ---
129
-
130
- ## 7b. Pinned / horizontal-scroll handoff
131
-
132
- **Reflex:** reach for `anticipatePin: 1` (and/or `scrub: true`) to "smooth" the moment a pinned or horizontal section engages.
133
- **The tell:** a jerk/jump right at the vertical-to-horizontal (or pin-on) handoff, worst on a fast scroll-in. It snaps then corrects instead of easing in. With smooth scroll (Lenis), `anticipatePin` predicts from a *smoothed* velocity, overshoots, and pins a frame early; `scrub: true` then passes that spike straight through 1:1.
134
-
135
- **Agency:** **no `anticipatePin`** with smooth scroll + `scrub: 1` (pairs with Lenis lerp ~0.09 so the track settles softly). If only the *first* scroll-in snaps, the cause is stale pin positions, so add the delayed initial `lenis.resize()` + `ScrollTrigger.refresh()` from [setup.md](setup.md) §2. Tuning `scrub` is not the fix; removing `anticipatePin` is. ([pinning-scrub.md](pinning-scrub.md) §2; every other pin cause, symptom first: [pitfalls.md](pitfalls.md) §2)
136
-
137
- ---
138
-
139
- ## 8. Section transitions
140
-
141
- **Reflex:** every section a watertight `py-16` box; boundaries are just background-color changes at a hard line.
142
- **The tell:** uniform vertical rhythm + zero cross-boundary elements. The page is a stack of slides.
143
-
144
- **Agency:** padding varies by section role (8/16/24rem); at least one element trespasses each major seam (negative margin + z); one act-break uses a scrubbed curtain sweep. ([composition.md](composition.md), [transitions.md](transitions.md) §2)
145
-
146
- ---
147
-
148
- ## 9. Page navigation
149
-
150
- **Reflex:** hard cut between routes, or a 200ms full-page opacity crossfade.
151
- **The tell:** navigation is an event, not a moment. No surface moves, content just swaps.
152
-
153
- **Agency:** quint-in-out curtain sweep (`scaleY` with flipping `transform-origin`) + incoming content rising with expo-out after a 0.25s overlap. Total ≤ 900ms. ([transitions.md](transitions.md) §1; for full-screen route transitions on multi-page sites see [route-transitions.md](route-transitions.md))
154
-
155
- ---
156
-
157
- ## 10. Buttons & hovers
158
-
159
- **Reflex:**
160
- ```tsx
161
- <button className="rounded-lg bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 hover:scale-105">
162
- ```
163
- **The tell:** color-darken + scale-up + `transition-all duration-300`, identical on every interactive element.
164
-
165
- **Agency:** one primary CTA with a *scene*: fill swelling from the bottom edge, label rolling through a mask or trailing magnetically, elastic return. Secondary links get the origin-flipping underline. Cards get three-clock hovers + sibling dimming. Press = `scale 0.97` at 150ms. ([micro-interactions.md](micro-interactions.md))
166
-
167
- ---
168
-
169
- ## 11. Color & accent
170
-
171
- **Reflex:** `bg-gradient-to-r from-blue-600 to-purple-600` (buttons, hero glow, `bg-clip-text` headline), accents in 4 hues, pure white page with gray-500 text.
172
- **The tell:** the blue-purple gradient is the watermark of generated design; multiple accents mean no hierarchy decision was made.
173
-
174
- **Agency:** temperature-shifted neutrals doing 95% of the work; **one** accent appearing at most once per viewport; headline color = ink, with hierarchy from scale. If a gradient exists at all it is a subtle same-hue tonal ramp on a surface, never on text. ([vibe-presets.md](vibe-presets.md))
175
-
176
- ---
177
-
178
- ## 12. Type scale
179
-
180
- **Reflex:** h1 `text-4xl`, h2 `text-3xl`, h3 `text-2xl`, h4 `text-xl`: five steps between 20 and 36px, all `font-bold`, default tracking.
181
- **The tell:** compressed mid-range hierarchy. Nothing leads, everything is "kind of big."
182
-
183
- **Agency:** two extremes, set through the role tokens so nothing is improvised: `font-display text-display` for the hero (the token carries the pairing's clamp, tracking and leading), `font-display text-h2` per section, `type-h3 text-h3` for items, `text-body` at 1 to 1.0625rem, and `eyebrow text-eyebrow text-ink-muted` (uppercase 0.75rem, tracked 0.12 to 0.18em per face) as wayfinding between. No `tracking-tight`: tracking belongs to the face and the role ([typography.md](typography.md) §3.1). **Hero/display runs light (300 to 400)**. Large + light is the agency signal; heavy display (600+) is a deliberate genre choice only, never the reflex. **Floor:** body ≥1rem, muted ≥0.875rem, nothing below 12px. **Contrast:** body ≥4.5:1 (WCAG AA), large/≥24px ≥3:1; `ink-faint` is never text. ([baseline-prompt.md](baseline-prompt.md); the Signature pairings, roles and fluid scale are in [typography.md](typography.md))
184
-
185
- ---
186
-
187
- ## 13. Iconography & ornament
188
-
189
- **Reflex:** 🚀✨💡 as feature icons; the AI-cliché trio (`Sparkles`, `Rocket`, `Zap`/lightning, in any set) standing in for "magic / launch / fast"; reflexive Lucide for everything; icons at 5 sizes/strokes in brand-blue circles with light-blue backgrounds; an animated icon looping forever in a dashboard, or a 500ms+ animation lagging a click; a complex decorative icon turned to mud at 16px.
190
- **The tell:** icon-as-filler. `Sparkles`/`Rocket`/`Zap` are the visual signature of generated UI; the icon circle chip is the card-grid's sidekick; constant-loop animation reads as distraction, not delight.
191
-
192
- **Agency:** icons only where they inform (UI affordances, scannability, button anchors, never decoration); **one coherent family chosen for the brand** (its own set, else Struq Icons, else Phosphor light; never reflexive Lucide, never two families), one size, `strokeWidth={1.5}`, ink-colored; system icons small, decorative/animated big. Animated icons: ≤500ms on click, 1.5 to 3s on hover, never loop in standard UI, natural physics, reduced-motion falls back to static. Decoration jobs otherwise go to hairline rules, ghost type, a single oversized numeral as an accent (not a per-row counter, see §16), real photography/render. Often the strongest move is **no icon at all**. (Full doctrine: [iconography.md](iconography.md); drawn or morphing line art belongs to [svg-motion.md](svg-motion.md))
193
-
194
- ---
195
-
196
- ## 14. The exit problem
197
-
198
- **Reflex:** choreographed entrances, then `display: none` exits: accordions snapping shut, modals vanishing, filtered items teleporting.
199
- **The tell:** asymmetry of care. The system only performs in one direction.
200
-
201
- **Agency:** every exit designed at ~60% of entrance duration with `power2.in` / `easeIn`, reverse stagger for groups, `AnimatePresence` for unmounts. ([transitions.md](transitions.md) §4)
202
-
203
- ---
204
-
205
- ## 15. Loading & lorem
206
-
207
- **Reflex:** lorem ipsum, "John Doe, CEO at Acme", `https://via.placeholder.com/600x400`, spinner gif while content pops in.
208
- **The tell:** placeholder *anything*. It signals nobody made content decisions, and layout was never tested against reality.
209
-
210
- **Agency:** real (or convincingly written) copy with opinions; image areas with explicit aspect ratios; entrances double as load choreography (content reveals when ready, no spinner, no CLS).
211
-
212
- ---
213
-
214
- ## 16. Numbered everything
215
-
216
- **Reflex:** `01 / 02 / 03 / 04` stamped on every section header, every feature, every list row (often oversized in a corner) as the page's wayfinding system.
217
- **The tell:** uniform sequencing is reflexive, not editorial. No art director numbers *everything*; it is the same impulse as `py-16` on every section, applied to labels. It signals "I needed a decoration and reached for the nearest one."
218
-
219
- **Agency:** wayfinding through variety: tracked uppercase eyebrows, hairline rules, scale jumps, surface changes. A numeral earns its place only as a **single deliberate accent** (one oversized `03` against a manifesto line), never as a per-row counter. If you can delete the numbers and lose no meaning, they were filler.
220
-
221
- **The boundary:** ordinal numbering *inside one genuinely stepped component* (the phase kickers of a pinned sequence, see [pinning-scrub.md](pinning-scrub.md) §1, or a numbered process that really is ordered) is legitimate content, not this tell. The tell is *page-wide* stamping: numerals as the default decoration on every section, feature, and row.
222
-
223
- ---
224
-
225
- ## 17. Monospace as flavor
226
-
227
- **Reflex:**
228
- ```tsx
229
- <p className="font-mono text-xs uppercase tracking-widest">// System online</p>
230
- <span className="font-mono text-sm">[ 2024 ]</span>
231
- ```
232
- **The tell:** monospace on eyebrows, labels, stats, badges, timestamps, or body to look "technical / engineered / terminal." It is the aesthetic of a code editor, not a designed page, and it has become a default reach for "techy," which makes it read as generated.
233
-
234
- **Agency:** a real text face carries all UI and copy; "precision" comes from tracking, `tabular-nums` on figures, and tight type, not from a typewriter font. **Mono is reserved for literal code** (snippets, inline `code`, terminal output on a dev-tool site where it is genuinely the subject). Anywhere else, it is a tell. The same logic kills the scramble/decode "terminal" loader ([loaders.md](loaders.md)).
235
-
236
- ---
237
-
238
- ## 18. Per-viewport pixel-fixing
239
-
240
- **Reflex:**
241
- ```tsx
242
- <h2 className="text-2xl md:text-4xl lg:text-5xl xl:text-6xl">
243
- <section className="px-6 py-12 md:px-12 md:py-20 lg:px-24 lg:py-32">
244
- // fixed px widths everywhere; a fresh override added each time a new screen breaks
245
- ```
246
- **The tell:** every size hand-tuned per breakpoint, layout authored in px, and it *still* drifts at 1440 and 2560, so each new viewport means another round of element-by-element fixes. The hours sink is real and it never converges.
247
-
248
- **Agency:** one fluid root-rem knob (an a11y-safe `clamp` that keeps a rem term so zoom works), everything authored in rem, breakpoints reserved for **layout changes** (column count, stack/row) not resizing; display type carries its own `clamp()` on top. The whole UI scales proportionally at every width with zero per-element overrides. ([scaling.md](scaling.md))
249
-
250
- ---
251
-
252
- ## The 30-second tell scan
253
-
254
- Before shipping, run these grep-level checks:
255
- - `shadow-md|shadow-lg` on cards → §1 · `from-blue|to-purple|bg-clip-text` → §11
256
- - `py-16` ≥3 times → §8 · `grid-cols-3` of identical children → §2
257
- - `hover:scale-105|transition-all` → §10 · `duration-300` as the only duration → [feel-layer.md](feel-layer.md)
258
- - `opacity` tweens with no transform partner → §3 · emoji in JSX → §13
259
- - `anticipatePin` anywhere while Lenis/smooth scroll is mounted → §7b (jagged pin handoff)
260
- - `Sparkles|Rocket|Zap` icon imports (any set) / icon in a colored chip / looping animated icon → §13 · `font-mono` outside a code block → §17
261
- - `grep -P '0\d\s*[/.\-\u2014\u2013]'` (catches `01 /`, `01.`, `01 -` and the dash variants) / `0{i + 1}` numbering every section or row → §16
262
- - `font-bold` on the hero/`h1` / largest size = `text-4xl` → §12 · faint text on body copy / contrast below AA → §12
263
- - size ladders like `text-2xl md:text-4xl lg:text-6xl`, `px-`/`py-` tuned per breakpoint, fixed-`px` layout widths, or no fluid root font-size → §18
264
- - `text-4xl` as the largest size on the page → §12 · "Lorem|Acme|John Doe" → §15
265
- - `tracking-tight`, `text-ink-faint`, or `text-xs uppercase tracking-[` on headings and eyebrows (role tokens bypassed) → §12 · `Inter|Roboto|Open_Sans|Poppins|Montserrat|Lato|Space_Grotesk|Geist` in `next/font` imports → [typography.md](typography.md) §3.4
@@ -1,118 +0,0 @@
1
- # The Minimal Baseline: rationale + standalone paste block
2
-
3
- **The canonical baseline block lives in [SKILL.md](../SKILL.md).** This file does two jobs. It explains **why each rule earns its place** (the rationale below), and it carries the one **standalone copy-paste block** for agents that do not load Struq as a skill: the canonical block plus two role-priming lines.
4
-
5
- <!-- GENERATED FROM SKILL.md: the block below is a copy of the canonical baseline block, with two role-priming lines above its title and one blank line between them. Do not edit it here. Edit SKILL.md, then re-copy the block and keep the two role-priming lines. -->
6
-
7
- ## The paste block (generated, for any LLM, any stack)
8
-
9
- Paste this at the top of any project prompt to lift the output into the top tier. Include the rationale section only when the agent has room.
10
-
11
- ```
12
- Act as a senior design engineer at an award-winning studio (Awwwards SOTD
13
- caliber). Apply every rule below; they override your defaults.
14
-
15
- STRUQ BASELINE: NON-NEGOTIABLES
16
-
17
- TYPE: Signature first: one pairing id from typography.md §3.1 (S1 to S7;
18
- Extended E1 to E6 only when no Signature temperament fits), two faces max.
19
- Never Inter, Roboto, Open Sans, Poppins, Montserrat or Lato; never Space
20
- Grotesk or Geist as a reflex. Scale 1.333 with skipped steps (body 1, lead
21
- 1.25, h3 1.78, h2 3.16, display 7 to 8). Hero h1 = text-display,
22
- clamp(2.875rem, 1.2rem + 6.86vw, 8rem), leading 0.92 to 1.0; S4 and S5 set
23
- larger, S7 smaller (typography.md §3.1). Display runs LIGHT (300 to 400);
24
- heavy display only as a declared genre (the Brutalist appendix), never a
25
- reflex. Tracking per face, never one tracking-tight on every size: sans
26
- display about -0.025 to -0.03em, serif display about -0.01 to -0.025em, h2
27
- about half that. Body 1rem to 1.0625rem at 400, leading 1.6+, 55 to 75ch;
28
- emphasis 500, never 700. Hierarchy from scale contrast, not medium sizes.
29
- ROLES: every string takes a role token, never text-xs, text-lg or text-5xl:
30
- font-display text-display, font-display text-h2, type-h3 text-h3,
31
- text-lead, text-body, text-small, eyebrow text-eyebrow, font-ui text-ui,
32
- stat text-stat, and font-display text-statement for the one Orient line
33
- or manifesto. Eyebrows: uppercase 0.75rem (the 12px floor), tracked 0.12
34
- to 0.18em per face, ink-muted, one per section, never ink-faint.
35
- FLOOR: body at least 1rem, muted at least 0.875rem, nothing under 12px.
36
- CONTRAST: body at least 4.5:1, large text at least 3:1. ink-faint is never
37
- text: hairlines and disabled states only.
38
- SIGNATURE: every STRUQ.md declares one typographic signature move
39
- (typography.md §5: weight-in, soft-scrub, type-mask-media,
40
- fit-width-wordmark, a cropped display). The masked line rise is a
41
- technique, not the default hero. Mono only for literal code. Headings get
42
- text-wrap: balance; body gets text-wrap: pretty.
43
-
44
- SPACING: Base-8 rhythm with deliberate asymmetry. Section padding varies by
45
- role: hero 12 to 20rem, dense content 6 to 8rem, breathers 16 to 24rem. NEVER
46
- uniform py-16 on every section. Whitespace is a material: at least one section
47
- should feel "too empty" by SaaS standards.
48
-
49
- SCALING: One fluid root-rem knob, not per-breakpoint size ladders:
50
- html { font-size: clamp(1rem, 0.92rem + 0.2vw, 1.125rem); }
51
- 16px at 390, about 17.6px at 1440, frozen at 18px from about 1640. Every
52
- clamp() keeps a rem term in its middle so zoom works; a vw-only middle is a
53
- bug. Author everything in rem; px only for 1px hairlines. Breakpoints change
54
- LAYOUT (columns, stack or row), never size. Display roles carry their own
55
- clamp() on top.
56
-
57
- SURFACE: Elevation is a tonal step, not a drop shadow. Dark mode: each layer
58
- steps LIGHTER (#0A0A0B, #111113, #19191C, #232328). Light mode: pick one
59
- direction and keep it (panels a shade deeper than a toned page, or white lifted
60
- off a toned page). Hairline borders about 8 to 12% off the surface; in light
61
- mode a border is the ink at low alpha (rgb(<ink> / 0.08 to 0.14)), never a
62
- flat gray hex. Shadows only for true floating elements, then large, soft and faint
63
- (0 24px 80px -12px rgb(0 0 0 / 0.4)). Never shadow-md or shadow-lg.
64
-
65
- COLOR: One accent, used sparingly (links, the key CTA, one highlight per
66
- viewport). Neutrals are temperature-shifted (warm cream and off-black, or cool
67
- charcoal), never pure #FFF and #000 with gray-500 text. A second accent is a
68
- declared genre choice with its own token and the same budget. No blue to
69
- purple gradients, no gradient text, no glassmorphism cards.
70
-
71
- MOTION: Lenis smooth scroll (lerp 0.09 to 0.11) on one gsap.ticker clock,
72
- unless the genre calls for native scroll. Every entrance animates opacity AND
73
- transform (y 24 to 80px, or a clip or overflow mask). Opacity-only fades are
74
- forbidden. Stagger siblings 0.06 to 0.1s. Durations: micro 0.2 to 0.3s,
75
- standard UI 0.3 to 0.6s, reveals 0.6 to 1.0s, hero and cinematic 1.0 to 1.6s;
76
- exits run about 60% of their entrance. Eases: entrances expo-out
77
- cubic-bezier(0.16, 1, 0.3, 1) (expo.out); exits power2.in; position swaps
78
- quart-in-out cubic-bezier(0.76, 0, 0.24, 1) (power3.inOut); curtains and
79
- panels quint-in-out cubic-bezier(0.83, 0, 0.17, 1) (power4.inOut). Never
80
- default ease-in-out everywhere.
81
- Scroll-tied effects scrub; they do not fire time-based tweens at a threshold.
82
- prefers-reduced-motion: gate transforms, keep opacity.
83
-
84
- COMPOSITION: Break the grid at least twice per page: an image bleeding off
85
- the viewport, an element overlapping two sections, oversized type running off
86
- canvas, a sticky element. No three identical icon cards in a row. Vary section
87
- layout: after a 50/50 split comes full-bleed or an asymmetric 1/3 and 2/3.
88
- Icons: ONE coherent family chosen for the brand, one stroke and size,
89
- ink-colored. Never emoji, never the AI-cliche icons (Sparkles, Rocket, Zap),
90
- never icons in colored chips. Animated icons run under 500ms on click and
91
- never loop in standard UI.
92
-
93
- DETAILS THAT SELL IT: Real running copy. Tracked uppercase eyebrows as
94
- wayfinding, not 01/02/03 stamped on every section (one deliberate oversized
95
- numeral is fine). The footer is designed as hard as the hero. Custom
96
- ::selection color. Visible, designed focus-visible states. Every route change
97
- is a designed transition, not a hard cut.
98
- ```
99
-
100
- The block is the floor, not the ceiling. For the full recipe stack (two-face type system, the Lenis + GSAP single clock, showpieces, route transitions, WebGL) start from [direction.md](direction.md), which turns a brief into one contract file, `STRUQ.md`, that cites the other reference files.
101
-
102
- ## Why each rule matters
103
-
104
- **Spacing asymmetry.** Uniform `py-16` is the single most reliable template tell. Real art direction treats vertical rhythm as pacing: a hero breathes, a feature block is dense, a manifesto line sits in 20rem of air. Varying section padding by role is free and reads instantly as "someone decided this." The "too empty" instruction exists because LLMs systematically under-use whitespace; what feels wasteful in code feels expensive in the browser.
105
-
106
- **Type scale contrast.** Templates use five sizes between 16 and 36px, so nothing leads. Agencies compress hierarchy into two extremes: enormous display type (it is the visual, not decoration around the visual) and small, quiet everything else. Negative tracking matters because text faces are spaced for 16px, and at 96px+ the gaps look broken; how much depends on the face (a geometric sans takes about -0.03em, a hairline serif about -0.01em), which is why tracking lives in the pairing row and not in one `tracking-tight`. The tracked-uppercase eyebrow replaces the "medium subhead" that flattens hierarchy. Role tokens (`text-display`, `text-h2`, `eyebrow text-eyebrow`) exist so no component improvises a size, and the one signature move exists so two Struq sites never open the same way. The deeper type system (Signature pairings, roles, fluid scale, SplitText typography) lives in [typography.md](typography.md).
107
-
108
- **Tonal elevation.** Drop shadows on light cards are the Bootstrap-era elevation model; design-led products (Linear, Vercel, Arc) moved to tonal ladders, where closer surfaces are lighter (dark mode) or distinguished by hairline borders and a subtle tone step (light mode). The light-mode direction is a preset decision: editorial/luxury pages are the lightest layer with panels stepping deeper; brutalist pages are toned with white panels lifting off them. One step per layer keeps depth legible either way. When you do shadow a floating element, the large/soft/low-opacity recipe reads as ambient light, while `shadow-lg` reads as a 2015 component library.
109
-
110
- **One accent.** Restraint is the strongest single luxury signal. Multiple accents (or a gradient) means no decision was made. Temperature-shifted neutrals are why agency sites feel "warm" or "cinematic" while AI output feels like a wireframe: pure white + gray-500 is the absence of a palette, not a palette. The second-accent escape hatch exists because playful/consumer genres legitimately run two voices, but it is a *declared token with the same budget*, mirroring how heavy display weight is allowed only as a genre choice, never a reflex.
111
-
112
- **Transform + mask in every entrance.** An opacity-only fade has no physicality: nothing arrives from anywhere. Adding y-travel or a mask (the element slides out from behind a clipped edge) gives motion a direction and a surface to interact with, which is what reads as "crafted." Stagger matters because simultaneous arrival is visual noise; 0.06 to 0.1s offsets create reading order. Ease asymmetry (fast-out entrances, accelerating exits) mirrors how physical objects behave; symmetric ease-in-out everywhere is the uncanny valley of motion.
113
-
114
- **Scrub over trigger.** Effects that progress with scroll position feel connected to the user's hand; effects that fire once at a threshold feel like a slideshow. This one distinction separates most award sites from most marketing sites.
115
-
116
- **Grid-breaking.** A layout where every element respects the container communicates "theme." Bleed, overlap, and stickiness communicate "art direction" because they require coordinating elements across section boundaries, which is exactly what templates cannot do. Twice per page is the floor; it should look intentional, not chaotic.
117
-
118
- **Details.** Real copy forces real layout decisions (lorem hides widow/orphan and width problems). Tracked eyebrow labels give long pages an editorial spine. Note what is *removed* here: stamping `01/02/03` on every section/feature/row is one of the most reliable AI tells. It is reflexive wayfinding that no editor would apply uniformly. One deliberate oversized numeral can be a strong accent; sequencing the whole page is filler. (Ordinal numbering *inside* one genuinely stepped component, such as a pinned phase sequence or a numbered process, is legitimate; page-wide stamping is the tell.) The same logic bans two other reflexes: **monospace as flavor** (it is the look of a code editor, not a designed page, and belongs only around literal code) and the **cliché icon set** (`Sparkles`/`Rocket`/`Zap` and their emoji forms 🚀✨💡 are the visual signature of generated UI). The footer is where everyone quits, so finishing it signals the whole build is finished. Selection color and focus states are tiny, but they are the difference between "surface-level styling" and "someone touched every state."