diff --git a/docs/README.md b/docs/README.md
index c395b1b..0d8f4d8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -26,5 +26,6 @@ The repository root retains the canonical [`LICENSE`](../LICENSE) and [`NOTICE`]
## Development references
+- [Product usage analytics](product-analytics.md)
- [Vercel agent notes](development/VERCEL_AGENTS.md)
- [Pose diagnostics summary](diagnostics/pose-summary.json)
diff --git a/docs/product-analytics.md b/docs/product-analytics.md
new file mode 100644
index 0000000..7f147e0
--- /dev/null
+++ b/docs/product-analytics.md
@@ -0,0 +1,71 @@
+# Product usage analytics
+
+Posecode keeps Vercel pageviews and product-usage events separate. Pageviews
+answer “which routes were visited?”; the events below answer “did someone use
+the product?”
+
+## Provider and production configuration
+
+The implementation is provider-neutral at the call sites. Event names, payload
+types, failure isolation, and session deduplication live in
+`playground/src/analytics.ts`. The current adapter is
+`playground/src/vercel-analytics.ts`.
+
+Vercel Web Analytics pageviews remain enabled without extra configuration.
+Vercel's current plan table says custom events are **not available on Hobby**;
+they are available on Pro and Enterprise. Pro allows at most two properties per
+custom event. The schema below deliberately stays within that limit.
+
+To enable product events on a Vercel Pro or Enterprise production project:
+
+1. Enable Web Analytics for the project in Vercel.
+2. Set `VITE_PRODUCT_ANALYTICS_PROVIDER=vercel` for the Production environment.
+3. Redeploy so Vite includes the provider choice in the client bundle.
+4. Exercise one event and confirm it in **Project → Analytics → Events**.
+
+Do not set the variable on Hobby expecting dashboard data: Hobby continues to
+show pageviews but does not expose custom events. No alternate paid analytics
+vendor is installed. A future adapter can call `configureUsageAnalytics`
+without changing UI event call sites.
+
+Sources:
+
+- [Vercel custom events](https://vercel.com/docs/analytics/custom-events)
+- [Vercel Web Analytics limits and pricing](https://vercel.com/docs/analytics/limits-and-pricing)
+
+## Event dictionary
+
+| Event | Fires when | Properties |
+|---|---|---|
+| `preset_opened` | A bundled movement is actually opened at initial load or selected in the library. | `source`: `library`, `direct_url`, `shared_link`, or `landing_cta`; `preset_id`: bundled stable ID |
+| `editor_changed` | The first real CodeMirror user edit in the page session. Programmatic preset loads do not count. | `document_kind`: `preset`, `shared`, or `custom` |
+| `render_succeeded` | `viewer.load()` successfully accepts a new meaningful document revision. Lazy boot and repeated recompiles of the same revision are deduplicated. The animation frame loop never emits this event. | `trigger`: `initial`, `preset_open`, `shared_link`, or `editor_change`; `document_kind` |
+| `share_created` | The generated preset/encoded URL has successfully been written to the clipboard. | `share_kind`: `preset` or `encoded` |
+| `embed_docs_clicked` | The embed documentation CTA on `/for-products` is clicked. | `location`: `for_products` |
+| `install_command_copied` | An npm/npx command on `/for-products` is successfully written to the clipboard. | `command`: `embed`, `packages`, or `mcp`; `location`: `for_products` |
+
+## Reading the dashboard
+
+Open **Analytics → Events**, select an event, then drill into its properties.
+Useful readings include:
+
+- `preset_opened` grouped by `source` separates library discovery from direct,
+ shared, and landing-page entry.
+- Compare `editor_changed` and `render_succeeded` counts to see whether editing
+ reaches a valid renderer update. They are intentionally not a strict funnel:
+ initial and preset renders also count.
+- `share_created` is a confirmed clipboard outcome, not a button-click count.
+- Group `install_command_copied` by `command` to compare integration intent.
+
+Vercel reports aggregate events rather than a user-level funnel. Do not attempt
+to join individual visitors or reconstruct sessions from these payloads.
+
+## Privacy and resilience
+
+Events never contain Posecode source text, authoring prompts, personal data,
+full share tokens, query strings, referrers, or sensitive URLs. `preset_id` is a
+bounded public catalogue identifier; all other values are closed enums.
+
+Every analytics call is best-effort and catches provider failures. Ad blockers,
+network failures, a missing provider configuration, or plan limitations do not
+change editing, rendering, sharing, navigation, or clipboard behavior.
diff --git a/playground/for-products.html b/playground/for-products.html
index 9bd6815..84c231f 100644
--- a/playground/for-products.html
+++ b/playground/for-products.html
@@ -77,18 +77,28 @@
Drop in a movement player
Use the framework-free <posecode-player> web component with inline Posecode, a .posecode URL, or a share token.
<script src="https://unpkg.com/posecode-embed@0.2.2/dist/posecode-embed.js"></script>
<posecode-player src="/moves/squat.posecode"></posecode-player>
+
02 / compose
Own the interface
Parse text into a typed, range-of-motion-clamped IR, then drive the Three.js renderer inside your own editor, lesson, or workflow.
npm install posecode-parser posecode-render three
+
+ Copy npm command
+
03 / agents
Run movement tools locally
The npm MCP server runs over stdio on your machine. It teaches an MCP client the language, validates documents, and creates playground links.
npx -y posecode-mcp@latest
+
+ Copy run command
+
diff --git a/playground/src/analytics.ts b/playground/src/analytics.ts
new file mode 100644
index 0000000..dcdff90
--- /dev/null
+++ b/playground/src/analytics.ts
@@ -0,0 +1,94 @@
+/**
+ * Provider-neutral product usage analytics.
+ *
+ * Event payloads intentionally contain only low-cardinality product metadata.
+ * Never add Posecode source, share tokens, prompts, or URLs here.
+ */
+
+export const USAGE_EVENT_NAMES = {
+ presetOpened: "preset_opened",
+ editorChanged: "editor_changed",
+ renderSucceeded: "render_succeeded",
+ shareCreated: "share_created",
+ embedDocsClicked: "embed_docs_clicked",
+ installCommandCopied: "install_command_copied",
+} as const;
+
+export type PresetOpenSource =
+ | "library"
+ | "direct_url"
+ | "shared_link"
+ | "landing_cta";
+export type DocumentKind = "preset" | "shared" | "custom";
+export type RenderTrigger =
+ | "initial"
+ | "preset_open"
+ | "shared_link"
+ | "editor_change";
+export type ShareKind = "preset" | "encoded";
+export type InstallCommand = "embed" | "packages" | "mcp";
+
+export interface UsageEventMap {
+ preset_opened: { source: PresetOpenSource; preset_id: string };
+ editor_changed: { document_kind: DocumentKind };
+ render_succeeded: { trigger: RenderTrigger; document_kind: DocumentKind };
+ share_created: { share_kind: ShareKind };
+ embed_docs_clicked: { location: "for_products" };
+ install_command_copied: {
+ command: InstallCommand;
+ location: "for_products";
+ };
+}
+
+export type UsageEventName = keyof UsageEventMap;
+export type UsageEventSink = (
+ name: Name,
+ properties: UsageEventMap[Name],
+) => void;
+
+let sink: UsageEventSink | null = null;
+
+export function configureUsageAnalytics(nextSink: UsageEventSink | null): void {
+ sink = nextSink;
+}
+
+export function trackUsageEvent(
+ name: Name,
+ properties: UsageEventMap[Name],
+): void {
+ try {
+ sink?.(name, properties);
+ } catch {
+ // Analytics must never interrupt the product interaction being measured.
+ }
+}
+
+/**
+ * Per-page-session noise control. Internal revision keys are never sent to the
+ * provider; they only prevent duplicate render events during lazy boot/reparse.
+ */
+export class UsageSession {
+ private firstEditTracked = false;
+ private renderedRevisions = new Set();
+
+ trackFirstEdit(documentKind: DocumentKind): void {
+ if (this.firstEditTracked) return;
+ this.firstEditTracked = true;
+ trackUsageEvent(USAGE_EVENT_NAMES.editorChanged, {
+ document_kind: documentKind,
+ });
+ }
+
+ trackSuccessfulRender(
+ revision: number,
+ trigger: RenderTrigger,
+ documentKind: DocumentKind,
+ ): void {
+ if (this.renderedRevisions.has(revision)) return;
+ this.renderedRevisions.add(revision);
+ trackUsageEvent(USAGE_EVENT_NAMES.renderSucceeded, {
+ trigger,
+ document_kind: documentKind,
+ });
+ }
+}
diff --git a/playground/src/editor.ts b/playground/src/editor.ts
index 5f509b3..a5f6ee2 100644
--- a/playground/src/editor.ts
+++ b/playground/src/editor.ts
@@ -5,7 +5,12 @@
* `posecode-language` (shared with the LSP), so the editor never reimplements them.
*/
-import { EditorState, StateEffect, StateField } from "@codemirror/state";
+import {
+ EditorState,
+ StateEffect,
+ StateField,
+ Transaction,
+} from "@codemirror/state";
import {
EditorView,
keymap,
@@ -301,7 +306,7 @@ export interface PosecodeEditor {
export interface PosecodeEditorOptions {
doc: string;
- onChange: (value: string) => void;
+ onChange: (value: string, userInitiated: boolean) => void;
}
export function createPosecodeEditor(
@@ -341,7 +346,13 @@ export function createPosecodeEditor(
indentWithTab,
]),
EditorView.updateListener.of((u) => {
- if (u.docChanged) opts.onChange(u.state.doc.toString());
+ if (u.docChanged) {
+ const userInitiated = u.transactions.some(
+ (transaction) =>
+ transaction.annotation(Transaction.userEvent) !== undefined,
+ );
+ opts.onChange(u.state.doc.toString(), userInitiated);
+ }
}),
],
}),
diff --git a/playground/src/for-products.css b/playground/src/for-products.css
index 2b3ff25..763a10b 100644
--- a/playground/src/for-products.css
+++ b/playground/src/for-products.css
@@ -174,6 +174,32 @@
font: inherit;
}
+.integration-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.code-action,
+.docs-action {
+ color: var(--accent);
+ font: 600 11px/1.4 var(--mono);
+}
+
+.code-action {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.code-action:hover,
+.docs-action:hover {
+ color: var(--text);
+}
+
.use-cases {
border-top: 1px solid var(--border);
}
diff --git a/playground/src/for-products.ts b/playground/src/for-products.ts
index fcba4ec..a3787fb 100644
--- a/playground/src/for-products.ts
+++ b/playground/src/for-products.ts
@@ -1,6 +1,46 @@
-import { inject } from "@vercel/analytics";
+import {
+ trackUsageEvent,
+ USAGE_EVENT_NAMES,
+ type InstallCommand,
+} from "./analytics.js";
+import { initializeAnalytics } from "./vercel-analytics.js";
-inject();
+initializeAnalytics();
+
+document.querySelector("[data-embed-docs]")?.addEventListener(
+ "click",
+ () => {
+ trackUsageEvent(USAGE_EVENT_NAMES.embedDocsClicked, {
+ location: "for_products",
+ });
+ },
+);
+
+for (const button of document.querySelectorAll(
+ "[data-copy-command]",
+)) {
+ button.addEventListener("click", async () => {
+ const command = button.dataset.command;
+ const commandKind = button.dataset.copyCommand as
+ | InstallCommand
+ | undefined;
+ if (!command || !commandKind) return;
+ const previous = button.textContent;
+ try {
+ await navigator.clipboard.writeText(command);
+ button.textContent = "Copied ✓";
+ trackUsageEvent(USAGE_EVENT_NAMES.installCommandCopied, {
+ command: commandKind,
+ location: "for_products",
+ });
+ } catch {
+ button.textContent = "Copy failed";
+ }
+ window.setTimeout(() => {
+ button.textContent = previous;
+ }, 1500);
+ });
+}
const prefersReducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
diff --git a/playground/src/main.ts b/playground/src/main.ts
index b920e5e..d4ff3dc 100644
--- a/playground/src/main.ts
+++ b/playground/src/main.ts
@@ -8,8 +8,16 @@
*/
import { parse, type ParseError, type Warning } from "posecode-parser";
-import { inject } from "@vercel/analytics";
import type { ConstraintDiagnostic, Viewer } from "posecode-render";
+import {
+ trackUsageEvent,
+ UsageSession,
+ USAGE_EVENT_NAMES,
+ type DocumentKind,
+ type PresetOpenSource,
+ type RenderTrigger,
+} from "./analytics.js";
+import { initializeAnalytics } from "./vercel-analytics.js";
import {
buildNicePlayPath,
buildNiceShareHash,
@@ -27,7 +35,8 @@ const DEFAULT_PRESET = PRESETS.find((p) => p.id === "squat") ?? PRESETS[0]!;
import { renderWarnings } from "./warnings.js";
import llmPrompt from "../../spec/llm-authoring.md?raw";
-inject();
+initializeAnalytics();
+const usageSession = new UsageSession();
const $ = (id: string): T =>
document.getElementById(id) as T;
@@ -74,6 +83,13 @@ let lastRomWarnings: Warning[] = [];
let lastContactSignature = "";
let lastContactRefresh = 0;
let scrubDiagnosticsRefresh = 0;
+let documentRevision = 1;
+let pendingRenderTrigger: RenderTrigger = "initial";
+
+function documentKind(): DocumentKind {
+ if (currentPresetId) return "preset";
+ return initialDocumentWasShared ? "shared" : "custom";
+}
/** Merge live solver residuals with source diagnostics without repainting each frame. */
function refreshContactDiagnostics(force = false): void {
@@ -255,9 +271,16 @@ function scheduleRecompile(): void {
}
/** Keep the address bar and library label in sync with editor changes. */
-function handleEditorChange(source: string): void {
+function handleEditorChange(source: string, userInitiated: boolean): void {
+ const editedDocumentKind = documentKind();
const preset = PRESETS.find((p) => p.source === source);
currentPresetId = preset?.id ?? null;
+ if (userInitiated) {
+ usageSession.trackFirstEdit(editedDocumentKind);
+ initialDocumentWasShared = false;
+ documentRevision++;
+ pendingRenderTrigger = "editor_change";
+ }
setCurrentPresetLabel(
preset,
source.trim() ? "Custom movement" : "New movement",
@@ -292,6 +315,11 @@ function recompile(): void {
if (ir && viewer) {
viewer.load(ir);
updateFloorGuideKey();
+ usageSession.trackSuccessfulRender(
+ documentRevision,
+ pendingRenderTrigger,
+ documentKind(),
+ );
viewer.setLoop(loop.checked);
viewer.setSpeed(Number(speed.value));
viewer.play();
@@ -493,6 +521,13 @@ function loadPreset(id: string): void {
const preset = PRESETS.find((p) => p.id === id);
if (!preset) return;
currentPresetId = preset.id;
+ initialDocumentWasShared = false;
+ documentRevision++;
+ pendingRenderTrigger = "preset_open";
+ trackUsageEvent(USAGE_EVENT_NAMES.presetOpened, {
+ source: "library",
+ preset_id: preset.id,
+ });
setCurrentPresetLabel(preset, preset.label);
editorApi?.setValue(preset.source);
history.replaceState(null, "", buildNicePlayPath(preset.source));
@@ -513,6 +548,7 @@ renderLibraryList();
// its text first.
$("new-doc").addEventListener("click", () => {
currentPresetId = null;
+ initialDocumentWasShared = false;
setCurrentPresetLabel(undefined, "New movement");
editorApi?.setValue("");
history.replaceState(null, "", "/play");
@@ -632,6 +668,9 @@ async function shareLink(): Promise {
const url = `${location.origin}${path}${hash}`;
history.replaceState(null, "", `${path}${hash}`);
await navigator.clipboard.writeText(url);
+ trackUsageEvent(USAGE_EVENT_NAMES.shareCreated, {
+ share_kind: path === "/play" ? "encoded" : "preset",
+ });
flash(shareBtn, "Link copied ✓", "success");
} catch (err) {
const message =
@@ -766,6 +805,7 @@ $("intro-dismiss").addEventListener("click", () => {
// over the default preset, opening exactly like a library selection.
const sharedSource =
resolveSharedSource(window.location.hash) ?? resolveSharedPath(window.location.pathname);
+let initialDocumentWasShared = Boolean(sharedSource);
let initialDoc: string;
if (sharedSource) {
const preset = PRESETS.find((p) => p.source === sharedSource);
@@ -773,10 +813,45 @@ if (sharedSource) {
setCurrentPresetLabel(preset, "↗ Shared link");
initialDoc = sharedSource;
intro.hidden = true;
+ pendingRenderTrigger = window.location.hash ? "shared_link" : "initial";
+ if (preset) {
+ let source: PresetOpenSource = window.location.hash
+ ? "shared_link"
+ : "direct_url";
+ try {
+ const referrer = new URL(document.referrer);
+ if (
+ !window.location.hash &&
+ referrer.origin === location.origin &&
+ referrer.pathname === "/"
+ ) {
+ source = "landing_cta";
+ }
+ } catch {
+ // Missing or external referrer: keep the direct/shared classification.
+ }
+ trackUsageEvent(USAGE_EVENT_NAMES.presetOpened, {
+ source,
+ preset_id: preset.id,
+ });
+ }
} else {
initialDoc = DEFAULT_PRESET.source;
currentPresetId = DEFAULT_PRESET.id;
setCurrentPresetLabel(DEFAULT_PRESET, DEFAULT_PRESET.label);
+ let source: PresetOpenSource = "direct_url";
+ try {
+ const referrer = new URL(document.referrer);
+ if (referrer.origin === location.origin && referrer.pathname === "/") {
+ source = "landing_cta";
+ }
+ } catch {
+ // Direct visits have no usable referrer.
+ }
+ trackUsageEvent(USAGE_EVENT_NAMES.presetOpened, {
+ source,
+ preset_id: DEFAULT_PRESET.id,
+ });
}
// Boot the two heavyweights (CodeMirror editor + Three.js renderer) after the
diff --git a/playground/src/vercel-analytics.ts b/playground/src/vercel-analytics.ts
new file mode 100644
index 0000000..df90921
--- /dev/null
+++ b/playground/src/vercel-analytics.ts
@@ -0,0 +1,17 @@
+import { inject, track } from "@vercel/analytics";
+import {
+ configureUsageAnalytics,
+ type UsageEventSink,
+} from "./analytics.js";
+
+/**
+ * Pageviews remain enabled exactly as before. Product events are opt-in because
+ * Vercel Hobby accepts pageviews but does not expose custom events.
+ */
+export function initializeAnalytics(): void {
+ inject();
+ if (import.meta.env.VITE_PRODUCT_ANALYTICS_PROVIDER !== "vercel") return;
+ configureUsageAnalytics(((name, properties) => {
+ track(name, properties);
+ }) as UsageEventSink);
+}
diff --git a/playground/test/analytics-wiring.test.ts b/playground/test/analytics-wiring.test.ts
new file mode 100644
index 0000000..b4d5682
--- /dev/null
+++ b/playground/test/analytics-wiring.test.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+const root = resolve(import.meta.dirname, "../..");
+const main = readFileSync(resolve(root, "playground/src/main.ts"), "utf8");
+const editor = readFileSync(resolve(root, "playground/src/editor.ts"), "utf8");
+const products = readFileSync(
+ resolve(root, "playground/src/for-products.ts"),
+ "utf8",
+);
+
+describe("product analytics UI wiring", () => {
+ it("distinguishes user editor transactions from programmatic document loads", () => {
+ expect(editor).toContain("Transaction.userEvent");
+ expect(main).toContain("if (userInitiated)");
+ expect(main).toContain("usageSession.trackFirstEdit");
+ });
+
+ it("tracks render only after a successful viewer load", () => {
+ const load = main.indexOf("viewer.load(ir)");
+ const tracked = main.indexOf("usageSession.trackSuccessfulRender", load);
+ expect(load).toBeGreaterThan(-1);
+ expect(tracked).toBeGreaterThan(load);
+ });
+
+ it("tracks share only after the link reaches the clipboard", () => {
+ const copied = main.indexOf("await navigator.clipboard.writeText(url)");
+ const tracked = main.indexOf("USAGE_EVENT_NAMES.shareCreated", copied);
+ expect(copied).toBeGreaterThan(-1);
+ expect(tracked).toBeGreaterThan(copied);
+ });
+
+ it("tracks install copy only in the clipboard success branch", () => {
+ const copied = products.indexOf("await navigator.clipboard.writeText(command)");
+ const tracked = products.indexOf(
+ "USAGE_EVENT_NAMES.installCommandCopied",
+ copied,
+ );
+ expect(copied).toBeGreaterThan(-1);
+ expect(tracked).toBeGreaterThan(copied);
+ });
+});
diff --git a/playground/test/analytics.test.ts b/playground/test/analytics.test.ts
new file mode 100644
index 0000000..796a392
--- /dev/null
+++ b/playground/test/analytics.test.ts
@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ configureUsageAnalytics,
+ trackUsageEvent,
+ UsageSession,
+ USAGE_EVENT_NAMES,
+ type UsageEventSink,
+} from "../src/analytics.js";
+
+describe("product usage analytics", () => {
+ const sink = vi.fn();
+
+ beforeEach(() => {
+ sink.mockClear();
+ configureUsageAnalytics(sink);
+ });
+
+ it("keeps stable event names in one typed dictionary", () => {
+ expect(USAGE_EVENT_NAMES).toEqual({
+ presetOpened: "preset_opened",
+ editorChanged: "editor_changed",
+ renderSucceeded: "render_succeeded",
+ shareCreated: "share_created",
+ embedDocsClicked: "embed_docs_clicked",
+ installCommandCopied: "install_command_copied",
+ });
+ });
+
+ it("deduplicates the first meaningful user edit per page session", () => {
+ const session = new UsageSession();
+ session.trackFirstEdit("preset");
+ session.trackFirstEdit("custom");
+
+ expect(sink).toHaveBeenCalledOnce();
+ expect(sink).toHaveBeenCalledWith("editor_changed", {
+ document_kind: "preset",
+ });
+ });
+
+ it("deduplicates successful renders by internal revision", () => {
+ const session = new UsageSession();
+ session.trackSuccessfulRender(4, "editor_change", "custom");
+ session.trackSuccessfulRender(4, "editor_change", "custom");
+ session.trackSuccessfulRender(5, "preset_open", "preset");
+
+ expect(sink).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not let a blocked provider break product behavior", () => {
+ configureUsageAnalytics((() => {
+ throw new Error("blocked");
+ }) as UsageEventSink);
+
+ expect(() =>
+ trackUsageEvent("share_created", { share_kind: "encoded" }),
+ ).not.toThrow();
+ });
+});
diff --git a/playground/test/for-products.test.ts b/playground/test/for-products.test.ts
index 5205c2e..5b9fe88 100644
--- a/playground/test/for-products.test.ts
+++ b/playground/test/for-products.test.ts
@@ -35,4 +35,11 @@ describe("product integration page", () => {
it("provides an integration-specific contact action", () => {
expect(page).toContain("mailto:hello@posecode.org?subject=Posecode%20product%20integration");
});
+
+ it("wires real embed documentation and install command actions", () => {
+ expect(page).toContain("data-embed-docs");
+ expect(page).toContain('data-copy-command="embed"');
+ expect(page).toContain('data-copy-command="packages"');
+ expect(page).toContain('data-copy-command="mcp"');
+ });
});