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...
+
+
+
+
+
+
+
+
+
+
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: