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 && {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 |
29 | {data.isPlaying ?
🔈 :
🔇 }
30 |
32 | setData({ isPlaying: !data.isPlaying, timestamp: Date.now() })
33 | }
34 | >
35 | {data.isPlaying ? "Pause" : "Play"}
36 |
37 |
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 |
43 | {title || `experiment "${padZero(index + 1)}"`}
44 |
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 |
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 |
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 | {
34 | const { count } = data;
35 | if (hasReacted) {
36 | setData({ count: count - 1 });
37 | if (ref.current) {
38 | localStorage.removeItem(ref.current.id);
39 | }
40 | setHasReacted(false);
41 | } else {
42 | setData({ count: count + 1 });
43 | if (ref.current) {
44 | localStorage.setItem(ref.current.id, "true");
45 | }
46 | setHasReacted(true);
47 | }
48 | }}
49 | className={`reaction ${hasReacted ? "reacted" : ""}`}
50 | selector-id=".reactions reaction"
51 | >
52 | {emoji} {data.count}
53 |
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 |
37 | Test Connection
38 |
39 |
51 | Pick Element
52 |
53 |
65 | View Inventory ({inventory.totalItems})
66 |
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