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
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
Expand All @@ -32,3 +33,5 @@ require (
// monorepo (same convention as kandev-plugin-activity-rings and
// kandev-plugin-session-cost).
replace github.com/kandev/kandev => ../kandev/apps/backend

tool github.com/kandev/kandev/cmd/plugin-pack
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
Expand Down Expand Up @@ -36,6 +38,8 @@ github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand Down
63 changes: 60 additions & 3 deletions ui/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -7487,6 +7487,11 @@ function kandyCard(h, data, celebration, care, timeOfDay, season, speech, motion
"div",
{
className: "kandev-kandy-wander",
// motion.wanderRef (when supplied) lets the widget drive the leg
// position straight on this node, so a stroll does not re-render the
// whole mascot tree ~25x/s. Legacy callers omit it and keep the
// pure, state-only transform below.
ref: motion.wanderRef,
style: { transform: "translateX(" + wanderX + "px)" },
},
h(
Expand Down Expand Up @@ -7943,6 +7948,28 @@ function makeKandyWidget(host) {
var cryEndTimerRef = React.useRef(null);
var chainTimerRef = React.useRef(null);
var lookTimerRef = React.useRef(null);
// Wander perf: during a leg only x changes, so it is pushed straight to
// the DOM (translateX on the wander wrapper) instead of re-rendering the
// whole mascot tree ~25x/s. React state still mirrors motion at leg
// boundaries (start/end, facing, cry). wanderNodesRef holds the live
// wander nodes; wanderRefCb (a stable callback ref) registers them on
// mount and, via the returned cleanup, unregisters them on unmount.
var wanderNodesRef = React.useRef(null);
if (!wanderNodesRef.current) {
var wanderRegistry = { nodes: new Set() };
wanderRegistry.cb = function (node) {
if (!node) return undefined;
wanderRegistry.nodes.add(node);
// A card mounting mid-leg must not flash the stale boundary x.
var live = motionRef.current;
node.style.transform = "translateX(" + ((live && live.x) || 0) + "px)";
return function () {
wanderRegistry.nodes.delete(node);
};
};
wanderNodesRef.current = wanderRegistry;
}
var wanderRefCb = wanderNodesRef.current.cb;
// liveRef mirrors the latest render values for the interval callbacks
// (the mount-effect closures would otherwise see mount-time state).
var liveRef = React.useRef({});
Expand Down Expand Up @@ -7977,6 +8004,16 @@ function makeKandyWidget(host) {
});
}

// applyWanderX: the imperative half of publishMotion. Writes just the
// leg position onto every live wander node, with no React render.
function applyWanderX(x) {
var reg = wanderNodesRef.current;
if (!reg) return;
reg.nodes.forEach(function (node) {
if (node && node.style) node.style.transform = "translateX(" + x + "px)";
});
}

function stopWanderFrames() {
if (wanderFrameTimerRef.current) {
clearInterval(wanderFrameTimerRef.current);
Expand Down Expand Up @@ -8019,8 +8056,13 @@ function makeKandyWidget(host) {
}
// v0.8.1: chained journeys — after a brief pause, amble on.
if (m.chainLeft > 0) scheduleChainLeg();
// Leg boundary: mirror walking=false and the final x into React.
publishMotion();
return;
}
publishMotion();
// Mid-leg: only x moves. Push it straight to the wander node(s)
// instead of re-rendering the whole mascot tree.
applyWanderX(m.x);
}, WANDER_FRAME_MS);
Comment on lines +8064 to 8066

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Overlays Lag Behind Mascot

Mid-leg updates move only the wander wrapper, leaving motionState.x at the previous boundary. Speech can appear without stopping the walk, but its sibling overlay is positioned from that stale state, so its anchor can be up to 35px away from the visible mascot. Pointer-driven gaze calculations also use the stale boundary position during a leg.

Knowledge Base Used: Client interface

}

Expand Down Expand Up @@ -8739,6 +8781,15 @@ function makeKandyWidget(host) {
}
}

// Keep the imperative wander position correct across renders triggered
// by anything other than the leg timer (data refresh, clock tick). After
// each commit this reasserts the live x while a leg is in flight, so the
// node never flashes back to the last boundary value.
(React.useLayoutEffect || React.useEffect)(function () {
var m = motionRef.current;
if (m && m.leg) applyWanderX(m.x);
});

React.useEffect(function () {
mountedRef.current = true;
load();
Expand Down Expand Up @@ -8802,7 +8853,13 @@ function makeKandyWidget(host) {
var resolvedGrottoView = tokenGrottoResolvedView(tokenGrottoModel, grottoView);
// The dialog card walks; the hover card never does.
var cardWalk = grottoTransitClass(grottoTransit, "card");
var dialogMotion = cardWalk ? Object.assign({}, motionState, { facing: 1, transit: cardWalk }) : motionState;
// Both cards carry the stable wander ref so the leg timer can move them
// imperatively; the dialog card additionally faces right and wears its
// grotto transit gait while walking between scenes.
var chipMotion = Object.assign({}, motionState, { wanderRef: wanderRefCb });
var dialogMotion = cardWalk
? Object.assign({}, motionState, { facing: 1, transit: cardWalk, wanderRef: wanderRefCb })
: Object.assign({}, motionState, { wanderRef: wanderRefCb });

// The underground Kandy travels wearing its own gait. Which scene it is
// standing in decides which leg of the trip applies to it. Asleep or still
Expand Down Expand Up @@ -9015,7 +9072,7 @@ function makeKandyWidget(host) {
// Same care wiring as the dialog: the hover card is a first-class
// surface — treat and bucket work here too. (Both cards are never
// mounted at once: the dialog's overlay blocks chip hover.)
kandyCard(h, shown, celebration, careProps, timeOfDay, currentSeason(), speech, motionState),
kandyCard(h, shown, celebration, careProps, timeOfDay, currentSeason(), speech, chipMotion),
),
),
h(
Expand Down
63 changes: 63 additions & 0 deletions ui/bundle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3446,6 +3446,69 @@ test("kandyCard motion wiring: wander layer, facing flip, gait class, tracked hi
assert.equal(parseFloat(bubbleAt(30).props.style.right), 248 - cRight.x - 26);
const cLeft = render.bonkContactFor(data, -30);
assert.equal(parseFloat(bubbleAt(-30).props.style.left), cLeft.x - 26);

// The widget threads a stable callback ref through motion.wanderRef so the
// leg timer can move this node imperatively (no per-frame re-render). When
// supplied it lands on the wander layer; omitted, it is simply undefined.
const refFn = () => {};
const withRef = render.kandyCard(jsx, data, null, care, 13, undefined, null, {
x: 0, facing: 1, walking: false, cry: 0, wanderRef: refFn,
});
assert.equal(findWander(withRef).props.ref, refFn, "motion.wanderRef lands on the wander layer");
assert.equal(findWander(idle).props.ref, undefined, "no ref when the caller omits wanderRef");
});

test("wander position is driven imperatively via a stable, self-syncing node ref", async () => {
const host = makeRerenderableWidget();
// level 1 (egg) is deterministically stationary, so the card renders with
// no dependence on the probabilistic stroll gate or the wall clock.
const kandyData = sampleKandy({ level: 1 });

const runtime = loadBundle();
runtime.plugin.initialize(
{ registerComponent: host.registerComponent, registerWsHandler() {} },
{
React: host.React,
api: {
fetch() {
return Promise.resolve({ json: () => Promise.resolve(kandyData) });
},
},
jsx,
ui: {
Dialog: "Dialog",
DialogContent: "DialogContent",
DialogTitle: "DialogTitle",
Tooltip: "Tooltip",
TooltipContent: "TooltipContent",
TooltipTrigger: "TooltipTrigger",
},
},
);

host.render(); // mount: kicks off the fetch
for (let i = 0; i < 10; i++) await Promise.resolve(); // let the fetch chain settle
let tree = host.render(); // now reflects the fetched data

const findWanderNode = (t) => findNode(t, (n) => n.props && n.props.className === "kandev-kandy-wander");
const wander = findWanderNode(tree);
assert.ok(wander, "the card exposes the wander layer");
const ref = wander.props.ref;
assert.equal(typeof ref, "function", "the wander layer carries a callback ref");

// Registering a node syncs it to the current position immediately, so a
// card mounting mid-stroll never flashes the stale boundary x.
const node = { style: {} };
const cleanup = ref(node);
assert.equal(node.style.transform, "translateX(0px)", "the ref seeds the node position on attach");
assert.equal(typeof cleanup, "function", "the ref returns an unregister cleanup");

// The ref identity is stable across renders, so React never detaches and
// reattaches the node between commits (which is what caused the churn).
tree = host.render();
assert.equal(findWanderNode(tree).props.ref, ref, "the wander ref is stable across renders");

cleanup(); // unregisters without throwing
});

test("kandyCard cry wiring: sob + tears only when stationary, awake, undisturbed", () => {
Expand Down
Loading