@rallycry/card-tech
v0.1.1
Published
Shared card motion, lighting, effects, React shells, and WebGL foil rendering for Rally Cry products.
Readme
@rallycry/card-tech
The shared card rendering stack: motion, lighting, the effect catalogue, the card shells, and the WebGL foil path.
Install
bun add @rallycry/card-techReact components and hooks require React 19 or newer. The WebGL entrypoint also
requires three and @react-three/fiber; both remain optional for DOM-only
consumers.
Quick start
Import the shared styles once at the app boundary, then import only the paths the product uses:
import "@rallycry/card-tech/card.css"
import "@rallycry/card-tech/effects.css"
import { FlipCard, HoloTiltLayer } from "@rallycry/card-tech/components"
import { resolveEffectBudget } from "@rallycry/card-tech/effects"It exists because three codebases had each built a different two-thirds of this and none of them had all of it:
| | what it had | what it lacked |
| --- | --- | --- |
| universal-rally-cry | a 27-preset effect library, flip/tilt/hand/modal shells | tested motion maths; any answer to N-up cost beyond two hardcoded name lists |
| full-build | headless, tested physics; spring, fan, breathe, velocity, wheel | any effect library; a consumer for half of what it had built |
| conveyor | a GPU path: foil shaders, one canvas anchored to DOM rects, tier detection | the DOM effect library; anything not shaped like its own product's card |
Conveyor drag wobble
useDragGhost is the reusable lift-and-throw motion used by Conveyor's card
overlay. It samples pointer velocity over a rolling window, then applies the
same speed-dependent damped spring, axis limits, perspective, and lift scale as
Conveyor's DragOverlayCard.
import { useRef } from "react"
import { useDragGhost } from "@rallycry/card-tech/react"
function DragGhost({ isDragging }: { isDragging: boolean }) {
const deltaRef = useRef({ x: 0, y: 0 })
const ghostRef = useRef<HTMLDivElement>(null)
useDragGhost({ active: isDragging, deltaRef, ghostRef })
return <div ref={ghostRef}>Card content</div>
}Update deltaRef.current from a drag library or raw pointer events. The hook
writes the transform in one animation frame loop without React rerenders and
respects reduced-motion preferences.
Layout
The root entrypoint is headless; maths, the custom-property contract, and capability detection. No React, no design system. Everything else is a subpath so a consumer only pays for what it uses.
@rallycry/card-tech motion maths, lighting/parallax, capabilities
@rallycry/card-tech/effects the preset catalogue, cost budget, overlays
@rallycry/card-tech/effects.css the preset stylesheet
@rallycry/card-tech/card.css the shell stylesheet
@rallycry/card-tech/react hooks: mouse/device tilt, drag flip, fan nav
@rallycry/card-tech/components FlipCard, CardHand, HoloTiltLayer
@rallycry/card-tech/gl the WebGL foil path (three + R3F optional peers)three and @react-three/fiber are optional peers: importing /gl is what
pulls them in.
Cross-repo consumption
Published releases are regular public npm packages, so another Rally Cry repo does not need this monorepo or a workspace link:
bun add @rallycry/card-tech@^0.1.0The WebGL renderer remains opt-in through @rallycry/card-tech/gl. Its Three.js
dependencies are optional peers, so DOM-only consumers do not install or bundle
them.
To publish a release, update version and push a matching
card-tech-vX.Y.Z tag. The release workflow checks, compiles, and publishes the
package. Conveyor can then install that version and map its product-specific
card faces onto these shared shells and effects.
The load-bearing decisions
One lighting contract, ten properties. Every effect rule reads only
--rct-mx, --rct-posx, --rct-hyp, --rct-o and friends, and nothing else.
That is why the pointer, drag-rotation and device-orientation drivers are
interchangeable; add a driver and every downstream effect keeps working; add
an effect and no driver changes.
Units in that contract are load-bearing. The percent-carrying properties
feed calc(var(--rct-posx) * -2.5 + 175%) inside effect rules; a bare number
invalidates the whole calc() and the layer silently falls back to 0 0.
--rct-hyp and --rct-o must stay unitless because they multiply into
filter() factors. Both are asserted in src/__tests__/lighting.test.ts.
One position serves both faces. A flipped card's back carries its own
rotateY(180deg), which cancels the parent's flip, so the back sees the
highlight exactly where the front does. Only the per-face strength differs.
The web app previously wrote the same value into a separate front and back
variable; mirroring instead would put the glare on the wrong shoulder after
every flip.
Cost belongs to the effect. The web app encoded it as two hand-maintained
lists of effect names (INTENSE_*, DENSE_VIEW_HEAVY_*) sitting in a package
that knows nothing about rendering, so adding an effect meant remembering to
edit a list elsewhere. Here each effect carries a cost, the host declares its
environment, and resolveEffectBudget decides. That immediately surfaced a
real bug: spotlight runs a window-level pointermove listener and an rAF
that never settles, but was missing from the heavy list, so roster grids ran
one such pair per card.
Reduced motion keeps still gradients. A budget of static still renders
foil and galaxy, because a still gradient is not motion. Suppressing the
effect entirely would make identity cosmetics vanish for those readers.
Unaffordable effects degrade to none, never to a cheaper lookalike. A
user who picked plasma and silently got foil would think their setting had
been changed.
One rAF owns the transform. Idle breathing and hover deflect are competing targets inside a single loop, not competing loops. Every loop stops at a settle epsilon and clears its inline properties; on a full table that is the difference between idle and a pegged CPU.
Tilt moves decoration, never interactive content. The tilt applies to a
pointer-events: none sublayer while controls sit on a flat sibling. Tilting
the whole face makes buttons drift out from under the pointer and flicker at
element boundaries.
WebKit gets a different composite. Inside a deep preserve-3d subtree
WebKit falls blend modes back to normal. The white spotlight still lightens,
so it survives as plus-lighter; the tinted prismatic band would darken the
card, so it is dropped. WebKit also refuses to apply a rounded clip to a
3D-composited layer, which is what the opaque -webkit-mask and the whole
flat rendering path are for.
Theming
The package carries no design-system dependency. Everything a host can theme
arrives as a custom property, documented at the top of src/components/card.css
- surface, border, radius, shadows, and the two holo tints. A host that sets nothing still gets a legible card.
In this repo the bridge is
apps/web/src/components/organisms/flip-card/cardTechTheme.ts, which maps the
org's MUI palette onto the contract once at :root.
What deliberately stayed behind
- Card faces. full-build's role/twist/event frames and the web app's seven profile layouts are game and product domain, not card tech.
RcCardModal. It is an MUIModal+Slidewrapper; reimplementing a modal primitive here would mean re-solving focus trapping and portalling for no gain.- Conveyor's snapDOM binding, FPS bench, and card-anatomy mapping. The
reusable capture queue, texture caches, and
ShowcaseCardlive in/gl; Conveyor supplies its product-specific rasterizer, slot content, and masks. getRarity(storyPointValue). The host decides what makes a card rare and passes a resolvedtier/intensity.
Storybook
bun run --cwd packages/card-tech storybookStories use a neutral full-bleed specimen face labelled with the effect under test. Its color and contrast make blend behavior visible without coupling the package to a product or game theme.
This repo's Storybook exercises the DOM effects and card shells. URC does not
import /gl; the complete shared-canvas, DOM-capture, and ShowcaseCard
integration runs in Conveyor and is covered by
rallycry/conveyor#3858. Package
tests cover the framework-independent WebGL registry, frame step, materials,
icon textures, capture ownership, and texture-cache behavior.
