From dd702a7236800eb211654e58c809ed0fc583de55 Mon Sep 17 00:00:00 2001 From: a-baran-orhan Date: Mon, 3 Aug 2026 23:25:14 +0300 Subject: [PATCH] Add guided first-edit activation --- playground/play.html | 29 ++++ playground/src/analytics.ts | 16 ++ playground/src/editor.ts | 19 +++ playground/src/guided-first-edit.ts | 120 +++++++++++++++ playground/src/main.ts | 92 +++++++++++- playground/src/style.css | 68 +++++++++ playground/test/analytics.test.ts | 4 + playground/test/guided-first-edit.test.ts | 174 ++++++++++++++++++++++ 8 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 playground/src/guided-first-edit.ts create mode 100644 playground/test/guided-first-edit.test.ts diff --git a/playground/play.html b/playground/play.html index 12818c6..12bb37c 100644 --- a/playground/play.html +++ b/playground/play.html @@ -183,6 +183,35 @@ + +
diff --git a/playground/src/analytics.ts b/playground/src/analytics.ts index 408751b..b24d6af 100644 --- a/playground/src/analytics.ts +++ b/playground/src/analytics.ts @@ -12,6 +12,10 @@ export const USAGE_EVENT_NAMES = { promptCopied: "prompt_copied", movementAttempted: "movement_attempted", shareCreated: "share_created", + guidedEditShown: "guided_edit_shown", + guidedEditStarted: "guided_edit_started", + guidedEditCompleted: "guided_edit_completed", + guidedEditDismissed: "guided_edit_dismissed", embedDocsClicked: "embed_docs_clicked", installCommandCopied: "install_command_copied", } as const; @@ -30,6 +34,8 @@ export type RenderTrigger = export type ShareKind = "preset" | "encoded"; export type PromptLocation = "landing" | "playground"; export type InstallCommand = "embed" | "packages" | "mcp"; +export type GuidedEditSurface = "desktop" | "mobile"; +export type GuidedEditStage = "edit" | "success"; export interface UsageEventMap { preset_opened: { source: PresetOpenSource; preset_id: string }; @@ -38,6 +44,16 @@ export interface UsageEventMap { prompt_copied: { location: PromptLocation }; movement_attempted: Record; share_created: { share_kind: ShareKind }; + guided_edit_shown: { experiment: "superhero_first_edit_v1" }; + guided_edit_started: { + experiment: "superhero_first_edit_v1"; + surface: GuidedEditSurface; + }; + guided_edit_completed: { experiment: "superhero_first_edit_v1" }; + guided_edit_dismissed: { + experiment: "superhero_first_edit_v1"; + stage: GuidedEditStage; + }; embed_docs_clicked: { location: "for_products" }; install_command_copied: { command: InstallCommand; diff --git a/playground/src/editor.ts b/playground/src/editor.ts index 7c120ee..9740f3b 100644 --- a/playground/src/editor.ts +++ b/playground/src/editor.ts @@ -601,6 +601,8 @@ export interface PosecodeEditor { getValue(): string; setValue(doc: string): void; focus(): void; + /** Reveal and focus a direct angle spinner for the requested joint action. */ + focusAngleControl(joint: string, action: string): boolean; /** Highlight an inclusive 1-based line range as the active phase; null clears. */ highlightPhase(from: number | null, to?: number): void; } @@ -744,6 +746,23 @@ export function createPosecodeEditor( opts.onJointSelect?.(null, []); }, focus: () => view.focus(), + focusAngleControl: (joint: string, action: string) => { + const target = findAngleTargets(view.state.doc.toString()).find( + (candidate) => + candidate.joint === joint && candidate.action === action, + ); + if (!target) return false; + view.dispatch({ + selection: { anchor: target.angleFrom, head: target.angleTo }, + effects: [ + setSelectedJoint.of(target.joint), + setActiveAngle.of(target), + EditorView.scrollIntoView(target.angleFrom, { y: "center" }), + ], + }); + opts.onJointSelect?.(target.joint, expandJoint(target.joint)); + return true; + }, highlightPhase: (from: number | null, to?: number) => { view.dispatch({ effects: setPhaseHighlight.of( diff --git a/playground/src/guided-first-edit.ts b/playground/src/guided-first-edit.ts new file mode 100644 index 0000000..47c8640 --- /dev/null +++ b/playground/src/guided-first-edit.ts @@ -0,0 +1,120 @@ +import { + trackUsageEvent, + USAGE_EVENT_NAMES, + type GuidedEditSurface, +} from "./analytics.js"; +import { findAngleTargets } from "./direct-manipulation.js"; + +export const GUIDED_FIRST_EDIT_EXPERIMENT = "superhero_first_edit_v1"; + +export const GUIDED_FIRST_EDIT_TARGET = { + joint: "knee_right", + action: "flex", + initialDegrees: 123, + suggestedDegrees: 115, +} as const; + +export type GuidedFirstEditStage = + | "idle" + | "edit" + | "success" + | "dismissed"; + +export interface GuidedEditFocusRequest { + joint: string; + action: string; + switchToEditor: boolean; +} + +function targetDegrees(source: string): number | null { + const target = findAngleTargets(source).find( + ({ joint, action }) => + joint === GUIDED_FIRST_EDIT_TARGET.joint && + action === GUIDED_FIRST_EDIT_TARGET.action, + ); + return target?.degrees ?? null; +} + +/** Keep the experiment on the single intended entry point and known source. */ +export function shouldOfferGuidedFirstEdit( + pathname: string, + hash: string, + source: string, +): boolean { + return ( + pathname === "/play/superhero-landing" && + hash === "" && + targetDegrees(source) === GUIDED_FIRST_EDIT_TARGET.initialDegrees + ); +} + +/** + * Small state machine for the guided activation experiment. Source is inspected + * only in the browser and is never included in analytics properties. + */ +export class GuidedFirstEditSession { + stage: GuidedFirstEditStage = "idle"; + private targetWasEdited = false; + private started = false; + + offer(): boolean { + if (this.stage !== "idle") return false; + this.stage = "edit"; + trackUsageEvent(USAGE_EVENT_NAMES.guidedEditShown, { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + }); + return true; + } + + begin(surface: GuidedEditSurface): GuidedEditFocusRequest | null { + if (this.stage !== "edit") return null; + if (!this.started) { + this.started = true; + trackUsageEvent(USAGE_EVENT_NAMES.guidedEditStarted, { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + surface, + }); + } + return { + joint: GUIDED_FIRST_EDIT_TARGET.joint, + action: GUIDED_FIRST_EDIT_TARGET.action, + switchToEditor: surface === "mobile", + }; + } + + noteUserEdit(source: string, userInitiated: boolean): boolean { + if (this.stage !== "edit" || !userInitiated) return false; + const degrees = targetDegrees(source); + this.targetWasEdited = + degrees !== null && degrees !== GUIDED_FIRST_EDIT_TARGET.initialDegrees; + return this.targetWasEdited; + } + + /** Call only after the parser has accepted and the viewer has loaded source. */ + confirmValidCustomRender(source: string): boolean { + if (this.stage !== "edit" || !this.targetWasEdited) return false; + const degrees = targetDegrees(source); + if ( + degrees === null || + degrees === GUIDED_FIRST_EDIT_TARGET.initialDegrees + ) { + return false; + } + this.stage = "success"; + trackUsageEvent(USAGE_EVENT_NAMES.guidedEditCompleted, { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + }); + return true; + } + + dismiss(): boolean { + if (this.stage !== "edit" && this.stage !== "success") return false; + const stage = this.stage; + this.stage = "dismissed"; + trackUsageEvent(USAGE_EVENT_NAMES.guidedEditDismissed, { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + stage, + }); + return true; + } +} diff --git a/playground/src/main.ts b/playground/src/main.ts index 20b36b4..2189afb 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -29,6 +29,11 @@ import { ANIMATION_PROGRESS_MESSAGE, PRESETS } from "./presets.js"; import { prioritizeFeaturedMovement } from "./library-order.js"; import { SHOWCASE_CLIPS } from "./clips.js"; import { previewTimeForLine } from "./direct-manipulation.js"; +import { + GUIDED_FIRST_EDIT_TARGET, + GuidedFirstEditSession, + shouldOfferGuidedFirstEdit, +} from "./guided-first-edit.js"; // During source-only typechecks the playground resolves posecode-render's last // built declaration bundle. Keep the local extension explicit until the normal @@ -79,6 +84,12 @@ const downloadBvhBtn = $("download-bvh"); const downloadGltfBtn = $("download-gltf"); const tabEditor = $("tab-editor"); const tabViewer = $("tab-viewer"); +const guidedFirstEditCard = $("guided-first-edit"); +const guidedFirstEditTitle = $("guided-first-edit-title"); +const guidedFirstEditMessage = $("guided-first-edit-message"); +const guidedFirstEditStart = $("guided-first-edit-start"); +const guidedFirstEditShare = $("guided-first-edit-share"); +const guidedFirstEditDismiss = $("guided-first-edit-dismiss"); // Three.js is heavy (~530 kB). Like the landing page, we load the renderer // *after* the editor shell paints (dynamic import → its own chunk) so first @@ -100,6 +111,8 @@ let documentRevision = 1; let pendingRenderTrigger: RenderTrigger = "initial"; let selectedBoneIds: readonly string[] = []; let pendingPreviewLine: number | null = null; +let guidedFirstEditSession: GuidedFirstEditSession | null = null; +let pendingGuidedAngleFocus = false; /** Keep the source selection and its live 3D joint markers in sync. */ function handleJointSelect( @@ -313,6 +326,7 @@ function handleEditorChange( const preset = PRESETS.find((p) => p.source === source); currentPresetId = preset?.id ?? null; if (userInitiated) { + guidedFirstEditSession?.noteUserEdit(source, true); usageSession.trackFirstEdit(editedDocumentKind); initialDocumentWasShared = false; documentRevision++; @@ -358,6 +372,9 @@ function recompile(): void { documentKind() === "custom" ) { usageSession.trackFirstValidCustomMovement(); + if (guidedFirstEditSession?.confirmValidCustomRender(source)) { + renderGuidedFirstEdit(); + } } usageSession.trackSuccessfulRender( documentRevision, @@ -582,6 +599,7 @@ function renderLibraryList(): void { function loadPreset(id: string): void { const preset = PRESETS.find((p) => p.id === id); if (!preset) return; + dismissGuidedFirstEdit(); currentPresetId = preset.id; initialDocumentWasShared = false; documentRevision++; @@ -608,6 +626,7 @@ renderLibraryList(); // A human can write directly or paste a draft from another tool without // overwriting a preset by hand-selecting its text first. $("new-doc").addEventListener("click", () => { + dismissGuidedFirstEdit(); currentPresetId = null; initialDocumentWasShared = false; setCurrentPresetLabel(undefined, "New movement"); @@ -628,6 +647,48 @@ tabEditor.addEventListener("click", () => setMobileView("editor")); tabViewer.addEventListener("click", () => setMobileView("viewer")); setMobileView("viewer"); +function renderGuidedFirstEdit(): void { + const stage = guidedFirstEditSession?.stage ?? "idle"; + guidedFirstEditCard.hidden = stage === "idle" || stage === "dismissed"; + if (guidedFirstEditCard.hidden) return; + guidedFirstEditCard.dataset.stage = stage; + const succeeded = stage === "success"; + guidedFirstEditTitle.textContent = succeeded + ? "Nice — your edit rendered." + : "Bend the right knee a little less."; + guidedFirstEditMessage.textContent = succeeded + ? "The source is valid and the figure updated." + : `Change knee_right: flex ${GUIDED_FIRST_EDIT_TARGET.initialDegrees} to ${GUIDED_FIRST_EDIT_TARGET.suggestedDegrees}°.`; + guidedFirstEditStart.hidden = succeeded; + guidedFirstEditShare.hidden = !succeeded; + guidedFirstEditDismiss.setAttribute( + "aria-label", + succeeded ? "Dismiss edit success" : "Dismiss quick edit", + ); +} + +function dismissGuidedFirstEdit(): void { + if (guidedFirstEditSession?.dismiss()) renderGuidedFirstEdit(); +} + +function focusGuidedAngleControl(): void { + const session = guidedFirstEditSession; + if (!session) return; + const surface = matchMedia("(max-width: 860px)").matches + ? "mobile" + : "desktop"; + const request = session.begin(surface); + if (!request) return; + if (request.switchToEditor) setMobileView("editor"); + pendingGuidedAngleFocus = true; + if (editorApi?.focusAngleControl(request.joint, request.action)) { + pendingGuidedAngleFocus = false; + } +} + +guidedFirstEditStart.addEventListener("click", focusGuidedAngleControl); +guidedFirstEditDismiss.addEventListener("click", dismissGuidedFirstEdit); + // --- Transport --- playpause.addEventListener("click", () => { if (viewer) setPlaying(viewer.toggle()); @@ -720,9 +781,9 @@ copyBtn.addEventListener("click", () => copyPrompt(copyBtn)); // --- Share (permalink) --- // Snapshot the current document into a URL hash, reflect it in the address bar // (so it's bookmarkable), and copy the full link to the clipboard. -async function shareLink(): Promise { +async function shareLink(feedbackButton = shareBtn): Promise { if (!editorApi) return; // editor still loading; nothing to snapshot yet - flash(shareBtn, "Copying…", "pending", 0); + flash(feedbackButton, "Copying…", "pending", 0); try { const source = editorApi.getValue(); const path = buildNicePlayPath(source); @@ -733,7 +794,7 @@ async function shareLink(): Promise { usageSession.trackSuccessfulShare( path === "/play" ? "encoded" : "preset", ); - flash(shareBtn, "Link copied ✓", "success"); + flash(feedbackButton, "Link copied ✓", "success"); } catch (err) { const message = err instanceof TypeError @@ -741,10 +802,13 @@ async function shareLink(): Promise { : err instanceof RangeError ? "Too long to link" : "Copy failed"; - flash(shareBtn, message, "error"); + flash(feedbackButton, message, "error"); } } -shareBtn.addEventListener("click", shareLink); +shareBtn.addEventListener("click", () => void shareLink()); +guidedFirstEditShare.addEventListener("click", () => + void shareLink(guidedFirstEditShare), +); // --- BVH export --- // Bake the current movement's authored motion into a .bvh file and hand it to @@ -954,6 +1018,18 @@ if (sharedSource) { }); } +if ( + shouldOfferGuidedFirstEdit( + window.location.pathname, + window.location.hash, + initialDoc, + ) +) { + guidedFirstEditSession = new GuidedFirstEditSession(); + guidedFirstEditSession.offer(); + renderGuidedFirstEdit(); +} + // Boot the two heavyweights (CodeMirror editor + Three.js renderer) after the // shell paints, each in its own lazy chunk, so neither is on the critical path. // They load independently; recompile() self-guards until both are ready, and @@ -966,6 +1042,12 @@ void import("./editor.js").then(({ createPosecodeEditor }) => { onChange: handleEditorChange, onJointSelect: handleJointSelect, }); + if (pendingGuidedAngleFocus) { + pendingGuidedAngleFocus = !editorApi.focusAngleControl( + GUIDED_FIRST_EDIT_TARGET.joint, + GUIDED_FIRST_EDIT_TARGET.action, + ); + } recompile(); }); diff --git a/playground/src/style.css b/playground/src/style.css index 81c0280..0222cd7 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -1479,6 +1479,51 @@ select:hover { box-shadow: 0 4px 14px -6px var(--accent-glow); } +/* --- Guided first edit ---------------------------------------------------- */ +.guided-first-edit { + display: flex; + align-items: center; + gap: 18px; + padding: 10px 20px; + color: var(--text-2); + background: #121512; + border-bottom: 1px solid var(--accent-line); +} +.guided-first-edit-copy { + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; + font-size: 12.5px; +} +.guided-first-edit-kicker { + flex: none; + color: var(--accent); + font-family: var(--mono); + font-size: 9px; + font-weight: 700; + letter-spacing: .8px; + text-transform: uppercase; +} +.guided-first-edit-copy strong { + flex: none; + color: var(--text); +} +.guided-first-edit-copy code { + color: var(--text); + font-family: var(--mono); + font-size: 11.5px; +} +.guided-first-edit-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 9px; +} +.guided-first-edit[data-stage="success"] { + background: rgba(212, 255, 63, .07); +} + /* --- Responsive ----------------------------------------------------------- */ @media (max-width: 860px) { body { @@ -1700,6 +1745,29 @@ select:hover { display: none; } .intro { padding-left: 12px; padding-right: 12px; } + .guided-first-edit { + align-items: flex-start; + gap: 10px; + padding: 11px 12px; + } + .guided-first-edit-copy { + flex: 1; + align-items: flex-start; + flex-direction: column; + gap: 2px; + line-height: 1.4; + } + .guided-first-edit-kicker { + margin-bottom: 2px; + } + .guided-first-edit-actions { + flex: none; + align-items: flex-start; + } + .guided-first-edit-actions .btn { + white-space: normal; + text-align: center; + } .layout { grid-template-columns: 1fr; } .floor-guide-key { /* Clear the phase plus a two-line coaching cue instead of overlaying it. */ diff --git a/playground/test/analytics.test.ts b/playground/test/analytics.test.ts index a155d11..7843255 100644 --- a/playground/test/analytics.test.ts +++ b/playground/test/analytics.test.ts @@ -23,6 +23,10 @@ describe("product usage analytics", () => { promptCopied: "prompt_copied", movementAttempted: "movement_attempted", shareCreated: "share_created", + guidedEditShown: "guided_edit_shown", + guidedEditStarted: "guided_edit_started", + guidedEditCompleted: "guided_edit_completed", + guidedEditDismissed: "guided_edit_dismissed", embedDocsClicked: "embed_docs_clicked", installCommandCopied: "install_command_copied", }); diff --git a/playground/test/guided-first-edit.test.ts b/playground/test/guided-first-edit.test.ts new file mode 100644 index 0000000..b5f8f19 --- /dev/null +++ b/playground/test/guided-first-edit.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + configureUsageAnalytics, + type UsageEventSink, +} from "../src/analytics.js"; +import { + GUIDED_FIRST_EDIT_EXPERIMENT, + GuidedFirstEditSession, + shouldOfferGuidedFirstEdit, +} from "../src/guided-first-edit.js"; + +const root = resolve(import.meta.dirname, "../.."); +const superhero = readFileSync( + resolve(root, "spec/examples/superhero-landing.posecode"), + "utf8", +); + +describe("guided first-edit activation", () => { + const sink = vi.fn(); + + beforeEach(() => { + sink.mockClear(); + configureUsageAnalytics(sink); + }); + + it("is offered only on the exact default superhero route and known source", () => { + expect( + shouldOfferGuidedFirstEdit( + "/play/superhero-landing", + "", + superhero, + ), + ).toBe(true); + expect(shouldOfferGuidedFirstEdit("/play", "", superhero)).toBe(false); + expect( + shouldOfferGuidedFirstEdit( + "/play/superhero-landing", + "#doc=private", + superhero, + ), + ).toBe(false); + expect( + shouldOfferGuidedFirstEdit( + "/play/superhero-landing", + "", + superhero.replace("knee_right: flex 123", "knee_right: flex 115"), + ), + ).toBe(false); + }); + + it("focuses in place on desktop and requests the Editor panel on mobile", () => { + const desktop = new GuidedFirstEditSession(); + desktop.offer(); + expect(desktop.begin("desktop")).toEqual({ + joint: "knee_right", + action: "flex", + switchToEditor: false, + }); + + const mobile = new GuidedFirstEditSession(); + mobile.offer(); + expect(mobile.begin("mobile")).toEqual({ + joint: "knee_right", + action: "flex", + switchToEditor: true, + }); + + expect(sink.mock.calls).toContainEqual([ + "guided_edit_started", + { experiment: GUIDED_FIRST_EDIT_EXPERIMENT, surface: "desktop" }, + ]); + expect(sink.mock.calls).toContainEqual([ + "guided_edit_started", + { experiment: GUIDED_FIRST_EDIT_EXPERIMENT, surface: "mobile" }, + ]); + }); + + it("dismisses from the edit state without allowing later completion", () => { + const session = new GuidedFirstEditSession(); + session.offer(); + expect(session.dismiss()).toBe(true); + expect(session.stage).toBe("dismissed"); + + const changed = superhero.replace( + "knee_right: flex 123", + "knee_right: flex 115", + ); + expect(session.noteUserEdit(changed, true)).toBe(false); + expect(session.confirmValidCustomRender(changed)).toBe(false); + expect(sink).toHaveBeenCalledWith("guided_edit_dismissed", { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + stage: "edit", + }); + expect(sink).not.toHaveBeenCalledWith( + "guided_edit_completed", + expect.anything(), + ); + }); + + it("completes only after the target user edit receives a valid custom render", () => { + const session = new GuidedFirstEditSession(); + session.offer(); + expect(session.confirmValidCustomRender(superhero)).toBe(false); + + const unrelated = superhero.replace( + "chest: flex 9", + "chest: flex 10", + ); + expect(session.noteUserEdit(unrelated, true)).toBe(false); + expect(session.confirmValidCustomRender(unrelated)).toBe(false); + + const changed = superhero.replace( + "knee_right: flex 123", + "knee_right: flex 115", + ); + expect(session.noteUserEdit(changed, false)).toBe(false); + expect(session.noteUserEdit(changed, true)).toBe(true); + expect(session.confirmValidCustomRender(changed)).toBe(true); + expect(session.stage).toBe("success"); + expect(sink).toHaveBeenCalledWith("guided_edit_completed", { + experiment: GUIDED_FIRST_EDIT_EXPERIMENT, + }); + + const analyticsPayload = JSON.stringify(sink.mock.calls); + expect(analyticsPayload).not.toContain("knee_right"); + expect(analyticsPayload).not.toContain("115"); + expect(analyticsPayload).not.toContain("posecode posture"); + }); +}); + +describe("guided first-edit UI wiring", () => { + const html = readFileSync(resolve(root, "playground/play.html"), "utf8"); + const main = readFileSync(resolve(root, "playground/src/main.ts"), "utf8"); + const editor = readFileSync(resolve(root, "playground/src/editor.ts"), "utf8"); + + it("provides accessible edit, Share, and dismissal controls", () => { + expect(html).toContain('id="guided-first-edit"'); + expect(html).toContain('aria-labelledby="guided-first-edit-title"'); + expect(html).toContain('id="guided-first-edit-message" aria-live="polite"'); + expect(html).toContain('id="guided-first-edit-share"'); + expect(html).toContain('aria-label="Dismiss quick edit"'); + }); + + it("switches mobile before focusing the existing direct angle control", () => { + const switchPanel = main.indexOf( + 'if (request.switchToEditor) setMobileView("editor")', + ); + const focusControl = main.indexOf( + "editorApi?.focusAngleControl(request.joint, request.action)", + switchPanel, + ); + expect(switchPanel).toBeGreaterThan(-1); + expect(focusControl).toBeGreaterThan(switchPanel); + expect(editor).toContain("focusAngleControl(joint: string, action: string)"); + expect(editor).toContain("setActiveAngle.of(target)"); + expect(editor).toContain("EditorView.scrollIntoView"); + }); + + it("confirms success only in the existing valid custom render branch", () => { + const load = main.indexOf("viewer.load(ir)"); + const valid = main.indexOf('errors.length === 0', load); + const custom = main.indexOf('documentKind() === "custom"', valid); + const completed = main.indexOf( + "guidedFirstEditSession?.confirmValidCustomRender(source)", + custom, + ); + expect(load).toBeGreaterThan(-1); + expect(valid).toBeGreaterThan(load); + expect(custom).toBeGreaterThan(valid); + expect(completed).toBeGreaterThan(custom); + }); +});