@opalseed/pack
v0.6.1
Published
The Opalseed booster pack: pre-rendered foil wrapper sprites, a framework-free tear, and a React component.
Readme
@opalseed/pack
The Opalseed booster pack as one asset for every product: pre-rendered foil
wrapper sprites from a compositor, a framework-free tear, and a React wrapper.
Plan of record: ~/.claude/plans/i-need-to-work-buzzing-quasar.md.
Layout
src/core/runtime with no framework: geometry, the stylesheet, layer helpers, the tear and sheen cores,mountPack()src/react/<BoosterPack>,<PackStyles>,useTear, plain React, no MUIscripts/the compositor and tooling (Playwright + sharp; never published)lab/Vite playground on port 5175 (npm run lab), shot in both engines bynpm run shots, gesture-tested bynpm run dragfonts/,marks/copies of generated output from the MIX pipeline (seemarks/README.md)packs/_defaults.jsonhouse-style defaults every recipe inherits
Recipes (packs/<slug>.json) and the emitted sprites live in each CONSUMER
repo; the lab keeps its own set under lab/packs and lab/sprites. Masters
(full-res PNGs, face plates, source photos) live outside git in
brand-assets/packs/<slug>/ (the lab uses lab/masters/, gitignored).
Commands
npm run lab # playground, port 5175
npm run shots # Chromium + WebKit screenshots to .shots/ (SHOTS_ROUTES='/?bench=yaw&yaw=24,...')
npm run drag # the gesture contract, asserted in both engines
npm run render -- --recipes <dir> --out <dir> --masters <dir> [--only a,b] [--relit] [--override '{...}' --suffix -x]
npm run faces -- --recipes <dir> --masters <dir> [--only slug] [--regen] # photo -> treated face plate (+ optional Gemini)
npm run check -- --out <dir> --masters <dir> # tiers, index, tear invariant
node scripts/contrast.mjs --recipes <dir> --masters <dir> # title >= 3:1, kicker >= 4.5:1
npm run gemini probe # verify the image model + call shape (needs GEMINI_API_KEY in .env)
npm run build # dist/ for publishing (prepublishOnly runs check + build)
npm run lint # must print nothingUsing it
import { BoosterPack } from '@opalseed/pack';
import PACKS from './assets/packs/index.generated.js'; // emitted by the renderer
<BoosterPack sprites={PACKS['above-the-city']} width={240} /> // static: shelves, thumbnails, tiles
<BoosterPack sprites={...} width={220} mode="interactive" onTorn={fn} onOpen={fn}> // the swipe-to-cut gesture is live
{cards /* render behind the body: they rise out of the torn top */}
</BoosterPack>
<BoosterPack sprites={...} width={240} mode="controlled" /> // a parent drives it (see the contract)Light and sway
The light on a moving pack's foil is one model shared by every product (one
key light, overhead and in front, the same one baked into the sprites): a
glare band along the top edge when the top tilts away from the viewer, a
shade that deepens toward the bottom when it tilts toward them, zero at
rest. light turns its layers on; something has to drive them.
// a tilt you already compute (the site's shelf: the pointer), -1..1
<BoosterPack sprites={...} width={240} light style={{ '--pack-pitch': 'var(--ty, 0)' }} />
// the idle sway on your own wrapper (it carries whatever must move with the
// pack: a shadow plate, a hint); the light runs on the sway's clock
const wrap = useRef(null);
useSway(wrap, staged && !cutting);
<div ref={wrap}><BoosterPack sprites={...} width={220} mode="interactive" light /></div>The sway turns about Y only, SWAY.turnDeg each way, starting and ending
square to the viewer (right, then left, then back) on a SWAY.ms clock: to
let the pack turn both ways before something happens (a timed tear), wait
SWAY.ms. Turning the sway off (a cut starting) eases it to rest from
wherever it is instead of snapping, and an interrupted sway stays off. Without React:
createSway(el).set(active). The numbers (GLARE_BAND, GLARE_LAMINATE,
SHADE, SWAY) are exported from @opalseed/pack/core and live in
src/core/light.js: change the look there and every product follows.
Without React: mountPack(container, PACKS[slug], { width, mode, onTorn }) from
@opalseed/pack/core builds the same DOM.
Brand chrome stays in the consumer: the site's shadow plates, --lift and
accent ring wrap the pack; MIX's halo and beams sit beside it. The pack root
carries no shadow of its own.
The contract
One root .osp element; everything is driven by custom properties and a data
attribute on it, so a controller (or the tear core) never re-renders anything:
| on the root | meaning |
|---|---|
| --pack-tear 0..1 | the cut's progress across the strip |
| --pack-fly 0..1 | the torn strip's flight (translate/rotate/fade) |
| --pack-fly-ms time | the flight's length: the tear core sets it and lets a CSS transition (compositor) carry the strip; a controller stepping --pack-fly itself leaves it 0 |
| --pack-heal time | transition length while an early release snaps back |
| --sheen-x --sheen-y 0..1 | key-light position; --sheen-p 0..1 shows the pointer sheen |
| --pack-pitch -1..1 | drives the light layers: < 0 the top tilts away (glare), > 0 toward the viewer (shade) |
| data-pack-state | sealed tearing healing torn open |
| data-quiet="1" | reduced motion: no idle sweep, no sparks, beats <= 180ms |
| data-ready | interactive / controlled only: 0 (the whole root hidden) until the face's images (body and seal, or the clipped sealed) have decoded, then 1, so both halves appear in one frame; after 10s it shows regardless (whenFaceReady, READY_LATEST_MS) |
| data-osp-sway="1" | on an ANCESTOR of the root, set by useSway / createSway: the idle sway, and the light on its clock |
Layers, bottom to top: the children slot · body · the seal (the
sealed sprite's top band, right of the finger: the uncut strip) · the strip (hinged at
the finger, flies on --pack-fly) · the light (opt-in, light) · the sheen (masked by spec) · the hit
zone ([data-testid="pack-tear-zone"], interactive only).
The gesture (interactive mode): the zone covers the top quarter of the pack
and reaches 28px past its top and sides (TEAR_ZONE). The cut follows the
finger left to right and never goes back. A release past 60% of the width
(TEAR_DONE) tears. A shorter release still tears on a flick: at least a
quarter of the width from where the cut began, and still moving right at
0.3 px/ms over the last 100ms (TEAR_FLICK). Any other release holds the
cut for 200ms (TEAR_GRACE_MS), and a finger back down in that window
carries the same cut on, so a touch that flickers off mid-slice is not
lost. After that it heals (onHeal), and a press during the heal starts a
new cut straight away.
Nothing in a pack can be selected, in any mode, and it shows no long-press
callout or tap flash (-webkit-user-select, -webkit-touch-callout,
-webkit-tap-highlight-color on .osp; Safari ignores the unprefixed
user-select). The slot's children inherit it: one that needs selecting
sets -webkit-user-select: text; user-select: text.
Under rotation the pack is a flat sheet: a flow-wrapped sleeve is sealed
flat along its sides, so a parent's perspective() rotateY() is all it
takes. Once the strip is gone the sheen ends at the lip (TORN_TOP), and
consumer chrome that frames the pack (shadow plates, rings) should follow
--pack-fly the same way: top: calc(var(--pack-fly, 0) * 11.6%), with
the pack's custom properties set on the framing element as well as the
pack. dimmed is static-only.
Sprites
Five layers per pack, emitted as WebP tiers by the compositor and listed in
index.generated.js with explicit imports (a missing file is a build error):
sealed 120/240/480/600/720 · seal body strip 480/720 · spec 480.
seal and strip are cropped bands; the index records each band
[top, height] as fractions of the pack height. seal is sealed's top
STRIP_LAYER alone: the interactive and controlled modes draw it instead of
the whole sealed sprite, which only static mode still fetches (since
0.6.0; an index rendered before it has no seal and the band falls back to
the whole sprite, clipped).
Recipes
{
"slug": "above-the-city",
"theme": { "accent": "#E0953A", "light": "#FFD9A3" },
"print": { "layout": "poster", "title": ["Above", "the City"], "kicker": "Opalseed event", "year": "2026" },
"face": { "photo": "source/IMG_2210.jpg", "focal": [0.5, 0.42], "zoom": 1.0,
"treatment": { "duotone": 0.55, "contrast": 1.12, "lift": 0.04, "saturation": 0.8, "grain": 0.035, "vignette": 0.35 },
"ai": { "mode": "none" } }
}Everything not given comes from packs/_defaults.json (and a consumer's own
packs/_defaults.json). print.layout is poster (a treated photo on the
art panel, darkening ramp under the title), burst (the letter/rings/sparkles
motif; MIX packs add hero.icon, chip, cardsLine) or solid (Link: a
flat body in one colour, the mark and the rays as one shape plus the words
and the tear line all in ONE ink, a tear notch on the left edge, the foot
crimp in the seal's foil; knobs body, ink, inkDepth, holo,
heroWidth, kickerSize, textColor, extras, documented at the top of
scripts/lib/print.mjs; pair it with material.specInk: 1, specArt: 0 for
foil-bright ink on a matte body). encode.* sets the WebP tiers' quality,
alpha quality, effort, the spec mask's alpha quality and whether the 120/240
sizes are near-lossless or lossy (defaults in scripts/lib/slices.mjs
ENCODE reproduce the original bytes). face.* paths resolve
against brand-assets/packs/<slug>/; a hand-made face.png there skips the
treatment and Gemini entirely. material.* and tear.* knobs are documented
in _defaults.json; --override + --suffix render try-outs side by side.
Gemini (face.ai.mode: outpaint | cleanup | generate) is optional and
cached beside the photo as face-ai-<hash>.png + a JSON sidecar; it is never
re-called without --regen. The key lives in .env (see .env.example).
