├── CNAME ├── templates ├── html-starter │ ├── package.json │ └── script.js ├── react-starter │ ├── vite.config.ts │ ├── src │ │ ├── main.tsx │ │ └── index.css │ ├── index.html │ └── package.json └── README.md ├── packages ├── playhtml │ ├── README.md │ ├── src │ │ ├── vite-env.d.ts │ │ ├── init.ts │ │ ├── utils.ts │ │ ├── sharing.ts │ │ ├── __tests__ │ │ │ └── elements.test.ts │ │ └── cursors │ │ │ └── spatial-grid.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── vite.config.ts │ ├── package.json │ └── vitest.setup.ts ├── common │ ├── tsconfig.json │ ├── vite.config.ts │ ├── package.json │ ├── src │ │ ├── sharedElements.ts │ │ ├── objectUtils.ts │ │ └── cursor-types.ts │ └── CHANGELOG.md ├── react │ ├── src │ │ ├── playhtml-singleton.ts │ │ ├── usePlayContext.ts │ │ ├── __tests__ │ │ │ ├── setup.ts │ │ │ └── PlayProvider.test.tsx │ │ └── hooks │ │ │ └── useLocation.ts │ ├── examples │ │ ├── package.json │ │ ├── ToggleSquare.tsx │ │ ├── ViewCount.tsx │ │ ├── VisitorCount.tsx │ │ ├── App.tsx │ │ ├── SharedColor.tsx │ │ ├── FridgeWord.tsx │ │ ├── SharedLamp.tsx │ │ ├── ReactiveOrb.scss │ │ ├── OnlineIndicator.tsx │ │ ├── SharedSlider.tsx │ │ ├── SharedSound.tsx │ │ ├── Confetti.tsx │ │ ├── resizable.tsx │ │ ├── Reaction.tsx │ │ ├── FridgeWord.scss │ │ ├── utils.ts │ │ ├── ReactiveOrb.tsx │ │ ├── LiveReactions.tsx │ │ ├── CursorOnlineIndicator.tsx │ │ ├── LiveChat.scss │ │ ├── SharedTimer.tsx │ │ ├── LiveChat.tsx │ │ └── RainSprinkler.tsx │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── vite.config.ts │ ├── example.tsx │ └── package.json └── extension │ ├── public │ └── icon │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 32.png │ │ └── 48.png │ ├── tsconfig.json │ ├── src │ ├── types.ts │ ├── entrypoints │ │ ├── popup │ │ │ └── index.html │ │ ├── content │ │ │ └── style.css │ │ └── background.ts │ └── components │ │ ├── SiteStatus.tsx │ │ ├── PlayerIdentityCard.tsx │ │ └── QuickActions.tsx │ ├── wxt.config.ts │ ├── CHANGELOG.md │ └── package.json ├── website ├── story.ts ├── public │ ├── icon.png │ ├── lamp-on.m4a │ ├── Akari-70F.png │ ├── candle-gif.gif │ ├── candle-off.png │ ├── name-stamp.png │ ├── playhtml.png │ ├── fire-hydrant.png │ ├── rain-cloud.webp │ ├── lamps │ │ ├── Akari-1A.png │ │ ├── Akari-1AD.png │ │ ├── Akari-1AG.png │ │ ├── Akari-1AR.png │ │ ├── Akari-1AS.png │ │ ├── Akari-1AT.png │ │ ├── Akari-1AV.png │ │ ├── Akari-1AY.png │ │ ├── Akari-1N.png │ │ └── Akari-1P.png │ ├── playhtml-fridge.png │ ├── playhtml-logo.png │ ├── playhtml-sign.png │ ├── playhtml-story.png │ ├── noguchi-akari-a1.png │ ├── playhtml-can-play.png │ ├── playhtml-can-spin.png │ ├── playhtml-candles.png │ ├── noguchi-hanging-lamp.png │ ├── playhtml-can-toggle.png │ ├── slot-machine-handle.png │ ├── playhtml-experiment-01.png │ ├── playhtml-experiment-02.png │ ├── playhtml-experiment-04.png │ └── under-construction-website.gif ├── utils │ ├── array.ts │ └── color.ts ├── tsconfig.json ├── events │ ├── gray-area │ │ └── gray-area.scss │ ├── walking-together │ │ ├── spec.md │ │ ├── index.html │ │ └── walking-together.scss │ └── gathering │ │ ├── index.html │ │ └── walking-together.scss ├── admin.html ├── test │ ├── playground.scss │ ├── playground.ts │ └── react-test.html ├── hooks │ └── useStickyState.ts ├── experiments │ ├── 3 │ │ └── index.html │ ├── 4 │ │ ├── index.html │ │ └── 4.scss │ ├── 5 │ │ └── index.html │ ├── 6 │ │ └── index.html │ ├── 7 │ │ ├── index.html │ │ ├── closedhand.svg │ │ └── openhand.svg │ ├── 8 │ │ ├── index.html │ │ └── 8.scss │ ├── test │ │ └── index.html │ ├── one │ │ ├── index.html │ │ └── one.scss │ ├── two │ │ ├── index.html │ │ └── two.scss │ ├── index.tsx │ └── index.html ├── components │ ├── ComponentStore.scss │ ├── DataModes.tsx │ ├── DataModes.scss │ ├── Permissions.scss │ └── ScheduledBehaviors.tsx ├── useLocation.ts ├── base.scss ├── candles.html ├── fridge.html └── fridge.scss ├── partykit.json ├── .changeset ├── cursor-room-support.md ├── config.json ├── README.md └── remove-element-data.md ├── partykit ├── db.ts ├── sharing.ts ├── const.ts └── request.ts ├── .gitignore ├── .github └── workflows │ ├── pr-validation.yml │ ├── release.yml │ └── claude.yml ├── vite.config.site.mts ├── tsconfig.json ├── LICENSE ├── package.json ├── CONTRIBUTING.md └── docs ├── data-cleanup.md └── shared-elements.md /CNAME: -------------------------------------------------------------------------------- 1 | playhtml.fun 2 | -------------------------------------------------------------------------------- /templates/html-starter/package.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/playhtml/README.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /website/story.ts: -------------------------------------------------------------------------------- 1 | import "./home.scss"; 2 | -------------------------------------------------------------------------------- /packages/playhtml/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/playhtml/src/init.ts: -------------------------------------------------------------------------------- 1 | import { playhtml } from "."; 2 | playhtml.init({}); 3 | -------------------------------------------------------------------------------- /website/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/icon.png -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/playhtml/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /website/public/lamp-on.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamp-on.m4a -------------------------------------------------------------------------------- /website/public/Akari-70F.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/Akari-70F.png -------------------------------------------------------------------------------- /website/public/candle-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/candle-gif.gif -------------------------------------------------------------------------------- /website/public/candle-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/candle-off.png -------------------------------------------------------------------------------- /website/public/name-stamp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/name-stamp.png -------------------------------------------------------------------------------- /website/public/playhtml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml.png -------------------------------------------------------------------------------- /packages/react/src/playhtml-singleton.ts: -------------------------------------------------------------------------------- 1 | import { playhtml } from "playhtml"; 2 | 3 | export default playhtml; 4 | -------------------------------------------------------------------------------- /website/public/fire-hydrant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/fire-hydrant.png -------------------------------------------------------------------------------- /website/public/rain-cloud.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/rain-cloud.webp -------------------------------------------------------------------------------- /website/public/lamps/Akari-1A.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1A.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AD.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AG.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AR.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AR.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AS.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AT.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AV.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AV.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1AY.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1AY.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1N.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1N.png -------------------------------------------------------------------------------- /website/public/lamps/Akari-1P.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/lamps/Akari-1P.png -------------------------------------------------------------------------------- /website/public/playhtml-fridge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-fridge.png -------------------------------------------------------------------------------- /website/public/playhtml-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-logo.png -------------------------------------------------------------------------------- /website/public/playhtml-sign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-sign.png -------------------------------------------------------------------------------- /website/public/playhtml-story.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-story.png -------------------------------------------------------------------------------- /partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml", 3 | "main": "partykit/party.ts", 4 | "compatibilityDate": "2024-01-27" 5 | } 6 | -------------------------------------------------------------------------------- /website/public/noguchi-akari-a1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/noguchi-akari-a1.png -------------------------------------------------------------------------------- /website/public/playhtml-can-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-can-play.png -------------------------------------------------------------------------------- /website/public/playhtml-can-spin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-can-spin.png -------------------------------------------------------------------------------- /website/public/playhtml-candles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-candles.png -------------------------------------------------------------------------------- /packages/extension/public/icon/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/packages/extension/public/icon/128.png -------------------------------------------------------------------------------- /packages/extension/public/icon/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/packages/extension/public/icon/16.png -------------------------------------------------------------------------------- /packages/extension/public/icon/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/packages/extension/public/icon/32.png -------------------------------------------------------------------------------- /packages/extension/public/icon/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/packages/extension/public/icon/48.png -------------------------------------------------------------------------------- /website/public/noguchi-hanging-lamp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/noguchi-hanging-lamp.png -------------------------------------------------------------------------------- /website/public/playhtml-can-toggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-can-toggle.png -------------------------------------------------------------------------------- /website/public/slot-machine-handle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/slot-machine-handle.png -------------------------------------------------------------------------------- /website/public/playhtml-experiment-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-experiment-01.png -------------------------------------------------------------------------------- /website/public/playhtml-experiment-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-experiment-02.png -------------------------------------------------------------------------------- /website/public/playhtml-experiment-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/playhtml-experiment-04.png -------------------------------------------------------------------------------- /website/utils/array.ts: -------------------------------------------------------------------------------- 1 | export function randomizeArray(array: T[]): T[] { 2 | return array.sort(() => Math.random() - 0.5); 3 | } 4 | -------------------------------------------------------------------------------- /website/public/under-construction-website.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerc99/playhtml/HEAD/website/public/under-construction-website.gif -------------------------------------------------------------------------------- /packages/extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.wxt/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "jsx": "react-jsx" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /templates/react-starter/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }) 7 | -------------------------------------------------------------------------------- /packages/react/src/usePlayContext.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { PlayContext } from "./PlayProvider"; 3 | 4 | export function usePlayContext() { 5 | return useContext(PlayContext); 6 | } -------------------------------------------------------------------------------- /.changeset/cursor-room-support.md: -------------------------------------------------------------------------------- 1 | --- 2 | "playhtml": minor 3 | "@playhtml/common": minor 4 | "@playhtml/react": minor 5 | --- 6 | 7 | Add composable cursor room configuration with filtering and styling options. 8 | -------------------------------------------------------------------------------- /partykit/db.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | 3 | export const supabase = createClient( 4 | process.env.SUPABASE_URL as string, 5 | process.env.SUPABASE_KEY as string, 6 | { auth: { persistSession: false } } 7 | ); 8 | -------------------------------------------------------------------------------- /templates/react-starter/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch" 10 | } 11 | -------------------------------------------------------------------------------- /packages/react/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml-react-examples", 3 | "license": "MIT", 4 | "scripts": { 5 | "dev": "vite" 6 | }, 7 | "devDependencies": { 8 | "sass": "^1.62.1", 9 | "typescript": "^5.0.2" 10 | }, 11 | "dependencies": { 12 | "@playhtml/react": "workspace:*" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": [ 5 | "src/__tests__", 6 | "**/*.test.ts", 7 | "**/*.test.tsx", 8 | "**/*.spec.ts", 9 | "**/*.spec.tsx" 10 | ], 11 | "compilerOptions": { 12 | "jsx": "react-jsx", 13 | "allowUmdGlobalAccess": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**.ts", "./**.tsx"], 4 | "exclude": [ 5 | "src/__tests__", 6 | "**/*.test.ts", 7 | "**/*.test.tsx", 8 | "**/*.spec.ts", 9 | "**/*.spec.tsx" 10 | ], 11 | "compilerOptions": { 12 | "jsx": "react-jsx", 13 | "allowUmdGlobalAccess": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /website/events/gray-area/gray-area.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | body { 6 | background: rgba(255, 254, 236, 0.85); 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | font-family: "Play"; 11 | overflow-y: auto; 12 | flex-direction: column; 13 | overflow-x: hidden; 14 | } 15 | 16 | p { 17 | margin: 0; 18 | } 19 | 20 | h1 { 21 | margin: 0.5em 0; 22 | } 23 | -------------------------------------------------------------------------------- /packages/common/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [dts({ rollupTypes: true })], 7 | build: { 8 | lib: { 9 | entry: path.resolve(__dirname, "src/index.ts"), 10 | name: "playhtml-common", 11 | fileName: (format) => `playhtml-common.${format}.js`, 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /templates/react-starter/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | playhtml React Starter 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /website/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PlayHTML Admin Console (React) 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/react/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import react from "@vitejs/plugin-react"; 3 | import path from "path"; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: "jsdom", 10 | setupFiles: ["./src/__tests__/setup.ts"], 11 | exclude: ["node_modules/**", "dist/**"], 12 | }, 13 | resolve: { 14 | alias: { 15 | "@": path.resolve(__dirname, "./src"), 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | site 13 | site-dist 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # dev persisted state 29 | .partykit 30 | 31 | # secrets 32 | .env 33 | 34 | .claude 35 | 36 | 37 | .wxt 38 | .output 39 | 40 | internal-docs 41 | -------------------------------------------------------------------------------- /packages/playhtml/src/utils.ts: -------------------------------------------------------------------------------- 1 | export async function hashElement( 2 | tag: string, 3 | element: Element 4 | ): Promise { 5 | const msgUint8 = new TextEncoder().encode(`${tag}-${element.outerHTML}}`); 6 | const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8); 7 | 8 | const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array 9 | const hashHex = hashArray 10 | .map((b) => b.toString(16).padStart(2, "0")) 11 | .join(""); // convert bytes to hex string 12 | return hashHex; 13 | } 14 | -------------------------------------------------------------------------------- /packages/playhtml/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { fileURLToPath } from "url"; 3 | 4 | const rootDir = fileURLToPath(new URL(".", import.meta.url)); 5 | const setupFile = fileURLToPath(new URL("./vitest.setup.ts", import.meta.url)); 6 | 7 | export default defineConfig({ 8 | root: rootDir, 9 | test: { 10 | globals: true, 11 | environment: "jsdom", 12 | exclude: ["node_modules/**", "dist/**"], 13 | setupFiles: [setupFile], 14 | include: ["src/__tests__/**/*.test.ts"], 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/react/examples/ToggleSquare.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | 4 | interface Props {} 5 | 6 | export const ToggleSquare = withSharedState( 7 | { defaultData: { on: false } }, 8 | ({ data, setData }, props) => { 9 | return ( 10 |
setData({ on: !data.on })} 17 | /> 18 | ); 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /templates/react-starter/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | body { 17 | margin: 0; 18 | min-width: 320px; 19 | min-height: 100vh; 20 | } 21 | 22 | button { 23 | font-family: inherit; 24 | } 25 | -------------------------------------------------------------------------------- /website/test/playground.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | background-color: #87ceeb; 5 | } 6 | .neighborhood { 7 | width: 800px; 8 | height: 600px; 9 | position: relative; 10 | background-color: #7cfc00; 11 | margin: 20px auto; 12 | overflow: hidden; 13 | } 14 | .house { 15 | width: 100px; 16 | height: 100px; 17 | background-color: #8b4513; 18 | position: absolute; 19 | } 20 | .character { 21 | width: 20px; 22 | height: 20px; 23 | background-color: #ff0000; 24 | position: absolute; 25 | transition: all 0.1s; 26 | left: 0; 27 | top: 0; 28 | } 29 | -------------------------------------------------------------------------------- /website/events/walking-together/spec.md: -------------------------------------------------------------------------------- 1 | - **User customization**: 2 | - Name input 3 | - Color picker for cursor 4 | - **URL sharing**: 5 | - Input field for favorite/notable URLs with validation 6 | - Real-time display on screen in chat-style format 7 | - Show user's name/initials in their chosen color followed by URL 8 | - **URL export feature**: 9 | - Create functionality to export all submitted URLs with associated usernames 10 | - Format as a simple bullet list or plain text for later use 11 | - **Collaborative activities**: 12 | - Provide simple instructions for collaborative activities 13 | -------------------------------------------------------------------------------- /packages/react/examples/ViewCount.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from "react"; 2 | import { PlayContext, withSharedState } from "@playhtml/react"; 3 | 4 | interface Props {} 5 | 6 | export const ViewCount = withSharedState( 7 | { defaultData: { count: 0 }, id: "viewCount" }, 8 | ({ data, setData }, props) => { 9 | const { hasSynced } = useContext(PlayContext); 10 | useEffect(() => { 11 | if (!hasSynced) { 12 | return; 13 | } 14 | 15 | setData({ count: data.count + 1 }); 16 | }, [hasSynced]); 17 | return
{data.count}
; 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /packages/extension/src/types.ts: -------------------------------------------------------------------------------- 1 | import { PlayerIdentity } from "@playhtml/common"; 2 | export interface InventoryItem { 3 | id: string; 4 | type: "element" | "site_signature" | "interaction"; 5 | name: string; 6 | description: string; 7 | collectedAt: number; 8 | sourceUrl: string; 9 | data?: any; 10 | } 11 | 12 | export interface GameInventory { 13 | items: InventoryItem[]; 14 | totalItems: number; 15 | lastUpdated: number; 16 | } 17 | 18 | export interface PlayHTMLStatus { 19 | detected: boolean; 20 | elementCount: number; 21 | checking: boolean; 22 | } 23 | 24 | export type { PlayerIdentity }; 25 | -------------------------------------------------------------------------------- /templates/react-starter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml-react-starter", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@playhtml/react": "latest", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^18.2.0", 18 | "@types/react-dom": "^18.2.0", 19 | "@vitejs/plugin-react": "^4.2.1", 20 | "typescript": "^5.2.2", 21 | "vite": "^5.0.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /website/hooks/useStickyState.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function useStickyState( 4 | key: string, 5 | defaultValue: T, 6 | onUpdateCallback?: (value: T) => void 7 | ): [T, (value: T) => void] { 8 | const [value, setValue] = React.useState(() => { 9 | const stickyValue = window.localStorage.getItem(key); 10 | return stickyValue !== null ? JSON.parse(stickyValue) : defaultValue; 11 | }); 12 | React.useEffect(() => { 13 | window.localStorage.setItem(key, JSON.stringify(value)); 14 | onUpdateCallback?.(value); 15 | }, [key, value]); 16 | return [value, setValue]; 17 | } 18 | -------------------------------------------------------------------------------- /packages/react/examples/VisitorCount.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CanPlayElement } from "../src"; 3 | import { formatSimpleNumber, pluralize } from "./utils"; 4 | 5 | export function LiveVisitorCount() { 6 | return ( 7 | 12 | {({ awareness }) => { 13 | const count = awareness.length; 14 | return ( 15 |
16 | {formatSimpleNumber(count)} {pluralize("visitor", count)} 17 |
18 | ); 19 | }} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /packages/extension/src/entrypoints/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | PlayHTML Bag 7 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | # playhtml Templates 2 | 3 | Quick-start templates to get you building with playhtml. 4 | 5 | ## HTML Starter 6 | 7 | A minimal HTML template showcasing playhtml capabilities with vanilla JavaScript. 8 | 9 | ```bash 10 | npx degit playhtml/playhtml/templates/html-starter my-project 11 | open my-project/index.html 12 | ``` 13 | 14 | Then open `index.html` in your browser. 15 | 16 | ## React Starter 17 | 18 | A React + Vite template with playhtml components. 19 | 20 | ```bash 21 | npx degit playhtml/playhtml/templates/react-starter my-project 22 | cd my-project 23 | bun install 24 | bun dev 25 | ``` 26 | 27 | The dev server will start at http://localhost:5173 28 | -------------------------------------------------------------------------------- /.changeset/remove-element-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@playhtml/react": minor 3 | "playhtml": minor 4 | --- 5 | 6 | Add `removeElementData` API for cleaning up orphaned element data 7 | 8 | This release adds a new `removeElementData(tag, elementId)` function to both the core `playhtml` package and the React wrapper. This function allows you to clean up orphaned data when elements are deleted, preventing accumulation of stale data in the database. 9 | 10 | **Usage:** 11 | 12 | ```tsx 13 | import { removeElementData } from "@playhtml/react"; 14 | 15 | // Or access via playhtml object 16 | import { playhtml } from "@playhtml/react"; 17 | playhtml.removeElementData("can-move", elementId); 18 | ``` 19 | -------------------------------------------------------------------------------- /packages/react/examples/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PlayProvider } from "@playhtml/react"; 3 | import { ReactionView } from "./Reaction"; 4 | 5 | export default function App() { 6 | return ( 7 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/playhtml/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | export default defineConfig({ 6 | plugins: [dts({ rollupTypes: true })], 7 | build: { 8 | rollupOptions: { 9 | input: ["src/init.ts", "src/index.ts"], 10 | output: { 11 | inlineDynamicImports: false, 12 | }, 13 | }, 14 | lib: { 15 | entry: path.resolve(__dirname, "src/index.ts"), 16 | formats: ["es"], 17 | name: "playhtml", 18 | fileName: (format, entryName) => { 19 | if (entryName === "init") return `init.${format}.js`; 20 | 21 | return `playhtml.${format}.js`; 22 | }, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/extension/wxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "wxt"; 2 | 3 | export default defineConfig({ 4 | srcDir: "src", 5 | manifest: { 6 | name: "Tiny Internets", 7 | description: 8 | "Turn the internet into an multiplayer playground. Add elements and discover what others have left behind.", 9 | permissions: ["storage", "activeTab", "scripting", "tabs"], 10 | host_permissions: ["http://*/*", "https://*/*"], 11 | action: { 12 | default_title: "Tiny Internets", 13 | }, 14 | web_accessible_resources: [ 15 | { 16 | resources: ["content-scripts/content.css"], 17 | matches: [""], 18 | }, 19 | ], 20 | }, 21 | modules: ["@wxt-dev/module-react"], 22 | }); 23 | -------------------------------------------------------------------------------- /.github/workflows/pr-validation.yml: -------------------------------------------------------------------------------- 1 | name: PR Validation 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build-site: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup Bun 16 | uses: oven-sh/setup-bun@v1 17 | with: 18 | bun-version: latest 19 | 20 | - name: Install dependencies 21 | run: bun install 22 | 23 | - name: Build packages 24 | run: bun build-packages 25 | 26 | - name: Run tests 27 | run: cd packages/playhtml && bun test 28 | 29 | - name: Lint 30 | run: bun run lint 31 | 32 | - name: Build site 33 | run: bun build-site 34 | -------------------------------------------------------------------------------- /packages/react/examples/SharedColor.tsx: -------------------------------------------------------------------------------- 1 | import { CanPlayProps, withSharedState } from "@playhtml/react"; 2 | 3 | interface ColorChange { 4 | color: string; 5 | timestamp: number; 6 | } 7 | 8 | interface Props { 9 | name: string; 10 | } 11 | 12 | function color({ data, setData }, props: Props) { 13 | props.name; 14 | { 15 | data; 16 | } 17 | return
; 18 | } 19 | 20 | export const Color = withSharedState( 21 | { 22 | defaultData: { colors: [] }, 23 | }, 24 | color 25 | ); 26 | 27 | export const ColorInline = withSharedState( 28 | { 29 | defaultData: { colors: [] as ColorChange[] }, 30 | }, 31 | ({ data, setData }, props: Props) => { 32 | props.name; 33 | data.colors; 34 | return
; 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /packages/react/examples/FridgeWord.tsx: -------------------------------------------------------------------------------- 1 | import { TagType } from "@playhtml/common"; 2 | import { withSharedState } from "@playhtml/react"; 3 | import "./FridgeWord.scss"; 4 | 5 | interface Props { 6 | id?: string; 7 | word: string; 8 | color?: string; 9 | } 10 | 11 | export const FridgeWord = withSharedState( 12 | { 13 | tagInfo: [TagType.CanMove], 14 | }, 15 | ({}, props: Props) => { 16 | const { id, word, color } = props; 17 | return ( 18 |
23 |
29 | {word} 30 |
31 |
32 | ); 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /website/test/playground.ts: -------------------------------------------------------------------------------- 1 | import "../home.scss"; 2 | import "./playground.scss"; 3 | import { playhtml } from "../../packages/playhtml/src"; 4 | 5 | playhtml.init({ 6 | cursors: { 7 | enabled: true, 8 | room: "domain", 9 | shouldRenderCursor: (presence) => { 10 | return presence.page === window.location.pathname; 11 | }, 12 | }, 13 | events: { 14 | confetti: { 15 | type: "confetti", 16 | onEvent: (data) => { 17 | window.confetti({ 18 | ...(data || {}), 19 | shapes: 20 | // NOTE: this serialization is needed because `slide` doesn't serialize to JSON properly. 21 | "shapes" in data 22 | ? data.shapes.map((shape) => (shape === "slide" ? slide : shape)) 23 | : undefined, 24 | }); 25 | }, 26 | }, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /website/experiments/7/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | playhtml experiment 7 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /website/experiments/6/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | screen symphony 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packages/extension/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @playhtml/extension 2 | 3 | ## 0.1.5 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [8580d25] 8 | - @playhtml/common@0.3.1 9 | - playhtml@2.5.1 10 | 11 | ## 0.1.4 12 | 13 | ### Patch Changes 14 | 15 | - Updated dependencies [325bfde] 16 | - Updated dependencies [60666b0] 17 | - playhtml@2.5.0 18 | - @playhtml/common@0.3.0 19 | - @playhtml/react@0.7.0 20 | 21 | ## 0.1.3 22 | 23 | ### Patch Changes 24 | 25 | - Updated dependencies [162cfe9] 26 | - Updated dependencies [09298ae] 27 | - @playhtml/common@0.2.1 28 | - playhtml@2.4.1 29 | - @playhtml/react@0.6.1 30 | 31 | ## 0.1.2 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies [335af8b] 36 | - Updated dependencies [aa19771] 37 | - Updated dependencies [335af8b] 38 | - @playhtml/react@0.6.0 39 | - playhtml@2.4.0 40 | 41 | ## 0.1.1 42 | 43 | ### Patch Changes 44 | 45 | - Updated dependencies [639c9b3] 46 | - playhtml@2.3.0 47 | - @playhtml/common@0.2.0 48 | - @playhtml/react@0.5.2 49 | -------------------------------------------------------------------------------- /vite.config.site.mts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { glob } from "glob"; 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | export default defineConfig({ 7 | root: path.join(__dirname, "website"), 8 | resolve: { 9 | alias: { 10 | "@playhtml/common": path.join(__dirname, "packages/common/src"), 11 | "@playhtml/react": path.join(__dirname, "packages/react/src"), 12 | playhtml: path.join(__dirname, "packages/playhtml/src/index.ts"), 13 | }, 14 | }, 15 | optimizeDeps: { 16 | exclude: ["@playhtml/common", "@playhtml/react", "playhtml"], 17 | }, 18 | build: { 19 | rollupOptions: { 20 | input: glob.sync(path.resolve(__dirname, "website", "**/*.html"), { 21 | ignore: ["**/test/**"], 22 | }), 23 | }, 24 | outDir: path.join(__dirname, "site-dist"), 25 | emptyOutDir: true, 26 | }, 27 | plugins: [react()], 28 | server: { 29 | allowedHosts: ["16ea7fb66b66.ngrok-free.app"], 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /website/experiments/8/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | grid paper typing 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/react/examples/SharedLamp.tsx: -------------------------------------------------------------------------------- 1 | import { CanToggleElement } from "@playhtml/react"; 2 | import React from "react"; 3 | 4 | export function SharedLamp({ 5 | src = "https://shop.noguchi.org/cdn/shop/products/1A_on_2048x.jpg?v=1567364979", 6 | shared, 7 | dataSource, 8 | id, 9 | }: { 10 | src?: string; 11 | shared?: boolean; 12 | dataSource?: string; 13 | id?: string; 14 | }) { 15 | return ( 16 | 17 | {({ data }) => { 18 | const on = typeof data === "object" ? data.on : data; 19 | return ( 20 | 32 | ); 33 | }} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "lib": ["ESNext", "es2019", "dom"], 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "node", 10 | "module": "esnext", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "baseUrl": ".", 22 | "typeRoots": ["./node_modules/@types"], 23 | "paths": { 24 | "playhtml": ["packages/playhtml/src"], 25 | "@playhtml/react": ["packages/react/src"], 26 | "@playhtml/common": ["packages/common/src"] 27 | } 28 | }, 29 | "include": ["partykit"], 30 | "exclude": [ 31 | "node_modules/*", 32 | "**/__tests__/*", 33 | "node_modules/@cloudflare/workers-types/index.ts", 34 | "**/dist" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | import react from "@vitejs/plugin-react"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | dts({ 11 | rollupTypes: true, 12 | exclude: [ 13 | "**/__tests__/**", 14 | "**/*.test.ts", 15 | "**/*.test.tsx", 16 | "**/*.spec.ts", 17 | "**/*.spec.tsx", 18 | ], 19 | }), 20 | ], 21 | build: { 22 | lib: { 23 | entry: path.resolve(__dirname, "src/index.tsx"), 24 | name: "react-playhtml", 25 | fileName: (format) => `react-playhtml.${format}.js`, 26 | }, 27 | rollupOptions: { 28 | external: ["react", "react-dom", "react/jsx-runtime"], 29 | output: { 30 | globals: { 31 | "react-dom": "ReactDom", 32 | react: "React", 33 | "react/jsx-runtime": "ReactJsxRuntime", 34 | }, 35 | }, 36 | }, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /website/experiments/4/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | playhtml experiment "04" 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /website/experiments/5/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | minute faces (together) 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /website/experiments/test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | minute faces (together) 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /website/utils/color.ts: -------------------------------------------------------------------------------- 1 | export function invertColor(hex: string, bw: boolean): string { 2 | if (hex.indexOf("#") === 0) { 3 | hex = hex.slice(1); 4 | } 5 | // convert 3-digit hex to 6-digits. 6 | if (hex.length === 3) { 7 | hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; 8 | } 9 | if (hex.length !== 6) { 10 | throw new Error("Invalid HEX color."); 11 | } 12 | let r = parseInt(hex.slice(0, 2), 16), 13 | g = parseInt(hex.slice(2, 4), 16), 14 | b = parseInt(hex.slice(4, 6), 16); 15 | if (bw) { 16 | // http://stackoverflow.com/a/3943023/112731 17 | return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? "#000000" : "#FFFFFF"; 18 | } 19 | // invert color components 20 | let rStr = (255 - r).toString(16); 21 | let gStr = (255 - g).toString(16); 22 | let bStr = (255 - b).toString(16); 23 | // pad each with zeros and return 24 | return "#" + padZero(rStr) + padZero(gStr) + padZero(bStr); 25 | } 26 | 27 | function padZero(str: string): string { 28 | var zeros = new Array(2).join("0"); 29 | return (zeros + str).slice(-2); 30 | } 31 | -------------------------------------------------------------------------------- /packages/react/examples/ReactiveOrb.scss: -------------------------------------------------------------------------------- 1 | // Floating orbs for reactive data card - retro computer style 2 | .floating-orb { 3 | position: absolute !important; 4 | width: 50px; 5 | height: 50px; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | color: var(--color-text, #333); 10 | font-family: "Courier New", monospace; 11 | font-weight: bold; 12 | font-size: 0.9em; 13 | cursor: pointer; 14 | transition: none; 15 | user-select: none; 16 | 17 | // Retro computer button style 18 | border: 2px solid var(--color-text, #333); 19 | background: var(--color-background-neutral, #f5f5f5); 20 | box-shadow: inset 1px 1px 0px rgba(255, 255, 255, 0.8), 21 | inset -1px -1px 0px rgba(0, 0, 0, 0.3), 2px 2px 0px rgba(0, 0, 0, 0.2); 22 | 23 | &:hover { 24 | background: rgba(255, 255, 255, 0.1); 25 | } 26 | 27 | &:active { 28 | // Pressed button effect 29 | transform: translate(1px, 1px); 30 | box-shadow: inset -1px -1px 0px rgba(255, 255, 255, 0.8), 31 | inset 1px 1px 0px rgba(0, 0, 0, 0.3), 1px 1px 0px rgba(0, 0, 0, 0.2); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playhtml/common", 3 | "description": "Common types for playhtml packages", 4 | "version": "0.3.1", 5 | "license": "MIT", 6 | "type": "module", 7 | "author": "Spencer Chang ", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/spencerc99/playhtml/packages/common.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/spencerc99/playhtml/issues" 14 | }, 15 | "main": "./dist/playhtml-common.es.js", 16 | "types": "./dist/main.d.ts", 17 | "module": "./dist/playhtml-common.es.js", 18 | "files": [ 19 | "dist" 20 | ], 21 | "exports": { 22 | ".": { 23 | "types": "./dist/main.d.ts", 24 | "import": "./dist/playhtml-common.es.js", 25 | "require": "./dist/playhtml-common.umd.js" 26 | } 27 | }, 28 | "publishConfig": { 29 | "access": "public" 30 | }, 31 | "scripts": { 32 | "build": "tsc && vite build" 33 | }, 34 | "devDependencies": { 35 | "typescript": "^5.0.2", 36 | "vite": "^7.1.2", 37 | "vite-plugin-dts": "^3.0.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/common/src/sharedElements.ts: -------------------------------------------------------------------------------- 1 | export type SharedReference = { 2 | domain: string; 3 | path: string; 4 | elementId: string; 5 | }; 6 | 7 | export function parseDataSource(value: string): SharedReference { 8 | // Format: domain[/path]#elementId 9 | const [domainAndPath, elementId] = value.split("#"); 10 | if (!domainAndPath || !elementId) { 11 | throw new Error("Invalid data-source attribute value"); 12 | } 13 | const firstSlash = domainAndPath.indexOf("/"); 14 | const domain = 15 | firstSlash === -1 ? domainAndPath : domainAndPath.slice(0, firstSlash); 16 | const path = firstSlash === -1 ? "/" : domainAndPath.slice(firstSlash); 17 | return { domain, path, elementId }; 18 | } 19 | 20 | export function normalizePath(path: string): string { 21 | if (!path) return "/"; 22 | const cleaned = path.replace(/\.[^/.]+$/, ""); 23 | return cleaned.startsWith("/") ? cleaned : `/${cleaned}`; 24 | } 25 | 26 | export function deriveRoomId(host: string, inputRoom: string): string { 27 | const normalized = normalizePath(inputRoom); 28 | return encodeURIComponent(`${host}-${normalized}`); 29 | } 30 | -------------------------------------------------------------------------------- /packages/react/examples/OnlineIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | 4 | export const OnlineIndicator = withSharedState( 5 | { defaultData: {}, myDefaultAwareness: "#008000", id: "online-indicator" }, 6 | ({ myAwareness, setMyAwareness, awareness }, props) => { 7 | const myAwarenessIdx = myAwareness ? awareness.indexOf(myAwareness) : -1; 8 | return ( 9 | <> 10 | {awareness.map((val, idx) => ( 11 |
24 | ))} 25 | setMyAwareness(e.target.value)} 28 | value={myAwareness} 29 | /> 30 | 31 | ); 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /packages/react/examples/SharedSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | 4 | type SliderData = { value: number }; 5 | 6 | interface SharedSliderProps { 7 | min?: number; 8 | max?: number; 9 | step?: number; 10 | label?: string; 11 | } 12 | 13 | export const SharedSlider = withSharedState( 14 | ({ min = 0, max = 100 }) => ({ 15 | defaultData: { value: Math.round((min + max) / 2) }, 16 | }), 17 | ({ data, setData }, { min = 0, max = 100, step = 1, label }) => { 18 | return ( 19 |
23 | {label && } 24 | setData({ value: Number(e.target.value) })} 31 | /> 32 | {data.value} 33 |
34 | ); 35 | } 36 | ); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Spencer Chang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@playhtml/extension", 3 | "version": "0.1.5", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "wxt", 8 | "dev:firefox": "wxt -b firefox", 9 | "build": "wxt build", 10 | "build:firefox": "wxt build -b firefox", 11 | "preview": "wxt preview", 12 | "zip": "wxt zip", 13 | "zip:firefox": "wxt zip -b firefox", 14 | "test": "vitest", 15 | "postinstall": "wxt prepare" 16 | }, 17 | "dependencies": { 18 | "@playhtml/common": "workspace:*", 19 | "@playhtml/react": "workspace:*", 20 | "playhtml": "workspace:*", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "webextension-polyfill": "^0.12.0" 24 | }, 25 | "devDependencies": { 26 | "@types/chrome": "^0.0.270", 27 | "@types/react": "^19.1.11", 28 | "@types/react-dom": "^19.1.7", 29 | "@types/webextension-polyfill": "^0.10.7", 30 | "@vitejs/plugin-react": "^4.2.1", 31 | "@wxt-dev/module-react": "^1.1.3", 32 | "typescript": "^5.0.2", 33 | "vite": "^7.1.2", 34 | "vitest": "^2.0.0", 35 | "wxt": "^0.20.8" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /website/experiments/3/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 18 | 19 | playhtml experiment "03" 20 | 21 | 22 |
23 |

24 | this experiment was allowing anyone to add words and remove words from 25 | my fridge poetry game 26 |

27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Bun 23 | uses: oven-sh/setup-bun@v2 24 | with: 25 | bun-version: latest 26 | 27 | - name: Install Dependencies 28 | run: bun install 29 | 30 | - name: Build Packages 31 | run: bun run build-packages 32 | 33 | - name: Create Release Pull Request or Publish to npm 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | publish: bun run release 38 | title: "Release: Version packages" 39 | commit: "chore(release): version packages" 40 | setupGitUser: true 41 | createGithubReleases: true 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /website/experiments/one/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | playhtml experiment "01" 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/react/examples/SharedSound.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | 4 | export const SharedSound = withSharedState( 5 | { defaultData: { isPlaying: false, timestamp: 0 } }, 6 | ({ data, setData }, { soundUrl }) => { 7 | const audioRef = useRef(null); 8 | 9 | useEffect(() => { 10 | if (data.isPlaying) { 11 | audioRef.current?.play(); 12 | } else { 13 | audioRef.current?.pause(); 14 | } 15 | }, [data.isPlaying]); 16 | 17 | return ( 18 |
19 |
38 | ); 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /packages/react/src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach, vi } from "vitest"; 2 | import { cleanup } from "@testing-library/react"; 3 | import * as matchers from "@testing-library/jest-dom/matchers"; 4 | 5 | // Extend Vitest's expect method with methods from React Testing Library 6 | expect.extend(matchers); 7 | 8 | // Runs a cleanup after each test case (e.g. clearing jsdom) 9 | afterEach(() => { 10 | cleanup(); 11 | }); 12 | 13 | // Create a mock playhtml instance 14 | const mockedPlayhtml = { 15 | isInitialized: false, 16 | init: vi.fn().mockImplementation(() => { 17 | mockedPlayhtml.isInitialized = true; 18 | return Promise.resolve(); 19 | }), 20 | setupPlayElements: vi.fn(), 21 | setupPlayElement: vi.fn(), 22 | removePlayElement: vi.fn(), 23 | deleteElementData: vi.fn(), 24 | elementHandlers: {}, 25 | globalData: new Map(), 26 | dispatchPlayEvent: vi.fn(), 27 | registerPlayEventListener: vi.fn().mockReturnValue("mock-id"), 28 | removePlayEventListener: vi.fn(), 29 | }; 30 | 31 | // Make mock available to tests 32 | vi.stubGlobal("MOCKED_PLAYHTML", mockedPlayhtml); 33 | 34 | // Mock playhtml initialization and event functions 35 | vi.mock("playhtml", () => { 36 | return { playhtml: mockedPlayhtml }; 37 | }); 38 | -------------------------------------------------------------------------------- /website/components/ComponentStore.scss: -------------------------------------------------------------------------------- 1 | .component-store { 2 | width: 100%; 3 | overflow: hidden; 4 | } 5 | 6 | .items-grid { 7 | display: flex; 8 | flex-wrap: wrap; 9 | flex-direction: row; 10 | gap: 0.2em; 11 | flex: 1; 12 | overflow: hidden; 13 | justify-content: center; 14 | // truncate afer two rows 15 | max-height: 80px; 16 | } 17 | 18 | .store-item { 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | padding: 6px 4px; 23 | cursor: pointer; 24 | transition: all 0.3s ease; 25 | position: relative; 26 | } 27 | 28 | .item-visual { 29 | height: 24px; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | margin-bottom: 4px; 34 | } 35 | 36 | .item-image { 37 | height: 24px; 38 | width: auto; 39 | filter: brightness(0.7) saturate(0.8); 40 | transition: all 0.3s ease; 41 | 42 | &.on { 43 | filter: brightness(1.2) saturate(1.6) 44 | drop-shadow(0px 0px 4px rgba(255, 220, 100, 0.5)); 45 | } 46 | } 47 | 48 | .item-emoji { 49 | font-size: 18px; 50 | opacity: 0.6; 51 | transition: all 0.3s ease; 52 | filter: grayscale(1); 53 | 54 | &.active { 55 | opacity: 1; 56 | filter: grayscale(0); 57 | transform: scale(1.1); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /website/events/gathering/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | playhtml get-together 16 | 17 | 18 | 20 | 21 |
29 |
30 |

playhtml get-together

31 |
32 |
33 |
34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /packages/extension/src/components/SiteStatus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import browser from "webextension-polyfill"; 3 | import { PlayHTMLStatus } from "../types"; 4 | 5 | interface SiteStatusProps { 6 | currentTab: browser.Tabs.Tab | null; 7 | playhtmlStatus: PlayHTMLStatus; 8 | } 9 | 10 | export function SiteStatus({ currentTab, playhtmlStatus }: SiteStatusProps) { 11 | return ( 12 |
13 |

16 | Current Site 17 |

18 |
26 |
27 | URL:{" "} 28 | {currentTab?.url ? new URL(currentTab.url).hostname : "Unknown"} 29 |
30 |
31 | PlayHTML detected:{" "} 32 | {playhtmlStatus.checking 33 | ? "Checking..." 34 | : playhtmlStatus.detected 35 | ? `Yes (${playhtmlStatus.elementCount} elements)` 36 | : "No"} 37 |
38 |
39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /packages/extension/src/entrypoints/content/style.css: -------------------------------------------------------------------------------- 1 | /* PlayHTML Extension Content Styles */ 2 | 3 | .playhtml-extension-overlay { 4 | position: fixed; 5 | top: 0; 6 | left: 0; 7 | width: 100%; 8 | height: 100%; 9 | pointer-events: none; 10 | z-index: 10000; 11 | } 12 | 13 | .playhtml-extension-cursor { 14 | position: absolute; 15 | width: 12px; 16 | height: 12px; 17 | border-radius: 50%; 18 | background: rgba(99, 102, 241, 0.6); 19 | pointer-events: none; 20 | transition: transform 0.1s ease; 21 | transform: translate(-50%, -50%); 22 | } 23 | 24 | .playhtml-extension-element-picker { 25 | outline: 2px dashed #6366f1 !important; 26 | background: rgba(99, 102, 241, 0.1) !important; 27 | cursor: pointer !important; 28 | } 29 | 30 | .playhtml-extension-enhanced-element { 31 | position: relative; 32 | } 33 | 34 | .playhtml-extension-enhanced-element::after { 35 | content: '✨'; 36 | position: absolute; 37 | top: -10px; 38 | right: -10px; 39 | font-size: 12px; 40 | opacity: 0.7; 41 | pointer-events: none; 42 | } 43 | 44 | /* Gentle shimmer for discoverable elements */ 45 | @keyframes playhtml-shimmer { 46 | 0% { opacity: 1; } 47 | 50% { opacity: 0.7; } 48 | 100% { opacity: 1; } 49 | } 50 | 51 | .playhtml-extension-discoverable { 52 | animation: playhtml-shimmer 3s ease-in-out infinite; 53 | } -------------------------------------------------------------------------------- /packages/react/examples/Confetti.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PlayContext } from "@playhtml/react"; 3 | import { useContext, useEffect } from "react"; 4 | 5 | const ConfettiEventType = "confetti"; 6 | 7 | export function useConfetti() { 8 | const { 9 | registerPlayEventListener, 10 | removePlayEventListener, 11 | dispatchPlayEvent, 12 | } = useContext(PlayContext); 13 | 14 | useEffect(() => { 15 | const id = registerPlayEventListener(ConfettiEventType, { 16 | onEvent: () => { 17 | // requires importing 18 | // somewhere in your app 19 | window.confetti({ 20 | particleCount: 100, 21 | spread: 70, 22 | origin: { y: 0.6 }, 23 | }); 24 | }, 25 | }); 26 | 27 | return () => removePlayEventListener(ConfettiEventType, id); 28 | }, []); 29 | 30 | return () => { 31 | dispatchPlayEvent({ type: ConfettiEventType }); 32 | }; 33 | } 34 | 35 | export function ConfettiZone() { 36 | const triggerConfetti = useConfetti(); 37 | 38 | return ( 39 |
triggerConfetti()} 43 | > 44 |

CONFETTI ZONE

45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /partykit/sharing.ts: -------------------------------------------------------------------------------- 1 | import { deriveRoomId } from "@playhtml/common"; 2 | 3 | export type SharedElementPermissions = "read-only" | "read-write"; 4 | 5 | // --- Helper: compute source room id from domain and pathOrRoom 6 | export function getSourceRoomId(domain: string, pathOrRoom: string): string { 7 | return deriveRoomId(domain, pathOrRoom); 8 | } 9 | 10 | // --- Helper: parse shared references array from connection/request URL 11 | export function parseSharedReferencesFromUrl(url: string): Array<{ 12 | domain: string; 13 | path: string; 14 | elementId: string; 15 | }> { 16 | try { 17 | const u = new URL(url); 18 | const raw = u.searchParams.get("sharedReferences"); 19 | if (!raw) return []; 20 | const parsed = JSON.parse(raw); 21 | if (Array.isArray(parsed)) return parsed; 22 | return []; 23 | } catch { 24 | return []; 25 | } 26 | } 27 | 28 | // --- Helper: parse shared elements (declared on source) from URL params 29 | export function parseSharedElementsFromUrl(url: string): Array<{ 30 | elementId: string; 31 | permissions?: SharedElementPermissions; 32 | }> { 33 | try { 34 | const u = new URL(url); 35 | const raw = u.searchParams.get("sharedElements"); 36 | if (!raw) return []; 37 | const parsed = JSON.parse(raw); 38 | if (Array.isArray(parsed)) return parsed; 39 | return []; 40 | } catch { 41 | return []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/react/examples/resizable.tsx: -------------------------------------------------------------------------------- 1 | // install react-resizable and add to package.json 2 | import React, { PropsWithChildren } from "react"; 3 | import { Resizable } from "react-resizable"; 4 | import { withSharedState } from "@playhtml/react"; 5 | import "react-resizable/css/styles.css"; 6 | 7 | interface Props { 8 | initialWidth: number; 9 | initialHeight: number; 10 | onResize?: (newWidth: number, newHeight: number) => void; 11 | } 12 | 13 | export const CanResizeElement = withSharedState( 14 | ({ initialWidth, initialHeight }) => ({ 15 | defaultData: { 16 | width: initialWidth, 17 | height: initialHeight, 18 | }, 19 | }), 20 | ({ data, setData }, props: PropsWithChildren) => { 21 | const { onResize, children } = props; 22 | const { width, height } = data; 23 | return ( 24 | { 29 | setData((state) => { 30 | state.width = d.size.width; 31 | state.height = d.size.height; 32 | }); 33 | onResize?.(d.size.width, d.size.height); 34 | }} 35 | > 36 |
42 | {children} 43 |
44 |
45 | ); 46 | } 47 | ); 48 | -------------------------------------------------------------------------------- /website/experiments/two/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 17 | 21 | 22 | cursor festival — playhtml experiment "02" 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /website/experiments/index.tsx: -------------------------------------------------------------------------------- 1 | import "../home.scss"; 2 | import React, { useEffect, useMemo, useState } from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | 5 | const ExperimentNumber = 5; 6 | 7 | const Experiments: Record = { 8 | 1: { 9 | slug: "one", 10 | }, 11 | 2: { slug: "two" }, 12 | }; 13 | 14 | function padZero(str) { 15 | var zeros = new Array(2).join("0"); 16 | return (zeros + str).slice(-2); 17 | } 18 | 19 | ReactDOM.createRoot(document.getElementById("app") as HTMLElement).render( 20 |
27 |

playhtml experiments

28 |

29 | a series of experiments playing with how playhtml can change the texture 30 | of the web. All code available on{" "} 31 | 32 | github 33 | 34 | . 35 |

36 |
    37 | {Array.from({ length: ExperimentNumber }, (v, i) => i).map((index) => { 38 | const info = Experiments[index + 1]; 39 | const { slug, title } = info || { slug: index + 1, title: undefined }; 40 | const href = `/experiments/${slug}/`; 41 | return ( 42 |
  1. 43 | {title || `experiment "${padZero(index + 1)}"`} 44 |
  2. 45 | ); 46 | })} 47 |
48 |
49 | ); 50 | -------------------------------------------------------------------------------- /website/useLocation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function getCurrentLocation() { 4 | return { 5 | pathname: window.location.pathname, 6 | search: window.location.search, 7 | }; 8 | } 9 | 10 | const listeners: Array<() => void> = []; 11 | 12 | /** 13 | * Notifies all location listeners. Can be used if the history state has been manipulated 14 | * in by another module. Effectifely, all components using the 'useLocation' hook will 15 | * update. 16 | */ 17 | export function notify() { 18 | listeners.forEach((listener) => listener()); 19 | } 20 | 21 | export function useLocation() { 22 | const [{ pathname, search }, setLocation] = useState(getCurrentLocation()); 23 | 24 | useEffect(() => { 25 | window.addEventListener("popstate", handleChange); 26 | return () => window.removeEventListener("popstate", handleChange); 27 | }, []); 28 | 29 | useEffect(() => { 30 | listeners.push(handleChange); 31 | return () => { 32 | listeners.splice(listeners.indexOf(handleChange), 1); 33 | }; 34 | }, []); 35 | 36 | function handleChange() { 37 | setLocation(getCurrentLocation()); 38 | } 39 | 40 | function push(url: string) { 41 | window.history.pushState(null, "", url); 42 | notify(); 43 | } 44 | 45 | function replace(url: string) { 46 | window.history.replaceState(null, "", url); 47 | notify(); 48 | } 49 | 50 | return { 51 | push, 52 | replace, 53 | pathname, 54 | search, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /partykit/const.ts: -------------------------------------------------------------------------------- 1 | // Storage key constants for consistency 2 | export const STORAGE_KEYS = { 3 | // Stores consumer room ids and the elementIds they are interested in 4 | subscribers: "subscribers", 5 | // Stores references out to other source rooms that this source room is interested in 6 | sharedReferences: "sharedReferences", 7 | sharedPermissions: "sharedPermissions", 8 | // Stores the reset epoch timestamp to detect when a room was reset 9 | resetEpoch: "resetEpoch", 10 | }; 11 | // Subscriber lease configuration (default 12 hours) 12 | export const DEFAULT_SUBSCRIBER_LEASE_MS = (() => { 13 | return 60 * 60 * 1000 * 12; 14 | })(); 15 | // Prune interval configuration (default 6 hours). See PartyKit alarms guide: 16 | // https://docs.partykit.io/guides/scheduling-tasks-with-alarms/ 17 | export const DEFAULT_PRUNE_INTERVAL_MS = (() => { 18 | return 60 * 60 * 1000 * 4; 19 | })(); 20 | export const ORIGIN_S2C = "__bridge_s2c__"; 21 | export const ORIGIN_C2S = "__bridge_c2s__"; 22 | 23 | export type Subscriber = { 24 | consumerRoomId: string; 25 | elementIds?: string[]; 26 | createdAt?: string; 27 | lastSeen?: string; 28 | leaseMs?: number; 29 | }; 30 | 31 | export type SharedRefEntry = { 32 | sourceRoomId: string; 33 | elementIds: string[]; 34 | lastSeen?: string; 35 | }; 36 | 37 | export function ensureExists(value: T | null | undefined): T { 38 | if (value === null || value === undefined) { 39 | throw new Error("ensureExists: value is null or undefined"); 40 | } 41 | return value; 42 | } 43 | -------------------------------------------------------------------------------- /packages/react/src/hooks/useLocation.ts: -------------------------------------------------------------------------------- 1 | // from https://gist.github.com/lenkan/357b006dd31a8c78f659430467369ea7 2 | import { useState, useEffect } from "react"; 3 | 4 | function getCurrentLocation() { 5 | return { 6 | pathname: window.location.pathname, 7 | search: window.location.search, 8 | }; 9 | } 10 | 11 | const listeners: Array<() => void> = []; 12 | 13 | /** 14 | * Notifies all location listeners. Can be used if the history state has been manipulated 15 | * in by another module. Effectifely, all components using the 'useLocation' hook will 16 | * update. 17 | */ 18 | export function notify() { 19 | listeners.forEach((listener) => listener()); 20 | } 21 | 22 | export function useLocation() { 23 | const [{ pathname, search }, setLocation] = useState(getCurrentLocation()); 24 | 25 | useEffect(() => { 26 | window.addEventListener("popstate", handleChange); 27 | return () => window.removeEventListener("popstate", handleChange); 28 | }, []); 29 | 30 | useEffect(() => { 31 | listeners.push(handleChange); 32 | return () => { 33 | listeners.splice(listeners.indexOf(handleChange), 1); 34 | }; 35 | }, []); 36 | 37 | function handleChange() { 38 | setLocation(getCurrentLocation()); 39 | } 40 | 41 | function push(url: string) { 42 | window.history.pushState(null, "", url); 43 | notify(); 44 | } 45 | 46 | function replace(url: string) { 47 | window.history.replaceState(null, "", url); 48 | notify(); 49 | } 50 | 51 | return { 52 | push, 53 | replace, 54 | pathname, 55 | search, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /website/experiments/one/one.scss: -------------------------------------------------------------------------------- 1 | @import "../../base.scss"; 2 | 3 | #main { 4 | color: var(--background-inverted); 5 | background: var(--background); 6 | padding-top: 6em; 7 | } 8 | 9 | table { 10 | border: 1px solid; 11 | 12 | th { 13 | border: 1px solid; 14 | } 15 | 16 | tr { 17 | border: 1px solid; 18 | 19 | td { 20 | border: 1px solid; 21 | padding: 0.5em; 22 | } 23 | } 24 | } 25 | 26 | details { 27 | width: 300px; 28 | } 29 | 30 | .colorController { 31 | position: absolute; 32 | max-width: 800px; 33 | width: 100%; 34 | top: 4em; 35 | padding-top: 6em; 36 | input { 37 | transform: scale(5); 38 | margin-bottom: 3.5em; 39 | border-color: var(--background-inverted); 40 | } 41 | button { 42 | width: 240px; 43 | height: 40px; 44 | transition: box-shadow 0.2s, background 0.2s, color 0.2s, opacity 0.3s; 45 | background: var(--background); 46 | border: 2px solid; 47 | border-color: var(--background-inverted); 48 | color: var(--background-inverted); 49 | border-radius: 8px; 50 | padding: 0.25em 0.5em; 51 | cursor: pointer; 52 | 53 | &:disabled { 54 | opacity: 0.6; 55 | cursor: not-allowed; 56 | } 57 | 58 | &:hover { 59 | background: var(--color); 60 | color: var(--color-inverted); 61 | // box-shadow: 0 0 12px 8px var(--color); 62 | } 63 | } 64 | } 65 | 66 | #awareness { 67 | display: flex; 68 | gap: 0.5em; 69 | } 70 | 71 | footer { 72 | color: var(--background-inverted); 73 | a { 74 | color: var(--background-inverted); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /website/experiments/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 18 | 19 | playhtml 20 | 26 | 27 | 28 | 29 | 30 |
31 |
32 |
33 | an open-source library created and maintained by 34 | spencer 35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /website/events/walking-together/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | walking together on the internet 16 | 17 | 18 | 20 | 21 |
29 |
30 |

walking together on the internet

31 |

32 | with kristoffer tjalve & 33 | spencer chang, with 34 | rhizome 35 |

36 |

37 | Apr 30 on the internet 38 |

39 |
40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /packages/extension/src/components/PlayerIdentityCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PlayerIdentity } from "../types"; 3 | 4 | interface PlayerIdentityCardProps { 5 | playerIdentity: PlayerIdentity; 6 | } 7 | 8 | export function PlayerIdentityCard({ playerIdentity }: PlayerIdentityCardProps) { 9 | return ( 10 |
11 |

18 | Your Identity 19 |

20 |
28 |
29 | ID: {playerIdentity.publicKey.slice(0, 12)}... 30 |
31 |
32 | Sites discovered:{" "} 33 | {playerIdentity.discoveredSites.length} 34 |
35 |
38 | Colors: 39 | {playerIdentity.playerStyle.colorPalette.map((color, i) => ( 40 |
49 | ))} 50 |
51 |
52 |
53 | ); 54 | } -------------------------------------------------------------------------------- /website/base.scss: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Use a more-intuitive box-sizing model. 3 | */ 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | /* 11 | 2. Remove default margin 12 | */ 13 | * { 14 | margin: 0; 15 | } 16 | 17 | /* 18 | 3. Allow percentage-based heights in the application 19 | this should include any root container too like `#__next` in next apps 20 | */ 21 | html, 22 | body { 23 | height: 100%; 24 | } 25 | 26 | /* 27 | Typographic tweaks! 28 | 4. Add accessible line-height 29 | 5. Improve text rendering 30 | */ 31 | body { 32 | line-height: 1.5; 33 | -webkit-font-smoothing: antialiased; 34 | } 35 | 36 | /* 37 | 6. Improve media defaults 38 | */ 39 | img, 40 | picture, 41 | video, 42 | canvas, 43 | svg { 44 | display: block; 45 | max-width: 100%; 46 | } 47 | 48 | /* 49 | 7. Remove built-in form typography styles 50 | */ 51 | input, 52 | button, 53 | textarea, 54 | select { 55 | font: inherit; 56 | } 57 | 58 | /* 59 | 8. Avoid text overflows 60 | */ 61 | p, 62 | h1, 63 | h2, 64 | h3, 65 | h4, 66 | h5, 67 | h6 { 68 | overflow-wrap: break-word; 69 | } 70 | 71 | /* 72 | 9. Create a root stacking context 73 | */ 74 | #root, 75 | #__next { 76 | isolation: isolate; 77 | } 78 | 79 | footer { 80 | @media screen and ((min-width: 768px) or (min-height: 768px)) { 81 | position: fixed; 82 | bottom: 32px; 83 | } 84 | 85 | margin-top: 20px; 86 | display: flex; 87 | justify-content: center; 88 | width: 100%; 89 | 90 | div { 91 | padding: 8px 0; 92 | background: rgba(var(--background), 0.7); 93 | text-align: center; 94 | backdrop-filter: blur(2px); 95 | border-radius: 8px; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /packages/common/src/objectUtils.ts: -------------------------------------------------------------------------------- 1 | export function isPlainObject(value: any): value is Record { 2 | return ( 3 | value !== null && 4 | typeof value === "object" && 5 | Object.getPrototypeOf(value) === Object.prototype 6 | ); 7 | } 8 | 9 | export function deepReplaceIntoProxy(target: any, src: any) { 10 | if (src === null || src === undefined) return; 11 | if (Array.isArray(src)) { 12 | target.splice(0, target.length, ...src); 13 | return; 14 | } 15 | if (isPlainObject(src)) { 16 | for (const key of Object.keys(target)) { 17 | if (!(key in src)) delete target[key]; 18 | } 19 | for (const [k, v] of Object.entries(src)) { 20 | if (Array.isArray(v)) { 21 | if (!Array.isArray(target[k])) target[k] = []; 22 | deepReplaceIntoProxy(target[k], v); 23 | } else if (isPlainObject(v)) { 24 | if (!isPlainObject(target[k])) target[k] = {}; 25 | deepReplaceIntoProxy(target[k], v); 26 | } else { 27 | (target as any)[k] = v as any; 28 | } 29 | } 30 | return; 31 | } 32 | // primitives 33 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 34 | target = src as any; 35 | } 36 | 37 | export function clonePlain(value: T): T { 38 | // Prefer structuredClone when available; fallback to JSON clone for plain data 39 | try { 40 | // @ts-ignore 41 | if (typeof structuredClone === "function") { 42 | // @ts-ignore 43 | return structuredClone(value); 44 | } 45 | } catch {} 46 | if (value === null || value === undefined) return value; 47 | if (typeof value === "object") { 48 | return JSON.parse(JSON.stringify(value)); 49 | } 50 | return value; 51 | } 52 | -------------------------------------------------------------------------------- /packages/react/examples/Reaction.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | 4 | interface Reaction { 5 | emoji: string; 6 | count: number; 7 | } 8 | 9 | interface Props { 10 | reaction: Reaction; 11 | } 12 | 13 | export const ReactionView = withSharedState( 14 | ({ reaction: { count } }: Props) => ({ 15 | defaultData: { count }, 16 | }), 17 | ({ data, setData, ref }, props: Props) => { 18 | const { 19 | reaction: { emoji }, 20 | } = props; 21 | const [hasReacted, setHasReacted] = useState(false); 22 | 23 | useEffect(() => { 24 | if (ref.current) { 25 | // This should be managed by playhtml.. it should be stored in some sort of 26 | // locally-persisted storage. 27 | setHasReacted(Boolean(localStorage.getItem(ref.current.id))); 28 | } 29 | }, [ref.current?.id]); 30 | 31 | return ( 32 | 54 | ); 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /packages/common/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @playhtml/common 2 | 3 | ## 0.3.1 4 | 5 | ### Patch Changes 6 | 7 | - 8580d25: Fix move bounds. 8 | 9 | ## 0.3.0 10 | 11 | ### Minor Changes 12 | 13 | - 60666b0: Handle shared elements. Declare a shared element via `shared` attribute, and reference it on other pages / domains via `data-source` attribute. Simple permissioning is supported for read-only and read-write. 14 | 15 | ### Patch Changes 16 | 17 | - 325bfde: Make cursor handling reactive in react package, migrating from `getCursors` -> `cursors` in `PlayContext`. 18 | 19 | ## 0.2.1 20 | 21 | ### Patch Changes 22 | 23 | - 162cfe9: Add localStorage persistence for cursor names and colors 24 | 25 | Previously, user cursor names and colors were randomly generated on each page visit, creating a confusing experience where users would have different identities across sessions. This update introduces localStorage persistence so users maintain consistent cursor identity. 26 | 27 | **Key Changes:** 28 | 29 | - Added `generatePersistentPlayerIdentity()` function that saves/loads identity from localStorage 30 | - Enhanced `setColor()` and `setName()` methods to persist changes automatically 31 | - Added `getCursors()` function to PlayContext for better React integration 32 | - Updated presence indicator in experiment 7 to show real-time user presence by color 33 | 34 | **Breaking Changes:** 35 | None - this is backward compatible and enhances the existing experience. 36 | 37 | **Migration:** 38 | No migration needed. Existing users will get a new persistent identity on their next visit, and from then on it will be preserved across sessions. 39 | 40 | ## 0.2.0 41 | 42 | ### Minor Changes 43 | 44 | - 639c9b3: Real-time cursor tracking system with proximity detection, chat, and global API 45 | -------------------------------------------------------------------------------- /partykit/request.ts: -------------------------------------------------------------------------------- 1 | export interface SubscribeRequest { 2 | action: "subscribe"; 3 | consumerRoomId: string; 4 | elementIds?: string[]; 5 | } 6 | 7 | export interface ExportPermissionsRequest { 8 | action: "export-permissions"; 9 | elementIds: string[]; 10 | } 11 | 12 | export interface ApplySubtreesImmediateRequest { 13 | action: "apply-subtrees-immediate"; 14 | subtrees: Record>; 15 | sender: string; 16 | originKind: "consumer" | "source"; 17 | resetEpoch?: number | null; 18 | } 19 | 20 | export type PartyKitRequest = 21 | | SubscribeRequest 22 | | ExportPermissionsRequest 23 | | ApplySubtreesImmediateRequest; 24 | 25 | export interface SubscribeResponse { 26 | ok: true; 27 | subscribed: true; 28 | elementIds: string[]; 29 | } 30 | 31 | export interface ExportPermissionsResponse { 32 | permissions: Record; 33 | } 34 | 35 | export interface ApplySubtreesResponse { 36 | ok: true; 37 | } 38 | 39 | export interface GenericErrorResponse { 40 | error: string; 41 | } 42 | 43 | export function isSubscribeRequest(body: any): body is SubscribeRequest { 44 | return body?.action === "subscribe" && typeof body?.consumerRoomId === "string"; 45 | } 46 | 47 | export function isExportPermissionsRequest(body: any): body is ExportPermissionsRequest { 48 | return body?.action === "export-permissions" && Array.isArray(body?.elementIds); 49 | } 50 | 51 | export function isApplySubtreesImmediateRequest(body: any): body is ApplySubtreesImmediateRequest { 52 | return ( 53 | body?.action === "apply-subtrees-immediate" && 54 | typeof body?.subtrees === "object" && 55 | typeof body?.sender === "string" && 56 | (body?.originKind === "consumer" || body?.originKind === "source") 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/react/examples/FridgeWord.scss: -------------------------------------------------------------------------------- 1 | .fridgeWord { 2 | --word-color: rgba(50, 50, 50, 1); 3 | line-height: 1; 4 | margin: 4px; 5 | background-color: #efefef; 6 | color: #000; 7 | padding: 0.4em; 8 | box-shadow: 3px 3px 0px 0px var(--word-color); 9 | cursor: pointer; 10 | position: relative; 11 | 12 | &.custom { 13 | position: absolute; 14 | &::before { 15 | content: " "; 16 | position: absolute; 17 | width: 100%; 18 | height: 100%; 19 | top: 0; 20 | left: 0; 21 | animation: dynamicGlow 2s; 22 | } 23 | } 24 | } 25 | 26 | .fridgeWordHolder { 27 | display: inline-block; 28 | 29 | // iPhone jiggle animation 30 | // inspo from https://www.kirupa.com/html5/creating_the_ios_icon_jiggle_wobble_effect_in_css.htm 31 | &:nth-child(2n) .fridgeWord:hover { 32 | animation-name: jiggle1; 33 | animation-iteration-count: infinite; 34 | transform-origin: 50% 10%; 35 | animation-duration: 0.25s; 36 | animation-delay: var(--jiggle-delay); 37 | } 38 | 39 | &:nth-child(2n-1) .fridgeWord:hover { 40 | animation-name: jiggle2; 41 | animation-iteration-count: infinite; 42 | animation-direction: alternate; 43 | transform-origin: 30% 5%; 44 | animation-duration: 0.45s; 45 | animation-delay: var(--jiggle-delay); 46 | } 47 | } 48 | 49 | @keyframes jiggle1 { 50 | 0% { 51 | transform: rotate(-1deg); 52 | animation-timing-function: ease-in; 53 | } 54 | 55 | 50% { 56 | transform: rotate(1.5deg); 57 | animation-timing-function: ease-out; 58 | } 59 | } 60 | 61 | @keyframes jiggle2 { 62 | 0% { 63 | transform: rotate(1deg); 64 | animation-timing-function: ease-in; 65 | } 66 | 67 | 50% { 68 | transform: rotate(-1.5deg); 69 | animation-timing-function: ease-out; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/react/examples/utils.ts: -------------------------------------------------------------------------------- 1 | // Shared utilities for playhtml React examples 2 | 3 | /** 4 | * Format large numbers with k/m/b notation while showing last 3 digits for progression feel 5 | * @param num The number to format 6 | * @returns Either a string for small numbers or an object with main digits and suffix 7 | */ 8 | export function formatLargeNumber(num: number) { 9 | if (num < 1000) return num.toString(); 10 | 11 | const lastThreeDigits = num % 1000; 12 | const paddedLastThree = lastThreeDigits.toString().padStart(3, "0"); 13 | 14 | if (num < 1000000) { 15 | const k = Math.floor(num / 1000); 16 | return { main: paddedLastThree, suffix: `${k}k` }; 17 | } else if (num < 1000000000) { 18 | const m = Math.floor(num / 1000000); 19 | return { main: paddedLastThree, suffix: `${m}m` }; 20 | } else { 21 | const b = Math.floor(num / 1000000000); 22 | return { main: paddedLastThree, suffix: `${b}b` }; 23 | } 24 | } 25 | 26 | /** 27 | * Simple number formatting with k/m/b notation (no last digits shown) 28 | * @param num The number to format 29 | * @returns Formatted string like "1.2k", "5m", etc. 30 | */ 31 | export function formatSimpleNumber(num: number): string { 32 | if (num < 1000) return num.toString(); 33 | if (num < 1000000) return `${(num / 1000).toFixed(num % 1000 === 0 ? 0 : 1)}k`; 34 | if (num < 1000000000) return `${(num / 1000000).toFixed(num % 1000000 === 0 ? 0 : 1)}m`; 35 | return `${(num / 1000000000).toFixed(num % 1000000000 === 0 ? 0 : 1)}b`; 36 | } 37 | 38 | /** 39 | * Pluralize a word based on count 40 | * @param word The word to pluralize 41 | * @param count The count to check 42 | * @returns The word with 's' added if count > 1 43 | */ 44 | export function pluralize(word: string, count: number) { 45 | return count > 1 ? `${word}s` : word; 46 | } -------------------------------------------------------------------------------- /packages/playhtml/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml", 3 | "title": "playhtml", 4 | "description": "Create interactive, collaborative html elements with a single attribute", 5 | "version": "2.5.1", 6 | "license": "MIT", 7 | "type": "module", 8 | "keywords": [ 9 | "html", 10 | "collaboration", 11 | "fun", 12 | "real-time", 13 | "persistence", 14 | "html energy" 15 | ], 16 | "author": { 17 | "name": "Spencer Chang", 18 | "email": "spencerc99@gmail.com" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "github:spencerc99/playhtml", 23 | "directory": "packages/playhtml" 24 | }, 25 | "funding": { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/spencerc99" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/spencerc99/playhtml/issues" 31 | }, 32 | "main": "./dist/playhtml.es.js", 33 | "types": "./dist/main.d.ts", 34 | "module": "./dist/playhtml.es.js", 35 | "homepage": "https://playhtml.fun", 36 | "files": [ 37 | "dist" 38 | ], 39 | "exports": { 40 | ".": { 41 | "types": "./dist/main.d.ts", 42 | "import": "./dist/playhtml.es.js" 43 | }, 44 | "./dist/style.css": { 45 | "import": "./dist/style.css", 46 | "require": "./dist/style.css" 47 | } 48 | }, 49 | "scripts": { 50 | "build": "tsc && vite build", 51 | "test": "vitest run --no-typecheck", 52 | "test:watch": "vitest --no-typecheck" 53 | }, 54 | "devDependencies": { 55 | "sass": "^1.62.1", 56 | "typescript": "^5.0.2", 57 | "vite": "^7.1.2", 58 | "vite-plugin-dts": "^3.0.3", 59 | "vitest": "^3.1.1", 60 | "jsdom": "^26.1.0", 61 | "happy-dom": "^15.11.6" 62 | }, 63 | "dependencies": { 64 | "@playhtml/common": "0.3.1", 65 | "@syncedstore/core": "^0.6.0", 66 | "y-partykit": "^0.0.31", 67 | "yjs": "13.6.18" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /website/test/react-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 18 | 19 | playhtml 20 | 21 | 22 | 23 |
24 |
25 |
👥 Total users online: 0
26 |
Page: /test/react-test
27 |
28 |
29 |
30 | 31 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playhtml-root", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "packages/playhtml", 7 | "packages/react", 8 | "packages/common", 9 | "packages/extension" 10 | ], 11 | "scripts": { 12 | "dev": "vite --config vite.config.site.mts", 13 | "dev-server": "bunx partykit dev partykit/party.ts", 14 | "dev-extension": "cd packages/extension && bun run dev", 15 | "deploy-server": "bunx partykit deploy", 16 | "deploy-server:staging": "bunx partykit deploy --preview staging", 17 | "build-site": "vite build website --config vite.config.site.mts", 18 | "build-packages": "for dir in packages/*; do if [ \"$(basename \"$dir\")\" != \"extension\" ]; then (cd \"$dir\" && bun run build); fi; done", 19 | "build-extension": "cd packages/extension && bun run build", 20 | "changeset": "changeset", 21 | "version-packages": "changeset version", 22 | "release": "bun run build-packages && changeset publish", 23 | "lint": "bunx tsc && cd website && bunx tsc && cd .. && for dir in packages/*; do if [ \"$(basename \"$dir\")\" != \"extension\" ]; then (cd \"$dir\" && bunx tsc); fi; done" 24 | }, 25 | "devDependencies": { 26 | "@cloudflare/workers-types": "^4.20230518.0", 27 | "@types/canvas-confetti": "^1.6.4", 28 | "@types/node": "^20.3.3", 29 | "@types/randomcolor": "^0.5.9", 30 | "@types/react": "^18.2.48", 31 | "@types/react-is": "^18.2.0", 32 | "@vitejs/plugin-react": "^4.2.1", 33 | "@changesets/cli": "^2.27.9", 34 | "glob": "^10.3.10", 35 | "sass": "^1.62.1", 36 | "typescript": "^5.0.2", 37 | "vite": "^7.1.2", 38 | "vite-plugin-mpa": "^1.2.0" 39 | }, 40 | "dependencies": { 41 | "@supabase/supabase-js": "^2.57.4", 42 | "canvas-confetti": "^1.9.2", 43 | "partykit": "0.0.108", 44 | "profane-words": "^1.5.11", 45 | "randomcolor": "^0.6.2", 46 | "react": "^18.2.0", 47 | "react-dom": "^18.2.0", 48 | "y-partykit": "0.0.31", 49 | "yjs": "13.6.18" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/extension/src/components/QuickActions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { GameInventory } from "../types"; 3 | 4 | interface QuickActionsProps { 5 | onTestConnection: () => void; 6 | onPickElement: () => void; 7 | onViewInventory: () => void; 8 | inventory: GameInventory; 9 | } 10 | 11 | export function QuickActions({ 12 | onTestConnection, 13 | onPickElement, 14 | onViewInventory, 15 | inventory 16 | }: QuickActionsProps) { 17 | return ( 18 |
19 |

22 | Quick Actions 23 |

24 |
25 | 39 | 53 | 67 |
68 |
69 | ); 70 | } -------------------------------------------------------------------------------- /packages/react/examples/ReactiveOrb.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withSharedState } from "@playhtml/react"; 3 | import { formatLargeNumber } from "./utils"; 4 | import "./ReactiveOrb.scss"; 5 | 6 | interface OrbProps { 7 | className: string; 8 | colorOffset?: number; 9 | id: string; 10 | } 11 | 12 | export const ReactiveOrb = withSharedState( 13 | { defaultData: { clicks: 0 } }, 14 | ({ data, setData }, props: OrbProps) => { 15 | const { className, colorOffset = 0, id } = props; 16 | 17 | // Scale based on magnitude but cap it 18 | const magnitude = Math.floor(Math.log10(Math.max(data.clicks, 1))); 19 | const scaleMultiplier = Math.min(magnitude * 0.05, 0.4); 20 | 21 | // Color calculation based on clicks and offset 22 | const hue = (data.clicks * 20 + colorOffset) % 360; 23 | const saturation = 70; 24 | const lightness = 50 + (data.clicks % 20); 25 | 26 | const formatted = formatLargeNumber(data.clicks); 27 | const isLargeNumber = typeof formatted === "object"; 28 | 29 | return ( 30 |
setData({ clicks: data.clicks + 1 })} 34 | style={{ 35 | transform: `scale(${1 + scaleMultiplier})`, 36 | background: `hsl(${hue}, ${saturation}%, ${lightness}%)`, 37 | color: lightness > 60 ? "#000" : "#fff", 38 | }} 39 | title={`Total clicks: ${data.clicks.toLocaleString()}`} 40 | > 41 | {isLargeNumber ? ( 42 |
50 |
{formatted.main}
51 |
52 | {formatted.suffix} 53 |
54 |
55 | ) : ( 56 | formatted 57 | )} 58 |
59 | ); 60 | } 61 | ); 62 | -------------------------------------------------------------------------------- /packages/react/example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CanPlayElement } from "./src/index"; 3 | 4 | export function SharedYoutube(video: string) { 5 | // TODO: extract url 6 | // 2. This code loads the IFrame Player API code asynchronously. 7 | var tag = document.createElement("script"); 8 | 9 | tag.src = "https://www.youtube.com/iframe_api"; 10 | var firstScriptTag = document.getElementsByTagName("script")[0]; 11 | firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); 12 | 13 | // 3. This function creates an