playhtml

repository·main·Indexed 17 days ago

https://github.com/spencerc99/playhtml

A library for creating collaborative, interactive HTML elements that persist state across sessions by automatically syncing element positions, states, and user presence. The project includes a Chrome extension for capturing web interaction traces (cursor, keyboard, navigation, and viewport) and recording tools for generating headless video visualizations of this data.

Tokens
87.3K
Snippets
246
Records
340
Agent score
61%

What's inside playhtml

  1. Core concepts of playhtml data and interaction

    main

    Playhtml goes beyond simple attributes to provide a full suite of collaborative primitives:

    • Element data: Persistent, synced state scoped to a single DOM element.
    • Page-level data: Shared data channels for global state (e.g., counters, votes).
    • Presence & cursors: Ephemeral per-user state and multiplayer cursors.
    • Events: Fire-and-forget broadcasts for transient actions like confetti or pings.
    • Shared elements: Cross-page and cross-domain state for interconnected sites.
    • Dynamic elements: Support for runtime-added nodes using setupPlayElement and selector-id.
    • SPA navigation: Compatibility with Astro ViewTransitions, React Router, Next.js, htmx boost, and Turbo.
  2. Prevent infinite write loops in reactive code

    main

    A critical error occurs when a reactive subscription (like a React useEffect) both depends on shared data and writes to it. Because setData with the replacement form (e.g., setData({ entries: [...] })) on an array is not idempotent, concurrent writes can cause the loop to never converge, leading to massive document growth.

    The Fix:

    1. Use a keyed map instead of an array: Store collections as objects keyed by ID. This allows for idempotent 'upsert' operations.
    2. Use the mutator form: Use setData((draft) => { ... }) to update specific keys. This is merge-safe and idempotent.
    3. Decouple dependencies: Use a ref to read the data inside your effect so the effect only triggers on the specific inputs that should cause a write (like a user's local identity), not on every change to the shared collection.
    // The strongest fix: Keyed map + mutator write
    const Roster = withSharedState({ defaultData: { entries: {} } }, ({ data, setData }) => {
      const me = useMe(); // { id, name }
      const ref = useRef(data.entries);
      ref.current = data.entries;
    
      useEffect(() => {
        const existing = ref.current[me.id];
        if (existing && existing.name === me.name) return;
    
        // Keyed mutator write is idempotent and merge-safe
        setData((draft) => { draft.entries[me.id] = me; });
      }, [me.id, me.name]); // Depends on local identity, NOT on data.entries
      return <div />;
    });
  3. Core Concepts of playhtml Capabilities

    main

    Every capability in playhtml is implemented as a single HTML attribute. When you apply a can-* attribute to an element, the library automatically handles state synchronization, persistence, and event wiring across all users.

    Requirements for Syncing

    • Stable IDs: Every interactive element must have a stable id (e.g., id="my-lamp"). This id is the key used to store and sync data. If omitted, playhtml falls back to hashing the HTML, which can break sync across different browsers.
    • Many-of-a-kind elements: For groups of similar elements (like a list of magnets), instead of unique IDs, use the selector-id attribute. Playhtml will index matches by position (the N-th match gets the N-th state slot).

    Resetting State

    Built-in interactive capabilities (can-move, can-spin, can-toggle, and can-grow) support shift-clicking an element to reset it to its default state. This reset is synchronized for all users in the session.

  4. Understand what the smoke tests check

    main

    The standard smoke tests iterate through a list of pages defined in smoke.spec.ts and verify the following for each:

    • HTTP Status: Must be < 400.
    • Rendering: No uncaught pageerror events.
    • Runtime Errors: No fatal console.error messages matching FATAL_CONSOLE_PATTERNS (e.g., missing PlayProvider).
    • Assets: No same-origin asset 4xx/5xx errors (e.g., broken JS chunks).

    Note: Cross-origin failures (like Google Fonts or PartyKit WebSockets) are intentionally ignored as these are build-output tests, not backend integration tests.

  5. Sync interactive elements across pages and domains with Shared Elements

    main

    Shared elements allow you to synchronize state and data between different pages on the same site or across different domains. You define a source element on one page and reference it on consumer pages. The consumer can use entirely different markup, styles, or layouts while still participating in the shared state via specific capabilities.

    Core Requirements for Consumers

    To successfully consume a shared element, the consumer must provide:

    1. data-source: A string in the format domain/path#elementId (e.g., thissite.com/blog/post#counter). The part after # is the shared ID.
    2. Matching Capability: The consumer must include a capability tag (e.g., can-move, can-toggle, can-grow) that matches the capability declared on the source.

    Permissions and Read-Only Modes

    • Source Permissions:
      • shared or shared="read-write" (default): Allows both reading and writing state.
      • shared="read-only" or shared="ro": The source only broadcasts updates; consumers cannot write back to it.
    • Consumer Permissions:
      • Add data-source-read-only to a consumer to force local read-only behavior, even if the source is configured for read-write. PlayHTML will automatically apply a not-allowed cursor to these elements.

    Combining Capabilities

    Consumers can include additional capabilities that are local-only. Only the capabilities declared on the source are synchronized. For example, if a source has can-move, a consumer can have can-move (synchronized) and can-toggle (local-only).

    <!-- Vanilla HTML Example -->
    <!-- Source page (thissite.com) -->
    <div id="couch" shared can-move style="font-size: 80px">🛋</div>
    
    <!-- Consumer page (anothersite.com) -->
    <div data-source="thissite.com#couch" can-move>🪑</div>
  6. Choose the right data primitive for your state

    main

    PlayHTML provides different primitives for moving state between readers. Selection should be based on lifetime (should it survive a page reload?) and scope (does it belong to one element, the whole page, or a single user?).

    Decision Guide

    RequirementPrimitiveSurvives ReloadScope
    State tied to one element (toggle, position, count)Element data (defaultData / can-play)YesOne element
    Page-wide state not tied to a DOM node (counter, prompt)Page data (playhtml.createPageData)YesOne page
    Connection status / reader countPresence (playhtml.presence.getPresences())NoPer-user
    Live status (e.g., "is typing")Custom presence channel or element awarenessNoPer-user
    Cursor positionsCursorsNoPer-user
    One-time triggers (confetti, chime, notification)Events (dispatchPlayEvent)NoBroadcast
    Reaction counts on a postElement data (a count field)YesOne element

    Key Rules

    • Persistence: If a new reader opening the page should see the state, use persistent data (Element or Page data). If they shouldn't, use presence or an event.
    • User Preferences: If you want state to survive a reload but not sync to other users (e.g., personal settings), use localStorage instead of PlayHTML primitives.
  7. What are PlayHTML events and when to use them

    main

    Events are transient, one-off signals used for real-time interactions that do not require persistence. They are broadcast to all currently connected readers.

    Key Characteristics:

    • No Persistence: Events do not replay for late-joiners. If a user joins after an event is dispatched, they will not see it.
    • Fire-and-Forget: They are intended for immediate, visual, or auditory feedback.

    When to use events: Use events when the answer to "should this still be here if I reload?" is no.

    • Use Events for: Confetti bursts, sound effects, screen shakes, toast notifications, "I'm waving" animations, or pings.
    • Do NOT use for state: If you need to track the count of confetti bursts or a list of people who waved, use element data.
    • Do NOT use for presence: For "Alice is typing" or cursor positions, use presence.
  8. Avoid infinite write loops in reactive code

    main

    The most dangerous bug in PlayHTML is a reactive callback (like a React useEffect or a vanilla updateElement) that both reads shared data and writes it. This creates an infinite loop: the write changes the data $\rightarrow$ the dependency re-fires $\rightarrow$ it writes again. Because PlayHTML uses CRDTs, these loops can grow the document size exponentially and crash the room.

    The Solution

    1. Prefer Keyed Maps: Instead of arrays, model collections as keyed maps (e.g., { [id: string]: Value }). Keyed writes are idempotent and merge-safe.
    2. Use Refs for Dependencies: When using useEffect, read the data through a useRef so the effect depends on a local identity rather than the shared collection itself.
    3. Event-Driven Writes: Always prefer writing shared data from explicit user events (like onClick) rather than reactive callbacks.
    // DANGER — infinite write loop
    useEffect(() => {
      setData({ entries: [...data.entries, me] }); // writes entries...
    }, [data.entries]);                            // ...re-runs because entries changed
    
    // FIX: Use a keyed map and a ref to avoid the loop
    const ref = useRef(data.entries);
    ref.current = data.entries;
    
    useEffect(() => {
      if (ref.current[me.id]?.name === me.name) return;       // already correct
      setData((draft) => { draft.entries[me.id] = me; });      // keyed, idempotent, merge-safe
    }, [me.id, me.name]); // local identity only — NOT data.entries
  9. Multiplayer room scoping for live previews

    main

    The system uses three distinct types of multiplayer rooms to manage state and presence:

    1. Docs URL Room: Scoped to the current page on the documentation site. Used for presence HUDs, scroll rails, and copy tallies.
    2. Preview Iframe Room: Scoped to a specific recipe-id or a specific remix-hash. This ensures that all users viewing the same recipe (or the same remix) share the same live state inside the preview. This is the primary mechanism for 'live sandboxing'.
    3. Editor Room (Future): Scoped to a remix-hash. This will enable collaborative source editing (multiple users typing in the same editor).
  10. Use the `can-toggle` capability to share state

    main

    The can-toggle capability allows you to store a single, persistent on/off value that is shared across every connected browser. When an element with this capability is clicked, the toggled class is added to or removed from that element in all connected browsers simultaneously. This is useful for creating global switches, toggles, or mode selectors that synchronize state across multiple sessions.

    <!-- Concept: Clicking an element with can-toggle toggles the 'toggled' class globally -->
    <div id="shared-switch" can-toggle></div>
  11. Configure Presence Rooms

    main

    The room option determines how users are grouped for presence. Users must be in the same room to see each other's cursors.

    • Section-Specific Presence: Use room: "section" to group users by the first path segment (e.g., all /blog/* pages share a room).
    • Domain-Wide Presence: Use room: "domain" to group all users on the same domain.
    • Custom Workspace Rooms: Pass a function to room to generate dynamic room IDs based on application logic (e.g., extracting a workspace ID from the URL).
    // Example: Workspace Rooms
    playhtml.init({
      cursors: {
        enabled: true,
        room: ({ domain, pathname }) => {
          const match = pathname.match(/\/workspace\/(\w+)/);
          if (match) {
            return `${domain}-workspace-${match[1]}`;
          }
          return `${domain}${pathname}`;
        }
      }
    });