From a4306c8cad79af5a9aecfbbf915c519ef369d040 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:10:52 +0100 Subject: [PATCH 01/11] fix agent zoo demo broken (#8) --- demo/zoo.ts | 69 +++++++++++++++++++++++++++++++++++++++ index.html | 2 +- zoo.html | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 demo/zoo.ts create mode 100644 zoo.html diff --git a/demo/zoo.ts b/demo/zoo.ts new file mode 100644 index 0000000..6fc35b4 --- /dev/null +++ b/demo/zoo.ts @@ -0,0 +1,69 @@ +import { initAgent } from "../src/index.ts"; +import * as agents from "../src/agents/index.ts"; + +const stage = document.getElementById("zoo-stage") as HTMLDivElement; +const status = document.getElementById("zoo-status") as HTMLSpanElement; +const count = document.getElementById("zoo-count") as HTMLSpanElement; +const animateAllBtn = document.getElementById("animate-all") as HTMLButtonElement; +const speakAllBtn = document.getElementById("speak-all") as HTMLButtonElement; +const resetAllBtn = document.getElementById("reset-all") as HTMLButtonElement; + +const entries = Object.entries(agents); +const zooAgents: { name: string; agent: any; x: number; y: number }[] = []; + +function layoutFor(index: number) { + const columns = 4; + const cellWidth = 230; + const cellHeight = 170; + const x = 36 + (index % columns) * cellWidth; + const y = 40 + Math.floor(index / columns) * cellHeight; + return { x, y }; +} + +async function loadZoo() { + status.textContent = "Loading all agents..."; + + for (const [index, [name, loader]] of entries.entries()) { + const position = layoutFor(index); + const agent = await initAgent(loader); + + agent.show(true); + agent.moveTo(position.x, position.y, 0); + + zooAgents.push({ name, agent, x: position.x, y: position.y }); + } + + count.textContent = `${zooAgents.length} agents`; + status.textContent = "All agents loaded."; +} + +animateAllBtn.addEventListener("click", () => { + for (const entry of zooAgents) { + entry.agent.animate(); + } + status.textContent = "Animating all agents."; +}); + +speakAllBtn.addEventListener("click", () => { + zooAgents.forEach((entry, index) => { + window.setTimeout(() => { + entry.agent.speak(`Hello from ${entry.name}.`); + entry.agent.animate(); + }, index * 250); + }); + status.textContent = "Starting roll call."; +}); + +resetAllBtn.addEventListener("click", () => { + for (const entry of zooAgents) { + entry.agent.stop(); + entry.agent.moveTo(entry.x, entry.y, 0); + } + status.textContent = "Reset all agents."; +}); + +loadZoo().catch((error) => { + console.error(error); + status.textContent = "Failed to load the zoo demo."; + stage.textContent = error instanceof Error ? error.message : String(error); +}); diff --git a/index.html b/index.html index 4024e5e..f52ad22 100755 --- a/index.html +++ b/index.html @@ -115,7 +115,7 @@

Welcome to Clippy.js!

This is a demo. Click an agent below to try it out.
See GitHub for usage and - docs. + docs. Want them all at once? Open the agent zoo.

diff --git a/zoo.html b/zoo.html new file mode 100644 index 0000000..ebc1f88 --- /dev/null +++ b/zoo.html @@ -0,0 +1,93 @@ + + + + + + 📎 Clippy Agent Zoo + + + + +
+
+
+ Clippy.js Agent Zoo +
+ +
+
+
+

All agents, one page

+

+ This page restores the agent zoo demo so every bundled assistant can be previewed + together. +

+
+ + + +
+

+ Loading agents... +

+
+
+
+ 0 agents + + Back to main demo + +
+
+
+ + + + From b0b07dabf29fb9e5aee0c0dadb010325e203d36c Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:31:21 +0100 Subject: [PATCH 02/11] Hallo World --- AGENTS.md | 2 +- README.md | 5 ++++- src/agent.ts | 2 +- src/balloon.ts | 28 +++++++++++++++++++++------- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ff15fc..ad72256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## Overview -ClippyJS is a modern ESM rewrite of [Clippy.JS](http://smore.com/clippy-js) — it adds nostalgic Windows 98-style animated assistant characters (Clippy and friends) to any website. Zero runtime dependencies, fully tree-shakeable, lazy-loaded agents with embedded sprites and sounds. +ClippyJS is a modern ESM rewrite of [Clippy.JS](http://smor2.com/clippy-js) — it adds nostalgic Windows 98-style animated assistant characters (Clippy and friends) to any website. Zero runtime dependencies, fully tree-shakeable, lazy-loaded agents with embedded sprites and sounds. - **Package:** `clippyjs` (npm) - **Repository:** `pi0/clippyjs` diff --git a/README.md b/README.md index 5b57f61..29fcef7 100755 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ agent.speak("Hello! I'm here to help.", { tts: true }); // Keep the balloon open until manually closed agent.speak("Read this carefully.", { hold: true }); +// Close a held balloon and allow queued actions to continue +agent.closeBalloon(); + // Move to a given point, using animation if available agent.moveTo(100, 100); @@ -116,7 +119,7 @@ Each agent has a unique voice personality using the [Web Speech API](https://dev agent.speak("Hello! I'm Clippy, your virtual assistant.", { tts: true }); ``` -# License +## License [MIT](./LICENCE) diff --git a/src/agent.ts b/src/agent.ts index 6ac2205..0f2f03f 100755 --- a/src/agent.ts +++ b/src/agent.ts @@ -305,7 +305,7 @@ export default class Agent { * Close the current speech balloon */ closeBalloon() { - this._balloon.hide(); + this._balloon.close(); } /** diff --git a/src/balloon.ts b/src/balloon.ts index 8ec5a67..fc9dc80 100755 --- a/src/balloon.ts +++ b/src/balloon.ts @@ -13,7 +13,7 @@ export default class Balloon { WORD_SPEAK_TIME: number; CLOSE_BALLOON_DELAY: number; _BALLOON_MARGIN: number; - _complete: Function; + _complete: Function | undefined; _addWord: Function | undefined; _loop: number | undefined; @@ -216,6 +216,13 @@ export default class Balloon { this._sayWords(text, hold, complete); } + _completeSpeech() { + if (!this._complete) return; + const complete = this._complete; + this._complete = undefined; + complete(); + } + /** * Show the balloon */ @@ -269,7 +276,7 @@ export default class Balloon { delete this._addWord; this._active = false; if (!this._hold) { - complete(); + this._completeSpeech(); this.hide(); } } else { @@ -314,7 +321,7 @@ export default class Balloon { done: () => { this._active = false; this._hold = false; - complete(); + this._completeSpeech(); this.hide(); }, }; @@ -324,11 +331,18 @@ export default class Balloon { * Close the balloon and trigger completion callback if held */ close() { - if (this._active) { - this._hold = false; - } else if (this._hold) { - this._complete(); + window.clearTimeout(this._loop); + if (this._hiding) { + window.clearTimeout(this._hiding); + this._hiding = null; } + + delete this._addWord; + this._active = false; + this._hold = false; + this._hidden = true; + this._balloon.style.display = "none"; + this._completeSpeech(); } /** From 0ab02211ac689a2e0468bf989029070b03f9d3f8 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:33 +0100 Subject: [PATCH 03/11] Add mute/unmute API and improve balloon testability --- src/agent.ts | 24 +++++++-- src/animator.ts | 29 +++++++++-- src/balloon.test.ts | 115 ++++++++++++++++++++++++++++++++++++++++++++ src/balloon.ts | 12 +++-- 4 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 src/balloon.test.ts diff --git a/src/agent.ts b/src/agent.ts index 0f2f03f..c40fa33 100755 --- a/src/agent.ts +++ b/src/agent.ts @@ -17,6 +17,7 @@ export default class Agent { _animator: Animator; _balloon: Balloon; _hidden: boolean; + _muted: boolean; _idlePromise: Promise | null; _idleResolve: Function | null; _offset: { top: number; left: number }; @@ -53,6 +54,7 @@ export default class Agent { this._animator = new Animator(this._el, mapUrl, data, sounds); this._balloon = new Balloon(this._el); this._tts = data.tts; + this._muted = false; this._setupEvents(); } @@ -273,7 +275,7 @@ export default class Agent { speak(text, options?: { hold?: boolean; tts?: boolean }) { this._addToQueue(function (complete) { this._balloon.speak(complete, text, options?.hold); - if (options?.tts) this._speakTTS(text); + if (options?.tts && !this._muted) this._speakTTS(text); }, this); } @@ -297,10 +299,26 @@ export default class Agent { stream.push(chunk); } - if (options?.tts && text) this._speakTTS(text); + if (options?.tts && text && !this._muted) this._speakTTS(text); stream.done(); } + mute() { + this.setMuted(true); + } + + unmute() { + this.setMuted(false); + } + + setMuted(muted: boolean) { + this._muted = muted; + this._animator.setMuted(muted); + if (muted && "speechSynthesis" in window) { + speechSynthesis.cancel(); + } + } + /** * Close the current speech balloon */ @@ -660,7 +678,7 @@ export default class Agent { } _speakTTS(text: string) { - if (!this._tts || !("speechSynthesis" in window)) return; + if (this._muted || !this._tts || !("speechSynthesis" in window)) return; speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text.replaceAll("\n", " ")); utterance.rate = this._tts.rate; diff --git a/src/animator.ts b/src/animator.ts index fd05e71..98afda4 100755 --- a/src/animator.ts +++ b/src/animator.ts @@ -12,6 +12,7 @@ export default class Animator { _endCallback: Function | undefined; _started: boolean; _sounds: { [key: string]: HTMLAudioElement }; + _muted: boolean; currentAnimationName: string | undefined; _overlays: HTMLElement[]; _loop: number | undefined; @@ -34,6 +35,7 @@ export default class Animator { this._endCallback = undefined; this._started = false; this._sounds = {}; + this._muted = false; this.currentAnimationName = undefined; this.preloadSounds(sounds); this._overlays = [this._el]; @@ -88,7 +90,21 @@ export default class Animator { let snd = this._data.sounds[i]; let uri = sounds[snd]; if (!uri) continue; - this._sounds[snd] = new Audio(uri); + const audio = new Audio(uri); + audio.muted = this._muted; + this._sounds[snd] = audio; + } + } + + setMuted(muted: boolean) { + this._muted = muted; + for (const key in this._sounds) { + const audio = this._sounds[key]; + audio.muted = muted; + if (muted) { + audio.pause(); + audio.currentTime = 0; + } } } @@ -192,14 +208,19 @@ export default class Animator { * @private */ _playSound() { + if (this._muted) return; let s = this._currentFrame.sound; if (!s) return; let audio = this._sounds[s]; if (audio) { // Handle autoplay policy - catch and ignore errors when browser blocks autoplay - audio.play().catch(() => { - // Silently ignore autoplay errors - browser autoplay policy prevents playback - }); + try { + audio.play().catch(() => { + // Silently ignore autoplay errors - browser autoplay policy prevents playback + }); + } catch { + // Silently ignore errors (e.g. JSDOM, autoplay policy) + } } } diff --git a/src/balloon.test.ts b/src/balloon.test.ts new file mode 100644 index 0000000..033637e --- /dev/null +++ b/src/balloon.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import Agent from "./agent.ts"; +import Balloon from "./balloon.ts"; + +function createTarget() { + const target = document.createElement("div"); + target.style.position = "fixed"; + target.style.top = "0"; + target.style.left = "0"; + target.style.width = "16px"; + target.style.height = "16px"; + document.body.appendChild(target); + return target; +} + +function createAgent() { + return new Agent( + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", + { + overlayCount: 1, + framesize: [16, 16], + sounds: [], + tts: { rate: 1, pitch: 1, voice: "" }, + animations: { + Idle1: { + frames: [{ duration: 50, images: [[0, 0]] }], + }, + }, + }, + {}, + ); +} + +describe("Balloon hold behavior", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("completes a held balloon after the text finished when closed", () => { + const balloon = new Balloon(createTarget()); + const complete = vi.fn(); + + balloon.speak(complete, "Hello world", true); + vi.advanceTimersByTime(balloon.WORD_SPEAK_TIME * 2); + + expect(complete).not.toHaveBeenCalled(); + + balloon.close(); + + expect(complete).toHaveBeenCalledTimes(1); + expect(balloon._balloon.style.display).toBe("none"); + + balloon.dispose(); + }); + + it("completes a held balloon exactly once when closed mid-stream", () => { + const balloon = new Balloon(createTarget()); + const complete = vi.fn(); + + balloon.speak(complete, "Hello from Clippy", true); + balloon.close(); + vi.runOnlyPendingTimers(); + + expect(complete).toHaveBeenCalledTimes(1); + expect(balloon._balloon.style.display).toBe("none"); + + balloon.dispose(); + }); +}); + +describe("Agent.closeBalloon", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + document.body.innerHTML = ""; + }); + + it("releases the queued speak action for held balloons", () => { + const agent = createAgent(); + agent._hidden = true; + const nextAction = vi.fn(); + + agent.speak("Queued hold", { hold: true }); + agent._addToQueue(function (complete) { + nextAction(); + complete(); + }, agent); + + vi.advanceTimersByTime(agent._balloon.WORD_SPEAK_TIME * 2); + expect(agent._queue._queue).toHaveLength(1); + + agent.closeBalloon(); + + expect(nextAction).toHaveBeenCalledTimes(1); + expect(agent._queue._queue).toHaveLength(0); + expect(agent._queue._active).toBe(false); + + agent.dispose(); + }); +}); diff --git a/src/balloon.ts b/src/balloon.ts index fc9dc80..8623067 100755 --- a/src/balloon.ts +++ b/src/balloon.ts @@ -213,7 +213,14 @@ export default class Balloon { this.reposition(); this._complete = complete; - this._sayWords(text, hold, complete); + this._sayWords(text, hold); + } + + _completeSpeech() { + if (!this._complete) return; + const complete = this._complete; + this._complete = undefined; + complete(); } _completeSpeech() { @@ -259,10 +266,9 @@ export default class Balloon { * Animate text appearing word by word * @param {string} text - Text to animate * @param {boolean} hold - If true, keep balloon open after speaking - * @param {Function} complete - Callback when animation is done * @private */ - _sayWords(text, hold, complete) { + _sayWords(text, hold) { this._active = true; this._hold = hold; let words = text.split(/( +|\n)/); From baadcfa1b37f5ae05ca0268e82a23653bb5c3512 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:33 +0100 Subject: [PATCH 04/11] Add legacy IIFE bundle and update build for global usage --- README.md | 19 +++++++++++ build.config.mjs | 57 ++++++++++++++++++++++--------- src/legacy.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 src/legacy.ts diff --git a/README.md b/README.md index 29fcef7..656b90d 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,25 @@ You can use ClippyJS directly in the browser using CDN: ``` +### Legacy script (no modules) + +If you need a classic non-module script (e.g. older CMS/forums), use the legacy global build. It exposes `window.clippy.load(...)` and embeds all agents, maps, and sounds (no external `BASE_PATH` needed). + +```html + + + + + + + +``` + ### npm package Install and import an agent: diff --git a/build.config.mjs b/build.config.mjs index 1d105b6..bc937d2 100644 --- a/build.config.mjs +++ b/build.config.mjs @@ -15,6 +15,23 @@ const agents = [ "rover", ]; +const inlinePngPlugin = { + name: "inline-png", + resolveId(source, importer) { + if (source.endsWith(".png") && importer) { + return resolve(dirname(importer), source); + } + }, + load(id) { + if (id.endsWith(".png")) { + const base64 = readFileSync(id, "base64"); + return `export default "data:image/png;base64,${base64}"`; + } + }, +}; + +let isLegacyBuild = false; + export default defineBuildConfig({ entries: [ { @@ -25,27 +42,35 @@ export default defineBuildConfig({ ...agents.map((agent) => `./src/agents/${agent}/index.ts`), ], rolldown: { - plugins: [ - { - name: "inline-png", - resolveId(source, importer) { - if (source.endsWith(".png") && importer) { - return resolve(dirname(importer), source); - } - }, - load(id) { - if (id.endsWith(".png")) { - const base64 = readFileSync(id, "base64"); - return `export default "data:image/png;base64,${base64}"`; - } - }, - }, - ], + plugins: [inlinePngPlugin], + }, + }, + { + type: "bundle", + input: "./src/legacy.ts", + minify: true, + dts: false, + rolldown: { + platform: "browser", + plugins: [inlinePngPlugin], }, }, ], hooks: { + rolldownConfig(cfg) { + isLegacyBuild = Object.values(cfg.input || {}).some((p) => + String(p).replaceAll("\\", "/").endsWith("/src/legacy.ts"), + ); + }, rolldownOutput(cfg) { + if (isLegacyBuild) { + cfg.format = "iife"; + cfg.name = "clippy"; + cfg.entryFileNames = "clippy.min.js"; + cfg.chunkFileNames = "_chunks/[name].js"; + return; + } + cfg.chunkFileNames = ({ facadeModuleId, moduleIds }) => { // src/agents/[name]/*.* const agentName = /src\/agents\/([^/]+)\//.exec(facadeModuleId || moduleIds[0])?.[1]; diff --git a/src/legacy.ts b/src/legacy.ts new file mode 100644 index 0000000..de7d836 --- /dev/null +++ b/src/legacy.ts @@ -0,0 +1,89 @@ +import { initAgent } from "./agent.ts"; + +import BonziData from "./agents/bonzi/agent.ts"; +import BonziSounds from "./agents/bonzi/sounds-mp3.ts"; +import BonziMap from "./agents/bonzi/map.png"; + +import ClippyData from "./agents/clippy/agent.ts"; +import ClippySounds from "./agents/clippy/sounds-mp3.ts"; +import ClippyMap from "./agents/clippy/map.png"; + +import F1Data from "./agents/f1/agent.ts"; +import F1Sounds from "./agents/f1/sounds-mp3.ts"; +import F1Map from "./agents/f1/map.png"; + +import GenieData from "./agents/genie/agent.ts"; +import GenieSounds from "./agents/genie/sounds-mp3.ts"; +import GenieMap from "./agents/genie/map.png"; + +import GeniusData from "./agents/genius/agent.ts"; +import GeniusSounds from "./agents/genius/sounds-mp3.ts"; +import GeniusMap from "./agents/genius/map.png"; + +import LinksData from "./agents/links/agent.ts"; +import LinksSounds from "./agents/links/sounds-mp3.ts"; +import LinksMap from "./agents/links/map.png"; + +import MerlinData from "./agents/merlin/agent.ts"; +import MerlinSounds from "./agents/merlin/sounds-mp3.ts"; +import MerlinMap from "./agents/merlin/map.png"; + +import PeedyData from "./agents/peedy/agent.ts"; +import PeedySounds from "./agents/peedy/sounds-mp3.ts"; +import PeedyMap from "./agents/peedy/map.png"; + +import RockyData from "./agents/rocky/agent.ts"; +import RockySounds from "./agents/rocky/sounds-mp3.ts"; +import RockyMap from "./agents/rocky/map.png"; + +import RoverData from "./agents/rover/agent.ts"; +import RoverSounds from "./agents/rover/sounds-mp3.ts"; +import RoverMap from "./agents/rover/map.png"; + +type LegacyCallback = (agent: any) => void; + +function createLoaders(data: any, map: string, sounds: any) { + return { + agent: async () => ({ default: data }), + map: async () => ({ default: map }), + sound: async () => ({ default: sounds }), + }; +} + +const agentLoadersByName = { + bonzi: createLoaders(BonziData, BonziMap, BonziSounds), + clippy: createLoaders(ClippyData, ClippyMap, ClippySounds), + f1: createLoaders(F1Data, F1Map, F1Sounds), + genie: createLoaders(GenieData, GenieMap, GenieSounds), + genius: createLoaders(GeniusData, GeniusMap, GeniusSounds), + links: createLoaders(LinksData, LinksMap, LinksSounds), + merlin: createLoaders(MerlinData, MerlinMap, MerlinSounds), + peedy: createLoaders(PeedyData, PeedyMap, PeedySounds), + rocky: createLoaders(RockyData, RockyMap, RockySounds), + rover: createLoaders(RoverData, RoverMap, RoverSounds), +} as const; + +function normalizeAgentName(name: string) { + return (name || "").trim().toLowerCase(); +} + +async function load(agentName: string, callback?: LegacyCallback, _selector?: any, _basePath?: string) { + const name = normalizeAgentName(agentName); + const loaders = agentLoadersByName[name as keyof typeof agentLoadersByName]; + if (!loaders) { + throw new Error(`Unknown agent "${agentName}". Available: ${Object.keys(agentLoadersByName).join(", ")}`); + } + const agent = await initAgent(loaders); + if (callback) callback(agent); + return agent; +} + +const legacyApi = { + BASE_PATH: "", + load, +}; + +const g = globalThis as any; +g.clippy = legacyApi; +g.clippyjs = legacyApi; + From f39759124781f892aa2516d967f86adea07151c0 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:33 +0100 Subject: [PATCH 05/11] Document new API shape and clarify exports --- AGENTS.md | 20 ++++++++++++-------- README.md | 5 +++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad72256..774461e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,10 @@ ClippyJS is a modern ESM rewrite of [Clippy.JS](http://smor2.com/clippy-js) — ## Architecture -``` +```text src/ -├── index.ts # Main exports: { Agent, initAgent } -├── agent.ts # Core Agent class (show, hide, speak, moveTo, animate, drag) +├── index.ts # Main exports: { initAgent } +├── agent.ts # Core Agent class (show, hide, speak, moveTo, animate, drag, mute) ├── animator.ts # Frame-by-frame sprite sheet animation engine ├── balloon.ts # Speech bubble with typewriter effect and auto-repositioning ├── queue.ts # Sequential action queue @@ -33,7 +33,7 @@ src/ | Class | File | Role | | ---------- | ----------------- | ------------------------------------------------------------ | -| `Agent` | `src/agent.ts` | Main API surface — lifecycle, speech, movement, drag, queue | +| `Agent` | `src/agent.ts` | Main API surface — lifecycle, speech, movement, drag, queue, mute | | `Animator` | `src/animator.ts` | Sprite rendering, frame stepping, exit branching, sound sync | | `Balloon` | `src/balloon.ts` | Speech balloon with typewriter animation, auto-positioning | | `Queue` | `src/queue.ts` | Sequential action queue with idle callback | @@ -103,8 +103,8 @@ TTS personality per agent: ### Package exports -``` -clippyjs → Agent class +```text +clippyjs → initAgent() clippyjs/agents → All 10 agents clippyjs/agents/* → Individual agents (bonzi, clippy, f1, genie, genius, links, merlin, peedy, rocky, rover) ``` @@ -117,6 +117,7 @@ clippyjs/agents/* → Individual agents (bonzi, clippy, f1, genie, genius, lin | `obuild` | Bundle builder (rolldown-based) | | `vite` | Dev server (`pnpm dev`) | | `tsgo` | Type checking (`pnpm typecheck`) | +| `vitest` + `jsdom` | DOM-based unit/regression tests | | `oxlint` + `oxfmt` | Linting and formatting | | `automd` | README badge/section formatting | | `changelogen` | Changelog and release management | @@ -125,13 +126,16 @@ clippyjs/agents/* → Individual agents (bonzi, clippy, f1, genie, genius, lin `build.config.mjs` uses obuild with a custom `inline-png` rolldown plugin that converts `.png` sprite sheets to base64 data URIs. Output goes to `dist/` with per-agent chunk naming (`dist/agents//`). +It also produces a legacy global bundle at `dist/clippy.min.js` (IIFE) that exposes `window.clippy.load(...)` for non-module usage. + ### Scripts | Script | Command | | ---------------- | ------------------------------------------ | | `pnpm dev` | Start Vite dev server | | `pnpm build` | Build with obuild | -| `pnpm test` | Lint + typecheck | +| `pnpm test` | Lint + typecheck + unit tests | +| `pnpm test:unit` | Run Vitest regression/unit tests | | `pnpm typecheck` | Type check via tsgo | | `pnpm lint` | oxlint + oxfmt check | | `pnpm fmt` | automd + oxlint fix + oxfmt | @@ -163,4 +167,4 @@ Two GitHub Actions workflows in `.github/workflows/`: - When modifying build config or tooling, update the tooling/scripts sections in `AGENTS.md` - When changing CI workflows, update the CI/CD section in `AGENTS.md` - Run `pnpm fmt` before committing to ensure consistent formatting -- Run `pnpm test` (lint + typecheck) to validate changes +- Run `pnpm test` (lint + typecheck + unit tests) to validate changes diff --git a/README.md b/README.md index 656b90d..cdb9462 100755 --- a/README.md +++ b/README.md @@ -97,6 +97,11 @@ agent.speak("When all else fails, bind some paper together. My name is Clippy.") // Speak with text-to-speech (uses Web Speech API) agent.speak("Hello! I'm here to help.", { tts: true }); +// Mute/unmute all sounds (animation sounds + TTS) +agent.mute(); +agent.unmute(); +agent.setMuted(true); + // Keep the balloon open until manually closed agent.speak("Read this carefully.", { hold: true }); From 3002df472ae96bbc1f69865967c376b706a9c738 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:33 +0100 Subject: [PATCH 06/11] Add unit testing infrastructure with Vitest and jsdom --- package.json | 4 +- pnpm-lock.yaml | 331 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 329 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d55a2da..7cc4c31 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "lint": "oxlint . && oxfmt --check .", "prepack": "pnpm build", "release": "pnpm test && pnpm build && changelogen --release && npm publish && git push --follow-tags", - "test": "pnpm lint && pnpm typecheck", + "test": "corepack pnpm lint && corepack pnpm typecheck && corepack pnpm test:unit", + "test:unit": "vitest run", "typecheck": "tsgo --noEmit --skipLibCheck" }, "devDependencies": { @@ -41,6 +42,7 @@ "@vitest/coverage-v8": "latest", "automd": "latest", "changelogen": "latest", + "jsdom": "latest", "obuild": "latest", "oxfmt": "latest", "oxlint": "latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f56a85..99abe0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,13 +19,16 @@ importers: version: 7.0.0-dev.20260217.1 '@vitest/coverage-v8': specifier: latest - version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)) + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@29.0.0)) automd: specifier: latest version: 0.4.3(magicast@0.5.2) changelogen: specifier: latest version: 0.6.2(magicast@0.5.2) + jsdom: + specifier: latest + version: 29.0.0 obuild: specifier: latest version: 0.4.27(@typescript/native-preview@7.0.0-dev.20260217.1)(magicast@0.5.2)(picomatch@4.0.3)(rollup@4.57.1)(typescript@5.9.3) @@ -43,10 +46,21 @@ importers: version: 7.3.1(@types/node@25.2.3)(jiti@2.6.1) vitest: specifier: latest - version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1) + version: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@29.0.0) packages: + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.3': + resolution: {integrity: sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/generator@8.0.0-rc.1': resolution: {integrity: sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -93,6 +107,46 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -258,6 +312,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -956,6 +1019,9 @@ packages: resolution: {integrity: sha512-5WJNEiaNpFm8h0OmQzhnESthadUQhJwQfka/TmmJpMudZ8qU9MZao9p0G1g7WYA9pVTz6FMMOSvxnfQ9g8q9vQ==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -1005,6 +1071,17 @@ packages: convert-gitmoji@0.1.5: resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -1044,6 +1121,10 @@ packages: oxc-resolver: optional: true + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1091,6 +1172,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1112,6 +1197,9 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -1135,6 +1223,15 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsdom@29.0.0: + resolution: {integrity: sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1153,6 +1250,10 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1169,6 +1270,9 @@ packages: mdbox@0.1.1: resolution: {integrity: sha512-jvLISenzbLRPWWamTG3THlhTcMbKWzJQNyTi61AVXhCBOC+gsldNTUfUNH8d3Vay83zGehFw3wZpF3xChzkTIQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -1231,6 +1335,9 @@ packages: resolution: {integrity: sha512-gFL35q7kbE/zBaPA3UKhp2vSzcPYx2ecbYuwv1ucE9Il6IIgBDweBlH8D68UFGZic2MkllKa2KHCfC1IQBQUYA==} engines: {node: '>=12'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1258,6 +1365,10 @@ packages: resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} engines: {node: '>=20'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -1265,6 +1376,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1307,6 +1422,10 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -1353,6 +1472,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1372,6 +1494,21 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1386,6 +1523,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.24.3: + resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==} + engines: {node: '>=20.18.1'} + untyped@2.0.0: resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} hasBin: true @@ -1464,6 +1605,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1473,8 +1630,33 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + snapshots: + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.3': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/generator@8.0.0-rc.1': dependencies: '@babel/parser': 8.0.0-rc.1 @@ -1514,6 +1696,34 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -1608,6 +1818,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1980,7 +2192,7 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260217.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260217.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@29.0.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -1992,7 +2204,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.3)(jiti@2.6.1) + vitest: 4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@29.0.0) '@vitest/expect@4.0.18': dependencies: @@ -2073,6 +2285,10 @@ snapshots: transitivePeerDependencies: - magicast + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + birpc@4.0.0: {} bundle-name@4.1.0: @@ -2136,6 +2352,20 @@ snapshots: convert-gitmoji@0.1.5: {} + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -2161,6 +2391,8 @@ snapshots: dts-resolver@2.1.3: {} + entities@6.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.27.3: @@ -2224,6 +2456,12 @@ snapshots: has-flag@4.0.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} is-docker@3.0.0: {} @@ -2238,6 +2476,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-potential-custom-element-name@1.0.1: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -2259,6 +2499,32 @@ snapshots: js-tokens@10.0.0: {} + jsdom@29.0.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.3 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.3 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} knitwork@1.3.0: {} @@ -2269,6 +2535,8 @@ snapshots: loglevel@1.9.2: {} + lru-cache@11.2.7: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2289,6 +2557,8 @@ snapshots: dependencies: md4w: 0.2.7 + mdn-data@2.27.1: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -2400,6 +2670,10 @@ snapshots: package-name-regex@2.0.6: {} + parse5@8.0.0: + dependencies: + entities: 6.0.1 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -2428,6 +2702,8 @@ snapshots: pretty-bytes@7.1.0: {} + punycode@2.3.1: {} + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -2435,6 +2711,8 @@ snapshots: readdirp@5.0.0: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260217.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): @@ -2521,6 +2799,10 @@ snapshots: run-applescript@7.1.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scule@1.3.0: {} semver@7.7.4: {} @@ -2564,6 +2846,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -2577,6 +2861,20 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tslib@2.8.1: optional: true @@ -2586,6 +2884,8 @@ snapshots: undici-types@7.16.0: {} + undici@7.24.3: {} + untyped@2.0.0: dependencies: citty: 0.1.6 @@ -2607,7 +2907,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 - vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1): + vitest@4.0.18(@types/node@25.2.3)(jiti@2.6.1)(jsdom@29.0.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)) @@ -2631,6 +2931,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.3 + jsdom: 29.0.0 transitivePeerDependencies: - jiti - less @@ -2644,6 +2945,22 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2652,3 +2969,7 @@ snapshots: wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} From 909022f9f37a47f992291a5669bf09cf6a4d066a Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:33 +0100 Subject: [PATCH 07/11] Update CI workflow to run full build and legacy copy --- .github/workflows/pages.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ecbb991..cc932c6 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -26,7 +26,9 @@ jobs: - uses: actions/setup-node@v6 with: { node-version: lts/*, cache: "pnpm" } - run: pnpm install + - run: pnpm build - run: pnpm vite build + - run: cp dist/clippy.min.js dist-pages/clippy.min.js - uses: actions/upload-pages-artifact@v3 with: { path: dist-pages } - id: deployment From a477e19164e10f7b4fe1e61d8c1fe9593d79755a Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:24:48 +0100 Subject: [PATCH 08/11] fix: resolve #8 zoo demo 404 by adding zoo.html build input --- src/balloon.ts | 7 ------- vite.config.ts | 10 ++++++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/balloon.ts b/src/balloon.ts index 8623067..0bcc44f 100755 --- a/src/balloon.ts +++ b/src/balloon.ts @@ -223,13 +223,6 @@ export default class Balloon { complete(); } - _completeSpeech() { - if (!this._complete) return; - const complete = this._complete; - this._complete = undefined; - complete(); - } - /** * Show the balloon */ diff --git a/vite.config.ts b/vite.config.ts index b2830b6..a1217a2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,17 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; +const rootDir = fileURLToPath(new URL(".", import.meta.url)); + export default defineConfig({ build: { outDir: "dist-pages", + rollupOptions: { + input: { + index: resolve(rootDir, "index.html"), + zoo: resolve(rootDir, "zoo.html"), + }, + }, }, }); From f46da89530294894e08715a19bf3348f9d87d44a Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:34:50 +0100 Subject: [PATCH 09/11] fix(legacy): honor CLIPPY_CDN/basePath metadata for backward compatibility --- README.md | 2 ++ src/legacy.ts | 42 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cdb9462..d3f984b 100755 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ You can use ClippyJS directly in the browser using CDN: If you need a classic non-module script (e.g. older CMS/forums), use the legacy global build. It exposes `window.clippy.load(...)` and embeds all agents, maps, and sounds (no external `BASE_PATH` needed). +For backwards compatibility, `window.CLIPPY_CDN` and the 4th `clippy.load(..., basePath)` argument are still accepted and reflected in `clippy.BASE_PATH`, but they are not used to fetch assets in the modern legacy bundle. + ```html diff --git a/src/legacy.ts b/src/legacy.ts index de7d836..abcca1f 100644 --- a/src/legacy.ts +++ b/src/legacy.ts @@ -42,6 +42,16 @@ import RoverMap from "./agents/rover/map.png"; type LegacyCallback = (agent: any) => void; +type LegacyGlobal = { + CLIPPY_CDN?: string; + clippy?: { + BASE_PATH?: string; + }; + clippyjs?: { + BASE_PATH?: string; + }; +}; + function createLoaders(data: any, map: string, sounds: any) { return { agent: async () => ({ default: data }), @@ -67,11 +77,35 @@ function normalizeAgentName(name: string) { return (name || "").trim().toLowerCase(); } -async function load(agentName: string, callback?: LegacyCallback, _selector?: any, _basePath?: string) { +function pickLegacyBasePath(globalObj: LegacyGlobal, basePath?: string) { + const fromLoadArg = typeof basePath === "string" ? basePath.trim() : ""; + if (fromLoadArg) return fromLoadArg; + + const fromGlobal = typeof globalObj.CLIPPY_CDN === "string" ? globalObj.CLIPPY_CDN.trim() : ""; + if (fromGlobal) return fromGlobal; + + return ""; +} + +const g = globalThis as LegacyGlobal; + +async function load( + agentName: string, + callback?: LegacyCallback, + _selector?: any, + basePath?: string, +) { + // Keep legacy config knobs (`window.CLIPPY_CDN` / 4th arg) for compatibility. + // Assets are embedded in `clippy.min.js`, so BASE_PATH is metadata only. + const configuredBasePath = pickLegacyBasePath(g, basePath); + legacyApi.BASE_PATH = configuredBasePath; + const name = normalizeAgentName(agentName); const loaders = agentLoadersByName[name as keyof typeof agentLoadersByName]; if (!loaders) { - throw new Error(`Unknown agent "${agentName}". Available: ${Object.keys(agentLoadersByName).join(", ")}`); + throw new Error( + `Unknown agent "${agentName}". Available: ${Object.keys(agentLoadersByName).join(", ")}`, + ); } const agent = await initAgent(loaders); if (callback) callback(agent); @@ -79,11 +113,9 @@ async function load(agentName: string, callback?: LegacyCallback, _selector?: an } const legacyApi = { - BASE_PATH: "", + BASE_PATH: pickLegacyBasePath(g), load, }; -const g = globalThis as any; g.clippy = legacyApi; g.clippyjs = legacyApi; - From 92abc5225b0576ce1ae9e5228b1fe96767897168 Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:44:25 +0100 Subject: [PATCH 10/11] fix: handle moveTo animations without WAITING and add regression test --- src/agent-move.test.ts | 77 ++++++++++++++++++++++++++++++++++++++++++ src/agent.ts | 18 ++++++++-- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 src/agent-move.test.ts diff --git a/src/agent-move.test.ts b/src/agent-move.test.ts new file mode 100644 index 0000000..d3fa731 --- /dev/null +++ b/src/agent-move.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import Agent from "./agent.ts"; +import Animator from "./animator.ts"; + +function createAgentWithMoveAnimations() { + return new Agent( + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", + { + overlayCount: 1, + framesize: [16, 16], + sounds: [], + tts: { rate: 1, pitch: 1, voice: "" }, + animations: { + Idle1: { + frames: [{ duration: 20, images: [[0, 0]] }], + }, + MoveLeft: { + frames: [{ duration: 20, images: [[0, 0]] }], + }, + MoveRight: { + frames: [{ duration: 20, images: [[0, 0]] }], + }, + MoveUp: { + frames: [{ duration: 20, images: [[0, 0]] }], + }, + MoveDown: { + frames: [{ duration: 20, images: [[0, 0]] }], + }, + }, + }, + {}, + ); +} + +describe("Agent.moveTo", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("falls back to movement tween when Move animation exits without WAITING", () => { + const agent = createAgentWithMoveAnimations(); + const playInternalSpy = vi + .spyOn(agent, "_playInternal") + .mockImplementation((_animation, cb) => { + cb("MoveLeft", Animator.States.EXITED); + }); + + const tweenSpy = vi + .spyOn(agent, "_animate") + .mockImplementation((element, props, _duration, callback) => { + for (const prop in props) { + element.style[prop] = `${props[prop]}px`; + } + if (callback) callback(); + }); + + const nextAction = vi.fn(); + agent.moveTo(300, 120, 200); + agent._addToQueue(function (complete) { + nextAction(); + complete(); + }, agent); + + expect(tweenSpy).toHaveBeenCalledTimes(1); + expect(playInternalSpy).toHaveBeenCalledTimes(1); + expect(nextAction).toHaveBeenCalledTimes(1); + + agent.dispose(); + }); +}); diff --git a/src/agent.ts b/src/agent.ts index c40fa33..776406b 100755 --- a/src/agent.ts +++ b/src/agent.ts @@ -127,14 +127,26 @@ export default class Agent { return; } + let startedMoveTween = false; let callback = (name, state) => { - if (state === Animator.States.EXITED) { - complete(); - } if (state === Animator.States.WAITING) { + if (startedMoveTween) return; + startedMoveTween = true; this._animate(this._el, { top: cy, left: cx }, duration, () => { this._animator.exitAnimation(); }); + return; + } + + if (state === Animator.States.EXITED) { + // Some Move* animations do not expose WAITING/useExitBranching. + // In that case, run the movement tween after animation exit. + if (!startedMoveTween) { + startedMoveTween = true; + this._animate(this._el, { top: cy, left: cx }, duration, complete); + return; + } + complete(); } }; From d65de0250aa9cbf2f8d779a9871eb15ac18ae4fc Mon Sep 17 00:00:00 2001 From: "Josefo (jozrftamson)" <107008476+jozrftamson@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:56:55 +0100 Subject: [PATCH 11/11] docs: improve demo guidance and add issue #15 implementation plan --- AGENTS.md | 10 +++--- README.md | 33 +++++++++++++++++- docs/issue-15-xp-helper-agent-plan.md | 49 +++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 docs/issue-15-xp-helper-agent-plan.md diff --git a/AGENTS.md b/AGENTS.md index 774461e..6ece260 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,12 @@ src/ ### Key classes -| Class | File | Role | -| ---------- | ----------------- | ------------------------------------------------------------ | +| Class | File | Role | +| ---------- | ----------------- | ----------------------------------------------------------------- | | `Agent` | `src/agent.ts` | Main API surface — lifecycle, speech, movement, drag, queue, mute | -| `Animator` | `src/animator.ts` | Sprite rendering, frame stepping, exit branching, sound sync | -| `Balloon` | `src/balloon.ts` | Speech balloon with typewriter animation, auto-positioning | -| `Queue` | `src/queue.ts` | Sequential action queue with idle callback | +| `Animator` | `src/animator.ts` | Sprite rendering, frame stepping, exit branching, sound sync | +| `Balloon` | `src/balloon.ts` | Speech balloon with typewriter animation, auto-positioning | +| `Queue` | `src/queue.ts` | Sequential action queue with idle callback | ### Agent data format diff --git a/README.md b/README.md index d3f984b..9c0d531 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,16 @@ Add Clippy or his friends to any website for instant nostalgia! -[**Online Demo**](https://clippy.pi0.io/) +[**Online Demo**](https://clippy.pi0.io/) | [**Agent Zoo**](https://clippy.pi0.io/zoo.html) + +If the hosted demo is temporarily unavailable, run it locally: + +```bash +corepack enable +corepack prepare pnpm@10.29.3 --activate +pnpm install +pnpm dev +``` ## Usage @@ -46,6 +55,28 @@ For backwards compatibility, `window.CLIPPY_CDN` and the 4th `clippy.load(..., b ``` +### XenForo integration (legacy script) + +For XenForo (or other forum software without ESM build tooling), add the legacy bundle to your page template. + +1. In XenForo Admin CP, open `Appearance` -> `Templates`. +2. Edit `PAGE_CONTAINER` (or a custom footer template). +3. Add this near the end of ``: + +```html + + +``` + +If you only want Clippy on specific pages, wrap the script with XenForo template conditions. + ### npm package Install and import an agent: diff --git a/docs/issue-15-xp-helper-agent-plan.md b/docs/issue-15-xp-helper-agent-plan.md new file mode 100644 index 0000000..ddc9cb9 --- /dev/null +++ b/docs/issue-15-xp-helper-agent-plan.md @@ -0,0 +1,49 @@ +# Issue #15 - Windows XP Helper Agent Plan + +Issue: https://github.com/pi0/clippyjs/issues/15 + +## Goal + +Add the Windows XP setup helper (question mark agent) as a first-class bundled agent in ClippyJS. + +## Required Inputs (Blockers) + +- Agent animation data in the same shape as existing `src/agents/*/agent.ts` files. +- Sprite sheet PNG (`map.png`) that matches the frame coordinates in the animation data. +- Sound map (`sounds-mp3.ts`) in the same format as existing agents. +- Final public agent name (for example: `QMark`, `XPHelper`, or `QuestionMark`). + +## Implementation Checklist + +1. Add new agent folder: + - `src/agents//index.ts` + - `src/agents//agent.ts` + - `src/agents//sounds-mp3.ts` + - `src/agents//map.png` +2. Export agent from `src/agents/index.ts`. +3. Add subpath export in `package.json` (`./agents/`). +4. Add new agent key to `build.config.mjs` `agents` array. +5. Include the agent in `src/legacy.ts` for `clippy.min.js` compatibility. +6. Add a demo button/option in `demo/demo.ts`. +7. Update docs: + - `README.md` available agents section + - `AGENTS.md` architecture/examples where needed +8. Run validation: + - `pnpm fmt` + - `pnpm test` + - `pnpm build` + +## Acceptance Criteria + +- Agent can be loaded via ESM: + - `import { } from "clippyjs/agents"` + - `import from "clippyjs/agents/"` +- Agent can be loaded via legacy API: + - `clippy.load("", callback)` (if mapped) +- Agent appears in demo UI and can `show`, `animate`, `speak`, `moveTo`. +- Build artifacts include `dist/agents//` chunks. + +## Notes + +- Keep naming consistent across folder name, export name, and demo label. +- If original XP assets need conversion, preserve frame coordinates and animation timing during extraction.