diff --git a/go.mod b/go.mod index 1f9dc1a..53c21e3 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 9cdccd9..a2c2211 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/ui/bundle.js b/ui/bundle.js index 2980331..aefc93e 100644 --- a/ui/bundle.js +++ b/ui/bundle.js @@ -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( @@ -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({}); @@ -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); @@ -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); } @@ -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(); @@ -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 @@ -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( diff --git a/ui/bundle.test.js b/ui/bundle.test.js index 38082b4..1630bda 100644 --- a/ui/bundle.test.js +++ b/ui/bundle.test.js @@ -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", () => {