@struq/skill 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/bin/struq.mjs +132 -0
  4. package/package.json +41 -4
  5. package/skills/struq/SKILL.md +237 -0
  6. package/skills/struq/cinematic/README.md +409 -0
  7. package/skills/struq/cinematic/checklists/01-preproduction.md +15 -0
  8. package/skills/struq/cinematic/checklists/02-implementation.md +17 -0
  9. package/skills/struq/cinematic/checklists/03-polish.md +17 -0
  10. package/skills/struq/cinematic/examples/code/nextjs/README.md +13 -0
  11. package/skills/struq/cinematic/examples/code/nextjs/app/cinematic-experience.tsx +13 -0
  12. package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +42 -0
  13. package/skills/struq/cinematic/examples/code/nextjs/app/page.tsx +6 -0
  14. package/skills/struq/cinematic/examples/code/shared/CameraRig.tsx +65 -0
  15. package/skills/struq/cinematic/examples/code/shared/ExperienceCanvas.tsx +20 -0
  16. package/skills/struq/cinematic/examples/code/shared/ExperienceDirector.tsx +57 -0
  17. package/skills/struq/cinematic/examples/code/shared/ExperienceOverlay.tsx +37 -0
  18. package/skills/struq/cinematic/examples/code/shared/ExperiencePage.tsx +52 -0
  19. package/skills/struq/cinematic/examples/code/shared/GsapFrameLoop.tsx +32 -0
  20. package/skills/struq/cinematic/examples/code/shared/InteriorRoot.tsx +30 -0
  21. package/skills/struq/cinematic/examples/code/shared/README.md +20 -0
  22. package/skills/struq/cinematic/examples/code/shared/SubjectRig.tsx +58 -0
  23. package/skills/struq/cinematic/examples/code/shared/build-experience-timeline.ts +237 -0
  24. package/skills/struq/cinematic/examples/code/shared/direction.ts +52 -0
  25. package/skills/struq/cinematic/examples/code/shared/gsap-client.ts +32 -0
  26. package/skills/struq/cinematic/examples/code/shared/math.ts +12 -0
  27. package/skills/struq/cinematic/examples/code/shared/registry.ts +58 -0
  28. package/skills/struq/cinematic/examples/code/shared/runtime.ts +52 -0
  29. package/skills/struq/cinematic/examples/code/shared/styles.css +127 -0
  30. package/skills/struq/cinematic/examples/code/shared/useExperienceInput.ts +42 -0
  31. package/skills/struq/cinematic/examples/code/vite/README.md +14 -0
  32. package/skills/struq/cinematic/examples/code/vite/src/App.tsx +11 -0
  33. package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +37 -0
  34. package/skills/struq/cinematic/examples/code/vite/src/main.tsx +9 -0
  35. package/skills/struq/cinematic/examples/concise/01-chapter-spec-example.md +42 -0
  36. package/skills/struq/cinematic/examples/concise/02-handoff-example.md +25 -0
  37. package/skills/struq/cinematic/examples/concise/03-audit-finding-example.md +13 -0
  38. package/skills/struq/cinematic/examples/verbose/01-full-experience-direction.md +687 -0
  39. package/skills/struq/cinematic/examples/verbose/02-full-audit-example.md +362 -0
  40. package/skills/struq/cinematic/references/01-core-mental-model.md +221 -0
  41. package/skills/struq/cinematic/references/02-vocabulary.md +384 -0
  42. package/skills/struq/cinematic/references/03-experience-architecture.md +302 -0
  43. package/skills/struq/cinematic/references/04-timeline-choreography.md +264 -0
  44. package/skills/struq/cinematic/references/05-handoff-contracts.md +241 -0
  45. package/skills/struq/cinematic/references/06-three-rig-architecture.md +286 -0
  46. package/skills/struq/cinematic/references/07-stack-integration.md +228 -0
  47. package/skills/struq/cinematic/references/08-gsap-plugin-patterns.md +218 -0
  48. package/skills/struq/cinematic/references/09-tuning-harness.md +202 -0
  49. package/skills/struq/cinematic/references/10-quality-systems.md +206 -0
  50. package/skills/struq/cinematic/references/11-agent-workflows.md +246 -0
  51. package/skills/struq/cinematic/references/12-audit-rubric.md +176 -0
  52. package/skills/struq/cinematic/references/13-official-docs.md +53 -0
  53. package/skills/struq/cinematic/templates/01-experience-brief.md +105 -0
  54. package/skills/struq/cinematic/templates/02-experience-contract.md +80 -0
  55. package/skills/struq/cinematic/templates/03-chapter-spec.md +103 -0
  56. package/skills/struq/cinematic/templates/04-handoff-contract.md +102 -0
  57. package/skills/struq/cinematic/templates/05-ownership-matrix.md +29 -0
  58. package/skills/struq/cinematic/templates/06-shot-presets.md +59 -0
  59. package/skills/struq/cinematic/templates/07-implementation-slice.md +66 -0
  60. package/skills/struq/cinematic/templates/08-tuning-register.md +36 -0
  61. package/skills/struq/cinematic/templates/09-acceptance-tests.md +57 -0
  62. package/skills/struq/references/anti-patterns.md +265 -0
  63. package/skills/struq/references/baseline-prompt.md +118 -0
  64. package/skills/struq/references/brand.md +143 -0
  65. package/skills/struq/references/brief-router.md +219 -0
  66. package/skills/struq/references/composition.md +191 -0
  67. package/skills/struq/references/depth-parallax.md +235 -0
  68. package/skills/struq/references/direction.md +188 -0
  69. package/skills/struq/references/feel-layer.md +107 -0
  70. package/skills/struq/references/gsap-api.md +293 -0
  71. package/skills/struq/references/iconography.md +255 -0
  72. package/skills/struq/references/imagery.md +144 -0
  73. package/skills/struq/references/loaders.md +287 -0
  74. package/skills/struq/references/micro-interactions.md +270 -0
  75. package/skills/struq/references/mobile-composition.md +70 -0
  76. package/skills/struq/references/nav-header.md +296 -0
  77. package/skills/struq/references/page-architecture.md +134 -0
  78. package/skills/struq/references/page-entrances.md +198 -0
  79. package/skills/struq/references/performance.md +235 -0
  80. package/skills/struq/references/pinning-scrub.md +341 -0
  81. package/skills/struq/references/pitfalls.md +290 -0
  82. package/skills/struq/references/route-transitions.md +281 -0
  83. package/skills/struq/references/scaling.md +151 -0
  84. package/skills/struq/references/scroll-reveals.md +280 -0
  85. package/skills/struq/references/seo.md +219 -0
  86. package/skills/struq/references/setup.md +397 -0
  87. package/skills/struq/references/showcase.md +87 -0
  88. package/skills/struq/references/stack-targets.md +78 -0
  89. package/skills/struq/references/struq-imagery.md +107 -0
  90. package/skills/struq/references/svg-motion.md +390 -0
  91. package/skills/struq/references/transitions.md +160 -0
  92. package/skills/struq/references/triage.md +71 -0
  93. package/skills/struq/references/typography.md +720 -0
  94. package/skills/struq/references/vibe-presets.md +287 -0
  95. package/skills/struq/references/webgl-r3f.md +274 -0
  96. package/skills/struq/references/webgl-scenes.md +593 -0
@@ -0,0 +1,107 @@
1
+ # Struq imagery: the beeldbank before stock
2
+
3
+ ## Decide here
4
+
5
+ Planning reads only this section ([direction.md](direction.md) §4); the rest is for the build.
6
+
7
+ - **When:** the brief has no photos of its own, or only some. The client's own photos always come first ([imagery.md](imagery.md) §4, rung 1); the beeldbank fills what is left, before generating your own images and long before stock.
8
+ - **What it is:** Struq's own beeldbank at [struq.nl/beeldbank](https://struq.nl/beeldbank): twenty sector packs of 24 images each, made and graded as one set per pack (generated, art-directed and checked, so no AI artefacts). CC0; credit to struq.nl is appreciated, not required, and a Struq build gives it anyway (§5).
9
+ - **One pack per site.** Each pack carries one grade, so one pack keeps the page on one grade ([imagery.md](imagery.md) §1). Never mix two packs on one site.
10
+ - **Pick by brief, then by grade:** the pack whose `sector` matches the brief, then the one whose grade sits with the planned palette and preset temperature. If no pack fits both, the beeldbank is out for this site (§6).
11
+ - **Context, never the product.** Beeldbank photos show the world around the client: the place, the material, the work in general. Never the client's own product, team, premises or projects.
12
+ - **Record in STRUQ.md:** under SYSTEM, `Imagery: <grade> (Struq beeldbank, pack <slug>), ratios <...>, grain <...>`; under MATERIALS Missing, which shot-list lines the pack fills and which stay placeholders.
13
+
14
+ ---
15
+
16
+ ## 1. When to reach for it
17
+
18
+ | The brief has | Do |
19
+ |---|---|
20
+ | Real photos for every planned image | Use them. No beeldbank. |
21
+ | Real photos for some images | Real photos where they exist; fill the remaining context slots from one pack whose grade sits closest to them, and run both through the same `GradedImage` layer ([imagery.md](imagery.md) §1) |
22
+ | No photos at all | One pack for every context image; product shots stay placeholders ([imagery.md](imagery.md) §4b) |
23
+
24
+ The materials request at the gate still asks for photos ([direction.md](direction.md) §5). The beeldbank stands in until they arrive; when they do, the client's photos replace the pack slot by slot.
25
+
26
+ ---
27
+
28
+ ## 2. Fetch the packs
29
+
30
+ Two public endpoints, JSON, no auth, CORS open. Use `curl` or the agent's own fetch tool.
31
+
32
+ ```bash
33
+ # every pack: slug, title, sector, description, imageCount, slots, aspectRatios
34
+ curl -s https://struq.nl/api/media/packs
35
+
36
+ # one pack: its images with id, slot, publicUrl, alt (Dutch) and description
37
+ curl -s https://struq.nl/api/media/packs/<slug>
38
+ ```
39
+
40
+ - The index returns `{ totalImages, packCount, license, packs: [...] }`. Read `sector` and `description` to match the brief, `slots` for how many images each role has, and `aspectRatios` for the crops on offer.
41
+ - The pack returns `{ pack, totalImages, images: [...] }`. Each image has `id`, `slot`, `publicUrl` (an absolute `https://struq.nl/media/packs/...` WebP), `alt` in Dutch and a longer `description`.
42
+ - Read the `description` of each image before choosing it; the `alt` is short on purpose.
43
+
44
+ ---
45
+
46
+ ## 3. Map the site plan to slots
47
+
48
+ Every image in the PLAN (the arc rows and the shot list under `Assets to produce`) gets one slot from the chosen pack:
49
+
50
+ | Slot | Takes the plan's |
51
+ |---|---|
52
+ | `hero` | hero image, the LCP image of the page |
53
+ | `band` | full-bleed statement between sections |
54
+ | `card` | standard content or work-thumb frames |
55
+ | `column` | tall images beside a column of text |
56
+ | `detail` | close-ups, material, texture beside copy |
57
+ | `backdrop` | decorative media behind type: `alt=""` or `aria-hidden` ([imagery.md](imagery.md) §5) |
58
+
59
+ - Place each image in the site's declared ratio set ([imagery.md](imagery.md) §2), choosing from the pack's `aspectRatios`. Crop to the declared ratio; never add a ratio to fit a photo.
60
+ - A slot that runs out takes an image from another slot of the **same pack**, recropped. Never a second pack. If nothing in the pack fits the frame, that frame stays a placeholder ([imagery.md](imagery.md) §4b).
61
+ - Do not use an image twice on one page.
62
+
63
+ ---
64
+
65
+ ## 4. Download, never hotlink
66
+
67
+ The chosen files live in the project, not on struq.nl. A hotlinked image breaks the day the pack changes, and it puts a second origin in front of the LCP.
68
+
69
+ ```bash
70
+ # one file per chosen image, named by its role on the site
71
+ curl -sL -o public/images/hero.webp "https://struq.nl/media/packs/<slug>/<file>.webp"
72
+ ```
73
+
74
+ - **Check every download is an image.** `curl -sL -o` saves whatever comes back, including an error page. After each download confirm the file is a real WebP (`file public/images/hero.webp` says "Web/P image", or the first bytes read `RIFF....WEBP`) and is more than a few KB. If any file fails, delete it and fall back to §6 for that slot; never ship a broken image or retry in a loop.
75
+ - Keep a short list of what came from where (role, pack slug, image `id`) under LOG in STRUQ.md, so a later swap for the client's own photo is a lookup, not a hunt.
76
+ - **Optimise per [performance.md](performance.md).** Check each file against the budgets in §1 there (hero 250KB at 1920w, content 150KB at 1440w). Any file over budget goes through the `sharp-cli` line in §4 there to a new file, never over the download. Then serve through `next/image` with `sizes`, width and height or `fill`, and the LCP hint on the hero only.
77
+ - **The grade is already in the pixels.** Tune the `GradedImage` constants against the pack, so the layer unifies the page instead of stacking a second grade on top ([imagery.md](imagery.md) §1).
78
+
79
+ ---
80
+
81
+ ## 5. Alt text and the credit
82
+
83
+ - **Alt:** on a Dutch site, use the pack's `alt` as it is. On a site in another language, translate it, keeping it a plain description of the subject. Adjust it where your crop no longer shows what it names. Decorative slots stay `alt=""`.
84
+ - **Credit:** one quiet line in the footer, or on the credits or colophon page if the site has one:
85
+
86
+ `Beeld: Struq (struq.nl/beeldbank)`
87
+
88
+ On a site in another language, translate the label and keep the name and address: `Images: Struq (struq.nl/beeldbank)`. Set in `text-small`, `ink-muted`, with the address as a plain link. Never a badge, never a logo, never above the fold.
89
+
90
+ ---
91
+
92
+ ## 6. When the beeldbank is not the answer
93
+
94
+ Go back to the sourcing ladder in [imagery.md](imagery.md) §4 (generated on-brief, then treated stock, or placeholders) when:
95
+
96
+ - struq.nl does not answer, or either endpoint returns an error;
97
+ - no pack matches the brief's sector, or the only sector match fights the planned palette and grade;
98
+ - the site needs images of the client's own product, people or projects (those are shot, or they are placeholders).
99
+
100
+ Write the reason under LOG in STRUQ.md, in one line.
101
+
102
+ ---
103
+
104
+ **Never present a beeldbank photo as the client's own work.** No pack image in a portfolio or work index, no caption that names it as the client's project, team, kitchen or workshop, no "our studio" over a stranger's room. It shows the world the client works in; the client's work stays theirs to shoot.
105
+
106
+ **Cheap tell:** two packs on one page, a hotlinked struq.nl URL, a Dutch alt on an English site, a pack photo captioned as the client's project.
107
+ **Instead:** one pack, matched to the sector and the grade, downloaded and optimised, slots mapped to the plan, alt in the site's language, one quiet credit line.
@@ -0,0 +1,390 @@
1
+ # SVG Motion: DrawSVG, MorphSVG, MotionPath
2
+
3
+ SVG motion is how a site signs its name: a line that draws the story down the page, a mark that writes itself on first load, a logo that folds into an icon. Done well it costs almost nothing (a dash offset, a path string); done badly it is a squiggle that plays on a loop for no reason. Every recipe here is tied to scroll, a state change or a first visit, never to a timer.
4
+
5
+ Prerequisites: the registry and `useGSAP` rules in [setup.md](setup.md), reduced motion via `gsap.matchMedia()` ([setup.md](setup.md) §4). API pointers: [gsap-api.md](gsap-api.md) §9.
6
+
7
+ ## 1. Registration
8
+
9
+ All three plugins ship in the public `gsap` package since 3.13. Register only the ones the build uses, in the one registry module:
10
+
11
+ ```ts
12
+ // lib/gsap.ts (additions)
13
+ import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
14
+ import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
15
+ import { MotionPathPlugin } from "gsap/MotionPathPlugin";
16
+
17
+ gsap.registerPlugin(ScrollTrigger, SplitText, useGSAP, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin);
18
+ export { DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin };
19
+ ```
20
+
21
+ ## 2. Stroke setup and the gotchas
22
+
23
+ DrawSVG animates `stroke-dashoffset` and `stroke-dasharray` on `path`, `line`, `polyline`, `polygon`, `rect`, `circle` and `ellipse`. `drawSVG: 0` is nothing drawn, `"100%"` the full stroke, `"40% 60%"` a segment, and `"50% 50%"` to `"0% 100%"` draws from the middle outward.
24
+
25
+ ```css
26
+ .draw { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
27
+ .draw-fluid { vector-effect: non-scaling-stroke; } /* static strokes only: never on a path DrawSVG animates */
28
+ ```
29
+
30
+ - **Colour from tokens.** `stroke: currentColor` and a `text-ink` or `text-accent` class on the `<svg>`. Never a hex in the SVG file.
31
+ - **Never combine `preserveAspectRatio="none"` with `vector-effect: non-scaling-stroke` on a drawn path.** The stroke is then dashed in screen space while DrawSVG measures in user space under a non-uniform scale, so the dash length is wrong and the line ends early or never finishes. A drawn path scales uniformly (default `preserveAspectRatio`), or is rebuilt in CSS pixels in a `viewBox` that matches its box and re-measured on resize (§3). A short, nearly flat swash may stretch without `vector-effect` (it measures correctly, the slight stroke distortion is invisible at that size, §5). `draw-fluid` is for static hairlines that are never drawn.
32
+ - **A drawn motif used at several sizes takes its stroke width as a prop.** One 120-unit profile rendered at 72px and at 600px is either a hairline or a marker with a fixed `stroke-width`, and `vector-effect: non-scaling-stroke` is off the table on a drawn path (above). Pass the weight per use so the rendered stroke stays about 1 to 1.5px: `strokeWidth = 1.25 * (viewBoxWidth / renderedWidthPx)`, written as a prop (`<Motif weight={2.1} />` at 72px, `weight={0.25}` at 600px).
33
+ - **Delete `pathLength` attributes.** Exported SVGs and CSS dash tutorials often set `pathLength="1"`, which rescales dash units; DrawSVG's measured lengths then stop matching and the line ends early or never finishes.
34
+ - **No existing dashes.** DrawSVG owns `stroke-dasharray`. A dashed line has to be faked (a mask over a dashed copy), not drawn directly.
35
+ - **Round caps show a dot at 0.** With `stroke-linecap: round`, a zero-length stroke still paints a dot. Start the element hidden and reveal it as drawing begins: `gsap.set(el, { autoAlpha: 1 })` right before the draw, or fade in over the first 5% of a scrub.
36
+ - **Outlined text cannot be drawn.** A font converted to outlines is filled shapes with two contours per stroke; DrawSVG traces both outlines. Signatures and handwriting need a **single-stroke centreline** path. For filled lettering, draw a thick centreline inside a `<mask>` and let it reveal the fill (§4).
37
+ - **Draw direction is path direction.** If it draws backwards, reverse the path in the editor or animate `drawSVG: "100% 100%"` to `"0% 100%"`.
38
+
39
+ ## 3. `path-journey`: a scrub-drawn line through the sections
40
+
41
+ One long path behind several sections, drawn as you scroll, connecting the chapters. It sits at plane -1 (behind content, above the background) and never crosses running text.
42
+
43
+ ```tsx
44
+ "use client";
45
+ import { useLayoutEffect, useRef, useState } from "react";
46
+ import { gsap, useGSAP } from "@/lib/gsap";
47
+
48
+ // Control points as fractions of the wrapper box; the path is rebuilt in CSS pixels.
49
+ const journey = (w: number, h: number) =>
50
+ `M${0.8 * w} 0 C ${0.8 * w} ${0.18 * h}, ${0.2 * w} ${0.22 * h}, ${0.2 * w} ${0.4 * h} ` +
51
+ `S ${0.8 * w} ${0.64 * h}, ${0.7 * w} ${0.8 * h} S ${0.3 * w} ${0.92 * h}, ${0.5 * w} ${h}`;
52
+
53
+ export function PathJourney({ children }: { children: React.ReactNode }) {
54
+ const root = useRef<HTMLDivElement>(null);
55
+ const [box, setBox] = useState({ w: 0, h: 0 });
56
+
57
+ useLayoutEffect(() => {
58
+ const el = root.current!;
59
+ const ro = new ResizeObserver(([e]) => {
60
+ const { width, height } = e.contentRect;
61
+ setBox((b) => (b.w === Math.round(width) && b.h === Math.round(height) ? b : { w: Math.round(width), h: Math.round(height) }));
62
+ });
63
+ ro.observe(el);
64
+ return () => ro.disconnect();
65
+ }, []);
66
+
67
+ useGSAP(() => {
68
+ if (!box.w) return;
69
+ const mm = gsap.matchMedia();
70
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
71
+ gsap.set("[data-journey]", { autoAlpha: 1 });
72
+ gsap.fromTo("[data-journey]", { drawSVG: 0 }, {
73
+ drawSVG: "100%",
74
+ ease: "none",
75
+ scrollTrigger: { trigger: root.current, start: "top 60%", end: "bottom 80%", scrub: 1 },
76
+ });
77
+ });
78
+ }, { scope: root, dependencies: [box.w, box.h], revertOnUpdate: true });
79
+
80
+ return (
81
+ <div ref={root} className="relative">
82
+ {box.w > 0 && (
83
+ <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full text-accent"
84
+ viewBox={`0 0 ${box.w} ${box.h}`}>
85
+ <path data-journey className="draw" d={journey(box.w, box.h)} />
86
+ </svg>
87
+ )}
88
+ <div className="relative">{children}</div>
89
+ </div>
90
+ );
91
+ }
92
+ ```
93
+
94
+ - **The rule:** a full-height drawn path is never stretched. `preserveAspectRatio="none"` with `vector-effect: non-scaling-stroke` looks like the easy way to fit one path to any content length, but DrawSVG cannot measure a stroke drawn that way (§2). Instead the `viewBox` is the wrapper's own pixel box, so one user unit is one CSS pixel, the stroke stays 1.5px with no `vector-effect`, and the curve is rebuilt from fractions of the box.
95
+ - **Resize re-measures.** The `ResizeObserver` updates the box only when the rounded size changes; `revertOnUpdate: true` kills the old tween and its ScrollTrigger and builds a fresh one against the new `d`, so DrawSVG always measures the path that is on screen. Check it at 390 and 1440.
96
+ - **Scrub, never a threshold.** `ease: "none"` and `scrub: 1`: the line is where the reader is ([feel-layer.md](feel-layer.md)). `start: "top 60%"` keeps the drawing tip near the middle of the viewport, where the eye already is.
97
+ - **Mobile is authored.** At 390px a wide S-curve crosses the text column. Write a second, straighter builder (a gentle edge-hugging line) and pick it from the box: `d={box.w < 768 ? edge(box.w, box.h) : journey(box.w, box.h)}`. The resize rebuild swaps it for free.
98
+ - **Reduced motion:** no tween is created, so the full path is drawn statically. The path itself is the design; motion only stages it.
99
+ - Want something travelling along the line? The pixel `viewBox` scales uniformly, so a MotionPath follower can ride this exact path (§9).
100
+ - Pairs with `pin-horizontal-gallery` or `manifesto-fill` in a brand-story arc ([showcase.md](showcase.md)), never with a second scrub-drawn line.
101
+
102
+ ## 4. Signature or logo draw
103
+
104
+ A mark that writes itself: strokes in order, each at a speed proportional to its length, so long strokes do not race.
105
+
106
+ ```tsx
107
+ "use client";
108
+ import { useRef } from "react";
109
+ import { gsap, useGSAP } from "@/lib/gsap";
110
+
111
+ export function SignatureDraw() {
112
+ const root = useRef<SVGSVGElement>(null);
113
+
114
+ useGSAP(() => {
115
+ const mm = gsap.matchMedia();
116
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
117
+ const strokes = gsap.utils.toArray<SVGPathElement>("[data-stroke]");
118
+ gsap.set(strokes, { autoAlpha: 0 }); // no round-cap dots waiting on the page
119
+ const tl = gsap.timeline({
120
+ scrollTrigger: { trigger: root.current, start: "top 75%", once: true },
121
+ });
122
+ let at = 0;
123
+ strokes.forEach((p) => {
124
+ const d = gsap.utils.clamp(0.25, 1.2, p.getTotalLength() / 400); // seconds per stroke, by length
125
+ tl.set(p, { autoAlpha: 1 }, at)
126
+ .fromTo(p, { drawSVG: 0 }, { drawSVG: "100%", duration: d, ease: "power2.inOut" }, "<");
127
+ at += d - 0.08; // the next stroke starts 0.08s before this one ends
128
+ });
129
+ });
130
+ }, { scope: root });
131
+
132
+ return (
133
+ <svg ref={root} viewBox="0 0 320 120" aria-label="Signed, the studio" role="img" className="w-64 text-ink">
134
+ <path data-stroke className="draw" d="M10 80 C 40 20, 70 20, 80 70 S 120 110, 140 60" />
135
+ <path data-stroke className="draw" d="M150 60 C 170 30, 200 30, 210 70 S 260 100, 300 40" />
136
+ </svg>
137
+ );
138
+ }
139
+ ```
140
+
141
+ - `power2.inOut` per stroke reads as a hand: it accelerates into the line and settles at the end. A linear draw reads as a plotter.
142
+ - Every stroke is hidden up front and revealed at the exact moment its own draw starts, so no stroke shows a round-cap dot while it waits its turn.
143
+ - The 0.08s overlap keeps the pen moving between strokes; a gap reads as a stutter.
144
+ - **Filled wordmark?** Keep the fill and reveal it through a mask: `<mask id="m"><path data-stroke className="draw" stroke="white" strokeWidth={14} d="(centreline)" /></mask><g mask="url(#m)">(filled letters)</g>`. The thick stroke draws, the mask follows it, and the real letterforms appear behind the pen.
145
+ - **When not to:** a mark that is geometric (circles, bars). Drawing a circle reads as a loading spinner; use a mask wipe or a scale-in instead.
146
+
147
+ ## 5. Link underline draw
148
+
149
+ The straight underline is pure CSS ([micro-interactions.md](micro-interactions.md) §3a); use that. DrawSVG earns its place only when the line is not straight: a hand-drawn swash under one word in a headline, or the main CTA.
150
+
151
+ ```tsx
152
+ "use client";
153
+ import { useRef } from "react";
154
+ import { gsap, useGSAP } from "@/lib/gsap";
155
+
156
+ export function SwashLink({ href, children }: { href: string; children: React.ReactNode }) {
157
+ const root = useRef<HTMLAnchorElement>(null);
158
+ const tl = useRef<gsap.core.Timeline | null>(null);
159
+
160
+ useGSAP(() => {
161
+ const mm = gsap.matchMedia();
162
+ mm.add("(hover: hover) and (prefers-reduced-motion: no-preference)", () => {
163
+ tl.current = gsap.timeline({ paused: true })
164
+ .set("[data-swash]", { autoAlpha: 1 })
165
+ .fromTo("[data-swash]", { drawSVG: 0 }, { drawSVG: "100%", duration: 0.45, ease: "power3.out" });
166
+ return () => { tl.current = null; };
167
+ });
168
+ }, { scope: root });
169
+
170
+ return (
171
+ <a ref={root} href={href} className="relative inline-block"
172
+ onPointerEnter={() => tl.current?.play()} onPointerLeave={() => tl.current?.reverse()}
173
+ onFocus={() => tl.current?.play()} onBlur={() => tl.current?.reverse()}>
174
+ {children}
175
+ <svg aria-hidden viewBox="0 0 200 12" preserveAspectRatio="none" className="absolute -bottom-2 left-0 h-3 w-full text-accent">
176
+ <path data-swash className="draw invisible" d="M2 8 C 50 2, 120 12, 198 4" />
177
+ </svg>
178
+ </a>
179
+ );
180
+ }
181
+ ```
182
+
183
+ On touch and under reduced motion no timeline exists; show the swash statically on the active link if the design needs it, or leave it out. The focus handlers make the keyboard path identical to hover.
184
+
185
+ ## 6. `draw-mark-loader`: the DrawMark preset, wired
186
+
187
+ The loader shell, the session gate and the scroll lock live in [loaders.md](loaders.md); this is the DrawSVG body for its DrawMark preset. The loader invariant from [../SKILL.md](../SKILL.md) holds: the revisit gate and the live animation are separate, and the `seen` flag is written only after the exit finishes.
188
+
189
+ ```css
190
+ /* Before React runs, the SSR markup must not paint a finished mark for a frame. */
191
+ .dm-mark { visibility: hidden; }
192
+ ```
193
+
194
+ ```tsx
195
+ "use client";
196
+ import { useRef, useState } from "react";
197
+ import { gsap, useGSAP } from "@/lib/gsap";
198
+ import { loaderSeen, markLoaderSeen } from "./useFirstVisit"; // loaders.md
199
+
200
+ export function LoaderDrawMark({ id = "intro" }: { id?: string }) {
201
+ const root = useRef<HTMLDivElement>(null);
202
+ const [done, setDone] = useState(false);
203
+
204
+ useGSAP(() => {
205
+ if (loaderSeen(id)) { setDone(true); return; } // revisit: gone before paint
206
+ const finish = () => { markLoaderSeen(id); setDone(true); };
207
+ const mm = gsap.matchMedia();
208
+ mm.add({ motion: "(prefers-reduced-motion: no-preference)", reduced: "(prefers-reduced-motion: reduce)" }, (ctx) => {
209
+ const tl = gsap.timeline({ onComplete: finish });
210
+ if (ctx.conditions!.reduced) {
211
+ tl.set(".dm-mark", { visibility: "visible", autoAlpha: 0 })
212
+ .to(".dm-mark", { autoAlpha: 1, duration: 0.2 })
213
+ .to(root.current, { autoAlpha: 0, duration: 0.3 }, "+=0.3");
214
+ return;
215
+ }
216
+ tl.set(".dm-mark", { visibility: "visible" })
217
+ .fromTo(".dm-stroke", { drawSVG: 0 }, { drawSVG: "100%", duration: 1.1, ease: "power2.inOut", stagger: 0.12 })
218
+ .from(".dm-word", { yPercent: 110, duration: 0.7, ease: "expo.out" }, "-=0.35")
219
+ .to(root.current, { autoAlpha: 0, duration: 0.6, ease: "power2.inOut" }, "+=0.25");
220
+ });
221
+ }, { scope: root });
222
+
223
+ if (done) return null;
224
+ return (
225
+ <div ref={root} aria-hidden className="fixed inset-0 z-[10001] grid place-items-center bg-surface-0 text-ink">
226
+ <div className="dm-mark flex items-center gap-4">
227
+ <svg viewBox="0 0 64 64" className="h-14 w-14">
228
+ <path className="dm-stroke draw" d="M12 48 L32 12 L52 48" />
229
+ <path className="dm-stroke draw" d="M20 36 H44" />
230
+ </svg>
231
+ <span className="overflow-clip"><span className="dm-word block font-display text-h3">Brand</span></span>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+ ```
237
+
238
+ - Total runtime about 2.3s on a first visit, zero on a revisit. Longer than 2.5s and the loader is in the way, not a welcome.
239
+ - The page renders and paints under the overlay ([seo.md](seo.md) §6); the loader only covers it. The hero's `line-rise-hero` starts on the overlay's fade ([typography.md](typography.md) §5).
240
+ - Use the real mark's paths, converted to single-stroke where the mark is drawn from lines ([brand.md](brand.md)).
241
+
242
+ ## 7. `logo-morph`: MorphSVG
243
+
244
+ MorphSVG rewrites a shape's geometry between two shapes. The target can be an element or a raw path string: `gsap.to(logo, { morphSVG: icon })`. A selector string also works, but it resolves lazily against the whole document, not your component (see the scoping note below).
245
+
246
+ **Rules that decide whether a morph looks designed or melted:**
247
+
248
+ - **Path, polyline, polygon.** MorphSVG morphs `<path>`, `<polyline>` and `<polygon>` in place (it rewrites `d` or `points`; the element stays the same node). `<circle>`, `<rect>` and `<ellipse>` need `MorphSVGPlugin.convertToPath(...)` first, and that call is the only one that **replaces** elements, which pulls nodes out from under React. Run it on an SVG React never re-renders, or better, convert once in the editor and commit paths.
249
+ - **Scope plugin selectors yourself.** `useGSAP`'s `scope` scopes the tween's *target* selector, but strings inside plugin config (`morphSVG.shape`, `motionPath.path`, `motionPath.align`) are resolved later by the plugin against `document`. With two instances on a page they grab the first match. Query the element from the component root and pass it: `root.current!.querySelector("[data-icon]")`.
250
+ - **One path to one path.** A five-letter wordmark morphing into one icon is five morphs, not one: morph the letter that becomes the icon and fade or scale the others away. Compound paths (several subpaths in one `d`) work best when both shapes have the same number of subpaths.
251
+ - **`shapeIndex`** chooses which point on the start shape maps to the start of the end shape. The default (`"auto"`) is usually right; when a morph twists or folds through itself, try small integers (`shapeIndex: 3`) until it travels cleanly.
252
+ - **`type: "rotational"`** interpolates points by angle and distance around a centre instead of in straight lines. Use it when linear morphing makes the shape collapse through its middle (blobs, organic marks, shapes that rotate as they change).
253
+ - Duration 0.5 to 0.8s, `power3.inOut` or the quart in-out curve: a morph is an A to B with both ends visible.
254
+
255
+ **Header mark that folds into an icon on scroll.** Wordmark at the top of the page, compact icon once the hero is past:
256
+
257
+ ```tsx
258
+ "use client";
259
+ import { useRef } from "react";
260
+ import { gsap, useGSAP, ScrollTrigger } from "@/lib/gsap";
261
+
262
+ export function MorphingMark() {
263
+ const root = useRef<SVGSVGElement>(null);
264
+
265
+ useGSAP(() => {
266
+ const mm = gsap.matchMedia();
267
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
268
+ const icon = root.current!.querySelector<SVGPathElement>("[data-icon]")!; // element, not a selector string
269
+ const tl = gsap.timeline({ paused: true, defaults: { duration: 0.6, ease: "power3.inOut" } })
270
+ .to("[data-shape]", { morphSVG: { shape: icon, type: "rotational" } }, 0)
271
+ .to("[data-rest]", { autoAlpha: 0, x: -8, stagger: 0.03 }, 0);
272
+ ScrollTrigger.create({
273
+ start: () => window.innerHeight * 0.4, // scroll position in px: 40% of the first viewport
274
+ end: "max",
275
+ onEnter: () => tl.play(),
276
+ onLeaveBack: () => tl.reverse(),
277
+ });
278
+ });
279
+ }, { scope: root });
280
+
281
+ return (
282
+ <svg ref={root} viewBox="0 0 160 40" role="img" aria-label="Brand" className="h-8 text-ink">
283
+ <path data-shape fill="currentColor" d="(first letter of the wordmark)" />
284
+ <g data-rest fill="currentColor">{/* remaining letters */}</g>
285
+ <path data-icon d="(the icon)" className="hidden" />
286
+ </svg>
287
+ );
288
+ }
289
+ ```
290
+
291
+ The `[data-icon]` path only supplies shape data, so it stays hidden. Under reduced motion swap the two with a cross-fade instead (Motion `AnimatePresence` or a CSS opacity transition), since the state change still has to read.
292
+
293
+ **Play and pause.** The cleanest icon morph there is, if both shapes have matching subpaths. Draw play as two halves so it matches pause's two bars:
294
+
295
+ ```tsx
296
+ const PLAY = "M7 5 L12 8.2 L12 15.8 L7 19 Z M12 8.2 L18 12 L18 12 L12 15.8 Z";
297
+ const PAUSE = "M7 5 H10.5 V19 H7 Z M13.5 5 H17 V19 H13.5 Z";
298
+
299
+ // in a click handler made with contextSafe from useGSAP:
300
+ gsap.to(iconPath, { morphSVG: playing ? PAUSE : PLAY, duration: 0.35, ease: "power3.inOut" });
301
+ ```
302
+
303
+ Two subpaths of four points each on both sides, so every point has a partner and the morph never tears. Reduced motion: set the `d` directly, no tween.
304
+
305
+ **Blobs.** A blob that morphs between organic shapes is the classic tell when it loops on a timer ("nothing moves the user did not cause", [feel-layer.md](feel-layer.md) §5). Tie it to scroll (`scrub`) or to hover, use `type: "rotational"`, keep two or three shapes with similar point counts, and keep it behind content at low contrast.
306
+
307
+ ## 8. Morph during a route transition
308
+
309
+ The cover overlay from [route-transitions.md](route-transitions.md) is the perfect stage for a mark morph: the page is hidden, the eye has nothing else to watch, and the morph fills the swap. Add the mark inside the overlay and play the morph inside the cover's window:
310
+
311
+ ```tsx
312
+ // PageTransition.tsx, additions only. Inside the overlay wrapper, above the panel:
313
+ <svg ref={mark} viewBox="0 0 64 64" className="absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 text-surface-0 opacity-0">
314
+ <path data-from fill="currentColor" d="(mark)" />
315
+ <path data-to className="hidden" d="(alternate shape)" />
316
+ </svg>
317
+
318
+ // In navigate(), after cover(...) starts, when motion is allowed:
319
+ if (!reduce && mark.current) {
320
+ const p = mark.current.querySelector<SVGPathElement>("[data-from]")!;
321
+ const to = mark.current.querySelector<SVGPathElement>("[data-to]")!;
322
+ gsap.timeline()
323
+ .to(mark.current, { opacity: 1, duration: 0.2 }, 0.3)
324
+ .to(p, { morphSVG: to, duration: 0.5, ease: "power3.inOut" }, 0.35)
325
+ .to(mark.current, { opacity: 0, duration: 0.2 }, 0.85)
326
+ .set(p, { morphSVG: p }); // reset to its own shape for the next navigation
327
+ }
328
+ ```
329
+
330
+ - Keep the whole thing inside the cover and reveal: the budget is still 900ms per navigation ([route-transitions.md](route-transitions.md)). If the morph needs longer, the morph is too complex.
331
+ - The final `set` with the path itself as the target restores its original shape; MorphSVG records the original `d` for exactly this.
332
+ - One mark, one morph. A different shape per route turns navigation into a slideshow.
333
+
334
+ ## 9. MotionPath: follow a path
335
+
336
+ An element that travels along a curve: a dot running the journey line, a plane crossing a map, a cursor of light along a diagram.
337
+
338
+ ```tsx
339
+ // inside a component with `const root = useRef<HTMLDivElement>(null)` on the section, holding a
340
+ // uniformly scaled <svg> with <path data-route className="draw" /> and a <div data-follower />
341
+ useGSAP(() => {
342
+ const mm = gsap.matchMedia();
343
+ mm.add("(prefers-reduced-motion: no-preference)", () => {
344
+ const route = root.current!.querySelector<SVGPathElement>("[data-route]")!; // element, not a string
345
+ const tl = gsap.timeline({
346
+ defaults: { ease: "none", duration: 1 },
347
+ scrollTrigger: { trigger: root.current, start: "top 60%", end: "bottom 60%", scrub: 1 },
348
+ });
349
+ tl.set(route, { autoAlpha: 1 }, 0)
350
+ .fromTo(route, { drawSVG: 0 }, { drawSVG: "100%" }, 0)
351
+ .to("[data-follower]", {
352
+ motionPath: {
353
+ path: route,
354
+ align: route, // follower sits on the path, in the path's coordinate space
355
+ alignOrigin: [0.5, 0.5], // centre of the follower on the line
356
+ autoRotate: true, // faces along the tangent; omit for a dot
357
+ start: 0,
358
+ end: 1,
359
+ },
360
+ }, 0);
361
+ });
362
+ }, { scope: root });
363
+ ```
364
+
365
+ - The drawn line and the follower share **one** timeline under one ScrollTrigger, both at position 0 with the same duration, so the tip of the line and the follower cannot drift apart.
366
+ - `path` and `align` get the element queried from `root`, never a selector string: plugin strings resolve against the whole document (§7).
367
+ - `align` needs the path's real geometry: the SVG must scale uniformly (no `preserveAspectRatio="none"`). The pixel `viewBox` from §3 qualifies; rebuild the timeline on resize the same way (`dependencies` plus `revertOnUpdate`).
368
+ - `autoRotate` on something with a front (an arrow, a plane). A dot does not need it, and rotating a circle costs a matrix per frame for nothing.
369
+ - Reduced motion: the follower sits at its end state (or is not rendered); the line is drawn statically.
370
+
371
+ ## 10. Performance
372
+
373
+ - **DrawSVG is cheap.** It is a dash offset; ten drawn paths on a page cost nothing measurable. The cost is paint area: a full-viewport path repaints its bounding box each frame, so keep strokes thin and avoid filters (`drop-shadow`, blur) on any animating SVG.
374
+ - **MorphSVG runs on the main thread.** It rebuilds the `d` string every frame, proportional to point count. Simplify paths before morphing (hundreds of points, not thousands), and never morph more than a few shapes at once.
375
+ - **Inline SVG only.** An `<img src="logo.svg">` cannot be drawn or morphed. Inline the few SVGs that animate; leave the rest as images.
376
+ - **Move the `<svg>`, not the path,** when the whole graphic travels: transform the container (compositor) and keep path work for shape changes.
377
+ - **One ScrollTrigger per graphic**, not per stroke: a timeline under one trigger, as in §4.
378
+
379
+ ## Tells
380
+
381
+ | Tell | Replace with |
382
+ |---|---|
383
+ | A squiggle or blob morphing on an infinite loop | Tied to scroll, hover or a state change |
384
+ | Drawn line that finishes before the reader arrives (time-based at a threshold) | `scrub` with `ease: "none"` |
385
+ | Outlined font text "drawing" its own outline | Single-stroke centreline, or a centreline mask over the fill |
386
+ | A round-cap dot visible before the draw starts | Hidden until the draw begins |
387
+ | A drawn path with `preserveAspectRatio="none"` and `non-scaling-stroke` that stops short | A pixel `viewBox` rebuilt on resize (§3) |
388
+ | A circle drawing itself as a "loader" | The real mark, or a mask wipe |
389
+ | Morph that folds through itself | `shapeIndex`, `type: "rotational"`, matched subpaths |
390
+ | A different SVG flourish in every section | One line, one mark: SVG motion is a signature, not a texture |