From a7db14d3c7f9a7715546dcc1ce46f1f29048b34f Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:49:03 +0200 Subject: [PATCH 1/2] fix(thing): refine diagonal boop response --- src/geometry.ts | 64 +++++++++++++--- src/thing-controller.ts | 25 +++++-- tests/privacy-thing-logo.test.ts | 121 ++++++++++++++++++++++++++++++- 3 files changed, 188 insertions(+), 22 deletions(-) diff --git a/src/geometry.ts b/src/geometry.ts index 1d936b5..90c185c 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -86,6 +86,8 @@ const HEAD_PATH_POINTS = { const BOOP_CURVE_TENSION = 0.276142; const BOOP_DIAGONAL_CONTACT_DEPTH = 3.2; +const BOOP_DIAGONAL_CONTACT_TENSION_SCALE = 0.62; +const BOOP_DIAGONAL_OPPOSITE_BULGE = 14; const formatThingPoint = ([x, y]: ThingPoint) => `${Number(x.toFixed(3))} ${Number(y.toFixed(3))}`; @@ -95,6 +97,7 @@ const distortBoopPoint = ( direction: Readonly<{ x: number; y: number }>, intensity: number, contactDepth: number, + oppositeBulge: number, ): ThingPoint => { const normalizedX = (x - 349.185) / 134.965; const normalizedY = (y - 322) / 134.965; @@ -106,11 +109,11 @@ const distortBoopPoint = ( const oppositeWeight = Math.max(-facing, 0) ** 2; const sideWeight = Math.max(1 - Math.abs(facing), 0) ** 2; const directionalOffset = (-30 * cursorWeight * contactDepth - 13 * oppositeWeight) * intensity; - const sideBulge = 11 * sideWeight * intensity; + const radialBulge = (11 * sideWeight + oppositeBulge * oppositeWeight) * intensity; return [ - x + direction.x * directionalOffset + radialX * sideBulge, - y + direction.y * directionalOffset + radialY * sideBulge, + x + direction.x * directionalOffset + radialX * radialBulge, + y + direction.y * directionalOffset + radialY * radialBulge, ]; }; @@ -120,26 +123,63 @@ const subtractThingPoints = ([leftX, leftY]: ThingPoint, [rightX, rightY]: Thing [leftX - rightX, leftY - rightY]; const scaleThingPoint = ([x, y]: ThingPoint, scale: number): ThingPoint => [x * scale, y * scale]; +const getDiagonalContactAnchors = (pose: Exclude) => { + switch (pose) { + case "north-east": + return [0, 1] as const; + case "south-east": + return [1, 2] as const; + case "south-west": + return [2, 3] as const; + case "north-west": + return [3, 0] as const; + default: + return null; + } +}; + +const createBoopCurve = ( + start: ThingPoint, + startTangent: ThingPoint, + end: ThingPoint, + endTangent: ThingPoint, + startTensionScale: number, + endTensionScale: number, +) => [ + `C${formatThingPoint(addThingPoints(start, scaleThingPoint(startTangent, startTensionScale)))}`, + formatThingPoint(subtractThingPoints(end, scaleThingPoint(endTangent, endTensionScale))), + formatThingPoint(end), +].join(" "); + export const createBoopHeadPath = ( pose: Exclude, intensity: number, ) => { const direction = HOVER_REACTION_DIRECTION_VECTORS[pose === "idle" ? "east" : pose]; - const contactDepth = pose.includes("-") ? BOOP_DIAGONAL_CONTACT_DEPTH : 1; - const top = distortBoopPoint(HEAD_PATH_POINTS.top, direction, intensity, contactDepth); - const right = distortBoopPoint(HEAD_PATH_POINTS.right, direction, intensity, contactDepth); - const bottom = distortBoopPoint(HEAD_PATH_POINTS.bottom, direction, intensity, contactDepth); - const left = distortBoopPoint(HEAD_PATH_POINTS.left, direction, intensity, contactDepth); + const isDiagonal = pose.includes("-"); + const contactDepth = isDiagonal ? BOOP_DIAGONAL_CONTACT_DEPTH : 1; + const oppositeBulge = isDiagonal ? BOOP_DIAGONAL_OPPOSITE_BULGE : 0; + const top = distortBoopPoint(HEAD_PATH_POINTS.top, direction, intensity, contactDepth, oppositeBulge); + const right = distortBoopPoint(HEAD_PATH_POINTS.right, direction, intensity, contactDepth, oppositeBulge); + const bottom = distortBoopPoint(HEAD_PATH_POINTS.bottom, direction, intensity, contactDepth, oppositeBulge); + const left = distortBoopPoint(HEAD_PATH_POINTS.left, direction, intensity, contactDepth, oppositeBulge); const topTangent = scaleThingPoint(subtractThingPoints(right, left), BOOP_CURVE_TENSION); const rightTangent = scaleThingPoint(subtractThingPoints(bottom, top), BOOP_CURVE_TENSION); const bottomTangent = scaleThingPoint(subtractThingPoints(left, right), BOOP_CURVE_TENSION); const leftTangent = scaleThingPoint(subtractThingPoints(top, bottom), BOOP_CURVE_TENSION); + const contactAnchors = getDiagonalContactAnchors(pose); + const positiveIntensity = Math.min(Math.max(intensity, 0), 1); + const contactTensionScale = 1 - positiveIntensity * (1 - BOOP_DIAGONAL_CONTACT_TENSION_SCALE); + const anchorTension = (anchor: number) => + contactAnchors && (contactAnchors[0] === anchor || contactAnchors[1] === anchor) + ? contactTensionScale + : 1; return [ `M${formatThingPoint(top)}`, - `C${formatThingPoint(addThingPoints(top, topTangent))} ${formatThingPoint(subtractThingPoints(right, rightTangent))} ${formatThingPoint(right)}`, - `C${formatThingPoint(addThingPoints(right, rightTangent))} ${formatThingPoint(subtractThingPoints(bottom, bottomTangent))} ${formatThingPoint(bottom)}`, - `C${formatThingPoint(addThingPoints(bottom, bottomTangent))} ${formatThingPoint(subtractThingPoints(left, leftTangent))} ${formatThingPoint(left)}`, - `C${formatThingPoint(addThingPoints(left, leftTangent))} ${formatThingPoint(subtractThingPoints(top, topTangent))} ${formatThingPoint(top)}Z`, + createBoopCurve(top, topTangent, right, rightTangent, anchorTension(0), anchorTension(1)), + createBoopCurve(right, rightTangent, bottom, bottomTangent, anchorTension(1), anchorTension(2)), + createBoopCurve(bottom, bottomTangent, left, leftTangent, anchorTension(2), anchorTension(3)), + `${createBoopCurve(left, leftTangent, top, topTangent, anchorTension(3), anchorTension(0))}Z`, ].join(""); }; diff --git a/src/thing-controller.ts b/src/thing-controller.ts index db94281..519f8df 100644 --- a/src/thing-controller.ts +++ b/src/thing-controller.ts @@ -22,6 +22,7 @@ export const BRAND_THING_HOVER_REACTION_TIMING = { const THING_VIEWBOX_WIDTH = 346; const THING_VIEWBOX_HEIGHT = 306; +const DIAGONAL_BOOP_EYE_AVOIDANCE_PX = 6; const formatTranslationPercent = (value: number, dimension: number) => `${Number(((value / dimension) * 100).toFixed(6))}%`; @@ -175,7 +176,7 @@ export class ThingController { this.#root.dataset.hoverReactionSequence = String(this.#sequence); this.#root.dataset.hoverReactionDirection = direction; this.#setPhase("notice"); - this.#applyPose(direction); + this.#applyPose(direction.includes("-") ? "idle" : direction); this.#removeLookAnimations(); const head = this.#root.querySelector("[data-thing-head]"); @@ -193,11 +194,14 @@ export class ThingController { } if (svg) { const vector = HOVER_REACTION_DIRECTION_VECTORS[direction]; + const isDiagonal = direction.includes("-"); + const recoilTravel = isDiagonal ? 0 : 3.2; + const settleTravel = isDiagonal ? 0 : 1.024; this.#animateElement(svg, [ { offset: 0, transform: readAnimatedTransform(svg) }, { offset: 0.18, transform: `translate(${vector.x * 0.45}%, ${vector.y * 0.45}%) scale(1.015, .985)` }, - { offset: 0.42, transform: `translate(${vector.x * -3.2}%, ${vector.y * -3.2}%) scale(.985, 1.015)` }, - { offset: 0.66, transform: `translate(${vector.x * -1.024}%, ${vector.y * -1.024}%) scale(1.012, .988)` }, + { offset: 0.42, transform: `translate(${vector.x * -recoilTravel}%, ${vector.y * -recoilTravel}%) scale(.985, 1.015)` }, + { offset: 0.66, transform: `translate(${vector.x * -settleTravel}%, ${vector.y * -settleTravel}%) scale(1.012, .988)` }, { offset: 0.82, transform: "translate(0, 0) scale(.996, 1.004)" }, { offset: 1, transform: "translate(0, 0) scale(1)" }, ], { @@ -205,13 +209,20 @@ export class ThingController { easing: "cubic-bezier(.22, 1, .36, 1)", }); } + const vector = HOVER_REACTION_DIRECTION_VECTORS[direction]; + const eyeAvoidance = direction.includes("-") ? DIAGONAL_BOOP_EYE_AVOIDANCE_PX : 0; + const eyeAvoidanceX = -vector.x * eyeAvoidance; + const eyeAvoidanceY = -vector.y * eyeAvoidance; + const eyeTransform = (progress: number, scaleY: number) => + `translate(${eyeAvoidanceX * progress}px, ${eyeAvoidanceY * progress}px) scaleY(${scaleY})`; this.#root.querySelectorAll("[data-hover-reaction-eye]").forEach((eye) => { this.#animateElement(eye, [ { offset: 0, transform: readAnimatedTransform(eye) }, - { offset: 0.16, transform: "scaleY(1)" }, - { offset: 0.34, transform: "scaleY(.12)" }, - { offset: 0.54, transform: "scaleY(1)" }, - { offset: 1, transform: "scaleY(1)" }, + { offset: 0.16, transform: eyeTransform(0.35, 1) }, + { offset: 0.34, transform: eyeTransform(1, 0.12) }, + { offset: 0.54, transform: eyeTransform(0.7, 1) }, + { offset: 0.78, transform: eyeTransform(0, 1) }, + { offset: 1, transform: eyeTransform(0, 1) }, ], { duration: 620, easing: "linear" }); }); diff --git a/tests/privacy-thing-logo.test.ts b/tests/privacy-thing-logo.test.ts index c59ea13..a845380 100644 --- a/tests/privacy-thing-logo.test.ts +++ b/tests/privacy-thing-logo.test.ts @@ -181,6 +181,46 @@ describe("@privacy-thing/brand", () => { await Promise.resolve(); }); + it("moves diagonal Boop eyes away from the pointer", () => { + vi.useFakeTimers(); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(RECT); + const animateDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "animate"); + const animate = vi.fn((_keyframes: Keyframe[]) => ({ cancel: vi.fn() }) as unknown as Animation); + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: animate, + }); + + try { + const element = createPrivacyThingLogo({ variant: "thing", hoverReaction: "boop" }); + document.body.append(element); + const thing = getThing(element); + if (!thing) throw new Error("Thing did not render."); + + dispatchPointer(thing, "pointerenter", -20, 120); + expect(thing.dataset.hoverReactionDirection).toBe("south-west"); + expect(thing.dataset.pose).toBe("idle"); + const eyeAnimations = animate.mock.calls + .map(([keyframes]) => keyframes) + .filter((keyframes) => keyframes.some((keyframe) => keyframe.offset === 0.34)); + + expect(eyeAnimations).toHaveLength(2); + for (const keyframes of eyeAnimations) { + const peak = keyframes.find((keyframe) => keyframe.offset === 0.34); + expect(peak?.transform).toBe("translate(4.242px, -4.242px) scaleY(0.12)"); + } + element.remove(); + expect(vi.getTimerCount()).toBe(0); + } finally { + if (animateDescriptor) { + Object.defineProperty(Element.prototype, "animate", animateDescriptor); + } else { + Reflect.deleteProperty(Element.prototype, "animate"); + } + vi.useRealTimers(); + } + }); + it("keeps Boop curves tangent-continuous in every direction", () => { for (const pose of BRAND_THING_POSES.filter((value) => value !== "idle" && value !== "zz")) { const coordinates = createBoopHeadPath(pose, 1).match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; @@ -199,10 +239,85 @@ describe("@privacy-thing/brand", () => { const outgoingX = (coordinates[join.outgoing] ?? 0) - anchorX; const outgoingY = (coordinates[join.outgoing + 1] ?? 0) - anchorY; const cross = incomingX * outgoingY - incomingY * outgoingX; - const lengths = Math.hypot(incomingX, incomingY) * Math.hypot(outgoingX, outgoingY); - expect(lengths).toBeGreaterThan(0); - expect(Math.abs(cross) / lengths).toBeLessThan(0.0001); + const incomingLength = Math.hypot(incomingX, incomingY); + const outgoingLength = Math.hypot(outgoingX, outgoingY); + const lengthProduct = incomingLength * outgoingLength; + expect(lengthProduct).toBeGreaterThan(0); + expect(Math.abs(cross) / lengthProduct).toBeLessThan(0.0001); + expect(Math.abs(incomingLength - outgoingLength) / Math.max(incomingLength, outgoingLength)).toBeLessThan(0.0001); + } + } + }); + + it("rounds the contact edge and transfers diagonal Boop volume to the opposite corner", () => { + const cases = [ + { pose: "north-east", segment: 0, oppositeAnchors: [2, 3], direction: [Math.SQRT1_2, -Math.SQRT1_2] }, + { pose: "south-east", segment: 1, oppositeAnchors: [3, 0], direction: [Math.SQRT1_2, Math.SQRT1_2] }, + { pose: "south-west", segment: 2, oppositeAnchors: [0, 1], direction: [-Math.SQRT1_2, Math.SQRT1_2] }, + { pose: "north-west", segment: 3, oppositeAnchors: [1, 2], direction: [-Math.SQRT1_2, -Math.SQRT1_2] }, + ] as const; + const readSegments = (path: string) => { + const coordinates = path.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + const segments = []; + let start = [coordinates[0] ?? 0, coordinates[1] ?? 0] as const; + for (let index = 0; index < 4; index += 1) { + const offset = 2 + index * 6; + const segment = { + start, + control1: [coordinates[offset] ?? 0, coordinates[offset + 1] ?? 0] as const, + control2: [coordinates[offset + 2] ?? 0, coordinates[offset + 3] ?? 0] as const, + end: [coordinates[offset + 4] ?? 0, coordinates[offset + 5] ?? 0] as const, + }; + segments.push(segment); + start = segment.end; + } + return segments; + }; + const midpoint = (segment: ReturnType[number]) => [ + (segment.start[0] + 3 * segment.control1[0] + 3 * segment.control2[0] + segment.end[0]) / 8, + (segment.start[1] + 3 * segment.control1[1] + 3 * segment.control2[1] + segment.end[1]) / 8, + ] as const; + const samplePoint = (segment: ReturnType[number], time: number) => { + const inverse = 1 - time; + const startWeight = inverse ** 3; + const control1Weight = 3 * inverse ** 2 * time; + const control2Weight = 3 * inverse * time ** 2; + const endWeight = time ** 3; + return [ + segment.start[0] * startWeight + segment.control1[0] * control1Weight + segment.control2[0] * control2Weight + segment.end[0] * endWeight, + segment.start[1] * startWeight + segment.control1[1] * control1Weight + segment.control2[1] * control2Weight + segment.end[1] * endWeight, + ] as const; + }; + const project = (point: readonly [number, number], direction: readonly [number, number]) => + (point[0] - 349.185) * direction[0] + (point[1] - 322) * direction[1]; + const radialDistance = (point: readonly [number, number]) => + Math.hypot(point[0] - 349.185, point[1] - 322); + + for (const { pose, segment, oppositeAnchors, direction } of cases) { + const baseSegments = readSegments(createBoopHeadPath(pose, 0)); + const compressedSegments = readSegments(createBoopHeadPath(pose, 1)); + const base = baseSegments[segment]; + const compressed = compressedSegments[segment]; + if (!base || !compressed) throw new Error(`Missing ${pose} contact curve.`); + const baseProjection = project(midpoint(base), direction); + const compressedProjection = project(midpoint(compressed), direction); + const compressedChordMidpoint = [ + (compressed.start[0] + compressed.end[0]) / 2, + (compressed.start[1] + compressed.end[1]) / 2, + ] as const; + + expect(baseProjection - compressedProjection).toBeGreaterThan(50); + expect(compressedProjection - project(compressedChordMidpoint, direction)).toBeLessThan(24); + for (const anchor of oppositeAnchors) { + const baseAnchor = baseSegments[anchor]?.start; + const compressedAnchor = compressedSegments[anchor]?.start; + if (!baseAnchor || !compressedAnchor) throw new Error(`Missing ${pose} opposite anchor.`); + expect(radialDistance(compressedAnchor) - radialDistance(baseAnchor)).toBeGreaterThan(10); } + const verticalCoordinates = compressedSegments.flatMap((pathSegment) => + Array.from({ length: 101 }, (_, index) => samplePoint(pathSegment, index / 100)[1])); + expect(Math.min(...verticalCoordinates)).toBeGreaterThanOrEqual(166); + expect(Math.max(...verticalCoordinates)).toBeLessThanOrEqual(470); } }); From 1ecf00a3650216f07c2d00908b6ba53b8d7616dc Mon Sep 17 00:00:00 2001 From: Tomasz Janusz <424941+TomaszJanusz@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:38:14 +0200 Subject: [PATCH 2/2] feat(thing): add event-driven gaze commands --- README.md | 31 ++++++++++++ package.json | 2 +- src/index.ts | 4 ++ src/privacy-thing-logo.ts | 34 +++++++++++++ src/thing-controller.ts | 47 +++++++++++++----- src/types.ts | 8 ++++ tests/privacy-thing-logo.test.ts | 82 ++++++++++++++++++++------------ 7 files changed, 164 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 2d45c59..fc85d15 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,37 @@ const icon = createPrivacyThingLogo({ registry. `createPrivacyThingLogo()` creates and configures an element in an optional owner document. +### Event commands + +Dispatch `privacy-thing:command` directly on a logo element to control its +animated Thing without replacing the SVG. The package helper keeps the event +name and payload typed: + +```ts +import { + dispatchPrivacyThingCommand, + type PrivacyThingLogoElement, +} from "@privacy-thing/brand"; + +const logo = document.querySelector( + "privacy-thing-logo", +); + +if (logo) { + dispatchPrivacyThingCommand(logo, { + type: "look", + direction: "north-east", + }); + + dispatchPrivacyThingCommand(logo, { type: "reset" }); +} +``` + +`look` accepts `idle` and all eight directions. It uses the configured pose +transition and can be superseded by later pointer interaction. `reset` returns +to the declarative `pose`. A configured `zz` scene remains authoritative and +ignores look commands. + ## Styling and composition - The complete logo uses `currentColor`. diff --git a/package.json b/package.json index 66b0672..461b15e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@privacy-thing/brand", - "version": "0.1.1", + "version": "0.1.2", "description": "Framework-agnostic Privacy Thing logos and animated Thing runtime.", "type": "module", "license": "UNLICENSED", diff --git a/src/index.ts b/src/index.ts index 50f45a6..d392c03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { createPrivacyThingLogo, definePrivacyThingLogo, + dispatchPrivacyThingCommand, + PRIVACY_THING_COMMAND_EVENT, } from "./privacy-thing-logo.js"; export { createBoopHeadPath } from "./geometry.js"; export { @@ -25,8 +27,10 @@ export { export type { BrandThingDirectionPose, BrandThingHoverReaction, + BrandThingLookDirection, BrandThingPose, BrandThingTiming, + PrivacyThingCommand, PrivacyThingLogoElement, PrivacyThingLogoOptions, PrivacyThingLogoVariant, diff --git a/src/privacy-thing-logo.ts b/src/privacy-thing-logo.ts index 64fd313..82868d0 100644 --- a/src/privacy-thing-logo.ts +++ b/src/privacy-thing-logo.ts @@ -8,12 +8,14 @@ import { type BrandThingHoverReaction, type BrandThingPose, type BrandThingTiming, + type PrivacyThingCommand, type PrivacyThingLogoElement, type PrivacyThingLogoOptions, type PrivacyThingLogoVariant, } from "./types.js"; const DEFAULT_LABEL = "Privacy Thing"; +export const PRIVACY_THING_COMMAND_EVENT = "privacy-thing:command" as const; let instanceSequence = 0; const BOOLEAN_ATTRIBUTES = [ @@ -35,6 +37,8 @@ const isPose = (value: string | null): value is BrandThingPose => value !== null && (BRAND_THING_POSES as readonly string[]).includes(value); const isHoverReaction = (value: string | null): value is BrandThingHoverReaction => value !== null && (BRAND_THING_HOVER_REACTIONS as readonly string[]).includes(value); +const isLookDirection = (value: unknown): value is Exclude => + typeof value === "string" && value !== "zz" && isPose(value); const createElementClass = () => class PrivacyThingLogoElementImpl extends HTMLElement @@ -57,11 +61,13 @@ const createElementClass = () => class PrivacyThingLogoElementImpl connectedCallback() { this.#connected = true; + this.addEventListener(PRIVACY_THING_COMMAND_EVENT, this.#onCommand); this.#render(); } disconnectedCallback() { this.#connected = false; + this.removeEventListener(PRIVACY_THING_COMMAND_EVENT, this.#onCommand); this.#thingController?.disconnect(); this.#thingController = null; } @@ -141,6 +147,22 @@ const createElementClass = () => class PrivacyThingLogoElementImpl this.#queueRender(); } + #onCommand = (event: Event) => { + const command = (event as CustomEvent).detail; + if (!command || typeof command !== "object" || !("type" in command)) return; + if (command.type === "reset") { + this.#thingController?.resetLook(); + return; + } + if ( + command.type === "look" + && "direction" in command + && isLookDirection(command.direction) + ) { + this.#thingController?.look(command.direction); + } + }; + #queueRender() { if (!this.#connected || this.#renderQueued) return; this.#renderQueued = true; @@ -221,3 +243,15 @@ export const createPrivacyThingLogo = ( element.configure(options); return element; }; + +export const dispatchPrivacyThingCommand = ( + element: PrivacyThingLogoElement, + command: PrivacyThingCommand, +) => { + const EventConstructor = element.ownerDocument.defaultView?.CustomEvent; + if (!EventConstructor) return false; + return element.dispatchEvent(new EventConstructor( + PRIVACY_THING_COMMAND_EVENT, + { detail: command }, + )); +}; diff --git a/src/thing-controller.ts b/src/thing-controller.ts index 519f8df..8835d16 100644 --- a/src/thing-controller.ts +++ b/src/thing-controller.ts @@ -22,10 +22,24 @@ export const BRAND_THING_HOVER_REACTION_TIMING = { const THING_VIEWBOX_WIDTH = 346; const THING_VIEWBOX_HEIGHT = 306; -const DIAGONAL_BOOP_EYE_AVOIDANCE_PX = 6; const formatTranslationPercent = (value: number, dimension: number) => `${Number(((value / dimension) * 100).toFixed(6))}%`; +const getBoopEyePose = (direction: BrandThingDirectionPose | "idle") => { + switch (direction) { + case "north-east": + return "south-west"; + case "south-east": + return "north-west"; + case "south-west": + return "north-east"; + case "north-west": + return "south-east"; + default: + return direction; + } +}; + const readAnimatedPath = (path: SVGPathElement) => { const computedPath = window.getComputedStyle(path).getPropertyValue("d"); const match = computedPath?.match(/^path\((["']?)(.*)\1\)$/); @@ -63,6 +77,20 @@ export class ThingController { this.#render(); } + look(direction: Exclude) { + if (this.#options.pose === "zz") return; + this.#clearTimers(); + this.#cancelAnimations(); + this.#active = false; + this.#tracking = false; + this.#setPhase("idle"); + this.#applyPose(direction); + } + + resetLook() { + if (this.#options.pose !== "zz") this.look(this.#options.pose); + } + disconnect() { this.#teardownRuntime(); this.#host.replaceChildren(); @@ -176,7 +204,7 @@ export class ThingController { this.#root.dataset.hoverReactionSequence = String(this.#sequence); this.#root.dataset.hoverReactionDirection = direction; this.#setPhase("notice"); - this.#applyPose(direction.includes("-") ? "idle" : direction); + this.#applyPose(getBoopEyePose(direction)); this.#removeLookAnimations(); const head = this.#root.querySelector("[data-thing-head]"); @@ -209,20 +237,13 @@ export class ThingController { easing: "cubic-bezier(.22, 1, .36, 1)", }); } - const vector = HOVER_REACTION_DIRECTION_VECTORS[direction]; - const eyeAvoidance = direction.includes("-") ? DIAGONAL_BOOP_EYE_AVOIDANCE_PX : 0; - const eyeAvoidanceX = -vector.x * eyeAvoidance; - const eyeAvoidanceY = -vector.y * eyeAvoidance; - const eyeTransform = (progress: number, scaleY: number) => - `translate(${eyeAvoidanceX * progress}px, ${eyeAvoidanceY * progress}px) scaleY(${scaleY})`; this.#root.querySelectorAll("[data-hover-reaction-eye]").forEach((eye) => { this.#animateElement(eye, [ { offset: 0, transform: readAnimatedTransform(eye) }, - { offset: 0.16, transform: eyeTransform(0.35, 1) }, - { offset: 0.34, transform: eyeTransform(1, 0.12) }, - { offset: 0.54, transform: eyeTransform(0.7, 1) }, - { offset: 0.78, transform: eyeTransform(0, 1) }, - { offset: 1, transform: eyeTransform(0, 1) }, + { offset: 0.16, transform: "scaleY(1)" }, + { offset: 0.34, transform: "scaleY(.12)" }, + { offset: 0.54, transform: "scaleY(1)" }, + { offset: 1, transform: "scaleY(1)" }, ], { duration: 620, easing: "linear" }); }); diff --git a/src/types.ts b/src/types.ts index 370cd14..a8fcf57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,6 +33,14 @@ export const BRAND_THING_DIRECTION_POSES = BRAND_THING_POSES.filter( export type BrandThingDirectionPose = typeof BRAND_THING_DIRECTION_POSES[number]; +/** Awake gaze directions accepted by imperative Thing commands. */ +export type BrandThingLookDirection = Exclude; + +/** Framework-agnostic commands dispatched directly to a logo element. */ +export type PrivacyThingCommand = + | Readonly<{ type: "look"; direction: BrandThingLookDirection }> + | Readonly<{ type: "reset" }>; + export const BRAND_THING_HOVER_REACTIONS = ["none", "boop"] as const; export type BrandThingHoverReaction = typeof BRAND_THING_HOVER_REACTIONS[number]; diff --git a/tests/privacy-thing-logo.test.ts b/tests/privacy-thing-logo.test.ts index a845380..8bc12c6 100644 --- a/tests/privacy-thing-logo.test.ts +++ b/tests/privacy-thing-logo.test.ts @@ -7,6 +7,7 @@ import { createBoopHeadPath, createPrivacyThingLogo, definePrivacyThingLogo, + dispatchPrivacyThingCommand, renderStaticLogo, resolveThingPointerDirection, resolveThingPointerPose, @@ -109,6 +110,36 @@ describe("@privacy-thing/brand", () => { expect(element.shadowRoot?.querySelectorAll('[data-motion-part="zz-drift"]')).toHaveLength(4); }); + it("accepts typed look and reset commands without rebuilding Thing", () => { + const element = createPrivacyThingLogo({ variant: "thing", pose: "west" }); + document.body.append(element); + const thing = getThing(element); + + expect(dispatchPrivacyThingCommand(element, { + type: "look", + direction: "north-east", + })).toBe(true); + expect(getThing(element)).toBe(thing); + expect(thing?.dataset.pose).toBe("north-east"); + expect(thing?.querySelector('[data-eye-side="left"]')?.getAttribute("transform")) + .toBe("translate(30 -20)"); + expect(thing?.querySelector('[data-eye-side="right"]')?.getAttribute("transform")) + .toBe("translate(30 -20)"); + + dispatchPrivacyThingCommand(element, { type: "reset" }); + expect(getThing(element)).toBe(thing); + expect(thing?.dataset.pose).toBe("west"); + }); + + it("keeps Zz authoritative over look commands", () => { + const element = createPrivacyThingLogo({ variant: "thing", pose: "zz" }); + document.body.append(element); + + dispatchPrivacyThingCommand(element, { type: "look", direction: "east" }); + expect(getThing(element)?.dataset.pose).toBe("zz"); + expect(element.shadowRoot?.querySelector('[data-scene="zz"]')).not.toBeNull(); + }); + it("keeps the Zz scene static under reduced motion", () => { const element = createPrivacyThingLogo({ variant: "thing", @@ -181,42 +212,33 @@ describe("@privacy-thing/brand", () => { await Promise.resolve(); }); - it("moves diagonal Boop eyes away from the pointer", () => { + it("maps diagonal Boop eyes to the opposite gaze pose", () => { vi.useFakeTimers(); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(RECT); - const animateDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "animate"); - const animate = vi.fn((_keyframes: Keyframe[]) => ({ cancel: vi.fn() }) as unknown as Animation); - Object.defineProperty(Element.prototype, "animate", { - configurable: true, - value: animate, - }); - try { - const element = createPrivacyThingLogo({ variant: "thing", hoverReaction: "boop" }); - document.body.append(element); - const thing = getThing(element); - if (!thing) throw new Error("Thing did not render."); - - dispatchPointer(thing, "pointerenter", -20, 120); - expect(thing.dataset.hoverReactionDirection).toBe("south-west"); - expect(thing.dataset.pose).toBe("idle"); - const eyeAnimations = animate.mock.calls - .map(([keyframes]) => keyframes) - .filter((keyframes) => keyframes.some((keyframe) => keyframe.offset === 0.34)); - - expect(eyeAnimations).toHaveLength(2); - for (const keyframes of eyeAnimations) { - const peak = keyframes.find((keyframe) => keyframe.offset === 0.34); - expect(peak?.transform).toBe("translate(4.242px, -4.242px) scaleY(0.12)"); + const cases = [ + { input: [-20, -20], direction: "north-west", gaze: "south-east", eyeTranslation: "translate(30 21)" }, + { input: [120, -20], direction: "north-east", gaze: "south-west", eyeTranslation: "translate(-30 21)" }, + { input: [120, 120], direction: "south-east", gaze: "north-west", eyeTranslation: "translate(-30 -20)" }, + { input: [-20, 120], direction: "south-west", gaze: "north-east", eyeTranslation: "translate(30 -20)" }, + ] as const; + + for (const { input, direction, gaze, eyeTranslation } of cases) { + const element = createPrivacyThingLogo({ variant: "thing", hoverReaction: "boop" }); + document.body.append(element); + const thing = getThing(element); + if (!thing) throw new Error("Thing did not render."); + + dispatchPointer(thing, "pointerenter", input[0], input[1]); + expect(thing.dataset.hoverReactionDirection).toBe(direction); + expect(thing.dataset.pose).toBe(gaze); + expect(thing.querySelector('[data-eye-side="left"]')?.getAttribute("transform")).toBe(eyeTranslation); + expect(thing.querySelector('[data-eye-side="right"]')?.getAttribute("transform")).toBe(eyeTranslation); + expect(element.shadowRoot?.querySelector('[data-motion-part="eye-boop"]')).toBeNull(); + element.remove(); } - element.remove(); expect(vi.getTimerCount()).toBe(0); } finally { - if (animateDescriptor) { - Object.defineProperty(Element.prototype, "animate", animateDescriptor); - } else { - Reflect.deleteProperty(Element.prototype, "animate"); - } vi.useRealTimers(); } });