DemoDirector (DemoDirector v0.1.6)

Copy Markdown View Source

Reproducible, replayable demos for Phoenix LiveView — author reusable scripts, with or without AI.

DemoDirector lets you record a narrated walkthrough of a Phoenix LiveView app as a tiny Elixir script. Anyone can replay it later — against the real app, against real data, with no AI in the runtime path. During playback, a subtitle bar narrates word-by-word, a highlight ring tracks the next element, and characters get typed into form fields at a readable speed.

See the README for the full integration walkthrough.

Concept

The package is intentionally small. The helpers in this module return JavaScript-string fragments that compose, via newline-joined IO.puts, into a script the runtime evaluates. Author by hand or let an AI agent drive the helpers live (e.g. via Tidewave Web's browser_eval); either way the saved .exs is the durable artifact.

Two layers:

  1. Top-level helpers in this module (subtitle/1, highlight/1, fill/2, fill_typed/3, click/1, wait/1) — each emits one JS statement.
  2. Overlay components in DemoDirector.Components — render the subtitle bar and highlight ring plus load the runtime. Host apps mount this once on a dev-time root layout.

Quick start

# In your dev-time root layout:
import DemoDirector.Components

~H"""
<.demo_director_overlay />
"""

# Save a demo at priv/demos/onboarding.exs:
# Demo: walk a new user through their first post.
# @start_at "/"

alias DemoDirector, as: DD

steps = [
  DD.subtitle("Let's add a new post."),
  DD.wait(1500),
  DD.highlight("#new-post"),
  DD.click("#new-post")
]

IO.puts(Enum.join(steps, "\n"))

Selectors

The runtime resolves targets in two passes: data-demo-id first, then document.querySelector. Prefer the most stable handle that already exists in the host's markup (semantic ids, label-pointed form ids, distinctive attributes). Reach for data-demo-id (via DemoDirector.HEEx.demo_id/1) only when no such handle exists. Avoid :nth-child chains and deep descendant paths.

Summary

Types

A demo-id string. Maps to the value of a data-demo-id attribute on one or more elements in the rendered page.

Options accepted by typing-driven helpers.

Functions

Clicks the element with the given demo-id.

Fills the element with the given demo-id with value instantly.

Fills the element with the given demo-id one character at a time, dispatching input and keyup events between keystrokes.

Highlights the element with the given demo-id.

Sets the subtitle overlay text.

Pauses for ms milliseconds. Useful between steps to let the user read a subtitle or watch a transition complete.

Types

demo_id()

@type demo_id() :: String.t()

A demo-id string. Maps to the value of a data-demo-id attribute on one or more elements in the rendered page.

type_opts()

@type type_opts() :: [{:per_char_ms, pos_integer()}]

Options accepted by typing-driven helpers.

  • :per_char_ms — delay between simulated keystrokes (default: 35).

Functions

click(id)

@spec click(demo_id()) :: String.t()

Clicks the element with the given demo-id.

fill(id, value)

@spec fill(demo_id(), String.t()) :: String.t()

Fills the element with the given demo-id with value instantly.

Useful for fields where typing animation would distract — uuids, prefilled fields, anything the user shouldn't be drawn to.

fill_typed(id, value, opts \\ [])

@spec fill_typed(demo_id(), String.t(), type_opts()) :: String.t()

Fills the element with the given demo-id one character at a time, dispatching input and keyup events between keystrokes.

The emitted JS is awaited so subsequent steps don't fire before typing completes.

Options

  • :per_char_ms — delay between simulated keystrokes (default: 35). Lower for filler text the viewer shouldn't linger on; raise for content the viewer is meant to read.

Examples

iex> DemoDirector.fill_typed("note", "Patient stable.")
~s|await window.DemoDirector.fillTyped("note", "Patient stable.", 35);|

iex> DemoDirector.fill_typed("note", "...", per_char_ms: 60)
~s|await window.DemoDirector.fillTyped("note", "...", 60);|

highlight(id)

@spec highlight(demo_id() | nil) :: String.t()

Highlights the element with the given demo-id.

Renders a focus ring around the matching element and scrolls it into view. Passing nil clears any active highlight.

subtitle(text)

@spec subtitle(String.t() | nil) :: String.t()

Sets the subtitle overlay text.

Returns JS that finds the subtitle overlay (rendered by DemoDirector.Components.demo_director_overlay/1) and updates its text content. The runtime reveals the text word-by-word at ~110ms/word; pace following wait/1 calls accordingly.

Pass nil to clear an active subtitle.

Examples

iex> DemoDirector.subtitle("Let's add a diagnosis.")
~s|window.DemoDirector.subtitle("Let's add a diagnosis.");|

iex> DemoDirector.subtitle(nil)
"window.DemoDirector.subtitle(null);"

wait(ms)

@spec wait(pos_integer()) :: String.t()

Pauses for ms milliseconds. Useful between steps to let the user read a subtitle or watch a transition complete.

Returned JS uses await, so an AI agent driving the demo via browser_eval must wrap its sequence in an async function (most do this automatically; Tidewave's browser.eval supports it). The saved-script playback runtime always wraps in async.

Examples

iex> DemoDirector.wait(750)
"await new Promise(r => setTimeout(r, 750));"