@kolkrabbi/kol-component 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { motion } from 'framer-motion'
|
|
3
|
+
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
|
+
import AssetPlaceholder from '../atoms/AssetPlaceholder.jsx'
|
|
5
|
+
import Button from '../atoms/Button.jsx'
|
|
6
|
+
import Image from '../molecules/Image.jsx'
|
|
7
|
+
import useTilt from '../hooks/useTilt.js'
|
|
8
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
9
|
+
|
|
10
|
+
/* taxonomy-ok: nests the same-file Media helper plus DS atoms/molecules
|
|
11
|
+
* (HlsVideo, AssetPlaceholder, Button, Image) it composes — an organism. */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True on coarse-pointer (touch/no-hover) devices. Local to BentoCard, mirrors
|
|
15
|
+
* TiltCard's copy; re-evaluates on device/orientation change via the
|
|
16
|
+
* media-query change event (the monorepo source froze `useIsTouchDevice` in a
|
|
17
|
+
* module-load const — fixed on recreate).
|
|
18
|
+
*/
|
|
19
|
+
function useCoarsePointer() {
|
|
20
|
+
const [coarse, setCoarse] = useState(
|
|
21
|
+
() => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const mq = window.matchMedia('(pointer: coarse)')
|
|
26
|
+
const onChange = () => setCoarse(mq.matches)
|
|
27
|
+
mq.addEventListener('change', onChange)
|
|
28
|
+
return () => mq.removeEventListener('change', onChange)
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
return coarse
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Media — internal, NOT exported. Sniffs `src` by extension and renders the
|
|
36
|
+
* right full-bleed media element: `.m3u8` → HlsVideo, `.mov|.mp4|.webm` →
|
|
37
|
+
* autoplay <video>, any other src → the Image molecule (its own broken-asset
|
|
38
|
+
* fallback), and no src → AssetPlaceholder. Positioning/fit arrive via
|
|
39
|
+
* `className`; Image gets an inline height so its base `h-auto` can't beat the
|
|
40
|
+
* `size-full` cover fill.
|
|
41
|
+
*/
|
|
42
|
+
function Media({ src, poster, className }) {
|
|
43
|
+
if (!src) return <AssetPlaceholder className={className} />
|
|
44
|
+
if (/\.m3u8$/i.test(src)) return <HlsVideo src={src} poster={poster} className={className} />
|
|
45
|
+
if (/\.(mov|mp4|webm)$/i.test(src)) {
|
|
46
|
+
return (
|
|
47
|
+
<video
|
|
48
|
+
src={src}
|
|
49
|
+
poster={poster}
|
|
50
|
+
autoPlay
|
|
51
|
+
muted
|
|
52
|
+
loop
|
|
53
|
+
playsInline
|
|
54
|
+
preload="auto"
|
|
55
|
+
className={className}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return <Image src={src} alt="" className={className} style={{ width: '100%', height: '100%' }} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* BentoCard — media hover-card for grid/bento walls. Full-bleed auto-detected
|
|
64
|
+
* media (HLS / video / image / placeholder) sits behind a content stack; on a
|
|
65
|
+
* fine pointer, hover reveals a darkening scrim plus subtitle / description /
|
|
66
|
+
* CTA over an always-visible title. No-hover (coarse-pointer) devices show
|
|
67
|
+
* everything statically and drop the media's pointer capture.
|
|
68
|
+
*
|
|
69
|
+
* Motion is gated: the pointer-following 3D tilt (shared `useTilt` framer
|
|
70
|
+
* springs — the monorepo's forked CSS `useBentoTilt` is gone) renders only on
|
|
71
|
+
* a fine pointer with motion allowed; reduced-motion, coarse pointer, or
|
|
72
|
+
* `enableTilt={false}` all fall back to a static card, and reduced-motion also
|
|
73
|
+
* drops the reveal's opacity transition.
|
|
74
|
+
*
|
|
75
|
+
* Zero CMS coupling — flat props. The CTA is a DS Button link (`<a href>`, no
|
|
76
|
+
* router import): `http*`/`mailto` open a new tab; any other href is a
|
|
77
|
+
* same-tab anchor whose `onNavigate(event)` seam lets an SPA intercept
|
|
78
|
+
* (preventDefault + its router) — wired capture-phase so it fires before the
|
|
79
|
+
* default navigation. Title/subtitle/description render exactly as authored;
|
|
80
|
+
* no casing transforms (author strings in their final case at the call site).
|
|
81
|
+
*
|
|
82
|
+
* @param {string} src media source; type auto-detected by extension
|
|
83
|
+
* @param {string} poster poster frame for HLS/video
|
|
84
|
+
* @param {ReactNode} title always-visible heading
|
|
85
|
+
* @param {ReactNode} subtitle hover-revealed line
|
|
86
|
+
* @param {ReactNode} description hover-revealed paragraph
|
|
87
|
+
* @param {string} href CTA target; `http*`/`mailto` → new-tab anchor, else same-tab (onNavigate seam)
|
|
88
|
+
* @param {Function} onNavigate (event) => void — same-tab CTA click seam (SPA intercept)
|
|
89
|
+
* @param {ReactNode} buttonLabel CTA label (no default — author it)
|
|
90
|
+
* @param {ReactNode} bodyContent extra content injected into the stack
|
|
91
|
+
* @param {number} overlayOpacity scrim darkness % over the media, 0 disables (default 60)
|
|
92
|
+
* @param {boolean} alignRight right-align the card (ms-auto) vs fill (size-full)
|
|
93
|
+
* @param {boolean} enableTilt master tilt switch (default true)
|
|
94
|
+
* @param {string} titleClassName title classes
|
|
95
|
+
* @param {string} contentClassName inner content-box classes
|
|
96
|
+
* @param {string} imageClassName media fit/position classes
|
|
97
|
+
* @param {string} contentStackClassName stack layout classes
|
|
98
|
+
* @param {string} className extra classes on the root
|
|
99
|
+
*/
|
|
100
|
+
export default function BentoCard({
|
|
101
|
+
src,
|
|
102
|
+
poster,
|
|
103
|
+
title,
|
|
104
|
+
subtitle,
|
|
105
|
+
description,
|
|
106
|
+
href,
|
|
107
|
+
onNavigate,
|
|
108
|
+
buttonLabel,
|
|
109
|
+
bodyContent = null,
|
|
110
|
+
overlayOpacity = 60,
|
|
111
|
+
alignRight = false,
|
|
112
|
+
enableTilt = true,
|
|
113
|
+
titleClassName = 'kol-sans-heading-01 text-absolute-white',
|
|
114
|
+
contentClassName = 'max-w-[384px]',
|
|
115
|
+
imageClassName = 'object-cover object-center',
|
|
116
|
+
contentStackClassName = 'relative z-20 h-full flex flex-col justify-start items-start gap-4 p-6 md:p-8',
|
|
117
|
+
className = '',
|
|
118
|
+
...rest
|
|
119
|
+
}) {
|
|
120
|
+
const reduced = usePrefersReducedMotion()
|
|
121
|
+
const coarse = useCoarsePointer()
|
|
122
|
+
const tilt = useTilt()
|
|
123
|
+
|
|
124
|
+
const tiltOff = !enableTilt || reduced || coarse
|
|
125
|
+
const Component = tiltOff ? 'div' : motion.div
|
|
126
|
+
const rootStyle = {
|
|
127
|
+
...(tiltOff ? {} : tilt.style),
|
|
128
|
+
backfaceVisibility: 'hidden',
|
|
129
|
+
WebkitBackfaceVisibility: 'hidden',
|
|
130
|
+
}
|
|
131
|
+
const tiltHandlers = tiltOff
|
|
132
|
+
? {}
|
|
133
|
+
: { ref: tilt.ref, onMouseMove: tilt.onMouseMove, onMouseLeave: tilt.onMouseLeave }
|
|
134
|
+
|
|
135
|
+
// Reveal choreography. Coarse (no-hover) devices show everything statically;
|
|
136
|
+
// fine pointers reveal on group-hover. The opacity transition is motion, so
|
|
137
|
+
// reduced-motion drops it (content still reveals, just without the fade).
|
|
138
|
+
const fade = reduced ? '' : 'transition-opacity duration-300'
|
|
139
|
+
const revealClass = `${coarse ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} ${fade}`.trim()
|
|
140
|
+
|
|
141
|
+
const mediaClass =
|
|
142
|
+
`absolute left-0 top-0 size-full rounded overflow-hidden ${imageClassName} ${coarse ? 'pointer-events-none' : ''}`.trim()
|
|
143
|
+
|
|
144
|
+
const isExternal = href && (href.startsWith('http') || href.startsWith('mailto'))
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Component
|
|
148
|
+
className={`relative group ${alignRight ? 'ms-auto' : 'size-full'} ${className}`.trim()}
|
|
149
|
+
{...tiltHandlers}
|
|
150
|
+
{...rest}
|
|
151
|
+
style={{ ...rootStyle, ...rest.style }}
|
|
152
|
+
>
|
|
153
|
+
<Media src={src} poster={poster} className={mediaClass} />
|
|
154
|
+
|
|
155
|
+
<div className="relative z-10 flex size-full h-full flex-col justify-start items-start text-auto">
|
|
156
|
+
<div className={`relative z-10 ${contentClassName} w-full h-full self-stretch`}>
|
|
157
|
+
{overlayOpacity > 0 && (
|
|
158
|
+
<div
|
|
159
|
+
className={`absolute -inset-1 rounded ${coarse ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'} ${fade} pointer-events-none`.trim()}
|
|
160
|
+
style={{ backgroundColor: `rgba(0, 0, 0, ${overlayOpacity / 100})` }}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
<div className={contentStackClassName}>
|
|
164
|
+
{title && <h3 className={titleClassName}>{title}</h3>}
|
|
165
|
+
{subtitle && <p className={`kol-mono-text text-absolute-white ${revealClass}`}>{subtitle}</p>}
|
|
166
|
+
{description && <p className={`kol-mono-12 text-absolute-white pb-6 ${revealClass}`}>{description}</p>}
|
|
167
|
+
{bodyContent}
|
|
168
|
+
{href && (
|
|
169
|
+
<div className={revealClass}>
|
|
170
|
+
<Button
|
|
171
|
+
href={href}
|
|
172
|
+
variant="primary"
|
|
173
|
+
size="sm"
|
|
174
|
+
{...(isExternal
|
|
175
|
+
? { target: '_blank', rel: 'noreferrer noopener' }
|
|
176
|
+
: { onClickCapture: onNavigate })}
|
|
177
|
+
>
|
|
178
|
+
{buttonLabel}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</Component>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canvas — the editor's aspect-ratio stage.
|
|
5
|
+
*
|
|
6
|
+
* A letterboxed frame (dashed guide border + mono ratio label) that hosts its
|
|
7
|
+
* children in a **fixed 1080-virtual-pixel coordinate space** and scales the
|
|
8
|
+
* whole layer to fit the viewport via a single CSS `transform`. Because
|
|
9
|
+
* children always render at 1080px-wide logical coords, a `168px` element is
|
|
10
|
+
* always `168/1080` of the frame regardless of zoom or viewport size — the
|
|
11
|
+
* scale seam is the only place real pixels enter. This 1080-virtual contract
|
|
12
|
+
* is the whole point: anything placed inside (SelectionOverlay, boxes, guides)
|
|
13
|
+
* shares it, so a consumer's drag math converts screen deltas to virtual by
|
|
14
|
+
* dividing by the live scale (frameWidth / 1080).
|
|
15
|
+
*
|
|
16
|
+
* Three parts ship from this file: `CanvasFrame` (the bare scale layer),
|
|
17
|
+
* `Canvas` (the letterbox, default export), and `PanViewport` (Space+drag pan).
|
|
18
|
+
* Export all three so a consumer can place a bare frame or compose their own
|
|
19
|
+
* viewport.
|
|
20
|
+
*
|
|
21
|
+
* Ported from the brand editor with three app-couplings dropped (per lobby
|
|
22
|
+
* spec): the static `ASPECTS` table → an `aspects` prop (+ DEFAULT_ASPECTS);
|
|
23
|
+
* the `#0E0E11` dark bg default → `transparent` (let the theme own the stage);
|
|
24
|
+
* and the hardwired `kol-grid-bg` grid → a `backdrop` slot the consumer fills.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/* Fixed virtual canvas width — children render in this pixel space and the
|
|
28
|
+
* outer rect scales to fit the viewport via CSS transform. Height is derived
|
|
29
|
+
* from the active aspect ratio. Load-bearing: the coordinate contract. */
|
|
30
|
+
export const CANVAS_VIRTUAL_W = 1080
|
|
31
|
+
|
|
32
|
+
/* Sensible DS default ratio table; a consumer passes its own `aspects` to
|
|
33
|
+
* override. Each entry is { id, label, ratio }. */
|
|
34
|
+
export const DEFAULT_ASPECTS = [
|
|
35
|
+
{ id: '1:1', label: '1:1', ratio: 1 },
|
|
36
|
+
{ id: '4:5', label: '4:5', ratio: 4 / 5 },
|
|
37
|
+
{ id: '9:16', label: '9:16', ratio: 9 / 16 },
|
|
38
|
+
{ id: '5:4', label: '5:4', ratio: 5 / 4 },
|
|
39
|
+
{ id: '16:9', label: '16:9', ratio: 16 / 9 },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export const CANVAS_DEFAULTS = {
|
|
43
|
+
guideColor: '#F5F3EF',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveAspect(aspect, customRatio, aspects) {
|
|
47
|
+
const table = aspects?.length ? aspects : DEFAULT_ASPECTS
|
|
48
|
+
const found = table.find((x) => x.id === aspect) ?? table[0]
|
|
49
|
+
const ratio = aspect === 'custom' && customRatio ? customRatio : found.ratio
|
|
50
|
+
const label = aspect === 'custom' && customRatio
|
|
51
|
+
? `Custom · ${Number(customRatio).toFixed(2)}`
|
|
52
|
+
: found.label
|
|
53
|
+
return { ratio, label }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Bare aspect frame — dashed guide border + label + virtual-pixel scale layer.
|
|
58
|
+
* No outer letterbox. Sizes to its parent (`width: 100%; aspect-ratio: ratio`)
|
|
59
|
+
* so the parent decides the frame's width/height.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} aspect aspect id, selects ratio+label from the table
|
|
62
|
+
* @param {number} customRatio numeric ratio + `Custom · N.NN` label when aspect==='custom'
|
|
63
|
+
* @param {Array} aspects [{ id, label, ratio }] ratio table (default DEFAULT_ASPECTS)
|
|
64
|
+
* @param {string} bgColor frame background fill (default transparent)
|
|
65
|
+
* @param {string} guideColor dashed border (@24%) + label (@70%) color
|
|
66
|
+
* @param {ReactNode} children rendered in the 1080-virtual scale layer
|
|
67
|
+
*/
|
|
68
|
+
export function CanvasFrame({
|
|
69
|
+
aspect,
|
|
70
|
+
customRatio,
|
|
71
|
+
aspects,
|
|
72
|
+
bgColor,
|
|
73
|
+
guideColor = CANVAS_DEFAULTS.guideColor,
|
|
74
|
+
children,
|
|
75
|
+
}) {
|
|
76
|
+
const { ratio, label } = resolveAspect(aspect, customRatio, aspects)
|
|
77
|
+
const virtualH = CANVAS_VIRTUAL_W / ratio
|
|
78
|
+
|
|
79
|
+
const rectRef = useRef(null)
|
|
80
|
+
const [scale, setScale] = useState(0)
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const node = rectRef.current
|
|
84
|
+
if (!node) return
|
|
85
|
+
const compute = () => {
|
|
86
|
+
const w = node.getBoundingClientRect().width
|
|
87
|
+
if (w > 0) setScale(w / CANVAS_VIRTUAL_W)
|
|
88
|
+
}
|
|
89
|
+
compute()
|
|
90
|
+
const ro = new ResizeObserver(compute)
|
|
91
|
+
ro.observe(node)
|
|
92
|
+
return () => ro.disconnect()
|
|
93
|
+
}, [ratio])
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
ref={rectRef}
|
|
98
|
+
className="relative w-full"
|
|
99
|
+
style={{
|
|
100
|
+
aspectRatio: ratio,
|
|
101
|
+
background: bgColor ?? 'transparent',
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<div
|
|
105
|
+
className="absolute inset-0 pointer-events-none z-[2]"
|
|
106
|
+
style={{
|
|
107
|
+
border: '1px solid',
|
|
108
|
+
borderColor: `color-mix(in srgb, ${guideColor} 24%, transparent)`,
|
|
109
|
+
}}
|
|
110
|
+
/>
|
|
111
|
+
<span
|
|
112
|
+
className="z-[2]"
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
top: 6,
|
|
116
|
+
left: 8,
|
|
117
|
+
fontSize: 10,
|
|
118
|
+
fontFamily: 'var(--kol-font-family-mono)',
|
|
119
|
+
letterSpacing: '0.1em',
|
|
120
|
+
color: `color-mix(in srgb, ${guideColor} 70%, transparent)`,
|
|
121
|
+
pointerEvents: 'none',
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{label}
|
|
125
|
+
</span>
|
|
126
|
+
{scale > 0 && (
|
|
127
|
+
<div
|
|
128
|
+
className="absolute top-0 left-0 z-[1]"
|
|
129
|
+
style={{
|
|
130
|
+
width: `${CANVAS_VIRTUAL_W}px`,
|
|
131
|
+
height: `${virtualH}px`,
|
|
132
|
+
transformOrigin: 'top left',
|
|
133
|
+
transform: `scale(${scale})`,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Canvas — letterboxed stage (default export).
|
|
145
|
+
*
|
|
146
|
+
* The OUTER wrapper letterboxes the inner `<CanvasFrame>` (rect carries the
|
|
147
|
+
* optional `bgColor`, dashed border, label, and virtual-pixel scale layer).
|
|
148
|
+
*
|
|
149
|
+
* `panEnabled` opts into Space-key + drag panning. When held, the cursor
|
|
150
|
+
* flips to grab/grabbing and the frame translates with the cursor; pointer
|
|
151
|
+
* events on the frame are suppressed while Space is held so layer handlers
|
|
152
|
+
* don't fire mid-pan. Decorative chrome (grid bg / dark bg / inset borders)
|
|
153
|
+
* is the consumer's responsibility — pass a `backdrop` node to fill the pan
|
|
154
|
+
* viewport behind the frame.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} aspect aspect id (see aspects table)
|
|
157
|
+
* @param {number} customRatio numeric ratio when aspect==='custom'
|
|
158
|
+
* @param {Array} aspects [{ id, label, ratio }] ratio table
|
|
159
|
+
* @param {string} bgColor frame background fill
|
|
160
|
+
* @param {string} guideColor guide border + label color
|
|
161
|
+
* @param {'center'|'start'} align vertical placement in the letterbox
|
|
162
|
+
* @param {boolean} panEnabled wrap in a Space+drag PanViewport
|
|
163
|
+
* @param {ReactNode} backdrop node placed behind the frame in the pan viewport (e.g. a grid)
|
|
164
|
+
* @param {ReactNode} children rendered in the 1080-virtual scale layer
|
|
165
|
+
*/
|
|
166
|
+
export default function Canvas({
|
|
167
|
+
aspect,
|
|
168
|
+
customRatio,
|
|
169
|
+
aspects,
|
|
170
|
+
bgColor,
|
|
171
|
+
guideColor = CANVAS_DEFAULTS.guideColor,
|
|
172
|
+
align = 'center',
|
|
173
|
+
panEnabled = false,
|
|
174
|
+
backdrop,
|
|
175
|
+
children,
|
|
176
|
+
}) {
|
|
177
|
+
const { ratio } = resolveAspect(aspect, customRatio, aspects)
|
|
178
|
+
|
|
179
|
+
const letterbox = (
|
|
180
|
+
<div
|
|
181
|
+
className={`flex ${align === 'start' ? 'items-start' : 'items-center'} justify-center w-full h-full`}
|
|
182
|
+
style={{ containerType: 'size' }}
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
style={{
|
|
186
|
+
width: `min(calc(100cqw - 48px), calc((100cqh - 48px) * ${ratio}))`,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<CanvasFrame
|
|
190
|
+
aspect={aspect}
|
|
191
|
+
customRatio={customRatio}
|
|
192
|
+
aspects={aspects}
|
|
193
|
+
bgColor={bgColor}
|
|
194
|
+
guideColor={guideColor}
|
|
195
|
+
>
|
|
196
|
+
{children}
|
|
197
|
+
</CanvasFrame>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (!panEnabled) return letterbox
|
|
203
|
+
return <PanViewport backdrop={backdrop}>{letterbox}</PanViewport>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* PanViewport — Space + drag pan wrapper.
|
|
208
|
+
*
|
|
209
|
+
* Hold Space → cursor `grab`. Mousedown while held → drag-pan, cursor
|
|
210
|
+
* `grabbing`. The pan transform applies to the child div that holds the frame;
|
|
211
|
+
* the viewport itself stays fixed. Pointer events on the transform layer
|
|
212
|
+
* disable while Space is held so layer-level mousedowns don't fire mid-pan.
|
|
213
|
+
* At rest the cursor is left unset so a consumer's tool cursor shows through.
|
|
214
|
+
*
|
|
215
|
+
* @param {ReactNode} backdrop placed behind the frame (grid / dark bg); the
|
|
216
|
+
* consumer owns its sizing (oversize it so panning
|
|
217
|
+
* never reveals an edge)
|
|
218
|
+
* @param {ReactNode} children the letterboxed frame
|
|
219
|
+
*/
|
|
220
|
+
export function PanViewport({ backdrop, children }) {
|
|
221
|
+
const [spaceHeld, setSpaceHeld] = useState(false)
|
|
222
|
+
const [pan, setPan] = useState({ x: 0, y: 0 })
|
|
223
|
+
const [dragging, setDragging] = useState(false)
|
|
224
|
+
const dragStart = useRef(null)
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const isInputTarget = (el) =>
|
|
228
|
+
el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
|
|
229
|
+
const onKeyDown = (e) => {
|
|
230
|
+
if (e.code === 'Space' && !isInputTarget(e.target)) {
|
|
231
|
+
e.preventDefault()
|
|
232
|
+
setSpaceHeld(true)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const onKeyUp = (e) => {
|
|
236
|
+
if (e.code === 'Space') {
|
|
237
|
+
setSpaceHeld(false)
|
|
238
|
+
setDragging(false)
|
|
239
|
+
dragStart.current = null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
window.addEventListener('keydown', onKeyDown)
|
|
243
|
+
window.addEventListener('keyup', onKeyUp)
|
|
244
|
+
return () => {
|
|
245
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
246
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
247
|
+
}
|
|
248
|
+
}, [])
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!dragging) return
|
|
252
|
+
const onMove = (e) => {
|
|
253
|
+
if (!dragStart.current) return
|
|
254
|
+
setPan({
|
|
255
|
+
x: e.clientX - dragStart.current.x,
|
|
256
|
+
y: e.clientY - dragStart.current.y,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
const onUp = () => {
|
|
260
|
+
setDragging(false)
|
|
261
|
+
dragStart.current = null
|
|
262
|
+
}
|
|
263
|
+
window.addEventListener('mousemove', onMove)
|
|
264
|
+
window.addEventListener('mouseup', onUp)
|
|
265
|
+
return () => {
|
|
266
|
+
window.removeEventListener('mousemove', onMove)
|
|
267
|
+
window.removeEventListener('mouseup', onUp)
|
|
268
|
+
}
|
|
269
|
+
}, [dragging])
|
|
270
|
+
|
|
271
|
+
const onMouseDown = (e) => {
|
|
272
|
+
if (!spaceHeld) return
|
|
273
|
+
e.preventDefault()
|
|
274
|
+
dragStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }
|
|
275
|
+
setDragging(true)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const cursor = dragging ? 'grabbing' : spaceHeld ? 'grab' : undefined
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div
|
|
282
|
+
className="relative w-full h-full overflow-hidden select-none"
|
|
283
|
+
style={cursor ? { cursor } : undefined}
|
|
284
|
+
onMouseDown={onMouseDown}
|
|
285
|
+
>
|
|
286
|
+
<div
|
|
287
|
+
className="absolute inset-0"
|
|
288
|
+
style={{
|
|
289
|
+
transform: `translate(${pan.x}px, ${pan.y}px)`,
|
|
290
|
+
transition: dragging ? 'none' : 'transform 120ms ease-out',
|
|
291
|
+
pointerEvents: spaceHeld ? 'none' : 'auto',
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
{backdrop}
|
|
295
|
+
<div className="relative w-full h-full">{children}</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { motion as Motion, useAnimationControls } from 'framer-motion'
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import TextPressure from '../atoms/TextPressure.jsx'
|
|
4
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ColorLoader — a full-height branded intro/loading curtain. Fills its parent
|
|
8
|
+
* on a solid backdrop, times in a variable-font wordmark (a live TextPressure
|
|
9
|
+
* effect) after `wordmarkDelay`, then reveals a bouncing down-chevron cue after
|
|
10
|
+
* `scrollDelay`. With `dismissOnClick`, clicking the curtain slides the whole
|
|
11
|
+
* thing up out of view and fires `onComplete`; otherwise the parent owns
|
|
12
|
+
* dismissal (unmount when its work is done). Generalized from the app's
|
|
13
|
+
* KOLKRABBI loader: the cursor trail, `cursor: none`, and window-scroll/keyboard
|
|
14
|
+
* gesture hijack are all dropped — exit is a plain click or parent-driven.
|
|
15
|
+
*
|
|
16
|
+
* Reduced motion → no timers, no tweens: one static frame (wordmark + cue shown
|
|
17
|
+
* at rest) and `onComplete` fires immediately on mount, so the curtain never
|
|
18
|
+
* makes a reduced-motion user wait.
|
|
19
|
+
*
|
|
20
|
+
* The wordmark needs a variable font — see TextPressure's font contract. Pass
|
|
21
|
+
* `fontFamily` / `fontUrl` to point at the KOL variable font (a consumer asset).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} text wordmark string fed to TextPressure
|
|
24
|
+
* @param {string} fontFamily wordmark font family (→ TextPressure)
|
|
25
|
+
* @param {string} [fontUrl] variable-font URL → TextPressure @font-face
|
|
26
|
+
* @param {() => void} onComplete fired after the exit slide-up (or at once, reduced)
|
|
27
|
+
* @param {boolean} dismissOnClick click the curtain to slide up + complete
|
|
28
|
+
* @param {string} bgColor backdrop color (KOL token / CSS color)
|
|
29
|
+
* @param {string} markColor wordmark + chevron color (KOL token / CSS color)
|
|
30
|
+
* @param {number} wordmarkDelay ms before the wordmark fades in
|
|
31
|
+
* @param {number} scrollDelay ms before the chevron cue reveals
|
|
32
|
+
* @param {number} exitDuration seconds for the slide-up exit tween
|
|
33
|
+
*/
|
|
34
|
+
export default function ColorLoader({
|
|
35
|
+
text = 'KOLKRABBI',
|
|
36
|
+
fontFamily = 'var(--kol-font-family-sans)',
|
|
37
|
+
fontUrl,
|
|
38
|
+
onComplete,
|
|
39
|
+
dismissOnClick = false,
|
|
40
|
+
bgColor = 'var(--kol-surface-primary)',
|
|
41
|
+
markColor = 'var(--kol-fg-emphasis)',
|
|
42
|
+
wordmarkDelay = 1000,
|
|
43
|
+
scrollDelay = 2000,
|
|
44
|
+
exitDuration = 1.8,
|
|
45
|
+
}) {
|
|
46
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
47
|
+
const [showMark, setShowMark] = useState(reducedMotion)
|
|
48
|
+
const [showCue, setShowCue] = useState(reducedMotion)
|
|
49
|
+
const [exiting, setExiting] = useState(false)
|
|
50
|
+
const controls = useAnimationControls()
|
|
51
|
+
const firedRef = useRef(false)
|
|
52
|
+
|
|
53
|
+
const fireComplete = () => {
|
|
54
|
+
if (firedRef.current) return
|
|
55
|
+
firedRef.current = true
|
|
56
|
+
onComplete?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (reducedMotion) {
|
|
61
|
+
// Skip the whole sequence: static frame, signal done at once.
|
|
62
|
+
setShowMark(true)
|
|
63
|
+
setShowCue(true)
|
|
64
|
+
fireComplete()
|
|
65
|
+
return undefined
|
|
66
|
+
}
|
|
67
|
+
const markTimer = setTimeout(() => setShowMark(true), wordmarkDelay)
|
|
68
|
+
const cueTimer = setTimeout(() => setShowCue(true), scrollDelay)
|
|
69
|
+
return () => {
|
|
70
|
+
clearTimeout(markTimer)
|
|
71
|
+
clearTimeout(cueTimer)
|
|
72
|
+
}
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [reducedMotion, wordmarkDelay, scrollDelay])
|
|
75
|
+
|
|
76
|
+
const dismiss = () => {
|
|
77
|
+
if (exiting) return
|
|
78
|
+
setExiting(true)
|
|
79
|
+
if (reducedMotion) {
|
|
80
|
+
fireComplete()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
controls
|
|
84
|
+
.start({ y: '-100%', transition: { duration: exitDuration, ease: 'easeInOut' } })
|
|
85
|
+
.then(fireComplete)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Motion.div
|
|
90
|
+
className={`flex h-full w-full flex-col ${dismissOnClick ? 'cursor-pointer' : ''}`}
|
|
91
|
+
style={{ backgroundColor: bgColor }}
|
|
92
|
+
animate={controls}
|
|
93
|
+
initial={{ y: 0 }}
|
|
94
|
+
onClick={dismissOnClick ? dismiss : undefined}
|
|
95
|
+
>
|
|
96
|
+
{/* Top third — spacer */}
|
|
97
|
+
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
98
|
+
|
|
99
|
+
{/* Middle third — wordmark + cue */}
|
|
100
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-12 self-stretch">
|
|
101
|
+
<Motion.div
|
|
102
|
+
initial={{ opacity: 0 }}
|
|
103
|
+
animate={{ opacity: showMark ? 1 : 0 }}
|
|
104
|
+
transition={{ duration: reducedMotion ? 0 : 0.5 }}
|
|
105
|
+
className="w-full px-6"
|
|
106
|
+
>
|
|
107
|
+
<div className="mx-auto h-20 w-full max-w-[280px] sm:h-24 sm:max-w-[400px] md:h-32 md:max-w-[600px]">
|
|
108
|
+
<TextPressure
|
|
109
|
+
text={text}
|
|
110
|
+
fontFamily={fontFamily}
|
|
111
|
+
fontUrl={fontUrl}
|
|
112
|
+
textColor={markColor}
|
|
113
|
+
flex
|
|
114
|
+
width
|
|
115
|
+
weight
|
|
116
|
+
italic={false}
|
|
117
|
+
minFontSize={40}
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
</Motion.div>
|
|
121
|
+
|
|
122
|
+
<Motion.div
|
|
123
|
+
initial={{ opacity: 0, y: 0 }}
|
|
124
|
+
animate={{
|
|
125
|
+
opacity: showCue ? 0.6 : 0,
|
|
126
|
+
y: showCue && !reducedMotion ? [0, 12, 0] : 0,
|
|
127
|
+
}}
|
|
128
|
+
transition={{
|
|
129
|
+
opacity: { duration: reducedMotion ? 0 : 0.5 },
|
|
130
|
+
y: reducedMotion ? { duration: 0 } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
|
|
131
|
+
}}
|
|
132
|
+
className="mt-12"
|
|
133
|
+
style={{ color: markColor }}
|
|
134
|
+
>
|
|
135
|
+
<svg
|
|
136
|
+
width="32"
|
|
137
|
+
height="32"
|
|
138
|
+
viewBox="0 0 24 24"
|
|
139
|
+
fill="none"
|
|
140
|
+
stroke="currentColor"
|
|
141
|
+
strokeWidth="2"
|
|
142
|
+
strokeLinecap="round"
|
|
143
|
+
strokeLinejoin="round"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
>
|
|
146
|
+
<polyline points="6 9 12 15 18 9" />
|
|
147
|
+
</svg>
|
|
148
|
+
</Motion.div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Bottom third — spacer */}
|
|
152
|
+
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
153
|
+
</Motion.div>
|
|
154
|
+
)
|
|
155
|
+
}
|