@struq/skill 0.5.2 → 0.5.3
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 +27 -27
- package/package.json +2 -2
- package/skills/struq/SKILL.md +168 -165
- package/skills/struq/blocks/CATALOG.md +49 -46
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +2 -1
- package/skills/struq/blocks/backgrounds/background-facets.tsx +5 -3
- package/skills/struq/blocks/backgrounds/background-silk.tsx +6 -2
- package/skills/struq/blocks/catalog.json +50 -5
- package/skills/struq/blocks/film.json +11 -7
- package/skills/struq/blocks/footers/footer-contact.tsx +8 -78
- package/skills/struq/blocks/headers/header-bar.tsx +144 -29
- package/skills/struq/blocks/headers/menu-curtain.tsx +36 -19
- package/skills/struq/blocks/heroes/hero-poster.tsx +36 -10
- package/skills/struq/blocks/kit/HeroCanvas.tsx +6 -2
- package/skills/struq/blocks/kit/SplitHeadline.tsx +2 -1
- package/skills/struq/blocks/kit/accent.ts +29 -3
- package/skills/struq/blocks/kit/blocks.css +530 -6
- package/skills/struq/blocks/kit/feel.ts +183 -0
- package/skills/struq/blocks/kit/gsap.ts +9 -6
- package/skills/struq/blocks/kit/middle/PlaceHours.tsx +254 -0
- package/skills/struq/blocks/kit/middle/ProcessStrip.tsx +428 -0
- package/skills/struq/blocks/kit/middle/ServicesList.tsx +496 -0
- package/skills/struq/blocks/kit/middle/entrance.ts +98 -0
- package/skills/struq/blocks/kit/middle/hours.ts +154 -0
- package/skills/struq/blocks/kit/middle/index.ts +14 -0
- package/skills/struq/blocks/kit/motion/HoverLink.tsx +33 -0
- package/skills/struq/blocks/kit/motion/ImageCurtain.tsx +79 -0
- package/skills/struq/blocks/kit/motion/MaskWipe.tsx +91 -0
- package/skills/struq/blocks/kit/motion/Pressable.tsx +58 -0
- package/skills/struq/blocks/kit/motion/RevealText.tsx +103 -0
- package/skills/struq/blocks/kit/motion/Rise.tsx +77 -0
- package/skills/struq/blocks/kit/motion/ScrubLine.tsx +108 -0
- package/skills/struq/blocks/kit/motion/index.ts +11 -0
- package/skills/struq/blocks/kit/quiet/Close.tsx +178 -52
- package/skills/struq/blocks/kit/quiet/Statement.tsx +3 -2
- package/skills/struq/blocks/kit/subject/AppScreen.tsx +388 -0
- package/skills/struq/blocks/kit/subject/Document.tsx +742 -0
- package/skills/struq/blocks/kit/subject/Mark.tsx +44 -0
- package/skills/struq/blocks/kit/subject/PatternPaper.tsx +410 -0
- package/skills/struq/blocks/kit/subject/SectionDrawing.tsx +349 -0
- package/skills/struq/blocks/kit/subject/Ticket.tsx +354 -0
- package/skills/struq/blocks/kit/subject/build.ts +66 -0
- package/skills/struq/blocks/kit/subject/draw.ts +196 -0
- package/skills/struq/blocks/kit/subject/drawings.ts +373 -0
- package/skills/struq/blocks/kit/subject/format.ts +122 -0
- package/skills/struq/blocks/kit/subject/index.ts +11 -0
- package/skills/struq/blocks/kit/subject/patterns.ts +204 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +75 -22
- package/skills/struq/blocks/showpieces/hero-opener.tsx +55 -7
- package/skills/struq/blocks/showpieces/image-to-fullbleed.tsx +64 -6
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +56 -3
- package/skills/struq/blocks/showpieces/panel-wipe.tsx +93 -11
- package/skills/struq/blocks/showpieces/wordmark-finale.tsx +38 -5
- package/skills/struq/canon/canon.json +1 -1
- package/skills/struq/references/art-direction.md +51 -0
- package/skills/struq/scripts/blocks.mjs +56 -20
- package/skills/struq/scripts/canon.mjs +36 -7
- package/skills/struq/scripts/check.mjs +417 -11
- package/skills/struq/scripts/draw.mjs +49 -0
- package/skills/struq/scripts/film-check.mjs +28 -4
- package/skills/struq/scripts/gate.mjs +13 -3
- package/skills/struq/scripts/hand.mjs +222 -0
- package/skills/struq/scripts/images.mjs +12 -3
- package/skills/struq/scripts/look.mjs +1184 -0
- package/skills/struq/scripts/stamp.mjs +47 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// components/struq/kit/middle/hours.ts (Struq recipe)
|
|
2
|
+
// Opening hours as data: the rows a page prints and the open marker computed from the given hours and the real local
|
|
3
|
+
// time in a given time zone. Never invented: with no hours there is nothing to print and no marker; with hours but no
|
|
4
|
+
// time zone the hours print and the marker stays off. Lifted from footers/footer-contact (one copy: the footer and
|
|
5
|
+
// kit/middle/PlaceHours both import it).
|
|
6
|
+
//
|
|
7
|
+
// const rows: HoursRow[] = [{ days: [1, 2, 3, 4, 5], from: "7:00", to: "18:00" }, { days: [6], from: "8:00", to: "16:00" }];
|
|
8
|
+
// hoursLines(rows, "en"); // [{ days: "Monday to Friday", time: "7:00 to 18:00" }, ...]
|
|
9
|
+
// openStatus(rows, wallClock(new Date(), "Europe/Amsterdam"), "en"); // { open: true, text: "Open now, until 18:00" }
|
|
10
|
+
// const now = useNow(Boolean(hours && timeZone)); // null on the server and the first paint, then every minute
|
|
11
|
+
//
|
|
12
|
+
// Exports
|
|
13
|
+
// - HoursRow: { days, from, to }. days are weekdays 1 (Monday) to 7 (Sunday); from and to are "9:00" style times.
|
|
14
|
+
// - HoursLang: "en" (default) or "nl". The words only; the times print as given.
|
|
15
|
+
// - toMinutes(time): minutes since midnight. daysText(days, lang): "Monday to Friday", "Monday, Wednesday and Friday".
|
|
16
|
+
// - hoursText(row, lang): one row as one line, "Monday to Friday, 9:00 to 18:00".
|
|
17
|
+
// - hoursLines(rows, lang): each row as { days, time } for a timetable.
|
|
18
|
+
// - wallClock(now, timeZone): the weekday and time on the wall in that zone ({ dow, h, m, hh, mm }).
|
|
19
|
+
// - openStatus(rows, wall, lang): { open, text } ("Open now, until 18:00", "Today from 9:00", "Tomorrow from 9:00",
|
|
20
|
+
// "Saturday from 8:00"); text is "" when no row has a day.
|
|
21
|
+
// - useNow(on): the current Date after mount, ticking on the minute; null when off, on the server and on first paint,
|
|
22
|
+
// so nothing time-dependent is ever rendered on the server.
|
|
23
|
+
import { useEffect, useState } from "react";
|
|
24
|
+
|
|
25
|
+
export type HoursRow = { days: number[]; from: string; to: string };
|
|
26
|
+
export type HoursLang = "en" | "nl";
|
|
27
|
+
export type Wall = { dow: number; h: number; m: number; hh: string; mm: string };
|
|
28
|
+
export type OpenStatus = { open: boolean; text: string };
|
|
29
|
+
|
|
30
|
+
const WORDS: Record<HoursLang, { days: string[]; through: string; and: string; to: string; openUntil: string; today: string; tomorrow: string; from: string }> = {
|
|
31
|
+
en: {
|
|
32
|
+
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
|
33
|
+
through: "to",
|
|
34
|
+
and: "and",
|
|
35
|
+
to: "to",
|
|
36
|
+
openUntil: "Open now, until",
|
|
37
|
+
today: "Today",
|
|
38
|
+
tomorrow: "Tomorrow",
|
|
39
|
+
from: "from",
|
|
40
|
+
},
|
|
41
|
+
nl: {
|
|
42
|
+
days: ["maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag", "zondag"],
|
|
43
|
+
through: "tot en met",
|
|
44
|
+
and: "en",
|
|
45
|
+
to: "tot",
|
|
46
|
+
openUntil: "Nu open, tot",
|
|
47
|
+
today: "Vandaag",
|
|
48
|
+
tomorrow: "Morgen",
|
|
49
|
+
from: "vanaf",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const WEEKDAY: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
|
54
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
55
|
+
|
|
56
|
+
/** Minutes since midnight for "9:00" or "18:30". */
|
|
57
|
+
export function toMinutes(time: string): number {
|
|
58
|
+
const [h, m] = time.split(":").map(Number);
|
|
59
|
+
return (h || 0) * 60 + (m || 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The days as words: a run of three or more as "Monday to Friday", other days listed ("Saturday and Sunday"). */
|
|
63
|
+
export function daysText(days: number[], lang: HoursLang = "en"): string {
|
|
64
|
+
const w = WORDS[lang];
|
|
65
|
+
const sorted = [...new Set(days)].filter((d) => d >= 1 && d <= 7).sort((a, b) => a - b);
|
|
66
|
+
if (!sorted.length) return "";
|
|
67
|
+
const runs: number[][] = [];
|
|
68
|
+
for (const d of sorted) {
|
|
69
|
+
const run = runs[runs.length - 1];
|
|
70
|
+
if (run && d === run[run.length - 1] + 1) run.push(d);
|
|
71
|
+
else runs.push([d]);
|
|
72
|
+
}
|
|
73
|
+
const parts = runs.flatMap((run) =>
|
|
74
|
+
run.length >= 3
|
|
75
|
+
?[`${w.days[run[0] - 1]} ${w.through} ${w.days[run[run.length - 1] - 1]}`]
|
|
76
|
+
: run.map((d) => w.days[d - 1]),
|
|
77
|
+
);
|
|
78
|
+
const text = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} ${w.and} ${parts[parts.length - 1]}`;
|
|
79
|
+
return cap(text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One row as one line: "Monday to Friday, 9:00 to 18:00". */
|
|
83
|
+
export function hoursText(row: HoursRow, lang: HoursLang = "en"): string {
|
|
84
|
+
const days = daysText(row.days, lang);
|
|
85
|
+
if (!days) return "";
|
|
86
|
+
return `${days}, ${row.from} ${WORDS[lang].to} ${row.to}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Each row as its days and its time, for a timetable. Rows without a valid day are dropped. */
|
|
90
|
+
export function hoursLines(rows: readonly HoursRow[], lang: HoursLang = "en"): { days: string; time: string }[] {
|
|
91
|
+
return rows
|
|
92
|
+
.map((row) => ({ days: daysText(row.days, lang), time: `${row.from} ${WORDS[lang].to} ${row.to}` }))
|
|
93
|
+
.filter((line) => line.days !== "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The weekday (1 Monday to 7 Sunday) and the time on the wall in `timeZone` at `now`. */
|
|
97
|
+
export function wallClock(now: Date, timeZone: string): Wall {
|
|
98
|
+
const parts = new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
|
|
99
|
+
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
100
|
+
const h = Number(get("hour")) % 24;
|
|
101
|
+
const m = Number(get("minute"));
|
|
102
|
+
return { dow: WEEKDAY[get("weekday")] ?? 1, h, m, hh: String(h).padStart(2, "0"), mm: String(m).padStart(2, "0") };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether the place is open on the wall clock, from the given rows only, and the words for it. */
|
|
106
|
+
export function openStatus(rows: readonly HoursRow[], wall: Wall, lang: HoursLang = "en"): OpenStatus {
|
|
107
|
+
const w = WORDS[lang];
|
|
108
|
+
const mins = wall.h * 60 + wall.m;
|
|
109
|
+
const on = (d: number) => rows.filter((row) => row.days.includes(d));
|
|
110
|
+
const today = on(wall.dow);
|
|
111
|
+
const now = today.filter((row) => mins >= toMinutes(row.from) && mins < toMinutes(row.to));
|
|
112
|
+
if (now.length) {
|
|
113
|
+
const until = now.reduce((a, b) => (toMinutes(b.to) > toMinutes(a.to) ? b : a));
|
|
114
|
+
return { open: true, text: `${w.openUntil} ${until.to}` };
|
|
115
|
+
}
|
|
116
|
+
const earliest = (list: HoursRow[]) => list.reduce((a, b) => (toMinutes(b.from) < toMinutes(a.from) ? b : a));
|
|
117
|
+
const later = today.filter((row) => mins < toMinutes(row.from));
|
|
118
|
+
if (later.length) return { open: false, text: `${w.today} ${w.from} ${earliest(later).from}` };
|
|
119
|
+
for (let k = 1; k <= 7; k += 1) {
|
|
120
|
+
const d = ((wall.dow - 1 + k) % 7) + 1;
|
|
121
|
+
const next = on(d);
|
|
122
|
+
if (next.length) {
|
|
123
|
+
const from = earliest(next).from;
|
|
124
|
+
return { open: false, text: k === 1 ? `${w.tomorrow} ${w.from} ${from}` : `${cap(w.days[d - 1])} ${w.from} ${from}` };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { open: false, text: "" };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The time now, only when `on` and only after mount (null on the server and the first paint), ticking on the minute. */
|
|
131
|
+
export function useNow(on: boolean): Date | null {
|
|
132
|
+
const [now, setNow] = useState<Date | null>(null);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!on) return;
|
|
135
|
+
let timer = 0;
|
|
136
|
+
const tick = () => {
|
|
137
|
+
const d = new Date();
|
|
138
|
+
setNow(d);
|
|
139
|
+
window.clearTimeout(timer);
|
|
140
|
+
timer = window.setTimeout(tick, 60_000 - (d.getSeconds() * 1000 + d.getMilliseconds()) + 30);
|
|
141
|
+
};
|
|
142
|
+
tick();
|
|
143
|
+
// A background tab's timers are held back; on return the clock catches up at once.
|
|
144
|
+
const onShow = () => {
|
|
145
|
+
if (document.visibilityState === "visible") tick();
|
|
146
|
+
};
|
|
147
|
+
document.addEventListener("visibilitychange", onShow);
|
|
148
|
+
return () => {
|
|
149
|
+
window.clearTimeout(timer);
|
|
150
|
+
document.removeEventListener("visibilitychange", onShow);
|
|
151
|
+
};
|
|
152
|
+
}, [on]);
|
|
153
|
+
return on ? now : null;
|
|
154
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// components/struq/kit/middle/index.ts (Struq recipe)
|
|
2
|
+
// The middle scenes: designed middles of a page, each with two or three variants extracted from the blocks, filled
|
|
3
|
+
// through typed slots. Pick a scene and a variant and fill it with the client's own words; never compose a middle from
|
|
4
|
+
// raw GSAP and a grid. Each renders its own data-scene root, takes a tone (ground, inverse, field), orders its parts in
|
|
5
|
+
// one timeline from one trigger and is timed by the site's temperament (../feel). Optional facts (hours, phone,
|
|
6
|
+
// address) have no defaults and render nothing when absent; a variant missing what it needs falls back in code.
|
|
7
|
+
export { ProcessStrip, resolveProcessVariant } from "./ProcessStrip";
|
|
8
|
+
export type { ProcessVariant, Step } from "./ProcessStrip";
|
|
9
|
+
export { PlaceHours, resolvePlaceVariant } from "./PlaceHours";
|
|
10
|
+
export type { PlaceDoor, PlaceVariant } from "./PlaceHours";
|
|
11
|
+
export { ServicesList, resolveServicesVariant, TONE_CLASS } from "./ServicesList";
|
|
12
|
+
export type { SceneTone, Service, ServicesVariant } from "./ServicesList";
|
|
13
|
+
export { daysText, hoursLines, hoursText, openStatus, wallClock } from "./hours";
|
|
14
|
+
export type { HoursLang, HoursRow } from "./hours";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// components/struq/kit/motion/HoverLink.tsx (Struq recipe)
|
|
2
|
+
// A text link with the site's one hover style. Pick the style once per site and use it for every text link on it
|
|
3
|
+
// (nav, footer, inline, "view the work"). CSS only, so it works before hydration and in a server component. Extracted
|
|
4
|
+
// from the live site's .link (one rule, one origin, reverses from wherever it is) and the kit's footer rule draw.
|
|
5
|
+
//
|
|
6
|
+
// <HoverLink href="/work" look="draw">See the work</HoverLink>
|
|
7
|
+
//
|
|
8
|
+
// Props
|
|
9
|
+
// - href, children, and any other <a> attribute (target, rel, aria-current, onClick).
|
|
10
|
+
// - look: "draw" (default: a rule draws in from the left under the text and draws back the same way), "slide" (the
|
|
11
|
+
// resting rule slides out to the right and a new one slides in from the left) or "color" (the text turns to the
|
|
12
|
+
// accent, no rule).
|
|
13
|
+
// - className: on the <a>. The link inherits its type and colour; set them on it or its parent.
|
|
14
|
+
//
|
|
15
|
+
// Motion: the rule moves over the feel's --dur-hover-line on --ease-enter, the colour over --dur-hover-color, read from
|
|
16
|
+
// [data-temperament] (blocks.css, "--- motion ---"). Keyboard focus shows the same state as hover, plus the locked
|
|
17
|
+
// focus ring. Reduced motion: the same states, cut in without movement.
|
|
18
|
+
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
|
19
|
+
|
|
20
|
+
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & {
|
|
21
|
+
href: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
look?: "draw" | "slide" | "color";
|
|
24
|
+
className?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function HoverLink({ href, children, look = "draw", className = "", ...rest }: Props) {
|
|
28
|
+
return (
|
|
29
|
+
<a {...rest} href={href} data-hover-link={look} className={className}>
|
|
30
|
+
{children}
|
|
31
|
+
</a>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/kit/motion/ImageCurtain.tsx (Struq recipe)
|
|
3
|
+
// A panel in the page's ground colour covers a photo and lifts off it as it enters, while the photo settles. Use it
|
|
4
|
+
// for a photo that should be uncovered, as if the page itself drew back; MaskWipe is the plainer cut. Extracted from
|
|
5
|
+
// showpieces/panel-wipe (ground-colour panels travelling off on the structural ease, the photo settling under them),
|
|
6
|
+
// made a single panel on enter.
|
|
7
|
+
//
|
|
8
|
+
// <ImageCurtain className="aspect-[3/2]"><img src="/rooms/table.jpg" alt="The long table by the window" className="h-full w-full object-cover" /></ImageCurtain>
|
|
9
|
+
//
|
|
10
|
+
// Props
|
|
11
|
+
// - children: the photo (an img, a next/image with fill, or kit/MediaFrame with reveal="none"). Give the wrapper its
|
|
12
|
+
// size (className) when the photo fills it.
|
|
13
|
+
// - lift: where the panel goes: "up" (default), "down", "left" or "right".
|
|
14
|
+
// - on: "scroll" (default, once at the feel's trigger) or "load" (on mount, for a hero).
|
|
15
|
+
// - delay: seconds before it starts. className: on the wrapper. The panel takes bg-surface-0, the scene's ground,
|
|
16
|
+
// so it reads correctly inside scene-inverse and scene-field too.
|
|
17
|
+
//
|
|
18
|
+
// Motion: the panel travels off over dur("curtain", size) on the feel's curtain ease; the photo settles from
|
|
19
|
+
// feel().travel.media scale to 1 over dur("settle", size) on the enter ease, both from the same moment. Reduced
|
|
20
|
+
// motion, no scripts or the server render: the photo uncovered and at rest. No layout shift: only transforms move.
|
|
21
|
+
import { useRef, type ReactNode } from "react";
|
|
22
|
+
import { feel, LOCKED, prefersReduced } from "../feel";
|
|
23
|
+
import { gsap, useGSAP } from "../gsap";
|
|
24
|
+
|
|
25
|
+
type Lift = "up" | "down" | "left" | "right";
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
lift?: Lift;
|
|
30
|
+
on?: "scroll" | "load";
|
|
31
|
+
delay?: number;
|
|
32
|
+
className?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const AWAY: Record<Lift, gsap.TweenVars> = {
|
|
36
|
+
up: { yPercent: -100 },
|
|
37
|
+
down: { yPercent: 100 },
|
|
38
|
+
left: { xPercent: -100 },
|
|
39
|
+
right: { xPercent: 100 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function ImageCurtain({ children, lift = "up", on = "scroll", delay = 0, className = "" }: Props) {
|
|
43
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
44
|
+
|
|
45
|
+
useGSAP(
|
|
46
|
+
() => {
|
|
47
|
+
const el = ref.current;
|
|
48
|
+
const panel = el?.querySelector<HTMLElement>("[data-curtain-panel]");
|
|
49
|
+
const photo = el?.querySelector<HTMLElement>("[data-curtain-photo]");
|
|
50
|
+
if (!el || !panel || !photo) return;
|
|
51
|
+
if (prefersReduced()) {
|
|
52
|
+
el.dataset.ready = "1";
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const f = feel(el);
|
|
56
|
+
const size = lift === "up" || lift === "down" ? el.offsetHeight : el.offsetWidth;
|
|
57
|
+
// The panel stands in place until the trigger; CSS holds it there before hydration. x and y are zeroed too:
|
|
58
|
+
// GSAP reads the resting CSS transform (translate -100%) into x or y in px, which would keep it off the photo.
|
|
59
|
+
gsap.set(panel, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
|
|
60
|
+
const tl = gsap.timeline({
|
|
61
|
+
delay,
|
|
62
|
+
scrollTrigger: on === "scroll" ? { trigger: el, start: LOCKED.trigger.start, once: LOCKED.trigger.once } : undefined,
|
|
63
|
+
});
|
|
64
|
+
tl.to(panel, { ...AWAY[lift], duration: f.dur("curtain", size), ease: f.ease.curtain }, 0);
|
|
65
|
+
tl.fromTo(photo, { scale: f.travel.media }, { scale: 1, duration: f.dur("settle", size), ease: f.ease.enter }, 0);
|
|
66
|
+
el.dataset.ready = "1";
|
|
67
|
+
},
|
|
68
|
+
{ scope: ref, dependencies: [lift, on, delay] },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div ref={ref} data-curtain={lift} className={`relative overflow-hidden ${className}`}>
|
|
73
|
+
<div data-curtain-photo="" className="absolute inset-0">
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
<span aria-hidden data-curtain-panel="" className="absolute inset-0 bg-surface-0" />
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/kit/motion/MaskWipe.tsx (Struq recipe)
|
|
3
|
+
// Wipes an element or a photo in from one edge with a clip-path, on enter or with the scroll. Use it for media,
|
|
4
|
+
// panels and blocks of colour that should open rather than fade. Extracted from kit/MediaFrame's curtain (inset clip
|
|
5
|
+
// from one edge, the photo settling inside it) with the edge made a prop.
|
|
6
|
+
//
|
|
7
|
+
// <MaskWipe from="left" settle className="aspect-[4/5]"><img src="/work/vase.jpg" alt="A celadon vase" className="h-full w-full object-cover" /></MaskWipe>
|
|
8
|
+
//
|
|
9
|
+
// Props
|
|
10
|
+
// - children: what is wiped in. Give the wrapper its size (className) when the child is absolutely placed.
|
|
11
|
+
// - from: the edge it opens from: "bottom" (default), "top", "left" or "right".
|
|
12
|
+
// - on: "scroll" (default, once at the feel's trigger), "load" (on mount) or "scrub" (opens with the scroll, closes
|
|
13
|
+
// going back).
|
|
14
|
+
// - settle: the first child also settles from feel().travel.media scale to 1 (for a photo). Default false.
|
|
15
|
+
// - delay: seconds before it starts (load and scroll only). as: "div" (default) or "figure". className: on the wrapper.
|
|
16
|
+
//
|
|
17
|
+
// Motion: the clip opens on the feel's curtain ease over dur("curtain", height), so a tall photo opens slower than a
|
|
18
|
+
// strip; settle runs over dur("settle", height) on the enter ease; scrubbed both run linear with scrub.flow. Reduced
|
|
19
|
+
// motion, no scripts or the server render: fully open, at rest. No layout shift: only clip-path and transform move.
|
|
20
|
+
import { useRef, type ReactNode } from "react";
|
|
21
|
+
import { feel, LOCKED, prefersReduced } from "../feel";
|
|
22
|
+
import { gsap, useGSAP } from "../gsap";
|
|
23
|
+
|
|
24
|
+
type Edge = "bottom" | "top" | "left" | "right";
|
|
25
|
+
|
|
26
|
+
type Props = {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
from?: Edge;
|
|
29
|
+
on?: "scroll" | "load" | "scrub";
|
|
30
|
+
settle?: boolean;
|
|
31
|
+
delay?: number;
|
|
32
|
+
as?: "div" | "figure";
|
|
33
|
+
className?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The closed clip for each edge: everything inset from the opposite side. */
|
|
37
|
+
export const CLOSED: Record<Edge, string> = {
|
|
38
|
+
bottom: "inset(100% 0% 0% 0%)",
|
|
39
|
+
top: "inset(0% 0% 100% 0%)",
|
|
40
|
+
left: "inset(0% 100% 0% 0%)",
|
|
41
|
+
right: "inset(0% 0% 0% 100%)",
|
|
42
|
+
};
|
|
43
|
+
const OPEN = "inset(0% 0% 0% 0%)";
|
|
44
|
+
|
|
45
|
+
export function MaskWipe({ children, from = "bottom", on = "scroll", settle = false, delay = 0, as = "div", className = "" }: Props) {
|
|
46
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
47
|
+
|
|
48
|
+
useGSAP(
|
|
49
|
+
() => {
|
|
50
|
+
const el = ref.current;
|
|
51
|
+
if (!el) return;
|
|
52
|
+
if (prefersReduced()) {
|
|
53
|
+
el.dataset.ready = "1";
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const f = feel(el);
|
|
57
|
+
const size = el.offsetHeight;
|
|
58
|
+
const scrub = on === "scrub";
|
|
59
|
+
const scrollTrigger = scrub
|
|
60
|
+
? { trigger: el, start: "top 95%", end: "top 40%", scrub: f.scrub.flow }
|
|
61
|
+
: on === "scroll"
|
|
62
|
+
? { trigger: el, start: LOCKED.trigger.start, once: LOCKED.trigger.once }
|
|
63
|
+
: undefined;
|
|
64
|
+
const tl = gsap.timeline({ delay: scrub ? 0 : delay, scrollTrigger });
|
|
65
|
+
tl.fromTo(
|
|
66
|
+
el,
|
|
67
|
+
{ clipPath: CLOSED[from] },
|
|
68
|
+
{ clipPath: OPEN, duration: f.dur("curtain", size), ease: scrub ? f.ease.scrub : f.ease.curtain },
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
const media = el.firstElementChild;
|
|
72
|
+
if (settle && media) {
|
|
73
|
+
tl.fromTo(
|
|
74
|
+
media,
|
|
75
|
+
{ scale: f.travel.media },
|
|
76
|
+
{ scale: 1, duration: f.dur("settle", size), ease: scrub ? f.ease.scrub : f.ease.enter },
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
el.dataset.ready = "1";
|
|
81
|
+
},
|
|
82
|
+
{ scope: ref, dependencies: [from, on, settle, delay] },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const Tag = as as "div";
|
|
86
|
+
return (
|
|
87
|
+
<Tag ref={ref} data-wipe={from} className={`relative overflow-hidden ${className}`}>
|
|
88
|
+
{children}
|
|
89
|
+
</Tag>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// components/struq/kit/motion/Pressable.tsx (Struq recipe)
|
|
2
|
+
// A button or link that answers the hand: it lifts a little under the pointer and gives way when pressed. Pick the
|
|
3
|
+
// press style once per site. CSS only, so it works before hydration and in a server component. Extracted from the
|
|
4
|
+
// live site's .btn press (a 1px sink in about 0.12s, settling back on the arrival ease) and the kit MagneticButton's
|
|
5
|
+
// rising ink fill, here on the press.
|
|
6
|
+
//
|
|
7
|
+
// <Pressable href="#book" press="sink" className="bg-accent px-8 py-4 text-small font-medium text-accent-ink">Book a table</Pressable>
|
|
8
|
+
//
|
|
9
|
+
// Props
|
|
10
|
+
// - href: renders an <a>; without it a <button type="button"> (pass type="submit" for a form). onClick, disabled,
|
|
11
|
+
// aria-* and the rest pass through.
|
|
12
|
+
// - press: "sink" (default: it moves down by the feel's sink) or "fill" (an ink fill floods up from the bottom edge
|
|
13
|
+
// and the label turns to the ground colour). Set --press-fill on it to fill with another colour.
|
|
14
|
+
// - lift: the hover lift by the feel's --press-lift (default true). Off for buttons in a dense row.
|
|
15
|
+
// - className: the look (ground, ink, padding, type). Pressable adds only the behaviour.
|
|
16
|
+
//
|
|
17
|
+
// Motion: the press goes in over --dur-press-in and settles back over --dur-press-out on --ease-enter; the lift and
|
|
18
|
+
// the hover share the settle. Keyboard focus lifts as hover does and keeps the locked focus ring. Reduced motion: no
|
|
19
|
+
// lift and no sink; the fill still shows, cut in.
|
|
20
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
21
|
+
|
|
22
|
+
type Common = {
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
press?: "sink" | "fill";
|
|
25
|
+
lift?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Props = Common &
|
|
30
|
+
(
|
|
31
|
+
| ({ href: string } & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children" | "className">)
|
|
32
|
+
| ({ href?: undefined } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "className">)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export function Pressable(props: Props) {
|
|
36
|
+
const { children, press = "sink", lift = true, className = "", ...rest } = props;
|
|
37
|
+
const data = { "data-pressable": press, "data-lift": lift ? "" : undefined };
|
|
38
|
+
const inner = (
|
|
39
|
+
<>
|
|
40
|
+
{press === "fill" ? <span aria-hidden data-press-fill="" /> : null}
|
|
41
|
+
<span data-press-label="">{children}</span>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
if ("href" in rest && typeof rest.href === "string") {
|
|
45
|
+
const anchor = rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
46
|
+
return (
|
|
47
|
+
<a {...anchor} {...data} className={`relative inline-flex items-center justify-center ${className}`}>
|
|
48
|
+
{inner}
|
|
49
|
+
</a>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const button = rest as ButtonHTMLAttributes<HTMLButtonElement>;
|
|
53
|
+
return (
|
|
54
|
+
<button type="button" {...button} {...data} className={`relative inline-flex items-center justify-center ${className}`}>
|
|
55
|
+
{inner}
|
|
56
|
+
</button>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/kit/motion/RevealText.tsx (Struq recipe)
|
|
3
|
+
// Text that rises out of a mask, line by line or word by word. Use it for every headline or statement that should
|
|
4
|
+
// arrive; never hand-roll a SplitText reveal. Extracted from kit/SplitHeadline (lines: SplitText, autoSplit re-cuts on
|
|
5
|
+
// resize and font load) and the live site's word reveal (words set in their own masks at render, no layout cost).
|
|
6
|
+
//
|
|
7
|
+
// <RevealText as="h2" by="lines" className="font-display text-h2 max-w-[18ch]">Grown slowly, poured by hand.</RevealText>
|
|
8
|
+
//
|
|
9
|
+
// Props
|
|
10
|
+
// - children: plain text (lines may carry an <em> accent; words take a string only).
|
|
11
|
+
// - as: "h1" | "h2" | "h3" | "p" | "span" | "div" (default "h2").
|
|
12
|
+
// - by: "lines" (default) or "words".
|
|
13
|
+
// - on: "scroll" (default, once at the feel's trigger), "load" (on mount, for a hero) or "scrub" (tied to the scroll
|
|
14
|
+
// while the text crosses the lower half of the screen, reverses going back).
|
|
15
|
+
// - delay: seconds before it starts (load and scroll only). className: on the element.
|
|
16
|
+
//
|
|
17
|
+
// Motion: each line or word starts feel().travel.text percent below its mask and rises on the feel's enter ease, over
|
|
18
|
+
// duration.line, staggered by stagger.line or stagger.word; scrubbed it runs linear with scrub.flow smoothing. The
|
|
19
|
+
// masks use the shared .struq-line-mask room, so g, j, p and y are never clipped and the layout never moves. Reduced
|
|
20
|
+
// motion, no scripts or the server render: the finished text.
|
|
21
|
+
import { useRef, type ReactNode } from "react";
|
|
22
|
+
import { feel, LOCKED, prefersReduced } from "../feel";
|
|
23
|
+
import { gsap, SplitText, useGSAP } from "../gsap";
|
|
24
|
+
|
|
25
|
+
type Props = {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
as?: "h1" | "h2" | "h3" | "p" | "span" | "div";
|
|
28
|
+
by?: "lines" | "words";
|
|
29
|
+
on?: "scroll" | "load" | "scrub";
|
|
30
|
+
delay?: number;
|
|
31
|
+
className?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The words of a string, each in its own shared mask, with the spaces kept as text so the line breaks as before. */
|
|
35
|
+
function wordsOf(text: string) {
|
|
36
|
+
const parts = text.split(/(\s+)/).filter((part) => part !== "");
|
|
37
|
+
return parts.map((part, i) =>
|
|
38
|
+
/^\s+$/.test(part) ? (
|
|
39
|
+
part
|
|
40
|
+
) : (
|
|
41
|
+
<span key={i} className="struq-line-mask" data-word="">
|
|
42
|
+
<span data-word-rise="">{part}</span>
|
|
43
|
+
</span>
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function RevealText({ children, as = "h2", by = "lines", on = "scroll", delay = 0, className = "" }: Props) {
|
|
49
|
+
const ref = useRef<HTMLElement>(null);
|
|
50
|
+
|
|
51
|
+
useGSAP(
|
|
52
|
+
() => {
|
|
53
|
+
const el = ref.current;
|
|
54
|
+
if (!el) return;
|
|
55
|
+
if (prefersReduced()) {
|
|
56
|
+
el.dataset.ready = "1";
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const f = feel(el);
|
|
60
|
+
const trigger =
|
|
61
|
+
on === "scrub"
|
|
62
|
+
? { trigger: el, start: "top 90%", end: "top 50%", scrub: f.scrub.flow }
|
|
63
|
+
: on === "scroll"
|
|
64
|
+
? { trigger: el, start: LOCKED.trigger.start, once: LOCKED.trigger.once }
|
|
65
|
+
: undefined;
|
|
66
|
+
const rise = (targets: Element[], stagger: number) =>
|
|
67
|
+
gsap.from(targets, {
|
|
68
|
+
yPercent: f.travel.text,
|
|
69
|
+
duration: f.duration.line,
|
|
70
|
+
ease: on === "scrub" ? f.ease.scrub : f.ease.enter,
|
|
71
|
+
stagger,
|
|
72
|
+
delay: on === "scrub" ? 0 : delay,
|
|
73
|
+
scrollTrigger: trigger,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (by === "words") {
|
|
77
|
+
rise(Array.from(el.querySelectorAll("[data-word-rise]")), f.stagger.word);
|
|
78
|
+
el.dataset.ready = "1";
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const split = SplitText.create(el, {
|
|
82
|
+
type: "lines",
|
|
83
|
+
mask: "lines",
|
|
84
|
+
linesClass: "struq-line",
|
|
85
|
+
autoSplit: true,
|
|
86
|
+
onSplit(self) {
|
|
87
|
+
return rise(self.lines, f.stagger.line);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
el.dataset.ready = "1";
|
|
91
|
+
return () => split.revert();
|
|
92
|
+
},
|
|
93
|
+
{ scope: ref, dependencies: [by, on, delay] },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const Tag = as as "h2";
|
|
97
|
+
const content = by === "words" && typeof children === "string" ? wordsOf(children) : children;
|
|
98
|
+
return (
|
|
99
|
+
<Tag ref={ref as React.RefObject<HTMLHeadingElement>} data-reveal-text={by} className={className}>
|
|
100
|
+
{content}
|
|
101
|
+
</Tag>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// components/struq/kit/motion/Rise.tsx (Struq recipe)
|
|
3
|
+
// A block that rises a little and comes up from clear as it enters: a paragraph, a list of rows, a caption, a row of
|
|
4
|
+
// doors. Use it for everything that arrives after a heading; RevealText is for the heading itself. Extracted from the
|
|
5
|
+
// kit's Reveal and the soft rise the sections share (a short travel in px with the opacity), put on the feel.
|
|
6
|
+
//
|
|
7
|
+
// <Rise as="ul" each className="grid gap-4">{rows.map((row) => <li key={row.term}>{row.text}</li>)}</Rise>
|
|
8
|
+
//
|
|
9
|
+
// Props
|
|
10
|
+
// - children: what rises.
|
|
11
|
+
// - as: "div" (default), "p", "ul", "ol", "figure", "figcaption" or "span".
|
|
12
|
+
// - each: the direct children rise one after another, staggered by the feel's stagger.block (default false: the
|
|
13
|
+
// element rises whole).
|
|
14
|
+
// - on: "scroll" (default, once at the feel's trigger) or "load" (on mount).
|
|
15
|
+
// - delay: seconds before it starts. className: on the element.
|
|
16
|
+
//
|
|
17
|
+
// riseFrom(feel) and riseTo(feel) return the same start and end state, so a scene that orders its own parts in one
|
|
18
|
+
// timeline rises its details exactly as Rise does.
|
|
19
|
+
//
|
|
20
|
+
// Motion: from feel().travel.block px below and clear, to rest, over duration.enter on the feel's enter ease; with
|
|
21
|
+
// each, stagger.block between children. Crisp travels 24px quickly, weighted 48px, slow 72px over a long settle.
|
|
22
|
+
// Reduced motion, no scripts or the server render: the block at rest. No layout shift: only transform and opacity.
|
|
23
|
+
import { useRef, type ReactNode } from "react";
|
|
24
|
+
import { feel, LOCKED, prefersReduced, type Feel } from "../feel";
|
|
25
|
+
import { gsap, useGSAP } from "../gsap";
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
as?: "div" | "p" | "ul" | "ol" | "figure" | "figcaption" | "span";
|
|
30
|
+
each?: boolean;
|
|
31
|
+
on?: "scroll" | "load";
|
|
32
|
+
delay?: number;
|
|
33
|
+
className?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The start of a rise in this feel: a block's travel below, clear. */
|
|
37
|
+
export function riseFrom(f: Feel): gsap.TweenVars {
|
|
38
|
+
return { y: f.travel.block, autoAlpha: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The end of a rise in this feel: at rest, over duration.enter on the enter ease. */
|
|
42
|
+
export function riseTo(f: Feel): gsap.TweenVars {
|
|
43
|
+
return { y: 0, autoAlpha: 1, duration: f.duration.enter, ease: f.ease.enter };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Rise({ children, as = "div", each = false, on = "scroll", delay = 0, className = "" }: Props) {
|
|
47
|
+
const ref = useRef<HTMLElement>(null);
|
|
48
|
+
|
|
49
|
+
useGSAP(
|
|
50
|
+
() => {
|
|
51
|
+
const el = ref.current;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
if (prefersReduced()) {
|
|
54
|
+
el.dataset.ready = "1";
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const f = feel(el);
|
|
58
|
+
const targets = each ? Array.from(el.children) : [el];
|
|
59
|
+
// fromTo, so the pre-paint gate's opacity is never read as the end state.
|
|
60
|
+
gsap.fromTo(targets, riseFrom(f), {
|
|
61
|
+
...riseTo(f),
|
|
62
|
+
stagger: each ? f.stagger.block : 0,
|
|
63
|
+
delay,
|
|
64
|
+
scrollTrigger: on === "scroll" ? { trigger: el, start: LOCKED.trigger.start, once: LOCKED.trigger.once } : undefined,
|
|
65
|
+
});
|
|
66
|
+
el.dataset.ready = "1";
|
|
67
|
+
},
|
|
68
|
+
{ scope: ref, dependencies: [each, on, delay] },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const Tag = as as "div";
|
|
72
|
+
return (
|
|
73
|
+
<Tag ref={ref as React.RefObject<HTMLDivElement>} data-rise={each ? "each" : ""} className={className}>
|
|
74
|
+
{children}
|
|
75
|
+
</Tag>
|
|
76
|
+
);
|
|
77
|
+
}
|