mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
- ChromeText centres the stack when there is a badge, but the heading box
is as wide as the stack once it wraps, so at 360px /playground's two-line
heading started at the left, 76px off the axis its badge and lede share.
It centres its text too.
- GitHub Light's orange token (#E36209) is 3.49:1 on its white, under AA
since 11265a21 put every sample through shiki. A light token ink under
4.5:1 is darkened, hue kept, until it clears it.
- useTween with `ms` 0 divided 0 by 0 when the frame's time was the effect's
own, and a negative elapsed share eased to NaN: /kai's distribution drew
`NaN` on some loads. The share is held to [0, 1] and 0 ms arrives at once.
180 lines
7.6 KiB
TypeScript
180 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* motion — framer-motion, with one rule added: a scroll reveal may not hide content.
|
|
*
|
|
* `whileInView` is an ENHANCEMENT, and the site had it holding the content
|
|
* hostage. The pairing every page reached for was
|
|
*
|
|
* initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
|
|
*
|
|
* and `initial` is rendered — framer-motion writes it into the `style`
|
|
* attribute of the exported HTML. So the shipped page states that the section
|
|
* is transparent, and the ONLY thing that can take it back is an
|
|
* IntersectionObserver firing after hydration. Every way that observer can be
|
|
* late or absent — a viewport it never enters, a hydration error, a slow chunk,
|
|
* a browser that never runs the script — leaves real copy at `opacity: 0`
|
|
* forever. Measured on the live site at 1440x900: 4,380px of /about, 972px of
|
|
* /solutions and 2,229px of the homepage rendered as black.
|
|
*
|
|
* The tell was that reduced-motion readers saw MORE than everyone else: a
|
|
* rescue rule forced the start state visible under `prefers-reduced-motion` and
|
|
* with scripting off, so the two audiences nobody designs for were the two the
|
|
* page worked for. A reveal that needs a rescue rule to be readable is not a
|
|
* reveal, it is a hiding place, and with this grounding none is left.
|
|
*
|
|
* So the start state is grounded here, once, instead of at 815 call sites: the
|
|
* element ships opaque and in position, and what is left of `initial` is a
|
|
* nudge the entrance closes. The same grounding covers an `animate` entrance
|
|
* (`initial={{ opacity: 0 }}` then `animate={{ opacity: 1 }}`), which is how
|
|
* the marketing heroes open — otherwise the first paint is a blank headline.
|
|
* Nothing about a call site changes — the same props, the same durations, the
|
|
* same stagger — and there is no arrangement of them that can blank a section
|
|
* again.
|
|
*/
|
|
|
|
import * as React from 'react'
|
|
import { LazyMotion, m as framer } from 'framer-motion'
|
|
|
|
/**
|
|
* The animation features, fetched after the page: `motion.*` is framer-motion's
|
|
* `m`, which renders at once and animates once these arrive, so the 35 KB of
|
|
* animation engine is not part of any page's first load. Each element carries
|
|
* its own `LazyMotion`, so a page that animates nothing loads none of it.
|
|
* `domAnimation` is enough: nothing here animates layout or drags.
|
|
*/
|
|
const features = () => import('./motion-features').then((r) => r.default)
|
|
|
|
/**
|
|
* How far a reveal may start from where it belongs.
|
|
*
|
|
* A rise is the house entrance (`page-kit`'s `useRise` opens the same way), and
|
|
* this is the whole of it: 8px is motion when it animates and nothing at all
|
|
* when it does not, which is the point — the un-revealed page has to be a page
|
|
* somebody can read, not a page waiting for one. Horizontal offsets are dropped
|
|
* outright rather than clamped: an `x: -20` enter is wider than the viewport for
|
|
* the length of the animation, which is the 4px sideways scroll `overflow-x:
|
|
* clip` in `app/globals.css` exists to swallow.
|
|
*/
|
|
const NUDGE = 8
|
|
|
|
/** Reveal a section as it comes to the fold, not after. */
|
|
const EARLY = '0px 0px 96px 0px'
|
|
|
|
/**
|
|
* A start state that a reader can read.
|
|
*
|
|
* `opacity`, `x` and `scale` are what make content unreadable when the reveal
|
|
* does not run, so they never reach the DOM; `y` survives, clamped. Anything
|
|
* else the call site asked for is its own business — `width: 0` draws a bar
|
|
* growing, and an undrawn ornament is not a missing section.
|
|
*
|
|
* A variant NAME (`initial="hidden"`) cannot be inspected from here, so it
|
|
* becomes `false`: the element mounts in the state `whileInView` targets, which
|
|
* is the visible one, and its children inherit that through motion's context.
|
|
*/
|
|
function grounded(initial: unknown) {
|
|
if (!initial || typeof initial !== 'object' || Array.isArray(initial)) return false
|
|
const { opacity, x, scale, y, ...rest } = initial as Record<string, unknown>
|
|
const start =
|
|
typeof y === 'number' ? { ...rest, y: Math.max(-NUDGE, Math.min(NUDGE, y)) } : rest
|
|
return Object.keys(start).length ? start : false
|
|
}
|
|
|
|
const wrapped = new Map<string, React.ComponentType<Record<string, unknown>>>()
|
|
|
|
export const motion = new Proxy(framer, {
|
|
get(target, key) {
|
|
// Intrinsic elements only — `motion.div`, `motion.section`. `create` and the
|
|
// symbols are motion's own and pass straight through.
|
|
if (typeof key !== 'string' || !/^[a-z]/.test(key)) return Reflect.get(target, key)
|
|
let El = wrapped.get(key)
|
|
if (!El) {
|
|
const Base = Reflect.get(target, key) as React.ComponentType<Record<string, unknown>>
|
|
El = (props: Record<string, unknown>) => {
|
|
const enter =
|
|
Boolean(props.whileInView) ||
|
|
(Boolean(props.animate) && !!props.initial && typeof props.initial === 'object')
|
|
return (
|
|
<LazyMotion features={features}>
|
|
{enter ? (
|
|
<Base
|
|
{...props}
|
|
initial={grounded(props.initial)}
|
|
{...(props.whileInView
|
|
? { viewport: { once: true, margin: EARLY, ...(props.viewport as object) } }
|
|
: null)}
|
|
/>
|
|
) : (
|
|
<Base {...props} />
|
|
)}
|
|
</LazyMotion>
|
|
)
|
|
}
|
|
El.displayName = `motion.${key}`
|
|
wrapped.set(key, El)
|
|
}
|
|
return El
|
|
},
|
|
}) as typeof framer
|
|
|
|
/**
|
|
* Whether the reader asked for less motion. False on the server and on the first
|
|
* client render, so the export and hydration agree; it settles a frame later.
|
|
*/
|
|
export function useReduced(): boolean {
|
|
const [reduced, setReduced] = React.useState(false)
|
|
React.useEffect(() => {
|
|
const q = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
const set = () => setReduced(q.matches)
|
|
set()
|
|
q.addEventListener('change', set)
|
|
return () => q.removeEventListener('change', set)
|
|
}, [])
|
|
return reduced
|
|
}
|
|
|
|
/** The house ease for a value moving to where it belongs: fast out, soft in. */
|
|
const settle = (t: number) => 1 - Math.pow(1 - t, 3)
|
|
|
|
/**
|
|
* Values that travel to their targets instead of jumping: a number, or a
|
|
* distribution. Each frame is a straight blend of where the values were and
|
|
* where they are going, so two distributions that each sum to one sum to one on
|
|
* every frame between them — the bars deform, they never reset. With reduced
|
|
* motion the values arrive at once. The first render returns `from` (or the
|
|
* targets), so the server and the client draw the same thing.
|
|
*/
|
|
export function useTween(to: readonly number[], { ms = 700, from }: { ms?: number; from?: readonly number[] } = {}): number[] {
|
|
const reduced = useReduced()
|
|
const [now, setNow] = React.useState<number[]>(() => [...(from ?? to)])
|
|
const last = React.useRef<number[]>(now)
|
|
const key = to.join(',')
|
|
React.useEffect(() => {
|
|
const start = last.current
|
|
const end = [...to]
|
|
if (reduced || start.length !== end.length) {
|
|
last.current = end
|
|
setNow(end)
|
|
return
|
|
}
|
|
let raf = 0
|
|
const t0 = performance.now()
|
|
// A frame's time can be the moment the effect ran or before it, so the
|
|
// elapsed share is held to [0, 1], and `ms` 0 arrives at once: 0 / 0 is NaN,
|
|
// and a bar drawn at NaN stays there.
|
|
const step = (t: number) => {
|
|
const k = ms > 0 ? settle(Math.min(1, Math.max(0, (t - t0) / ms))) : 1
|
|
const v = start.map((a, i) => a + (end[i] - a) * k)
|
|
last.current = v
|
|
setNow(v)
|
|
if (k < 1) raf = requestAnimationFrame(step)
|
|
}
|
|
raf = requestAnimationFrame(step)
|
|
return () => cancelAnimationFrame(raf)
|
|
// `key` stands for `to`'s values: a new array of the same numbers is not a move.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, ms, reduced])
|
|
return now
|
|
}
|