Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 18 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@

## 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`
- **License:** MIT

## 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
Expand All @@ -31,12 +31,12 @@ src/

### Key classes

| Class | File | Role |
| ---------- | ----------------- | ------------------------------------------------------------ |
| `Agent` | `src/agent.ts` | Main API surface — lifecycle, speech, movement, drag, queue |
| `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 |
| 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 |

### Agent data format

Expand Down Expand Up @@ -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)
```
Expand All @@ -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 |
Expand All @@ -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/<name>/`).

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 |
Expand Down Expand Up @@ -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
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected] --activate
pnpm install
pnpm dev
```

## Usage

Expand All @@ -25,6 +34,49 @@ You can use ClippyJS directly in the browser using CDN:
</html>
```

### 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).

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
<!doctype html>
<html>
<body>
<script src="https://cdn.jsdelivr.net/npm/clippyjs/dist/clippy.min.js"></script>
<script>
clippy.load("Clippy", (agent) => {
agent.show();
agent.speak("Hello! I'm Clippy, your virtual assistant.");
});
</script>
</body>
</html>
```

### 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 `<body>`:

```html
<script src="https://cdn.jsdelivr.net/npm/clippyjs/dist/clippy.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
clippy.load("Clippy", function (agent) {
agent.show();
agent.speak("Welcome to the forum!");
});
});
</script>
```

If you only want Clippy on specific pages, wrap the script with XenForo template conditions.

### npm package

Install and import an agent:
Expand Down Expand Up @@ -78,9 +130,17 @@ 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 });

// 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);

Expand Down Expand Up @@ -116,7 +176,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)

Expand Down
57 changes: 41 additions & 16 deletions build.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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];
Expand Down
69 changes: 69 additions & 0 deletions demo/zoo.ts
Original file line number Diff line number Diff line change
@@ -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);
});
49 changes: 49 additions & 0 deletions docs/issue-15-xp-helper-agent-plan.md
Original file line number Diff line number Diff line change
@@ -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/<name>/index.ts`
- `src/agents/<name>/agent.ts`
- `src/agents/<name>/sounds-mp3.ts`
- `src/agents/<name>/map.png`
2. Export agent from `src/agents/index.ts`.
3. Add subpath export in `package.json` (`./agents/<name>`).
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 { <Name> } from "clippyjs/agents"`
- `import <Name> from "clippyjs/agents/<name>"`
- Agent can be loaded via legacy API:
- `clippy.load("<name>", callback)` (if mapped)
- Agent appears in demo UI and can `show`, `animate`, `speak`, `moveTo`.
- Build artifacts include `dist/agents/<name>/` 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.
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ <h2>Welcome to Clippy.js!</h2>
<p>
This is a demo. Click an agent below to try it out.<br />
See <a href="https://github.com/pi0/clippyjs" target="_blank">GitHub</a> for usage and
docs.
docs. Want them all at once? Open the <a href="./zoo.html">agent zoo</a>.
</p>
<div class="agent-select-group" id="agent-buttons"></div>
<div class="anim-select-group">
Expand Down
Loading