Skip to content
Merged
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
29 changes: 29 additions & 0 deletions playground/play.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,35 @@
</button>
</nav>

<aside
id="guided-first-edit"
class="guided-first-edit"
aria-labelledby="guided-first-edit-title"
hidden
>
<div class="guided-first-edit-copy">
<span class="guided-first-edit-kicker">Quick edit</span>
<strong id="guided-first-edit-title">Bend the right knee a little less.</strong>
<span id="guided-first-edit-message" aria-live="polite">
Change <code>knee_right: flex 123</code> to <code>115°</code>.
</span>
</div>
<div class="guided-first-edit-actions">
<button id="guided-first-edit-start" class="btn primary sm" type="button">
Adjust knee angle
</button>
<button id="guided-first-edit-share" class="btn primary sm" type="button" hidden>
Share movement
</button>
<button
id="guided-first-edit-dismiss"
class="x"
type="button"
aria-label="Dismiss quick edit"
>×</button>
</div>
</aside>

<main class="layout">
<section class="editor-pane">
<div class="pane-head">
Expand Down
16 changes: 16 additions & 0 deletions playground/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };
Expand All @@ -38,6 +44,16 @@ export interface UsageEventMap {
prompt_copied: { location: PromptLocation };
movement_attempted: Record<string, never>;
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;
Expand Down
19 changes: 19 additions & 0 deletions playground/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(
Expand Down
120 changes: 120 additions & 0 deletions playground/src/guided-first-edit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading