@struq/skill 0.3.0 → 0.4.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.
- package/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// preloader-wordmark (Struq preloader recipe)
|
|
3
|
+
// The first paint is the page's own ground with the studio's name set huge across the screen in the display face, at
|
|
4
|
+
// a light weight, measured to fit the width with the loaded face. While the page loads, each letter fills: a faint
|
|
5
|
+
// tone of the name first, then the full ink rising inside every letter, a little later in each letter than the one
|
|
6
|
+
// before. The fill is driven by real progress (the display face in, the hero photo decoded) between a floor (it never
|
|
7
|
+
// flashes past) and a cap (it never hangs). Then the name opens onto the page as a curtain, three ways:
|
|
8
|
+
//
|
|
9
|
+
// split the filled name splits along a line through its middle; the top half rises and the bottom half falls,
|
|
10
|
+
// each carrying its half of the cover.
|
|
11
|
+
// drop the letters drop away one by one, left to right, each taking its column of the cover with it, so the
|
|
12
|
+
// page arrives letter by letter.
|
|
13
|
+
// settle the name shrinks and travels into the header's wordmark (the page marks it data-wordmark-target) while
|
|
14
|
+
// the cover lifts, so the preloader's name becomes the site's mark; on landing the page's own wordmark
|
|
15
|
+
// takes over in place.
|
|
16
|
+
//
|
|
17
|
+
// First paint to clear never exceeds 2.4 seconds: the fill takes at least a second, and whatever the page still owes
|
|
18
|
+
// at the cap is filled in quickly so the opening keeps its full length. The page under the cover is already in its
|
|
19
|
+
// rest state, so the opening uncovers the finished page with no seam: every edge the cover is cut along sits on a whole
|
|
20
|
+
// pixel, and the cuts only appear in the frame the opening starts.
|
|
21
|
+
//
|
|
22
|
+
// Mount it once, before the page it covers, and mark the header's wordmark for `settle`:
|
|
23
|
+
// <PreloaderWordmark name="Noord" variant="split" />
|
|
24
|
+
// <header><a href="/" data-wordmark-target>Noord</a></header>
|
|
25
|
+
// blocks.css sets that wordmark in the preloader's own face, weight and tracking, so the hand-over is exact. A hero
|
|
26
|
+
// that wants its entrance in the cover's wake uses the shared signal (preloader-slices-uncovered.ts):
|
|
27
|
+
// useGSAP((_, contextSafe) => whenUncovered(contextSafe!(() => { ...the entrance... })), { scope })
|
|
28
|
+
//
|
|
29
|
+
// The block owns its gate. A tiny script inside the cover runs while the browser parses it, before the first paint:
|
|
30
|
+
// only with no earlier play this session does it arm the cover, close the curtain on <html>, hold the page still
|
|
31
|
+
// (wheel, touch and scroll keys) and start the page at the top. A second tiny script, parsed right after the name,
|
|
32
|
+
// loads the display face, fits the name to the width and only then shows it, so the name is never drawn in a fallback
|
|
33
|
+
// face (and stays unseen if its face never comes). From the first paint the fill runs on CSS alone (one number,
|
|
34
|
+
// --wm-p), which the block's script takes over where it stands when it hydrates. If the bundle never arrives, that CSS
|
|
35
|
+
// fades the cover out from 1.95 seconds and hides it at 2.3, and the hold lets go at 2.4: the hero is never left
|
|
36
|
+
// covered. Without JavaScript the cover never paints. Reduced motion: the filled name, held briefly, then a short fade.
|
|
37
|
+
// `always` plays it on every load (the lab).
|
|
38
|
+
import { useRef, useState, type CSSProperties } from "react";
|
|
39
|
+
import { gsap, useGSAP } from "../kit/gsap";
|
|
40
|
+
import { closeCurtain, covered, liftCurtain, whenUncovered } from "./preloader-slices-uncovered";
|
|
41
|
+
|
|
42
|
+
export { whenUncovered };
|
|
43
|
+
|
|
44
|
+
export const WORDMARK_VARIANTS = [
|
|
45
|
+
{ id: "split", title: "Gespleten" },
|
|
46
|
+
{ id: "drop", title: "Letter voor letter" },
|
|
47
|
+
{ id: "settle", title: "In de kop" },
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export type WordmarkVariant = (typeof WORDMARK_VARIANTS)[number]["id"];
|
|
51
|
+
|
|
52
|
+
const SEEN_KEY = "struq:preloader-wordmark";
|
|
53
|
+
|
|
54
|
+
/** Seconds, measured from the first paint. */
|
|
55
|
+
const BUDGET = 2.4;
|
|
56
|
+
const T = {
|
|
57
|
+
floor: 1.0, // the fill never completes sooner than this
|
|
58
|
+
rush: 0.3, // at the cap, whatever is left of the fill is drawn in this long
|
|
59
|
+
beat: 0.14, // the filled name holds this long before it opens
|
|
60
|
+
follow: 0.16, // the fill follows real progress with this time constant
|
|
61
|
+
pace: 0.8, // and never slower than this (of the whole fill, per second) while it has ground to make up
|
|
62
|
+
late: 1.8, // a script that hydrates later than this leaves the CSS clock to finish (its fade starts at 1.95)
|
|
63
|
+
reduceLate: 1.05, // reduced motion: the same, before the CSS fade that starts at 1.1
|
|
64
|
+
minExit: 0.45, // an opening shorter than this is not worth showing
|
|
65
|
+
reduceHold: 0.7, // reduced motion: the filled name stays at least until this time
|
|
66
|
+
reduceWait: 1.0, // reduced motion: waits for the page at most until this time
|
|
67
|
+
reduceFade: 0.3,
|
|
68
|
+
} as const;
|
|
69
|
+
/** The opening's length per variant. */
|
|
70
|
+
const EXIT: Record<WordmarkVariant, number> = { split: 0.86, drop: 0.94, settle: 1.0 };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Runs while the browser parses the cover, before the first paint. Never throws. Marks the session as played the
|
|
74
|
+
* moment it arms, so every way the cover can end (its own opening, a late script, the CSS failsafe) counts as the one
|
|
75
|
+
* play of the session.
|
|
76
|
+
*/
|
|
77
|
+
const ARM = `(function(){try{var e=document.currentScript.parentElement,w=window;if(!e.hasAttribute("data-always")&&sessionStorage.getItem("${SEEN_KEY}")==="1")return;e.setAttribute("data-t0",String(performance.now()));if(matchMedia("(prefers-reduced-motion: reduce)").matches)e.setAttribute("data-reduce","");e.setAttribute("data-armed","");try{sessionStorage.setItem("${SEEN_KEY}","1")}catch(x){}document.documentElement.setAttribute("data-curtain","closed");if(!location.hash){history.scrollRestoration="manual";scrollTo(0,0)}var keys=" PageDown PageUp ArrowDown ArrowUp Home End".split(" ");keys[0]=" ";var opt={capture:true,passive:false},stop=function(v){if(v.type==="keydown"&&keys.indexOf(v.key)<0)return;v.preventDefault();v.stopImmediatePropagation()},types=["wheel","touchmove","keydown"],off=function(){types.forEach(function(t){w.removeEventListener(t,stop,opt)});w.__preloaderWordmarkHold=null};types.forEach(function(t){w.addEventListener(t,stop,opt)});w.__preloaderWordmarkHold=off;setTimeout(off,${BUDGET * 1000})}catch(x){}})()`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs right after the name is parsed: loads the display face for the name's own letters, fits the name to the width
|
|
81
|
+
* and measures each letter's ink (inkOf below, the same sum) and only then marks the cover data-fit, which shows it.
|
|
82
|
+
* Refits when another face finishes loading. A face that fails to load leaves the name unseen rather than drawn in a
|
|
83
|
+
* fallback.
|
|
84
|
+
*/
|
|
85
|
+
const fitScript = (text: string) =>
|
|
86
|
+
`(function(){try{var r=document.currentScript.closest("[data-preloader]");if(!r||!r.hasAttribute("data-armed"))return;var n=r.querySelector("[data-base] [data-name]"),f=document.fonts;var ink=function(){try{var s=getComputedStyle(n),x=document.createElement("canvas").getContext("2d");if(!x)return;x.font=s.fontStyle+" "+s.fontWeight+" 1000px "+s.fontFamily;var h=x.measureText("H"),cap=h.actualBoundingBoxAscent/1000,top=CSS.supports("text-box","trim-both cap alphabetic")?cap:(h.fontBoundingBoxAscent+(1000-h.fontBoundingBoxAscent-h.fontBoundingBoxDescent)/2)/1000;if(!(top>0))return;r.style.setProperty("--wm-mid",(top-cap/2).toFixed(4));Array.prototype.forEach.call(n.children,function(c,i){var m=x.measureText(c.firstChild.textContent);r.style.setProperty("--wm-a-"+i,(top-m.actualBoundingBoxAscent/1000).toFixed(4)+"em");r.style.setProperty("--wm-b-"+i,(top+m.actualBoundingBoxDescent/1000).toFixed(4)+"em")})}catch(x){}};var fit=function(){if(r.hasAttribute("data-open"))return;ink();var b=n.parentElement,c=getComputedStyle(b),room=b.clientWidth-parseFloat(c.paddingLeft)-parseFloat(c.paddingRight);r.style.setProperty("--wm-size","100px");var w=n.getBoundingClientRect().width;if(w>0&&room>0)r.style.setProperty("--wm-size",Math.min(100*room/w,innerHeight*0.8).toFixed(2)+"px")};var go=function(){fit();r.setAttribute("data-fit","");f&&f.addEventListener("loadingdone",fit)};if(!f)return go();var s=getComputedStyle(n),fam=s.fontFamily.split(",")[0].trim().replace(/^["']|["']$/g,""),has=false;f.forEach(function(x){if(x.family.replace(/^["']|["']$/g,"")===fam)has=true});f.load(s.fontStyle+" "+s.fontWeight+" 100px "+s.fontFamily,${text}).then(function(l){if(l.length||!has)go()},function(){})}catch(x){}})()`;
|
|
87
|
+
|
|
88
|
+
/** Lets go of the hold the arm script put on the page. */
|
|
89
|
+
function releaseHold() {
|
|
90
|
+
(window as Window & { __preloaderWordmarkHold?: (() => void) | null }).__preloaderWordmarkHold?.();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The fill's floor: an in-out curve, the same one blocks.css draws before the script takes over. */
|
|
94
|
+
const floorCurve = (x: number) => {
|
|
95
|
+
const t = Math.min(1, Math.max(0, x));
|
|
96
|
+
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
|
97
|
+
};
|
|
98
|
+
const smooth = (x: number) => x * x * (3 - 2 * x);
|
|
99
|
+
|
|
100
|
+
/** Any CSS colour as rgb(), through a one-pixel canvas, so GSAP can tween it whatever syntax the palette uses. */
|
|
101
|
+
function rgbOf(color: string): string {
|
|
102
|
+
const cv = document.createElement("canvas");
|
|
103
|
+
cv.width = 1;
|
|
104
|
+
cv.height = 1;
|
|
105
|
+
const ctx = cv.getContext("2d", { willReadFrequently: true });
|
|
106
|
+
if (!ctx) return color;
|
|
107
|
+
ctx.fillStyle = "#000";
|
|
108
|
+
ctx.fillStyle = color;
|
|
109
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
110
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
111
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Measures where each letter's ink sits in its cell, in em, so the fill's window runs from the bottom of the ink to the
|
|
116
|
+
* top of it exactly (blocks.css): --wm-a-<i> is the top of the ink below the cell's top (negative above it), --wm-b-<i>
|
|
117
|
+
* the bottom. The cell is the cap band where the browser trims text boxes, and the whole line box where it does not
|
|
118
|
+
* (Firefox), so the baseline's depth in the cell comes from the face's own metrics there. --wm-mid is the middle of the
|
|
119
|
+
* cap band, where `split` cuts.
|
|
120
|
+
*/
|
|
121
|
+
function inkOf(el: HTMLElement) {
|
|
122
|
+
const n = el.querySelector<HTMLElement>("[data-base] [data-name]");
|
|
123
|
+
const x = n ? document.createElement("canvas").getContext("2d") : null;
|
|
124
|
+
if (!n || !x) return;
|
|
125
|
+
const s = getComputedStyle(n);
|
|
126
|
+
// Measured at 1000px, so the bounds (whole pixels in some engines) land within a thousandth of an em.
|
|
127
|
+
const u = 1000;
|
|
128
|
+
x.font = `${s.fontStyle} ${s.fontWeight} ${u}px ${s.fontFamily}`;
|
|
129
|
+
const h = x.measureText("H");
|
|
130
|
+
const cap = h.actualBoundingBoxAscent / u;
|
|
131
|
+
const top = CSS.supports("text-box", "trim-both cap alphabetic")
|
|
132
|
+
? cap
|
|
133
|
+
: (h.fontBoundingBoxAscent + (u - h.fontBoundingBoxAscent - h.fontBoundingBoxDescent) / 2) / u;
|
|
134
|
+
if (!(top > 0)) return;
|
|
135
|
+
el.style.setProperty("--wm-mid", (top - cap / 2).toFixed(4));
|
|
136
|
+
Array.from(n.children).forEach((c, i) => {
|
|
137
|
+
const m = x.measureText(c.firstChild?.textContent ?? "");
|
|
138
|
+
el.style.setProperty(`--wm-a-${i}`, `${(top - m.actualBoundingBoxAscent / u).toFixed(4)}em`);
|
|
139
|
+
el.style.setProperty(`--wm-b-${i}`, `${(top + m.actualBoundingBoxDescent / u).toFixed(4)}em`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Fits the name to the width left inside its box, measured in the face it is drawn in. */
|
|
144
|
+
function fitName(el: HTMLElement) {
|
|
145
|
+
const n = el.querySelector<HTMLElement>("[data-base] [data-name]");
|
|
146
|
+
const box = n?.parentElement;
|
|
147
|
+
if (!n || !box) return;
|
|
148
|
+
const cs = getComputedStyle(box);
|
|
149
|
+
const room = box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
150
|
+
el.style.setProperty("--wm-size", "100px");
|
|
151
|
+
const w = n.getBoundingClientRect().width;
|
|
152
|
+
if (w > 0 && room > 0) el.style.setProperty("--wm-size", `${Math.min((100 * room) / w, window.innerHeight * 0.8).toFixed(2)}px`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The box of a node's text itself (the glyphs' advance and content area), not of the element around it. */
|
|
156
|
+
function textBox(node: Element): DOMRect {
|
|
157
|
+
const range = document.createRange();
|
|
158
|
+
range.selectNodeContents(node);
|
|
159
|
+
return range.getBoundingClientRect();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Set once the cover has played in this document, so a remount never replays it. */
|
|
163
|
+
let playedThisDocument = false;
|
|
164
|
+
/** A teardown's lift, held back one task so a remount in the same commit (strict mode) can take it back. */
|
|
165
|
+
let pendingTeardown = 0;
|
|
166
|
+
|
|
167
|
+
type Props = {
|
|
168
|
+
/** The studio's name, set across the screen. One or two short words read best. */
|
|
169
|
+
name: string;
|
|
170
|
+
/** How the name opens onto the page. */
|
|
171
|
+
variant?: WordmarkVariant;
|
|
172
|
+
/** `settle` only: the header's wordmark the name travels into. Without one in view it lifts with the cover. */
|
|
173
|
+
target?: string;
|
|
174
|
+
/** Play on every load, ignoring the session (the lab uses it). */
|
|
175
|
+
always?: boolean;
|
|
176
|
+
/** Called as the opening begins. */
|
|
177
|
+
onReveal?: () => void;
|
|
178
|
+
className?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export function PreloaderWordmark({
|
|
182
|
+
name,
|
|
183
|
+
variant = "split",
|
|
184
|
+
target = "[data-wordmark-target]",
|
|
185
|
+
always = false,
|
|
186
|
+
onReveal,
|
|
187
|
+
className = "",
|
|
188
|
+
}: Props) {
|
|
189
|
+
const root = useRef<HTMLDivElement>(null);
|
|
190
|
+
const [done, setDone] = useState(false);
|
|
191
|
+
const letters = Array.from(name);
|
|
192
|
+
|
|
193
|
+
useGSAP(
|
|
194
|
+
() => {
|
|
195
|
+
const el = root.current;
|
|
196
|
+
if (!el) return;
|
|
197
|
+
window.clearTimeout(pendingTeardown);
|
|
198
|
+
const armed = el.hasAttribute("data-armed");
|
|
199
|
+
const t0 = Number(el.dataset.t0 ?? performance.now());
|
|
200
|
+
const since = () => Math.max(0, (performance.now() - t0) / 1000);
|
|
201
|
+
let mark: HTMLElement | null = null;
|
|
202
|
+
const showMark = () => {
|
|
203
|
+
if (mark) mark.style.visibility = "";
|
|
204
|
+
};
|
|
205
|
+
const quit = () => {
|
|
206
|
+
releaseHold();
|
|
207
|
+
showMark();
|
|
208
|
+
if (covered()) liftCurtain();
|
|
209
|
+
setDone(true);
|
|
210
|
+
};
|
|
211
|
+
const gaveUp = armed && getComputedStyle(el).visibility === "hidden";
|
|
212
|
+
if (!armed || gaveUp || (playedThisDocument && !always)) return quit();
|
|
213
|
+
|
|
214
|
+
const reduce = el.hasAttribute("data-reduce");
|
|
215
|
+
// Hydrated late: the CSS clock is about to fade the cover (from 1.95s, or 1.1s with reduced motion) and hides it
|
|
216
|
+
// soon after. Let it, and only tidy up once it has. Any earlier, the script takes the cover over, full or not,
|
|
217
|
+
// and opens it in the variant's own move, shortened to the time left (exitFor).
|
|
218
|
+
if (since() > (reduce ? T.reduceLate : T.late)) {
|
|
219
|
+
const later = window.setTimeout(quit, Math.max(0, BUDGET - since()) * 1000);
|
|
220
|
+
return () => window.clearTimeout(later);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
playedThisDocument = true;
|
|
224
|
+
// Take the fill over from the CSS clock where it stands, then stop the clock (and its failsafe).
|
|
225
|
+
let p = reduce ? 1 : parseFloat(getComputedStyle(el).getPropertyValue("--wm-p")) || 0;
|
|
226
|
+
const render = () => el.style.setProperty("--wm-p", p.toFixed(4));
|
|
227
|
+
render();
|
|
228
|
+
el.dataset.live = "";
|
|
229
|
+
closeCurtain();
|
|
230
|
+
|
|
231
|
+
let finished = false;
|
|
232
|
+
let cancelled = false;
|
|
233
|
+
// The script's own ceiling, now that the CSS one is off: whatever happens, the cover is gone by then.
|
|
234
|
+
const ceiling = window.setTimeout(() => {
|
|
235
|
+
if (!finished) quit();
|
|
236
|
+
}, Math.max(0, BUDGET + 0.3 - since()) * 1000);
|
|
237
|
+
|
|
238
|
+
// Real progress: the name in its own face (the fit script, or this one if that never ran) and every photo in
|
|
239
|
+
// the first screen decoded.
|
|
240
|
+
if (!el.hasAttribute("data-fit")) {
|
|
241
|
+
const n = el.querySelector<HTMLElement>("[data-base] [data-name]");
|
|
242
|
+
const s = n ? getComputedStyle(n) : null;
|
|
243
|
+
if (s && document.fonts) {
|
|
244
|
+
document.fonts.load(`${s.fontStyle} ${s.fontWeight} 100px ${s.fontFamily}`, name).then(
|
|
245
|
+
(loaded) => {
|
|
246
|
+
if (cancelled || el.hasAttribute("data-fit") || !loaded.length) return;
|
|
247
|
+
inkOf(el);
|
|
248
|
+
fitName(el);
|
|
249
|
+
el.dataset.fit = "";
|
|
250
|
+
},
|
|
251
|
+
() => undefined,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let pageReady = false;
|
|
256
|
+
const photos = Array.from(document.images).filter((img) => {
|
|
257
|
+
if (el.contains(img)) return false;
|
|
258
|
+
const r = img.getBoundingClientRect();
|
|
259
|
+
return r.bottom > 0 && r.top < window.innerHeight && r.width > 0;
|
|
260
|
+
});
|
|
261
|
+
Promise.all(photos.map((img) => img.decode().catch(() => undefined))).then(() => (pageReady = true));
|
|
262
|
+
|
|
263
|
+
const onResize = () => {
|
|
264
|
+
if (!el.hasAttribute("data-open") && el.hasAttribute("data-fit")) fitName(el);
|
|
265
|
+
};
|
|
266
|
+
window.addEventListener("resize", onResize);
|
|
267
|
+
|
|
268
|
+
const q = gsap.utils.selector(el);
|
|
269
|
+
const base = q("[data-base]")[0] as HTMLElement;
|
|
270
|
+
const baseGround = base.querySelector<HTMLElement>("[data-ground]")!;
|
|
271
|
+
const baseName = base.querySelector<HTMLElement>("[data-name]")!;
|
|
272
|
+
let exitTl: gsap.core.Timeline | null = null;
|
|
273
|
+
|
|
274
|
+
const finish = () => {
|
|
275
|
+
finished = true;
|
|
276
|
+
window.clearTimeout(ceiling);
|
|
277
|
+
showMark();
|
|
278
|
+
setDone(true);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/** Hand over: the page is its own from here, and its entrance starts. */
|
|
282
|
+
const handOver = () => {
|
|
283
|
+
el.dataset.open = "";
|
|
284
|
+
releaseHold();
|
|
285
|
+
liftCurtain();
|
|
286
|
+
onReveal?.();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Reduced motion: the filled name, held briefly, then a short fade. No travel.
|
|
290
|
+
if (reduce) {
|
|
291
|
+
let poll = 0;
|
|
292
|
+
const wait = () => {
|
|
293
|
+
if (cancelled) return;
|
|
294
|
+
const t = since();
|
|
295
|
+
const ready = pageReady && el.hasAttribute("data-fit");
|
|
296
|
+
if (t >= T.reduceHold && (ready || t >= T.reduceWait)) {
|
|
297
|
+
handOver();
|
|
298
|
+
exitTl = gsap.timeline({ onComplete: finish }).to(el, { opacity: 0, duration: T.reduceFade, ease: "power1.out" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
poll = window.setTimeout(wait, 30);
|
|
302
|
+
};
|
|
303
|
+
wait();
|
|
304
|
+
return () => {
|
|
305
|
+
cancelled = true;
|
|
306
|
+
window.clearTimeout(poll);
|
|
307
|
+
window.clearTimeout(ceiling);
|
|
308
|
+
window.removeEventListener("resize", onResize);
|
|
309
|
+
exitTl?.kill();
|
|
310
|
+
if (finished) return;
|
|
311
|
+
playedThisDocument = false;
|
|
312
|
+
pendingTeardown = window.setTimeout(() => {
|
|
313
|
+
releaseHold();
|
|
314
|
+
if (covered()) liftCurtain();
|
|
315
|
+
}, 0);
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The opening: its full length when there is room, shorter when the script came late.
|
|
320
|
+
const exitFor = () => Math.min(EXIT[variant], BUDGET - since() - 0.04);
|
|
321
|
+
const fillBy = BUDGET - EXIT[variant] - T.beat - 0.04;
|
|
322
|
+
|
|
323
|
+
/** Opens the cover. Every cut is decided now, on whole pixels, and appears in this same frame. */
|
|
324
|
+
const open = () => {
|
|
325
|
+
const dur = exitFor();
|
|
326
|
+
if (dur < T.minExit) return quit();
|
|
327
|
+
const vw = el.clientWidth;
|
|
328
|
+
const vh = el.clientHeight;
|
|
329
|
+
const ease = "power4.inOut";
|
|
330
|
+
const y = (v: number) => `translate3d(0, ${Math.round(v)}px, 0)`;
|
|
331
|
+
const tl = gsap.timeline({ onComplete: finish });
|
|
332
|
+
exitTl = tl;
|
|
333
|
+
|
|
334
|
+
if (variant === "split") {
|
|
335
|
+
// The line through the middle of the name's cap band (measured in inkOf, so it holds where text boxes are
|
|
336
|
+
// not trimmed), on a whole pixel.
|
|
337
|
+
const r = baseName.getBoundingClientRect();
|
|
338
|
+
const mid = parseFloat(el.style.getPropertyValue("--wm-mid"));
|
|
339
|
+
const fs = parseFloat(getComputedStyle(baseName).fontSize);
|
|
340
|
+
const cut = Math.round(r.top + (mid > 0 && fs > 0 ? mid * fs : r.height / 2));
|
|
341
|
+
const [top, bottom] = q("[data-piece]") as HTMLElement[];
|
|
342
|
+
top.style.clipPath = `inset(0px 0px ${vh - cut}px 0px)`;
|
|
343
|
+
bottom.style.clipPath = `inset(${cut}px 0px 0px 0px)`;
|
|
344
|
+
top.style.visibility = "inherit";
|
|
345
|
+
bottom.style.visibility = "inherit";
|
|
346
|
+
base.style.visibility = "hidden";
|
|
347
|
+
const s = { p: 0 };
|
|
348
|
+
tl.to(s, {
|
|
349
|
+
p: 1,
|
|
350
|
+
duration: dur,
|
|
351
|
+
ease,
|
|
352
|
+
onUpdate: () => {
|
|
353
|
+
top.style.transform = y(-cut * s.p);
|
|
354
|
+
bottom.style.transform = y((vh - cut) * s.p);
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
} else if (variant === "drop") {
|
|
358
|
+
// One column per letter, its edges halfway between the letters, on whole pixels; each column carries its own
|
|
359
|
+
// letter. Letters sit above every column (blocks.css), so a glyph's overhang is never cut by a neighbour.
|
|
360
|
+
const boxes = (baseName.querySelectorAll("[data-l]") as NodeListOf<HTMLElement>);
|
|
361
|
+
const rects = Array.from(boxes, (b) => b.getBoundingClientRect());
|
|
362
|
+
const edges = rects.map((r, i) => (i === 0 ? 0 : Math.round((rects[i - 1].right + r.left) / 2)));
|
|
363
|
+
edges.push(vw);
|
|
364
|
+
const pieces = q("[data-piece]") as HTMLElement[];
|
|
365
|
+
const count = pieces.length;
|
|
366
|
+
const stagger = count > 1 ? Math.min(0.075, (dur * 0.34) / (count - 1)) : 0;
|
|
367
|
+
const fall = dur - stagger * (count - 1);
|
|
368
|
+
pieces.forEach((piece, i) => {
|
|
369
|
+
const ground = piece.querySelector<HTMLElement>("[data-ground]")!;
|
|
370
|
+
const letter = piece.querySelector<HTMLElement>("[data-fitbox]")!;
|
|
371
|
+
ground.style.clipPath = `inset(0px ${vw - edges[i + 1]}px 0px ${edges[i]}px)`;
|
|
372
|
+
piece.style.visibility = "inherit";
|
|
373
|
+
const s = { p: 0 };
|
|
374
|
+
tl.to(
|
|
375
|
+
s,
|
|
376
|
+
{
|
|
377
|
+
p: 1,
|
|
378
|
+
duration: fall,
|
|
379
|
+
ease: "power3.in",
|
|
380
|
+
onUpdate: () => {
|
|
381
|
+
const t = y(vh * s.p);
|
|
382
|
+
ground.style.transform = t;
|
|
383
|
+
letter.style.transform = t;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
i * stagger,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
base.style.visibility = "hidden";
|
|
390
|
+
} else {
|
|
391
|
+
// settle: the cover lifts; the name shrinks and travels into the header's wordmark, in its colour.
|
|
392
|
+
const found = document.querySelector<HTMLElement>(target);
|
|
393
|
+
const tr = found?.getBoundingClientRect();
|
|
394
|
+
const inView = !!found && !!tr && tr.width > 0 && tr.bottom > 0 && tr.top < vh;
|
|
395
|
+
const s = { p: 0 };
|
|
396
|
+
let paint = () => {
|
|
397
|
+
const t = y(-vh * s.p);
|
|
398
|
+
baseGround.style.transform = t;
|
|
399
|
+
baseName.style.transform = t;
|
|
400
|
+
};
|
|
401
|
+
if (inView && found) {
|
|
402
|
+
mark = found;
|
|
403
|
+
const from = textBox(baseName);
|
|
404
|
+
const to = textBox(found);
|
|
405
|
+
const own = baseName.getBoundingClientRect();
|
|
406
|
+
const k = parseFloat(getComputedStyle(found).fontSize) / parseFloat(getComputedStyle(baseName).fontSize);
|
|
407
|
+
// The name scales about its own top left corner, by an even ratio per step (k ** m), so the eye reads one
|
|
408
|
+
// steady shrink rather than a late collapse; its text box travels a straight line from where it stands to
|
|
409
|
+
// where the wordmark's is.
|
|
410
|
+
const offX = from.left - own.left;
|
|
411
|
+
const offY = from.top - own.top;
|
|
412
|
+
baseName.style.transformOrigin = "0 0";
|
|
413
|
+
baseName.style.willChange = "transform";
|
|
414
|
+
const ink = rgbOf(getComputedStyle(baseName).color);
|
|
415
|
+
const markInk = rgbOf(getComputedStyle(found).color);
|
|
416
|
+
baseName.style.setProperty("--wm-ink", ink);
|
|
417
|
+
if (markInk !== ink) tl.to(baseName, { "--wm-ink": markInk, duration: dur * 0.8, ease: "power2.inOut" }, dur * 0.2);
|
|
418
|
+
// The page's own wordmark waits unseen until the name lands on it, so the mark is never there twice.
|
|
419
|
+
found.style.visibility = "hidden";
|
|
420
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
421
|
+
const travel = { p: 0 };
|
|
422
|
+
paint = () => {
|
|
423
|
+
baseGround.style.transform = y(-vh * s.p);
|
|
424
|
+
const m = travel.p;
|
|
425
|
+
const sc = k ** m;
|
|
426
|
+
const tx = lerp(from.left, to.left, m) - own.left - sc * offX;
|
|
427
|
+
const ty = lerp(from.top, to.top, m) - own.top - sc * offY;
|
|
428
|
+
baseName.style.transform = `translate3d(${tx.toFixed(2)}px, ${ty.toFixed(2)}px, 0) scale(${sc.toFixed(5)})`;
|
|
429
|
+
};
|
|
430
|
+
// The name leaves a beat after the cover starts to lift, and slows into the mark as the cover clears.
|
|
431
|
+
tl.to(travel, { p: 1, duration: dur * 0.94, ease: "power3.inOut", onUpdate: paint }, dur * 0.06);
|
|
432
|
+
}
|
|
433
|
+
tl.to(s, { p: 1, duration: dur, ease, onUpdate: paint }, 0);
|
|
434
|
+
tl.add(() => {
|
|
435
|
+
// Landed: the page's own wordmark takes over in the same frame the name goes.
|
|
436
|
+
baseName.style.visibility = "hidden";
|
|
437
|
+
showMark();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
handOver();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// The fill: follows real progress under the floor's curve, never back; at the cap whatever is left is drawn in.
|
|
444
|
+
let phase: "fill" | "beat" | "open" = "fill";
|
|
445
|
+
let beatUntil = 0;
|
|
446
|
+
let rushFrom: number | null = null;
|
|
447
|
+
let last = since();
|
|
448
|
+
const tick = () => {
|
|
449
|
+
if (cancelled) return;
|
|
450
|
+
const t = since();
|
|
451
|
+
const dt = Math.max(0, t - last);
|
|
452
|
+
last = t;
|
|
453
|
+
if (phase === "fill") {
|
|
454
|
+
const real = 0.2 + (el.hasAttribute("data-fit") ? 0.3 : 0) + (pageReady ? 0.5 : 0);
|
|
455
|
+
const allowed = floorCurve(t / T.floor);
|
|
456
|
+
const goal = Math.min(allowed, real);
|
|
457
|
+
// An eased follow with a least pace, so the last of the fill never creeps.
|
|
458
|
+
const gap = Math.max(0, goal - p);
|
|
459
|
+
let next = p + Math.max(gap * (1 - Math.exp(-dt / T.follow)), Math.min(gap, dt * T.pace));
|
|
460
|
+
const rushStart = fillBy - T.rush;
|
|
461
|
+
if (t >= rushStart) {
|
|
462
|
+
if (rushFrom === null) rushFrom = p;
|
|
463
|
+
const k = Math.min(1, (t - rushStart) / Math.max(0.01, fillBy - rushStart));
|
|
464
|
+
next = Math.max(next, rushFrom + (1 - rushFrom) * smooth(k));
|
|
465
|
+
}
|
|
466
|
+
if (goal >= 1 && next > 0.996) next = 1;
|
|
467
|
+
p = Math.min(1, Math.max(p, next));
|
|
468
|
+
render();
|
|
469
|
+
if (p >= 1) {
|
|
470
|
+
phase = "beat";
|
|
471
|
+
beatUntil = Math.min(t + T.beat, fillBy + T.beat);
|
|
472
|
+
}
|
|
473
|
+
} else if (phase === "beat" && t >= beatUntil) {
|
|
474
|
+
phase = "open";
|
|
475
|
+
gsap.ticker.remove(tick);
|
|
476
|
+
open();
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
gsap.ticker.add(tick);
|
|
480
|
+
|
|
481
|
+
return () => {
|
|
482
|
+
cancelled = true;
|
|
483
|
+
gsap.ticker.remove(tick);
|
|
484
|
+
window.clearTimeout(ceiling);
|
|
485
|
+
window.removeEventListener("resize", onResize);
|
|
486
|
+
exitTl?.kill();
|
|
487
|
+
if (finished) return;
|
|
488
|
+
// Torn down before it finished. In strict mode's double mount the remount comes in the same commit and takes
|
|
489
|
+
// this back; a real unmount lets the page go: hold released, curtain lifted, wordmark shown, entrances run.
|
|
490
|
+
playedThisDocument = false;
|
|
491
|
+
showMark();
|
|
492
|
+
pendingTeardown = window.setTimeout(() => {
|
|
493
|
+
releaseHold();
|
|
494
|
+
if (covered()) liftCurtain();
|
|
495
|
+
}, 0);
|
|
496
|
+
};
|
|
497
|
+
},
|
|
498
|
+
{ scope: root },
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
if (done) return null;
|
|
502
|
+
|
|
503
|
+
const lag = letters.length > 1 ? 0.6 / (letters.length - 1) : 0;
|
|
504
|
+
/** The name, one cell per letter: the faint tone and the ink over it, which rises through a mask (blocks.css). */
|
|
505
|
+
const nameOf = (only?: number) => (
|
|
506
|
+
<span data-name="" style={{ "--wm-lag": lag } as CSSProperties}>
|
|
507
|
+
{letters.map((ch, i) => {
|
|
508
|
+
const glyph = ch === " " ? " " : ch;
|
|
509
|
+
return (
|
|
510
|
+
<span
|
|
511
|
+
key={i}
|
|
512
|
+
data-l=""
|
|
513
|
+
className={only !== undefined && only !== i ? "invisible" : undefined}
|
|
514
|
+
style={{ "--i": i, "--wm-a": `var(--wm-a-${i}, -0.08em)`, "--wm-b": `var(--wm-b-${i}, 0.04em)` } as CSSProperties}
|
|
515
|
+
>
|
|
516
|
+
<span data-faint="">{glyph}</span>
|
|
517
|
+
<span data-ink="">{glyph}</span>
|
|
518
|
+
</span>
|
|
519
|
+
);
|
|
520
|
+
})}
|
|
521
|
+
</span>
|
|
522
|
+
);
|
|
523
|
+
const fitbox = (only?: number) => (
|
|
524
|
+
<div data-fitbox="" className="absolute inset-0 flex items-center justify-center px-[max(1rem,4vw)]">
|
|
525
|
+
{nameOf(only)}
|
|
526
|
+
</div>
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<div
|
|
531
|
+
ref={root}
|
|
532
|
+
data-preloader="wordmark"
|
|
533
|
+
data-variant={variant}
|
|
534
|
+
data-always={always ? "" : undefined}
|
|
535
|
+
aria-hidden="true"
|
|
536
|
+
suppressHydrationWarning
|
|
537
|
+
className={`pointer-events-none fixed inset-0 z-[100] select-none text-ink ${className}`}
|
|
538
|
+
>
|
|
539
|
+
<script dangerouslySetInnerHTML={{ __html: ARM }} />
|
|
540
|
+
{/* The whole cover as one piece: what the first paint shows and what fills. */}
|
|
541
|
+
<div data-base="" className="absolute inset-0">
|
|
542
|
+
<div data-ground="" className="pointer-events-auto absolute inset-0 bg-surface-0" />
|
|
543
|
+
{fitbox()}
|
|
544
|
+
</div>
|
|
545
|
+
{/* Parsed right after the name: shows it (blocks.css) once its own face is in and it is fitted. */}
|
|
546
|
+
<script
|
|
547
|
+
dangerouslySetInnerHTML={{ __html: fitScript(JSON.stringify(name).replace(/</g, "\\u003c")) }}
|
|
548
|
+
/>
|
|
549
|
+
{/* The cover cut for the opening, unseen until the frame it starts in. */}
|
|
550
|
+
{variant === "split"
|
|
551
|
+
? (["top", "bottom"] as const).map((half) => (
|
|
552
|
+
<div key={half} data-piece={half} className="invisible absolute inset-0">
|
|
553
|
+
<div data-ground="" className="pointer-events-auto absolute inset-0 bg-surface-0" />
|
|
554
|
+
{fitbox()}
|
|
555
|
+
</div>
|
|
556
|
+
))
|
|
557
|
+
: null}
|
|
558
|
+
{variant === "drop"
|
|
559
|
+
? letters.map((_, i) => (
|
|
560
|
+
<div key={i} data-piece="" className="invisible absolute inset-0">
|
|
561
|
+
<div data-ground="" className="pointer-events-auto absolute inset-0 bg-surface-0" />
|
|
562
|
+
{fitbox(i)}
|
|
563
|
+
</div>
|
|
564
|
+
))
|
|
565
|
+
: null}
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
}
|