Runtime flow
How a page becomes a tour: the content script gate, the service worker, the player, and the frame-agent relay for cross-origin frames.
flowchart LR
subgraph page["Top frame (host page)"]
gate["1. Content gate"]
player["4. Player<br/>(/player.js)"]
ghost["8. Ghost anchor<br/>data-wp-synthetic"]
end
subgraph xf["Cross-origin frame"]
gate2["Content gate<br/>(allFrames)"]
agent["6. Frame agent<br/>(/frame-agent.js)"]
end
subgraph sw["Service worker"]
reg["frame.register<br/>frames:tabId"]
man["manifest.get"]
inj["player.inject"]
rel["Relay<br/>locator.resolve"]
end
store[("LocalStore<br/>IndexedDB")]
gate -->|"2. frame.register,<br/>manifest.get"| reg
gate -->|"2"| man
man --> store
man -->|"3. entry matches path"| gate
gate -->|"3. player.inject"| inj
inj -->|"4. inject into frame 0"| player
player -->|"5. tour.get,<br/>progress.get/set"| store
player -->|"6. locator.resolve"| rel
rel --> agent
agent -->|"7. rect"| rel
rel -->|"7. rect"| player
player -->|"8. refresh every 300 ms"| ghost
gate2 -->|"frame.register"| reg
style store fill:#e0f2f1,stroke:#00796b
style sw fill:#eceff1,stroke:#546e7a
Companion note
| Node |
Description |
| 1. Content gate |
content.js, about 2 kB gzip. Registered at run time with scripting.registerContentScripts for granted origins only, in all frames (allFrames, matchOriginAsFallback). Zod-free |
| frame.register |
Stores each frame of a tab in frames:<tabId> in storage.session, written through a promise chain so concurrent registrations do not overwrite each other |
| manifest.get |
Returns the published manifest entries for the app that owns the page origin |
| player.inject |
Asks the worker to inject the player |
| 4. Player |
/player.js, injected by the worker into frame 0 only. Runs triggers, steps, ambient content |
| 6. Frame agent |
/frame-agent.js, injected on demand into a cross-origin frame. Resolves the locator tail inside that frame and returns a rectangle |
| Relay |
The worker handles the player's locator.resolve, strips the hop it just crossed from the locator, and forwards it to the frame as the internal guidepost.frame.resolve message |
| 8. Ghost anchor |
An element in the top frame positioned at the rectangle and refreshed every 300 ms. The overlay anchors to it |
| LocalStore |
IndexedDB guidepost. The worker is the only reader for the content script and player |
| Step |
Flow |
| 1 |
Page loads. The gate runs in every frame of a granted origin |
| 2 |
The gate sends frame.register and manifest.get to the worker |
| 3 |
If a manifest entry matches the page path, the gate sends player.inject. Otherwise it stops and does no more work |
| 4 |
The worker injects /player.js into frame 0 |
| 5 |
The player fetches content with tour.get and reads and writes progress with progress.get and progress.set |
| 6 |
For an anchor in a cross-origin frame the player sends locator.resolve to the worker |
| 7 |
The worker injects the frame agent if needed, the agent resolves the locator and returns a rectangle |
| 8 |
The player draws against a ghost anchor in the top frame, refreshed every 300 ms |
Authoring paths
- Picker: the studio sends
picker.start. The worker injects /picker.js into all frames of the tab and keeps the session in storage.session as picker:<tabId>. It re-injects on tabs.onUpdated, so a navigation does not end a session. Picks come back with picker.picked.
- Preview:
preview.play goes to the worker, which retries tabs.sendMessage for up to 3 seconds while the player starts.
Gotchas
- The service worker is stopped after about 30 seconds idle. Nothing important lives in its memory: sessions are in
storage.session, content is in IndexedDB.
- The gate does the minimum before the worker answers: a page that matches nothing costs one
manifest.get round trip and no injection.
- Only frame 0 gets the player. Frames that are same-origin are reached directly from the player; cross-origin frames need the agent.