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>
+
+ + Read embed docs → +
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
+
+ +
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
+
+ +
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"'); + }); });