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
6 changes: 6 additions & 0 deletions .changeset/flow-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rustrak/openshowcase-recorder": minor
"@rustrak/openshowcase-extension": minor
---

A recorder flow can name its demo with `title`: the `.osrec` carries it and the editor opens the demo with that title (shown in the player's browser bar and used for the export's file name). The skill tells the agent to pick a short, descriptive one.
7 changes: 7 additions & 0 deletions .changeset/hotspot-style-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rustrak/openshowcase-player-core": minor
"@rustrak/openshowcase-player-react": minor
"@rustrak/openshowcase-player-vue": minor
---

Add a `hotspotStyle` option (`{ bgColor, textColor }`) to the player and the React/Vue `InteractiveDemo` that overrides every step's hotspot and tooltip colors, so a host app can force its brand color whatever the demo was recorded with.
7 changes: 7 additions & 0 deletions .changeset/live-appearance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rustrak/openshowcase-player-core": minor
"@rustrak/openshowcase-player-react": minor
"@rustrak/openshowcase-player-vue": minor
---

Add an `appearance` option (`"light" | "dark" | "auto"`) that overrides the demo's own, so the player can follow the host site's mode, or with `"auto"` the viewer's `prefers-color-scheme` as it changes. It and `hotspotStyle` now apply live (`Player.setAppearance()` / `setHotspotStyle()`, and prop changes in React/Vue) without remounting or restarting the demo.
5 changes: 5 additions & 0 deletions .changeset/navbar-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rustrak/openshowcase-player-core": patch
---

Polish the player's step bar: segments keep the same width (no reflow when the step changes), fills and the bar itself ease in and out instead of snapping, segments are thicker with a step number on hover, the counter no longer shifts width, and from 12 steps the segments join into one dense scrubber.
5 changes: 5 additions & 0 deletions .changeset/navbar-seek-during-video.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rustrak/openshowcase-player-core": patch
---

Fix clicking a navbar segment while a video step plays: the segments re-rendered every frame by detaching and re-inserting their buttons, which dropped the click.
2 changes: 2 additions & 0 deletions adapters/player-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ In SSR frameworks (Next.js App Router, etc.) render it from a client component (
| `assetBaseUrl` | `string` | Overrides the asset base URL inferred from `src`. |
| `aspectRatio` | `number` | Width/height ratio used while the container has no explicit height. Defaults to `16 / 9`. |
| `className` / `style` | | Applied to the container. |
| `appearance` | `"light" \| "dark" \| "auto"` | Overrides the demo's light/dark appearance (browser frame, background, controls), e.g. to follow your site's mode; `"auto"` follows the viewer's `prefers-color-scheme`. Changes apply live, without restarting the demo. |
| `hotspotStyle` | `{ bgColor?: string; textColor?: string }` | Overrides every step's hotspot/tooltip colors, e.g. to force your brand color whatever the demo was recorded with. Changes apply live. |
| `onStepChange` | `(index: number, step: Step) => void` | Called on every step change. |

## License
Expand Down
34 changes: 33 additions & 1 deletion adapters/player-react/src/InteractiveDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
type AssetResolver,
type HotspotStyle,
loadDemo,
Player,
type PlayerAppearance,
} from "@rustrak/openshowcase-player-core";
import type { Demo, Step } from "@rustrak/openshowcase-schema";
import { type CSSProperties, useEffect, useRef, useState } from "react";
Expand All @@ -20,6 +22,11 @@ export interface InteractiveDemoProps {
aspectRatio?: number;
className?: string;
style?: CSSProperties;
/** Overrides every step's hotspot/tooltip colors, e.g. to force your brand color. */
hotspotStyle?: HotspotStyle;
/** Overrides the demo's light/dark appearance, e.g. to follow your site's mode,
* or `"auto"` for the viewer's `prefers-color-scheme`. Changes apply live. */
appearance?: PlayerAppearance;
onStepChange?: (index: number, step: Step) => void;
}

Expand All @@ -40,12 +47,20 @@ export function InteractiveDemo({
aspectRatio = 16 / 9,
className,
style,
hotspotStyle,
appearance,
onStepChange,
}: InteractiveDemoProps) {
const [state, setState] = useState<LoadState>(
demo ? { status: "ready", demo, assetBaseUrl } : { status: "loading" },
);
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Player>(undefined);
// Compared by value: an inline `hotspotStyle={{...}}` is a new object every render
const { bgColor, textColor } = hotspotStyle ?? {};
// Read at mount; later changes go through the setters below, without a remount
const live = useRef({ appearance, bgColor, textColor });
live.current = { appearance, bgColor, textColor };

useEffect(() => {
if (demo) {
Expand Down Expand Up @@ -95,12 +110,29 @@ export function InteractiveDemo({
demo: state.demo,
assetBaseUrl: state.assetBaseUrl,
resolveAsset: state.resolveAsset,
hotspotStyle: {
bgColor: live.current.bgColor,
textColor: live.current.textColor,
},
appearance: live.current.appearance,
onStepChange,
});
player.mount();
return () => player.destroy();
playerRef.current = player;
return () => {
player.destroy();
playerRef.current = undefined;
};
}, [state, onStepChange]);

useEffect(() => {
playerRef.current?.setAppearance(appearance);
}, [appearance]);

useEffect(() => {
playerRef.current?.setHotspotStyle({ bgColor, textColor });
}, [bgColor, textColor]);

// The player always takes its content's own size — the container imposes no aspect ratio.
const ready = state.status === "ready";

Expand Down
31 changes: 31 additions & 0 deletions adapters/player-react/src/__test__/InteractiveDemo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@
expect(imageSources()).toContain(png);
});

it("follows appearance and hotspotStyle changes without remounting", async () => {
const png = URL.createObjectURL(await pngBlob());
const preloaded: Demo = {
...demo,
steps: [
{ ...demo.steps[0]!, image: { src: png, width: 800, height: 600 } },

Check warning on line 80 in adapters/player-react/src/__test__/InteractiveDemo.test.tsx

View workflow job for this annotation

GitHub Actions / ci

lint/style/noNonNullAssertion

Forbidden non-null assertion.
],
} as Demo;
root.render(<InteractiveDemo demo={preloaded} />);
await vi.waitFor(() => expect(hotspot()).not.toBeNull());
const player = container.querySelector(".openshowcase-player");

root.render(
<InteractiveDemo
demo={preloaded}
appearance="dark"
hotspotStyle={{ bgColor: "#0047ff" }}
/>,
);

await vi.waitFor(() =>
expect(player?.classList.contains("openshowcase-player--dark")).toBe(
true,
),
);
expect(
(hotspot() as HTMLElement).style.getPropertyValue("--openshowcase-color"),
).toBe("#0047ff");
expect(container.querySelector(".openshowcase-player")).toBe(player);
});

it("plays a single-file bundle", async () => {
const bundle = await buildDemoBundle({
demo,
Expand Down
2 changes: 2 additions & 0 deletions adapters/player-vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ In SSR frameworks (Nuxt, etc.) render it client-side only (e.g. `<ClientOnly>`),
| `demo` | `Demo` | A preloaded demo object, as an alternative to `src`. |
| `assetBaseUrl` | `string` | Overrides the asset base URL inferred from `src`. |
| `aspectRatio` | `number` | Width/height ratio used while the container has no explicit height. Defaults to `16 / 9`. |
| `appearance` | `"light" \| "dark" \| "auto"` | Overrides the demo's light/dark appearance (browser frame, background, controls), e.g. to follow your site's mode; `"auto"` follows the viewer's `prefers-color-scheme`. Changes apply live, without restarting the demo. |
| `hotspotStyle` | `{ bgColor?: string; textColor?: string }` | Overrides every step's hotspot/tooltip colors, e.g. to force your brand color whatever the demo was recorded with. Changes apply live. |
| `onStepChange` | `(index: number, step: Step) => void` | Called on every step change (also usable as `@step-change`). |

## License
Expand Down
9 changes: 9 additions & 0 deletions adapters/player-vue/src/InteractiveDemo.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
HotspotStyle,
PlayerAppearance,
} from "@rustrak/openshowcase-player-core";
import type { Demo, Step } from "@rustrak/openshowcase-schema";

export interface InteractiveDemoProps {
Expand All @@ -12,5 +16,10 @@ export interface InteractiveDemoProps {
assetBaseUrl?: string;
/** Width/height ratio used while no explicit height is set via `class`/`style`. Defaults to 16/9. */
aspectRatio?: number;
/** Overrides every step's hotspot/tooltip colors, e.g. to force your brand color. */
hotspotStyle?: HotspotStyle;
/** Overrides the demo's light/dark appearance, e.g. to follow your site's mode,
* or `"auto"` for the viewer's `prefers-color-scheme`. Changes apply live. */
appearance?: PlayerAppearance;
onStepChange?: (index: number, step: Step) => void;
}
11 changes: 11 additions & 0 deletions adapters/player-vue/src/InteractiveDemo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,23 @@ function mountPlayer(): void {
demo: state.value.demo,
assetBaseUrl: state.value.assetBaseUrl,
resolveAsset: state.value.resolveAsset,
hotspotStyle: props.hotspotStyle,
appearance: props.appearance,
onStepChange: props.onStepChange,
});
player.mount();
}

watch(state, mountPlayer);
// Live: no remount, the current step keeps playing
watch(
() => props.appearance,
(appearance) => player?.setAppearance(appearance),
);
watch(
() => [props.hotspotStyle?.bgColor, props.hotspotStyle?.textColor],
() => player?.setHotspotStyle(props.hotspotStyle),
);
onMounted(mountPlayer);
onUnmounted(() => player?.destroy());

Expand Down
31 changes: 30 additions & 1 deletion adapters/player-vue/src/__test__/InteractiveDemo.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { buildDemoBundle } from "@rustrak/openshowcase-exporter";
import type { Demo } from "@rustrak/openshowcase-schema";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type App, createApp, h } from "vue";
import { type App, createApp, h, reactive } from "vue";
import InteractiveDemo from "../InteractiveDemo.vue";

// Same cases as player-react's tests: the two adapters keep feature parity.
Expand Down Expand Up @@ -78,6 +78,35 @@ describe("<InteractiveDemo />", () => {
expect(imageSources()).toContain(png);
});

it("follows appearance and hotspotStyle changes without remounting", async () => {
const png = URL.createObjectURL(await pngBlob());
const props = reactive<Record<string, unknown>>({
demo: {
...demo,
steps: [
{ ...demo.steps[0], image: { src: png, width: 800, height: 600 } },
],
},
});
app = createApp({ render: () => h(InteractiveDemo, props) });
app.mount(container);
await vi.waitFor(() => expect(hotspot()).not.toBeNull());
const player = container.querySelector(".openshowcase-player");

props.appearance = "dark";
props.hotspotStyle = { bgColor: "#0047ff" };

await vi.waitFor(() =>
expect(player?.classList.contains("openshowcase-player--dark")).toBe(
true,
),
);
expect(
(hotspot() as HTMLElement).style.getPropertyValue("--openshowcase-color"),
).toBe("#0047ff");
expect(container.querySelector(".openshowcase-player")).toBe(player);
});

it("plays a single-file bundle", async () => {
const bundle = await buildDemoBundle({
demo,
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ WXT + React Chrome extension (MV3) with two jobs: **capture** (continuous tab vi
## Recordings from the recorder CLI

- `lib/automation.ts` is the API `packages/recorder` calls over CDP (`globalThis.openshowcase` in the service worker): `start` → `stop` (waits for the save) → `exportFile` (with the flow's direction) → `readChunk` (base64). Its shape and `Direction` (`lib/direction.ts`) are mirrored in the recorder's `browser.ts`; change both.
- Direction: `cueClicks` puts the flow's `label`/`zoom`/`hotspot` on the matching click markers; `RecordingRecord.direction` keeps `cuts` and `zooms`. `buildStepPlan` uses them only when present (hand-made recordings are unchanged): a `hotspot: false` click is activity, not a photo; cuts split clips (jump cuts, and don't count toward the 3s long-gap rule); a zoom splits its clip into wide → zoomed (padded 0.5s) → wide contiguous pieces.
- Direction: `cueClicks` puts the flow's `label`/`zoom`/`hotspot` on the matching click markers; `RecordingRecord.direction` keeps `cuts`, `zooms` and the flow's `title` (the editor's starting title). `buildStepPlan` uses them only when present (hand-made recordings are unchanged): a `hotspot: false` click is activity, not a photo; cuts split clips (jump cuts, and don't count toward the 3s long-gap rule); a zoom splits its clip into wide → zoomed (padded 0.5s) → wide contiguous pieces.
- A session started that way has `automated: true`, so saving it opens no editor tab.
- `.osrec` (`lib/recording-file.ts`): `OSREC` + version byte, uint32 LE header length, JSON header (recording meta, markers, optional direction), then the webm. The editor imports it from its empty screen (`ImportScreen`, also reached from the popup's file icon), or on load from a file packaged in the extension (`editor.html?import=imports/<name>.osrec`, `core/import-source.ts`: what the recorder's review browser uses), into IndexedDB under a new id.
- `MarkerRecord.label` (the flow's `tooltip`) wins over `elementText` for the hotspot's tooltip.
Expand Down
3 changes: 2 additions & 1 deletion apps/extension/entrypoints/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ export function Editor({
}: EditorProps) {
const { doc, commit, patchAll, undo, redo, canUndo, canRedo } =
useEditorDocument({
title: "Untitled demo",
// a recorder CLI flow names its demo
title: recording.direction?.title ?? "Untitled demo",
theme: defaultTheme,
steps: initialSteps,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/lib/__test__/unit/automation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe("exportFile / readChunk", () => {
clicks: [{ start: 2_450, end: 2_600, label: "Open the menu", zoom: 2 }],
cuts: [{ start: 2_700, end: 2_800 }],
zooms: [],
title: "Create an invoice",
});

let base64 = "";
Expand All @@ -139,6 +140,7 @@ describe("exportFile / readChunk", () => {
expect(file.direction).toEqual({
cuts: [{ start: 2_700, end: 2_800 }],
zooms: [],
title: "Create an invoice",
});
expect([...new Uint8Array(await file.video.arrayBuffer())]).toEqual([
9, 8, 7,
Expand Down
1 change: 1 addition & 0 deletions apps/extension/lib/__test__/unit/recording-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe("packRecording / unpackRecording", () => {
const direction = {
cuts: [{ start: 2_600, end: 2_900 }],
zooms: [{ start: 2_500, end: 2_800, x: 0.4, y: 0.6, scale: 2 }],
title: "Create an invoice",
};
const unpacked = await unpackRecording(
packRecording(recordingFile({ direction })),
Expand Down
9 changes: 7 additions & 2 deletions apps/extension/lib/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,15 @@ export function createAutomation(deps: AutomationDeps) {
deps.getMarkers(recordingId),
]);
if (!record) throw new Error(`No recording ${recordingId}`);
const { clicks = [], cuts = [], zooms = [] } = direction ?? {};
const { clicks = [], ...recorded } = direction ?? {};
exported = packRecording(
fileFromRecords(
{ ...record, ...(direction && { direction: { cuts, zooms } }) },
{
...record,
...(direction && {
direction: { cuts: [], zooms: [], ...recorded },
}),
},
cueClicks(markers, clicks),
),
);
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface RecordingDirection {
cuts: TimeRange[];
/** Clips that play an action the flow zoomed into frame on it. */
zooms: (TimeRange & { x: number; y: number; scale: number })[];
/** The flow's name for the demo: the editor's starting title. */
title?: string;
}

export type MarkerKind = "click" | "scroll" | "type" | "drag";
Expand Down
6 changes: 5 additions & 1 deletion apps/extension/lib/recording-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ export async function unpackRecording(blob: Blob): Promise<RecordingFile> {
(m) => typeof m?.capturedAt === "number" && typeof m.pageUrl === "string",
) ||
(direction != null &&
!(Array.isArray(direction.cuts) && Array.isArray(direction.zooms)))
!(
Array.isArray(direction.cuts) &&
Array.isArray(direction.zooms) &&
(direction.title == null || typeof direction.title === "string")
))
)
throw damaged();

Expand Down
4 changes: 3 additions & 1 deletion packages/player-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ loaded.dispose(); // stops the download and frees the bundle's asset URLs
| `demo` | `Demo` | A validated demo (use `parseDemo` from the schema package). |
| `assetBaseUrl` | `string` | Prefix for relative image/video paths. |
| `resolveAsset` | `(src) => string \| Promise<string>` | Serves asset URLs instead of `assetBaseUrl` (pass `loadDemo()`'s for a bundle). |
| `appearance` | `"light" \| "dark" \| "auto"` | Overrides the demo's light/dark appearance (browser frame, background, controls), e.g. to follow your site's mode; `"auto"` follows the viewer's `prefers-color-scheme`. Changes apply live, without restarting the demo. |
| `hotspotStyle` | `{ bgColor?: string; textColor?: string }` | Overrides every step's hotspot/tooltip colors, e.g. to force your brand color whatever the demo was recorded with. Changes apply live. |
| `onStepChange` | `(index, step) => void` | Called on every step change. |

`readStoredZip(stream)` yields a bundle's files as they arrive, for tools that inspect a bundle.

`loadDemo(src, { assetBaseUrl?, signal? })` fetches a demo and tells a bundle from a `steps.json` by its first bytes. It resolves as soon as the demo is known; a bundle's assets keep downloading in the one request, each served as soon as it arrives.

Methods: `mount()`, `destroy()`, `next()`, `prev()`, `goTo(index)`. Getters: `currentIndex`, `currentStep`.
Methods: `mount()`, `destroy()`, `next()`, `prev()`, `goTo(index)`, `setAppearance(appearance)`, `setHotspotStyle(style)` (both live, without remounting). Getters: `currentIndex`, `currentStep`.

The player's styles are scoped under `.openshowcase-player` and don't reset the host page.

Expand Down
12 changes: 11 additions & 1 deletion packages/player-core/src/__test__/component/Navbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ describe("Navbar", () => {
expect(buttons[0]!.classList.contains("segment--active")).toBe(false);
});

it("joins many segments into one dense scrubber", async () => {
const few = await render({ segments: segments() });
expect(few.container.querySelector(".segments--dense")).toBeNull();

const many = await render({
segments: Array.from({ length: 12 }, () => segments()[2]!),
});
expect(many.container.querySelector(".segments--dense")).not.toBeNull();
});

it("calls onSeek with the clicked segment index", async () => {
const onSeek = vi.fn();
const screen = await render({
Expand All @@ -45,7 +55,7 @@ describe("Navbar", () => {
onSeek,
});

await screen.getByTitle("Go to step 3").click();
await screen.getByRole("button", { name: "Go to step 3" }).click();

expect(onSeek).toHaveBeenCalledWith(2);
});
Expand Down
Loading
Loading