Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/tranche-b-3-1-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@mkbabb/keyframes.js": minor
---

Tranche B — the engine's debt transposed, the demo made true.

**Engine (gestalt transposition, net-deletion).** A typed `Easing`
(`{ fn, css? }`) replaces the former Symbol-on-a-closure side channels;
`resolveEasing(name)` (async, fail-explicit) + `toEasing` replace the
`EasingResolvable` resolver — the light engines accept a callable
`TimingFunction` or a typed `Easing` only (a string name throws
`AnimationOptionError`; resolve it up front). One `RAFPlayback` driver
(`play`/`drive`/`loop`) owns every rAF loop; one `withReducedMotion` gate
drives every reduced-motion snap; one explicit rest-position/fill contract
(`settle()` pure teardown, `reset()` explicit rewind, `restPosition` from
`fillMode`). Option setters are fail-explicit: a malformed PRESENT value
throws a typed `AnimationOptionError` (genuine omission still defaults).
WAAPI delegation is restored (the prior renderer check was bind-broken) and
made faithful: it delegates only when the easing has a CSS twin, and
`stop()`/`reset()` cancel the compositor animation. New exports: `Easing`,
`resolveEasing`, `toEasing`, `AnimationOptionError`, `UnknownEasingError`,
`RAFPlaybackOptions`, `Tickable`. `getTimingFunction` now resolves CSS
`steps()`/`step-start`/`step-end` (Easing Level 1 complete).

**Boundary gate widened.** `proof:boundary` now proves every light barrel
export (not just `SpringProgress`), the heavy engine's dynamic boundary,
and the absence of dormant static value.js specifiers.

**Demo + CI** (not part of the published library): the production demo
build is repaired (it was shipping blank), the four blank scenes render,
the cube is no longer clipped, and CI gains demo-paint (inv γ) + occlusion
(inv δ) gates.
85 changes: 70 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
# ci — PR + push-to-master gate matrix for @mkbabb/keyframes.js.
#
# LIBRARY-SCOPED gate (A inv β — the library build is glass-ui-free). Every
# step touches only the publishable surface (`src/` → `dist/`):
# TWO gate postures, by inv β:
#
# check:lib — tsc --noEmit -p tsconfig.lib.json (src/ only; never demo)
# build:lib — vite build --mode production (library entry; externals
# value.js + glass-ui; demo graph untouched)
# test — vitest (test/, jsdom) (no glass-ui imports)
# proof:boundary — build a spring-only entry and count value.js bytes +
# static engine-* edges (A inv α — the boundary is gated)
# gates (LIBRARY) — glass-ui-FREE. Every step touches only the
# publishable surface (`src/` → `dist/`):
# check:lib — tsc --noEmit -p tsconfig.lib.json (src/ only; never demo)
# build:lib — vite build --mode production (library entry; externals
# value.js + glass-ui; demo graph untouched)
# test — vitest (test/, jsdom) (no glass-ui imports)
# proof:boundary — bundle EVERY light barrel entry and assert 0 value.js +
# 0 static engine edge per entry, the heavy engine dynamic-only,
# no dormant static specifier (A inv α, widened in B.W2)
# `@mkbabb/glass-ui` is an OPTIONAL `file:../glass-ui` dependency; a clean
# runner has no sibling checkout, so `npm ci` cleanly SKIPS it
# (`optional: true`) — verified by the /tmp clean-runner archive run. The
# library gate therefore runs glass-ui-free.
#
# `@mkbabb/glass-ui` is an OPTIONAL `file:../glass-ui` dependency (demo-dev
# only). A clean runner has no sibling checkout, so npm skips it and the
# library gate runs glass-ui-free. The demo / gh-pages build is a separate
# dev-machine arm the release path does not require.
# demo-smoke (DEMO) — the demo legitimately needs glass-ui, so this is a
# SEPARATE job that installs the published package + a browser and asserts
# the BUILT gh-pages demo paints (B inv γ) and occludes nothing (B inv δ).
# A blank/tree-shaken build or an occluded page fails CI here.
#
# Cross-repo dependency-order note: keyframes.js resolves @mkbabb/value.js
# via the npm registry (^0.10.0). A breaking value.js publish surfaces here
Expand All @@ -29,11 +35,12 @@ on:

jobs:
gates:
name: library gate (glass-ui-free)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
Expand All @@ -42,7 +49,55 @@ jobs:
run: npm run check:lib
- name: build (library)
run: npm run build:lib
- name: dts artefact is real (not a 12-byte empty stub)
run: |
test -f dist/keyframes.d.ts
bytes=$(wc -c < dist/keyframes.d.ts)
echo "dist/keyframes.d.ts = $bytes bytes"
test "$bytes" -gt 10000
grep -q "class Animation" dist/keyframes.d.ts
grep -q "loadAnimationEngine" dist/keyframes.d.ts
- name: test
run: npm test -- --run
- name: proof:boundary (value.js static/dynamic boundary gate)
- name: proof:boundary (value.js static/dynamic boundary gate, all light entries)
run: npm run proof:boundary

demo-smoke:
name: demo gate (inv γ paints + inv δ occlusion-free)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
# The demo arm needs glass-ui (the library gate does not). The
# PUBLISHED tarball ships only `dist/styles/` while tailwind's CSS
# `@import "@mkbabb/glass-ui/styles"` resolves the SOURCE's root
# `styles/` (it does not honor the package `exports` map) — so the
# demo build resolves glass-ui ONLY against the sibling source the
# `file:../glass-ui` optional dep points at. Check out + build that
# sibling (it consumes keyframes/value.js from the REGISTRY — no
# circular sibling dep), so the demo build resolves it exactly as a
# dev machine does.
- name: check out + build the glass-ui sibling (file:../glass-ui)
run: |
git clone --depth 1 https://github.com/mkbabb/glass-ui.git "$GITHUB_WORKSPACE/../glass-ui"
cd "$GITHUB_WORKSPACE/../glass-ui"
npm ci
npm run build
- name: npm ci (picks up the built file:../glass-ui)
run: npm ci
- name: install playwright (demo-gate only; --no-save keeps the lib posture)
run: npm i --no-save @playwright/test
- name: install chromium
run: npx playwright install --with-deps chromium
- name: build the demo (gh-pages)
run: npm run gh-pages
- name: inv γ — the demo cannot ship blank
run: node scripts/demo-smoke.mjs
- name: inv δ — no page occludes on any viewport
run: node scripts/occlusion-gate.mjs
env:
KF_REQUIRE_BROWSER: "1"
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
registry-url: "https://registry.npmjs.org"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,18 @@

<!-- Animation stage. Mobile: a dedicated 1fr row track (row 2) below the
`auto` controls-pane row — the pane no longer overlays/clips the
stage at narrow widths (Qσ V1). Desktop: stage spans cols 2-4 of
the 3-col [400px 1fr 1fr] grid, controls-pane keeps col 1. -->
stage at narrow widths (Qσ V1). Desktop: the stage spans the FULL
3-col grid (col 1-4) so the subject centers in the viewport, NOT
in cols 2-3 — which, when the controls pane is closed/hidden,
collapsed the `1fr 1fr` tracks to zero width and jammed the cube
off the right edge (B.W3 BLOCKER: the cube was ~half-clipped at
1280/1440). The controls-pane (col-1, z-controls, position:
relative) overlays the stage's left edge when open — its own
400px backdrop sits above the centered stage, so an open pane
frames the subject without shifting it. -->
<div
:class="[
'justify-self-stretch self-center min-h-0 h-full overflow-visible overscroll-contain col-span-full row-start-2 lg:row-start-1 lg:row-end-auto lg:col-start-2 lg:col-end-4',
'justify-self-stretch self-center min-h-0 h-full overflow-visible overscroll-contain col-span-full row-start-2 lg:row-start-1 lg:row-end-auto lg:col-start-1 lg:col-end-4',
]"
>
<slot name="animation-content"></slot>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
label="duration"
label-class="font-mono text-base text-muted-foreground"
tooltip="Animation length (e.g. 5s, 200ms)"
@update:model-value="(v) => { animation.setDuration(v); storedAnimationOptions.animationOptions.duration = v; }"
@update:model-value="(v) => { trySetOption(() => animation.setDuration(v)); storedAnimationOptions.animationOptions.duration = v; }"
/>

<LabeledInput
:model-value="storedAnimationOptions.animationOptions.delay ?? '0ms'"
label="delay"
label-class="font-mono text-base text-muted-foreground"
tooltip="Delay before start (e.g. 0s, 500ms)"
@update:model-value="(v) => { animation.setDelay(v); storedAnimationOptions.animationOptions.delay = v; }"
@update:model-value="(v) => { trySetOption(() => animation.setDelay(v)); storedAnimationOptions.animationOptions.delay = v; }"
/>

<LabeledInput
Expand All @@ -34,7 +34,7 @@
tooltip="Repeat count (number or 'infinite')"
@update:model-value="
(v: string) => {
animation.setIterationCount(v);
trySetOption(() => animation.setIterationCount(v));
storedAnimationOptions.animationOptions.iterationCount = v;
}
"
Expand Down Expand Up @@ -212,6 +212,23 @@ const props = defineProps<{

const storedAnimationOptions = getStoredAnimationOptions(props.animation);

/**
* The engine setters are fail-explicit — a malformed PRESENT value throws
* an `AnimationOptionError` (B.W2). User input mid-keystroke is routinely
* malformed (an empty field, a partial number), so guard the live handlers:
* an empty value is omission (no-op), anything else attempts the set and
* swallows the typed error until the input is valid. The store still
* records the raw string so the field round-trips.
*/
const trySetOption = (apply: () => void) => {
try {
apply();
} catch (e) {
if ((e as Error)?.name !== "AnimationOptionError") throw e;
// malformed-in-progress input — ignore until it parses
}
};

const {
timingFunctionsAnd,
convertedFromName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,13 @@ export function useTimingFunctionEditor(

const setAnimationTimingFunction = (timingFunction: TimingFunction) => {
const animation = getAnimation();
animation.options.timingFunction = timingFunction;
// The engine carries easing as a typed `Easing` ({ fn, css? });
// wrap the bare callable once and share the reference across
// frames so WAAPI uniform-timing eligibility sees ONE easing.
const easing = { fn: timingFunction };
animation.options.timingFunction = easing;
animation.frames.forEach((frame) => {
frame.timingFunction = timingFunction;
frame.timingFunction = easing;
});
};

Expand Down
8 changes: 4 additions & 4 deletions demo/@/components/custom/dock/TopDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const activeLayer = computed(() => {
<template>
<div
class="fixed left-1/2 -translate-x-1/2 z-dock flex items-center justify-center pointer-events-none"
style="top: calc(var(--work-area-top-offset, 0px) + var(--dock-margin) / 4);"
style="top: calc(max(var(--work-area-top-offset, 0px), env(safe-area-inset-top, 0px)) + var(--dock-margin) / 4);"
>
<div class="pointer-events-auto">
<GlassDock ref="dockRef" :collapse-delay="2500" :start-collapsed="true" :fit-content="true" :always-expanded="isMobile">
Expand Down Expand Up @@ -170,7 +170,7 @@ const activeLayer = computed(() => {
@update:model-value="(id) => emit('switchScene', String(id))"
>
<DockSelectTrigger aria-label="Scene" class="instrument-serif text-lg [&>span]:line-clamp-none">
<img v-if="sceneIcons[currentSceneId]" :src="sceneIcons[currentSceneId]" class="w-5 h-5 shrink-0 object-contain" />
<img v-if="sceneIcons[currentSceneId]" :src="sceneIcons[currentSceneId]" :alt="`${currentSceneId} scene`" class="w-5 h-5 shrink-0 object-contain" />
<Home v-else class="icon-sm text-muted-foreground" />
<SelectValue />
</DockSelectTrigger>
Expand All @@ -192,7 +192,7 @@ const activeLayer = computed(() => {
>
<span class="flex items-center gap-2">
<StatusDot :variant="currentSceneId === scene.id ? 'active' : 'idle'" />
<img v-if="sceneIcons[scene.id]" :src="sceneIcons[scene.id]" class="w-5 h-5 shrink-0 object-contain" />
<img v-if="sceneIcons[scene.id]" :src="sceneIcons[scene.id]" :alt="`${scene.label} scene`" class="w-5 h-5 shrink-0 object-contain" />
<span :class="currentSceneId === scene.id ? 'font-bold' : ''">{{ scene.label }}</span>
</span>
</SelectItem>
Expand All @@ -209,7 +209,7 @@ const activeLayer = computed(() => {

<!-- Collapsed state -->
<template #collapsed>
<img v-if="sceneIcons[currentSceneId]" :src="sceneIcons[currentSceneId]" class="w-5 h-5 shrink-0 object-contain" />
<img v-if="sceneIcons[currentSceneId]" :src="sceneIcons[currentSceneId]" :alt="`${currentSceneId} scene`" class="w-5 h-5 shrink-0 object-contain" />
<Home v-else class="icon-sm text-muted-foreground" />
<span class="text-lg instrument-serif font-semibold text-foreground whitespace-nowrap">
{{ currentLabel }}
Expand Down
4 changes: 4 additions & 0 deletions demo/@/components/custom/editor-shell/EditorShell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
</div>
</Transition>

<!-- The single <main> landmark (lighthouse landmark-one-main). `contents`
keeps it out of the grid's box model so the layout is unchanged. -->
<main class="contents" aria-label="Animation editor">
<AnimationControlsGroup
:key="superKey"
:animation-group="animationGroup"
Expand All @@ -56,6 +59,7 @@
<slot name="target"></slot>
</template>
</AnimationControlsGroup>
</main>

<KeyboardShortcutsModal
v-model:open="shortcutsOpen"
Expand Down
53 changes: 27 additions & 26 deletions demo/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,32 +100,33 @@
</template>

<template #target>
<!-- Scene host. Vue's documented nesting for an async scene swapped
through a transition is Transition > KeepAlive > Suspense > async
component. <Suspense> resolves the async chunk BEFORE its vnode is
handed up to KeepAlive/Transition, so the out-in transition's leave
hook never walks a torn-down (null subTree) async-wrapper subtree —
the getNextHostNode crash (Qρ F1 / Qσ D1) cannot occur.

KeepAlive caches up to 3 resolved scene instances so returning to a
scene doesn't re-evaluate lazy modules (Monaco, Three.js, etc.).
Home + Cube share the same key so CubeScene persists across home ↔ cube. -->
<Transition name="scene" mode="out-in" :css="ready">
<KeepAlive :max="3">
<Suspense :key="activeSceneKey">
<component
:is="activeSceneComponent"
ref="sceneRef"
v-bind="activeSceneProps"
/>
<template #fallback>
<div class="flex h-full w-full items-center justify-center">
<span class="instrument-serif text-lg text-muted-foreground animate-pulse">Loading scene&#x2026;</span>
</div>
</template>
</Suspense>
</KeepAlive>
</Transition>
<!-- Scene host. A keyed <Suspense> resolves the active scene's
async chunk and shows #fallback while it loads.

NO <KeepAlive>, NO wrapping <Transition>: both broke async
scene loading outright — a `<Transition mode="out-in">` /
`<KeepAlive>` around a keyed `<Suspense>` whose child is a
`defineAsyncComponent` never triggered the async loader, so
amiga / square / easing / spring shipped a BLANK viewport on
every load (the chunk was never even requested — B.W3's
headline blocker). The lazy boundary survives on the
`<Suspense>` alone (each scene's chunk + its heavy deps —
three, monaco — stay code-split); the browser module cache
covers revisits, so dropping KeepAlive costs only a cheap
scene re-setup, not a re-download. `ready` still gates the
scene-swap CSS class below so the first paint is calm. -->
<Suspense :key="activeSceneKey">
<component
:is="activeSceneComponent"
ref="sceneRef"
v-bind="activeSceneProps"
/>
<template #fallback>
<div class="flex h-full w-full items-center justify-center">
<span class="instrument-serif text-lg text-muted-foreground animate-pulse">Loading scene&#x2026;</span>
</div>
</template>
</Suspense>
</template>
</EditorShell>
</template>
Expand Down
Loading